演算法 | 快速排序詳解

就良同學發表於2023-05-07

1 快速排序基本思想

從待排序記錄序列中選取一個記錄(隨機選取)作為基點,其關鍵字設為key,然後將其餘關鍵字小於key的記錄移到前面,而將關鍵字大於key的記錄移到後面,結果將待排序記錄序列分為兩個子表,最後將關鍵字key的記錄插入到分界線的位置。這個過程稱為一趟快速排序

經過這一趟劃分之後,就可以關鍵字key為界將整個待排序序列劃分為兩個子表,前面的子表均不大於key,後面的子表均不小於key。繼續對分割後的子表進行上述劃分,直至所有子表的表長不超過1為止,此時待排序的記錄就成為了一個有序序列(執行多趟快速排序)。

圖片

Q:如何實現一趟快速排序呢?

A1:一般實現。

先選取基點key=4,然後建立2個臨時陣列tmp1和tmp2分別用於儲存“<4的元素”和“≥4的元素”,然後遍歷原陣列,將相應元素存於臨時陣列,最後,按照tmp1→key→tmp2的順序將各元素填充回原陣列。

但是這種做法效率並不高,因為需要開闢新的記憶體空間,最後還需要將元素填回,非常耗時。

A2:不開闢新記憶體,一邊遍歷一邊整理。

step1:選取首元素作為基點key,將陣列劃分成多個片段。

  • 橙色區域:基點;
  • 黃色&綠色區域:已處理序列;
    · 黃色區域:元素均<key;
    · 綠色區域:元素均≥key,且綠色區域緊挨著黃色區域;
  • 灰色區域:待處理序列。
    即:
  • nums[left]:基點key;
  • nums[left+1...k):黃色區域,<key;
  • nums[k...i):綠色區域,≥key;
  • nums[i...n):灰色區域,待處理序列;
    初始時:i=left+1,k=left+1,保證初始時刻黃色區域和綠色區域均為空。

圖片

step2:遍歷灰色區域元素。

  • 若當前元素nums[i]≥key,表示屬於綠色區域,將當前元素追加至綠色區域尾部(i++即可);
    圖片

  • 若當前元素nums[i]<key,表示屬於黃色區域,將當前元素與綠色區域首元素進行交換,然後k++(表示黃色區域邊界右擴且綠色區域邊界左縮)。

交換之後,黃色區域和綠色區域仍滿足各自區域內元素要麼全<key,要麼全≥key。
圖片

step3:灰色區域遍歷結束,將基點與黃色區域尾元素(即nums[k-1])進行交換。

交換之後,黃色區域和綠色區域仍滿足各自區域內元素要麼全<key,要麼全≥key。
此時,完成一趟快速排序,基點對應下標為k-1,即key_index = k-1。
圖片

step4:對黃色區域和綠色區域分別進行快速排序,key_index = k-1。

  • 黃色區域:nums[left ... key_index-1];
  • 綠色區域:nums[key_index+1 ... right]。

2 程式碼實現

class Solution {
public:
    int partition(vector<int>& nums, int left, int right){
        int privot = nums[left];
        int i = left+1;
        int k = i; // [left+1...k):<privot; [k...i):≥privot
        for(; i<=right; i++){
            if(nums[i] < privot){
                swap(nums[i], nums[k++]);
            }
        }
        swap(nums[left], nums[k-1]);
        return k-1;
    } 
    void quickSort(vector<int>& nums, int left, int right){
        if(left >= right){
            return;
        }
        int privotIndex = partition(nums, left, right);
        quickSort(nums, left, privotIndex-1);
        quickSort(nums, privotIndex+1, right);
    }
    vector<int> sortArray(vector<int>& nums) {
        quickSort(nums, 0, nums.size()-1);
        return nums;
    }
};

有效性測試

LeeCode.912. 排序陣列

圖片

針對近乎有序的陣列,將超出時間限制。

3 隨機選取基點

Q:若選取首個元素作為基點,那麼劃分操作(即partition)在順序陣列或逆序陣列上的效果將很差(如上面的超出時間限制)。

A:

  • 拆分的子問題只比原來減少了1個元素;
  • 每一次劃分只能確定一個元素的位置;
  • 導致遞迴樹高度增加(非常不平衡、遞迴樹傾斜);
  • 快速排序退化成選擇排序,時間複雜度為O(N^2)。

解決:透過隨機選取基點,打破原陣列的有序性。

程式碼實現

