資料結構知識整理 - 排序演算法(本篇包括折半插入排序、快速排序以及堆排序)
主要內容
前提
排序是計算機程式設計中的一種重要操作,在很多領域中都有著廣泛的應用。
排序的一個主要目的是便於查詢。在前兩篇關於查詢的博文中提到的折半查詢(要求有序的順序表)和樹表查詢(二叉排序樹結構)都涉及到排序演算法。
人們設計了大量的排序演算法以滿足不同的需求。
著名電腦科學家D.E.Knuth在他的鉅著《計算機程式設計藝術》第三卷《排序與查詢》中,給出了25種排序方法,然而這還只是現有排序方法中的冰山一角。(人類真是nb......)
初步認識排序
將“無序”的資料元素,通過一定的方法按關鍵字順序排列的過程叫做排序。
排序又分為內部排序和外部排序:若整個排序過程不需要訪問外存便能完成,則稱此類排序問題為內部排序;反之,若參加排序的記錄數量很大,整個序列的排序過程不可能在記憶體中完成,則稱此類排序問題為外部排序。
內部排序是一個逐步擴大記錄的有序序列長度的過程。在排序過程中,可以將排序記錄區分為兩個:有序序列區和無序序列區。使有序序列區中記錄的數目增加一個或幾個的操作稱為一趟排序。
常見的內部排序可分類為:
1)插入類:將無序子序列中的一個或幾個記錄“插入”到有序序列中。主要包括直接插入排序
2)交換類:通過“交換”無序序列中的記錄,從而得到其中關鍵字最大或最小的記錄,並將它加入有序子序列中,以此方法增加記錄的有序子序列長度。主要包括氣泡排序和快速排序。
3)選擇類:從記錄的無序子序列中“選擇”關鍵字最小或最大的記錄,並將它加入有序子序列中,以此方法增加記錄的有序子序列長度(沒錯,跟交換類的描述幾乎相同)。主要包括簡單選擇排序、樹形選擇排序和堆排序。
4)歸併類:通過“歸併”兩個或兩個以上的記錄的有序子序列,逐步增加有序序列長度。常見有2-路歸併排序。
5)分配類:唯一一種不需要比較關鍵字的排序方法,排序時主要利用分配和收集兩種基本操作來完成。常見有基數排序
排序還可以根據穩定性分為兩類;
1)穩定排序:
假設在待排序的檔案中,存在兩個或兩個以上的記錄具有相同的關鍵字,在用某種排序法排序後,若這些相同關鍵字的元素的相對次序仍然不變,則這種排序方法是穩定的。其中氣泡排序,直接插入排序,折半插入排序,基數排序,歸併排序等屬於穩定排序。
2)不穩定排序:
與穩定排序的結果相反則為不穩定排序。其中選擇類排序,快速排序,希爾排序屬於不穩定排序。
(本篇對於各排序演算法的時間複雜度和空間複雜度暫不作分析)
待排序記錄表的結構定義
(示例程式碼中的待排序記錄均以順序表為儲存方式,且關鍵字設為整型資料)
#define MAXSIZE 20 /*順序表的最大長度*/
typedef int KeyType /*定義關鍵字型別為整型,即KeyType key等價於int key*/
/*與構造查詢表類似*/
typedef struct
{
KeyType key;
OtherType other;
} RecordType; /*記錄型別*/
typedef struct
{
RecordType rcds[MAXSIZE+1] /*rcds[0]用作監視哨或閒置,可回顧線性表查詢*/
int length;
} SqList; /*順序表型別*/
折半插入排序(Binary Insertion Sort)
插入排序的基本思想:每一趟排序中,將一個待排序記錄按其關鍵字大小插入到“有序”記錄的適當位置,直到所有待排序記錄全部插入為止。
直接插入排序是最簡單的排序方法,它採用順序查詢表查詢待排序記錄在有序序列上的插入位置,而“查詢”操作可利用“折半查詢”來實現,以“折半查詢法”查詢插入位置的排序則稱為折半插入排序。
<思路>
1)rcds[0]作監視哨或閒置,r[1]只有一個記錄,不需要排序,所以排序從rcds[2]開始;
2)比較rcds[1]和rcds[2]的關鍵字大小,若rcds[1]的關鍵字大於rcds[2],則將rcds[1]後移一位,將首元素的位置讓給rcds[2];反之,若rcds[2]的關鍵字更大,則保持rcds[2]原來的位置;
3)同(2)可以推理得出,若帶插入記錄rcds[i]的關鍵字大於rcds[j],小於rcds[j+1],則將rcds[j+1]到rcds[i-1]的元素後移一位,讓出rcds[j+1]的位置給rcds[i];反之,若rcds[i]的關鍵字大於有序序列中的所有記錄,則保持rcds[i]原來的位置;
4)由(3)可知,rcds[0]不僅可用作監視哨,還可以暫存待排序記錄的資訊。
程式碼如下(可回顧“折半查詢”):
void Bin_Insert_Sort(SqList &L)
{
for(int i = 2; i <= L.length; i++) /*從rcds[2]開始插入排序*/
{
L.rcds[0] = L.rcds[i]; /*設定監視哨並暫存待排序記錄的資訊*/
int low = 1, high = i - 1; /*折半查詢的範圍是從1到i-1*/
while(low <= high) /*折半查詢rcds[i]的插入位置*/
{
int mid = (low + high) / 2;
if(L.rcds[0].key < L.rcds[mid].key) high = mid - 1;
else low = mid + 1;
}
/*經過折半排序可將rcds[i]的位置確定在rcds[high+1],在草稿紙上推理一下*/
for(int j = i-1; j >= high+1; j--) /*關鍵字更大的記錄後移一位*/
L.rcds[j+1] = L.rcds[j];
L.rcds[high+1] = L.rcds[0]; /*插入*/
}
快速排序(Quick Sort)
交換排序的基本思想:兩兩比較待排序記錄的關鍵字,一旦發現兩個記錄不滿足次序要求時則進行交換,直到整個序列全部滿足要求為止。
氣泡排序(Bubble Sort)是一種最簡單的交換排序方法,它通過兩兩比較相鄰記錄的關鍵字,逆序則交換,從而使關鍵字較小的記錄如氣泡一般逐漸往上“漂浮”(左移),或者使關鍵字較大的記錄如石塊一般“沉落”(右移)。從右往左進行交換更接近“冒泡”的含義,而從左往右交換更像是“沉石”的過程。(可回顧“這裡”)
在氣泡排序中,需要安排一個變數flag表示排序迴圈結束的標誌。flag = 0表示在本趟排序中沒有發生“交換”,即排序已經完成;反之,若flag = 1,繼續進行排序。
快速排序由氣泡排序改進得到。氣泡排序中只能比較相鄰的記錄,所以每次“交換”只能消除一個逆序。而快速排序能夠比較兩個不相鄰的記錄,通過一次“交換”消除多個逆序,從而大大加快排序的速度。
<邏輯思路>
1)在待排序的n個記錄中選擇任意一個記錄(通常選擇第一個記錄)作為樞軸(支點),設其關鍵字為pivotkey。經過一趟排序後,所有關鍵字小於pivotkey的記錄“交換”到序列前面,所有關鍵字大於pivotkey的記錄交換到序列後面,最後將樞軸記錄插在中間;
2)不斷重複上述過程,直至每一部分只包含一個記錄。
<實現思路>
1)在待排序序列首尾設定指標low、high。初始化時,指標low指向rcds[1],指標high指向rcds[L.length];
2)將rcds[1]的資訊暫存在rcds[0],此時rcds[1]包含的記錄作為樞軸,rcds[1]的位置作為空位;
3)指標low從左往右查詢關鍵字大於pivotkey的記錄,指標high從右往左查詢關鍵字小於pivotkey的記錄;
4)因為空位在前半部分,所以指標high首先開始查詢關鍵字小於pivotkey的記錄。假設找到的是rcds[n]上的記錄,找到後將其記錄存放在空位,儲存後空位便由rcds[1]改為rcds[n],即空位出現在後半部分;
5)指標low開始查詢關鍵字大於pivotkey的記錄,並將記錄存放在後半部分的空位,同時“生成”新的空位;
6)不斷重複(4)(5)的過程,直至low == high,而且此時rcds[low](或rcds[high])正是樞軸記錄的存放位置。
7)對前後部分分別重複上述操作,直至所有部分都只包含一個記錄(遞迴)。
/*--------在一趟排序中切分前後部分,並返回樞軸位置--------*/
int Slice_n_Pivot(SqList &L, int low, int high)
{
L.rcds[0] = L.rcds[1]; /*暫存樞軸記錄*/
int pivotkey = L.rcds[1].key; /*初始化pivotkey*/
while(low < high) /*當low未與high重合時*/
{
/*指標high從右往左移動,找到(第一個)關鍵字小於pivotkey的記錄*/
while((low < high) && (L.rcds[high].key >= pivotkey)) high--;
L.rcds[low] = L.rcds[high]; /*將找到的記錄存放在前半部分的空位,同時得到新的空位*/
/*指標low從右往左移動,找到(第一個)關鍵字大於pivotkey的記錄*/
while((low < high) && (L.rcds[low].key <= pivotkey)) low++;
L.rcds[high] = L.rcds[low];
}
L.rcds[low] = L.rcds[0]; /*或L.rcds[high] = L.rcds[0];*/
return low; /*返回樞軸的位置,作為前半部分指標high和後半部分指標low的依據*/
}
/*----------在前後部分中遞迴-----------*/
void Recursive(SqList &L, int low, int high)
{
if(low < high)
{
pivot = Slice_n_Pivot(L, low, high);
/*因為在每次遞迴中low與high的值都會發生相應的變化,所以不需要再初始化實參*/
Recursive(L, low, pivot-1); /*在前半部分遞迴排序*/
Recursive(L, pivot+1, high); /*在後半部分遞迴排序*/
}
}
/*-----------總結快速排序------------*/
void Quick_Sort(SqList &L)
{
Recursive(L, 1, L.length);
}
堆排序(Heap Sort)
選擇排序的基本思想:每一趟從待排序記錄中選出關鍵字最小的記錄,按順序放在已排序的記錄序列最後,直到全部記錄排序好為止。
堆排序是一種樹形選擇排序,在排序過程中,將儲存待排序記錄的順序表看成是一棵完全二叉樹(區別滿二叉樹)的順序儲存結構。利用完全二叉樹中父結點與子結點的內在關係,在當前無序的序列中選擇關鍵字最小(或最大)的記錄。
<條件>
1)堆的定義:
以任意結點為根結點,根結點的關鍵字都大於(或小於)左、右子樹的根結點。
若堆頂記錄的關鍵字最大,則稱堆為大根堆,反之稱小根堆。
2)完全二叉樹的定義:
所有序號大於[n/2](n為結點數)的結點都是葉結點。
因此只需要依次將以[n/2]、[n/2-1]、...、[1]的結點作為根結點的子樹調整為堆即可。
<思路>
(以大根堆為例)
1)調整一個小堆:
設根結點為rcds[s],左、右子樹根結點分別為rcds[2s]、rcds[2s+1]。
先比較左、右子樹根結點的關鍵字,若較大值為右子樹根結點,則右子樹根結點與根結點比較。若根結點的關鍵字更大,則該小堆滿足要求;若左、右子樹根結點中較大的關鍵字更大,則交換兩個結點。
但是,交換結點後,可能會出現(原)根結點的關鍵字小於(原)右子樹根結點的左、右子樹根結點(rcds[2(2s+1)]、rcds[2(2s+1)+1]))的情況,這時候需要繼續進行調整,即需要迴圈或遞迴。
2)利用完全二叉樹的特性,從[n/2]結點開始往前調整小堆;
3)經過上面兩步只能使一個無序序列剛好構成堆的條件,還未能稱為有序序列。但我們可以利用堆的特性,將每次調整完得到的堆頂記錄(關鍵字最大)放到最後(依次由[L.length]、[L.length-1]、...),這樣就能得到一個從左往右,關鍵字從小到大的有序序列。
/*------------調整一個小堆-------------*/
void Adjust_Small_Heap(SqList &L, int s, int m) /*s為調整根結點,m為序號最大的結點*/
{
L.rcds[0] = L.rcds[s]; /*暫存根結點的記錄*/
for(int i = 2 * s; i <= m; i *= 2) /*定位結點的左子樹根結點*/
{
if((j < m) && (L.rcds[i] < L.rcds[i+1])) i++; /*如果左小於右,用i表示右*/
if(L.rcds[0] > L.rcds[i]) break; /*如果根大於較大的結點,滿足堆的條件,跳出迴圈*/
L.rcds[s] = L.rcds[i]; s = i; /*如果沒有在上一步跳出迴圈,交換結點後繼續判斷是否滿足堆的條件*/
}
L.rcds[s] = L.rcds[0]; /*經過上面的迴圈後,s表示的位置可能已經改變,需要重新插入(原)根結點的記錄*/
}
/*----------利用完全二叉樹的特性------------*/
void UseCBT(SqList &L) /*CBT->Completed Binary Tree,完全二叉樹*/
{
int m = L.length;
for(int s = n/2; s >=1; i--)
Adjust_Small_Heap(L, s, m);
}
/*-------------將大根堆有序化--------------*/
void Heap_Sort(SqList &L)
{
UseCBT(L); /*將無序序列轉化成大根堆*/
for(int i = L.length; i > 1; i--)
{
int t = L.rcds[1]; /*將堆頂記錄往後存放*/
L.rcds[1] = L.rcds[i];
L.rcds[i] = t;
Adjust_Small_Heap(L, 1, i-1); /*交換結點後需要調整以rcds[1]為根結點的堆*/
}
}
路過的圈毛君:“......”