排序算法
排序是最基本的算法之一,常見的排序算法有插入排序、希爾排序、選擇排序、冒泡排序、堆排序、歸并排序及快速排序。每個排序算法的時間復雜度是不同的,但是最優的時間復雜度是O(NlogN)
。有些排序算法是原址排序(即不需要額外空間),也有一些是非原址排序,這也是需要注意的特點。同樣地,還要注意排序算法是否是穩定排序,這有時候很重要。這篇文章簡單地介紹各個排序算法的思想,然后使用C++
實現各個排序算法。
插入排序
插入排序算法思想很簡單,就是將元素插入已經有序的數組來完成排序。假定數組前i-1
位置是有序的,現在你要將第i
個元素插入這個有序數組,那么可以依次與第i-1
,i-2
...等位置的元素進行比較,直到找出一個小于該元素的位置j
。將j+1
至i-1
位置元素依次后移一個位置,然后將該元素放入第j+1
位置。此時前i
個位置是有序的。重復這個過程至第N
個元素,就可以完成數組的排序了。
// 插入排序
template <typename Comparable>
void insertionSort(vector<Comparable>& v)
{
for (int i = 1; i < v.size(); ++i)
{
// 先保存當前要插入的元素
Comparable tmp = move(v[i]);
int j = i;
for (; j > 0 && tmp < v[j - 1]; --j)
{
v[j] = move(v[j - 1]); // 大于tmp的元素后移
}
v[j] = move(tmp); // 插入正確的位置
}
}
這里的實現我們采用泛型模板,泛型參數Comparable
要求支持比較操作符<
。同時我們使用了移動語義,這有利于效率的提升。后面其它算法的實現我們也都采用這種方式。從算法的實現可以看到有兩層循環,那么插入排序的復雜度是O(N^2)
。此外,插入排序對于兩個相等的元素,并不會改變其先后順序,所以是穩定排序。同時其不需要額外空間,也是原址排序。
希爾排序
希爾排序是以提出者(Donald Shell)命名的。希爾排序與插入排序的思想很相似,但是其使用了一個遞增序列H1, H2, ..., Ht
。希爾排序每個階段處理這個遞增序列中的一個元素Hk
,其進行的是Hk
間隔排序,就是保證對于任意的i
,要有A[i] < A[i+Hk]
。每個階段的排序利用與插入排序相似的思想:處理的位置i
是hk, hk + 1, ..., N
,而且每個位置的插入比較位置是i, i - Hk, , i - 2Hk...
。希爾排序的一個特點是,如果先進行Hk
間隔排序,那么Hk-1
間隔排序后,Hk
間隔仍然保持有序。這是希爾排序有效的重要保證。對于遞增序列,常使用的是Ht = floor(N/2)
,而且Hk = floor((Hk+1)/2)
。
// 希爾排序
template <typename Comparable>
void shellSort(vector<Comparable>& v)
{
// 依次處理遞增序列各個間隔值(從后往前)
for (int gap = v.size() / 2; gap > 0; gap /= 2)
{
// 對于每個間隔,進行插入排序
for (int i = gap; i < v.size(); ++i)
{
Comparable tmp = move(v[i]);
int j = i;
for (; j >= gap && tmp < v[j - gap]; j -= gap)
{
v[j] = move(v[j - gap]);
}
v[j] = move(tmp);
}
}
}
希爾排序的復雜度并不是那么直接,其與選用的遞增序列有關,最差狀態下為O(N^2)
,但是如果選用合適的遞增序列,其復雜度可以達到次二次時間,如O(N^(3/2))
。此外,也可以看到希爾排序是原址排序,但不是穩定排序。
選擇排序
選擇排序應該是最直觀的排序算法,其將數組分為排序部分與未排序部分,每次在未排序部分選出一個最小值,然后放到排序部分的后面。假定數組前i-1
個位置已經有序,那么從未排序序列i, i+1,..., N
中找到最小值位置j
,然后交換位置j
與位置i
上元素。選擇排序也需要重復這樣的過程N-1
次。
// 選擇排序
template <typename Comparable>
void selectSort(vector<Comparable>& v)
{
for (int i = 0; i < v.size() - 1; ++i)
{
int minIdx = i;
// 尋找未排序部分的最小值位置
for (int j = i + 1; j < v.size(); ++j)
{
if (v[minIdx] > v[j])
{
minIdx = j;
}
}
swap(v[minIdx], v[i]); // 通過交換將最小值在正確位置
}
}
選擇排序的時間復雜度為O(N^2)
,且是原址排序。但是選擇排序由于交換,導致其是不穩定排序方式。
冒泡排序
冒泡排序如其名,就是讓數組元素像水中氣泡一樣逐漸上浮,從而達到排序的目的。其也是將數組分為排序部分與未排序部分。對于未排序部分,依次比較相鄰兩個元素,如果前者大于后者則交換其位置。和選擇排序與插入排序一樣,冒泡排序也應該需要N-1
次重復操作,但是有一個更好的選擇。那就是在每個階段,記錄一個flag
標志,如果沒有進行任何一次元素交換,說明未排序部分已經有序,后面就不需要再繼續冒泡了。
// 冒泡排序
template <typename Comparable>
void bubbleSort(vector<Comparable>& v)
{
bool flag = true; // 是否交換過元素
for (int i = 0; flag; ++i)
{
flag = false; // 初始為false
// 向下冒泡
for (int j = v.size() - 1; j > i; --j)
{
if (v[j] < v[j - 1]) // 不是<=,那樣是不穩定排序
{
swap(v[j], v[j - 1]);
flag = true;
}
}
}
}
冒泡排序時間復雜度也是O(N^2)
,而且是原址排序。冒泡排序也是穩定排序,但是要注意交換條件。
歸并排序
前面所討論的排序算法都是復雜度為O(N^2)
的低效率排序算法。下面的算法都是時間復雜度為O(NlogN)
的高級算法。我們從歸并算法說起,歸并算法是基于分治策略。歸并算法的基礎是合并兩個已經有序的子數組,將兩個已經有序的子數組進行合并是容易的。比如兩個有序子數組A
和B
,然后有一個輸出數組C
。此時你需要三個位置索引i
、j
和k
,每次比較A[i]
與B[j]
,然后將最小者復制到C[k]
,同時遞增相應的位置索引。重復上述過程知道某一個子數組遍歷完,未遍歷完的子數組剩余部分直接復制到輸出數組就完成整個合并過程。利用合并,歸并排序算法的步驟為:(1)將數組分為兩個大小相等的子數組;(2)對每個子數組進行排序,除非子數組比較小,否則利用遞歸方式完成排序;(3)合并兩個有序的子數組,完成排序。
// 歸并排序的輔助方法:合并兩個有序數組
// v為要排序數組,tmp為輔助數組
// left為左子數組的開始位置,right為右子數組的開始位置,end為結束位置
template <typename Comparable>
void merge(vector<Comparable>& v, vector<Comparable>& tmp, int left,
int right, int end)
{
int tmpPos = left;
int leftEnd = right - 1;
int num = end - left + 1; // 總數
// 合并兩個子數組直到某一個子數組遍歷完
while (left <= leftEnd && right <= end)
{
if (v[left] <= v[right])
{
tmp[tmpPos++] = move(v[left++]);
}
else
{
tmp[tmpPos++] = move(v[right++]);
}
}
// 處理左子數組剩余部分
while (left <= leftEnd) { tmp[tmpPos++] = move(v[left++]); }
// 處理右子數組剩余部分
while (right <= end) { tmp[tmpPos++] = move(v[right++]); }
// 合并結果復制到原數組
while (num > 0)
{
v[end] = move(tmp[end]);
--end;
--num;
}
}
// 歸并合并的內部調用函數
// v為要排序數組,tmp為輔助數組
// left要排序數組部分的開始位置,right是結束位置
template <typename Comparable>
void mergeSort(vector<Comparable>& v, vector<Comparable>& tmp,
int left, int right)
{
if (left < right)
{
int mid = (left + right) / 2;
// 遞歸處理每個子數組
mergeSort(v, tmp, left, mid);
mergeSort(v, tmp, mid + 1, right);
// 合并
merge(v, tmp, left, mid + 1, right);
}
}
// 歸并排序
template<typename Comparable>
void mergeSort(vector<Comparable>& v)
{
vector<Comparable> tmp(v.size());
mergeSort(v, tmp, 0, v.size() - 1);
}
歸并排序的時間復雜度是O(NlogN)
,但是其唯一的問題是需要一個額外的數組空間,所以是非原址排序。但是其也是穩定排序。
堆排序
堆排序是利用堆來進行排序。堆一種特殊的二叉樹,對于最大堆來說,其每個節點的值都大于或者等于其子節點的值。可以使用數組來存儲堆:將根節點放在第一個數組位置,根節點的左右子節點分別存儲在數組的第二與第三位置,然后其左子節點的左右子節點放置在第四與第五位置,依次類推。如果數組索引是從0
開始的,對于位置i
處的節點,其左子節點位置為2*i+1
,其右子節點位置為2*(i+1)
。要進行堆排序,首先要建立堆。要構建堆,需要使用一個”下沉過程“,這里我們稱為siftdown
:首先將位于根節點的鍵值與其子節點的較大鍵值進行比較,如果根節點的鍵值較小,那么就交換根節點與子節點,然后對該子節點重復這個過程,直到到達葉節點或者根節點的鍵值不小于其子節點的鍵值。假定一個堆的深度為d
,那么首先可以將深度為d-1
的節點使用siftdown
過程,這樣深度為d-1
的子樹滿足堆性質,然后對深度為d-2
的節點使用siftdown
過程,······,最后處理堆的根節點,此時這個樹滿足堆性質。一旦我們構建好了堆,我們可以在保持堆性質的同時重復刪除根節點,得到的這些根節點是有序的,從而達到排序的目的。怎么在刪除根節點之后還保持堆性質呢?這里有一個技巧,我們可以交換根節點與最右子節點的鍵值,此時將堆將縮減一個元素(最右子節點),然后對當前根節點調用siftdown
。我們知道最右子節點恰好是數組最后的位置,所以重復這一過程,可以達到原址排序的目的。
// 返回節點i的左子節點位置
inline int leftChild(int i) { return 2 * i + 1; }
// 堆排序輔助函數
// v是存儲堆的數組,i是要下沉的節點,n代表當前堆的大小
template <typename Comparable>
void siftDown(vector<Comparable>& v, int i, int n)
{
int child;
Comparable tmp = move(v[i]); // 記錄要下沉的值
while (leftChild(i) < n)
{
child = leftChild(i); // 左子節點
// 尋找最大子節點
if (child != n - 1 && v[child] < v[child + 1])
{
++child;
}
if (tmp < v[child]) // 子節點上移
{
v[i] = move(v[child]);
i = child;
}
else // 終止
{
break;
}
}
v[i] = move(tmp); // 下沉到正確位置
}
template <typename Comparable>
void heapSort(vector<Comparable>& v)
{
// 先建立堆
for (int i = v.size() / 2 - 1; i >= 0; --i)
{
siftDown(v, i, v.size());
}
// 重復刪除根節點
for (int i = v.size() - 1; i > 0; --i)
{
swap(v[i], v[0]); // 交換根節點與最右子節點
siftDown(v, 0, i); // 下沉根節點
}
}
堆排序的時間復雜度也是O(NlogN)
,其不需要額外空間,是原址排序。但是由于進行了根節點與最右子節點的交換,堆排序是不穩定的。
快速排序
快速排序與歸并排序有相似之處,其采用的也是分治的策略。快速排序也將數組劃分為兩部分,但是其劃分是根據一個選定的中心點(pivot
),前半部分是小于pivot
值,后半部分大于pivot
。不斷重復這種策略在每個子數組上,即可完成排序??焖倥判虻囊粋€關鍵點是選擇中心點,選擇中心點后要將原數組分割成兩部分。如果中心點選擇不恰當,那么會導致分割的兩個子數組大小嚴重不平衡,這樣快速排序的性能就會惡化。一個比較好的策略是選擇數組最左邊、最右邊與中心位置的中間值,即Median-of-Three
策略:
// 快速排序:選定中心點策略
// v是排序數組,left與right分別是要分割數組的左右邊界
template <typename Comparable>
const Comparable& median3(vector<Comparable>& v, int left, int right)
{
int mid = (left + right) / 2;
if (v[mid] < v[left]) { swap(v[mid], v[left]); }
if (v[left] > v[right]) { swap(v[left], v[right]); }
if (v[mid] > v[right]) { swap(v[mid], v[right]); }
// left位置的值小于等于pivot,right位置的值一定大于等于pivot,
// 要分割的數組變成left+1到right-1
swap(v[mid], v[right - 1]); // 將pivot放到right-1位置處
return v[right - 1];
}
這個選擇中心點的策略很簡單,但是最后把選擇的中心點存儲在數組倒數第二個位置,這個是為分割做準備的。一旦選定中心點,那么就要根據中心點將數組分為左右兩部分。一個比較好的策略是采用左右夾逼。假定要分割的數組是A[left], A[left+1], ..., A[right]
。此時記住A[right]
此時存儲的是中心點的值。我們設置兩個位置索引變量i
和j
,i
從最左側left
開始,j
從最右側right-1
開始。我們將i
向右移動,直到此位置處的值大于或者等于pivot
,同時我們將j
向左移動,直到此位置處的值小于或者等于pivot
。如果此時i
還在j
的左側,我們交換位置i
和位置j
處的值。然后重復上面的過程直到i
出現在j
的右側,此時我們只需要交換位置i
與位置right
處的值就完成了分割。完成分割后,我們只需要遞歸處理每個子數組即可。
// 快速排序輔助函數
template <typename Comparable>
void quickSort(vector<Comparable>& v, int left, int right)
{
if (left + 1 < right) // 3個及以上元素
{
Comparable pivot = median3(v, left, right); // 中心點
int i = left, j = right - 1;
while (true)
{
while (v[++i] < pivot) {} // i右移
while (v[--j] > pivot) {} // j左移
if (i < j)
{
swap(v[i], v[j]);
}
else
{
break;
}
}
swap(v[i], v[right - 1]);
// 對子數組遞歸
quickSort(v, left, i - 1);
quickSort(v, i + 1, right);
}
else if (left < right) // 兩個元素
{
if (v[left] > v[right]) { swap(v[left], v[right]); }
}
}
// 快速排序
template <typename Comparable>
void quickSort(vector<Comparable>& v)
{
quickSort(v, 0, v.size() - 1);
}
遞歸時,要分兩種情況,三個及以上元素時繼續調用快速排序,但是兩個元素時,必須要單獨處理。因為選定中心點需要三個及以上元素。其實,快速排序對于小數組優勢并不是很明顯,當數組較小時,可以使用其它排序算法處理,比如插入排序:
// 插入排序
template <typename Comparable>
void insertionSort(vector<Comparable>& v, int left, int right)
{
for (int i = 1; i < v.size(); ++i)
{
Comparable tmp = move(v[i]);
int j = i;
for (; j > 0 && tmp < v[j - 1]; --j)
{
v[j] = move(v[j - 1]);
}
v[j] = move(tmp);
}
}
// 快速排序輔助函數
const int SIZE = 5;
template <typename Comparable>
void quickSort(vector<Comparable>& v, int left, int right)
{
if (left + SIZE < right)
{
Comparable pivot = median3(v, left, right); // 中心點
int i = left, j = right - 1;
while (true)
{
while (v[++i] < pivot) {} // i右移
while (v[--j] > pivot) {} // j左移
if (i < j)
{
swap(v[i], v[j]);
}
else
{
break;
}
}
swap(v[i], v[right - 1]);
// 對子數組遞歸
quickSort(v, left, i - 1);
quickSort(v, i + 1, right);
}
else
{
insertionSort(v, left, right);
}
}
// 快速排序
template <typename Comparable>
void quickSort(vector<Comparable>& v)
{
quickSort(v, 0, v.size() - 1);
}
快速排序的時間復雜度平均為O(NlogN)
,但是最差時也表現為O(N^2)
。其次,快速排序也是原址排序,但是其是不穩定的。
大部分排序算法這里算是介紹完了,從比較上來看,這些排序算法最優性能為O(NlogN)
。但是大家可以發現一個事實,這些算法都是通過比較來完成的,而且已經證明O(NlogN)
是所有利用比較來進行排序的算法的一個下限。還有一點要說明,這些排序算法都有一個前提,那就是要排序的數組可以全部讀進內存。但是當要排序的元素量非常大時,可能無法一下子將所有元素放進內存,此時需要外部排序算法。感興趣可以去了解。
鏈表排序
前面的排序方法我們都是處理是vector
,其實我們都默認要排序的是數組結構,那么元素可以隨機存取(Random Access)。但是,我們知道有些結構是不支持隨機存取的,比如鏈表結構。這里我們簡單地討論單鏈表結構:
// 單鏈表節點
class ListNode
{
public:
int val;
ListNode* next;
ListNode(int value, ListNode* nt = nullptr)
:val{value}, next{nt}
{}
};
鏈表結構只能前向遍歷,這是很大的限制。但是,這并不代表上面的排序算法不能起作用,只不過要進行修改。我們先看一下如何使用插入排序來對一個鏈表排序。對于插入排序,關鍵的是要找到插入點位置,鏈表只能前向遍歷,所以必須從頭節點找到插入點位置,而不能采用之前的策略。實現就很簡單了:
// 從鏈表頭節點開始尋找插入點位置
ListNode* findInsertPos(ListNode* head, int x)
{
ListNode* prev = nullptr;
for (ListNode* cur = head; cur != nullptr && cur->val <= x;
prev = cur, cur = cur->next)
;
return prev;
}
// 使用插入排序對鏈表進行排序
void insertionSortList(ListNode* & head)
{
//// 啞巴節點
ListNode dummy{ INT_MIN }; // 不要執行dummy.next = head
// 每次處理一個節點
for (ListNode* cur = head; cur != nullptr;)
{
ListNode* pos = findInsertPos(&dummy, cur->val); // 確定插入位置
// 插入此位置
ListNode* tmp = cur->next;
cur->next = pos->next;
pos->next = cur;
cur = tmp;
}
head = dummy.next;
}
上面的插入排序算法的時間復雜度還是O(N^2)
,并且不要額外空間。我們也可以使用歸并排序對鏈表排序,此時的復雜度為O(NlogN)
。具體實現如下:
// 歸并兩個有序鏈表
ListNode* mergeList(ListNode* l1, ListNode* l2)
{
// 啞巴節點
ListNode dummy{ 0 };
ListNode* cur = &dummy;
// 處理公共部分
for (; l1 != nullptr && l2 != nullptr;
cur = cur->next)
{
if (l1->val <= l2->val)
{
cur->next = l1;
l1 = l1->next;
}
else
{
cur->next = l2;
l2 = l2->next;
}
}
// 處理l1剩余部分
while (l1 != nullptr) { cur->next = l1; l1 = l1->next; cur = cur->next; }
// 處理l2剩余部分
while (l2 != nullptr) { cur->next = l2; l2 = l2->next; cur = cur->next; }
return dummy.next;
}
// 歸并排序鏈表
void mergeSortList(ListNode* & head)
{
// 無元素或者只有一個元素
if (head == nullptr || head->next == nullptr) return;
// 利用快慢指針找到中間節點
ListNode* slow = head, *fast = head;
while (fast->next != nullptr && fast->next->next != nullptr)
{
fast = fast->next->next;
slow = slow->next;
}
// 根據中間節點分割鏈表
fast = slow;
slow = slow->next;
fast->next = nullptr;
mergeSortList(head); // 處理前半部分
mergeSortList(slow); // 處理后半部分
head = mergeList(head, slow); // 歸并
}
可以看到,鏈表的歸并排序不需要額外存儲空間,這與數組的歸并排序不同。
前面說過,僅通過比較的方式來進行排序的算法,其效率不可能優于O(NlogN)
。但是也是存在可以在線性時間內完成排序的算法,如桶排序與基數排序。路漫漫其修遠兮,感興趣的繼續探索吧!
Reference
[1] Mark Allen Weiss, Data Structures and Algorithm Analysis in C++, fourth edition, 2013.
[2] Richard E. Neapolitan, Foundations of Algorithms, fifth edition, 2016.
[3] LeetCode 題解.