1. 程式人生 > >資料結構 之 常見的幾種“排序”

資料結構 之 常見的幾種“排序”

排序(sorting)是演算法家族裡比較重要也比較基礎的一類,內容也是五花八門了:
1、有“基於比較”的,也有“不基於比較”的;
2、有迭代的(iterative)也有遞迴的(recursive);
3、有利用分治法(divide and conquer)思路解決的;(除了顯而易見的“二路歸併”演算法,
“代入法(substitution method)”也是分治的一種,如快速排序/插入排序)

再進入正文之前,我想推薦大家一個很好的可以視覺化學習演算法的網站VisuALgo

判斷演算法的“好壞”,我們一般藉助時間(空間)複雜度為依據,包括最好情況/最壞情況/和平均情況的複雜度。

排序方法 平均情況 最好情況 最壞情況 輔助空間 穩定性
氣泡排序 O(n²) O(n) O(n²) O(1) 穩定
簡單選擇排序 O(n²) O(n²) O(n²) O(1) 不穩定
直接插入排序 O(n²) O(n) O(n²) O(1) 穩定
希爾排序 O(nlogn)~O(n²) O(nlogn) O(n²) O(1) 穩定
堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 不穩定
歸併排序 O(nlogn) O(nlogn) O(nlogn) O(n) 穩定
快速排序 O(nlogn) O(nlogn) O(n²) O(logn)~O(n) 不穩定

1*、迭代的(iterative)與遞迴的(recursive)的區別
迭代(iterative)指迴圈反覆執行某操作,由舊值遞推出新值

,每一次對過程的重複稱為一次“迭代”,而每一次迭代得到的結果會作為下一次迭代的初始值;
遞迴(recursive)指程式在執行過程中直接或間接呼叫自己。遞迴演算法要求有邊界條件、遞迴前段和遞迴返回段。當邊界條件不滿足時,遞迴前進;當邊界條件滿足時,遞迴返回。
我們以階乘(factorial)為例,看看兩類演算法是如何操作的:

#迭代iterative
def factorial(number):
    product = 1
    for i in range(number):  #主體是迴圈
        product = product * (i+1)
    return product
m = factorial(5)
print(m)

#遞迴recursive
def factorial(number):
    if number <= 1:  #遞迴的邊界條件(出口)
        return 1
    else:
        return number * factorial(number-1) #呼叫自身
n = factorial(5)
print(n)

我們來看一下兩個演算法執行過程:

程式執行過程

對於這個問題來說,迭代比遞迴的時間效率更高。
不過在真正使用的時候,還需要根據情況討論兩類演算法的優劣。

2*、分治法(devide and conquer)中的代入法(substitution method)
分治法,簡而言之就是把大問題拆分成小問題,通過遞迴的求解小問題,最終得到大問題的解。

  • 這裡插上一小句,分治法與動態規劃(Dynamic Programming)看上去很像,都是拆大問題為小問題求解,不過動態規劃“更聰明”也更靈活,已求解的子問題會被儲存起來,避免重複子問題的反覆求解。
    另外,動態規劃與數學的遞迴分析法有著很深的淵源,演算法的思路往往能夠被表示成陣列遞迴的等式。
    代入法具體的做法與“數學歸納法”的思路不謀而合:
    給出遞推過程中第n+1項與第n項的關係;
    從第0項開始將每一項的引數帶入這個遞推公式求解。
    重點就在於這個適用於任一個階段的“遞推關係”的確定,這也是神仙演算法“快速排序”的精髓。

時間複雜度O(N²)的基於比較的排序演算法

通過兩兩條目進行比較,決定是否將兩個條目進行交換(swap)。
這類演算法是最容易理解和應用的,但同時並不那麼高效,它們的時間複雜度通常為O(N²)。

氣泡排序(Bubble Sort)

演算法過程
1、比較相鄰的兩個條目(a,b);
2、如果兩個條目的大小關係與排序目標不符,則將兩個條目交換;(假設我們想要建立升序序列)
3、重複以上兩個步驟直到隊尾;
4、這時,隊尾條目就為佇列中的最大值;這時我們再從步驟1開始重複,交換至倒數第二位;直到佇列中所有條目都有序。
時間複雜度
有內外兩個迴圈,時間複雜度為O(N²)。

改進:提前終止的氣泡排序
如果在內層迴圈中沒有進行交換,那麼就意味著該佇列已經有序,便可以終止排序操作。
因此對於一個已經有序的序列,其最好情況的時間複雜度為O(n)。
不過這一點改進並不能改變氣泡排序的階級屬性平均時間複雜度。

