1. 程式人生 > >迭代與遞迴:To Iterate,Human; to Recurse, Divine.

迭代與遞迴:To Iterate,Human; to Recurse, Divine.

引言

從前有座山,山裡有座廟,廟裡有個老和尚,正在給小和尚講故事呢!故事是什麼呢?「從前有座山,山裡有座廟,廟裡有個老和尚,正在給小和尚講故事呢!故事是什麼呢?『從前有座山,山裡有座廟,廟裡有個老和尚,正在給小和尚講故事呢!故事是什麼呢?……』」

什麼是遞迴

遞迴(Recursion),在數學與電腦科學中,是指在函式的定義中使用函式自身的方法。

為什麼要用遞迴

  • 有些問題很難用一般的迴圈來解決,採用遞迴使得我們思考的方式得以簡化。
  • 遞迴可以處理無限迴圈問題。(例如對於一個 字串進行全排列 ,字串長度不定)
    void permute(const string &prefix, const
    string &str) { if(str.length() == 0) cout << prefix << endl; else { for(int i = 0; i < str.length(); i++) permute(prefix+str[i], str.substr(0,i)+str.substr(i+1,str.length())); } }

遞迴基本思想

  • 把規模大的問題轉化為規模小的 相似 的子問題來解決。
  • 解決大問題的方法和解決小問題的方法往往是同一個方法。

遞迴使用條件

  • 存在一個遞迴呼叫的 終止條件 ;
  • 每次遞迴的呼叫必須 越來越靠近終止條件
     ;只有這樣遞迴才會終止,否則是不能使用遞迴的!

遞迴過程理解

  • 我們已經完成了嗎?如果完成了,返回結果。
  • 如果沒有,則簡化問題,解決較容易的問題,並將結果組裝成原始問題的解決辦法。

return 語句

下面將會在解釋遞迴深度為N層的遞迴函式的具體執行過程,在這之前,先回憶一下 “return”語句,因為遞迴一般都是使用return語句進行處理。

return語句用於結束當前正在執行的函式,並將控制權返回給呼叫此函式的函式。

return語句有兩種形式:

  • “ return; ”

不帶返回值的return語句只能用於返回型別為void的函式。 
一般情況下使用不帶返回值的return語句是為了引起函式的強制結束。 
隱式的return發生在函式的最後一個語句完成時。

  • “ return expression; ”

任何返回型別不是void的函式都必須返回一個值, 返回值的型別必須和函式的返回型別相同,或者能隱式轉化為函式的返回型別。

遞迴函式

遞迴函式一般可以分為三個部分:

  • 遞迴呼叫前的處理
  • 遞迴函式本身
  • 遞迴呼叫後的處理

即:

recursion(){

//block1:遞迴呼叫前的處理
code before recursion		 
{
do something;
}

//block2:呼叫遞迴函式本身
{
 if(end_condition) //遞迴終止條件
  {
  end;	 
  }
 else
  {
  recursion();
  }
}

//block3:遞迴呼叫後的處理
code after recursion		  
{
do something;
}

}//end of recursion

在解釋上述程式碼之前,對於遞迴首先必須理解如下幾點:

  1. 每一次函式呼叫都會有一次返回,顯式的return語句返回或者隱式的執行完最後一條語句之後返回。
  2. 位於遞迴函式入口  的語句,由 最外層往最裡層 執行。
  3. 位於遞迴函式入口  的語句,由 最裡層往最外層 執行。
  4. 當程式流執行到某一級遞迴的結尾處時(執行完block3),它會轉移到前一級遞迴繼續執行

上述程式碼的具體執行過程為(假設遞迴深度為N):

  1. 執行第1層的block1;
  2. 執行第1層的block2;
  3. 執行第2層的block1;
  4. 執行第2層的block2; 
    ... ... 
    執行第N層的block1; 
    執行第N層的block2時,由於滿足end_condition,不再呼叫遞迴函式 
    執行第N層的block3。

轉移到前一級的遞迴處

執行第N-1層的block3; 
執行第N-2層的block3; 
... ... 
執行第2層的block3; 
執行第1層的block3;

對於Fibonacci函式

int Fib(int n)
{
    if( n < 2)
         return n;
     return (Fib(n-1)+Fib(n-2));
 }

上述Fibonacci函式具體執行過程如下: 

遞迴應用:

  • 把問題規模遞迴到最小 :遞迴在處理類似T(n)=aT(n/b)+f(n)的問題上的應用,如資料的定義是按遞迴定義的(Fibonacci函式,n的階乘),問題解法按遞迴實現(分治,回溯)。

處理方法: 展開遞迴 直到可以 直接求解問題 ,在遞迴 “返回過程” 中求解每步中未解決部分的問題

