two pointers思想與歸併排序
什麼是 two pointers
以一個例子引入:給定一個遞增的正整數序列和一個正整數 M,求序列中的兩個不同位置的數 a 和 b,使得它們的和恰好為 M,輸出所有滿足條件的方案。
本題的一個最直觀的想法是,使用二重迴圈列舉序列中的整數 a 和 b,判斷它們的和是否為 M。時間複雜度為 O(n^2)。當n的規模足夠大時,這顯然是不可取的。
two pointers 將利用有序序列的列舉特性來有效降低複雜度。它針對本題的演算法如下:
令下標 i 的初值為0,下標 j 的初值為 n-1,即令 i、j 分別指向序列的第一個元素和最後一個元素,接下來根據 a[i]+a[j] 與 M 的大小來進行下面三種選擇,使 i 不斷向右移動、使 j 不斷向左移動,直到 i≥j 成立
-
若 a[i]+a[j]==M ,說明找到了其中一種方案,令 i=i+1、j=j-1。
-
若 a[i]+a[j]>M,令 j=j-1。
-
若 a[i]+a[j]<M,令 i=i+1。
反覆執行上面三個判斷,直到 i≥j 成立,在遞增序列的前提下,迴圈只需要進行到i>=j時停止,所以理想狀態下只需要遍歷半個序列,時間複雜度只需要O(n)。
程式碼如下:
while(i < j) { if(a[i]+a[j] == M) { printf("%d %d\n", i, j); i++; j--; } else if(a[i]+a[j] < M) { i++; } else { j--; } }
序列合併問題
再來看序列合併問題。假設有兩個遞增序列 A 與 B,要求將它們合併為一個遞增序列 C。
同樣的,可以設定兩個下標 i 和 j ,初值均為0,表示分別指向序列 A 的第一個元素和序列 B 的第一個元素,然後根據 A[i] 與 B[j] 的大小來決定哪一個放入序列 C。
- 若 A[i]≤B[j],把 A[i] 加入序列 C 中,並讓 i 加1
- 若 A[i]>B[j],把 B[j] 加入序列 C 中,並讓 j 加1
上面的分支操作直到 i、j 中的一個到達序列末端為止,然後將另一個序列的所有元素依次加入序列 C 中,程式碼如下:
int merge(int A[], int B[], int C[], int n, int m) { int i=0, j=0, index=0; // i指向A,j指向B,index指向C while(i<n && j<m) { if(A[i] <= B[j]) { // 若 A[i]≤B[j] C[index++] = A[i++]; } else { // 若 A[i]>B[j] C[index++] = B[j++]; } } while(i<n) C[index++] = A[i++]; // 若 A 有剩餘 while(j<m) C[index++] = B[j++]; // 若 B 有剩餘 return index; // 返回 C 長度 }
廣義上的 two pointers 是利用問題本身與序列的特性,使用兩個下標 i、j 對序列進行掃描(可以同向掃描,也可以反向掃描),以較低的複雜度(一般為 O(n) )解決問題。
歸併排序
歸併排序是一種基於“歸併”思想的排序方法,本節主要介紹其中最基本的 2-路歸併排序。2-路歸併排序的原理是,將序列兩兩分組,將序列歸併為\(\lceil \frac n 2 \rceil\)個組,組內單獨排序;然後將這些組再兩兩歸併,生成\(\lceil \frac n 4 \rceil\)個組,組內再單獨排序;以此類推,直到只剩下一個組為止。時間複雜度為 O(nlogn)。
說明:對於偶數個的陣列正常對半分就行,對於奇數個的陣列,留下最後1個多餘的單獨1組,其餘兩兩1組。
我們先寫一個merge函式將陣列的兩個區間合併為一個有序區間。
const int maxn = 100;
// 將陣列A的 [L1,R1] 與 [L2,R2] 合併為有序區間(此處 L2=R1+1 )
void merge(int A[], int L1, int R1, int L2, int R2) {
int i=L1, j=L2;
int temp[maxn], index=0; // temp 臨時儲存合併序列
while(i<=R1 && j<=R2) {
if(A[i] <= A[j]) { // 若 A[i] ≤ A[j]
temp[index++] = A[i++];
} else { // 若 A[i] > A[j]
temp[index++] = A[j++];
}
}
while(i <= R1) temp[index++] = A[i++];
while(j <= R2) temp[index++] = A[j++];
for(int i=0; i<index; ++i) {
A[L1+i] = temp[i]; // 將合併後的序列賦值回 A
}
}
1. 遞迴實現
只需反覆將當前區間 [left,right] 分為兩半,對兩個子區間 [left,mid] 與 [mid+1, right] 分別遞迴進行歸併排序,然後將兩個已經有序的子區間合併為有序序列即可。程式碼如下:
// 歸併排序遞迴實現
// 只需反覆將當前區間 [left,right] 分為兩半,對兩個子區間 [left,mid] 與 [mid+1, right]
// 分別遞迴進行歸併排序,然後將兩個已經有序的子區間合併為有序序列即可。
void mergeSort(int A[], int left, int right) {
if(left < right) { // 當 left==right 時,只有一個元素,認定為有序
int mid = (left+right)/2;
mergeSort(A, left, mid); // 分為左區間和右區間
mergeSort(A, mid+1, right);
merge(A, left, mid, mid+1, right); // 將左區間和右區間合併
}
}
2.非遞迴實現
非遞迴實現主要考慮到這樣一點:每次分組時組內元素個數上限都是2的冪次。於是就可以想到這樣的思路:令步長 step 的初值為2,然後將陣列中每 step 個元素作為一組,將其內部進行排序;再令 step 乘以2,重複上面的操作,直到 step/2 超過元素個數 n 。程式碼如下:
// 歸併排序非遞迴實現
// 令步長 step 的初值為2,然後將陣列中每 step 個元素作為一組,
// 將其內部進行排序;再令 step 乘以2,重複上面的操作,直到 step/2 超過元素個數 n 。
void mergeSort(int A[]) {
// step 為組內元素個數
for(int step=0; step/2 <= n; step *= 2) {
for(int i = 1; i <= n; i += step) { // 對每一組,陣列下標從1開始
int mid = i + step/2 -1; // 左區間元素個數為 step/2
if(mid+1 <= n) { // 右區間存在元素
// 左區間為 [left,mid],右區間為 [mid+1, min(i+step-1,n)
merge(A, i, mid, mid+1, min(i+step-1, n));
}
/*
// 28-32行也可以用 sort 代替 merge 函式,只要時間允許
sort(A+i, A+min(i+step, n+1));
*/
}
// 用 sort 代替,此處輸出歸併排序的某一趟結束時的序列
}
}
如果題目只要求給出歸併排序每一趟結束時的序列,可以用sort函式代替 merge 函式,只要時間允許。
3.演算法分析
時間複雜度: O(nlogn)
歸併排序的執行效率與要排序的原始陣列的有序程度無關,所以其時間複雜度是非常穩定的,不管是最好情
況、最壞情況,還是平均情況,時間複雜度都是 O(nlogn)。
空間複雜度:O(n)
它有一個致命的“弱點”,那就是歸併排序不是原地排序演算法。
這是因為歸併排序的合併函式,在合併兩個有序陣列為一個有序陣列時,需要藉助額外的儲存空間。
在每次進行合併操作時,需要O(n)的陣列空間存放資料。
參考自:https://www.cnblogs.com/coderJiebao/p/Algorithmofnotes08.html