簡單選擇排序 (Selection Sort)

演算法過程
1、在[i,N-1]範圍內尋找最小的條目的位置X(初始時i=0);
2、將條目x與條目i交換;
3、將i加1,重複步驟1、2,直到所有條目有序。

void selectionSort(int a[], int N) {
  for (int i = 0; i <= N-2; i++) { 外層迴圈 O(N)
    int X = min_element(a+i, a+N) - a; //內層迴圈 O(N),找到最小條目的位置
    swap(a[X], a[L]); // O(1) 而知也可能相等(並不真正交換)
  }
}

時間複雜度
同樣是內外兩層迴圈,時間複雜度為O(N²)。

插入排序(Insertion Sort)

演算法過程
插入排序的演算法思路很像我們在打牌時調整牌序的做法:
紙牌

1、開始的時候手裡只有一張牌;
2、拿到下一張牌,將牌放到手中牌組的合適位置;
3、每張牌都重複上面的步驟。

void insertionSort(int a[], int N) {
  for (int i = 1; i < N; i++) { // 外層迴圈 O(N)
    X = a[i]; // X 是將插入的物件
    for (j = i-1; j >= 0 && a[j] > X; j--) //從後往前在已經有序的前i-1個條目中找到應當插入的位置
      a[j+1] = a[j]; // 為X的插入騰出位置
    a[j+1] = X; // 將X插入j+1位
  }
}

時間複雜度
顯然,外層迴圈的時間複雜度為O(N)
而內層迴圈的時間複雜度則與待排序序列的有序狀況有關:

  • 最好情況下,待排序序列已經是有序的,這時候內層迴圈壓根不用找(待排序條目始終比已排序的最後一個條目大)所以這種情況下內層迴圈的時間複雜度為O(1);
  • 最壞情況下,待排序序列是逆序的,這時候內層每次都要遍歷到開頭才能找到該插入的位置,這時內層迴圈的時間複雜度就為O(N);
    綜上而言,最好情況下的時間複雜度為O(N),最壞情況下為O(N²),平均情況下的時間複雜度為O(N²)。

時間複雜度O(NlogN)的基於比較的排序演算法

歸併排序(Merge Sort)

演算法過程
1、將兩個條目分為一組,合併成為有序的長度為2的序列;
2、將兩個已排序的長度為2的序列分為一組,合併成為有序的長度為4的序列;
重複該步驟...
3、最終,將兩個已排序的長度為(N/2)的序列合併成為有序的長度為N的序列,排序完成。

以上只是大體的思路,去進一步瞭解歸併排序,我們先從“合併”(merge)這個操作談起:
從兩個待合併序列的首部開始,邊比較邊向後移(取出兩邊指標所指的較小條目的到輔助佇列中去,並將指標向後移一位)

void merge(int a[], int low, int mid, int high) {
  // 子序列1 = a[low..mid], 子序列2 = a[mid+1..high], 都是有序的
  int N = high-low+1;
  int b[N]; // 一個輔助陣列
  int left = low, right = mid+1, bIdx = 0; //初始化子序列和輔助序列的指標
  while (left <= mid && right <= high) // 合併過程
    b[bIdx++] = (a[left] <= a[right]) ? a[left++] : a[right++];
  while (left <= mid) b[bIdx++] = a[left++]; // 處理餘下的部分
  while (right <= high) b[bIdx++] = a[right++]; //  處理餘下的部分
  for (int k = 0; k < N; k++) a[low+k] = b[k]; // 將輔助陣列中的內容貼上回去
}

以上就是歸併排序演算法的靈魂核心所在了。
還記得之前提到過的“分治法”(Divide and Conquer)嗎?
將大問題拆分正小問題,通過解決小問題遞迴的解決大問題。

歸併排序就是一個典型的利用“分治法”思路的演算法:
“分”的過程很容易:將待排序的序列一分為二,一直分到不能再分(單個條目),再通過迭代的思路回溯著求解;
“治”的部分就是我們剛剛介紹的合併(merge)的過程。

完整演算法過程:

void mergeSort(int a[], int low, int high) {
  // 待排序的序列是a[low..high]
  if (low < high) { // 迭代的出口是單個條目或空(low>=high)
    int mid = (low+high) / 2;   
    mergeSort(a, low  , mid ); // 將序列一分為二,迭代求解(recursive)
    mergeSort(a, mid+1, high); 
    merge(a, low, mid, high); // “治”的部分,合併子序列
  }
}

時間複雜度

merge_tree

