資料結構與演算法知識點總結(4)各類排序演算法

LyAsano發表於2022-04-18

1. 插入排序

1.1 直接插入排序

  直接插入排序的特點:

  • 時空效率: 時間複雜度為O(n^2),空間複雜度為O(1)。最好情況下是元素基本有序,此時每插入一個元素,只需比較幾次而無需移動,時間複雜度為O(n)

  • 穩定性: 保證相等元素的插入相對位置不會變化,穩定排序

    void insertion_sort_int(int *arr,int n) {
        for(int i = 1;i < n;i++) {
            int key = arr[i];
            int j = i - 1;
            for(; j >= 0 && arr[j] > key;j--) {
                arr[j + 1] = arr[j];
            }
            arr[j + 1] = key;
        }
    }

     

1.2 折半插入排序

  在查詢插入位置時改為折半查詢,即為折半插入排序。

void binary_insertion_sort_int(int *arr,int n) {
    for(int i = 1;i < n;i++) {
        int key = arr[i];
        int left = 0;
        int right = i - 1;
        while(left <= right) {
            int mid = (left + right) / 2;
            if(key < arr[mid]) 
                right = mid - 1;
             else
                 left = mid + 1;
        }
        for(int j = i - 1; j >= left; j--)
            arr[j + 1] = arr[j];
        arr[left] = key;
    }
}

1.3 希爾排序

  首先取長度的一半作為增量的步長d1,把表中全部記錄分成d1個組,把步長隔d1的記錄放在同一組中再進行直接插入排序;然後再取d1的一半作為步長,重複插入排序操作。

  • 一般預設n在某個特定範圍時,希爾排序的時間複雜度為O(n^1.3)

  • 穩定性: 當相同關鍵字對映到不同的子表中,可能會改變相對次序,不穩定排序。例如(3,2,2)就會改變2的相對次序

void shell_sort_int(int *arr,int n) {
    for(int dk = n / 2; dk >= 1; dk >>= 1) {
        for(int i = dk; i < n;i++) {
            int key = arr[i];
            int j = i - dk;
            for(; j >=0 && arr[j] > key; j -= dk) {
                arr[j + dk] = arr[j];
            }
            arr[j + dk] = key;
        }
    }
}

2. 歸併排序

2.1 二路歸併排序

  二路歸併排序是分治法的應用,模式如下:

  • 分解: n個元素分解成兩個n/2的子序列
  • 解決: 用歸併排序對兩個子序列進行遞迴地排序
  • 合併: 合併兩個有序的子序列得到排序結果

  關於兩個有序列表之間的合併,只需要複製到輔助陣列中,根據大小關係兩兩比較再放入原始陣列中,這種合併方法有很多變式。二路歸併排序的特點如下:

  • 時間效率: 有lgn趟歸併,每次歸併時間為O(n),則時間複雜度為O(nlgn)

  • 空間效率: merge操作需要輔助空間O(n),建議在merge操作外分配一個大的陣列(注意也有O(1)的合併演算法)

  • 穩定性: 是穩定排序,不改變相同關鍵字記錄的相對次序

void merge(int *arr,int *help,int left,int mid,int right) {
    for(int i = left; i <= right;i++)
        help[i] = arr[i]; //把A中元素複製到B中,藉助B處理
    int i = left;
    int j = mid + 1;
    int k = left;
    while(i <= mid && j <= right) { //記錄有多少個共同的
        if(help[i] < help[j]) 
            arr[k++] = help[i++]; //較小值複製到A中
        else 
            arr[k++] = help[j++];
    }
    while(i <= mid)   arr[k++] = help[i++]; //某表未檢測完直接複製
    while(j <= right) arr[k++] = help[j++];
}


void merge_sort_int(int *arr,int *help,int left,int right) {
    if(left < right) {
        int mid = ((right - left) >> 1) + left;
        merge_sort_int(arr,help,left,mid);
        merge_sort_int(arr,help,mid + 1,right);
        merge(arr,help,left,mid,right);
    }
}

2.2 原地歸併排序

原地歸併排序不需要輔助陣列就可以歸併,關鍵在於merge函式。思路是:

  • 遍歷i找到第一個arr[i] > arr[j](start = j),確定i的位置。也就是說在這之前的元素都小於從j開始的元素
  • 遍歷j找到第一個元素arr[j] > arr[i](end = j),確定j的位置,則說明在這之前的元素小於從i開始的元素

