1. 程式人生 > >快排演算法為什麼會這樣寫?

快排演算法為什麼會這樣寫?

快排演算法是什麼?

快速排序,顧名思義,就是一種快速對數字進行大小排序的演算法,據我所知,它應該是最快的演算法了,它的時間複雜度為o(n2)。但同樣地,它的演算法要比簡單的氣泡排序要複雜的多。如果你去網上搜,你可以搜到它的各種語言實現,比如這個 C 語言版本:

int partition(int a[], int low, int high){ 
     
    int k = a[low]; 
    
    while(low < high){ 
        while(low < high && a[high] > k) --high;
        
        a[low] = a[high];
        
        while(low < high && a[low] < k) ++low;
        
        a[high] = a[low];
    }
    a[low] = k;
    return low;
}
void quicksort(int arr[], int low, int high)
{
    if(low < high)
    {
        int position = partition(arr, low, high);
        quicksort(arr, low, position - 1);
        quicksort(arr, position + 1, high);
    }
}

程式碼不多,但是很難看懂。儘管網上有不少高人對這段程式碼作了各種各樣的註釋和說明,但完全不能說清楚:為什麼快排演算法要這樣寫?

快排演算法真的有那麼難理解嗎?其實不是的,通過對演算法的描述來看,演算法本很好理解,但讀起程式碼來就讓人一臉懵逼,完全不知道這段程式碼在寫什麼。這是因為這段程式碼是以犧牲可讀性為代價的高度凝練和簡化後的版本,讀不懂它也就是自然的了。

演算法描述

雖然我們說快排演算法從理論上並不難理解,但有我們還是要儘量更深入地解析它,這對於接下來的編碼至關重要。快排演算法有許多變異的版本,我們以最基本的快速排序為例。百度百科對快排的定義是:

通過一趟排序將要排序的資料分割成獨立的兩部分,其中一部分的所有資料都比另外一部分的所有資料都要小,然後再按此方法對這兩部分資料分別進行快速排序,整個排序過程可以遞迴進行,以此達到整個資料變成有序序列。

這裡有幾個概念,需要分別闡述一下。

  1. 一趟快排

    快排是通過遞迴進行的,因此它需要反覆多次呼叫函式自身才能完成排序。而每一次呼叫,我們稱之為一趟快排

  2. 分割槽

    在一趟快排中,存在分割槽的概念。所謂的分割槽,即完整陣列的一個子集,我們進行一趟快排,暫時只會處理這個分割槽中的數字。當然在第一次呼叫快排函式時(即進行第一次一趟快排時),這個分割槽是整個陣列。我們會從分割槽中任意取一個數 k,找出這個數應該擺放的正確位置。這裡的任意數,可以是分割槽的第一個數(一般情況),也可以是分割槽的最後一個數,或者任意位置的數。在一趟快排中,如何找出這個數的正確位置呢?

  3. 兩條軍規

    這裡需要強調一下正確位置的概念。怎樣才能認為一個數在陣列中的位置是正確的呢?其實這裡兩條判斷標準:

    • 規則 1

      如果在陣列中,這個數左邊還有數其它數字,那麼這些數字都應該比它小

    • 規則 2

      如果在陣列中,這個數右邊還有其它數字,那麼這些數字應該都比它大

    嚴格來說,就算一個數達到了這個標準,這個陣列也不能說是排好序了。比如:3,5,2,8,9,其中 8 這個數字,已經符合位置正確的標準,這個陣列仍然不是有序的。只有當陣列中每一個數都符合這個標準,都達到了位置正確的程度,這個陣列才是有序的。雖然這樣,單個數的位置正確對於快排來說仍然有重要的意義,因為它達到位置正確後,它的位置就固定下來了,其它數無論怎麼排序,都不會影響它的位置,因為它已經站在了正確位置上,它可以提前結束排序。當然它左右兩邊如果還有數字(即它不是分割槽的最大數或最小數),那麼我們還要將它左右兩邊的數字分成兩個更小的分割槽(即將左邊小於它的數分一個區,右邊大於它的數分另一個去),分別進行一趟快排(即遞迴呼叫快排函式)。這樣每次遞迴找出一個位置正確的數,直至所有數都排好序。
    關鍵是如何對分割槽中的單個數進行排序(也叫分割槽演算法或一趟快排演算法)。如果用氣泡排序法,這很簡單,我們可以把這個數和分割槽裡其它數挨個進行比較。但是快排法之所以叫快排法,顯然不能這麼簡單。因為這樣需要進行更多的比較,效率比較低。它實際上是這樣做的:

    1. 先取分割槽第一個數,記作 k。
    2. 從分割槽尾開始向左掃描,檢視是否有比 k 小的數。
      1. 如果有,那麼將這個數丟到 k 左邊,因為根據標準,一個位置正確的數,凡是小於它的數都應該放在左邊。要把右邊的數放到左邊,簡單地將該數和 k 進行交換。
      2. 交換完後,記下右指標所在的位置。然後去掃描左邊的數(從分割槽開始到達右指標所在的分割槽),查詢比這個數更大的數。
      3. 如果沒有(也就是右指標一直移動到了左指標的位置),說明這個數已經是正確位置,因為它左邊沒有數字了,直接 return。
    3. 進行左掃描,即從分割槽首向右掃描,一直到抵達右指標的位置。檢視左邊的數是否有比 k 大的。
      1. 如果有,那麼將這個數丟到 k 右邊。要把左邊的數放到 k 右邊,簡單地將這個數和 k 進行交換。
      2. 交換完後,記下左指標的位置。如果左右指標不等,繼續下次迴圈,對二者間的數字進行再次掃描。
      3. 如果沒有(也就是左指標一直移動到右指標的位置),說明這個數已經是正確位置,因為它左邊的數字比它小,右邊的數字比它大,直接 return。

