《資料結構與演算法之美》專欄閱讀筆記3——排序演算法
上週排計劃,說花個一天的時間看完好了(藐視臉)~然後每天回家看一會,看了一個星期……做人,要多照鏡子好嘛
文章目錄
1、簡單排序
1.1 如何分析排序演算法
從以下幾個方方面入手。
執行效率
- 最好情況、最壞情況、平均情況時間複雜度
- 時間複雜度的係數、常數、低階
時間複雜度反映的時資料規模較大的時候的增長趨勢。但實際開發中,也存在很多小規模的資料,此事稀疏、常數和低階的佔比較大,需要進行考慮。 - 比較次數和交換次數
記憶體消耗
可以通過空間複雜度來衡量。新概念:原地排序。特指空間複雜度為O(1)的排序演算法。
穩定性
對於同一序列,排序的結果相同。
因為實際比較中,更多的是對物件進行排序。
2、排序演算法
2.1、冒泡
原理:重複地走訪過要排序的元素列
,依次比較兩個相鄰的元素,如果他們的順序錯誤就把他們交換過來。重複地進行直到沒有相鄰元素需要交換,直到排序完成。
要點:
- 對N個待排序元素,要排序的序列是0 ~ (N-已排序個數)。
- 如果一次排序中沒有進行任何元素交換,說明序列已經是有序的,可以停止比較。
分析:
順便複習下均攤時間複雜度的使用場景:
- 大部分情況下,時間複雜度都很低,只有個別情況下,時間複雜度較高。
- 操作之間存在前後連貫的時序關係
對排序演算法的平均複雜度分析,可以使用有序度
和逆序度
來進行分析。 - 有序度:陣列中具有有序關係的元素對的個數。
- 滿序度:完全有序的大小為n的陣列的有序度,為n*(n-1)/2
- 逆序度:與有序度相反。
關鍵公式:逆序度 = 滿有序度 - 有序度。
排序的過程就是達到滿有序度的過程
結論:交換的次數等於逆序度。
平均交換次數 = [0(最好) + n*(n-1)/2(最壞)]/2 = n*(n-1)/4
在作者給的基礎上再來一點優化:
public static <T extends Comparable<T>> void BubbleSort(T[] values, int length) {
if (length <= 1)
return;
int flag = length;
while (flag > 0) {
int end = flag - 1;
flag = 0;
for (int j = 0; j < end; j++) {
if (values[j].compareTo(values[j+1]) > 0) {
T tmp = values[j];
values[j] = values[j+1];
values[j+1] = tmp;
flag = j + 1;
}
}
}
}
通過flag來縮短要排序序列
。
2.2、插入
原理:取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間資料一直有序。
要點:
插入是通過移動來達到滿序度的,所以移動的次數等於逆序度。
public static <T extends Comparable<T>> void InsSort(T[] values, int length) {
if (length <= 1)
return;
for (int i = 0; i < length; i++) {
T v = values[i];
int j = i - 1;
for (; j >= 0; --j) {
if (values[j].compareTo(v) > 0) {
values[j+1] = values[j];
} else {
break;
}
}
values[j+1] = v;
}
}
直接插入排序,因為是要在已排序的序列中找到插入位置,所以需要移動是主節奏。
2.3、選擇排序
原理:每次選取未排序區間的最小值,放到已排序區間的末尾。
要點:相較於插入排序,不需要移動元素,交換次數為n,消耗在遍歷比較上。
實現
public static <T extends Comparable<T>> void SelSort(T[] values, int length) {
if (length <= 1)
return;
for (int i = 0; i < length; i++) {
T min = values[i];
int minIdx = i;
for (int j = i + 1; j < length; j++) {
if (values[j].compareTo(min) < 0) {
min = values[j];
minIdx = j;
}
}
if (minIdx != i) {
T tmp = values[i];
values[i] = values[minIdx];
values[minIdx] = tmp;
}
}
}
2.4、小結
3、分治思路的排序演算法
3.1、歸併排序
原理:將大問題分解成小問題來進行排序,然後對排序後的結果進行合併。(妥妥地遞迴哦)
時間複雜度分析
T(1) = C;
T(n) = 2*T(n/2) + n;
= 2^k * T(n/2^k) + k*n
另外一個分析思路就是:
拆分和合並的結構可以看作一棵二叉樹,拆分部分和合並部分的深度都是logn,每個元素要找到自己的最終位置都要在樹裡跑一遍呢~所以複雜度為n*logn
實現:
private static <T extends Comparable<T>> void SortC(T[] values, int left, int right, T[] tmp) {
if (left >= right)
return;
int mid = (left + right)/2;
for (int i = left; i <= right; i++) {
if (i == mid + 1)
System.out.print(" + ");
System.out.print(values[i] + " ");
}
System.out.println("");
SortC(values, left, mid, tmp);
SortC(values, mid + 1, right, tmp);
MergeC(values, left, mid, right, tmp);
}
private static <T extends Comparable<T>> void MergeC(T[] values, int left, int mid, int right, T[] tmp) {
int i = left;
int j = mid + 1;
int tmpIdx = 0;
while (i <= mid && j <= right) {
if (values[i].compareTo(values[j]) < 1) {
tmp[tmpIdx++] = values[i++];
} else {
tmp[tmpIdx++] = values[j++];
}
}
while (i <= mid) {
tmp[tmpIdx++] = values[i++];
}
while (j <= right) {
tmp[tmpIdx++] = values[j++];
}
tmpIdx = 0;
for (i = left; i <= right; i++) {
values[i] = tmp[tmpIdx++];
}
}
利用哨兵簡化程式設計的思路是:
MERGE(A, p, q, r)
n1 ← q-p+1; //計算左半部分已排序序列的長度
n2 2 ← r-q; //計算右半部分已排序序列的長度
create arrays L[1..n1+1] and R[1..n2+1] //新建兩個陣列臨時儲存兩個已排序序列,長度+1是因為最後有一個標誌位
for i ← 1 to n1
do L[i] ← A[p + i-1] //copy左半部分已排序序列到L中
for j ← 1 to n2
do R[j] ← A[q + j] //copy右半部分已排序序列到R中
L[n1+1] ← ∞ //L、R最後一位設定一個極大值作為標誌位
R[n2+1] ← ∞
i ← 1
j ← 1
for k ← p to r //進行合併
do if L[i] < R[j]
then A[k] ← L[i]
i ← i + 1
else A[k] ← R[j]
j ← j + 1
使用極大值作為哨兵。
程式碼實現:
void Merge(int A[],int p,int q,int r)
{
int i,j,k;
int n1=q-p+1;
int n2=r-q;
int *L=new int[n1+1]; //開闢臨時儲存空間
int *R=new int[n2+1];
for(i=0;i<n1;i++)
L[i]=A[i+p]; //陣列下標從0開始時,這裡為i+p
for(j=0;j<n2;j++)
R[j]=A[j+q+1]; //陣列下標從0開始時,這裡為就j+q+1
L[n1]=INT_MAX; //"哨兵"設定為整數的最大值,INT_MAX包含在limits.h標頭檔案中
R[n2]=INT_MAX;
i=0;
j=0;
for(k=p;k<=r;k++) //開始合併
{
if(L[i]<=R[j])
A[k]=L[i++];
else
A[k]=R[j++];
}
}
3.2、快速排序
原理:也是利用分治的思想。先找到待排序序列中任意一點作為基準點,將小於基準點的放到基準點左邊,大於的放到右邊,並以基準點進行分割槽,遞迴到最小區間為1則所有資料完成排序。
小技巧:為了實現原地排序,在分割槽函式中使用資料交換而不是搬移。
效能分析
- 分割槽極其均衡時,比如兩個大小相同的區間,則跟合併排序效能差不多,O(nlogn)。
- 分割槽極其不均衡時,取決於分割槽函式的實現,類似於冒泡+選擇混合體了,退化到O(n^2)。
雖然均攤分析並不適用於此場景,分析思路可以借鑑,極其不均衡是要求每次分割槽都達到最不均衡的情況,概率比較小,所以平均時間複雜度還是O(nlogn)。
(作者沒有繼續分析,我只好揮發聰明才智瞎猜咯~)
實現
private static <T extends Comparable<T>> int partition(T[] values, int left, int right) {
T pivot = values[right];
int i = left;
for (int j = left; j < right; j++) {
if (values[j].compareTo(pivot) < 0) {
T tmp = values[i];
values[i] = values[j];
values[j] = tmp;
i++;
}
}
T tmp = values[right];
values[right] = values[i];
values[i] = tmp;
return i;
}
private static <T extends Comparable<T>> void QuickSortC(T[] values, int left, int right) {
if (left >= right)
return;
int partIdx = partition(values, left, right);
QuickSortC(values, left, partIdx - 1);
QuickSortC(values, partIdx + 1, right);
}
public static <T extends Comparable<T>> void QuickSort(T[] values, int length) {
QuickSortC(values, 0, length - 1);
}
4、線性排序
桶排序、計數排序和基數排序都不是基於比較的排序演算法,都不涉及元素之間的比較操作,時間複雜度可以達到O(n),也就是線性的。
4.1、桶排序
原理:將要排序的資料分到幾個有序的桶裡,每個桶裡的資料再單獨進行排序,排序完之後按照順序依次取出。
桶排序對資料的要求比較嚴格:
- 要排序的資料需要很容易就能劃分成m個桶
- 桶與桶之間有著天然的大小順序(桶內排序完成後,桶之間不需要進行排序)
- 資料在桶之間的分佈比較均勻。
適用場景:外部排序。資料儲存在外部磁碟中,因為記憶體有限。
4.2、計數排序
看了一下作者給的示意圖,然後還讓集中精神,就一臉懵逼。
其實好像可以小小總結下更簡單呢:
- step1:根據數值範圍,按數值單位劃分成K個桶。(這樣可以不用進行桶內排序)
- step2:每個桶記錄的是下表對應數值的個數。(啥也不做的話我們已經知道每個數值有多少個重複的啦)
- step3:從左到右累加。(因為要知道排序後的位置,就需要知道前面有多少個數據,累加完就曉得啦)
資料來源:2,5,3,0,2,3,0,3。
|
|
- step4:遍歷原始陣列,根據“桶”組可以算出資料在排序後的陣列中的下標。注意:因為重複的資料是挨著存的,對一個數據排序完畢後,需要對桶儲存的值減一。
適用場景:資料範圍不大的非負整數。
實現
public static void CountingSort(Integer[] values, int length) {
if (length <= 1)
return;
int max = values[0];
for (int i = 0; i < length; i++) {
if (max < values[i]) {
max = values[i];
}
}
// 構造索引陣列並初始化
int[] c = new int[max + 1];
for (int i = 0; i <= max; i++) {
c[i] = 0;
}
// 統計
for (int i = 0; i < length; i++) {
c[values[i]]++;
}
// 累加得到索引值
for (int i = 1; i <= max; i++) {
c[i] += c[i-1];
}
int r[] = new int[length];
for (int i = length - 1; i >= 0; --i) {
int index = c[values[i]] - 1;
r[index] = values[i];
c[values[i]]--;
}
for (int i = 0; i < length; i++) {
values[i] = r[i];
}
}
4.3、基數排序
原理:將待比較的數值分割成位,如果低位能夠確定大小則無須繼續比較高位。
按照低位優先比較和高位優先比較有兩種寫法,思路都是一致的。
適用場景:需要可以分割出獨立的“位”來比較,而且位之間有遞進的關係。
注意:用來比較位的演算法必須是穩定的,否則低位的比較結果沒有意義。對於非等長的情況可以使用0來補齊。
5、排序演算法的優化
一個通用的排序演算法需要兼顧效能和適用的資料規模。
5.1、快速排序中分割槽點選取的優化方向
最壞情況下的快速排序的時間複雜度是O(n^2),主要是分割槽點的選擇影響的。
最理想的分割槽點是可以對半分。關於取樣點的選取,作者給了兩條思路(網友給了千千萬萬個<可能不太靠譜>的思路)
三數取中法
從區間的首、中、尾各取一個元素,選大小為中間值的那個作為分割槽點。
看上去就是取樣的嘛(取樣啥的當然是訊號處理專業的強項丫~對不起!我給本專業丟臉了!)
隨機法
從待排序區間中隨機選取一個元素作為分割槽點。
看上去就像是擲色子(相信科學喵~)
引入隨機化快速排序的作用,就是當該序列趨於有序時,能夠讓效率提高,大量的測試結果證明,該方法確實能夠提高效率。但在整個序列數全部相等的時候,隨機快排的效率依然很低,它的時間複雜度為O(N^2)。
5.2、遞迴優化
快速排序跟合併排序最大的不同就是它是先分割槽排序再進行遞迴。如果待排序的序列劃分極端不平衡,遞迴的深度將趨近於n,而棧的大小是很有限的,每次遞迴呼叫都會耗費一定的棧空間,函式的引數越多,每次遞迴耗費的空間也越多。優化後,可以縮減堆疊深度。倆思路:
- 限制遞迴深度
- 通過再堆上模擬一個函式呼叫棧,手動模擬遞迴壓棧、出棧的過程,來消除系統棧大小的限制。(啥意思?黑人問號)
大概說的是下面這個意思?
private static <T extends Comparable<T>> void QuickSortC(T[] values, int left, int right) {
if (left >= right)
return;
// int partIdx = partition(values, left, right);
// QuickSortC(values, left, partIdx - 1);
// QuickSortC(values, partIdx + 1, right);
LinkedListStack<Integer> stack = new LinkedListStack<>();
int partIdx = partition(values, left, right);
if (left < partIdx - 1) {
stack.push(left);
stack.push(partIdx);
}
if (partIdx + 1 < right) {
stack.push(partIdx + 1);
stack.push(right);
}
while(!stack.empty()) {
right = stack.pop();
left = stack.pop();
partIdx = partition(values, left, right);
if (partIdx == left || partIdx == right)
continue;
if (left < partIdx - 1) {
stack.push(left);
stack.push(partIdx);
}
if (partIdx + 1 < right) {
stack.push(partIdx + 1);
stack.push(right);
}
}
}
6、小結
沒有小結,謝謝。