上面操作說明第二個序列(start,end-1)的元素都應該在i之前-採用迴圈移位的辦法移到i前面。
例如0 1 5 6 9 | 2 3 4 7 8:

  • 第一次遍歷確定了5和7,然後就把2 3 4迴圈移位到5 6 9前面,
  • i再從5的新位置開始作為第一個序列,j的位置是第二個序列開始
/*三個輔助函式*/
void swap(int *a,int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

/*長度為n的陣列翻轉*/
void reverse(int *arr,int n) {
    int i = 0;
    int j = n - 1;
    while(i < j) {
        swap(&arr[i],&arr[j]);
        i++;
        j--;
    }
}

/**
 * 將含有n個元素的陣列左迴圈移位i位
 */
void rotation_left(int *arr,int n,int i) {
    reverse(arr,i);
    reverse(arr + i,n - i);
    reverse(arr,n);
}

void merge_inplace(int  *arr,int left,int mid,int right) {
    int i = left,j = mid + 1, k = right;
    while(i < j && j <= k) {
        while(i < j && arr[i] <= arr[j]) i++;
        int step = 0;
        while(j <= k && arr[j] <= arr[i]) {
            j++;
            step++;
        }
        //arr+i為子陣列,j-i表示子陣列元素個數,j-i-step表示左迴圈移位的位數(把前面的元素移到後面去)
        rotation_left(arr + i,j- i,j - i - step);
    }
}


void merge_sort_inplace(int *arr,int left,int right) {
    if(left < right) {
        int mid = (left + right) / 2;
        merge_sort_inplace(arr,left,mid);
        merge_sort_inplace(arr,mid+1,right);
        merge_inplace(arr,left,mid,right);
    }
}

3. 交換排序

3.1 氣泡排序

  氣泡排序總共要進行n-1趟遍歷,每次都能確定一個元素最終的位置(剩餘序列中的最值)。在一個序列中,正向遍歷能確定一個最大值,反向遍歷確定一個最小值,每次序列減少一個元素。它的特點如下:

  • 時空效率: 平均時間複雜度為O(n^2),空間複雜度為O(1),產生的有序子序列一定是全域性有序的

  • 穩定性: 穩定的排序方法

void bubble_sort_int(int *arr,int n) {
    for(int i = 0; i < n - 1; i++) { //n-1趟冒泡,每次確定一個元素,每次確定小值元素
        bool flag = false;
        for(int j = n- 1; j > i; j--) {
            if(arr[j - 1] > arr[j]) { //有逆序
                swap(&arr[j - 1],&arr[j]);
                flag = true; //發生交換
            }
        }
        if(flag == false)
            return ; //表明本次遍歷沒有交換,則表已經有序
    }
}

3.2 快速排序

  快速排序的核心在於劃分操作,它的效能也取決於劃分操作的好壞,快排是所有內部排序演算法中平均效能最好的。它的特點如下:

  • 時間效率: 快排的執行時間與劃分是否對稱有關,平均時間複雜度為O(nlgn),最壞為O(n^2)

  • 為了提高演算法效率: 在元素基本有序時採取直接插入排序(STL就這麼幹,給定一個閾值)

  • 為了保證劃分的平衡性,例如三數取中位數或者隨機選擇pivot元素甚至Tukey ninther選取策略,這樣使得最壞情況在實際情形中不太可能發生

  • 空間效率: 採用遞迴工作棧,空間複雜度最好情況下為O(lgn),最壞為O(n)

  • 穩定性: 在劃分演算法中,若右側有兩個相同的元素,在小於pivot時交換到左側相對位置就會發生變化,表明快排是一種不穩定排序,但每趟排序能將一個元素位置確定下來(pivot)。注意使用有些劃分演算法能夠保證區域性穩定性

  快速排序的主框架如下:

void quick_sort_int(int *arr,int left,int right) {
    if(left < right) {
        int pos = partition1(arr,left,right); //劃分
        quick_sort_int(arr,left,pos - 1);
        quick_sort_int(arr,pos + 1,right);
    }
}

3.2.1 首尾指標向中間掃描法

  兩指標分別從首尾向中間掃描。這裡主要考慮的是選取pivot的策略,可能是首元素或尾元素,甚至是三數取中或者隨機取某個數。思路是將資料以pivot作劃分,右側小於pivot移到左側,左側大於pivot移到右側

  此方法可以用來確定第k大或第k小的元素。

int partition(int *arr,int left,int right) {
    int pivot = arr[left];
    while(left < right) {
        while(left < right && pivot <= arr[right])
            right--;
        arr[left] = arr[right]; //比pivot值小的移到左端
        while(left < right && pivot >= arr[left])
            left++;
        arr[right] = arr[left]; //比pivot值大的移到右端
    }
    arr[left] = pivot; //left == right
    return left;
}

  其實還有一種交換策略: 把左右端不滿足條件的元素交換,最後迴圈終止的元素應該與開始元素進行交換。這種方法結合前後兩個策略: 既雙向遍歷又使用了交換操作。

 

int partition1(int *arr,int left,int right) {
    int start = left;
    int pivot = arr[left];
    while(left < right) {
        while(left < right && pivot <= arr[right])
            right--;
        while(left < right && pivot >= arr[left])
            left++;
        swap(&arr[left],&arr[right]); 
    }
    swap(&arr[start],&arr[left]);
    return left;
}

3.2.2 一前一後指標向後掃描法

  採用單向遍歷: 兩指標索引一前一後逐步向後掃描。它的策略是: 記錄一個last指標,用於儲存小於或等於pivot的元素。從左往右掃描,每遇見一個元素小於等於pivot即將它儲存於last指標裡,所以一趟劃分過後last之前的元素(包括last,這取決於last的初始化)小於等於pivot。

  可以看出該劃分演算法的特點: 從前向後遍歷,last記錄的是小於等於pivot的元素,這些元素相對順序不變。若要大於等於pivot的元素相對順序不變,可從後向前遍歷,pivot取首元素。

int partition2(int *arr,int left,int right) {
    int pivot = arr[right];
    int last = left -1;
    for(int i = left; i < right; i++) {
        if(arr[i] <= pivot) {
            ++last;
            swap(&arr[last],&arr[i]);
        }
    }
    swap(&arr[last + 1],&arr[right]);
    return last + 1;
}

  如果pivot不方便取,例如Dijkstra提出的荷蘭三色旗問題要保證0,1,2三者有序,使用單純的單向遍歷會存在0和1順序亂的情況,並且元素的重複次數比較多,所以Dijkstra提出了一種簡單快速的三向劃分法。

3.2.3 三向劃分法

  Dijkstra三向快速切分: 用於處理有大量重複元素的情形,減少遞迴時重複元素的比較的次數。即遍歷陣列一次,維護三個指標lt、cur、gt

  • lt使得arr[0..lt-1]的元素小於v
  • gt使得arr[gt+1..n-1]的元素大於v
  • cur使得arr[lt..i-1]的元素等於v,arr[i..gt]的元素仍不確定

  這和荷蘭三色旗是類似的問題,思路比較簡單。要學會掌握用一個工作指標結合兩個邊界指標進行一次線性遍歷,同樣有很多變式。完整的三向劃分快速排序程式碼如下:

void quick_sort_threeway(int *arr,int left,int right) {
    if(left < right) {
        int lt = left;
        int cur = left;
        int gt = right;
        int pivot = arr[left];
        while(cur <= gt) {
            if(arr[cur] == pivot) {
                cur++;
            } else if(arr[cur] < pivot) {
                swap(&arr[cur],&arr[lt]);
                lt++;
                cur++;
            } else {
                swap(&arr[cur],&arr[gt]);
                gt--;
            }
        } //cur > gt則退出,直接形成了left..lt-1 lt..gt gt+1..right三個區間
        quick_sort_threeway(arr,left,lt - 1);
        quick_sort_threeway(arr,gt + 1,right);
    }
    
}

  不過這種方法唯一的缺點就是在陣列中重複元素不多的情況下比標準的二分法多使用了很多次交換。90年代,John Bently等人用一個聰明的方法解決了此問題,使得三向切分的快排比一般的排序方法都要快。

3.2.4 快速三向劃分法

  Bently用重複元素放置於子陣列兩端的方式實現了一個資訊量最優的演算法,這裡額外的交換隻用於和切分的pivot元素相等的元素,上面的額外交換是用於切分不相等的元素。

  思路如下:

  • 維護兩個索引p、q,使得arr[lo..p-1]和arr[q+1..hi]的元素都和 a[lo]相等(記為v)
  • 使用兩個索引i、j,使得a[p..i-1]小於v,a[j+1..q]大於v。
  • 若在前面遇到等於v的元素與p交換,後面的與q交換(類似操作)

  如圖:

資料結構與演算法知識點總結(4)各類排序演算法

  它的程式碼如下:

void quick_sort_threeway_fast(int *arr,int left,int right) {
    if(left < right) {
        int p = left , q = right + 1; 
        int pivot = arr[left];
        int i = left , j = right + 1;
        while(true) {
            while(arr[++i] < pivot) 
                if(i == right) 
                    break;
            while(arr[--j] > pivot) 
                if(j == left)
                    break;

            if(i == j && arr[i] == pivot)
                swap(&arr[++p],&arr[i]);
            if(i >= j) break;

            swap(&arr[i],&arr[j]);
            if(arr[i] == pivot)
                swap(&arr[++p],&arr[i]);
            if(arr[j] == pivot)
                swap(&arr[--q],&arr[j]);
        }
        i = j + 1;
        for(int k = left; k <= p; k++) swap(&arr[k],&arr[j--]);
        for(int k = right; k >= q;k--) swap(&arr[k],&arr[i++]);
        quick_sort_threeway_fast(arr,left,j);
        quick_sort_threeway_fast(arr,i,right);
    }
}

3.3 最優化的排序演算法

  在元素個數比較小的時候使用直接插入排序,元素個數較多的適合主要在於pivot元素的選取策略。程式碼如下:

/**
 * 三數取中策略: 返回陣列的下標
 */
int median3(int *arr,int i,int j,int k) {
    if(arr[i] < arr[j]) {
        if(arr[j] < arr[k])
            return j;
        else {
            return (arr[i] < arr[k])? k : i;
        }
    } else {
        if(arr[k] < arr[j])
            return j;
        else {
            return (arr[k] < arr[i])? k : i;
        }
    }
}

void optimal_sort_int(int *arr,int left,int right) {
    int n = right - left + 1;
    if(n <= THRESHOLD) {
        insertion_sort_int(arr,n);
    } else if(n <= 500) { //用三數取中排序
        int pos = median3(arr,left, left + n / 2,right);
        swap(&arr[pos],&arr[left]);
    } else {//採取Tukey ninther 作為pivot元素
        int eighth = n / 8;
        int mid = left + n / 2;
        int m1 = median3(arr,left,left + eighth,left + eighth * 2);
        int m2 = median3(arr,mid - eighth,mid,mid + eighth);
        int m3 = median3(arr,right - eighth * 2,right - eighth,right);
        int ninther = median3(arr,m1,m2,m3);
        swap(&arr[ninther],&arr[left]);
    }
    quick_sort_threeway_fast(arr,left,right);
}

4. 選擇排序

4.1 簡單選擇排序

  選擇排序的基本思路: 每一趟要在後面的元素中確定一個最值元素,重複n - 1趟。它的特點如下:

  • 時間效率: 注意元素的比較次數與初始序列無關,始終要比較n(n-1)/2,時間複雜度為O(n^2)
  • 空間效率為O(1)
  • 穩定性:不穩定排序,例如6 8 6 5
void selection_sort_int(int *arr,int n) {
    int min;
    for(int i = 0; i < n - 1;i++) {//n-1趟選擇排序,每次選擇一個最小值
        min = i;
        for(int j = i + 1; j < n;j++) {
            if(arr[min] < arr[j]) {
                min = j; //更新最小元素位置
            }
        }
        if(min != i) swap(&arr[i],&arr[min]); //更新到的最小值與i位置交換
    }
}

4.2 堆排序和優先順序佇列

  堆排序是一種樹形選擇排序,利用完全二叉樹中父節點和子節點的關係選擇最值的元素。排序的步驟如下:

  • 先構造大根堆(這也是一個反覆向下調整滿足最大堆性質的操作)
  • 然後再把堆頂元素與堆底元素交換,此時根結點不再滿足堆的性質,堆頂元素向下調整重複操作,直到堆中剩一個元素為止,要進行n-1趟交換和調整操作

  堆排序的特點是:

  • 時空效率: 在最好、平均和最壞情況下,堆排序的時間複雜度均為O(nlgn);空間複雜度: O(1)

  • 穩定性: 不穩定排序

  堆排序的主程式如下(注意0號不儲存元素,實際儲存從1開始,陣列長度為len + 1):

void heap_sort_int(int *arr,int len) {
    build_max_heap(arr,len);
    for(int i = len; i > 1;i--) {
        swap(&arr[i],&arr[1]); //和堆頂元素交換,並輸出堆頂元素
        sink_down(arr,1,i - 1); //注意還剩餘i-1個元素調整堆
    }
}

4.2.1 堆調整操作

  A 下標為k的堆的自頂向下操作

  這種操作是為了保持根為下標k的元素的子樹滿足最大堆性質。如果某個節點比它們的兩個子節點或之一更小,則以該節點為根的子樹不滿足最大堆性質。

  調整思路: 把更小的元素由上至下下沉,以類似的方式維持其子節點的堆狀態性質直到某子節點元素滿足最大堆的狀態。向下調整的時間與樹高有關為O(h)

  此操作用於構造堆,堆排序和刪除操作(delMax),程式碼如下:

void sink_down(int *arr,int k,int len) {
    while(2 * k <= len) {
        int j = 2 * k;
        if(j <len && arr[j] < arr[j + 1]) j++; //取更大的元素
        if(arr[k] > arr[j]) break; //根元素大於子節點,則滿足最大堆性質無需調整
        swap(&arr[k],&arr[j]);
        k = j;
    }
}

B 下標為k的堆自底向上操作

  這種操作是因為該節點的大小比其父節點更大,則需要進行向上調整,注意終止條件是k=1且父節點更大。

  該操作用於在堆底插入新元素

void swim_up(int *arr,int k) {
    while(k > 1 && arr[k] > arr[k / 2]) {
        swap(&arr[k],&arr[k / 2]);
        k = k / 2;
    }
}

  總結起來堆和優先順序佇列的插入、刪除和取最值還是比較簡單的,關鍵還是在於這兩個堆調整操作,程式碼量不大,但需要理解其執行邏輯。

4.2.2 構造堆操作

  以無序陣列自底向上構造出一個最大堆,時間複雜度為O(n),實際儲存從下標1開始,陣列長度為len + 1。從n=len/2開始進行自頂向下調整操作

void build_max_heap(int *arr,int len) {
    for(int i = len / 2; i >= 1;i--)
        sink_down(arr,i,len);
}

  大根堆一般可用於求海量資料中最小的k個數: 即先讀取k個元素構造最大堆,再依次讀入資料。若當前資料比堆頂小,則替換堆頂;若當前資料比較大,不可能是最小的k個數 。對應地求最大的k個數一般用小根堆。

  如下分別是用快排的劃分演算法和堆的性質取得最小的k個數

void print(int *arr,int left,int right) {
    for(int i = left; i<= right;i++)
        printf("%d ",arr[i]);
    printf("\n");
}

/*基於partition演算法取最小的k個數*/
void getleastk(int *arr,int n,int k) {
     int left = 0;
    int right = n - 1;
    int pos = partition(arr,left,right);
    while(pos != k - 1) {
        if(pos > k - 1) {
            right = pos - 1;
            pos = partition(arr,left,right);
        } else {
            left = pos + 1;
            pos = partition(arr,left,right);
        }
    }
    print(arr,0,k-1);
}

/*基於最大堆求最小的k個數*/
void getmink(int *arr,int n,int k) {
    int b[k+1];
    for(int i = 1; i <= k;i++) {
            b[i] = arr[i - 1];        
    }

    build_max_heap(b,k);
    for(int i = k; i < n;i++) {
        if(arr[i] > b[1])
            continue;
        else {
            b[1] = arr[i];
            sink_down(b,1,k);
        }
    }
    heap_sort_int(b,k);
    print(b,1,k);
}

5. 線性時間排序

  後續補充

5.1 計數排序

5.2 基數排序

5.3 桶排序

相關文章