還原快排演算法

看完上面的演算法描述,我們可以根據自己的理解來編寫自己的快排演算法了。可能這樣實現的程式碼和前面所列的程式碼不太一樣,但無疑是未經刪減的原始版本,真實還原了快排演算法的原型,理解起來相對容易一些。

工具函式

在這之前,我們先從簡單的地方入手。實現兩個工具函式,比如前面提到的交換演算法:

void swap(int a[],int i,int j){
    int tmp = a[i];
    a[i] = a[j];
}

比如迴圈列印一個數組中的數字:

void printArray(int a[],int len){
    NSMutableString* str = [NSMutableString new];
    for(int i = 0 ;i<len;i++){
        [str appendString:@(a[i]).stringValue];
        [str appendString:@","];
    }
    NSLog(@"%@",str);
}

都是很簡單的程式碼,不用解釋了。

遞迴

快排演算法基於遞迴,因此在快排函式中,主要是這麼一個遞迴函式:

void quicksort(int a[],int left,int right){
    if(left < right){
        int correctPos = partition(a, left, right);// 排好一個數        
        quicksort(a, left, correctPos - 1); // 遞迴左邊
        quicksort(a, correctPos + 1, right);// 遞迴右邊
    }
}

並沒有太多程式碼,關鍵的程式碼還是在分割槽演算法(或一趟快排演算法)中。

分割槽演算法

按照前面的演算法描述,分割槽演算法(一趟快排演算法)的初步實現如下:

