數據結構 之 常見的幾種「排序」

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

再進入正文以前,我想推薦你們一個很好的能夠可視化學習算法的網站VisuALgo算法

判斷算法的「好壞」,咱們通常藉助時間(空間)複雜度爲依據,包括最好狀況/最壞狀況/和平均狀況的複雜度。api

排序方法 平均狀況 最好狀況 最壞狀況 輔助空間 穩定性
冒泡排序 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)

咱們來看一下兩個算法運行過程:dom

程序運行過程

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

2*、分治法(devide and conquer)中的代入法(substitution method)
分治法,簡而言之就是把大問題拆分紅小問題,經過遞歸的求解小問題,最終獲得大問題的解。學習

  • 這裏插上一小句,分治法與動態規劃(Dynamic Programming)看上去很像,都是拆大問題爲小問題求解,不過動態規劃「更聰明」也更靈活,已求解的子問題會被保存起來,避免重複子問題的反覆求解。
    另外,動態規劃與數學的遞歸分析法有着很深的淵源,算法的思路每每可以被表示成數組遞歸的等式。
    代入法具體的作法與「數學概括法」的思路不謀而合:
    給出遞推過程當中第n+1項與第n項的關係;
    從第0項開始將每一項的參數帶入這個遞推公式求解。
    重點就在於這個適用於任一個階段的「遞推關係」的肯定,這也是神仙算法「快速排序」的精髓。

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

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

冒泡排序(Bubble Sort)

算法過程
一、比較相鄰的兩個條目(a,b);
二、若是兩個條目的大小關係與排序目標不符,則將兩個條目交換;(假設咱們想要創建升序序列)
三、重複以上兩個步驟直到隊尾;
四、這時,隊尾條目就爲隊列中的最大值;這時咱們再從步驟1開始重複,交換至倒數第二位;直到隊列中全部條目都有序。
時間複雜度
有內外兩個循環,時間複雜度爲O(N²)。ui

改進:提早終止的冒泡排序
若是在內層循環中沒有進行交換,那麼就意味着該隊列已經有序,即可以終止排序操做。
所以對於一個已經有序的序列,其最好狀況的時間複雜度爲O(n)。
不過這一點改進並不能改變冒泡排序的階級屬性平均時間複雜度。.net

簡單選擇排序 (Selection Sort)

算法過程
一、在[i,N-1]範圍內尋找最小的條目的位置X(初始時i=0);
二、將條目x與條目i交換;
三、將i加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)

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

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

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)

算法過程
一、將兩個條目分爲一組,合併成爲有序的長度爲2的序列;
二、將兩個已排序的長度爲2的序列分爲一組,合併成爲有序的長度爲4的序列;
重複該步驟...
三、最終,將兩個已排序的長度爲(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²)

不過,歸併排序也有一些弱勢的部分:
一、算法稍顯複雜;(不過咱們也不須要從底層寫起(from scratch))
二、須要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(從根節點開始,層序遍歷入隊),由此一來,咱們可以很容易獲得節點之間的關係:
一、父節點 parent(i) = i>>1 (1/2)
二、左子節點 left(i) = i<<1 (i2)
三、右子節點 right(i) = i<<1 + 1 (i
2+1)

通常步驟

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

添加元素 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)

相關文章
相關標籤/搜索