排序作為演算法最基礎的一部分,但是還是有部分程式設計師連手寫氣泡排序都比較困難,包括我 :joy:,看來在我們有空的時候還是很有必要複習一下排序演算法喲, 要理解各大排序演算法,一定要自己動手畫一畫,這樣才能更好的幫助自己捋清整個排序思路
但是到底哪種排序演算法更快呢,請往下面看,當然,你也可以直接看最下面的結果
由於本人水平有限,有疏漏或不正確的地方,還請指正
暴力排序
嗯,這是最簡單的排序了,不需要任何解釋,你也能理解,時間複雜度為O(n^2)
,空間複雜度O(1)
[8 4 5 7 1 3 6 2]
1 [8 4 7 5 3 6 2]
1 2 [8 7 5 4 6 3]
1 2 3 [8 7 5 6 4]
1 2 3 4 [8 7 6 5]
1 2 3 4 5 [8 7 6]
1 2 3 4 5 6 [8 7]
1 2 3 4 5 6 7 [8]
複製程式碼
// 暴力排序
for (int i = 0; i < data.length - 1; i++) {
for (int j = i + 1; j < data.length; j++) {
// 比較並進行交換
if (data[i] > data[j]) {
ArrayUtil.swap(data, i, j);
}
}
}
複製程式碼
氣泡排序
這也是最簡單的排序演算法之一了,其思想是通過與相鄰元素的比較將較小(大)值交換到最後面,時間複雜度O(n^2)
,空間複雜度O(1)
陣列: 8 4 5 7 1 3 6 2
第一輪:[4 5 7 1 3 6 2] 8
第二輪:[4 5 1 3 6 2] 7 8
第三輪:[4 1 3 5 2] 6 7 8
第四輪:[1 3 4 2] 5 6 7 8
第五輪:[1 3 2] 4 5 6 7 8
第六輪:[1 2] 3 4 5 6 7 8
第七輪:[1] 2 3 4 5 6 7 8
複製程式碼
// 需要n-1趟遍歷
for (int i = 1; i < data.length; i++) {
// 將最值依次往後挪
for (int j = 0; j < data.length - i; j++) {
if (data[j] > data[j + 1]) {
ArrayUtil.swap(data, j, j + 1);
}
}
}
複製程式碼
插入排序
從索引位置為1
的元素開始,對前2
個元素進行排序,索引變為2
,對前3
個元素進行排序,以此類推,直至排序完成,時間複雜度O(n^2)
,空間複雜度O(1)
陣列: 8 4 5 7 1 3 6 2
從i=1開始:[4 8] 5 7 1 3 6 2
第二輪i=2:[4 5 8] 7 1 3 6 2
第三輪i=3:[4 5 7 8] 1 3 6 2
第四輪i=4:[1 4 5 7 8] 3 6 2
第五輪i=5:[1 3 4 5 7 8] 6 2
第六輪i=6:[1 3 4 5 6 7 8] 2
第七輪i=7:[1 2 3 4 5 6 7 8]
複製程式碼
// 依次對前i+1個元素進行排序
for (int i = 1; i < data.length; i++) {
int curr = data[i];
int j = i - 1;
// 將第i個元素插入到正確的位置
while (j >= 0 && data[j] > curr) {
data[j + 1] = data[j--];
}
data[j + 1] = curr;
}
複製程式碼
思考一下,還可以優化嗎?當然,我們還可以實現一個基於二分查詢的插入排序
快速排序
我們需要一個基準數(就是一個參考數,可以從陣列中隨便選一個)作為參考,將比基準數大的放到基準數的右側,比基準數小的放到基準數的左側,
為了完成這項工作,我們還需要兩個哨兵i
和j
,來對陣列進行探測,哨兵j
從length-1
的位置最先出發,直到找到一個小於基準數的元素停止,
同理,哨兵i
從位置0
出發,直到遇到大於基準的元素停止,然後對i
和j
處的元素進行交換,j
又率先出發,繼續探測,直到i>=j
,並將i
處的元素與基準數進行交換,
並終止這一輪的探測
下一輪將分別對基準數的左側和右側進行一次快速排序,重複上述過程,直至排序完成,時間複雜度O(nlog2n)
,空間複雜度O(nlog2n)
陣列:5 4 6 7 3 1 2 8
以5為基準數排序之後的結果:3 4 2 1 [5] 7 6 8
5的左側以3為基準數,右側以7作為基準數排序之後的結果:2 1 [3] 4 [5] 6 [7] 8
繼續以新的基準數排序,直到無法派生新的基準數:1 [2] [3] [4] [6] [7] [8]
複製程式碼
private void quickSort() {
quickSortHelper(data, 0, data.length - 1);
}
private void quickSortHelper(int[] data, int start, int end) {
if (start < end) {
// 從左邊開始還探測的哨兵
int i = start;
// 從右邊開始探測的哨兵
int j = end;
// 基準數
int base = data[i];
while (i < j) {
// 找到小於基準數的索引
while (j > i && data[j] >= base) {
j--;
}
// 找到大於基準數的索引
while (j > i && data[i] <= base) {
i++;
}
if (i < j) {
// 交換兩個哨兵處的元素
ArrayUtil.swap(data, i, j);
} else if (i == j) {
// 交換基準數與哨兵處的元素(兩個哨兵一定會相遇)
ArrayUtil.swap(data, start, i);
}
}
// 對基準數左側的序列進行快速排序
quickSortHelper(data, start, j - 1);
// 對基準數右側的序列進行快速排序
quickSortHelper(data, i + 1, end);
}
}
複製程式碼
選擇排序
這是一種非常直觀的排序演算法,其工作原理是在整個未排序序列中找到最小(大)值,並與這個未排序序列的第一元素進行交換,這樣第一個元素就已經排序了,
接下來對索引位置1
開始的未排序序列進行排序,以此類推
其主要優點是資料移動次數較少,時間複雜度O(n^2)
,空間複雜度O(1)
有陣列:[5 4 6 7 3 1 2 8]
排序過程如下:
1 [4 6 7 3 5 2 8]
1 2 [6 7 3 5 4 8]
1 2 3 [7 6 5 4 8]
1 2 3 4 [6 5 7 8]
1 2 3 4 5 [6 7 8]
1 2 3 4 5 6 [7 8]
1 2 3 4 5 6 7 [8]
複製程式碼
// 從未排序序列中找到最值並交換到序列中的最前面
for (int i = 0; i < data.length - 1; i++) {
// 未排序序列的起始索引
int lowIdx = i;
// 在當前序列中找到最小值索引
for (int j = i + 1; j < data.length; j++) {
if (data[j] < data[lowIdx]) {
lowIdx = j;
}
}
if (lowIdx != i) {
// 將最小值交換當前序列的最前面
ArrayUtil.swap(data, i, lowIdx);
}
}
複製程式碼
希爾排序
希爾排序是一個名叫希爾的人發明的一種排序演算法,其實質就是一個分組的插入排序,是插入排序的高效率實現,其思想是按陣列下標的一定增量gap
進行分組,
對每組進行插入排序,隨著增量的減少,直到增量等於零,整個排序完成,又稱縮小增量排序,時間複雜度O(n^1.3)
,空間複雜度O(1)
陣列:5 4 6 7 3 1 2 8
相同符號的表示一組,對同一組進行插入排序:
gap=4: (5) [4] {6} <7> (3) [1] {2} <8> ==> (3) [1] {2} <7> (5) [4] {6} <8>
gap=3: (3) [1] {2} (7) [5] {4} (6) [8] ==> (3) [1] {2} (6) [5] {4} (7) [8]
gap=2: {3} [1] {2} [6] {5} [4] {7} [8] ==> {2} [1] {3} [4] {5} [6] {7} [8]
gap=1: [2] [1] [3] [4] [5] [6] [7] [8] ==> [1] [2] [3] [4] [5] [6] [7] [8]
複製程式碼
// 按陣列下標增量分組
for (int gap = data.length / 2; gap > 0; gap /= 2) {
// 從增量的索引位置開始進行插入排序
for (int i = gap; i < data.length; i++) {
int curr = data[i];
int j = i - gap;
// 將i處的元素插入到正確的位置
while (j >= 0 && data[j] > curr) {
data[j + gap] = data[j];
j -= gap;
}
data[j + gap] = curr;
}
}
複製程式碼
歸併排序
歸併排序採用了經典的分治策略,將大問題拆分成多個小問題逐個求解,比如這裡的歸併排序,將一個陣列拆分兩個序列,再分別將這兩個序列拆分成兩個序列,
直到序列長度為1
,然後依次向上對這兩個序列進行合併排序,這樣每次我們合併的都是兩個有序的序列,時間複雜度O(nlog2n)
,
空間複雜度O(n)
比如陣列:[8 4 5 7 1 3 6 2]
拆分:[[8 4 5 7] [1 3 6 2]]
再拆分:[[[8 4] [5 7]] [[1 3] [6 2]]]
再拆分,直到長度等於一:[[[[8] [4]] [[5] [7]]] [[[1] [3]] [[6] [2]]]]
合併排序:[[[4 8] [5 7]] [[1 3] [2 6]]]
再向上合併排序:[[4 5 7 8] [1 2 3 6]]
再向上合併,直到合併後序列長度等於原陣列長度:[1 2 3 4 5 6 7 8]
複製程式碼
示例程式碼如下:
// 組大小,從1開始,以2的倍數增長
int groupSize;
// 將兩個組合並後的最大大小:groupSize*2
int mergedSize = 1;
while (mergedSize <= data.length) {
groupSize = mergedSize;
mergedSize <<= 1;
// 對mergedSize大小內的兩個分組進行有序合併
for (int j = 0; j < data.length; j += mergedSize) {
// 建立一個合法的臨時工作陣列
int diff = data.length - j;
int[] temp = new int[diff < mergedSize ? diff : mergedSize];
// 第一個組的起始位置
int left = j;
// 第一個組的截止位置
int maxLeft = j + groupSize;
// 第二個組的起始位置
int right = maxLeft;
// 第二個組的截止位置
int maxRight = j + temp.length;
// 有序的合併兩個有序分組
for (int k = 0; k < temp.length; k++) {
if (right >= maxRight || (left < maxLeft && data[right] > data[left])) {
temp[k] = data[left++];
} else {
temp[k] = data[right++];
}
}
// 將工作陣列拷貝到原陣列
System.arraycopy(temp, 0, data, j, temp.length);
}
}
複製程式碼
堆排序
這種演算法稍複雜一些,首先你需要了解堆結構,它是一顆近似完全二叉樹的資料結構,並且需要將它調整成大頂堆或小頂堆,也就是說父節點總是大於(小於)或等於任何一個子節點,
堆化後,將堆頂元素與堆最後一個元素進行交換,堆的最後一個元素將不再參與下一輪的堆化,重複堆化和交換的過程,直到堆的大小等於1
,整個堆排序完成
所以堆排序的重點其實是如何調整最大(小)堆,如果用陣列表示堆的話,父節點為i
的節點,其子節點分別為2*i+1
、2*i+2
,從n/2
的父節點開始,
對其子節點進行比較,並調整成最大(小)堆,再對n/2-1
的父節點包括其子樹進行調整,最後對0
的父節點也就是整顆樹進行調整,整個堆化完成,時間複雜度O(nlog2n)
,
空間複雜度O(1)
陣列:8 4 5 7 1 3 6 2
陣列堆化後:
8
/ \
4 [5] (i=2)
/ \ / \
7 1 3 6
/
2
從i=4開始調整,發現沒有子節點,i=3時是一顆合法的大頂堆,i=2,調整如下:
8
/ \
i=1[4] 6
/ \ / \
7 1 3 5
/
2
i=1時調整如下,直到i=0,大頂堆調整完成
8
/ \
7 6
/ \ / \
4 1 3 5
/
2
此時陣列變成了:[8 7 6 4 1 3 5 2]
將堆頂元素與最後一個元素交換,並將最後一個元素從堆中刪除(不是真的刪除,只是不參與堆化了):[2 7 6 4 1 3 5] 8
堆變成了:
2 7
/ \ / \
7 6 交換後對新堆進行堆化 4 6
/ \ / \ / \ / \
4 1 3 5 2 1 3 5
此時陣列變成了:[7 4 6 2 1 3 5] 8
將堆頂與堆最後一個元素交換:[5 4 6 2 1 3] 7 8,交換後堆變成了:
5 6
/ \ / \
4 6 ===> 4 5
/ \ / / \ /
2 1 3 2 1 3
堆化後陣列:[6 4 5 2 1 3] 7 8,交換:[3 4 5 2 1] 6 7 8
3 5
/ \ / \
4 5 ===> 4 3
/ \ / \
2 1 2 1
堆化後陣列:[5 4 3 2 1] 6 7 8,交換:[1 4 3 2] 5 6 7 8
1 4
/ \ / \
4 3 ===> 2 3
/ /
2 1
堆化後陣列:[4 2 3 1] 5 6 7 8,交換:[1 2 3] 4 5 6 7 8
1 3
/ \ / \
2 3 ===> 2 1
堆化後陣列:[3 2 1] 4 5 6 7 8,交換:[1 2] 3 4 5 6 7 8
1 2
/ /
2 ===> 1
堆化後陣列:[2 1] 4 5 6 7 8,交換:[1] 2 3 4 5 6 7 8
當堆中只有一個元素時,排序完成
複製程式碼
private void heapSort() {
// 將待排序的序列構建成一個大頂堆
for (int i = data.length / 2; i >= 0; i--) {
heapSortHelper(data, i, data.length);
}
// 逐步將堆頂元素與末尾元素交換,並且再次調整二叉樹,使其成為大頂堆
for (int i = data.length - 1; i > 0; i--) {
// 將堆頂記錄和當前未排序序列的最後一個記錄交換
ArrayUtil.swap(data, 0, i);
// 交換之後,需要重新檢查堆是否符合大頂堆,不符合則要調整
heapSortHelper(data, 0, i);
}
}
/**
* 堆化節點
*/
private void heapSortHelper(int[] data, int i, int n) {
int child;
int father;
for (father = data[i]; 2 * i + 1 < n; i = child) {
child = 2 * i + 1;
// 如果左子樹小於右子樹,則需要比較右子樹和父節點
if (child != n - 1 && data[child] < data[child + 1]) {
// 指向右子樹
child++;
}
// 如果父節點小於孩子結點,則需要交換
if (father < data[child]) {
data[i] = data[child];
} else {
// 大頂堆結構未被破壞,不需要調整
break;
}
}
data[i] = father;
}
複製程式碼
關於交換
一般來說我們會使用一個額外的空間來對陣列兩個索引位置的元素進行值交換,如下:
private void swap(int[] data, int i, int j) {
int tmp = data[i];
data[i] = data[j];
data[j] = tmp;
}
複製程式碼
但是,如果不允許使用額外的空間又如何實現呢?思考一下,不要著急看下面的程式碼
private void swap(int[] data, int i, int j) {
data[i] = data[i] + data[j];
data[j] = data[i] - data[j];
data[i] = data[i] - data[j];
}
複製程式碼
當然這種方案也有個缺點,當整數足夠大時,它可能會導致整數溢位
知識延伸:為什麼說Java只有值傳遞
效能比較
在學習了這些常用排序演算法之後,下面我們來看看各個排序演算法在不同資料量的表現吧
演算法名稱 | 一千資料量 | 一萬資料量 | 十萬資料量 | 百萬資料量 |
---|---|---|---|---|
暴力排序 | 52ms | 239ms | 37883ms | 2639.261s |
氣泡排序 | 11ms | 177ms | 18665ms | 1430.342s |
插入排序 | 5ms | 30ms | 1430ms | 84.078s |
快速排序 | 1ms | 4ms | 27ms | 114ms |
選擇排序 | 11ms | 74ms | 5080ms | 398.866s |
希爾排序 | 1ms | 9ms | 22ms | 191ms |
歸併排序 | 4ms | 9ms | 41ms | 173ms |
堆排序 | 3ms | 8ms | 16ms | 115ms |
對上述執行時長進行排序:
一千資料量:
快速排序 > 希爾排序 > 堆排序 > 歸併排序 > 插入排序 > 選擇排序 > 氣泡排序 > 暴力排序
一萬資料量:
快速排序 > 堆排序 > 希爾排序 > 歸併排序 > 插入排序 > 選擇排序 > 氣泡排序 > 暴力排序
十萬資料量:
堆排序 > 希爾排序 > 快速排序 > 歸併排序 > 插入排序 > 選擇排序 > 氣泡排序 > 暴力排序
百萬資料量:
快速排序 > 堆排序 > 歸併排序 > 希爾排序 > 插入排序 > 選擇排序 > 氣泡排序 > 暴力排序
複製程式碼
由於執行環境和資料的不同,執行時長可能會出現較大差異
通常來說,快速排序
在資料量較小時,表現得最優秀,而在資料量較大時堆排序
表現得更優秀,平均來說希爾排序
會比歸併排序
和快速排序
快一點,
當然這些結論都不完全準確,因為每種演算法都存在最優和最壞的情況,但是後面四種排序演算法的排名應該是不會出現變動的,由此可見,這些排序演算法之間效能差異還是很大的
總結
像冒泡這種簡單的排序一定要能夠手寫出來,個人覺得除堆排序
外,其他的排序演算法,都還好理解,主要是要動手畫一畫整個排序流程,理解整個排序思想,
捋清自己的思路,儘管堆排序
比較困難一些,但是最好這些排序演算法都能夠用自己的程式碼實現出來