希爾排序
你可以將希爾排序理解成——先通過幾次分組的、較小的組間插入排序將原數組變得有序,最后再進行一次序列基本有序的完整插入排序。
#include <stdio.h>#define ARR_LEN(arr) (sizeof(arr) / sizeof(arr[0]))void print_arr(int arr[], int len) {for (int i = 0; i < len; i++) {printf("%d ", arr[i]);}printf("\n"); }// 希爾排序: 縮小增量排序, 其實就是多人摸牌, 逐漸減少摸牌人數 // 希爾排序中, 增量序列的設計非常重要,這里采取簡單的gap序列: 長度減半..一直減半,直到為1 // gap為1時就是一個在數組元素基本有序情況下的,插入排序 void shell_sort(int arr[], int len) {// 第一個元素是第一個人的初始手牌,一直到第gap個元素都是初始手牌int gap = len >> 1;while (gap > 0) {// 外層for的i仍然代表新摸到的手牌的下標,i從gap開始,直到摸完整個數組元素for (int i = gap; i < len; i++) {// 先記錄一下新手牌的值, 便于后續的插入操作int tmp = arr[i];int j = i - gap; // 代表每一個人舊手牌的最后一張牌for (; j >= 0; j -= gap) {// 內層for代表 每個人每摸到一張新手牌,都會和原本的舊手牌比較,但由于gap存在,所以需要減去gapif (arr[j] > tmp) { // 注意:不能加=,加了就不穩定了arr[j + gap] = arr[j]; // 將舊手牌中大于新手牌的所有牌都向后移}else{break; // 只要發現一張舊手牌更小或相等, 就說明已經找到新手牌的插入位置了}}arr[j + gap] = tmp;}print_arr(arr, len); // 每一輪希爾排序后查看數組排序結果gap >>= 1; // 每一輪希爾排序,增量都減半} }int main(void) {int arr[] = { 16, 1, 45, 23, 99, 2, 18, 67, 42, 10 };int arr_len = ARR_LEN(arr);shell_sort(arr, arr_len);return 0; }
時間復雜度:
希爾排序的時間復雜度,和選擇的增量序列有密切的關聯:
若使用希爾本人提出的減半序列,時間復雜度通常會小于O(n2),但在最壞情況也會接近O(n2)
空間復雜度分析:
希爾排序是一種原地排序算法,不需要占用額外內存空間。空間復雜度是O(1)
穩定性分析:
希爾排序顯然不是一種穩定的排序算法,因為它先分組再插入排序的方式,使得相同元素可能會由于分組不同改變位置。很明顯這不是穩定的排序算法。
?歸并排序
歸并排序的分治策略思路大體上如下:
- 分解大問題:將一個大數組分解成兩個或更多的子數組,直到每個子數組足夠小,通常是直到每個子數組只包含一個元素或者是空數組。
- 解決子問題:數組小到只有一個元素或者沒有元素,那自然是"有序數組",所以這些子問題可以視為被解決了。
- 合并:歸并排序的核心在于合并步驟,也可以叫"歸并"操作,它會將兩個有序的子數組合并成一個大的有序數組。這個過程通常需要反復比較元素,比較復雜。
?遞歸分解:
遞歸分解的過程會不停地將大數組分解成兩個小的子數組,這個分解的過程會根據大數組的左右界限求一個中間索引,然后將大數組盡量等分為兩份。所以,遞歸分解的函數,至少需要三個參數:
- 遞歸分解的數組arr
- 數組分解的左下標left
- 數組分解的右下標right
此遞歸分解的函數會將arr數組的[left, right]區間分解成兩個小數組。
于是遞歸的出口就很明顯了是:left >= right,這表示子數組縮小到只包含一個元素或沒有元素時,遞歸將停止。
在計算中索引時,我們將采用一種優化的方案:
- 一般情況下,可以直接使用 "(left + right) >> 1" 來直接求中間索引。
- 但C語言中int類型可能只占2個字節,此時int類型取值范圍較小,上面的表達式可能會出現數據溢出失真。為避免這種情況發生,我們可以采用表達式"left + (right - left >> 1)"去求中間索引。
合并 操作:
- 從左到右輪流比較待合并子數組中的元素,把比較過程中的較小元素存入臨時數組中,直到某個子數組元素為空。
- 然后再將存在剩余元素的子數組中的所有元素,輪流放入臨時數組中。
- 最后把臨時數組中的元素,復制回原數組。
注:臨時數組的長度和原數組是一致的,且合并過程共有同一套下標索引。
#include <stdio.h> #define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))// 打印數組的函數 void print_arr(int arr[], int left, int right) {for (int i = left; i <= right; i++) {printf("%d ", arr[i]);}printf("\n"); }/* * 合并的思路: * 1.把左右子數組中元素按照順序合并到臨時數組中,過程類似"穿針引線" * 2.將排好序的臨時數組元素按照下標賦值給原數組 * 注:臨時數組和原數組共有一套下標 * 傳入函數邏輯上的左右子數組是有序的,相當于合并兩個有序的左右子數組 */ static void merge(int arr[], int left, int mid, int right, int *tmp) {/** tmp_idx: 用于存放合并結果的臨時數組的開始下標* left_idx: 左子數組的開始下標* right_idx: 右子數組的開始下標*/int tmp_idx = left, left_idx = left, right_idx = mid + 1;// 只要左右子數組同時還有元素while (left_idx <= mid && right_idx <= right) {// 捉對比較左右子數組的元素,按照從小到大放入臨時數組// <=判斷不會改變相同元素的相對位置,是穩定算法。反之則不是穩定算法if (arr[left_idx] <= arr[right_idx]) {tmp[tmp_idx++] = arr[left_idx++];}else {tmp[tmp_idx++] = arr[right_idx++];}}// while結束時,左右子數組必然有一個沒有元素了,此時另一個數組必然還有元素// 也就是說只會有一個數組是空的// 但我們無法確定是哪個數組沒有元素了// 所以我們都判斷一下將左右子數組還剩余的元素取出來while (left_idx <= mid) {// 說明左數組還有元素tmp[tmp_idx++] = arr[left_idx++];}while (right_idx <= right) {// 說明右數組還有元素tmp[tmp_idx++] = arr[right_idx++];}// 將臨時數組中已排序好的元素復制到原始數組中for (int i = left; i <= right; i++) {arr[i] = tmp[i];}// 打印此一輪歸并排序的元素print_arr(arr, left, right); }/* * 輔助函數,實現對[left, right]范圍內的數組遞歸分解合并 * left表示遞歸分解的區間起點,right表示遞歸分解區間的終點,是一個閉區間 * 遞歸分解的思路是: * 對[left, right]區間元素的排序,可以分解成: * [left, mid]區間,和[mid + 1, right]區間的排序合并 * 遞歸的出口是: * 如果區間僅有一個元素或沒有元素,遞歸結束 */ static void divide_merge(int arr[], int left, int right, int *tmp) {// 遞歸的出口if (left >= right) {return;}// 遞歸體// 計算中間索引int mid = left + (right - left >> 1);// 分解,規定左數組是[left, mid]// 右數組是[mid + 1, right]divide_merge(arr, left, mid, tmp);divide_merge(arr, mid + 1, right, tmp);/** 歸并,歸并排序的核心操作* 需要一個臨時數組完成此操作* 這個臨時數組至少要和原先的數組一般大* 有兩種方案:* 1.用全局變量數組或局部變量,該方式簡潔易實現,無需考慮內存回收* 但缺點是* a.必須編譯時期確定數組長度,無法運行時期動態分配* b.棧區和數據段都無法創建長數組,在大數據集下容易產生溢出錯誤* 為了解決這兩個缺點,我們可以在堆上動態分配數組* 但同樣也有缺點:* a.內存管理風險* b.動態分配數組會帶來額外性能開銷*/merge(arr, left, mid, right, tmp);}void merge_sort(int arr[], int len) {// 臨時數組int *tmp = calloc(len, sizeof(int));if (tmp == NULL) {printf("calloc failed in merge_sort.\n");return;}// 將整個數組進行遞歸分解合并,即完成歸并排序divide_merge(arr, 0, len - 1, tmp);// 不要忘記free釋放資源free(tmp); }// 測試歸并排序 int main() {int arr[] = { 8, 3, 2, 6, 9, 7, 1, 0, 4, 5 };int arr_size = ARR_SIZE(arr);merge_sort(arr, arr_size);return 0; }
時間復雜度:
無論原始數組處在什么狀態,歸并排序都會按照既定步驟分解、合并。所以在最好,最壞,平均情況下,歸并排序的時間復雜度都是一樣的,都是O(nlogn)。
歸并排序的時間復雜度分析需要考慮它的兩個主要操作,分解和合并:
- 分解過程也就是遞歸調用的過程,這個過程大概分解了log2n次(每次都將數組折半,也就是遞歸的深度)
- 在合并的過程中,需要遍歷并比較子數組的元素,然后將它們按順序復制回原數組。每次合并操作的時間復雜度都是O(n),因為它涉及到整個數組的遍歷。合并的次數和分解的次數是一樣的,都是log2n次,所以對于合并操作,總的時間復雜度是O(nlogn)
?空間復雜度:
歸并排序顯然不是一個原地算法。它需要額外的內存空間:
- 需要一個與原始數組大小相同的,長度是n的輔助數組來進行合并操作。
- 遞歸調用,占用額外的棧空間。因為每次遞歸調用都會將數組分為兩個大致相等的部分,所以每次都將問題的規模減半。遞歸深度大致是log2n。
所以空間復雜度是O(n)。
穩定性:
歸并排序是穩定的排序算法。這是因為如果兩個元素相等,歸并排序不會改變它們的相對順序。
快速排序
單向分區
所謂單向分區,指的是快速排序算法在分區的過程中,元素比較和交換的操作是單向進行的,也就是從數組的一端進行到另外一端。
單向分區快速排序算法,具體而言,它的思路是:
- 選擇一個基準值(pivot),可以是隨機選擇,也可以是直接選首尾元素。選定基準值后,一般會將pivot交換到數組末尾,這樣做可以簡化分區操作。
- 設置一個索引(比如叫idx)來追蹤小于基準值的元素應該插入的位置,一開始idx索引指向數組的首元素。
遍歷數組進行分區操作:
- 從數組首元素開始遍歷整個數組
- 如果元素小于基準值,則將該元素與idx位置的元素交換,idx索引加1。
- 如果元素大于或等于基準值,則不做任何操作,繼續遍歷下一個元素。
當遍歷到最后一個元素,也就是pivot時,遍歷結束:
- 最后將pivot元素和此時idx索引元素進行交換,完成這一輪分區操作。
- 此時pivot左側的元素一定都是小于基準值的。
- pivot右側的元素一定都是大于等于基準值的。
- 對基準值左右兩邊的子數組遞歸地執行以上步驟,直到每個子數組的大小減少到1或0,此時數組就被完全排序了。
?
#include <stdio.h> #include <time.h> #include <stdlib.h>#define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0])) #define SWAP(arr, i, j) { \int tmp = arr[i]; \arr[i] = arr[j]; \arr[j] = tmp; \ }// 打印數組的函數 void print_arr(int arr[], int left, int right) {for (int i = left; i <= right; i++) {printf("%d ", arr[i]);}printf("\n"); }// 分區核心操作實現,返回一輪快排選擇的pivot的下標 int partition(int arr[], int left, int right) {// 1.隨機選擇一個基準值,然后把它先放到數組末尾int pivot_idx = left + rand() % (right - left + 1); // 得到一個[left, right]范圍內的隨機索引int pivot = arr[pivot_idx];SWAP(arr, pivot_idx, right);// 2.設置一個partition_idx索引,指示小于pivot的元素應該插入的位置// 同時該索引最終表示分區的界限索引,所以命名為partition_idxint partition_idx = left;// 3.遍歷整個數組,當元素小于pivot時,將它和partition_idx位置元素交換,partition_idx加1// 希望遍歷結束時,i指向數組末尾的pivot,所以i < rightfor (int i = left; i < right; i++) {if (arr[i] < pivot) {SWAP(arr, i, partition_idx);partition_idx++;}}// 4.遍歷結束后,將pivot元素(最后一個元素)交換到partition_idx位置SWAP(arr, right, partition_idx);printf("此一輪分區操作,選擇的pivot是: %d\n分區結束后的數組是: ", pivot);print_arr(arr, left, right);// 5.返回基準值的位置索引return partition_idx; }/* * 輔助函數 * 用于對對[left, right]區間中的元素進行遞歸分區操作 */ void partition_recursion(int arr[], int left, int right) {// 遞歸出口if (left >= right) {return;}// 遞歸體int idx = partition(arr, left, right); // 分區操作,找到pivot元素的下標位置partition_recursion(arr, left, idx - 1);partition_recursion(arr, idx + 1, right); }void quick_sort_one_way(int arr[], int len) {// 初始化隨機數生成器,time(NULL)獲取當前時間戳// 用于生成隨機索引srand(time(NULL));// 調用輔助函數進行遞歸分解partition_recursion(arr, 0, len - 1); }int main(void) {// 測試單向分區快速排序int arr[] = { 8,3,2,6,9,5 };int len = ARR_SIZE(arr);quick_sort_one_way(arr, len);return 0; }
時間復雜度分析:平均時間復雜度就是O(nlogn)
空間復雜度分析:在最佳和平均情況下,遞歸深度大約是log2n,空間復雜度是O(logn)。但如果是在最壞情況下,遞歸深度接近n,此時空間復雜度為O(n)
穩定性:快速排序是一種不穩定的排序算法
雙向分區
比起單向分區,雙向分區是更常用的快排分區策略,一般而言當我們提起快速排序,指的都是雙向分區策略的快速排序。
所謂雙向分區,指的是在分區過程中,元素比較和交換操作的方向是,同時從數組的兩端向中間逼近的。
雙向分區快速排序算法,具體而言,它的思路是:
- 選擇基準值pivot,基準值可以是一個隨機元素,也可以選擇一個固定元素。然后將基準值元素和首元素交換,這樣做的目的是為了將交換元素操作優化成一個賦值操作。并且要將基準值存儲起來。
設置兩個索引 low 和 high :
- 索引 low 一開始指向數組首元素,它的含義是指示小于基準值的元素應該置于的位置。
- 索引 high 一開始指向數組尾元素,它的含義是指示大于等于基準值的元素應該置于的位置。
率先移動索引high,它從尾元素開始向左移動,目標是找到第一個小于基準值的元素:
- 找到該元素后,直接將該元素賦值給low索引位置,也就是覆蓋掉基準值。
- 賦值結束后,low索引和high索引都不需要移動。
然后向右移動索引 low,找到第一個大于等于基準值的元素:
- 找到該元素后,直接將該元素賦值給high索引位置
- 賦值結束后,low索引和high索引都不需要移動。
- 重復過程3和4,直到索引high和low相遇。最后將基準值放入它們相遇的位置。
- 于是分區就結束了,基準值到達了排序的最終位置,基準值左邊都是小于基準值的元素,右邊都是大于等于基準值的元素。
- 對基準值左右兩邊的子數組遞歸地執行以上步驟,直到每個子數組的大小減少到1或0,此時數組就被完全排序了。
#include <stdio.h> #define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))// 打印數組的函數 void print_arr(int arr[], int left, int right) {for (int i = left; i <= right; i++) {printf("%d ", arr[i]);}printf("\n"); }// 快速排序的核心操作: 雙向分區, 也就是確定pivot的最終位置 // 挑選一個基準值,通過雙向分區操作,決定最終的位置,最終位置就是基準值排好序的位置 static int partition(int arr[], int left, int right) {// 1.為了簡化實現,直接挑選首元素為基準值(因為基準值要交換到開頭,所以直接挑選首元素作為基準值,可以減少一步交換)int pivot = arr[left];// 2.初始化兩個索引low和high,分別指向數組兩端int low = left, high = right;// 3.循環遍歷這個數組區間while (low < high) { // 兩個索引沒有相遇就繼續循環// 在兩個索引沒有相遇的情況下,high索引用于尋找比基準值小的元素while (low < high && arr[high] >= pivot) {high--;} // while循環結束時,要么兩個索引相遇了,要么high索引已經找到了一個比基準值小的元素arr[low] = arr[high]; // 將這個比基準值小的元素覆蓋到low位置//low++; 該行語句不能加,因為若此時兩個索引相遇結束while,low++將導致相遇的索引不再相遇// 在兩個索引沒有相遇的情況下,low索引用于尋找比基準值大和相等的元素while (low < high && arr[low] < pivot) {low++;} // while循環結束時,要么兩個索引相遇了,要么low索引已經找到了一個比基準值大或相等的元素arr[high] = arr[low]; // 將這個比基準值大或相等的元素覆蓋到high位置//high--; 該行語句不能加,因為若此時兩個索引相遇結束while,high--將導致相遇的索引不再相遇} // while循環結束時,說明low和high索引相遇,此時該位置就是pivot應該放置的位置arr[low] = pivot;printf("此一輪分區操作選擇的pivot = %d\n", pivot);print_arr(arr, left, right);return low; }// 對[left, right]區間進行遞歸分區操作 void partition_recursion(int arr[], int left, int right) {// 遞歸出口if (left >= right) {return;}// 遞歸體int idx = partition(arr, left, right); // 分區操作,找到pivot下標位置partition_recursion(arr, left, idx - 1);partition_recursion(arr, idx + 1, right); }void quick_sort_two_way(int arr[], int len) {partition_recursion(arr, 0, len - 1); }int main(void) {int arr[] = { 8,3,2,6,9,5 };int len = ARR_SIZE(arr);// 測試雙向分區-快速排序quick_sort_two_way(arr, len);return 0; }
時間復雜度分析:平均時間復雜度就是O(nlogn)
空間復雜度分析:在最佳和平均情況下,遞歸深度大約是log2n,空間復雜度是O(logn)。但如果是在最壞情況下,遞歸深度接近n,此時空間復雜度為O(n)
穩定性:快速排序是一種不穩定的排序算法
?堆排序
上述堆排序算法在具體實現時,要分為兩個步驟:
- 將待排序的原始數組,在邏輯上進行第一次堆化的操作
- 將大頂堆的根結點元素移到數組末尾,交換首尾元素,邏輯上堆大小減1,以新的根結點進行堆化操作。
這兩個步驟中的核心操作邏輯都是——堆化。
堆化的過程,其實就是自父結點開始,向下檢查左右子樹和這個父結點大小關系的過程:
- 如果左子樹大于父結點,那么交換左子樹和父結點
- 如果右子樹大于父結點,那么交換右子樹和父結點
- 如果出現了交換,那么被交換的左子樹或右子樹就要重新進行堆化操作。
- 如果根結點已經是最大值(相等的最大值也算),沒有交換,那么堆化結束。
?
#include <stdio.h> #define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0])) #define SWAP_ELEMENT(arr, i, j){ \int tmp = arr[i]; \arr[i] = arr[j]; \arr[j] = tmp; \ }void print_arr(int arr[], int n) {for (int i = 0; i < n; i++) {printf("%d ", arr[i]);}printf("\n"); }// 該函數會把以root_idx索引元素為根結點的 // 邏輯長度是tree_len的一棵完全二叉樹arr,構建成一個大頂堆 static void heapify(int arr[], int tree_len, int root_idx) {/*堆化操作必然是需要循環來完成的如果對于某個循環,既不清楚循環的次數,循環結束的條件也不太好找到那么可以先寫一個死循環, 然后具體到代碼中再用break,return等結束循環*/while (1) {// 根據根節點的下標,先計算出左右子樹的下標int lchild_idx = (root_idx << 1) + 1;int rchild_idx = (root_idx << 1) + 2;int max_idx = root_idx; // 先假設根節點就是最大值if (lchild_idx < tree_len && arr[lchild_idx] > arr[max_idx]) {// 如果左子樹存在且左子樹值比假設的最大值要大,那么左子樹下標就是新的最大值下標max_idx = lchild_idx;}if (rchild_idx < tree_len && arr[rchild_idx] > arr[max_idx]) {// 如果右子樹存在且右子樹值比假設的最大值要大,那么右子樹下標就是新的最大值下標max_idx = rchild_idx;}if (max_idx != root_idx) {// 交換左右子樹較大者和根節點的值SWAP_ELEMENT(arr, max_idx, root_idx);// 此時max_idx結點的值就是以前根節點的值,此時由于數據發生了改變,max_idx結點的樹就不一定是大頂堆了// 所以接下來要以max_idx為根節點,繼續構建大頂堆root_idx = max_idx;}else {// 不需要交換了,說明以root_idx為根節點的樹已經是大頂堆了break;}} }// 第一次將數組構建成大頂堆,自下而上將每一個非葉子結點構建大頂堆 static void first_build_heap(int arr[], int len) {int last_idx = len - 2 >> 1; //最后一個非葉子結點的下標for (int i = last_idx; i >= 0; i--) {heapify(arr, len, i);}printf("第一次堆化后數組為: \n");print_arr(arr, len); } void heap_sort(int arr[], int len) {// 1.將原arr數組構建成大頂堆,第一次構建大頂堆first_build_heap(arr, len);// 2.反復移除根結點元素,然后再重建大頂堆int heap_len = len; // 堆邏輯上的長度,一開始就是數組長度,隨著反復移除重建大頂堆,這個長度會一直減少1while (heap_len > 1) { // 只要堆還有兩個元素就需要繼續構建移除SWAP_ELEMENT(arr, 0, heap_len - 1);heap_len--;/*堆排序的核心操作:重新構建大頂堆*/heapify(arr, heap_len, 0); // 堆排序核心操作:堆化printf("重新構建大頂堆后: \n");print_arr(arr, heap_len);} }int main(void) {int arr[] = { 4, 10, 3, 5, 1 };int len = ARR_SIZE(arr);heap_sort(arr, len);return 0; }
時間復雜度:堆排序在任何情況下,時間復雜度都是O(nlogn)。
空間復雜度:堆排序顯然是一個原地算法,不需要任何額外內存空間,空間復雜度是O(1)
穩定性:堆排序也是一種不穩定的排序算法,在堆化的過程需要交換父節點和左右子樹結點,這個過程非常容易出現改變相同元素位置的情況。
幾種排序算法的應用場景
- 選擇排序:建議任何情況都不用。
- 冒泡排序:建議任何情況都不用。
- 插入排序:適合小數據集,尤其當數據已基本有序時非常好用。
- 希爾排序:一般不使用。
- 歸并排序:大數據集的場景下,需要穩定排序算法時使用。
- 快速排序:大數據集的場景下,通用的排序算法,效率高,但不穩定。
- 堆排序:大數據集的場景下,性能均衡的不穩定排序算法,優點是不占用額外內存空間。