在資料的收集過程中找到了兩個比較好的算法演示動畫網站Sorting Algorithms Animations (包含了排序算法的各種演示、對比,以及偽代碼)和 Algorithm Visualizer (大量算法的js代碼和算法過程動畫)
排序算法
基本上每個學算法、數據結構的人,都會學一下各種排序算法吧,我也是剛剛上路,也走一下這個過程。
總體而言,目前自己完成了如下6種排序算法的js和java實現,選擇排序和冒泡排序這兩種太過基礎的就在此略過了。后續再寫到新的排序算法的時候再加上。
- 插入排序及其變種
- 快速排序
- 歸并排序
- 堆排序
- 希爾排序
- 基數排序
下面來仔細講一講每一種排序算法。
插入排序
插入排序的思想其實很簡單,如果玩過撲克牌的同學肯定可以秒懂,就是每摸一張新的牌就把他插入到正確的位置上去,那具體從算法角度描述就是假如當前已有i個數有序排列著,再加入第i+1個數時,把他插入到正確的位置上保持i+1項有序,如此不斷加入數字。
代碼實現上就很簡單了:
function insertSort1(arr){
if(arr.length < 2){
return;
}
for(var i = 1;i < arr.length;i++){
//前i-1個數字是從小到大有序排列著的
var temp = arr[i];
for(var z = i-1;z >= 0;z--){
//我們在前i-1個數中只需要找到比arr[i]小的位置停下來,
//把數字插進去,把后面的數字往后挪就可以了
//使用數組的splice方法來插入和刪除可能代碼量更小,但這里主要是為了表達思想
if(arr[z] <= temp){
break;
}
//我這里的實現是從后往前找位置插入,還沒到插入的地方的時候需要把元素都往后挪
arr[z+1] = arr[z];
}
//找到插入的地方了,把該插入的元素插入進去
arr[z+1] = temp;
}
}
變種① : 兩路插入排序
在上面插入排序的過程中,因為我們是找到合適的位置把第i個元素插入前i+1個元素的序列當中去,所以存在把比第i個元素大的元素都向后挪的情況:
比如把最后的5插入進去的時候,需要把6到14都依次向后挪動,所以一種解決辦法既是構造一個循環數組,然后向其中插入元素,可以做到挪動的元素數量減半。
舉個栗子:
現在的循環數組是這樣的,假如你要插入2.5,原先的方案你需要將3到9全部向后挪一個位置,然后將2.5插入進去,現在你只需要把1和2往前挪就好了。
代碼實現:
function insertSort2(arr){
if(arr.length < 2){
return;
}
var head,tail = head = 0,tempArr = new Array(arr.length);
tempArr[0] = arr[0];
//構建一個新的數組,這個循環數組存放我們的排序結果,排序完成之后再把元素輸出回去
for(var i =1;i<arr.length;i++){
if(arr[i] >= tempArr[0]){
//首先和tempArr[0]比較,如果較大,那么就在0到tail中找位置插進去
var j = 0;
while(j <= tail){
if(arr[i] > tempArr[j]){
j++
}else{
//找到應該插入的位置了
break;
}
}
tempArr.splice(j,0,arr[i])
tempArr.splice(++tail+1,1)
}else{
//如果比tempArr[0]小,那么就在head到arr.length - 1中找位置插進去
var j = arr.length - 1;
if(head === 0){
head = j;
tempArr[head] = arr[i];
}else{
while(j >= head){
if(arr[i] < tempArr[j]){
j--
}else{
break;
}
}
tempArr.splice(j+1,0,arr[i])
tempArr.splice(--head-1,1);
}
}
}
for(i = 0;i < arr.length;i++){
//把循環數組中的元素取出來放回原數組
arr[i] = tempArr[(i + head) % arr.length];
}
}
變種② : 靜態鏈表插入
數組插入肯定是無法避免的需要挪動元素、騰出空間的,那用鏈表就不會出現這樣的問題了,這里演示的是用靜態鏈表來實現的(靜態鏈表相當于是用一個數組來實現線性表的鏈式存儲結構,不過數組的每個元素里不僅存了內容,還有一個next指針,指明鏈表的下一個元素在數組中的下標)。
算法實現:
function insertSort3(arr){
if(arr.length < 2){
return;
}
var staticLinkedList = arr.map(function(value){
//構造靜態鏈表
//每一個元素是一個對象,value是原先數組的具體的內容,
//后面的代碼里會向對象添加next屬性,表示鏈表的后繼節點是數組的哪個元素
return {value:value}
})
staticLinkedList.next = 0;
staticLinkedList[0].next = null;
for(var i = 1;i < arr.length;i++){
for(var pointer = staticLinkedList;(pointer.next != null) && (staticLinkedList[pointer.next].value < arr[i]);pointer = staticLinkedList[pointer.next]){
}
pointer.next === null ? (pointer.next = i,staticLinkedList[i].next = null):(staticLinkedList[i].next =pointer.next,pointer.next = i);
}
pointer = staticLinkedList.next;
i= 0;
while(pointer !== null){
arr[i] = staticLinkedList[pointer].value;
i++;
pointer = staticLinkedList[pointer].next;
}
}
快速排序
快速排序的基本思想是,通過一趟排序將待排元素分割成兩部分,使得其中一部分元素均小于另外一部分元素,那接下來就可以繼續遞歸的對這兩部分進行排序,最終使得整個序列是有序的。
我們想想該怎么實現,先想個小計劃:先掙他個一個億,先選擇一個數作為基準,然后遍歷整個序列,把大于基準的數移到序列的尾部去,小于等于的就不動。這樣一輪遍歷下來,就可以實現序列分成一部分大一部分小了。
話是這樣說,可是實現起來肯定不是這樣啦,上面說的“移到尾部去”這個小操作就意味著很多的數據挪動,所以我們在具體實現時要修改一下,我們可以設定兩個游標,一個叫做low指向數組的頭部,一個叫做high指向數組的尾部,然后選擇一個數作為基準,假如我們選第一個數為基準
- high往前遍歷,遇到比基準大的就略過(因為我們本來就希望數組的尾部放的是比基準大的那部分),直到遇到一個比基準小的,把他跟基準交換一下
- low往后遍歷,遇到比基準小的就略過(因為我們本來就希望數組的前部放的是比基準小的那部分),直到遇到一個比基準大的,把他跟基準交換一下
- 回到1 繼續執行直到low和high相交,這個時候遍歷完了,也就結束了一趟排序
上述過程中我們發現在和基準交換的過程中,我們經常要把基準交換,仔細分析一下其實可以在這一趟排序終止之后再把基準寫進去。
算法實現:
function quickSort(arr,start,end){
if(arr.length<2){
return;
}
start = start || 0;
end = end || arr.length - 1;
var left = start;
var right = end;
var pivot = arr[left];
while(left < right){
while(arr[right] >= pivot && left < right){
right--;
}
//把pivot和那個小于pivot的元素交換一下
arr[left] = arr[right];
while(arr[left] <= pivot && left < right){
left++;
}
//把pivot和那個大于pivot的元素交換一下
arr[right] = arr[left];
}
//最后再把pivot寫進去
arr[left] = pivot;
if(left - 1 > start){
quickSort(arr,start,left-1);
}
if(left +1 < end){
quickSort(arr,left+1,end);
}
}
有的算法是以中間的元素作為基準,其實沒有什么區別,你只需要把每趟排序前先把中間的數和第一個數交換一下就完了
歸并排序
歸并排序是分治法的一個很好應用:歸并排序的具體思想是把原來的序列分成兩個子序列,分別使兩個子序列有序之后再把兩個子序列合并到一起。使子序列有序的過程又是一次排序的過程,那么就需要遞歸的把子序列排好序,再去合并。
不用再過多解釋,上張圖就明白了:
很明顯,算法的具體過程就是在序列的元素個數大于2的時候把序列分成兩個子序列,然后對子序列遞歸的繼續執行拆分成兩個子序列的過程,當序列的長度小于2時,就不需要再拆分了,直接對兩個子序列合并成一個有序序列即可,這樣逐次向上合并,最終完成排序過程。
算法實現:
function mergeSort(array,start,end){
if(array.length<2){
return;
}
start = start || 0;
end = end || array.length - 1;
//選擇中間的位置作為分割點
var mid = Math.floor((start + end) / 2);
if(start < mid){
//對左邊的序列遞歸執行排序過程
mergeSort(array,start,mid);
}
if(mid + 1< end){
//對右邊的序列遞歸執行排序過程
mergeSort(array,mid+1,end);
}
//排好序了,接下來合并就好了
var i = start,j = mid + 1,z = 0;
var tempArr = [];
//設置兩個指針,合并兩個數組,誰小就把誰加入數組當中
while((i < mid + 1 ) && ( j < end + 1)){
if(array[i] > array[j]){
tempArr[z] = array[j];
j++;
z++;
}else{
tempArr[z] = array[i];
i++;
z++;
}
}
if(i < mid + 1){
while(i < mid + 1){
tempArr[z] = array[i];
i++;
z++;
}
}
if(j < end + 1){
while(j < end + 1){
tempArr[z] = array[j];
j++;
z++;
}
}
for(i = 0;i < end + 1 - start;i++){
array[i+start] = tempArr[i];
}
}
堆排序
堆排序是利用二叉堆來排序,堆分為大根堆和小根堆,是完全二叉樹。
以大根堆為例,大根堆的要求是每個節點的值都不大于其父節點的值,即A[PARENT[i]] >= A[i]。對于每一個節點滿足上述要求,所以這就使得這個二叉堆的根節點是這個堆里最大的一個數,所以我們只要把待排序的序列建成一個大/小根堆然后輸出根節點,此時堆已經沒有了根節點,需要調整堆節構使其繼續成為二叉堆,就可以又把最大/小的節點調整到根節點上面去,接下來就可以不斷重復上述過程直至把堆里的節點都輸出出來這就完成了排序過程。
堆排序已經足夠簡單,直接上代碼吧:
function heapSort(arr){
var temp;
if(arr.length <2){
return;
}
for(var i = Math.floor((arr.length - 2) / 2);i > -1;i--){
//從最后一個非葉子節點開始調整,使得以其為根節點的子樹成為一個小根堆
//這樣,當遍歷到根節點時,這棵樹也就被調整成為了小根堆
_heapAdjust(arr,i,arr.length-1)
}
for(var j = arr.length - 1;j > 0;j--){
//將最大的節點輸出(把根節點和arr[j]節點交換)
temp = arr[j];
arr[j] = arr[0];
arr[0] = temp;
//對0~j-1節點構成的子樹,進行調整,使其再次成為小根堆
_heapAdjust(arr,0,j-1);
}
}
//堆調整函數,在假設某一節點的左右子樹均為小根堆的情況下,調整該節點為根節點的子樹,使其成為小根堆
function _heapAdjust(arr, start, end){
var top = arr[start];
//將top調整到合適的位置
for(var i = start * 2 + 1; i < end + 1;i = start * 2 + 1){
if(i != end && arr[i] > arr[i+1]){
i++
}
if(top < arr[i]){
//如果此時滿足兩個子節點都小于top,那么也就調整好了
break;
}
//否則將那個較小的節點上移
arr[start] = arr[i];
//指針指到那個較小的節點上去,接下來不斷的遍歷子樹,不斷將較小的節點向上移動
start = i;
}
//此時,start就是top節點該存放的位置
arr[start] = top;
}
希爾排序
希爾排序的實質就是分組插入排序,該方法又稱縮小增量排序,因DL.Shell于1959年提出而得名。
該方法的基本思想是:先將整個待排元素序列分割成若干個子序列(由相隔某個“增量”的元素組成的)分別進行直接插入排序,然后依次縮減增量再進行排序,待整個序列中的元素基本有序(增量足夠小)時,再對全體元素進行一次直接插入排序。
依然是上一個圖,就可以講得很明白了:
對于希爾排序的增量應該怎么選取,其實是有大學問的,一般可以預先設置好增量序列,也可以動態設置增量,增量的設置會影響到算法的效率。對于預先設置好的增量序列,素數的增量會起到較好的效果。動態的設置增量則要保證增量序列的計算過程不會太復雜,不至于在增量上浪費大量計算性能。
下面給出一種動態增量的希爾排序實現:
function shellSort(arr){
if(arr.length < 2){
return;
}
var growth = 1;
while(growth < arr.length / 3){
//先計算出growth可取的最大值,
//在后面的代碼中,growth不斷減小,從而實現希爾排序的縮小增量排序
growth = growth * 3 + 1;
}
while(growth > 0){
_sortWithGrowth(arr,growth)
growth = (growth - 1)/3;
}
}
function _sortWithGrowth(arr,growth){
for(var i = 0;i < growth;i++){
for(var j = i + growth;j < arr.length; j += growth){
var temp = arr[j];
var z = j - growth;
while(z >= i){
if(arr[z] <= temp){
break;
}
arr[z+growth] = arr[z];
z -= growth;
}
arr[z + growth] = temp;
}
}
}
基數排序
基數排序與本系列前面講解的5種排序方法都不同,它不需要比較關鍵字的大小。
它是根據關鍵字中各位的值,通過對排序的N個元素進行若干趟“分配”與“收集”來實現排序的。
基數排序又叫"桶子法"(bucket sort),我們可以把“分配”的過程看成是扔到桶里的過程,比如有一個序列,我們先對這個序列中的每一個數按照其個位“分配”到0-9這10個不同的桶里去,然后又依次從0號桶開始,把他們依次“收集”起來繼續排成一個序列,這個時候這個序列已經是按照個位的大小排好了順序,接下來再按照十位,把他們依次的放入到0-9號桶里去,然后再繼續上述收集過程,這個時候序列已經先按十位、再按個位排好了順序,接下來重復上述過程,就可以把序列從小到大的排好順序了。
代碼實現:
function radixSort(arr){
if(arr.length < 2){
return;
}
var radix = 1;
//找出最大值
var maxValue = Math.max.apply(null,arr);
var loopTimes = 0;
//看看最大值是基數的幾次方就知道要循環幾次了
while(maxValue){
loopTimes++;
maxValue = Math.floor(maxValue / 10);
}
for(var i = 0;i < loopTimes; i++){
//這個數組的元素就是\"桶\",而每個桶又是一個數組
var arrayOfArr = [];
for(var j = 0;j<arr.length;j++){
var position = Math.floor((arr[j] % (10 * radix))/radix);
//把數字扔到桶里的過程就是向數組中push即可
arrayOfArr[position] || (arrayOfArr[position] = []);
arrayOfArr[position].push(arr[j]);
}
//清空原數組
arr = [];
for(j = 0;j<10;j++){
//從各個桶里按順序把元素收集起來
Array.prototype.push.apply(arr,arrayOfArr[j]);
}
radix *= 10;
}
return arr;
}
回過頭看看
寫了6個排序算法,寫各種排序的這個過程給我一種很模糊、粗淺的感覺,就是在排序的過程中將元素移動得更遠似乎就越省時省力、排序越快。
這樣一種粗淺的直觀感受在知乎上找到了這樣的佐證:
可以用逆序數來理解,假設我們要從小到大排序,一個數組中取兩個元素如果前面比后面大,則為一個逆序,容易看出排序的本質就是消除逆序數,可以證明對于隨機數組,逆序數是O(N2)的,而如果采用“交換相鄰元素”的辦法來消除逆序,每次正好只消除一個,因此必須執行O(N2)的交換次數,這就是為啥冒泡、插入等算法只能到平方級別的原因,反過來,基于交換元素的排序要想突破這個下界,必須執行一些比較,交換相隔比較遠的元素,使得一次交換能消除一個以上的逆序,希爾、快排、堆排等等算法都是交換比較遠的元素,只不過規則各不同罷了。鏈接在此