剖析八種經典排序演算法
排序(Sorting) 是計算機程式設計中的一種重要操作,它的功能是將一個數據元素(或記錄)的任意序列,重新排列成一個關鍵字有序的序列。
我整理了以前自己所寫的一些排序演算法結合網上的一些資料,共介紹8種常用的排序演算法,希望對大家能有所幫助。
八種排序演算法分別是:
1.氣泡排序;
2.選擇排序;
3.插入排序;
4.快速排序;
5.歸併排序;
6.希爾排序;
7.二叉排序;
8.計數排序;
其中快排尤為重要,幾乎可以說IT開發類面試必考內容,而希爾排序和歸併排序的思想也非常重要。下面將各個排序演算法的排序原理,程式碼實現和時間複雜度一一介紹。
—,最基礎的排序——氣泡排序
氣泡排序是許多人最早接觸的排序演算法,由於邏輯簡單,所以大量的出現在計算機基礎課本上,作為一種最基本的排序演算法被大家所熟知。
設無序陣列a[]長度為N,以由小到大排序為例。冒泡的原理是這樣的:
1.比較相鄰的前兩個資料,如果前面的資料a[0]大於後面的資料a[1] (為了穩定性,等於不交換),就將前面兩個資料進行交換。在將計數器 i ++;
2.當遍歷完N個數據一遍後,最大的資料就會沉底在陣列最後a[N-1]。
3.然後N=N-1;再次進行遍歷排序將第二大的資料沉到倒數第二位置上a[N-2]。再次重複,直到N=0;將所有資料排列完畢。
無序陣列: 2 5 4 7 1 6 8 3
遍歷1次後: 2 4 5 1 6 7 3 8
遍歷2次後: 2 4 1 5 6 3 7 8
...
遍歷7次後: 1 2 3 4 5 6 7 8
可以輕易的得出,冒泡在 N– 到 0 為止,每遍近似遍歷了N個數據。所以冒泡的時間複雜度是 -O(N^2)。
按照定義實現程式碼如下:
void BubbleSore(int *array, int n)
{
int i = 0;
int j = 0;
int temp = 0;
for(i = 0; i < n; ++i){
for(j = 1; j < n - i; ++j){
if (array[j - 1] > array[j]){
temp = array[j-1];
array[j - 1] = array[j];
array[j] = temp;
}
}
}
}
我們對可以對冒泡進行優化,迴圈時,當100個數據,僅前10個無序,發生了交換,後面沒有交換說明有序且都大於前10個數據,那麼以後迴圈遍歷時,就不必對後面的90個數據進行遍歷判斷,只需每遍從0迴圈到10就行了。
void BubbleSore(int *array, int n) //優化
{
int i = n;
int j = 0;
int temp = 0;
Boolean flag = TRUE;
while(flag){
flag = FALSE;
for(j = 1; j < i; ++j){
if(array[j - 1] > array[j]){
temp = array[j-1];
array[j - 1] = array[j];
array[j] = temp;
flag = TRUE;
}
}
i--;
}
}
雖然我們對冒泡進行了優化,但優化後的時間複雜度邏輯上還是-O(n^2),所以說冒泡還是效率比較低下的,資料較大時,建議不要採用冒泡。
二,最易理解的排序——選擇排序
如果讓一個初學者寫一個排序演算法,很有可能寫出的就是選擇排序(反正我當時就是 ^.^),因為選擇排序甚至比冒泡更容易理解。
原理就是遍歷一遍找到最小的,與第一個位置的數進行交換。再遍歷一遍找到第二小的,與第二個位置的數進行交換。看起來比較像冒泡,但它不是相鄰資料交換的。
無序陣列: 2 5 4 7 1 6 8 3
遍歷1次後: 1 5 4 7 2 6 8 3
遍歷2次後: 1 2 4 7 5 6 8 3
...
遍歷7次後: 1 2 3 4 5 6 7 8
選擇排序的時間複雜度也是 -O(N^2);
void Selectsort(int *array, int n)
{
int i = 0;
int j = 0;
int min = 0;
int temp = 0;
for(i; i < n; i++){
min = i;
for(j = i + 1; j < n; j++){
if(array[min] > array[j])
min = j;
}
temp = array[min];
array[min] = array[i];
array[i] = temp;
}
}
#endif
三,撲克牌法排序——插入排序
打牌時(以挖坑為例)我們一張張的摸牌,將摸到的牌插入手牌的”順子”裡,湊成更長的順子,這就是插入排序的含義。
設無序陣列a[]長度為N,以由小到大排序為例。插入的原理是這樣的:
1.初始時,第一個資料a[0]自成有序陣列,後面的a[1]~a[N-1]為無序陣列。令 i = 1;
2.將第二個資料a[1]加入有序序列a[0]中,使a[0]~a[1]變為有序序列。i++;
3.重複迴圈第二步,直到將後面的所有無序數插入到前面的有序數列內,排序完成。
無序陣列: 2 | 5 4 7 1 6 8 3
遍歷1次後: 2 5 | 4 7 1 6 8 3
遍歷2次後: 2 4 5 | 7 1 6 8 3
遍歷3次後: 2 4 5 7 | 1 6 8 3
...
插入排序的時間度仍然是-O(N^2),但是,插入排序是一種比較快的排序,因為它每次都是和有序的數列進行比較插入,所以每次的比較很有”意義”,導致交換次數較少,所以插入排序在-O(N^2)級別的排序中是比較快的排序演算法。
{
int i = 0;
int j = 0;
int temp = 0;
for(i = 1; i < n; i++){
if(array[i] < array[i-1]){
temp = array[i];
for(j = i - 1; j >= 0 && array[j] > temp; j--){
array[j+1] = array[j];
}
array[j+1] = temp;
}
}
}
四,最快的排序——快速排序
我真的很敬佩設計出這個演算法的大神,連起名字都這麼霸氣——Quick Sort。為什麼這麼自信的叫快速排序?因為已經被數學家證明出 在交換類排序演算法中,快排是是速度最快的!
快排是C.R.A.Hoare於1962年提出的一種劃分交換區的排序。它採用一種很重要的”分治法(Divide-and-ConquerMethod)”的思想。快排是一種很有實用價值的排序方法,很多IT公司在面試演算法時幾乎都會去問,所以快排是一定要掌握的。
快排的原理是這樣的:
1. 先在無序的陣列中取出一個數作為基數。
2. 將比基數小的數扔到基數的左邊,成為一個區。將比基數大的數扔到基數的右邊,成為另一個區。
3. 將左右兩個區重複進行前兩步操作,使數列變成四個區。
4. 重複操作,直到每個區裡只有一個數時,排序完成。
快速排序初次接觸比較難理解,我們可以把快排看做挖坑填數,具體操作如下:
陣列下標: 0 1 2 3 4 5 6 7
無序數列: 4 2 5 7 1 6 8 3
初始時,left = 0; right = 7; 將第一個數設為基數 base = a[left];
由於將a[0]儲存到base中,可以理解為在a[0]處挖了一個坑,可以將資料填入a[0]中。
從最右邊right挨個開始找比base小的數。當right==7符合,則將a[7]挖出來填入a[0]的坑裡面(a[0] = a[7]),所以又 形成了新坑a[7],並且left ++。
再從左邊left開始挨個找比base大的數(注意上一步left++),當left == 2符合,就將a[2]挖出來填入a[7]位置處,並且right–。
現在陣列變為:
陣列下標: 0 1 2 3 4 5 6 7
無序數列: 3 2 5 7 1 6 8 5
重複以上步驟,左邊挖的坑在右邊找,右邊找到比基數小的填到左邊,左邊++。右邊的坑在左邊找,找到比基數大的填在右邊,右邊–。
迴圈條件是left > right,當排序完後,將基數放在迴圈停止的位置,比基數小的都到了基數的左邊,比基數大的都到了基數的右邊。
陣列下標: 0 1 2 3 4 5 6 7
無序數列: 3 2 1 4 7 6 8 5
再對0~2區間和4~7區間重複以上操作。直到分的區間只剩一個數,證明排序已經完成。
可以看出快排是將陣列一分為二到底,需要log N次,再乘以每個區間的排序次數 N。所以時間複雜度為:-O(N * log N)。
void Quicksort(int *array, int l, int r)
{
int i = 0;
int j = 0;
int x = 0;
if(l < r){
i = l;
j = r;
x = array[l];
while(i < j){
while(i < j && array[j] >= x){
j--;
}
if(i < j){
array[i++] = array[j];
}
while(i < j && array[i] <= x){
i++;
}
if(i < j){
array[j--] = array[i];
}
}
array[i] = x;
Quicksort(array, l, i - 1);
Quicksort(array, i + 1, r);
}
}
快排還有許多改進版本,如隨機選擇基數,區間內資料較少時直接用其他排序來減小遞迴的深度等等。快排現在仍是很多人研究的課題,有興趣的同學可以深入的研究下。
五,分而治之——歸併排序
歸併排序是建立在歸併操作上的一種優秀的演算法,也是採用分治思想的典型例子。
我們知道將兩個有序數列進行合併,是很快的,時間複雜度只有-O(N)。而歸併就是採用這種操作,首先將有序數列一分二,二分四……直到每個區都只有一個數據,可以看做有序序列。然後進行合併,每次合併都是有序序列在合併,所以效率比較高。
無序陣列: 2 5 4 7 1 6 8 3
第一步拆分:2 5 4 7 | 1 6 8 3
第二步拆分:2 5 | 4 7 | 1 6 | 8 3
第三步拆分:2 | 5 | 4 | 7 | 1 | 6 | 8 | 3
第一步合併:2 5 | 4 7 | 1 6 | 3 8
第二步合併:2 4 5 7 | 1 3 6 8
第三步合併:1 2 3 4 5 6 7
可見歸併排序的時間複雜度是拆分的步數 log N 乘以排序步數 N ,為-O(N * log N)。也是高級別的排序演算法(-O(N ^ 2)為低級別)。
void Mergesort(int *array, int n)
{
int *temp = NULL;
if(array == NULL || n < 2)
return;
temp = (int *)Malloc(sizeof(int )*n);
mergesort(array, 0, n - 1, temp);
free(temp);
}
void mergesort(int *array, int first, int last, int *temp)
{
int mid = -1;
if(first < last){
mid = first + ((last - first) >> 1);
mergesort(array, first, mid, temp);
mergesort(array, mid+1, last, temp);
mergearray(array, first, mid, last, temp);
}
}
void mergearray(int *array, int first, int mid, int last, int *temp)
{
int i = first;
int m = mid;
int j = mid + 1;
int n = last;
int k = 0;
while(i <= m && j <= n){
if(array[i] <= array[j]){
temp[k++] = array[i++];
}else{
temp[k++] = array[j++];
}
}
while(i <= m){
temp[k++] = array[i++];
}
while(j <= n){
temp[k++] = array[j++];
}
memcpy(array + first, temp, sizeof(int) * k);
}
由於要申請等同於原陣列大小的臨時陣列,歸併演算法快速排序的同時也犧牲了N大小的空間。這是速率與空間不可調和矛盾,接觸資料結構越多,越能發現這個道理,我們只能取速度與空間權衡點,不可能兩者兼得。
六,縮小增量——希爾排序
希爾排序的實質就是分組插入排序,該方法又稱為縮小增量排序,因DJ.Shell與1959年提出而得名。
該方法的基本思想是:先將整個待排序列分割成若干個子序列(由相隔某個”增量”的元素組成)分別進行插入排序,然後依次縮減增量再次進行排序,待整個序列中的元素基本有序時(增量足夠小),再對全體進行一次直接插入排序。因為直接插入排序在元素基本有序的情況下(接近最好情況),效率是很高的。
無序陣列: 2 5 4 7 1 6 8 3
第一次gap=8/2 2A 1A
5B 6B
4C 8C
7D 3D
設第一次增量為N/2 = 4,即a[0]和a[4]插入排序,a[1]和a[5]插入排序,a[2]和a[6],a[3]和a[7].字母相同代表在同一組進行排序。
排序完後變為:
一次增量: 1 5 4 3 2 6 8 7
A B C D A B C D
縮小增量,gap=4/2。
一次增量: 1 5 4 3 2 6 8 7
第二次gap=4/2 :1A 4A 2A 8A
5B 3B 6B 7B
第二次增量變為2,即a[0],a[2],a[4],a[6]一組進行插入排序。a[1],a[3],a[5],a[7]一組進行排序。結果為:
二次增量: 1 3 2 5 4 6 8 7
第三次增量gap=1,直接進行選擇排序。
三次增量: 1 2 3 4 5 6 7 8
希爾排序的時間複雜度為-O(N * log N),前提是使用最佳版本,後面有提到。
void Shellsort(int *array, int n)
{
int i,j,k,temp,gap;
for(gap = n/2; gap > 0; gap /= 2){
for(i = 0; i < gap; i++){
for(j = i + gap; j < n; j += gap){
for(k = j - gap; k >= i && array[k] > array[k+1]; k -= gap){
temp = array[k+1];
array[k+1] = array[k];
array[k] = temp;
}
}
}
}
}
很顯然,上面的Shell排序雖然對直觀理解希爾排序有幫助,但程式碼過長迴圈過多,不夠簡潔清晰。因此進行一下改進和優化,在gap內部進行排序顯然也能達到縮小增量排序的目的。
void Shellsort(int *array, int n)
{
int i,j,k,temp;
for(gap = n/2; gap > 0; gap /= 2){
for(j = gap; j < n; j ++){
if(array[j] < array[j-gap]){
temp = array[j];
k = j - gap;
while(k >= 0 && array[k] > temp){
array[k+gap] = array[k];
k -= gap;
}
array[k+gap] = temp;
}
}
}
}
希爾排序的縮小增量思想很重要,學習資料結構主要就是學習思想。我們上面排序的步長gap都是N/2開始,在進行減半,實際上還有更高效的步長選擇,如果你有興趣,可以去維基百科檢視更多的步長演算法推導。
七,集中資料的排序——計數排序
如果有這樣的數列,其中元素種類並不多,只是元素個數多,請選擇->計數排序。
比如一億個1~100的整型資料,它出現的資料只有100種可能。這個時候計數排序非常的快(親測,快排需要19秒,基數排序只需要不到1秒!)。
計數排序的思想是這樣的:
1. 根據資料範圍size(100),malloc構造一個用於計算資料出現次數的陣列,並將其初始化個數都置為0。
2. 遍歷一遍,將出現的每個資料的次數記錄於陣列。
3. 再次遍歷,按照順序並根據資料出現的次數重現擺放,排序完成。
可見計數排序僅僅遍歷了兩遍。時間複雜度:-O(N) + -O(N) = -O(N)。
void count_sort(int *array, int length, int min, int max)
{
int *count = NULL;
int c_size = max - min + 1;
int i = 0;
int j = 0;
count = (int *)Malloc(sizeof(int) * c_size);
bzero(count, sizeof(int) * c_size);
for(i = 0; i < length; ++i){
count[array[i] - min]++;
}
for(i = 0, j = 0; i < c_size;){
if(count[i]){
array[j++] = i + min;
count[i]--;
}else{
i++;
}
}
free(count);
}
計數排序雖然時間複雜度最小,速度最快。但是,限制條件是資料一定要比較集中,要是資料範圍很大,程式可能會卡死。
八,構造樹——二叉堆排序
堆排序與快速排序,歸併排序一樣都是時間複雜度為 O(N*logN)的幾種常見排序方法。學習堆排序前,先講解下什麼是資料結構中的二叉堆。
二叉堆的定義:
二叉堆是完全二叉樹或者是近似完全二叉樹。
二叉堆滿足二個特性:
1.父結點的鍵值總是大於或等於(小於或等於)任何一個子節點的鍵值。
2.每個結點的左子樹和右子樹都是一個二叉堆(都是最大堆或最小堆)。
當父結點的鍵值總是大於或等於任何一個子節點的鍵值時為最大堆。當父結點的鍵值總是小於或等於任何一個子節點的鍵值時為最小堆。下圖展示一個最小堆:
由於其它幾種堆(二項式堆,斐波納契堆等)用的較少,一般將二叉堆就簡稱為堆。
堆的儲存:
一般都用陣列來表示堆,i 結點的父結點下標就為(i – 1) / 2。它的左右子結點下標分別為 2 * i + 1 和 2 * i + 2。如第 0 個結點左右子結點下標分別為 1 和 2。
堆的操作——插入刪除:
下面先給出《資料結構 C++語言描述》中最小堆的建立插入刪除的圖解,再給出程式碼實現,最好是先看明白圖後再去看程式碼。
堆的插入:
每次插入都是將新資料放在陣列最後。可以發現從這個新資料的父結點到根結點必然為一個有序的數列,現在的任務是將這個新資料插入到這個有序資料中——這就類似於直接插入排序中將一個數據併入到有序區間中,寫出插入一個新資料時堆的調整程式碼:
void MinHeapFixup(int a[], int i)
{
int j,temp;
temp = a[i];
j = (i - 1) / 2; //父結點
while (j >= 0){
if (a[j] <= temp)
break;
a[i] = a[j]; //把較大的子結點往下移動,替換它的子結點
i = j;
j = (i - 1) / 2;
}
a[i] = temp;
}
更簡短的表達為:
void MinHeapFixup(int a[], int i)
{
for (int j = (i - 1) / 2; j >= 0 && a[i] > a[j]; i = j, j = (i - 1) / 2)
Swap(a[i], a[j]);
}
插入時://在最小堆中加入新的資料nNum
void MinHeapAddNumber(int a[], int n, int nNum)
{
a[n] = nNum;
MinHeapFixup(a, n);
}
堆的刪除:
按定義,堆中每次都只能刪除第 0 個數據。為了便於重建堆,實際的操作是將最後一個數據的值賦給根結點,然後再從根結點開始進行一次從上向下的調整。調整時先在左右兒子結點中找最小的,如果父結點比這個最小的子結點還小說明不需要調整了,反之將父結點和它交換後再考慮後面的結點。相當於從根結點將一個數據的“下沉”過程。下面給出程式碼:
// 從i節點開始調整,n為節點總數 從0開始計算 i節點的子節點為 2*i+1, 2*i+2
void MinHeapFixdown(int a[], int i, int n)
{
int j, temp;
temp = a[i];
j = 2 * i + 1;
while (j < n){
if (j + 1 < n && a[j + 1] < a[j]) //在左右孩子中找最小的
j++;
if (a[j] >= temp)
break;
a[i] = a[j]; //把較小的子結點往上移動,替換它的父結點
i = j;
j = 2 * i + 1;
}
a[i] = temp;
}
//在最小堆中刪除數
void MinHeapDeleteNumber(int a[], int n)
{
Swap(a[0], a[n - 1]);
MinHeapFixdown(a, 0, n - 1);
}
堆化陣列:
有了堆的插入和刪除後,再考慮下如何對一個數據進行堆化操作。要一個一個的從陣列中取出資料來建立堆吧,不用!先看一個數組,如下圖:
很明顯,對葉子結點來說,可以認為它已經是一個合法的堆了即 20,60, 65,4, 49 都分別是一個合法的堆。只要從 A[4]=50 開始向下調整就可以了。然後再取 A[3]=30,A[2] = 17,A[1] = 12,A[0] = 9 分別作一次向下調整操作就可以了。下圖展示了這些步驟:
寫出堆化陣列的程式碼:
//建立最小堆
void MakeMinHeap(int a[], int n)
{
for (int i = n / 2 - 1; i >= 0; i--)
MinHeapFixdown(a, i, n);
}
至此,堆的操作就全部完成了,再來看下如何用堆這種資料結構來進行排序。
堆排序:
首先可以看到堆建好之後堆中第 0 個數據是堆中最小的資料。取出這個資料再執行下堆的刪除操作。
這樣堆中第 0 個數據又是堆中最小的資料,重複上述步驟直至堆中只有一個數據時就直接取出這個資料。由於堆也是用陣列模擬的,故堆化陣列後,第一次將 A[0]與 A[n - 1]交換,再對A[0…n-2]重新恢復堆。第二次將 A[0]與 A[n – 2]交換,再對 A[0…n - 3]重新恢復堆,重複這樣的操作直到 A[0]與 A[1]交換。
由於每次都是將最小的資料併入到後面的有序區間,故操作完成後整個陣列就有序了。有點類似於直接選擇排序。
// 堆排序 最小堆 –> 降序排序
void MinheapsortTodescendarray(int a[], int n)
{
for (int i = n - 1; i >= 1; i--){
Swap(a[i], a[0]);
MinHeapFixdown(a, 0, i);
}
}
注意使用最小堆排序後是遞減陣列,要得到遞增陣列,可以使用最大堆。由於每次重新恢復堆的時間複雜度為 O(logN),共 N - 1 次重新恢復堆操作,再加上前面建立堆時 N / 2 次向下調整,每次調整時間複雜度也為 O(logN)。二次操作時間相加還是 O(N * logN)。故堆排序的時間複雜度為 O(N * logN)。
八種排序演算法已經介紹完畢,希望大家有所收穫!
染塵 16.4.29