大話數據結構學習筆記 - 排序算法及圖解以及C
實現
概述
排序概念
假設含有
n
個記錄的序列爲 , 其相應的關鍵字分別爲 , 需確定1, 2, ... , n
的一種排列 , 使其相應的關鍵字滿足 (非遞減或非遞增)關係,即使的序列稱爲一個按關鍵字有序的序列 , 這樣的操作就稱爲排序
排序的穩定性
假設 , 且在排序前的序列中 領先於 (即 。 如果排序後 仍領先於 , 則稱所用的排序方法是穩定的; 反之,若可能使得排序後的序列中 領先於 , 則稱所用過得排序方法是不穩定的。
內排序與外排序
根據在排序過程中待排序的記錄是否全部被放置在內存中, 排序分爲:內排序和外排序
內排序是在排序整個過程中, 待排序的所有記錄全部被放置在內存中。外排序是由於排序的記錄個數太多,不能同時放置在內存,整個排序過程需要在內外寸之間多次交換數據才能進行
內排序算法的性能主要受3
個方面影響
- 時間性能:內排序主要進行兩種操作: 比較和 移動, 高效的算法應儘可能減少比較次數和移動次數
- 輔助空間:即除了存放待排序所佔用的存儲空間外,算法執行所需要的其他存儲空間
- 算法複雜性:是指算法本身的複雜度,而不是算法的時間複雜度
算法實現前提
順序表結構
該結構用於後續學習的所有排序算法
#define MAXSIZE 10 // 用於待排序數組個數的最大值, 可根據需要修改
typedef struct
{
int r[MAXSIZE + 1]; // 用於存儲待排序數組, r[0] 用作哨兵或臨時變量
int length; // 用於記錄順序表的長度
}SqList;
交換算法
用於交換數組兩元素的值
/*
* 交換 L 中數組 r 的下標爲 i 和 j 的值
*/
void swap(SqList *L, int i, int j)
{
int temp = L->r[i];
L->r[i] = L->r[j];
L->r[j] = temp;
}
冒泡排序
思想
在要排序的一組數中,對當前還未排好序的範圍內的全部數,自上而下對相鄰的兩個數依次進行比較和調整,讓較大的數往下沉,較小的往上冒:即每當兩相鄰的數比較後發現他們的排序與排序要求相反時,就交換
操作步驟
- 比較相鄰的元素,如果第一個比第二個大,就交換
- 對每一對相鄰元素做同樣的工作,從開始第一對到結尾的最後一對,每次循環都會把最大的數放在最後
- 針對所有的元素重複以上的步驟,除了已經排好序的
- 持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較
複雜度分析
- 時間複雜度 :
代碼
/*
* 冒泡排序
*/
void BubbleSort(SqList *L)
{
for(int i = 1; i < L->length; i++)
{
// 注意 j 是從前往後循環, 也可以從後往前循環, 此處的 -i + 1 是爲了每次減少比較的次數,因爲每次循環都會把當前的最大數放置在其位置
for(int j = 1; j < L->length - i + 1; j++)
if(L->r[j] > L->r[j + 1]) // 若前者大於後者
swap(L, j, j + 1); // 交換元素值
}
}
直接插入排序
思想
簡單選擇排序(Simple Selection Sort
)就是通過n - i
次關鍵字間的比較,從n - i + 1
個記錄中選出關鍵字最小的記錄, 並和第 個記錄交換
複雜度
交換移動數據次數相當少,且最好做茶情況比較次數一樣多
- 時間複雜度 : , 雖然與冒泡一樣,但要略優於冒泡
代碼
/*
* 簡單選擇排序
*/
void SelectSort(SqList *L)
{
for(int i = 1; i < L->length; i++)
{
int min = i;
for(int j = i + 1; j <= L->length; j++) // 每次從身後的元素中選擇出最小值
if(L->r[j] < L->r[min])
min = j;
if(i != min)
swap(L, i, min); // 交換最小值到當前座標
}
}
直接插入排序
思想
直接插入排序(Straight Insertion Sort
)的基本操作是將一個記錄插入到已經排好序的有序表中,從而得到一個新的、記錄數增1
的有序表
複雜度
- 時間複雜度: 平均爲 , 但要比冒泡和簡單選擇排序性能要好一些
- 空間複雜度: 一個哨兵,故爲
希爾排序
思想
基本有序: 即小的關鍵字基本在前面,大的基本在後面,而不大不小的基本在 中間, 類似於{2,1,3,6,4,7,5,8,9}
, 而像{1,5,8,3,7,8,2,4,6}
只能算局部有序,不是基本有序
希爾排序就是通過採取跳躍分割的策略,將相距某個 增量的記錄組成一個子序列, 這樣才能保證在子序列內分別進行直接插入排序後得到的結果是基本有序而不是局部有序
複雜度
- 時間複雜度: 希爾排序的時間複雜度依賴於 增量的選取, 時間複雜度爲最好爲
代碼
/**
* 希爾排序
* @param L
* @param s
* @param m
*/
void ShellSort(SqList *L)
{
int increment = L->length;
do
{
increment = increment / 3 + 1; // 增量序列
for(int i = increment + 1; i <= L->length; i++)
{
if(L->r[i] < L->r[i - increment])
{
// 需將 L->r[i] 插入有序增量子表
int j;
L->r[0] = L->r[i]; // 暫存在 L->r[0]
for(j = i - increment; j > 0 && L->r[j] > L->r[0]; j -= increment)
L->r[j + increment] = L->r[j]; // 記錄後移, 查找插入位置
L->r[j + increment] = L->r[0]; // 插入
}
}
}
while(increment > 1);
}
堆排序
堆是具有下列性質得到完全二叉樹
- 大頂堆: 每個結點的值都大於或等於其左右孩子結點的值
- 小頂堆: 每個結點的值都小於或等於其左右孩子結點的值
堆排序主要是藉助堆來實現的選擇排序,首先實現堆排序需要解決兩個問題
如何由一個無序序列構建成一個堆?
使用數組表示一個堆的元素,由初始的無序數組構建堆只需要自底向上從第一個非葉元素開始依次調整成一個堆
如何在輸出堆頂元素後,調整剩餘元素成爲一個新的堆?
首先將堆頂元素和未排序的子序列最後一個元素交換,然後比較堆頂元素的左右孩子節點,因爲除了堆頂元素,左右孩子都滿足堆條件,故只需讓堆頂元素與左右孩子的較大者(大頂堆)交換,直至葉子節點。
複雜度
堆排序是一種不穩定的排序方法,且初始構建堆所需的比較次數較多,故不適合待排序序列個數較少的情況
- 時間複雜度: 總體來說爲 , 無論最好、最壞還是平均都是
- 空間複雜度: 僅用一個交換的暫存單元, 爲
代碼
/*
* 構建大頂堆: 已知 L->r[s ... m]中記錄的關鍵字除 L->r[s] 之外均滿足堆的定義
* 本函數挑中 L->r[s] 的關鍵字, 使 L->r[s ... m]稱爲一個大頂堆
*/
void HeapAdjust(SqList *L, int s, int m)
{
int temp = L->r[s];
for(int j = s * 2; j <= m; j *= 2) // 沿關鍵字較大的孩子節點向下篩選
{
if(j < m && L->r[j] < L->r[j + 1])
++j; // j 爲關鍵字中較大的記錄下標
if(temp >= L->r[j])
break; // 表示父節點比孩子節點大
L->r[s] = L->r[j]; // 將孩子節點中最大值插入其父節點
s = j;
}
L->r[s] = temp; // 插入孩子節點或子孫結點
}
/*
* 堆排序
*/
void HeapSort(SqList *L)
{
for(int i = L->length / 2; i >= 1; i--) // 把 L 中的 r 構建成一個大頂堆
HeapAdjust(L, i, L->length);
for(int i = L->length; i > 1; i--)
{
swap(L, 1, i); // 將對頂記錄和當前未經排序子序列的最後一個記錄交換
HeapAdjust(L, 1, i - 1); // 將 L->r[1 ... i - 1] 重新調整爲大頂堆
}
}
歸併排序
思想
歸併排序(Merging Sort
)就是利用歸併的思想實現的排序方法。假設初始序列含有n
個記錄,則可以看成n
個有序的子序列, 每個子序列的長度爲1
,然後兩兩歸併,得到 ( 表示不小於x
的最小整數)個長度爲2
或1
的有序子序列; 再兩兩歸併, …… , 如此重複, 直至得到一個長度爲n
的有序序列爲止, 這種排序方法稱爲2
路歸併排序
複雜度
- 時間複雜度: 每次歸併都要將相鄰的有序序列進行兩兩歸併,故耗費 , 完全二叉樹的深度爲 , 故總的時間複雜度爲 , 而且是最好、最壞、【平均的時間性能
- 空間複雜度: 歸併排序在歸併過程需要與原始記錄序列同樣數量的存儲空間存放歸併結果以及遞歸深度爲 的棧空間,故時間複雜度爲
代碼
/**
* 歸併操作, 將 arr[left ... mid] 和 arr[mid + 1 ... right] 歸併
* 該方法先將所有元素複製到輔助數組 temp 中, 然後再歸併到 arr[] 中
* @param arr 待歸併數組
* @param left 起始位置
* @param mid 中間位置
* @param right 結束位置
*/
void merge(int arr[], int left, int mid, int right)
{
int i = left, j = mid + 1, k = 0, temp[right - left + 1];
while(i <= mid && j <= right) // 將左半邊和右半邊按大小依次放入 temp 中
{
if(arr[i] <= arr[j])
temp[k++] = arr[i++];
else
temp[k++] = arr[j++];
}
while(i <= mid) // 若左半邊留有元素, 則將剩餘元素全部放入 temp 中
temp[k++] = arr[i++];
while(j <= right) // 若右半邊留有元素, 則將剩餘元素全部放入 temp 中
temp[k++] = arr[j++];
for(int p = 0; p < k; p++) // 將排序好的所有元素放回 arr數組, left + p 表示 arr[left, right] 區域
arr[left + p] = temp[p];
}
/**
* 歸併排序 排序操作
* @param arr 待排序數組
* @param left 起始位置
* @param right 結束位置
*/
void mSort(int arr[], int left, int right)
{
if(right <= left) return;
int mid = left + (right - left) / 2;
mSort(arr, left, mid); // 遞歸排序左半邊
mSort(arr, mid + 1, right); // 遞歸排序右半邊
merge(arr, left, mid, right); // 歸併
}
/**
* 歸併排序
* @param L
*/
void MergeSort(SqList *L)
{
// 將 L 中的數組 r 歸併排序, 範圍是 [1, L->length]
mSort(L->r, 1, L->length);
}
快速排序
思想
快速排序(Quick Sort
) 的基本思想是:通過一趟排序將待排記錄分割成獨立的兩部分,其中一部分記錄的關鍵字均比另一部分記錄的關鍵字小,則可分別對這兩部分記錄繼續進行排序,已達到整個序列有序的目的
複雜度
時間複雜度
- 最優情況下,即
Partition
每次都均勻劃分序列爲兩部分,故若排序n
個關鍵字,則遞歸樹深度爲 ( 表示不大於x
的最小整數, 故僅需遞歸 次。假設時間爲 , 每次Partition
都會掃描整個數組,即n
次比較, 然後將數組一分爲二,則兩部分各自需要 時間, 推導公式如下
故最優情況下,時間複雜度爲
最壞情況下,待排序的序列爲正序或逆序時,每次劃分都只會得到一個比上一次劃分少一個記錄的子序列,另一個子序列爲空。此時的遞歸樹爲一棵斜樹,故需要執行
n - 1
次遞歸調用, 且第i
次劃分需要比較n - i
次,故最終時間複雜度爲平均時間複雜度爲
- 最優情況下,即
空間複雜度: 遞歸造成的棧空間,最好情況,遞歸樹深度爲 , 故空間複雜度爲 ; 最壞情況,需要進行
n - 1
次遞歸調用,空間複雜度爲 , 平均爲
代碼
/* 快速排序******************************** */
/**
* 交換順序表 L 中子表的記錄, 使得樞軸確定其位置, 並返回座標
* 此時在樞軸左邊的元素都不大於它, 在其右邊的都不小於它
* @param L 包含子表的順序表 L
* @param low 起始位置
* @param high 結束位置
* @return 樞軸位置
*/
int Partition(SqList *L, int low, int high)
{
int pivotkey = L->r[low]; // 用待排序序列的第一個記錄做樞軸記錄
while(low < high) // 從序列的兩端交替的向中間掃描
{
while(low < high && L->r[high] > pivotkey) // 從後向前, 獲取小於樞軸記錄的記錄位置
--high;
swap(L, low, high); // 將比樞軸記錄小的記錄交換到首端
while(low < high && L->r[low] < pivotkey) // 從前向後, 獲取大於樞軸記錄的記錄位置
++low;
swap(L, low, high); // 將比樞軸記錄大的記錄交換到尾端
}
return low; // 返回樞軸所在位置
}
/**
* 快速排序
* @param L 包含子表的順序表 L
* @param low 起始位置下標
* @param high 結束位置下標
*/
void QSort(SqList *L, int low, int high)
{
if(low < high)
{
int pivot = Partition(L, low, high); // 將 L->r[low ... high] 一分爲二, 算出樞軸記錄下標 pivot
QSort(L, low, pivot - 1); // 對首端子表遞歸排序
QSort(L, pivot + 1, high); // 對尾端子表遞歸排序
}
}
/**
* 快速排序驅動程序
* @param L 包含子表的順序表 L
*/
void QuickSort(SqList *L)
{
QSort(L, 1, L->length);
}
/* **************************************** */
優化快速排序算法
- 優化選取樞軸: 三位取中法(
median-of-three
) 即取三個關鍵字先進行排序,將中間數作爲樞軸,一般是取左端、右端和中間三個數 - 優化不必要交換: 將樞軸元素直接替換到其最終的位置,而非交換
- 優化小數組的排序方案: 若數組非常小,快速排序性能還不如直接插入排序,原因在於快排使用了遞歸操作
改進快速排序算法代碼
/* 改進後快速排序******************************** */
/**
* 改進後快速排序:交換順序表 L 中子表的記錄, 使得樞軸確定其位置, 並返回座標
* 此時在樞軸左邊的元素都不大於它, 在其右邊的都不小於它
* @param L 包含子表的順序表 L
* @param low 起始位置
* @param high 結束位置
* @return 樞軸位置
*/
int Partition1(SqList *L, int low, int high)
{
/* 三位取中法, 即取三個關鍵字先進行排序, 將中間數作爲樞軸, 一般是取左端、中間和右端三個數 */
int middle = low + (high - low) / 2; // 計算數組中間的元素下標
if(L->r[low] > L->r[high])
swap(L, low, high); // 交換左端與右端數據, 保證左端較小
if(L->r[middle] > L->r[high])
swap(L, middle, high); // 交換中間與右端數據, 保證中間較小
if(L->r[low] < L->r[middle])
swap(L, low, middle); // 交換左端與中間數據, 保證中間最小, 左端居中
int pivotkey = L->r[low]; // 將三位取中後的中間數作爲樞軸記錄
L->r[0] = pivotkey; // 將樞軸關鍵字保存在 L->r[0]
while(low < high) // 從表的兩端交替向中間掃描
{
while(low < high && L->r[high] >= pivotkey)
--high;
L->r[low] = L->r[high]; // 採用替換而不是交換的方式進行操作
while(low < high && L->r[low] <= pivotkey)
++low;
L->r[high] = L->r[low]; // 採用替換而不是交換的方式進行操作
}
L->r[low] = L->r[0]; // 將樞軸數值替換回 L->r[low]
return low; // 返回樞軸下標
}
/**
* 改進快速排序:大數據使用快速排序, 小數據量使用直接插入排序
* 使用尾遞歸即迭代的方式減少遞歸深度
* @param L 包含子表的順序表 L
* @param low 起始位置下標
* @param high 結束位置下標
*/
void QSort1(SqList *L, int low, int high)
{
if((high - low) >= MAX_LENGTH_INSERT_SORT)
{
while(low < high)
{
int pivot = Partition1(L, low, high); /* 將L->r[low..high]一分爲二,算出樞軸值pivot */
QSort(L, low, pivot - 1); /* 對低子表遞歸排序 */
low = pivot + 1; /* 尾遞歸 */
}
}
else
InsertSort(L);
}
/**
* 改進快速排序驅動程序
* @param L 包含子表的順序表 L
*/
void QuickSort1(SqList *L)
{
QSort1(L, 1, L->length);
}
/* **************************************** */
源代碼
結語
本文學習了排序的基本概念,包括排序定義、穩定性、內排序與外排序
排序的分類如下圖所示
7
種算法的指標對比從算法的簡單性,排序算法主要分爲兩類
- 簡單算法:冒泡、簡單選擇、直接插入
- 改進算法:希爾、堆、歸併和快速排序
不過 快排是性能最好的排序算法, 一定要熟記於心
至此,大話數據結構這本書筆記已經學習整理完畢,但還有更多的知識等待掌握,加油 , Fighting