排序算法對你們來講確定都不陌生吧,做爲最基礎且最重要的算法之一,在面試中經典排序算法也常常被要求手撕代碼。但是排序算法實在是太多了(見下圖),有些名字聽起來都莫名其妙的,好比雞尾酒排序,侏儒排序,煎餅排序等。固然,這篇文章會爲你們講解衆多排序算法中最經典的部分,也是你們最熟悉的幾種算法,包括
冒泡排序
、插入排序
、選擇排序
、歸併排序
、計數排序
、基數排序
、桶排序
、希爾排序
、堆排序
。同時也會利用一些手繪圖來幫助你們更好地理解,但願你們在閱讀完本文章後都可以有所收穫。ios
聲明: 在下面講解的全部算法中,咱們默認須要對待處理的數組進行升序排序。即排序好的數組中,元素的大小從左到右遞增排序。c++
在正式開始講解各類排序算法以前,我還但願你們思考一個問題。什麼樣的排序算法纔是一個好的算法,各類各樣的排序算法它們的應用場景又有什麼不一樣?但願你們在讀完這篇文章以後可以有一個答案。git
其實,要想真正學好排序算法,咱們要作的不只僅是瞭解它的算法原理,而後背下代碼就完事。更重要的是,咱們要學會去分析和評價一個排序算法。那麼對於這麼多的排序算法,咱們應該關注它們的哪些方面呢?面試
分析一個算法的好壞,第一個固然就是應該分析該算法的時間複雜度
。排序算法須要對一組數據進行排序,在實際的工程中,數據的規模多是10個、100個,也多是成千上萬個。同時,對於要進行排序處理的數據,多是接近有序的,也多是徹底無序的。所以,在分析其時間複雜度時,咱們不只要考慮平均狀況下的時間複雜度,還要分析它在最好狀況
以及最壞狀況
下代碼的執行效率有何差別。算法
對於一個常見的排序算法來講,執行過程當中每每會涉及兩個操做步驟,一個是進行元素的比較
,二是對元素進行交換
或者移動
。因此在分析排序算法的時間複雜度時,也要特別注意算法實現過程當中不一樣的元素比較
和交換
(或移動
)的次數。shell
這裏須要引入一個新的概念,原地排序
。原地排序就是指在排序過程當中沒必要申請額外的存儲空間,只利用原來存儲待排數據的存儲空間進行比較和排序的排序算法。換句話說,原地排序不會產生多餘的內存消耗。api
對於通常的算法,咱們通常只須要分析它的時間複雜度
和空間複雜度
,可是對於排序算法來講,咱們還有一個很是重要的分析指標,那就是排序算法的穩定性
。數組
穩定性
是指,在須要進行排序操做的數據中,若是存在值相等的元素,在排序先後,相等元素之間的排列順序不發生改變。數據結構
你們可能會想,反正都是相等的元素,經過排序後誰在前誰在後有什麼不同呢?對排序算法進行穩定性分析又有什麼實際意義呢?ide
其實,在學習數據結構與算法的過程當中,咱們解決的問題基本上都是對簡單的數字進行排序。這時,咱們考慮其是否穩定彷佛並無什麼意義。
可是在實際應用中,咱們面對的數據對象每每都是複雜的,每一個對象可能具備多個數字屬性且每一個數字屬性的排序都是有意義的。因此在排列時,咱們須要關注每一個數字屬性的排序是否會對其餘屬性進行干擾。
舉個例子,假如咱們要給大學中的學生進行一個排序。每一個學生都有兩個數字屬性,一個是學生所在年級,另外一個是學生的年齡,最終咱們但願按照學生年齡大小進行排序。而對於年齡相同的同窗,咱們但願按照年級從低到高的順序排序。那麼要知足這樣的需求,咱們應該怎麼作呢?
第一個想到的,固然就是先對學生的年齡進行排序,而後再在相同年齡的區間裏對年級進行排序。這種辦法很直觀且彷佛沒什麼問題,可是仔細一想,會發現若是咱們要進行一次完整的排序,咱們須要採用5次排序算法(按年齡排序1次,四個年級分別排序4次)。那麼咱們有沒有更好地解決辦法呢?
若是咱們利用具備穩定性
的排序算法,這個問題就會更好地解決了。咱們先按照年級對學生進行排序,而後利用穩定的排序算法,按年齡進行排序。這樣,只須要運用兩次排序,咱們就完成了咱們的目的。
這是由於,穩定的排序算法可以保證在排序過程當中,相同年齡的同窗,在排序以後,他們的順序不發生改變。因爲第一次咱們已經將學生按年級排序好了,因而在第二次排序時,咱們運用穩定的排序算法,相同年齡的學生依舊按年級保持順序。
瞭解如何分析排序算法後,接下來就能夠開始下面各類排序算法的學習了。
在講解插入排序
以前,咱們先來回顧一下,在一個有序數組中,咱們是如何插入一個新的元素並使數組保持有序的呢?
咱們須要遍歷整個數組,直到找到該元素應該插入的位置,而後將後面相應的元素日後移動,最後插入咱們的目標元素。(插入過程以下圖)
插入排序其實就是藉助這樣的思想,首先咱們將數組中的數據分爲兩個區間,一個是已排序區間
,另外一個是未排序區間
,同時這兩個區間都是動態
的。開始時,假設最左側的元素已被排序,即爲已排序區間,每一次將未排序區間的首個數據放入排序好的區間中,直達未排序空間爲空。
#include<iostream> #include<vector> using namespace std; void InsertionSort(vector<int>&, int); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; InsertionSort(test, test.size()); for (auto x : test) cout << x << " "; return 0; } void InsertionSort(vector<int>& arr, int len) { for (int i = 1; i < len; ++i) { //注意i從1開始 int key = arr[i]; //須要插入的元素 int j = i - 1; //已排序區間 while ((j >= 0) && (arr[j] > key)) { arr[j + 1] = arr[j]; //元素向後移動 j--; } arr[j + 1] = key; } }
插入排序的時間複雜度?
最好狀況
: 即該數據已經有序,咱們不須要移動任何元素。因而咱們須要從頭至尾遍歷整個數組中的元素O(n).
最壞狀況
: 即數組中的元素恰好是倒序的,每次插入時都須要和已排序區間中全部元素進行比較,並移動元素。所以最壞狀況下的時間複雜度是O(n^2).
平均時間複雜度
:相似咱們在一個數組中插入一個元素那樣,該算法的平均時間複雜度爲O(n^2).
插入排序是原地排序嗎?
從插入排序的原理中能夠看出,在排序過程當中並不須要額外的內存消耗,也就是說,插入排序是一個原地排序算法
。
插入排序是穩定的排序算法嗎?
其實,咱們在插入的過程當中,若是遇到相同的元素,咱們能夠選擇將其插入到以前元素的前面也能夠選擇插入到後面。因此,插入排序能夠是穩定
的也多是不穩定的。
選擇排序和插入排序相似,也將數組分爲已排序
和未排序
兩個區間。可是在選擇排序的實現過程當中,不會發生元素的移動
,而是直接進行元素的交換
。
選擇排序的實現過程: 在不斷未排序
的區間中找到最小
的元素,將其放入已排序
區間的尾部
。
#include<iostream> #include<vector> using namespace std; void SelectionSort(vector<int>&); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; SelectionSort(test); for (auto x : test) cout << x << " "; return 0; } void SelectionSort(vector<int>& arr) { for (int i = 0; i < arr.size()-1; i++) { int min = i; for (int j = i + 1; j < arr.size(); j++) if (arr[j] < arr[min]) min = j; swap(arr[i], arr[min]); } }
選擇排序的時間複雜度?
最好狀況
,最壞狀況
:都須要遍歷未排序區間,找到最小元素。因此都爲O(n^2).所以,平均複雜度也爲O(n^2).
選擇排序是原地排序嗎?
與插入排序同樣,選擇排序沒有額外的內存消耗,爲原地排序算法
。
插入排序是穩定的排序算法嗎?
答案是否認
的,由於每次都要在未排序區間找到最小的值和前面的元素進行交換,這樣若是遇到相同的元素,會使他們的順序發生交換
。
好比下圖的這組數據,使用選擇排序算法來排序的話,第一次找到最小元素1,與第一個2交換位置,那前面的2和後面的2順序就變了,因此就不穩定
了。
冒泡排序和插入排序和選擇排序不太同樣。冒泡排序每次只對相鄰
兩個元素進行操做。每次冒泡操做,都會比較
相鄰兩個元素的大小,若不知足排序要求,就將它倆交換
。每一次冒泡,會將一個元素
移動到它相應的位置,該元素就是未排序元素中最大
的元素。
#include<iostream> #include<vector> using namespace std; void BubbleSort(vector<int>&); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; BubbleSort(test); for (auto x : test) cout << x << " "; return 0; } void BubbleSort(vector<int>& arr) { for (int i = 0; i < arr.size() - 1; i++) for (int j = 0; j < arr.size() - i - 1; j++) if (arr[j] > arr[j+1]) swap(arr[j], arr[j+1]); }
若是咱們仔細觀察冒泡排序算法,咱們會注意到在第一次冒泡中,咱們已經將最大
的元素移到末尾。在第二次冒泡中,咱們將第二大
元素移至倒數第二個位置,而後以此類推,因此很容易想到利用遞歸
來實現冒泡排序。
遞歸思路:
#include<iostream> #include<vector> using namespace std; void Recursive_BubbleSort(vector<int>&, int); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; Recursive_BubbleSort(test,test.size()); for (auto x : test) cout << x << " "; return 0; } void Recursive_BubbleSort(vector<int>& arr, int n) { if (n == 1) return; for (int i = 0; i < arr.size() - 1; i++) { if (arr[i] > arr[i + 1]) swap(arr[i], arr[i + 1]); } Recursive_BubbleSort(arr, n - 1); }
冒泡排序的時間複雜度?
最好狀況
:咱們只須要進行一次冒泡操做,沒有任何元素髮生交換,此時就能夠結束程序,因此最好狀況時間複雜度是O(n).
最壞狀況
: 要排序的數據徹底倒序
排列的,咱們須要進行n次冒泡操做,每次冒泡時間複雜度爲O(n),因此最壞狀況時間複雜度爲O(n^2)。
平均複雜度
:O(n^2)
冒泡排序是原地排序嗎?
冒泡的過程只涉及相鄰數據之間的交換
操做而沒有額外的內存消耗,故冒泡排序爲原地排序算法
。
冒泡排序是穩定的排序算法嗎?
在冒泡排序的過程當中,只有每一次冒泡操做纔會交換
兩個元素的順序。因此咱們爲了冒泡排序的穩定性,在元素相等的狀況下,咱們不予交換,此時冒泡排序即爲穩定的排序算法
。
接下來將爲你們介紹兩種
最重要同時也
最經常使用的排序算法,你們必定要提起精神認真看了,這但是10次面試9次都會問到的排序算法。可是在介紹這兩種排序算法以前還須要給你們講一講什麼是
分治思想
。
在計算機科學中,分治法
是基於多項分支遞歸
的一種重要的算法思想。從名字能夠看出,「分治」也就是「分而治之」的意思,就是把一個複雜的問題分紅兩個或多個相同或相似的子問題,直到子問題能夠簡單直接地解決,原問題的解即爲子問題的合併
。
分治算法
通常都是用遞歸
來實現的,具體的分治算法能夠按照下面三個步驟來解決問題:
該算法是利用分治思想
解決問題的一個很是典型的應用,歸併排序的基本思路就是先把數組一分爲二,而後分別把左右數組排好序,再將排好序的左右兩個數組合併成一個新的數組,最後整個數組就是有序的了。
運用遞歸法實現歸併操做的主要步驟:
- 申請空間,使其大小爲兩個已經排序序列之和,該空間用來存放
合併
後的序列。- 設定
兩個指針
,最初位置分別爲兩個已經排序序列的起始
位置。- 比較兩個指針所指向的元素,選擇
較小
的元素放入到合併空間,並將指針移動到下一位置
。- 重複步驟3直到
某一指針
到達序列尾,而後將另外一序列剩下的全部元素直接複製到合併
序列尾
#include<iostream> #include<vector> using namespace std; void Merge(vector<int>& , int , int , int ); void MergeSort(vector<int>& , int , int ); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; MergeSort(test,0,test.size()-1); for (auto x : test) cout << x << " "; return 0; } void Merge(vector<int>& arr, int left, int mid, int right) { int i = left; int j = mid + 1; int k = 0; vector<int> temp(right - left + 1); while (i <= mid && j <= right) temp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++]; while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int m = 0; m < temp.size(); m++) arr[left + m] = temp[m]; } void MergeSort(vector<int>& arr,int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; MergeSort(arr, left, mid); MergeSort(arr, mid + 1, right); Merge(arr, left, mid, right); }
歸併排序中須要用到兩個函數,一個是MergeSort
函數,一個是Merge
函數。MergeSort
函數的做用是把數組中left至right的元素所有排列好。而Merge
函數的做用是把左右兩個已經排序好的數組合併
成一個數組。
Merge
函數的編寫很是重要,首先咱們須要建立一個新的數組temp,數組大小爲right-left+1.而後定義兩個下標i和j, 其中i=left, j=mid+1,i表示第一個數組的起始位置,j表示第二個數組的起始位置。同時還須要一個下標k來標記temp數組中填入元素的位置。
接下來開始遍歷兩個數組,比較i和j所指元素的大小,將較小者放入temp數組中,同時該較小者下標和k向後移動。當其中一個子數組循環完後,將剩下數組中的元素依次放入temp數組中。
最終,將temp中已排序好的數組拷貝回原數組array,再返回通過歸併排序好的數組。
MeergeSort
函數主要是用於遞歸調用。當right >= left時,就直接return。不然,找到數組的中間下標,將數組一分爲二,分別兩邊兩邊數組進行歸併排序,最後將兩個數組用Merge
函數合併起來。
歸併排序的時間複雜度?
歸併排序的遞推公式爲T(n)=2*T(n/2)+n
該遞歸式代表,對n個元素遞歸排序所需時間複雜度,等於左右子區間n/2個元素分別遞歸排序的時間,加上將兩個已排好的子區間合併起來的時間O(n)
當遞歸循環至最後一層時,即n=1時,T(1)=1,因而能夠推導出歸併排序的時間複雜度爲O(nlongn)
歸併排序是原地排序嗎?
從原理中能夠看出,在歸併排序過程當中咱們須要分配臨時數組temp,因此不是
原地排序算法,空間複雜度爲O(n).
歸併排序是穩定的排序算法嗎?
當咱們遇到左右數組中的元素相同時,咱們能夠先把左邊的元素放入temp數組中,再放入右邊數組的元素,這樣就保證了相同元素的先後順序不發生改變。因此,歸併排序是一個穩定
的排序算法。
快速排序
,也就是咱們常說的「快排」
。其實,快排也是利用的分治思想
。它具體的作法是在數組中取一個基準pivot,pivot位置能夠隨機選擇(通常咱們選擇數組中的最後一個元素)。選擇完pivot以後,將小於pivot的全部元素放在pivot左邊,將大於pivot的全部元素放在右邊。最終,pivot左側元素都將小於右側元素。接下來咱們遞歸將左側的子數組和右側子數組進行快速排序。若是左右兩側的數組都是有序的話,那麼咱們的整個數組就處於有序的狀態了。
快速排序的主要步驟爲:
基準值
:從數組中挑出一個元素,稱爲「基準」(pivot)分割
:從新排序數組,全部比pivot小的元素擺放在pivot前面,全部比pivot值大的元素放在pivot後面(與pivot值相等的數能夠到任何一邊)。遞歸
排序子數組:遞歸地將小於pivot元素的子序列和大於pivot元素的子序列進行快速排序。底部
的判斷條件是數列的大小是零或一,此時該數列顯然已經有序。#include<iostream> #include<vector> using namespace std; int partition(vector<int>& , int , int ); void QuickSort(vector<int>& , int , int ); int main() { vector<int> test = { 3, 7, 6, 4, 5, 1, 2, 8 }; QuickSort(test,0,test.size()-1); for (auto x : test) cout << x << " "; return 0; } int partition(vector<int>& arr, int left, int right) { int pivot = right; int location = left; for (int i = left; i < right; i++) { if (arr[i] < arr[pivot]) { int temp = arr[i]; arr[i] = arr[location]; arr[location] = temp; location++; } } int temp = arr[pivot]; arr[pivot] = arr[location]; arr[location] = temp; return location; } void QuickSort(vector<int>& arr,int left, int right) { if (left >= right) return; int pivot = partition(arr, left, right); QuickSort(arr, left, pivot-1); QuickSort(arr, pivot + 1, right); }
快速排序算法中有兩個函數,QuickSort
函數和partition
函數。partition
函數的做用返回pivot下標,意思是此時,全部在pivot左側的元素都比pivot的值小,在右側的值比pivot大。接下來對左右兩側的數組遞歸調用QuickSort
函數進行快排。
咱們每次指定pivot指向最後一個元素,同時定義一個變量location,用來標記pivot最後應該置於的位置。在location左側的全部元素都是比pivot值小的,從location開始,右側全部元素都比pivot大。
只要遍歷到的元素比pivot的值小,就與location所指的元素進行交換,同時location++,更新pivot應該在的位置。
數組遍歷結束,最後將元素pivot與location所指元素進行交換,這樣,pivot左側的元素就所有比pivot小,右側元素所有比pivot大了。
快速排序的時間複雜度?
快排的時間複雜度也能夠像歸併排序那樣用遞推公式計算出來。若是每次分區都恰好把數組分紅兩個大小同樣的區間,那麼它的時間複雜度也爲O(nlogn).可是若是遇到最壞狀況下,該算法可能退化成O(n^2).
快速排序是原地排序嗎?
根據上述原理能夠知道,快速排序也沒有額外的內存消耗,故也是一種原地排序算法
。
快速排序是穩定的排序算法嗎?
由於分區操做涉及元素之間的交換(以下圖),當遍歷到第一個小於2的元素1時,會交換1與前面的3,所以兩個相等3的順序就發生了改變。因此快速排序不是
一個穩定的排序算法。
上面講的5種排序算法過程都是基於元素之間的比較和交換,因此咱們經常把這種排序算法稱爲比較類排序
。同時上面介紹的5種排序算法的時間複雜度最快也只能達到 O(nlogn),因此咱們也稱這類排序算法稱爲非線性時間比較類排序
。接下將介紹另外幾種
線性時間非比較類排序
,雖然這幾種排序算法彷佛效率更高,可是通過下面的介紹你就會發現,它們也並非萬能的。
計數排序不是基於比較的排序算法,其核心在於將輸入的數據值
轉化爲鍵
存儲在額外開闢
的數組空間中。簡單點說,就是用額外的數組記錄輸入數據中各數據出現的次數
,而後將數據按出現頻數
取出。
其中array爲原數組,count數組用於計算每一個元素出現次數。
計數排序實現步驟:
計數排序思路比較簡單,先找到數組中元素最大值max,額外分配一個大小爲max+1的數組用於計算元素出現次數。最後從小到大按元素個數更新原數組。
#include<iostream> #include<vector> using namespace std; void CountSort(vector<int>&); int main() { vector<int> test = { 3, 3, 6, 2, 5, 1, 2, 8 }; CountSort(test); for (auto x : test) cout << x << " "; return 0; } int FindMax(vector<int> arr) { int max = 0; for (auto x : arr) if (x > max) max = x; return max; } void CountSort(vector<int>& arr) { int max = FindMax(arr); vector<int> count(max+1,0); //須要分配數組大小,節省了數組空間 for (int i = 0; i < arr.size(); i++) { //開始計數 count[arr[i]]++; // } int index = 0; for (int k = 0; k <count.size(); k++) { for (int cnt = 0; cnt < count[k]; cnt++) arr[index++] = k; } }
你們從上面的圖中能夠看到,咱們在計數時分配了計數數組count,可是count數組的兩個位置根本沒有計入任何數字。那是由於該數組中最小的元素爲2,不可能存在0和1了。所以咱們能夠對咱們以前的代碼進行改進,找到數組中的最大元素和最小元素,而後分配max-min+1空間的數組便可。
#include<iostream> #include<vector> using namespace std; void CountSort(vector<int>&); int main() { vector<int> test = { 3, 3, 6, 2, 5, 1, 2, 8 }; CountSort(test); for (auto x : test) cout << x << " "; return 0; } int FindMax(vector<int> arr) { int max = arr[0]; for (auto x : arr) if (x > max) max = x; return max; } int FindMin(vector<int> arr) { int min = arr[0]; for (auto x : arr) if (x < min) min = x; return min; } void CountSort(vector<int>& arr) { int max = FindMax(arr); int min = FindMin(arr); vector<int> count(max - min + 1,0); //須要分配數組大小,節省了數組空間 for (int i = 0; i < arr.size(); i++) { //開始計數 count[arr[i]-min]++; // } int index = 0; for (int k = 0; k <count.size(); k++) { for (int cnt = 0; cnt < count[k]; cnt++) arr[index++] = k+min; } }
注意count數組腳標與原數組腳標之間的轉換
通過改進以後的算法節省了必定的空間,可是細心的朋友會發現,每次排序的結果是根據count數組中元素出現次數直接對原數組進行更新。這樣的話,咱們原數組中的元素被覆蓋,算法的穩定性
也就得不到保障。那麼咱們應該怎麼保證計數排序先後數組數據的先後一致性
和穩定性
呢?
接下的講解會有一些繁瑣,但願你們結合文字和圖片慢慢理解。
首先,咱們須要對咱們以前的count數組進行變形,咱們將數組中的每個元素進行累加。換句話說,就是從第二個元素開始,每個元素都加上前面元素之和。
所以咱們count數組進行以下變形:
那麼這樣累加的意義是什麼呢?
其實,這時count數組中的元素值表示相應整數最終的排序位置。
例如咱們的數組[5,2,3,6,2,5],count數組中count[4]=6
,(count數組中下標爲4,可是表示存儲元素的實際大小爲6),因此元素6最終會位於數組中第6的位置。的確,array一共有6個元素,最大值爲6,故排序後6處於數組的最後位,即第6位。
因爲咱們要保證原數組數據的先後一致性
,因此接下來咱們須要分配一個新的數組
,用於拷貝array中的元素,最終達到有序。
可是還有一個問題,咱們要如何保證計數排序的穩定性
呢?
這裏很巧妙地用到了一個辦法——反向填充數組
創建好count數組後,咱們用k遍歷原數組array.
例如當k=5
時,咱們在count數組中找到對應下標(5-2=3)
的位置,count[3]=5
.也就是說,咱們的元素5最終會在有序數組中的第5位。因爲數組下標從0開始,因此咱們須要將5保存至Sorted_array下標爲4的地方,即Sorted_array[4]=array[5]
。同時存入一個數據後,咱們須要將count數組中該元素對應值減1,表示下一個相等元素位置向前移動,直到咱們遍歷完整個數組array.
上面的文字描述能夠簡單表述爲:
k=5 -> array[k]=5 -> array[k]-min=3
count[3]=5 -> sorted_array[4] = array[5]
因爲最後咱們將原數組反向填充
到新數組中,同時指向位置的指針不斷向前移動
,這樣,咱們就保證了咱們計數排序算法的穩定性
。
#include<iostream> #include<vector> using namespace std; vector<int> CountSort(vector<int>&); int main() { vector<int> test = { 3, 3, 6, 2, 5, 1, 2, 8 }; vector<int> newarray = CountSort(test); for (auto x : newarray) cout << x << " "; return 0; } int FindMax(vector<int> arr) { int max = arr[0]; for (auto x : arr) if (x > max) max = x; return max; } int FindMin(vector<int> arr) { int min = arr[0]; for (auto x : arr) if (x < min) min = x; return min; } vector<int> CountSort(vector<int>& arr) { int max = FindMax(arr); int min = FindMin(arr); vector<int> count(max - min + 1,0); //須要分配數組大小,節省了數組空間 vector<int> sortedarray(arr.size(), 0); for (int i = 0; i < arr.size(); i++) { //開始計數 count[arr[i]-min]++; // } for (int j = 1; j < count.size(); j++) { count[j] = count[j - 1] + count[j]; } for (int k = arr.size()-1; k >=0; k--) { sortedarray[count[arr[k]-min]-1] = arr[k]; count[arr[k] - min]--; } return sortedarray; }
若是對整個過程仍是不太明白,建議你們結合手繪圖解,以及代碼再仔細看一下。下面總結一下基數排序算法的整個過程。
計數排序算法的基本步驟:
count[i]
項,每放一個元素,就將count[i]-1
.計數算法的時間複雜度爲O(n+k),因爲咱們須要分配額外的數組空間,空間複雜度也爲O(n+k),即不是
原地排序算法.同時咱們經過反向填充數組的辦法保證了計數排序算法的穩定性
。
因爲用來計數的數組count的長度取決於待排序數組中數據的範圍,這使得對於數據範圍很大的數組,計數排序須要消耗大量額外的內存。也就是說計數排序具備必定的侷限性,雖然做爲一種線性時間複雜度的排序,計數排序要求輸入必須是肯定範圍的整數。若是數據範圍太大,意味着咱們須要額外消耗的內存也就更大,因此計數排序也不是那麼萬能的。
桶排序中的桶其實有點相似於計數排序中的「鍵」,不過這裏的桶表明的是一個區間範圍。桶排序算法的實現就是將數組分配到有限量的桶裏,而後對每一個桶分別進行排序(有可能用到其餘排序算法),排序完後再將桶裏的數據依次拿出,便可獲得排序後的數列。
桶排序的步驟:
4.從不爲空的桶中按順序拿出元素放入本來的數組中。
#include<iostream> #include<vector> #include<algorithm> #include<queue> using namespace std; void bucketsort(vector<int>&); int main() { vector<int> test = { 40, 8, 2, 15, 37, 42, 11, 29, 24, 7 }; bucketsort(test); for (auto x : test) cout << x << " "; return 0; } void bucketsort(vector<int>& arr) { queue<int> buckets[10]; for (int digit = 1; digit <= 1e9; digit *= 10) { for (int elem : arr) { buckets[(elem / digit) % 10].push(elem); } int idx = 0; for (queue<int>& bucket : buckets) { while (!bucket.empty()) { arr[idx++] = bucket.front(); bucket.pop(); } } } }
桶排序思路比較簡單,若是桶的數量等於數組元素的數量,那麼桶排序就變成了計數排序。因此在代碼中能夠看到與計數排序類似的地方。
假設咱們須要排序的數組元素有n個,同時用m個桶來存儲咱們的數據。那麼平均每一個桶的元素個數爲k = n/m個.若是在桶內咱們使用快速排序,那麼時間複雜度爲klogk,總的時間複雜度即爲nlog(n/m).若是桶的數量接近元素的數量,桶排序的時間複雜度就是O(n) 了。可是若是運氣很差,全部的元素都到了一個桶了,那麼它的時間複雜度就退化成 O(nlogn) 了。
基數排序其實也是一個非比較型的整數排序算法,其原理是將整數按位切割成不一樣的數字,而後按每一個位數分別比較。可是在計算機中字符串和浮點數也能夠用整數表示,因此也能夠用基數排序。
因而可知,基數排序是基於位數的比較,因此再處理一些位數較多的數字時基數排序就有明顯的優點了。例如在給手機號排序,或者給一些較長的英語專業名詞排序等。
基數排序的主要步驟:
#include<iostream> #include<vector> using namespace std; void radixsort(vector<int>&); int maxbit(vector<int>); int main() { vector<int> test = { 77, 15, 31, 50, 8, 100, 24, 3, 43, 65 }; radixsort(test); for (auto x : test) cout << x << " "; return 0; } int maxbit(vector<int> arr) //求數據的最大位數 { int max = arr[0]; for (auto x : arr) if (x > max) max = x; int bit = 1; while (max >= 10) { max /= 10; ++bit; } return bit; } void radixsort(vector<int>& arr) //基數排序 { int bit = maxbit(arr); vector<int> tmp(arr.size()); vector<int> count(10); //0-9計數器 int i, j, k; int radix = 1; for (i = 1; i <= bit; i++) //進行bit次排序 { for (j = 0; j < 10; j++) count[j] = 0; //每次分配前清空計數器 for (j = 0; j < arr.size(); j++) { k = (arr[j] / radix) % 10; count[k]++; } for (j = 1; j < 10; j++) count[j] = count[j - 1] + count[j]; for (j = arr.size() - 1; j >= 0; j--) { k = (arr[j] / radix) % 10; tmp[count[k] - 1] = arr[j]; count[k]--; } for (j = 0; j < arr.size(); j++) arr[j] = tmp[j]; radix = radix * 10; } }
基數排序算法中,基於位數0-9的排序有點相似計數排序,若是你們對代碼有所疑惑,能夠多看幾回計數排序的思路和代碼。
根據上面的講解你們也能夠很容易地看出,基數排序的時間複雜度是O(k*n),其中n是排序的元素個數,k是元素中最大元素的位數。所以,基數算法也是線性的時間複雜度,可是因爲k取決於數字的位數,因此在某些狀況下該算法不必定優於O(nlogn).
到目前爲止,咱們已經介紹了8種排序算法,其實這8種排序算法已經包括了全部的基本實現思想。可是「革命還沒有成功,同志仍需努力」。接下來還會繼續爲你們介紹另外兩種比較特殊的排序算法——希爾排序
和堆排序
。
希爾排序
,也稱遞減增量排序算法
,是插入排序
的一種更高效的改進
版本。
因此在具體講解希爾排序以前,咱們仍是先來回顧一下插入排序的整個實現過程:
首先咱們將數組中的數據分爲兩個區間,一個是已排序區間,另外一個是未排序區間,同時這兩個區間都是動態的,須要添加和移動元素。開始時,假設最左側的一個元素已被排序,即爲已排序區間,每一次將未排序區間的首個數據插入排序好的區間中,直達未排序空間爲空。
那麼插入排序有哪些不足的地方呢?
在插入排序中,咱們每次只交換兩個相鄰
元素,當一個元素須要向前移動至它的正確位置時,只能一步一步地移動。所以插入排序的平均時間複雜度爲O(n^2).
而希爾排序
的想法是實現具備必定間隔
元素之間的交換,即首先排序有必定間隔的元素,同時按順序依次減少間隔
,這樣就可讓一個元素一次性地朝最終位置前進一大步,當間隔爲1時就是插入排序了。
希爾排序算法步驟:
#include<iostream> #include<vector> #include<algorithm> #include<queue> using namespace std; void shell_sort(vector<int>&); int main() { vector<int> test = { 40, 8, 2, 15, 37, 42, 11, 29, 24, 7 }; shell_sort(test); for (auto x : test) cout << x << " "; return 0; } void shell_sort(vector<int>& arr) { for (int gap = arr.size() / 2; gap > 0; gap /= 2) { for (int i = gap; i < arr.size(); i++) { int temp = arr[i]; int j; for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) { arr[j] = arr[j - gap]; } arr[j] = temp; } } }
在希爾算法中,步長的選擇尤爲重要。在上面的代碼中,最初的步長選擇爲n/2,而且不斷對步長取半,直到最後爲1.雖然這樣能夠比普通的插入排序O(n^2)更好,可是根據步長的選擇不一樣,希爾排序的平均時間複雜度還能夠更優。下圖爲不一樣步長序列選擇下的最壞時間複雜度。
下面是幾種優秀的步長序列:
- `Shell’s original sequence: N/2 , N/4 , …, 1
- Knuth’s increments: 1, 4, 13, …, (3k – 1) / 2
- Sedgewick’s increments: 1, 8, 23, 77, 281, 1073, 4193, 16577…4j+1+ 3·2j+ 1.
- Hibbard’s increments: 1, 3, 7, 15, 31, 63, 127, 255, 511…
- Papernov & Stasevich increment: 1, 3, 5, 9, 17, 33, 65,…
- Pratt: 1, 2, 3, 4, 6, 9, 8, 12, 18, 27, 16, 24, 36, 54, 81….
由於希爾排序在實現過程當中沒有分配額外的內存空間,因此是一種原地排序算法
。可是因爲希爾排序將數組分組並存在元素的交換,因此並不是
一個穩定的排序算法。
堆排序是利用「堆」這種數據結構實現的一種排序算法。那麼什麼是堆呢?
先給你們簡單介紹一下堆,堆通常具備下面兩個性質:
堆老是一棵徹底二叉樹。(徹底二叉樹是指即除了最底層,其餘層的節點都必須被元素填滿,同時最底層的葉子節點必須所有。)
堆中的任意節點的值都必須大於等於或小於等於它的子節點
其中,將每一個節點的值都大於等於其子節點值的堆稱爲「大頂堆」(max heap),將每一個節點的值小於等於子節點值的堆稱爲「小頂堆」(min heap).
以下圖:
由於咱們經常使用數組來存儲徹底二叉樹,因此咱們也能夠用數組來存儲堆。
堆在數組中的存儲圖以下:
由上圖咱們能夠看出對於堆中元素下標爲i的點:
2*i+1
爲它的左子節點2*i+2
爲它的右子節點i/2
是它的前繼節點簡單介紹完了堆,接下來就讓咱們看看這些對於堆有哪些具體的操做吧:
(咱們經常用「大頂堆」用於堆排序,因此下面將要實現的堆都默認爲「大頂堆」。)
對於「大頂堆」,它的每一個節點的值都必須大於等於它的子節點的值。因此說爲了維護一個「大頂堆」,咱們須要設計一個算法將不知足「大頂堆」性質的節點進行修改。修改思路是將其與子節點相比較,若是它的值小於它的子節點,就將其與子節點中最大值發生交換,而後繼續對該點進行判斷,直到到達合適的地方。
咱們已經知道怎麼維護一個堆了,那麼咱們怎麼將用數組建一個「大頂堆」呢?
當咱們須要維護一個大小爲n的堆時,通常從下標n/2的位置不斷向前移動。以下圖,咱們首先判斷下標爲4的元素,其知足堆的定義。而後咱們判斷下標爲3的元素,使其與值爲29的子節點發生交換。依次循環,當下標爲1時,值爲2的節點須要不斷與子節點發生交換,直到葉子節點的位置。具體實現以下圖:
根據「大頂堆」的定義,堆頂元素即爲整個數據中的最大值。因而當咱們建好堆後,咱們將堆頂元素與堆中最後一個元素交換,即將堆中最大元素放在了數組中的最後一個位置。此時,由於咱們將較小的元素放在了堆頂,因此咱們須要對其進行堆維護(heapify)
.維護完成後,堆頂元素爲此時堆中的最大元素,而後繼續重複上面的操做,反向填充數組
,直到最後堆中剩下一個元素,即在數組的首位置。
#include <iostream> #include<vector> using namespace std; void heapSort(vector<int>&, int); int main() { vector<int> arr = { 40, 2, 8, 29, 37, 24, 11, 15, 7, 36 }; heapSort(arr, arr.size()); for (auto x : arr) cout << x << " "; } void heapify(vector<int>& arr, int n, int i) { int largest = i; int l = 2 * i + 1; int r = 2 * i + 2; if (l < n && arr[l] > arr[largest]) largest = l; if (r < n && arr[r] > arr[largest]) largest = r; if (largest != i) { swap(arr[i], arr[largest]); heapify(arr, n, largest); } } void heapSort(vector<int>& arr, int n) { for (int i = n / 2 - 1; i >= 0; i--) heapify(arr, n, i); for (int i = n - 1; i >= 0; i--) { swap(arr[0], arr[i]); heapify(arr, i, 0); //堆的數量減一 } }
上面堆排序算法代碼中用到了兩個函數,heapify
和 heapSort
.其中heapify
函數的做用是對堆中下標爲i的點進行堆維護,使其知足堆的性質。在heapSort
函數中咱們先將堆從下標n/2-1
的位置進行維護,直到成爲「大頂堆」,而後對堆頂元素進行交換和維護循環。最後,數組中的元素即爲有序保存的了。
須要注意的是算法中的heapify
函數須要傳入除數組外的兩個整數,第一個整數是指須要維護堆的大小。在最開始進行堆維護時,咱們須要對整個數組進行維護,n
即爲數組的大小。可是在對堆頂元素和最後位置的元素進行交換後,此時最後一個元素已經在它正確的位置,因此咱們須要維護堆的大小將逐漸減少
,即傳入代碼中的i
.
咱們已經知道,包含n個元素的完整二叉樹的高度爲logn.
而當咱們使用heapify
函數,對某個元素進行維護時,咱們須要繼續將元素與其左,右子元素進行比較,並將其向下推移,直到其兩個子元素均小於其大小。在最壞的狀況下,咱們須要將元素從根移動到葉子節點,進行屢次logn
的比較和交換。在build_max_heap
階段,咱們對n/2
元素執行此操做,所以build_heap
步驟的最壞狀況複雜度爲n/2*log(n) ~ nlogn
。
在排序步驟中,咱們將根元素與最後一個元素交換,並堆放根元素。對於每一個元素,這又須要花費logn
最長時間,由於咱們可能須要將元素從根一直帶到最遠的葉子上。因爲咱們重複了n
次,所以heap_sort
步驟也是nlogn
。
因爲build_max_heap
和heap_sort
步驟是一個接一個地執行的,所以算法複雜度不會增長,而且保持爲nlogn
。
所以,堆排序在全部狀況下,即最好最壞以及平均狀況下的時間複雜度均爲O(nlogn).
到目前爲止,已經爲你們介紹完了十種基本算法。感謝你們的認真閱讀,最後對十種算法的總結以下,但願你們有所收穫!
若是你喜歡個人文章,歡迎關注個人公衆號【Coderoger】瞭解更多LeetCode題解思路以及算法知識!