每日一算法,今天我們來談談分治算法,再結合算法看看歸併排序的實現。同時進一步探討一下如果從分治算法的結構算出算法的時間複雜度,這點尤爲重要。
首先分治算法模型有三個基本步驟:
1.分解:將原問題分解成若干個子問題,這些子問題是原問題的規模較小的實例。
2.解決:將這些子問題再進一步遞歸的分解。當若干子問題的規模足夠小時,就直接求解。
3.合併:將上述子問題的解合併成最終問題的解。(這一步至關重要!)
上面是分治算法的步驟,也就是說任何用分治思想實現的各種算法都可以用上面3步分解出來看。
我們下面以歸併排序看一下,就結合上面3步,歸併排序我們分成3步:
1.分解:將n元素的數組分成n/2個元素的兩個子序列。
2.解決:將這些子序列再分解成更小規模的序列,遞歸地排序兩個子序列。
3.合併:合併這兩個已排好序的子序列生成最終答案。
文字說的差不多了,下面show code!!
<span style="font-size:18px;">/*
* mergeSort.cpp
*
* Created on: Dec 4, 2015
* Author: freestyle4568
*/
#include <iostream>
#include <vector>
using namespace std;
void print(vector<int> &A)
{
for (size_t i = 0; i < A.size(); i++)
cout << A[i] << " ";
cout << endl;
}
void merge(vector<int> &A, int p, int q, int r)
{
vector<int>::iterator iter = A.begin();
vector<int> L(iter+p, iter+q+1);
vector<int> R(iter+q+1, iter+r+1);
size_t i = 0, j = 0;
while (i < L.size() && j < R.size()) {
if (L[i] < R[j])
A[p++] = L[i++];
else
A[p++] = R[j++];
}
if (i == L.size()) {
while (j < R.size())
A[p++] = R[j++];
} else {
while (i < L.size())
A[p++] = L[i++];
}
}
void mergeSort(vector<int> &A, int p, int r)
{
if (p < r) {
int q = (p + r) / 2;
cout << "mergeSort " << p << " " << q << endl;
mergeSort(A, p, q);
cout << "mergeSort " << q+1 << " " << r << endl;
mergeSort(A, q+1, r);
cout << "merge from " << p << " to " << r << endl;
merge(A, p, q, r);
}
}
int main()
{
size_t n = 0;
cout << "input the numbers of arrays: ";
cin >> n;
vector<int> A(n, 0);
for (size_t i = 0; i < n; i++) {
cin >> A[i];
}
//print(A);
mergeSort(A, 0, n-1);
print(A);
return 0;
}
</span>
merge是合併的過程。
mergeSort是分解和解決的過程。
這裏和算法導論上面實現的略微不一樣。導論上面用到了哨兵元素,即比實際數據大很多的元素,這樣帶來的好處是不用判斷兩個數組有沒有到頭了。可以直接一個for循環結束,判斷條件就是將較小者放入原數組中。我覺得merge代碼看起來不是很費力氣,但是遞歸的過程我希望大家能做的心中有數,這個很重要,就是原數組的哪個部分先merge,哪個部分再merge。理解這個mergeSort過程對我們掌握遞歸思想很有幫助。我特地在mergeSort之前加了print內容,從輸出結果可以一目瞭然遞歸過程。
可以結合上圖看看遞歸的過程,下面我們分析一下歸併排序的複雜度。
還是先回歸分治算法,我們來總的看一下分治算法的一般複雜性解決思路。
設解決規模爲n的問題的時間爲T(n), 將原問題分解爲a個子問題,每個子問題的規模是原來的1/b倍。注意a和b不一定相等哦!然後可以得到下面遞歸方程表現:
T(n) = aT(n/b) + D(n) +C(n)。
其中D(n)表示分解子問題的時間,這個大多是常數時間
C(n)表示合併子問題的時間,這個很容易看出。
具體如何計算上面的遞歸方程呢?其實有多種方式,這裏介紹兩種,一種是遞歸數方式,一種是主方法。當然在數學上有更多的方法求解,感興趣的同學可以試着用不同的方法去解解看。
在上面的歸併排序中,a = b = 2;D(n)是一個常數O(1),可以忽略,C(n)是O(n)。
所以T(n) = 2T(n/2) + n;
合併n/2規模的子問題到最終答案時,用掉n時間。
形成n/2子問題的解用掉n/2時間。
形成n/4子問題的解用掉n/4時間。
..............
我們可以用數形表示:(由於沒有找到a=b=2的,勉強用a=b=3湊合把,原理都是一樣的)
一共有log3^n列,所以複雜度爲O(nlog3^n)。
如果a = b = 2呢,O(nlog2^n)。
如果a = b = x呢,O(nlogx^n)。
所以我們可以發現,不管歸併排序分成多少子問題,複雜度都是一樣的,爲O(nlogn)。
下面總結一下:
歸併排序的時間複雜度爲:O(nlogn)。
有人說能不能用更簡單的方法一下子看出分治算法的複雜度啊!下面我們來介紹主方法:
主方法爲我們提供菜譜式的求解方法求解遞歸式:
T(n) = aT(n/b) + f(n)
定理:令a >= 1和b > 1是常數,f(n)是一個函數,T(n)是定義在非負整數上的遞歸式,如上式。那麼T(n)有如下漸進界:
1 )若f(n) = O(n^logb^a), 則T(n) = Θ(n^logb^a);
2 )若f(n) = Θ(n^logb^a), 則T(n) =Θ(n^logb^a * logn);
3 )若f(n) = Ω(n^logb^a), 且對於某個常數c<1和所有足夠大的n有af(n/b) <= cf(n),則T(n) =Θ(f(n))
下面解釋一下O,Θ, Ω的關係:
f(n) = O(g(n))表示 存在f(n) <= c*g(n), c爲常數。
f(n) = Ω(g(n))表示 存在f(n) >= c*g(n).
f(n) = Θ(g(n))表示 存在a*g(n) <= f(n) <= b*g(n).
有個主定理我們在看歸併排序,然後一目瞭然,f(n) = Θ(n^2),所以T(n) = Θ(nlogn)。
如果不滿足主定理的話,我們還是老老實實的用遞歸數做比較好!!!
今天分治算法就講到這裏,以後會結合有趣的算法題來用用分治算法的。