1、遞歸
定義:遞歸算法是一個過程或函數在其定義或說明中又直接或間接調用自身的一種方法。
遞歸算法可以將一個大型的複雜問題轉化爲一個與原問題相似的規模較小的問題求解,其優勢在於用有限的語句定義無限的集合,可以有效減少代碼量,使程序簡潔易懂;其缺點在於運行效率低,空間消耗大,容易造成堆棧溢出。
遞歸需要有邊界條件,遞歸前進段和遞歸返回段。當不滿足邊界條件時,遞歸前進;當滿足邊界條件時,遞歸返回。遞歸必須有一個明確的邊界條件,又稱爲遞歸出口,否則遞歸將無限進行下去。
遞歸算法適用的3類問題:
- 數據的定義是遞歸定義的。例如Ackerman函數;
- 問題解法用遞歸算法實現。例如回溯算法;
- 數據結構的形式是遞歸定義的。如樹的遍歷。
具有遞歸特性的問題
(1) 阿克曼函數
阿克曼函數的數學定義如下:
阿克曼函數的算法描述如下:
int ack(int n,int m)
{
if(m == 0)
return n + 1;
else if(n == 0)
return ack(m - 1,n);
else
return ack(m - 1,ack(m,n - 1));
}
(2) 斐波那契數列
斐波那契數列又稱黃金分割數列,其頭兩項均爲1;從第三項開始,每一項都等於前兩項之和。
斐波那契數列的數學定義如下:
斐波那契數列的遞歸算法描述如下:
int fibo(int n)
{
if(n <= 1)
return 1;
else
return fibo(n - 1) + fibo(n - 2);
}
斐波那契數列也可以用轉化爲遞推問題。遞推算法描述如下:
int fibo(int n)
{
int f[n];
f[0] = f[1] = 1;
for(int i = 2;i < n;i++)
f[i] = f[i - 1] + f[i - 2];
return f[n - 1];
}
(3) 漢諾塔問題
漢諾塔問題可以概括爲:某處有三個柱子A,B,C;在柱子A上有64個圓盤的,並且大圓盤在下,小圓盤在上;若一次只能搬動一個圓盤,且搬動過程始終保持大圓盤在下,小圓盤在上。如何搬動才能將柱子A上的圓盤全部轉移到柱子B上?
分析:
如果只有一個圓盤,那隻需要將它從A搬到B,即可完成;
如果有兩個圓盤,那需要先將上方圓盤搬到C,再將下方圓盤搬到B,最後將C上的圓盤搬到B上,即可完成;
如果有三個圓盤,那需要先將上方兩個圓盤搬到C,再將最下方圓盤搬到B,最後將上方兩個圓盤搬到B,即可完成;
以此類推:對於n個圓盤,先將上方的n-1個圓盤搬到C,再將最下方圓盤搬到B,最後將上方n-1個圓盤搬到B,即可完成。
由此,可得漢諾塔問題的遞歸算法描述如下:
//move函數的作用是將第一個柱子的第一個圓盤搬到第二個柱子上
void hanoi(int n,char a,char b,char c) //a爲起始柱,b爲目標柱,c爲中間柱
{
if(n == 1) move(a,b);
else{
hanoi(n - 1,a,c,b);
move(a,b);
hanoi(n - 1,c,b,a);
}
}
遞歸算法分析
設遞歸算法的時間複雜度爲T(n),每次可以將問題劃分爲a個規模爲原來1/b的子問題,且每次劃分和重組的花費爲d(n)。
當n=1時爲遞歸出口,此時時間複雜度爲1;
由此可以計算遞歸算法的時間複雜度:
設n = bi,有:
設d(x)爲積性函數,即d(x*y)=d(x)*d(y),有:
若a > d(b),則有:
若a < d(b),則有:
若a = d(b),則有:
以快速排序算法爲例
快速排序的具體算法描述如下:
template<class T>
int Partition(T *p,int low,int high)
{
T pivot = p[low]; //獲取樞軸
//p[0] = p[low]; //暫存樞軸
while(low < high){ //兩端掃描並劃分
while(low < high && p[high] >= pivot)
--high;
p[low] = p[high];
while(low < high && p[low] <= pivot)
++low;
p[high] = p[low];
}
p[low] = pivot; //記錄樞軸
return low; //返回樞軸位置
}
template<class T>
void QuickSort(T *p,int low,int high)
{
if(low < high){ //遞歸出口
int pivot = Partition(p,low,high); //第一次劃分
QuickSort(p,low,pivot-1); //對左邊遞歸
QuickSort(p,pivot+1,high); //對右邊遞歸
}
}
其a=2,b=2,d(n)=n爲積性函數,且a = d(b) = 2;
由此可知,快速排序的時間複雜度爲:
2、分治策略
分治策略(又稱分治法)是對於一個規模爲n的問題,若該問題可以容易地解決則直接解決,否則將其分解爲k個規模較小的子問題,這些子問題互相獨立且與原問題形式相同。分治策略遞歸地解這些子問題,再將各個子問題的解合併得到原問題的解。
分治法的基本步驟:
- 分解:將原問題分解爲若干個規模較小、相互獨立、與原問題形式相同的子問題;
- 解決:若子問題規模較小而容易解決則直接解,否則遞歸地解;
- 合併:將各子問題的解合併爲原問題的解。
合併是分治法的關鍵所在,需要具體問題具體分析。
分治法的適用條件:
- 該問題縮小到一定程度可以容易地解決;
該條件對於絕大多數問題能夠滿足。 - 該問題可以分解爲若干個規模較小的相同子問題,即問題具有最優子結構性質;
該條件是應用分治法的前提,也是大多數問題可以滿足的,反映了遞歸思想的應用。 - 利用該問題分解出的子問題可以合併爲該問題的解;
能否利用分治法完全取決於該條件。若具備條件1、2而不具備條件3,可以考慮動態規劃法或貪心法。 - 該問題的各個子問題是獨立的,不包含公共子問題。
該條件涉及分治法的效率。若各子問題不是獨立的,則分治法需要很多不必要的工作。
分治策略往往和遞歸算法同時使用,因此遞歸算法的時間複雜度分析可適用於分治法。
應用分治法解決問題舉例
上面介紹遞歸算法的時間複雜度計算時舉例的快速排序也是分治法的一種應用。
(1) 二分查找法
給定n個元素組成的有序序列{0:n-1],在這n個元素中找到特定元素x。若使用順序查找法,最壞情況下的時間複雜度爲O(n);
利用有序的條件,採用二分查找法可以在最壞情況下將時間複雜度減少到O(log n)。
二分查找法的基本思想是:
- 將n個元素分成兩半,取a[n/2[與x比較;若x = a[n/2[,則找到對應元素,查找結束;
- 若x <a[n/2],則在數組的左半部分繼續查找;否則在右半部分繼續查找;
- 無法再劃分時,查找失敗;
二分查找法的算法描述如下:
int binarySearch(int a[],int x,int n)
{
int low = 0;
int high = n - 1;
while(low <= high){
int middle = (low + high) / 2;
if(a[middle] == x)
return middle;
else if(x < a[middle])
high = middle - 1;
else
low = middle + 1;
}
return -1;
}
每次while循環都將待查找的數組減小了一半,因此在最壞情況下,while循環執行了O(log n)次,而循環體內的執行時間爲O(1),因此二分查找法在最壞情況下的時間複雜度爲O(log n)。
(2) 棋盤覆蓋問題
在一個2k * 2k 個方格組成的棋盤中,若恰有一個與其他方格不同,稱該方格爲特殊方格,且稱該棋盤爲特殊棋盤。要求用4種不同形狀(方向不同)的L型骨牌覆蓋棋盤上除特殊方格以外的所有方格,且L型骨牌不得重疊覆蓋。顯然,在任何一個棋盤中,使用的L型骨牌個數爲(4k-1)/3。
使用分治策略可以得到棋盤覆蓋問題的一個簡捷的算法。算法基本思想如下:
- 沿棋盤的兩條對稱線,將棋盤劃分爲4個2k-1 * 2k-1 個方格組成的子棋盤;劃分後,4個子棋盤中將有一個棋盤爲特殊棋盤,剩餘的棋盤中均不包含特殊方格。
- 爲了將三個普通棋盤轉化爲普通方格,將三個棋盤的匯合處(即原棋盤中點附近四個方格中的三個)用一個L型骨牌覆蓋,並在子棋盤中將它們看作特殊方格;
- 重複使用這種分治策略,直至棋盤劃分爲11的子棋盤,則三個11子棋盤的方格可以用一個L型骨牌覆蓋,即棋盤覆蓋完成。
棋盤覆蓋問題的分治算法描述如下:
int board[1025][1025];//爲方便遞歸,將棋盤設爲全局變量,其中[0][0]表示左上角方格。
static int title = 1;//使用的骨牌編號
void chessboard(int tr,int tc,int dr,int dc,int size)//tr,tc表示棋盤的左上角座標;dr,dc表示特殊方格座標;size表示棋盤大小
{
if(size == 1) return;
int s = size / 2;
int t = title++;
if(dr < tr + s && dc < tc + s) //特殊方格在左上角棋盤
chessboard(tr,tc,dr,dc,s);
else{//不在左上角棋盤,使用t號骨牌覆蓋右下角方格
board[tr + s - 1][tc + s - 1] =t;
chessboard(tr,tc,tr+s-1,dr+s-1,s);
}
if(dr < tr + s && dc >= tc + s) //在右上角棋盤
chessboard(tr,tc+s,dr,dc,s);
else{ //不在右上角棋盤
board[tr + s - 1][tc + s] = t;
chessboard(tr,tc+s,tr+s-1,tc+s,s);
}
if(dr >= tr + s && dc < tc + s)//左下角
chessboard(tr+s,tc,dr,dc,s);
else{
board[tr + s][tc + s - 1] = t;
chessboard(tr+s,tc,tr+s,tc+s-1,s);
}
if(dr >= tr + s && dc >= tc + s)//右下角
chessboard(tr+s,tc+s,dr,dc,s);
else{
board[tr + s][tc + s] = t;
chessboard(tr+s,tc+s,tr+s,tc+s,s);
}
}
每一次分治,都將問題分解爲四個規模爲原先一半的子問題(邊長),總共需要分解k次。而分解和合並花費的時間爲O(1);因此該算法的時間複雜度爲O(4k)。
(3) 大整數乘法
設有兩個二進制數X和Y,現要計算它們的乘積X * Y。按計算規則,X中每個數都要與Y中所有數相乘,因此一般方法下乘法的時間複雜度位O(n2)。
採用分治算法處理降低算法複雜度。將X,Y分別分成兩個部分,每個部分分別有n / 2位。
則有如下關係:
其時間複雜度可以寫成如下遞歸形式:
對於T(n),可以計算得到:T(n) = O(n2),並沒有改進算法的性能。
要提升算法性能,需要減少乘法的計算次數。藉助如下數學方法,可以提升算法性能。
該計算過程中共進行3次n/2位的乘法運算,6次加減法和2次移位。其時間複雜度:
可計算得T(n) = O(log 3) = O(n1.59),算法的性能得到改進。
(4) 矩陣乘法
進行矩陣運算C=A×B,其中A,B爲n×n矩陣。矩陣乘法一般的運算方法是:
根據該規則,完成一次矩陣乘法,需要進行3次次數爲n的循環,因此時間複雜度爲O(n3)。
現根據分治策略,將矩陣劃分爲四個子矩陣。
根據分塊矩陣的運算規則,可以得到計算公式:
其時間複雜度可以寫爲遞歸形式:
可以計算得到,此算法時間複雜度T(n) = O(n3),未提升算法性能。
爲了提升性能,需要減少乘法運算的次數。採用如下數學方法進行優化:
其時間複雜度的遞歸形式如下:
最後算得時間複雜度:T(n) = O(log 7) = O(n2.87)。