recursion(T(n)) //T(n)為問題規模
{
  展開遞迴,直到可以直接求解問題:
  if (end_condition) //end_conditon:可以直接求解問題的T(n),如問題規模為1,2
  {
  return expression;	  
  }
  else
  {	  
  return_value = recursion(g(f(n)));//繼續展開,g(f(n))的規模小於f(n)的規模
  return solve(return_value);//solve()求解每步中未解決的部分,這部分求解依賴規模較小問題的解。
  上面兩部步就相當於Fib函式中的return (Fib(n-1)+Fib(n-2));
  }
}
  • 將問題遞迴窮舉其所有的情形 ——遞迴在處理不確定數量的巢狀迴圈中的應用,如處理資料結構本身是按遞迴定義的樹,圖等問題,不能用迴圈實現,只能使用遞迴。

處理方法: 展開遞迴 ,在遞迴 “展開過程” 中求解問題:

recursion(T(n))
{	
展開遞迴,直到滿足終止條件:
 if (end_condition)//end_condition:遞迴已經展開完畢,即已窮舉問題的所有情形
 {
  return;	 
 }

在展開過程中的每一步都解決該步中的問題。
solve(T(n));

recursion(g(f(n)));	//繼續展開;

}

求解遞迴式

求解遞迴式一般有3種方法:

代入法

猜測一個界,然後用數學歸納法證明這個界是正確的。

遞迴樹法

將遞迴式轉換為一棵樹,其節點表示不同層次的遞迴呼叫產生的代價。

  • 在遞迴樹中 每個節點表示一個單一子問題的代價 ,子問題對應某次遞迴呼叫。
  • 將 樹中每層中的代價求和 ,得到每層的代價,然後將 所有層的代價求和 ,得到所有層次的遞迴呼叫的總代價

主方法

求解形如T(n)=aT(n/b)+f(n)的遞迴式的界。

遞迴式描述的是這樣一種演算法的執行時間:

它將規模為n的問題分解為a個子問題,每個子問題的規模為n/b,其中a,b都是正常數。a個子問題遞迴地進行求解,每個花費時間T(n/b)。 函式f(n)包含了問題分解和子問題合併的代價 。

嚴格講上述遞迴式並不是良好定義的,因為n/b並不一定是整數,但將a項T(n/b)都替換為T(⌊n/b⌋)或T(⌈n/b⌉)並不影響遞迴式的漸進分析。

主方法求解依賴下面的定理: 

主定理不能適合於這樣的遞迴式:T(n)=2T(n/2)+nlgn,因為該遞迴式落入了情況2和情況3之間的間隙。

對於所有遞迴式,只需 計算  並和f(n)比較 即可。 
如:

T(n)=2T(n/2)+O(n) ---> O(nlgn) 
T(n)=2T(n/2)+O(lgn) ---> O(n)

如何書寫遞迴式

書寫遞迴式主要的難點在於確定f(n), 函式f(n)包含了問題分解和子問題合併的代價 ,下面幾個公式概括了常見的遞迴處理形式。

公式1

如果遞迴程式 迴圈處理 輸入, 每次減少一項 ,則遞迴式為:T(n)=T(n-1)+n。T(N)=N*N/2

公式2

如果遞迴程式 每一步處理一半的輸入 (另一半輸入不需要考慮,如二分查詢),則遞迴公式為:T(n)=T(n/2)+1。T(N)=lgN

公式3

如果遞迴程式 每一步處理一半的輸入 ,但需要 檢查輸入的每一項 ,則遞迴公式為:T(n)=T(n/2)+n。T(N)=2N

公式4

如果遞迴程式 每一步將輸入分成兩半 ,但在劃分之前、劃分過程中或劃分之後 需要線性地遍歷輸入 ,則遞迴公式為:T(n)=2T(n/2)+n。T(N)=NlgN

公式5

如果程式 每一步將輸入分成兩半 ,並且需要 做一些其他處理 (消耗常數時間),則遞迴公式為:T(n)=2T(n/2)+1。T(N)=2N

遞迴型別:

Tail recursion:

當遞迴呼叫是整個函式體中 最後執行的語句 ,且 它的返回值不屬於表示式的一部分 時,這個遞迴呼叫就是尾遞迴。即如下這種形式的遞迴: 
```c 
foo(){

something else;

return foo()//最後執行的語句,且不屬於任何表示式的一部分 
}

求解最大公約數的演算法就是一個最典型的尾遞迴:
```c
int GCD(int ,int y)
{   
   if(y == 0) 
     return x;
   else
     return GCD(y, x % y);
}

普通遞迴的實現:

普通遞迴的實現是通過呼叫函式本身, 每次呼叫函式本身要儲存區域性變數、形參、呼叫函式地址、返回值 。如果遞迴呼叫N次,就要分配N 區域性變數、N 形參、N 呼叫函式地址、N 返回值這個執行過程的開銷往往很大。當遞迴深度很大時,往往會導致棧溢位。