對於每一次長度為k的序列的合併(merge)操作來說,它的時間複雜度是O(k)。(最多有k-1次比較,當兩個待合併的序列正好“鑲嵌”時)
由上圖可知,在第k層,每一個待合併的序列長度為n/(2^(k-1)),需要執行合併的次數為2^(k-1)。
所以可以得到,在第k層,合併的總的時間複雜度為O[N/(2^(k-1))]*O[2^(k-1)] = O(N);
易知該歸併樹一共有logN層,所以可得歸併排序總的時間複雜度為O(NlogN)。

歸併排序的一個很大優點就是,無論待排序的序列情況如何,其時間複雜度都是O(NlogN)。
這種性質使得其適用於大規模的排序。(NlogN的增長速度遠小於N²)

不過,歸併排序也有一些弱勢的部分:
1、演算法稍顯複雜;(不過我們也不需要從底層寫起(from scratch))
2、需要O(N)的空間複雜度(一個輔助佇列),使得這個演算法不是就地演算法

快速排序(Quick Sort)

快速排序也是一個使用“分治法”思路的演算法。
演算法過程
我們用“分治法”的思路來分析演算法:
“分”的部分:
選擇一個條目p(相當於一箇中央標杆)
然後將待排序序列a[i...j]分為三部分:a[i...m-1],a[m],a[m+1...j]

  • a[i...m-1](可能為空)中的條目都小於剛才選定的標杆a[p]的值;
  • a[m]的值為標杆的值(可以認為這裡是把標杆a[p]移動到了排序後正確的位置上)
  • a[m+1...j](可能為空)中的條目都大於標杆的值。
    接下來,將該過程應用在左右這兩個子序列中,迭代下去。

“治”的部分:
...什麼都不做。

是不是感覺和之前討論的“歸併排序”完全相反呢?

我們先從重要的“分”的部分(經典版本)開始討論:
為了分隔a[i...j],我們先選擇a[i]作為中央標杆p。
餘下的元素被分到到三個區域:
① S1 = a[i+1...m] 其中元素都 < p;
② S2 = a[m+1...k-1] 其中元素 ≥ p;
③ 未知區域 = a[k...j] 尚未分配至S1/S2。

初始時,S1區和S2區都是空的;即除了p自身,所有的元素都在“未知區域”中。
對於每一個在未知區域中的元素a[k],我們將其與p比較,決定其分到S1還是S2。

先通過圖片來對“分組”的操作有一個直觀的認識:

情況一:a[i] ≥ p

case1

case1_motion

情況二:a[i] < p

case2

case2_motion

演算法實現:

int partition(int a[], int i, int j) {
  int p = a[i]; // 選擇a[i]作為中心軸
  int m = i; // S1和S2初始情況下都是空的
  for (int k = i+1; k <= j; k++) { // 遍歷未知區域
    if (a[k] < p) { // 情況2
      m++;
      swap(a[k], a[m]);
    } // 對於情況1: a[k] >= p,僅僅k++,無額外操作
  }
  swap(a[i], a[m]); // 最後一步,將a[m]與a[i]交換,將中心軸放在最終位置
  return m; // 返回p最終位置的下標
}

void quickSort(int a[], int low, int high) {
  if (low < high) {
    int m = partition(a, low, high); // 時間複雜度 O(N)
    // m為low最終的位置
    quickSort(a, low, m-1); // 迭代求解左邊分組
    quickSort(a, m+1, high); // 迭代求解右邊分組
  }
}

複雜度分析:
首先,分析每一次“分組”(partition)的複雜度:
對於partition(a,i,j),只需要遞迴執行(j-i)次(將未分組的條目一一分組),所以它的時間複雜度是O(N)。

最壞的情況下,即如果序列本來就是有序的,那麼每次都選擇第一個條目作為“中心軸”的結果就是,分組的左半邊只有p(x≤p),而餘下的條目都在右半邊(x>p)。
這種情況下一共需要執行n-1次“分組”的操作。總的時間複雜度為O(N²)。

worst_case

而最好的情況下,每一次選擇的p都能夠將序列分為相等大小的兩部分。
這種情況下,遞迴的深度只有O(logN)(與歸併排序相類似),每一層的時間複雜度為O(N),得到總的時間複雜度為O(NlogN)。

隨機快速排序(Random Quick Sort)

隨機快速排序與快速排序不同的一點就是,相對於從“固定”的位置選擇p(比如一直選擇起始部分的元素作為p),p的選擇是隨機的。