#include<time.h>
#include<random>  
int partition(vector<int>& nums, int left, int right){
      srand((unsigned)time(NULL)); // 設定隨機種子
      int randomIndex = rand() % (right - left + 1) + left;
      swap(nums[randomIndex], nums[left]);
      int privot = nums[left];
      int i = left+1;
      int k = i; // [left+1...k):<privot [k...i):≥privot
      for(; i<=right; i++){
          if(nums[i] < privot){
              swap(nums[i], nums[k++]);
          }
      }
      swap(nums[left], nums[k-1]);
      return k-1;
  } 

有效性測試:LeeCode.912. 排序陣列
圖片

針對原陣列近乎有序的測試用例已經透過,但是針對含有大量重複元素的陣列,依舊超時。

4 雙路快排和三路快排

Q:針對陣列含有大量重複元素(甚至所有元素均相同)的場景,隨機選取基點無效!

A:由於存在大量重複元素,多次隨機選取基點可能選中的元素值沒有發生變化,也就未打破原陣列的順有序性,所以依舊超時。

解決方法如下:

圖片

4.1 雙路快排

雙路快排的目的:

將與基點相等的元素均勻地分配到陣列兩側(即黃色區域和綠色區域,分別代表≤pivot和≥pivot的元素集合)。

Q:為什麼黃色區域和綠色區域都可以=pivot,即二者區域內元素取值有重合呢?
A:因為雙路快排是要將與基點值相等的元素均勻地分佈到黃色區域和綠色區域,也就是使得黃色和綠色各自區域內均存在多個值=pivot的元素
• 若二者取值不重合,如黃色區域代表≤pivot,綠色區域代表>pivot,那麼經一趟快排之後,值=pivot的元素將全部在黃色區域內。
• 若二者取值重合,可保證值=pivot的元素不會扎堆在某一單色區域。

具體步驟:

step1:選取首元素作為基點key(隨機選取基點),將陣列劃分成多個片段。

  • 橙色區域:基點;
  • 黃色&綠色區域:已處理序列,分別位於陣列的最左側(忽略基點)和最右側;
    · 黃色區域:元素均≤key;
    · 綠色區域:元素均≥key;
  • 灰色區域:待處理序列。
    即:
  • nums[left]:基點key;
  • nums[left+1...i):黃色區域,≤key;
  • nums(j...right]:綠色區域,≥key;
  • nums[i...j]:灰色區域,待處理序列;
    初始時,i = left+1,j = right,保證初始時刻黃色區域和綠色區域為空。

圖片

step2:透過指標i和指標j雙向遍歷灰色區域元素。

  • 正向遍歷:
    • 若當前元素nums[i]<key,表示屬於黃色區域,將當前元素追加至黃色區域尾部(i++即可);
    • 若當前元素nums[i]≥key,表示屬於綠色區域,i暫停遍歷,等待反向遍歷至合適位置(即等j停住)。
  • 反向遍歷:
    • 若當前元素nums[j]>key,表示屬於綠色區域,將當前元素加入綠色區域頭部(j--即可);
    • 若當前元素nums[j]≤key,表示屬於黃色區域,j暫停遍歷,等待正向遍歷至合適位置(即等i停住)。
  • 當i和j都停住時,代表i指向的元素應該放入綠色區域,j指向的元素應該放入黃色區域,遂交換nums[i]和nums[j],然後i++,j--,繼續遍歷。

Q:為什麼i和j掃描到nums[i]=key或nums[j]=key時,也要停住,這種情況不是滿足≤key或≥key
A:因為雙路快排是要將與基點值相等的元素均勻地分佈到黃色區域和綠色區域,不僅要使得黃色和綠色各自區域內均存在多個值=pivot的元素,還要儘可能使得每個顏色區域內值=pivot的元素離散分佈不要連續扎堆,否則在後續對子表繼續進行快排時,就可能會出現值=pivot的元素連續扎堆的情況。
• i和j掃描到nums[i]=key或nums[j]=key時,不停住,只有當nums[i]>key和nums[j]<key時才停住:出現值=pivot的元素連續扎堆的情況;
• i和j掃描到nums[i]=key或nums[j]=key時,停住然後二者交換,實現值=pivot的元素在各顏色區域內離散分佈。

step3:灰色區域遍歷結束,此時i≥j,將基點與nums[j]進行交換。

  • i=j時:

由於i停住的條件是nums[i]≥key,j停住的條件是nums[j]≤key,因此,當某一時刻i和j都停住且i=j時,此時兩個指標指向的元素值必定等於key,所以將基點與其交換之後,基點之前的黃色區域相當於在頭部加了一個值為基點值的元素,黃色區域依舊連續。
圖片

  • i>j時:

此時i超出黃色區域指向綠色區域首元素,j超出綠色區域指向黃色區域尾元素。將基點與黃色區域尾元素進行交換,交換之後黃色區域依舊連續,如下圖。
圖片