尾遞迴的實現:

尾遞迴特點是在遞迴返回過程中不用做任何操作 ,大多數現代的編譯器會利用這種特點自動生成優化的程式碼。當編譯器檢測到一個函式呼叫是尾遞迴的時候,它就 覆蓋當前的活躍記錄而不是在棧中去建立一個新的 。通過覆蓋當前的棧幀而不是在其之上重新新增一個,這樣所使用的棧空間就大大縮減了,這使得實際的執行效率會變得更高。

因此,只要有可能我們就需要將遞迴函式寫成尾遞迴的形式。 
採用尾遞迴實現Fibonacci函式:

 int Fib(int n,int ret1,int ret2)//ret1,ret2為數列的開頭兩項
 {
    if(n==0)
       return ret1; 
     return Fib(n-1,ret2,ret1+ret2);
 }

(原理:0 1 1 2 3 5 8 13 21 ... F(8)=23,變換後為1 (0+1) 2 3 5 8 13 21 ... F(7)=23) 
函式執行過程如下: 

Augmenting recursion:

Whenever there is a pending operation to be performed on return from each recursive call, then recursion is known as Augmenting recursion.

The "infamous" factorial function fact is usually written in a non-tail-recursive manner:
int fact (int n)
{
   if (n == 0)  return 1;
      return n * fact(n - 1);
}
  • Direct Recursion: 
    A function foo is directly recursive if it contains an explicit call to itself .
    int foo(int x)
    { 
     if (x <= 0) return x;
        return foo(x - 1); 
    }
  • Indirect Recursion: 
    A function foo is indirectly recursive if it contains a call to another function which ultimately calls foo .
    int foo(int x) 
    { 
     if (x <= 0) return x;
     return foo1(x); 
    }
    int foo1(int y) 
    {
     return foo(y - 1);
    }
  • Mutual Recursion: 
    When the pair of functions contains call to each other then they are said to perform mutual recursion.
    int foo(int x)
    {
     if (x <= 0) return x;
        return foo1(x);  
    }
    int foo1(int y) {
     return foo(y - 1);
    }
  • Linear Recursion: 
    A recursive function is said to be linearly recursive when no pending operation involves another recursive call to the function .
For example, the "infamous" fact function is linearly recursive.

int fact (int n)
{
   if (n == 0)  return 1;
      return n * fact(n - 1);
}

The pending operation is simply multiplication by a scalar, it does not involve another call to fact
  • Tree or Non-Linear Recursion:

    A recursive function is said to be tree recursive (or non-linearly recursive) when the pending operation does involve another recursive call to the function . 
    ```c 
    The Fibonacci function fib provides a classic example of tree recursion.

int fib(int n) 

if (n == 0) return 0; 
if (n == 1) return 1; 
return fib(n - 1) + fib(n - 2); 
}

Notice that the pending operation for the recursive call is another call to fib. Therefore fib is tree-recursive.

遞迴VS迭代

先解釋一下以下幾個概念:

迴圈(loop) ,一段在程式中只出現一次,但可能會連續執行多次的程式碼(不變的重複)。比如,while語句。 
迭代(iterate) ,反覆地運用同一函式計算,前一次迭代得到的結果被用於作為下一次迭代的輸入(變化的迴圈)。指的是按照某種順序逐個訪問列表中的每一項。比如,for語句。 
遍歷(traversal) ,指的是按照一定的規則訪問樹形結構中的每個節點,而且每個節點都只訪問一次。 
遞迴(recursion) ,指的是一個函式不斷呼叫自身的行為。比如斐波納契數列。

使用遞迴的場景:

  • 問題較複雜,使用遞迴可以簡潔的解決
  • 問題本身有遞迴的含義,如樹的遍歷

    使用迭代的場景:

    • 問題很簡單
    • 問題本身沒有遞迴的含義
    • 在現代計算機體系中,程序的stack空間遠遠小於heap空間,當stack空間有限而遞迴層次較深時考慮使用迭代

      實踐準則- 怎麼解決問題方便就怎麼來

      you must keep in mind not only the degree of difficulty of the resulting algorithm, but also the programming code necessary to implement it. 
      The figure below graphically illustrates this principle. 

使用遞迴的場景

  • Quick Sort
  • Merge Sort
  • All N-Log Sort
  • Tree traversals
  • XML Parsers
  • HTML Parsers
  • Backtracking Algorithm
  • Binary Space Partitioning (BSP) Trees used for collision detection in 
    game development.
  • Recursive-descent language parsers
  • Simulating state machines
  • Lists (Linked Lists)
  • Graphs
  • Inductive reasoning used in AI