為什麼這個隨機快速排序的時間複雜度為O(NlogN)呢?解釋起來可能稍顯繁瑣,不過我們可以建立一種直觀的感受:
如果是隨機選擇p的話,我們遇到極端情況的概率(完全正序)就會很小,(可以把它想象成符合一種溫和的正態式的隨機分佈)那麼這種“較好情況”和“較差情況”碰撞疊加相平均,結果便會得到O(NlogN)的時間複雜度。

不基於比較的排序演算法

基於比較的排序演算法時間複雜度的下限為O(NlogN),也就是說,能夠做到最壞情況的時間複雜度也為O(NlogN)的演算法就可以被視作最優演算法了。

然而,如果使用不基於比較的排序方法,我們可以“變得更快”,甚至達到O(N)的時間複雜度。(不過待排序列需要滿足一些前提條件)

計數排序(Counting Sort)

前提條件:如果待排序的序列為小範圍內的整型數(Integer),我們只需記下每個整型數出現的頻次,再按序輸出就行了。

例如,待排序序列的範圍是[1,9],只需要記錄下“1”出現了多少次,“2”出現了多少次……再按從1到9的順序輸出就行了。

基數排序(Radix Sort)

前提條件:待排序的序列可以是較大範圍的整型數,但是位數不能太大。

基數排序又被稱為“桶子法”(Bucket Sort)。在基數排序中,我們將每個待排序的數視作一個 w 長的字串(如果長度不夠可以在前面添零)

① 先從最右位(最小位)開始,將待排序的數根據最小位的數值分到(0~9)這十個“桶子”中去,再從“0號桶”開始,依次將每個桶子中的數取出來,排成一個最小位有序的序列。
② 接著,根據倒數第二位的數值,“依序”將各數再次分到十個“桶子”中去,然後將每個桶子中的數取出排列成新的序列。(注意取出的時候要維持放入桶中的順序)這個時候得到就是後兩位有序的序列了。
③ 重複這個操作,直到最左位,便可得到有序的數列了。

不難看出,這個排序方法是“穩定”的。其時間複雜度為O(w*(N+k))

“放”的時間複雜度為O(N),“取”的時間複雜度為O(k)(這裡指有k個“桶”),一共需要操作w次(共有w位)。

堆排序(Heap Sort)

背景知識
有以下兩個性質:

  • 是一棵完全二叉樹(就是隻有最下一層的右側可為空的滿二叉樹)
  • 堆中某個節點的值總是不大於(大根堆)或不小於(小根堆)其父節點的值

一個完全二叉樹能夠被儲存成為一個數列A(從根節點開始,層序遍歷入隊),由此一來,我們能夠很容易得到節點之間的關係:
1、父節點 parent(i) = i>>1 (1/2)
2、左子節點 left(i) = i<<1 (i2)
3、右子節點 right(i) = i<<1 + 1 (i
2+1)

一般步驟

1、初始建成一個大根堆;
2、將堆頂元素取出,並將堆末尾(對應的數列的末尾元素)移至堆頂處;
3、調整堆中的元素位置,再次構成大根堆,回到第一步,直到所有元素被取出。

新增元素 insert(v)

為了保證堆的完全二叉樹的特性,新增元素只能在末尾新增。
新增元素之後,可能會破壞堆的順序,因此要進行相應的交換調整。
時間複雜度為O(logN)

初始化堆 heapify()

有兩種時間複雜度不同的初始方式:

  • siftUp: O(NlogN)初始為空,每在末尾新增一個元素,都要進行交換排序;
  • siftDown:O(N)初始是一個沒有經過排序的二叉樹,在其基礎上進行排序調整;

從直觀上看一下這兩種初始化方式的時間複雜度區別:

heapify

  • siftUp的每新增一個元素,相當於在當時的高度h上進行了一次順序調整;然而這個高度h隨著元素的新增在不斷升高,高度越高,呼叫“調整”的次數就越多,“調整”的時間複雜度近似向最末層的時間複雜度O(logN)靠攏,故總的時間複雜度為O(NlogN);
  • 而siftDown與之相反,需要“調整”的次數由下層至上層遞增,“調整”的時間複雜度像下靠攏(O(1)),得到總的時間複雜度為O(N)。

調整 siftDown()

在元素數為K時,可得其調整的時間複雜度為O(h) = O(logK)
因為底層的元素較多,所以我們可以認為整體的時間複雜度向下靠攏(O(logN))
因此可以得到堆排序的總的時間複雜度為O(N)[初始堆]+O(NlogN)[調整] = O(NlogN)

---恢復內容結束---