否則,若將基點與nums[i]交換,將會破壞綠色區域連續性,如下圖。
圖片

程式碼實現:

int partition(vector<int>& nums, int left, int right){
    /* step1: 隨機選取基點privot */
    srand((unsigned)time(NULL)); // 設定隨機種子
    int randomIndex = rand() % (right - left + 1) + left;
    swap(nums[randomIndex], nums[left]);
    int privot = nums[left];

    /* step2: 執行一趟快排 */
    int i = left+1;
    int j = right;
    while(1){
        while(i <= j && nums[i] < privot){
            i++;
        }
        while(i <= j && nums[j] > privot){
            j--;
        }
        if(i >= j){
            break;
        }
        swap(nums[i], nums[j]);
        i++;
        j--;
    }
    /* step3: 將基點放在分界線處 */
    swap(nums[left], nums[j]);
    return j;
} 

4.2 三路快排

三路快排目的:

將與基點相等的元素集中放置在陣列的中央位置,即實現下圖效果。這樣,相較於二路快排,三路快排經過1次快速排序就可以將多個元素放在它正確的位置上(多個與基點值相等的元素擠到了陣列中央),而二路快排每次僅能確定一個元素在正確位置上。

圖片

具體步驟:

step1:選取首元素作為基點key(隨機選取基點),將陣列劃分成多個片段。

  • nums[left]:基點key;
  • nums[left+1...lt):黃色區域,<key;
  • nums(gt...right]:綠色區域,>key;
  • nums[i...gt]:灰色區域,待處理序列;

初始時,lt = i = gt = left + 1,保證初始時刻黃色、橙色、綠色區域均為空。
圖片

step2:遍歷灰色區域。

  • 若nums[i]=key,將其加入橙色區域,即i++即可;

  • 若nums[i]<key,需要將其加入黃色區域尾部,透過交換nums[i]和nums[lt]實現,然後lt++,i++;
    圖片

  • 若nums[i]>key,需要將其加入綠色區域頭部,透過交換nums[i]和nums[gt]實現,然後gt--;
    圖片

程式碼實現:

#include<time.h>
#include<random>
class Solution {
public:
    void quickSort(vector<int>& nums, int left, int right){
        if(left >= right){
            return;
        }
        /* step1: 隨機選取基點privot */
        srand((unsigned)time(NULL)); // 設定隨機種子
        int randomIndex = rand() % (right - left + 1) + left;
        swap(nums[randomIndex], nums[left]);
        int privot = nums[left];
        /* step2: 執行一趟快排 */
        int i = left + 1;
        int lt = left + 1, gt = right;
        while(i <= gt){
            if(nums[i] == privot){
                i++;
            }else if(nums[i] < privot){
                swap(nums[i++], nums[lt++]);
            }else{
                swap(nums[i], nums[gt--]);
            }
        }
        /* step3: 將基點放在分界線處 */
        swap(nums[left], nums[lt-1]);
        /* step4: 對左右子表進行快排 */
        quickSort(nums, left, lt-2);
        quickSort(nums, gt+1, right);
    }
    vector<int> sortArray(vector<int>& nums) {
        quickSort(nums, 0, nums.size()-1);
        return nums;
    }
};

4.3 有效性測試

LeeCode.912. 排序陣列

圖片

5 三路快排partition思路的應用

問題描述:

LeetCode.75. 顏色分類

演算法思想:

將陣列分組,如下圖所示:

圖片

  • nums[left...k)內元素均==0;

  • nums[k...i)內元素均==1;

  • nums[i...j]為待處理序列;

  • nums(j...right]內元素均==2。
    初始時,i=left, k=left, j=right,保證初始時刻黃色、橙色、綠色三個區域均為空,然後遍歷灰色區域直至結束:

  • 若nums[i]==1,應將其追加至橙色區域尾部,i++即可;

  • 若nums[i]==0,應將其追加至黃色區域尾部,透過交換nums[i]和nums[k]實現,交換完畢需要執行i++, k++;

  • 若nums[i]==2,應將其插入到綠色區域頭部,透過交換nums[i]和nums[j]實現,交換完畢執行j--。

程式碼實現:

class Solution {
public:
    void sortColors(vector<int>& nums) {
        int left = 0, right = nums.size()-1;
        int i = 0, j = right, k = left;
        while(i <= j){
            if(nums[i] == 1){
                i++;
            }else if(nums[i] == 0){
                swap(nums[i++], nums[k++]);
            }else{
                swap(nums[i], nums[j--]);
            }
        }
    }
};

圖片

相關文章