一、什么是遞歸
遞歸是指某個函數直接或間接的調用自身。問題的求解過程就是划分成許多相同性質的子問題的求解,而小問題的求解過程可以很容易的求出,這些子問題的解就構成里原問題的解了。
二、遞歸的幾個特點
1.遞歸式,就是如何將原問題划分成子問題。
2.遞歸出口,遞歸終止的條件,即最小子問題的求解,可以允許多個出口。
3.界函數,問題規模變化的函數,它保證遞歸的規模向出口條件靠攏
三、遞歸的運做機制
很明顯,很多問題本身固有的性質就決定此類問題是遞歸定義,所以遞歸程序很直接算法程序結構清晰、思路明了。但是遞歸的執行過程卻很讓人費解,這也是讓很多人難理解遞歸的原因之一。由於遞歸調用是對函數自身的調用,在一次調用沒有結束之前又開始了另外一次調用,按照作用域的規定,函數在執行終止之前是不能收回所占用的空間,必須保存下來,這也就意味着每一次的調用都要把分配的相應空間保存起來。為了更好管理這些空間,系統內部設置一個棧,用於存放每次函數調用與返回所需的各種數據,其中主要包括函數的調用結束的返回地址,返回值,參數和局部變量等。
其過程大致如下:
1.計算當前函數的實參的值
2.分配空間,並將首地址壓棧,保護現場
3.轉到函數體,執行各語句,此前部分會重復發生(遞歸調用)
4.直到出口,從棧頂取出相應數據,包括,返回地址,返回值等等
5.收回空間,恢復現場,轉到上一層的調用位置繼續執行本次調用未完成的語句。
四、引入非遞歸
從用戶使用角度來說,遞歸真的很簡便,對程序宏觀上容易理解。遞歸程序的時間復雜度雖然可以根據T(n)=T(n-1)*f(n)遞歸求出,其中f(n)是 遞歸式的執行時間復雜度,一般來說,時間復雜度和對應的非遞歸差不多,但是遞歸的效率是相當低的它主要花費在反復的進棧出棧,各種中斷等機制上(具體的可 以參考操作系統)更有甚者,在遞歸求解過程中,某些解會重復的求好幾次,這是不能容忍的,這些也是引入非遞歸機制的原因之一。
五、遞歸轉非遞歸的兩種方法
1.一般根據是否需要回朔可以把遞歸分成簡單遞歸和復雜遞歸,簡單遞歸一般就是根據遞歸式來找出遞推公式(這也就引申出分治思想和動態規划)。
2.復雜遞歸一般就是模擬系統處理遞歸的機制,使用棧或隊列等數據結構保存回朔點來求解。
六.如何用棧實現遞歸與非遞歸的轉換:
1.遞歸與非遞歸轉換的原理.
遞歸與非遞歸的轉換基於以下的原理:
所有的遞歸程序都可以用樹結構表示出來.
下面我們以二叉樹來說明,不過大多數情況下二叉樹已經夠用,而且理解了二叉樹的遍歷,其它的樹遍歷方式就不難了.
1)前序遍歷
a)遞歸方式:
void preorder_recursive(Bitree T) /* 先序遍歷二叉樹的遞歸算法 */
{
if (T) {
visit(T); /* 訪問當前結點 */
preorder_recursive(T->;lchild); /* 訪問左子樹 */
preorder_recursive(T->;rchild); /* 訪問右子樹 */
}
}
b)非遞歸方式
void preorder_nonrecursive(Bitree T) /* 先序遍歷二叉樹的非遞歸算法 */
{
initstack(S);
push(S,T); /* 根指針進棧 */
while(!stackempty(S)) {
while(gettop(S,p)&&p) { /* 向左走到盡頭 */
visit(p); /* 每向前走一步都訪問當前結點 */
push(S,p->lchild);
}
pop(S,p);
if(!stackempty(S)) { /* 向右走一步 */
pop(S,p); /* 空指針退棧 *
push(S,p->rchild);
}
}
}
2)中序遍歷
a)遞歸方式
void inorder_recursive(Bitree T) /* 中序遍歷二叉樹的遞歸算法 */
{
if (T) {
inorder_recursive(T->;lchild); /* 訪問左子樹 */
visit(T); /* 訪問當前結點 */
inorder_recursive(T->;rchild); /* 訪問右子樹 */
}
}
b)非遞歸方式
void inorder_nonrecursive(Bitree T)
{
initstack(S); /* 初始化棧 */
push(S, T); /* 根指針入棧 */
while (!stackempty(S)) {
while (gettop(S, p) && p) /* 向左走到盡頭 */
push(S, p->lchild);
pop(S, p); /* 空指針退棧 */
if (!stackempty(S)) {
pop(S, p);
visit(p); /* 訪問當前結點 */
push(S, p->;rchild); /* 向右走一步 */
}
}
}
3)后序遍歷
a)遞歸方式
void postorder_recursive(Bitree T) /* 中序遍歷二叉樹的遞歸算法 */
{
if (T) {
postorder_recursive(T->;lchild); /* 訪問左子樹 */
postorder_recursive(T->;rchild); /* 訪問右子樹 */
visit(T); /* 訪問當前結點 */
}
}
b)非遞歸方式
typedef struct {
BTNode* ptr;
enum {0,1,2} mark;
} PMType; /* 有mark域的結點指針類型 */
void postorder_nonrecursive(BiTree T) /* 后續遍歷二叉樹的非遞歸算法/
{
PMType a;
initstack(S); /* S的元素為PMType類型 */
push (S,{T,0}); /* 根結點入棧 */
while(!stackempty(S)) {
pop(S,a);
switch(a.mark)
{
case 0:
push(S,{a.ptr,1}); /* 修改mark域 */
if(a.ptr->;lchild)
push(S,{a.ptr->;lchild,0}); /* 訪問左子樹 */
break;
case 1:
push(S,{a.ptr,2}); /* 修改mark域 */
if(a.ptr->;rchild)
push(S,{a.ptr->;rchild,0}); /* 訪問右子樹 */
break;
case 2:
visit(a.ptr); /* 訪問結點 */
}
}
}
4)如何實現遞歸與非遞歸的轉換
通常,一個函數在調用另一個函數之前,要作如下的事情:
a)將實在參數,返回地址等信息傳遞給被調用函數保存;
b)為被調用函數的局部變量分配存儲區;
c)將控制轉移到被調函數的入口.
從被調用函數返回調用函數之前,也要做三件事情
:a)保存被調函數的計算結果;
b)釋放被調函數的數據區;
c)依照被調函數保存的返回地址將控制轉移到調用函數.
所有的這些,不論是變量還是地址,本質上來說都是"數據",都是保存在系統所分配的棧中的.
遞歸調用時數據都是保存在棧中的,有多少個數據需要保存就要設置多少個棧,而且最重要的一點是:控制所有這些棧的棧頂指針都是相同的,否則無法實現 同步.
下面來解決第二個問題:在非遞歸中,程序如何知道到底要轉移到哪個部分繼續執行?
回到上面說的樹的三種遍歷方式,抽象出來只有三種操作:訪問當前結點,訪問左子樹,訪問右子樹.這三種操作的順序不同,遍歷方式也不同.如果我們再抽象一點,對這三種操作再進行一個概括,可以得到:
a)訪問當前結點:對目前的數據進行一些處理;
b)訪問左子樹:變換當前的數據以進行下一次處理;
c)訪問右子樹:再次變換當前的數據以進行下一次處理(與訪問左子樹所不同的方式).
下面以先序遍歷來說明:
void preorder_recursive(Bitree T) /* 先序遍歷二叉樹的遞歸算法 */
{
if (T) {
visit(T); /* 訪問當前結點 */
preorder_recursive(T->;lchild); /* 訪問左子樹 */
preorder_recursive(T->;rchild); /* 訪問右子樹 */
}
}
visit(T)這個操作就是對當前數據進行的處理, preorder_recursive(T->;lchild)就是把當前數據變換為它的左子樹,訪問右子樹的操作可以同樣理解了.
現在回到我們提出的第二個問題:如何確定轉移到哪里繼續執行?關鍵在於以下三個地方:
a) 確定對當前數據的訪問順序,簡單一點說就是確定這個遞歸程序可以轉換為哪種方式遍歷的樹結構;
b)確定這個遞歸函數轉換為遞歸調用樹時的分支是如何划分的,即確定什么是這個遞歸調用樹的"左子樹"和"右子樹"
c)確定這個遞歸調用樹何時返回,即確定什么結點是這個遞歸調用樹的"葉子結點.
三.兩個例子
好了上面的理論知識已經足夠了,下面讓我們看看幾個例子,結合例子加深我們對問題的認識:
1)例子一:
f(n) = n + 1; (n <2)
f[n/2] + f[n/4](n >= 2);
這個例子相對簡單一些,遞歸程序如下:
int f_recursive(int n)
{
int u1, u2, f;
if (n < 2)
f = n + 1;
else {
u1 = f_recursive((int)(n/2));
u2 = f_recursive((int)(n/4));
f = u1 * u2;
}
return f;
}
下面按照我們上面說的,確定好遞歸調用樹的結構,這一步是最重要的.首先,什么是葉子結點 ,我們看到當n < 2時f = n + 1,這就是返回的語句,有人問為什么不是f = u1 * u2,這也是一個返回的語句呀?
答案是:這條語句是在u1 = exmp1((int)(n/2))和u2 = exmp1((int)(n/4))之后執行的,是這兩條語句的父結點. 其次,什么是當前結點,由上面的分析,f = u1 * u2即是父結點.然后,順理成章的u1 = exmp1((int)(n/2))和u2 = exmp1((int)(n/4))就分別是左子樹和右子樹了.最后,我們可以看到,這個遞歸函數可以表示成后序遍歷的二叉調用樹.好了,樹的情況分析到這里,
下面來分析一下棧的情況,看看我們要把什么數據保存在棧中,在上面給出的后序遍歷的如果這個過程你沒非遞歸程序中我們已經看到了要加入一個標志域,因此在棧中要保存這個標志域;另外,u1,u2和每次調用遞歸函數時的n/2和n/4參數都要保存,這樣就要分別有三個棧分別保存:標志域,返回量和參數,不過我們可以做一個優化,因為在向上一層返回的時候,參數已經沒有用了,而返回量也
只有在向上返回時才用到,因此可以把這兩個棧合為一個棧.
如果對於上面的分析你沒有明白,建議你根據這個遞歸函數寫出它的遞歸棧的變化情況以加深理解,再次重申一點:前期對樹結構和棧的分析是最重要的,如果你的程序出錯,那么請返回到這一步來再次分析,最好把遞歸調用樹和棧的變化情況都畫出來,並且結合一些簡單的參數來人工分析你的算法到底出錯在哪里.
2)例子二
快速排序算法
遞歸算法如下:
void swap(int array[], int low, int high)
{
int temp;
temp = array[low];
array[low] = array[high];
array[high] = temp;
}
int partition(int array[], int low, int high)
{
int p;
p = array[low];
while (low < high) {
while (low < high && array[high] >= p)
high--;
swap(array,low,high);
while (low < high && array[low] <= p)
low++;
swap(array,low,high);
}
return low;
}
void qsort_recursive(int array[], int low, int high)
{
int p;
if(low < high) {
p = partition(array, low, high);
qsort_recursive(array, low, p - 1);
qsort_recursive(array, p + 1, high);
}
}
需要說明一下快速排序的算法: partition函數根據數組中的某一個數把數組划分為兩個部分, 左邊的部分均不大於這個數,右邊的數均不小於這個數,然后再對左右兩邊的數組再進行划分.這里我們專注於遞歸與非遞歸的轉換,partition函數在非遞歸函數中同樣的可以調用(其實partition函數就是對當前結點的訪問).
再次進行遞歸調用樹和棧的分析:
遞歸調用樹:
a)對當前結點的訪問是調用partition函數;
b)左子樹: qsort_recursive(array, low, p - 1);
c)右子樹:qsort_recursive(array, p + 1, high);
d)葉子結點:當low < high時;
e)可以看出這是一個先序調用的二叉樹
棧:要保存的數據是兩個表示范圍的坐標.
void qsort_nonrecursive(int array[], int low, int high)
{
int m[50], n[50], cp, p;
/* 初始化棧和棧頂指針 */
cp = 0;
m[0] = low;
n[0] = high;
while (m[cp] < n[cp]) {
while (m[cp] < n[cp]) { /* 向左走到盡頭 */
p = partition(array, m[cp], n[cp]); /* 對當前結點的訪問 */
cp++;
m[cp] = m[cp - 1];
n[cp] = p - 1;
}
/* 向右走一步 */
m[cp + 1] = n[cp] + 2;
n[cp + 1] = n[cp - 1];
cp++;
}
}