排序算法總覽
分類:
- 插入類:直接插入排序、折半插入排序、希爾排序
- 交換類:冒泡排序、快速排序
- 選擇類:直接選擇排序、堆排序
- 歸併類:二路歸併排序
特徵:
- 平均時間複雜度:快、希、歸、堆排序爲:O(nlog n);其餘排序爲O(n )(記憶口訣:快些歸隊)
- 穩定性:快、希、選、堆排序爲:不穩定;其他排序是穩定的(記憶口訣:快些選一堆)
- 經過一趟排序就能保證一個元素到達最終位置:(交換類)冒泡排序、快速排序、(選擇類)直接選擇排序、堆排序
- 元素比較次數與初始序列無關:直接選擇排序、折半插入排序
- 排序的趟數與初始序列有關:交換類
- 冒泡排序:越有序,需要的趟數越少
- 快速排序:越有序,需要的趟數越多
- 注意插入排序需要的趟數是固定n-1的,只是越有序每趟元素比較的次數越少
——>下面具體分析每種排序:
- 注:默認序列下標從0開始
一、插入排序
基本思想:將待排序表看做兩個部分:無序區、有序區。整個排序過程就是將無序區的元素逐個插入到有序區中(注意①如何尋找插入點,②插入時如何移動有序區的元素是關鍵),構成新的有序區。
- 直接插入排序
- 折半插入排序
- 希爾排序
1、直接插入排序
基本思想
將待排序表分爲左右兩個部分,無序區在左邊,有序區在右邊。一邊尋找插入點一邊後移。
-------○■
----------○■
-------------○■
- 雙層循環。
- 外層:由小到大(從左到右)。由■代表
i
:待插入值的序號,從i = 1
開始 - 內層:從右到左。由○代表
j=i-1
:被比較值的序號
代碼
/**
* 直接插入排序
* @param R[0..N-1] 待排序表
* @param n 序列個數
*/
void insertSort(int[] R, int n) {
int i, j;
int temp;
//外層循環:由小到大
for (i = 1; i < n; i++) {
temp = R[i];
j = i-1;
//內層循環:從右到左(首先要進行數組越界判斷)
while (j >= 0 && temp < R[j]) {
//一邊尋找插入點一邊後移
R[j+1] = R[j];
--j;
}
//插入位置
R[j+1] = temp;
}
}
方法改進:監視哨
對於待排序表R[1..N],由於R[0]不設置元素,在R[0]處設置“監視哨”,作用:
- 相當於越界判斷
- 相當於temp
——>改進代碼:
/**
* 帶監視哨的直接插入排序
* @param R[1..N] 待排序表
* @param n 序列個數
*/
void insertSort1(int[] R, int n) {
int i, j;
// 外層循環
for (i = 1; i < n; i++) {
R[0] = R[i];
j = i-1;
//內層循環(不需要j>=1)
while (R[0] < R[j]) {
R[j+1] = R[j];
--j;
}
// 插入位置
R[j+1] = R[0];
}
}
性能分析
適用在序列基本有序的情況中。
- 最好的情況:整個序列已經有序,時間複雜度O(n)
- 最壞的情況:整個序列逆序,基本操作需要執行 =n(n-1)/2,時間複雜度O(n )
- 平均時間複雜度:O(n )
- 空間複雜度:O(1),額外空間只有一個temp
2、折半插入排序
基本思想
其排序的思想與直接插入排序一樣,只不過在尋找插入點時不一樣:先通過二分法找到插入點,即low處,然後在後移元素(不能像直接插入排序一邊尋找一邊後移)
-------○■
----------○■
-------------○■
- 雙層循環。
- 外層:由小到大(從左到右)。由■代表
i
:待插入值的序號 - 內層:從右到左。由○代表
high=i-1
(這裏high相當於直接插入排序中的j) - 比較的方式不同:先二分查找,在整體後移
代碼
/**
* 折半插入排序
* @param R[0..N-1] 待排序表
* @param n 序列個數
*/
void binaryInsertSort(int[] R, int n) {
//外層循環
for (int i = 1; i < n; i++) {
int temp = R[i];
int low = 0;
int high = i-1;
//內層循環
//①先尋找插入點
while (low <= high) {
int middle = (low + high) / 2;
if (temp < R[middle]) {
high = middle -1;
}else {
low = middle + 1;
}
}
//②插入點的索引就是low,然後後移元素
for (int j = i; j > low; j--) {
R[j] = R[j-1];
}
//插入
R[low] = temp;
}
}
性能分析
折半插入排序適合序列數較多的場景。與直接插入排序相比,折半插入排序在尋找插入點所花費的時間將大大減少(比較次數與初始序列無關),但是在移動次數方面和直接插入排序一樣的。所以時間複雜度與直接插入排序是一樣的。
- 最好的情況:整個序列已經有序,時間複雜度O(n)
- 最壞的情況:整個序列逆序,時間複雜度O(n )
- 平均時間複雜度:O(n )
- 空間複雜度:O(1)
3、希爾排序
基本思想
將待排序表劃分爲若干組(步長d),在每組中進行直接插入排序,通過縮小步長使序列逐漸有序。
- 注意是三層循環:最外層循環用於縮量步長,後兩次循環按照直接插入排序的過程
——>爲啥需要希爾排序?
- 我們知道直接插入排序適合序列基本有序的情況,希爾排序在每次迭代(最外層循環)中通過縮小增量步長的方式來使整個序列逐漸基本有序
- 元素個數越少,直接插入排序的效率越高
——>步長的取值
依次逐漸縮小:d = n/2,d = d /2,…,d = 1
注意,最後一趟的d 一定要爲1
——>例子:
代碼
/**
* 希爾排序
* @param R[0..N-1] 待排序表
* @param n 元素個數
*/
void shellSort(int R[], int n) {
//增加步長d
for (int d = n/2; d >= 1 ; d /= 2) {
//以下按照直接插入排序的過程
for (int i = 0 + d; i < n; i++) {
int temp = R[i];
//相當於直接插入排序中的j = i-1
int j = i-d;
while (j > 0 && temp < R[j]) {
//後移d位
R[j+d] = R[j];
j = j-d;
}
R[j+d] = temp;
}
}
}
性能分析
- 時間複雜度O(nlog n):由於每一趟的序列都認爲基本有序,則各趟的時間複雜度爲O(n);總共需要的趟數爲log n
- 空間複雜度O(1)
二、交換排序
基本思想:兩兩比較待排序表的元素,發現倒序就交換。比較/交換的位置不同出現不同的方法。
- 冒泡排序:相鄰位置比較
- 快速排序:與選出的中間元素比較
1、冒泡排序
基本思想
冒泡一趟,一定能將該趟序列中的最大(最小)元素交換到最終的位置。
--○----------■
--○--------■
--○------■
- 雙層循環
- 外層:由大到小(從右到左)。由■代表
i
:每趟逐漸減小i
。從i=n-1
開始,到i=1
結束 - 內層:從左到右。由○代表
j
:一趟比較序號從j=1
開始,到j=i
結束(下標0基址)
代碼
/**
* 冒泡排序
* @param R[0..N-1] 待排序表
* @param n 元素個數
*/
void bubbleSort(int[] R, int n) {
//外層循環
for (int i = n-1; i >= 1; i--) {
//用來標記該趟排序是否發生了交換
boolean flag = false;
//內層循環
for (int j = 1; j <= i; j++) {
if (R[j-1] > R[j]) {
//交換
int temp = R[j];
R[j] = R[j-1];
R[j-1] = temp;
flag = true;
}
}
//一趟排序過程中沒有發生交換,說明序列已經有序
if (!flag) {
return;
}
}
}
性能分析
- 最好的情況:整個序列已經有序,僅需要一趟排序,執行n-1次,時間複雜度O(n)
- 最壞的情況:整個序列逆序,基本操作需要執行n(n-1)/2,時間複雜度O(n )
- 平均時間複雜度:O(n )
- 空間複雜度:O(1),額外空間只有一個temp
2、快速排序
基本思想
分治法的思想(在分階段同時進行排序):首先選定一個元素作爲中間元素,然後將表中所有元素與該元素比較,比它小的調到表的前面,比它大的調到表的後面。一趟排序完後以中間元素爲分裂點將表分爲左右兩個子表繼續排序。
通用的快速排序思想:首先選擇表頭作爲中間元素temp。然後,從j開始掃描,遇到小於temp的停止掃描,將A[i](此時的i在中間元素位置,並保存在temp中)與A[j]交換,然後i++。接着,從i開始掃描,遇到大於temp的停止掃描,將A[j]與A[i]交換,然後j- -。以此類推,直到i與j交叉或相遇,將temp賦值到A[i]中。
具體分析詳見:分治法中的合併排序和快速排序
代碼
/**
* 快速排序
* @param R[0..N-1] 待排序表
* @param n 元素個數
*/
void quickSort(int[] R,int l,int r){
if (l < r) {
int i = l;
int j = r;
int temp = R[l];
while (i < j) {
//i<j爲越界限制
while(i < j && R[j] > temp) // 從右向左找第一個小於x的數
j--;
if(i < j)
R[i++] = R[j];
while(i < j && R[i] < temp) // 從左向右找第一個大於等於x的數
i++;
if(i < j)
R[j--] = R[i];
}
R[i] = temp;
quickSort(R, l, i-1);
quickSort(R, i+1, r);
}
}
性能分析
從平均時間性能來說,快速排序目前被認爲是最好的一種內部排序方法。
- 一趟劃分比較的時間複雜度固定在O(n)
- 最好的情況:每趟都將子表等分成兩部分,需要log n趟,理想的時間複雜度爲O(nlog n)
- 最壞的情況:序列基本有序,每次選取的中間元素要麼最大要麼最小,劃分成一個空表一個n-1的子表,則需要n-1趟,最壞的時間複雜度O(n )
- 平均時間複雜度:趨向於最好的情況,O(nlog n)
- 空間複雜度:O(log n),遞歸需要棧的輔助
三、選擇排序
基本思想:在每一趟排序中,在待排序表中都選出最大或最小的元素放到最終的位置。選擇的方式不同,出現不同的方法。
- 直接選擇排序
- 堆排序
1、直接選擇排序
基本思想
每趟選擇完,都將待排序表中的最大(小)元素放到表後(前)。這裏每次選的是最小元素。
■○----------N-1
■○--------N-1
■○------N-1
- 雙層循環
- 外層:由大到小(但從左到右)。由■代表
i
:每趟也逐漸減小i
。但從i=0
開始,到i=n-1
結束 - 內層:從左到右。由○代表
j
:一趟比較序號從j=i+1
開始,到j=n-1
結束
代碼
/**
* 直接選擇排序
* @param R R[0..N-1] 待排序表
* @param n 元素個數
*/
void selcetSort(int[] R, int n) {
int i, j;
int minIndex;
//外層循環
for (i = 0; i < n; i++) {
minIndex = i;
//內層循環
for (j = i+1; j < n; j++) {
//選出最小元素索引
if (R[j] < R[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
//交換
int temp = R[i];
R[i] = R[minIndex];
R[minIndex] = temp;
}
}
}
性能分析
- 時間複雜度:由於比較次數固定:(n-1 + 1)(n-1)/2 = n(n-1)/2,且與初始序列無關。所以時間複雜度爲O(n )
- 空間複雜度:O(1),額外空間只有一個temp
2、堆排序
基本思想
可以把堆看成一顆完全二叉樹。必須滿足任何一個非葉子節點的值不大於(小根堆)或不小於(大根堆)左右孩子節點的值。
堆排序的過程:初始建堆——>n-1調整——>n-1-1調整——>…
建立完一趟的大根堆,並不意味着排序完成。雖然可以得到最大的元素,但是無法知道左右孩子的正確序列。所以需要將根刪除繼續n-1建堆,直到堆規模只剩下一個元素。
建堆的過程:先將待排序錶轉變成完全二叉樹形式,然後建堆,可以參考:圖解排序算法(三)之堆排序
但是要記住:從第一個非葉子節點開始,從右到左,從下到上,對每個節點進行調整,最終得到一顆大根堆。
代碼
- 注:序列的下標從1開始。則2i爲節點i的左孩子,2i+1爲節點i的右孩子
/**
* 堆排序
* @param R[1..N] 待排序表
* @param n 元素個數
*/
void heapSort(int[] R, int n) {
//初始建堆
//從n/2表示最後一個非葉子節點開始,從右到左,從下到上
for (int i = n/2; i >= 1; i--) {
sift(R, i, n);
}
for (int i = n; i >= 2 ; i--) {
//交換,將根節點放到序列最終位置
int temp = R[1];
R[1] = R[i];
R[i] = temp;
//往後的調整,每次只需要從頭結點開始就可以了
sift(R, 1, i-1);
}
}
/**
* 篩選
* @param R
* @param low
* @param high
*/
void sift(int[] R, int low, int high) {
//節點i,和其左孩子2i
int i = low, j = 2*i;
int temp = R[i];
while (j <= high) {
//如果右孩子大,則j指向有孩子
if (j < high && R[j] < R[j+1]) {
++j;
}
//如果父節點小於孩子,賦值父節點
if (temp < R[j]) {
R[i] = R[j];
i = j;
//繼續比較下去,孩子的孩子節點是否大於temp
j = 2*i;
}else {
break;
}
}
R[i] = temp;
}
性能分析
- 時間複雜度爲:O(nlog n)
- 篩選操作的時間:堆排序算法花費的時間最多用在初始建堆和調整時所進行的篩選上。每個節點篩選的時間複雜度爲O(log n)(解釋:完全二叉樹的高度k=log n+1,最多需要比較2(k-1)次)。
- 初次建堆的時間:初始建堆需要n/2次篩選操作
- 調整操作的時間:剩下需要n-1調整操作,每次只需要篩選頭節點
- 因此堆排序算法的整個基本操作次數爲O(log n)x(n/2) + O(log n)x(n-1)。簡化後時間複雜度爲O(nlog n)
- 空間複雜度:O(1),額外空間只有一個temp
- 堆排序適合的場景是記錄數很多的情況,比如從10000個記錄中選出前10個最小的。
四、歸併排序
基本思想
所謂歸併是指將兩個或兩個以上的有序表合併成一個新的有序表。歸併排序也可以歸納爲分治法的思想,但是重點在合併階段。
具體分析及代碼詳見:分治法中的合併排序和快速排序
性能分析
- 時間複雜度爲:O(nlog n)。其中,一趟排序的時間複雜度爲O(n),需要 log n趟遞歸
- 空間複雜度:O(n),需要存儲整個待排序表
參考
- 《數據結構(C++描述)》 胡學剛 張晶 主編 人民郵電出版社
- 《2015版數據結構高分筆記》
- 圖解排序算法