深度剖析八大經典排序演算法—C++實現(通俗易懂版)
內容會持續更新,有錯誤的地方歡迎指正,謝謝!
引言
需握各種排序和常用的資料結構的核心思想,並知道是以什麼樣的方式解決了什麼樣的問題。
該部落格的示例程式碼均以遞增排序為目的~
學習建議:切忌看示例程式碼去理解演算法,而是理解演算法原理寫出程式碼,否則會很快就會忘記。
//排序原始陣列:
int a[10]= { 7,5,3,4,4,1,9,45,121,3 };
演算法分類
八大經典排序演算法分類:
- 交換類排序(氣泡排序、快速排序)
- 插入類排序(簡單插入排序、希爾排序)
- 選擇類排序(簡單選擇排序、堆排序)
- 歸併排序
- 計數排序
總結:
所需輔助空間最多:歸併排序
平均速度最快:快速排序
不穩定:快速排序,希爾排序,簡單選擇排序,堆排序
資料初始排序狀態對堆排序不會產生太大的影響,而快速排序卻恰恰相反。還有基數排序、桶排序可以瞭解一下。
演算法的實現
非線性時間比較類排序
交換類排序
一、最基礎的排序—氣泡排序
原理:
- 比較相鄰的兩個數的大小,將最大的數放在右邊,計數器i++;
- 繼續重複操作1,直到a[n-2]和a[n-1]比較結束,陣列a中最大的值已在a[n-1];
- 將進行排序的陣列長度n減1,重複操作1和操作2,直到n為1,排序完畢。
複雜度:
平均時間複雜度:O(n^2)
最好的情況:如果待排序資料序列為正序,則一趟冒泡就可完成排序,排序的比較次數為n-1次,且沒有移動,時間複雜度為O(n)。要實現O(n)的複雜度,程式碼裡需要加一個標誌位(Bool變數)。
最壞的情況:如果待排序資料序列為逆序,則氣泡排序需要n-1次趟,每趟進行n-i次排序的比較和移動,即比較和移動次數均達到最大值:比較次數=n(n−1)/2=O(n^2),移動次數等於比較次數,因此最壞時間複雜度為O(n^2)。
穩定性:
穩定排序(穩定性和不穩定性指:相同元素的前後順序沒變或變了)
程式碼:
void BubbleSort(int* array, int length)
{
for (int i = 0; i < length - 1; ++i)
{
//bool is_Swap=false;
for (int j = 0 ; j < length - 1 - i; ++j)
{
if (array[j] > array[j + 1])
{
//is_Swap=true;
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
/*
交換還可使用如下方式
a = a + b;
b = a - b;
a = a - b;
交換還可使用如下方式
a=a^b;
b=b^a;
a=a^b;
*/
}
}
//if(is_Swap==false)
//return;
}
}
二、最快的排序—快速排序
在交換類排序演算法中,快排是速度最快的。採用分治的思想。
原理:
- 從n個元素中選擇一個元素作為分割槽的標準,一般選第一個元素;
- 把小於該元素的放在左邊,把大於等於該元素的放在右邊,中間就放該元素;
- 再分別對左右子序列重複操作1和2,直到每個子序列裡只有一個元素,排序完畢。
複雜度:
平均時間複雜度:O(n*log n)。原因:快排是將陣列一分為二到底,所以需要O(log n)次此操作,每次操作需要排序n次,所以,大多數情況下,時間複雜度都是O(n*log n)。
最好的情況:是每趟排序結束後,每次劃分使兩個子檔案的長度大致相等,時間複雜度為O(n*log n)。
最壞的情況:是待排序元素已經排好序。第一趟經過n-1次比較後第一個元素保持位置不變,並得到一個n-1個元素的子序列;第二趟經過n-2次比較,將第二個元素定位在原來的位置上,並得到一個包括n-2個元素的子序列,依次類推,這樣總的比較次數是:n(n-1)/2=O(n^2)。
穩定性:
不穩定排序
程式碼:
第一種快速排序:一個“指標”從左往右,一個“指標”從右往左
void QuickSort(int* array,int low,int high)
{
if (low >= high)
return;
int left = low;
int right = high;
int key = array[left];//選擇第一個元素作為區分元素,當然也可以選最後一個元素。
while (left != right)
{
while (left != right&&array[right] >= key)//從右往左,把小於key的元素放到key的左邊
--right;
array[left] = array[right];
while (left != right&&array[left] <= key)//從左往右,把大於key的元素放到key的右邊
++left;
array[right] = array[left];
}
array[left] = key;//此時left等於right
//一分為二,分治思想,遞迴呼叫。
QuickSort(array, low, left - 1);
QuickSort(array, left + 1, high);
}
眾所周知,Partition函式不管是在快速排序中,還是在找第K大這類問題中,都有很重要的地位。上述程式碼將Partition函式寫入了QuickSort函式裡,並未單獨寫成函式。現在我把它單獨寫成函式:
int Partition(int* array,int left,int right)
{
int key = array[left];
while (left != right)
{
while (left != right&&array[right] >= key)//從右往左,把小於key的元素放到key的左邊
--right;
array[left] = array[right];
while (left != right&&array[left] <= key)//從左往右,把大於key的元素放到key的右邊
++left;
array[right] = array[left];
}
array[left] = key;
return left;//返回區分函式
}
//快排主函式
void quicksort(int* arr, int left, int right)
{
if(left< right)
{
int middle = mypartition(arr, left, right);
quicksort(arr, left, middle-1);
quicksort(arr, middle+1, right);
}
}
第二種快速排序:兩個“指標”一前一後地往前走,以一定的規律swap兩個元素
int Partition(int* arr, int left, int right)
{
int pivot = arr[left];//選第一個元素作為樞紐元,也可以選最後一個元素。
int location = left;//location指向left
for(int i = left+1; i <= right; i++)//從left+1到right
if(arr[i] < pivot)
swap(arr[i], arr[++location]);
swap(arr[left], arr[location]);//樞紐元和一個大於樞紐元的數互換位置
return location;//返回現在樞紐元的位置
}
插入類排序
三、撲克牌法排序—簡單插入排序
插入排序是拿元素去找位置,氣泡排序是拿位置去找元素。
原理:
俗氣的說法:打撲克牌時,一張張地摸牌並將摸到的牌按大小順序插入到現有的序列中。
正式的說法:從待排序的陣列的第二個元素開始,將其與前面的數進行大小比較,尋找合適的位置並插入,直到全部元素都已插入。
複雜度:
平均時間複雜度:O(n^2)
最好的情況:當待排序記錄已經有序,這時需要比較的次數為n-1=O(n)
最壞的情況:如果待排序記錄為逆序,則最多的比較次數為n*(n-1)/2=O(n^2)
穩定性:
穩定排序
程式碼:
void InsertSort(int* array,int length)
{
int i = 0, j = 0, temp = 0;
for (i = 1; i < length; ++i)
{
//如果該元素小於前面的元素,大於該元素的元素全部後移一位,
//直到找到該元素要插入的位置並插入之。
if (array[i] < array[i-1])
{
temp = array[i];
for (j = i-1; temp < array[j] && j >= 0 ; --j)
{
array[j+1] = array[j];
}
array[j + 1] = temp;
}
}
}
四、縮小增量—希爾排序
希爾排序又叫縮小增量排序,插入排序的改進版。
原理:
- 對相鄰指定距離(即增量)的元素進行分組,再對每個組內的元素進行插入排序。
- 每個組排序結束後,再把增量除以2,重複操作1。
- 直到增量變為1,最後再進行一次插入排序。
假設:對5 9 7 4 8 2 9 1排序
第一次增量為8/2=4:a[0]和a[4]、a[1]和a[5]、a[2]和a[6]、a[3]和a[7]一組
每組插入排序的結果彙總:5 2 7 1 8 9 9 4
第二次增量為4/2=2:a[0]和a[2]和a[4]和a[6]、a[1]和a[3]和a[5]和a[7]一組
每組插入排序的結果彙總:5 1 7 2 8 4 9 9
第三次增量為2/2=1:全部元素一組
全部元素插入排序的結果:1 2 4 5 7 8 9 9
複雜度:
希爾排序的時間複雜度取決於其增量的選擇,範圍在O(n*log n)到O(n^2)。
穩定性:
不穩定排序
程式碼:
void ShellSort(int* array, int length)
{
int i = 0, j = 0, k = 0 , temp = 0, gapVal = 0;
for (gapVal = length / 2; gapVal > 0; gapVal = gapVal / 2)
{
//分為gapVal個組
for (i = 0; i < gapVal; ++i)
{
//開始插入排序
for (j = i + gapVal; j < length; j += gapVal)
{
if (array[j] < array[j - 1])
{
temp = array[j];
for (k = j - 1; k >= 0 && temp < array[k]; --k)
{
array[k + 1] = array[k];
}
array[k+1] = temp;
}
}
}
}
}
選擇類排序
五、最易理解的排序—簡單選擇排序
選擇排序比冒泡更容易理解。
原理:
遍歷一遍找到最小的元素,將其和第一個元素交換位置;再找到第二小的元素,將其和第二個元素交換位置;直到排序完畢。看起來像是冒泡,但它不是相鄰元素之間的交換。
複雜度:
平均時間複雜度:n*(n-1)/2=O(n^2)
最好的情況:也是O(n^2)
最壞的情況:也是O(n^2)
穩定性:
不穩定排序(例如:5,4,9,5,1,7。第一個5會和1互換位置導致不穩定。)
程式碼:
void SelectSort(int* array, int length)
{
int i = 0, j = 0, k = 0, temp = 0;
for (i = 0; i < length-1; ++i)
{
k = i;
for (j = i+1; j < length; ++j)
{
if (array[j] < array[k])
k = j;
}
temp = array[i];
array[i] = array[k];
array[k] = temp;
}
}
六、樹形選擇排序—堆排序
堆排序和快速排序在效率上是差不多的,但是堆排序一般優於快速排序的重要一點是:資料的初始分佈情況對堆排序的效率沒有大的影響。
簡單選擇排序中,第一次選擇經過了n-1次比較操作,只是從陣列中選出了一個最小的元素,而沒有儲存其他中間比較結果。所以後一趟排序時又要重複許多比較操作,降低了效率。而堆排序避免這一缺點。
要掌握堆排序,必須要先學習堆的知識,務必點選連結進行學習後,再接著看~
原理:
- 先將初始陣列a[0..n-1]建成一個最大堆,此堆為初始的無序區。
- 再將值最大的元素a[0]和無序區的最後一個元素a[n-1]交換,由此得到新的無序區a[0..n-2]和有序區a[n-1]。
- 由於交換後新的根a[0]可能違反堆性質,故應將當前無序區a[0..n-2]調整為最大堆。然後再次將a[0..n-2]中值最大的元素a[0]和該區間的最後一個記錄a[n-2]交換,由此得到新的無序區a[0..n-3]和有序區a[n-2..n-1]。
重複此操作,直到無序區只有一個元素為止。
注意:
1.只需做n-1趟排序;
2.堆排序中無序區總是在有序區之前,且有序區是在原向量的尾部由後往前逐步擴大至整個向量為止。
複雜度:
分析:建堆需N / 2次向下調整,每次調整的時間複雜度為O(logN);每次堆調整的時間複雜度為O(logN),共需N - 1次堆調整操作。兩次操作時間相加還是O(N * logN)。故堆排序的時間複雜度為O(N * logN)。
平均時間複雜度:O(n*log n)
最好的情況:如果根據待排序陣列建的堆是最大堆,則不需要建堆操作,但需要O(n*log n)次堆調整操作,時間複雜度為O(n*log n)。
最壞的情況:如果根據待排序陣列建的堆是最小堆,不僅要O(n*log n)的建堆操作,還需要O(n*log n)次堆調整操作,時間複雜度還是O(n*log n)。
穩定性:
不穩定排序
程式碼:
void MaxHeapSort(int* array, int length)
{
//建最大堆
MakeMaxHeap(array, length);
int temp = 0;
for (int i = 0;i<length-1; ++i)
{
//最大堆的根節點和最後一個子葉節點交換
temp = array[0];
array[0] = array[length - 1 - i];
array[length - 1 - i] = temp;
//調整為最大堆
MaxHeapFixDown(array, length - 1 - i, 0);
}
}
七、分而治之—歸併排序
號稱比較類排序中效能最佳者,應用較廣。
原理:
歸併排序是分治法的一個典型的應用,先使每個子序列有序,再使每個子序列間有序。將兩個有序子序列合併成一個有序表,稱為二路歸併。
步驟:首先將有序數列一分二,二分四……直到每個區都只有一個數據,此時每個子序列都可看做有序序列。然後進行合併,每次合併都是有序序列在合併,所以效率比較高。
無序陣列: 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
複雜度:
平均、最好、最壞的時間複雜度都為:O(n*log n)
可以這樣理解:合併需要O(log n)步操作,每步將排好序的子序列合併需要O(n)的操作。那時間複雜度肯定是O(n*log n)。
穩定性:
穩定排序
程式碼:
先考慮下如何將將二個有序數組合並。這個非常簡單,比較二個數組的第一個數,誰小就先取誰,取了後就在對應陣列中刪除這個數。繼續重複前面的操作,直到有一個數組為空,再直接將另一個數組的資料依次取出即可。
//合併兩個有序的陣列成一個有序的陣列
void MergeArray(int* arrayA, int lengthA, int* arrayB, int lengthB, int* temp)
{
int i = 0, j = 0, k = 0;
while (i < lengthA&&j < lengthB)
{
if (arrayA[i] < arrayB[j])
temp[k++] = arrayA[i++];
else
temp[k++] = arrayB[j++];
}
while (i < lengthA)
{
temp[k++] = arrayA[i++];
}
while (j < lengthB)
{
temp[k++] = arrayB[j++];
}
}
先遞迴地分解陣列到每個區只有一個數據,再遞迴的合併陣列(參考上方程式碼可寫出下方程式碼):
void MergeArray(int* array, int first, int mid, int last, int* temp)
{
//將a[first...mid]和a[mid+1...last]合併
int i = first, j = mid + 1, k = 0;
int lengthA = mid+1, lengthB = last+1;
while (i < lengthA&&j < lengthB)
{
if (array[i] < array[j])
temp[k++] = array[i++];
else
temp[k++] = array[j++];
}
while (i < lengthA)
{
temp[k++] = array[i++];
}
while (j < lengthB)
{
temp[k++] = array[j++];
}
for (i = 0; i < k; ++i)
{
array[first + i] = temp[i];
}
}
void MergeSort(int* array, int first, int last, int* temp)
{
if (first >= last)
return;
int mid = (first + last) / 2;
MergeSort(array, first, mid, temp);//左邊有序
MergeSort(array, mid + 1, last, temp);//右邊有序
MergeArray(array, first, mid, last, temp);//合併兩個有序的子序列
}
//總結:有些書上的歸併排序程式碼,是在MergeArray()或MergeSort()中申請temp[]數
//組的記憶體,這樣的做法,非常浪費效能!像我這樣,So Easy~
另外,由於要申請和陣列大小相同的空間n,即空間複雜度為O(n)。這是速度和空間的相愛相殺,總要找一個平衡點才好~
總結:歸併排序在處理很多個數據的時候,是非常快的!而當,資料個數小於50的時候,還不如冒泡快!哈哈哈~
線性時間非比較類排序
八、集中資料的排序—計數排序
計數排序的優勢在於在對於較小範圍內的整數排序。它的複雜度為Ο(n+k)(其中k是待排序數的範圍),快於任何比較排序演算法,缺點就是非常消耗空間。很明顯,如果而且當O(k)>O(n*log(n))的時候其效率反而不如基於比較的排序。
原理:
有n個數,待排序的元素是0到k之間的整數。對於每一個元素x,計算小於或等於元素x的元素的個數,然後根據計數情況將元素放入到相應的位置。
要求:待排序的元素中最大數值不能太大。
複雜度:
分析:為了計算出每個數出現的次數,需要遍歷該n個數,也就需要O(n)次;小於x的要累加,累加總次數為k次,也就需要O(k)次。
時間複雜度:O(n+k)
空間複雜度:O(k)
穩定性:
穩定
程式碼:
void CountSort(int* array, int length, int min, int max)
{
//length為陣列的長度,min為最小值,max為最大值。
int i = 0;
int j = 0;
int sizeOfCount = max - min + 1;
int* count = new int[sizeOfCount];
memset(count, 0, sizeof(int) * sizeOfCount);
for (i = 0;i<length; ++i)
count[array[i] - min]++;
for (i = 0, j = 0; i < sizeOfCount;)
{
if (count[i] != 0)
{
array[j++] = min + i;
count[i]--;
}
else
++i;
}
delete[] count;
}
注意:計數排序是典型的以空間換時間的排序演算法,對待排序的資料有嚴格的要求。比如待排序的數值中包含負數,最大值很大,最好就別用了。
九、基數排序
屬於“分配式排序”,又稱“桶子法”。
原理:
將整形10進位制按每位拆分,然後從低位到高位依次比較各個位。主要分為兩個步驟:
1.分配,先從個位開始,根據位值(0-9)分別放到0~9號桶中(比如32,個位為2,則放入2號桶中);
2.收集,再將放置在0~9號桶中的資料按順序放到陣列中;
重複1和2,從個位到最高位。
複雜度:
時間複雜度:O(dn)(d即表示整形的最高位數)
空間複雜度:O(10n) (10表示0~9,用於儲存臨時的序列)
穩定性:
穩定
十、桶排序
桶排序也是分配排序的一種,但它是基於比較排序的,這也是與基數排序最大的區別所在。
原理:
桶排序演算法想法類似於散列表。首先,假設待排序的元素符合某種均勻分佈,例如資料均勻分佈在[ 0,100)區間上,則可將此區間劃分為10個小區間,稱為桶,對散佈到同一個桶中的元素再排序。
例如待排序列K= {27、 86、 27、 57、 12、 4、 98、 56}。這些資料全部在1—100之間。因此我們定製10個桶,然後確定對映函式f(k)=k/10。則第一個元素27將定位到第2個桶中(27/10=2)。依次將所有元素全部放入桶中,並在每個非空的桶中進行快速排序。
複雜度:
對n個元素進行桶排序的時間複雜度分為兩個部分:
1.迴圈計算每個元素的桶對映函式,這個時間複雜度是O(N)。
2.利用快速排序演算法對每個桶內的所有資料進行排序,對於N個待排資料,M個桶,平均每個桶[N/M]個數據,則桶內排序的時間複雜度為 O(M*N/M*log(N/M)) = O(N*log(N/M))。
平均時間複雜度為線性的O(N+N*log(N/M))。
最好的時間複雜度為:O(N),也就是每個桶只有一個數的情況。
穩定性:
穩定
八大排序演算法動態圖文講解
看完上面的內容,再來看這個圖解,一目瞭然,便可銘記於心!