int partition(int a[],int left,int right){ // 1
    int k = a[left]; // 2
    while(left < right){ // 3
        while(left < right){ // 4
            if(a[right] >= k){ // 5
                right --;
            }else{ // 6
                break;
            }
        }
        
        if(right == left){ // 7
            break;
        }else{ // 8
				  swap(a, right, left);
        }
        while(left < right){ // 9
            if(a[left] <= k){ // 10
                left ++;
            }else{ // 11
                break;
            }
        }
        if(right == left){ // 12
            break;
        }else{ // 13
            swap(a, left, right);
        }
    }
    return left; // 14
}
  1. int partition(int a[],int left,int right){ // 1

    partition 函式有 3 個引數:要排序的陣列 a,本次分割槽的頭位置 left 和尾位置 right(也就是左指標和右指標)。

  2. int k = a[left]; // 2

    首先隨便取一個數 k(這裡取的是分割槽第一個數),我們將在方法中計算出它的正確位置然後 return。

  3. while(left < right){ // 3

    這是一個大迴圈,將左右掃描的程式碼放到這個大迴圈裡。我們需要在這個迴圈中不停地掃描並移動左右指標(left 和 right),直到兩個指標相等(left == right)時,才終止迴圈。當然,如果引數中傳來的 left 本來就等於 right,說明本次分割槽中只有一個數,則直接跳到方法最後一句返回 return right。

  4. while(left < right){ // 4

    在大迴圈中,首先進行的是右掃描,即從分割槽尾向左掃描。掃描的方式是迴圈,迴圈條件同樣是 left < right。

  5. if(a[right] >= k){ // 5

    右掃描的方式是依次取出右指標所在的數和 k 進行比較,如果這個數大於 k,則不管(因為它符合規則 2,位於 k 右側,且大於 k),右指標減一,right – ; 繼續比較下一個數,直到左右指標碰頭,即 right == left。

  6. }else{ // 6

    如果掃描到的數字違反了規則 2,則指標在這個位置停下,退出右掃迴圈(即 break;)。

  7. if(right == left){ // 7

    如果是沒找到,那麼肯定滿足 right == left 條件,那麼 break 退出大迴圈。退出大迴圈後就只有一句 return 語句了。這是因為右掃找不到,左掃肯定也掃不到(k 是第一個數啊,它左邊沒數了),那麼同時滿足規則 1 和規則 2,於是證明 k 就是正確位置了,肯定返回。

  8. else{ // 8

    如果找到,那麼讓它和 k 調換一下位置。這樣調換後它就位於 k 右邊了,符合規則 2。也就是說,到此為止,right 右邊的數全都符合規則 2 的了。

  9. while(left < right){ // 9

    右掃完成,開始左掃。左掃完成的條件也是 left == right。

  10. if(a[left] <= k){ // 10

    如果左掃中的資料 <= k,滿足規則 1,則不管,繼續移動指標 left ++。

  11. }else{ // 11

    如果掃描到的數字 > k,違反規則 1,指標在此停下,退出左掃迴圈 break;

  12. if(right == left){ // 12

    如果沒有找到任何違反規則的數,即 right == left,則 break,再次退出大迴圈,去執行 return。這是因為,左掃找不到違反規則 1 的,右掃也找不到違反規則 2 的(就算曾經找到,也被我們通過 swap 處理過了,已經符合規則 2 了),那肯定這個數就是位置正確的了,應該返回。

  13. }else{ // 13

    如果找到違反規則的數,那麼同理,讓它和 k 調換一下位置,這樣它就位於 k 的左邊了,符合規則 1。也就是說,到此為止,left 左邊的數都是符合規則 1 的了。

    注意,這裡使用的交換是 swap(a, left, right),這個 right 就是 k 目前所在的位置。為什麼呢?因為如果右掃描沒有找到違反規則 2 的數,則程式碼直接退出大迴圈外了。而現在程式碼已經執行到這裡,說明右掃描是一定找到違反規則 2 的數的,這樣它就一定和 k 進行過交換,即 right 指標所指的數應該是 k,而那個違反規則的數被交換到了 k 的左側(左掃描未開始時的 left 處,即陣列的第一個位置)。

    如果最後左右指標之間還有數字沒有被掃描到,即 left != right,那麼大迴圈肯定還要繼續,繼續對剩下的數字重複4-13 的步驟。否則退出迴圈,進到第 14 步。

  14. 當大迴圈執行到這裡,左右指標肯定已經碰頭,k 也正好位於左右指標共同指向的位置,同時,k 也滿足規則 1 和規則 2。因此可以返回了。由於 left == right,因此 return left 和 return right 其實是一樣的。

注意,在方法中,基本上沒執行一個步驟都要對 left < right 進行判斷,防止左右指標穿越,一旦發現 left == right,立即就要 return。因為左右指標遵循的規則是恰恰相反的,一旦穿越對方來到對方的區域,原來符合規則的數恰恰變成了違反規則的數,導致死迴圈。

簡化程式碼

分析程式碼發現,其實有的判斷是不必要的,交換也是不必要的。因此我們可以將程式碼精簡為:

int partition(int a[],int left,int right){
    int k = a[left];
    while(left < right){
        while(left < right && a[right] >= k){ // 1
                right --;
        }
        if(left < right){ // 2
            a[left] = a[right];// 3
        }
        
        while(left < right && a[left] <= k){ // 4
                left ++;
        }
        if(left < right){ // 5
            a[right]=a[left]; // 6
        }
    }
    a[left] = k; // 7
    return left; // 8
}
  1. 在迴圈條件中加入規則 2 (即右邊數必須 >= k),這樣當出現違反規則 2 的情況時自動就退出迴圈了。這樣就不需要在迴圈中對是否違反規則 2 的情況進行判斷,節省了一條 if 語句。
  2. 因為基本上後面的每個語句都添加了 left < right 判斷,所以這裡沒有必要對 left == right 進行判斷了。當 left = right 時,大迴圈體內的每個語句都不會執行,相當於一個 break 語句。因此 if(left==right){break;}就可以省去。
  3. 省去兩個數的交換,因為分割槽首的第一個數 a[left] 實際上在 k 中保留了拷貝,這裡用 a[right] 覆蓋 a[left] 即可,相當於把右指標找到的數放到了左邊,維持小數居左原則。同時,右指標所在的數是多餘的了,可以用來儲存其它數。此時,找到的數同時在分割槽首和右指標的位置儲存了兩份拷貝。
  4. 同 1.
  5. 同 2.
  6. 省去兩數交換,因為在分割槽首和右指標處有兩處重複拷貝,所以可以利用其中一個來儲存找到的數,因為右指標處的數是多餘的拷貝,所以可以用右指標來儲存左指標找到的數。相當於把左指標的數放到了右邊。維持大數居右原則。這時,左指標處的數顯得有些多餘了,可以用來放其它缺少的 k 值。
  7. 當大迴圈退出,即 left == right 時,表明已經計算出 k 的正確位置,可以將 k 放在正確的位置即可。因為 left == right,那麼 a[left] = k 或者 a[right] = k 是一樣的。
  8. 返回 k 的正確位置,因為此時 left == right,那麼 return left 或者 return right 是一樣的。

現在的程式碼已經變得和經典 C 快排程式碼差不多了,只要你願意,你還可以繼續簡化成:

int partition(int a[],int left,int right){
    int k = a[left];
    while(left < right){
        while(left < right && a[right] >= k){
            right --;
        }
        a[left] = a[right]; // 1
        
        while(left < right && a[left] <= k){
            left ++;
        }
        a[right]=a[left]; // 2
    }
    a[right] = k;
    return left;
}

這就跟經典 C 程式碼一模一樣了(除了變數名有所不同)。修改的地方主要是:

  1. 直接省去了 if(left < right) 判斷。直接執行 a[left] = a[right]; 實際上,這個判斷沒有必要。為 while 迴圈退出條件的限制,退出迴圈只有兩種可能,一種是沒找到,左右指標碰頭(相等),left == right,這種情況下讓 a[left] = a[right] 實際上等於 a = a,用自己對自己賦值 ,就算執行了也沒什麼關係;另一種情況是找到了,left < right,這種情況本來就應該執行 a[left] = a[right];
  2. 同 1.

表面上看,經典 C 程式碼的程式碼行數要少許多,但是你真的很難理解它在幹什麼。經過從未刪減版到精簡版的一番推導過程,你是否覺得會更好記一些了呢?