十二種排序包你滿意(冒泡、插入、歸併、快速排序等包含希爾和計數排序)
前言
排序演算法在電腦科學入門課程中很普遍,在學習排序演算法的時候,涉及到大量的各種核心演算法概念,例如大O表示法,分治法,堆和二叉樹之類的資料結構,隨機演算法,最佳、最差和平均情況分析,時空權衡以及上限和下限,本文就介紹了十二種排序演算法供大家學習。
簡介
排序演算法是用來根據元素對應的比較運算子重新排列給定的陣列的演算法,輸出的陣列是一個根據比較符從小到大或者從大到小依次排列的陣列。比較運算子是用於確定相應資料結構中元素的新順序,比如在整數數組裡面,對應的比較符號就是大於或者小於號,使用者也可以自己定義對應的比較運算子。
比如如果輸入是[4,2,3,1]
,按照從小到大輸出,結果應該是[1,2,3,4]
特性
穩定性
如果在陣列中有兩個元素是相等的,在經過某個排序演算法之後,原來在前面的的那個元素仍然在另一個元素的前面,那麼我們就說這個排序演算法是穩定的。
如果在排序之後,原來的兩個相等元素中在前面的一個元素被移到了後面,那麼這個演算法就是不穩定的。
比如排序之前陣列為[3(a),2,3(b)]
(其中a
和b
分別代表兩個不同的3
),經過某個排序演算法之後是[2,3(a),3(b)]
,那麼這個演算法就是穩定的;如果變成了[2,3(b),3(a)]
,那麼這個演算法是不穩定的。
再比如在按照身高排隊去食堂打飯的過程中,小明和小剛的身高都是170,原來小明在小剛前面,但是經過排序之後小明發現小剛到了他前面了,這樣小明肯定對這個不穩定的排序有意見。
時間複雜度
時間複雜度反映了演算法的排序效率,通常用大O表示法來表示,通常暗示這個演算法需要的最多操作次數的量級,比如\(O(n)\)表示最多需要進行\(n\)量級操作。
空間複雜度
空間複雜度反映了演算法需要消耗的空間,比如\(O(1)\)表示只需要常數量級的空間,不會隨著陣列大小的變化而變化。
如果一個排序演算法不需要額外的儲存空間,可以直接在原來的陣列完成排序操作,這個演算法可以被稱之為原地演算法,空間複雜度是\(O(1)\)
比較排序、非比較排序
如果一個演算法需要在排序的過程中使用比較操作來判斷兩個元素的大小關係,那麼這個排序演算法就是比較排序,大部分排序演算法都是比較排序,比如氣泡排序、插入排序、堆排序等等,這種排序演算法的平均時間複雜度最快也只能是\(O(nlogn)\)。
非比較排序比較典型的有計數排序、桶排序和基數排序,這類排序能夠脫離比較排序時間複雜度的束縛,達到\(O(n)\)級別的效率。
演算法
首先定義基本的交換陣列元素的基本方法,節省後面的程式碼量。
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
氣泡排序
氣泡排序是從左到右依次比較相鄰的兩個元素,如果前一個元素比較大,就把前一個元素和後一個交換位置,遍歷陣列之後保證最後一個元素相對於前面的永遠是最大的。然後讓最後一個保持不變,重新遍歷前n-1
個元素,保證第n-1
個元素在前n-1
個元素裡面是最大的。依此規律直到第2
個元素是前2
個元素裡面最大的,排序就結束了。
因為這個排序的過程很像冒泡泡,找到最大的元素不停的移動到最後端,所以這個排序演算法就叫氣泡排序。
圖片來自這裡
用Java程式碼實現
private void bubbleSort(int[] nums) {
for (int i = nums.length - 1; i >= 1; i--) { // 冒泡得到n-1個最大值
for (int j = 1; j <= i; j++) {
if (nums[j-1]>nums[j])
swap(nums, j, j-1); // 交換得到較大值
}
}
}
氣泡排序的最大特點就是程式碼簡單,短短的五行程式碼就能完成整個排序的操作。
時間複雜度比較穩定不管怎樣都需要\(O(n^2)\)次比較,所以是\(O(n^2)\)的時間複雜度。
空間複雜度是\(O(1)\),所有操作在原來的陣列完成就可以了,不需要額外的空間。
演算法是穩定的,在冒泡的過程中如果兩個元素相等,那麼他們的位置是不會交換的。
選擇排序
選擇排序的思路比較簡單,先找到前n
個元素中最大的值,然後和最後一個元素交換,這樣保證最後一個元素一定是最大的,然後找到前n-1
個元素中的最大值,和第n-1
個元素進行交換,然後找到前n-2
個元素中最大值,和第n-2
個元素交換,依次類推到第2個元素,這樣就得到了最後的排序陣列。
其實整個過程和氣泡排序差不多,都是要找到最大的元素放到最後,不同點是氣泡排序是不停的交換元素,而選擇排序只需要在每一輪交換一次。
原圖來自這裡
程式碼實現:
private void selectionSort(int[] nums) {
for (int i = nums.length - 1; i > 0; i--) {
int maxIndex = 0; // 最大元素的位置
for (int j = 0; j <= i; j++) {
if (nums[maxIndex]<nums[j]) {
maxIndex = j;
}
}
swap(nums, maxIndex, i); // 把這個最大的元素移到最後
}
}
時間複雜度和氣泡排序一樣比較穩定,都需要\(O(n^2)\)次比較,所以時間複雜度是\(O(n^2)\)
空間複雜度是\(O(1)\),不需要額外空間,是原地演算法。
選擇排序最簡單的版本是不穩定的,比如陣列[1,3,2,2]
,表示為[1,3,2(a),2(b)]
,在經過一輪遍歷之後變成了[1,2(b),2(a),3]
,兩個2
之間的順序因為第一個2
和3
的調換而顛倒了,所以不是穩定排序。
不過可以改進一下選擇排序變成穩定的。原來不穩定是因為交換位置導致的,現在如果改成插入操作(不是使用陣列而是連結串列,把最大的元素插入到最後)的話,就能變成穩定排序。比如[1,3,2(a),2(b)]
,在第一輪中變成了[1,2(a),2(b),3]
,這樣就能夠保持相對位置,變成穩定排序。
插入排序
插入排序的核心思想是遍歷整個陣列,保持當前元素左側始終是排序後的陣列,然後將當前元素插入到前面排序完成的陣列的對應的位置,使其保持排序狀態。有點動態規劃的感覺,類似於先把前i-1
個元素排序完成,再插入第i
個元素,構成i
個元素的有序陣列。
圖片來自這裡
簡單程式碼實現:
private void insertionSort(int[] nums) {
for (int i = 1; i < nums.length; i++) { // 從第二個元素開始遍歷
int j = i;
while (j>0&&nums[j]<nums[j-1]) { // 將當前元素移動到合適的位置
swap(nums, j, j-1);
j--;
}
}
}
時間複雜度上,插入排序在最好的情況,也就是陣列已經排好序的時候,複雜度是\(O(n)\),在其他情況下都是\(O(n^2)\)。
空間複雜度是\(O(1)\),不需要額外的空間,是原地演算法。
插入排序是穩定排序,每次交換都是相鄰元素的交換,不會有選擇排序的那種跳躍式交換元素。
希爾排序
希爾排序可以看作是一個氣泡排序或者插入排序的變形。希爾排序在每次的排序的時候都把陣列拆分成若干個序列,一個序列的相鄰的元素索引相隔的固定的距離gap
,每一輪對這些序列進行冒泡或者插入排序,然後再縮小gap
得到新的序列一一排序,直到gap
為1
比如對於陣列[5,2,4,3,1,2]
,第一輪gap=3
拆分成[5,3]
、[2,1]
和[4,2]
三個陣列進行插入排序得到[3,1,2,5,2,4]
;第二輪gap=2
,拆分成[3,2,2]
和[1,5,4]
進行插入排序得到[2,1,2,4,3,5]
;最後gap=1
,全域性插入排序得到[1,2,2,3,4,5]
圖片來自這裡
簡單程式碼實現:
private void shellSor(int[] nums) {
int gap = nums.length >> 1;
while (gap > 0) {
for (int i = 0; i < gap; i++) { // 對每個子序列進行排序
for (int j = i+gap; j < nums.length; j+=gap) { // 插入排序的部分
int temp = j;
while (temp > i && nums[temp] < nums[temp-gap]) {
swap(nums, temp, temp-gap);
temp -= gap;
}
}
}
gap >>= 1;
}
}
Donald Shell於1959年釋出了這種排序演算法,執行時間在很大程度上取決於它使用的間隔,在實際使用中,其時間複雜度仍然是一個懸而未決的問題,基本在\(O(n^2)\)和\(O(n^{4/3})\)之間。
空間複雜度是\(O(1)\),是原地演算法。
這個演算法是不穩定的,裡面有很多不相鄰元素的交換操作。
歸併排序
歸併排序是典型的使用分治思想(divide-and-conquer)解決問題的案例。在排序的過程中,把原來的陣列變成左右兩個陣列,然後分別進行排序,當左右的子陣列排序完畢之後,再合併這兩個子陣列形成一個新的排序陣列。整個過程遞迴進行,當只剩下一個元素或者沒有元素的時候就直接返回。
圖片來自這裡
程式碼如下:
private void mergeSort(int[] nums, int left, int right) { // 需要左右邊界確定排序範圍
if (left >= right) return;
int mid = (left+right) / 2;
mergeSort(nums, left, mid); // 先對左右子陣列進行排序
mergeSort(nums, mid+1, right);
int[] temp = new int[right-left+1]; // 臨時陣列存放合併結果
int i=left,j=mid+1;
int cur = 0;
while (i<=mid&&j<=right) { // 開始合併陣列
if (nums[i]<=nums[j]) temp[cur] = nums[i++];
else temp[cur] = nums[j++];
cur++;
}
while (i<=mid) temp[cur++] = nums[i++];
while (j<=right) temp[cur++] = nums[j++];
for (int k = 0; k < temp.length; k++) { // 合併陣列完成,拷貝到原來的陣列中
nums[left+k] = temp[k];
}
}
時間複雜度上歸併排序能夠穩定在\(O(nlogn)\)的水平,在每一級的合併排序陣列過程中總的操作次數是\(n\),總的層級數是\(logn\),相乘得到最後的結果就是\(O(nlogn)\)
空間複雜度是\(O(n)\),因為在合併的過程中需要使用臨時陣列來存放臨時排序結果。
歸併排序是穩定排序,保證原來相同的元素能夠保持相對的位置。
快速排序
快速排序(有時稱為分割槽交換排序)是一種高效的排序演算法。由英國電腦科學家Tony Hoare於1959年開發並於1961年發表,它在現在仍然是一種常用的排序演算法。如果實現方法恰當,它可以比主要競爭對手(歸併排序和堆排序)快兩到三倍。
其核心的思路是取第一個元素(或者最後一個元素)作為分界點,把整個陣列分成左右兩側,左邊的元素小於或者等於分界點元素,而右邊的元素大於分界點元素,然後把分界點移到中間位置,對左右子陣列分別進行遞迴,最後就能得到一個排序完成的陣列。當子陣列只有一個或者沒有元素的時候就結束這個遞迴過程。
其中最重要的是將整個陣列根據分界點元素劃分成左右兩側的邏輯,目前有兩種演算法,圖片展示的是第一種。
圖片來自這裡
第一種實現,也是圖片中的排序邏輯的實現:
private void quickSort(int[] nums, int left, int right) {
if (left >= right) return;
int lo = left+1; // 小於分界點元素的最右側的指標
int hi = right; // 大於分界點元素的最左側的指標
while (lo<=hi) {
if (nums[lo]>nums[left]) { // 交換元素確保左側指標指向元素小於分界點元素
swap(nums, lo, hi);
hi--;
} else {
lo++;
}
}
lo--; // 回到小於分界點元素陣列的最右側
swap(nums, left, lo); // 將分界點元素移到左側陣列最右側
quickSort2(nums, left, lo-1);
quickSort2(nums, lo+1, right);
}
第二種,不用hi
來標記大於分界點元素的最右側,而是隻用一個lo
來標記最左側。在遍歷整個陣列的過程中,如果發現了一個小於等於分界點元素的元素,就和lo+1
位置的元素交換,然後lo
自增,這樣可以保證lo
的左側一定都是小於等於分界點元素的,遍歷到最後lo
的位置就是新的分界點位置,和最開始的分界點元素位置互換。
private void quickSort(int[] nums, int left, int right) {
if (left>=right) return;
int cur = left + 1; // 從左側第二個元素開始
int lo = left; // 分界點為第一個元素
while (cur <= right) {
if (nums[cur] <= nums[left]) { // 交換位置保證lo的左側都是小於num[left]
swap(nums, lo+1, cur);
lo ++;
}
cur++;
}
swap(nums, left, lo); // 把分界點元素移動到新的分界位置
quickSort(nums, left, lo-1);
quickSort(nums, lo+1, right);
}
時間複雜度在最佳情況是\(O(nlogn)\),但是如果分界點元素選擇不當可能會惡化到\(O(n^2)\),但是這種情況比較少見(比如陣列完全逆序),如果隨機選擇分界點的話,時間複雜度能夠穩定在\(O(nlogn)\)。另外如果元素中相同元素數量比較多的話,也會降低排序效能。
空間複雜度在\(O(logn)\)水平,屬於堆疊呼叫,在最壞的情況下空間複雜度還是\(O(n)\),平均情況下複雜度是\(O(logn)\)
快速排序是不穩定的,因為包含跳躍式交換元素位置。
堆排序
堆排序是一個效率要高得多的選擇排序,首先把整個陣列變成一個最大堆,然後每次從堆頂取出最大的元素,這樣依次取出的最大元素就形成了一個排序的陣列。堆排序的核心分成兩個部分,第一個是新建一個堆,第二個是彈出堆頂元素後重建堆。
新建堆不需要額外的空間,而是使用原來的陣列,一個數組在另一個維度上可以當作一個完全二叉樹(除了最後一層之外其他的每一層都被完全填充,並且所有的節點都向左對齊),對於下標為i
的元素,他的子節點是2*i+1
和2*i+2
(前提是沒有超出邊界)。在新建堆的時候從左向右開始遍歷,當遍歷到一個元素的時候,重新排列從這個元素節點到根節點的所有元素,保證滿足最大堆的要求(父節點比子節點要大)。遍歷完整個陣列的時候,這個最大堆就完成了。
在彈出根節點之後(把根節點的元素和樹的最底層最右側的元素互換),堆被破壞,需要重建。從根節點開始和兩個子節點比較,如果父節點比最大的子節點小,那麼就互換父節點和最大的子節點,然後把互換後在子節點位置的父節點當作新的父節點,和它的子節點比較,如此往復直到最後一層,這樣最大堆就重建完畢了。
圖片來自這裡
簡單java程式碼:
private void heapSort(int[] nums) {
heapify(nums); // 新建一個最大堆
for (int i = nums.length - 1; i >= 1; i--) {
swap(nums, 0, i); // 彈出最大堆的堆頂放在最後
rebuildHeap(nums, 0,i-1); // 重建最大堆
}
}
private void heapify(int[] nums) {
for (int i = 1; i < nums.length; i++) {
int par = (i-1)>>1; // 找到父節點
int child = i; // 定義子節點
while (child>0&&nums[par]<nums[child]) { // 從子節點到根節點構建最大堆
swap(nums, par, child);
child = par;
par = (par-1) >> 1;
}
}
}
private void rebuildHeap(int[] nums, int par, int last) {
int left = 2*par+1; // 左子節點
int right = 2*par+2; // 右子節點
int maxIndex = left;
if (right<=last && nums[right]>nums[left]) { // 找到最大子節點
maxIndex = right;
}
if (left<=last && nums[par] < nums[maxIndex]) {// 和最大子節點比較
swap(nums, par, maxIndex); // 互換到最大子節點
rebuildHeap(nums, maxIndex, last); // 重建最大子節點代表的子樹
}
}
時間複雜度穩定在\(O(nlogn)\),因為在構建堆的時候時間遍歷陣列對於每個元素需要進行\(O(logn)\)次比較,時間複雜度是\(O(nlogn)\)。在彈出每個元素重建堆需要\(O(logn)\)的複雜度,時間複雜度也是\(O(nlogn)\),所以整體的時間複雜度是\(O(nlogn)\)
空間複雜度是\(O(1)\),在原陣列進行所有操作就可以了。
堆排序是不穩定,堆得構建和重建的過程都會打亂元素的相對位置。
堆排序的程式碼量相對於其他的排序演算法來說是比較多的,理解上也比較難,涉及到最大堆和二叉樹等相關概念。雖然在實際使用中相對於快速排序不是那麼好用,但是最壞情況下的\(O(nlogn)\)的時間複雜度也是優於快排的。空間使用是恆定的,是優於歸併排序。
二叉搜尋樹排序
二叉樹搜尋排序用陣列內的所有元素構建一個搜尋二叉樹,然後用中序遍歷重新將所有的元素填充回原來的陣列中。因為搜尋二叉樹不能用陣列來表示,所以必須使用額外的資料結構來構建二叉樹。
圖片來自這裡
簡單程式碼如下:
private int[] bstSort(int[] nums) {
TreeNode root = new TreeNode(nums[0]); // 構建根節點
for (int i = 1; i < nums.length; i++) { // 將所有的元素插入到二叉搜尋樹中
buildTree(root, nums[i]);
}
inorderTraversal(root, nums, new int[1]);// 中序遍歷獲取二叉樹中的所有節點
return nums;
}
private void inorderTraversal(TreeNode node, int[] nums, int[] pos) {
if (node == null) return;
inorderTraversal(node.left, nums, pos);
nums[pos[0]++] = node.val;
inorderTraversal(node.right, nums, pos);
}
private void buildTree(TreeNode node, int num) {
if (node == null) return;
if (num >= node.val) { // 插入到右子樹中
if (node.right == null) {
node.right = new TreeNode(num);
} else {
buildTree(node.right, num);
}
} else { // 插入到左子樹中
if (node.left == null) {
node.left = new TreeNode(num);
} else {
buildTree(node.left, num);
}
}
}
static class TreeNode { // 樹節點的資料結構
int val;
TreeNode left;
TreeNode right;
public TreeNode(int val) {
this.val = val;
}
}
時間複雜度上面根據原陣列變化比較大,最差情況是整個陣列是已經排好序的,這樣二叉樹會變成一個連結串列結構,時間複雜度退化到了\(O(n^2)\),但是最優和平均情況下時間複雜度在\(O(nlogn)\)水平。
空間複雜度是\(O(n)\),因為要構建一個包含n
個元素的二叉搜尋樹。
這個演算法是穩定,在構建二叉樹的過程中能夠保證元素順序的一致性。
計數排序
計數排序是一個最基本的非比較排序,能夠將時間複雜度提高到\(O(n)\)的水平,但是使用上比較有侷限性,通常只能應用在鍵的變化範圍比較小的情況下,如果鍵的變化範圍特別大,建議使用基數排序。
計數排序的過程是建立一個長度為陣列中最小和最大元素之差的陣列,分別對應陣列中的每個元素,然後用這個新的陣列來統計每個元素出現的頻率,然後遍歷新的陣列,根據每個元素出現的頻率把元素放回到老的陣列中,得到已經排好序的陣列。
圖片來自這裡
簡單程式碼實現:
private void countSort(int[] nums) {
int min = Integer.MAX_VALUE;
int max = Integer.MIN_VALUE;
for (int num : nums) { // 找到最大最小值
min = Math.min(min, num);
max = Math.max(max, num);
}
int[] count = new int[max-min+1]; // 建立新陣列
for (int num : nums) { // 統計每個元素出現頻率
count[num-min]++;
}
int cur = 0;
for (int i = 0; i < count.length; i++) { // 根據出現頻率把計數陣列中的元素放回到舊陣列中
while (count[i]>0) {
nums[cur++] = i+min;
count[i]--;
}
}
}
計數排序能夠將時間複雜度降低到\(O(n+r)\)(r為陣列元素變化範圍),不過這是對於陣列元素的變化範圍不是特別大。隨著範圍的變大,計數排序的效能就會逐漸降低。
空間複雜度為\(O(n+r)\),隨著陣列元素變化範圍的增大,空間複雜度也會變大。
計數排序是穩定的,原來排在前面的相同在計數的時候,仍然是排在每個計數位置的前面,在最後復原的時候也是從每個計數位的前面開始復原,所以最後相對位置還是相同的。
桶排序
桶排序是將所有的元素分佈到一系列的區間(也可以稱之為桶)裡面,然後對每個桶裡面的所有元素分別進行排序的演算法。
首先新建一個桶的陣列,每個桶的規則需要提前制定好,比如元素在09為一個桶、1019為一個桶。然後遍歷整個待排序的陣列,把元素分配到對應的桶裡面。接下來單獨對每個桶裡面的元素進行排序,排序演算法可以選擇比較排序或者非比較排序,得到排序後的陣列。最後把所有的桶內的元素還原到原數組裡面得到最後的排序陣列。
圖片來自這裡
private void bucketSort(int[] nums) {
int INTERVAL = 100; // 定義桶的大小
int min = Integer.MAX_VALUE;
int max = Integer.MIN_VALUE;
for (int num : nums) { // 找到陣列元素的範圍
min = Math.min(min, num);
max = Math.max(max, num);
}
int count = (max - min + 1); // 計算出桶的數量
int bucketSize = (count % INTERVAL == 0) ?( count / INTERVAL) : (count / INTERVAL+1);
List<Integer>[] buckets = new List[bucketSize];
for (int num : nums) { // 把所有元素放入對應的桶裡面
int quotient = (num-min) / INTERVAL;
if (buckets[quotient] == null) buckets[quotient] = new ArrayList<>();
buckets[quotient].add(num);
}
int cur = 0;
for (List<Integer> bucket : buckets) {
if (bucket != null) {
bucket.sort(null); // 對每個桶進行排序
for (Integer integer : bucket) { // 還原桶裡面的元素到原陣列
nums[cur++] = integer;
}
}
}
}
時間複雜度上桶排序和計數排序一樣,是\(O(n+r)\)的水平,但是隨著資料元素範圍的增大,時間消耗也在增大。
空間複雜度也是\(O(n+r)\),需要額外的空間來儲存所有的桶和桶裡面的元素。
桶排序是穩定的(前提是桶內排序的邏輯是穩定的),和計數排序的邏輯類似,遍歷過程插入桶的過程中沒有改變相同元素的相對位置,排序也沒有改變,最後的還原也沒有改變。
基數排序
基數排序和桶排序有點相似,基數排序中需要把元素送入對應的桶中,不過規則是根據所有數字的某一位上面的數字來分類。
假設當前陣列的所有元素都是正數,桶的數量就固定在了10個,然後計算出最大元素的位數。首先根據每個元素的最低位進行分組,比如1
就放入1
這個桶,13
就放入3
這個桶,111
也放入1
這個桶,然後把所有的數字根據桶的順序取出來,依次還原到原數組裡面。在第二輪從第二位開始分組,比如1
(看作01
)放入0
這個桶,13
放入1
這個桶,111
也放入1
這個桶,再把所有的元素從桶裡面依次取出放入原陣列。經過最大元素位數次的這樣的操作之後,還原得到的陣列就是一個已經排好序的陣列。
圖片來自這裡
考慮到數組裡面還有負數的情況,可以把桶的大小擴大到19個,分別代表對應位在-9~9之間的數字,程式碼如下:
private void radixSort(int[] nums) {
int max = -1;
int min = 1;
for (int num : nums) { // 計算最大最小值
max = Math.max(max, num);
min = Math.min(min, num);
}
max = Math.max(max, -min); // 求得絕對值最大的值
int digits = 0;
while (max > 0) { // 計算絕對值最大的值的位數
max /= 10;
digits++;
}
List<Integer>[] buckets = new List[19]; // 建一個包含所有位數的陣列
for (int i = 0; i < buckets.length; i++) {
buckets[i] = new ArrayList<>();
}
int pos;
int cur;
for (int i = 0, mod = 1; i < digits; i++, mod*=10) { // 對十進位制每一位進行基數排序
for (int num : nums) { // 掃描陣列將值放入對應的桶
pos = (num / mod) % 10;
buckets[pos+9].add(num);
}
cur = 0;
for (List<Integer> bucket : buckets) { // 將桶內元素放回到數組裡面
if (bucket!=null) {
for (Integer integer : bucket) {
nums[cur++] = integer;
}
bucket.clear(); // 將桶清空
}
}
}
}
時間複雜度基本在\(O(n·\frac{k}{d})\)水平,其中\(k\)為key的總數量,\(d\)為絕對值最大的數字的十進位制位數。
空間複雜度是\(O(n+2^d)\)。
基數排序是一個穩定排序演算法,在排序新增元素的過程中沒有改變相同元素的相互位置。
TimSort
Timsort是由Tim Peters在2002年實現的,自Python 2.3以來,它一直是Python的標準排序演算法。Java在JDK中使用Timsort對非基本型別進行排序。Android平臺和GNU Octave還將其用作預設排序演算法。
Timsort是一種穩定的混合排序演算法,同時應用了二分插入排序和歸併排序的思想,在時間上擊敗了其他所有排序演算法。它在最壞情況下的時間複雜度為\(O(nlogn)\)優於快速排序;最佳情況的時間複雜度為\(O(n)\),優於歸併排序和堆排序。
由於使用了歸併排序,使用額外的空間儲存資料,TimSort空間複雜度是\(O(n)\)
由於篇幅原因,TimSort的具體實現過程暫且就不講了,感興趣的同學可以看我的另外一篇部落格——世界上最快的排序演算法——Timsort
總結
排序演算法 | 最好情況 | 平均情況 | 最差情況 | 空間複雜度 | 穩定性 |
---|---|---|---|---|---|
氣泡排序 | \(n^2\) | \(n^2\) | \(n^2\) | \(1\) | ✓ |
選擇排序 | \(n^2\) | \(n^2\) | \(n^2\) | \(1\) | |
插入排序 | \(n\) | \(n^2\) | \(n^2\) | \(1\) | ✓ |
希爾排序 | \(nlogn\) | \(n^{4/3}\) | \(n^{4/3}\) | \(1\) | |
二叉樹排序 | \(nlogn\) | \(nlogn\) | \(n^2\) | \(n\) | ✓ |
歸併排序 | \(nlogn\) | \(nlogn\) | \(nlogn\) | \(n\) | ✓ |
快速排序 | \(nlogn\) | \(nlogn\) | \(n^2\) | \(logn\) | |
堆排序 | \(nlogn\) | \(nlogn\) | \(nlogn\) | \(1\) | |
計數排序 | - | \(n+r\) | \(n+r\) | \(n+r\) | ✓ |
桶排序 | - | \(n+r\) | \(n+r\) | \(n+r\) | ✓ |
基數排序 | - | \(\frac{nk}{d}\) | \(\frac{nk}{d}\) | \(n+2^d\) | ✓ |
TimSort | \(n\) | \(nlogn\) | \(nlogn\) | \(n\) | ✓ |
備註:\(r\)為排序數字的範圍,\(d\)是數字總位數,\(k\)是數字總個數
上面的表格總結了講到的排序演算法的時間和空間複雜度以及穩定性等,在實際應用中會有各種排序演算法變形的問題,都可以通過優化排序演算法來達到優化演算法的目的。
如果對時間複雜度要求比較高並且鍵的分佈範圍比較廣,可以使用歸併排序、快速排序和堆排序。
如果不能使用額外的空間,那麼快速排序和堆排序都是不錯的選擇。
如果規定了排序的鍵的範圍,可以優先考慮使用桶排序。
如果不想寫太多的程式碼同時時間複雜度沒有太高的要求,可以考慮氣泡排序、選擇排序和插入排序。
如果排序的過程中沒有複雜的額外操作,直接使用程式語言內建的排序演算法就行了。
參考
超詳細十大經典排序演算法總結(java程式碼)
十大經典排序演算法
十大經典排序演算法(動圖演示)
Sorting algorithm
Timsort
Data Structure - Sorting Techniques
This is the fastest sorting algorithm ever
TimSort
Timsort: The Fastest sorting algorithm for real-world problems
更多內容請看我的個人部落格