1. 程式人生 > >遞迴演算法(轉)

遞迴演算法(轉)

轉自:https://blog.csdn.net/feizaosyuacm/article/details/54919389

目錄: 
1.簡單遞迴定義 
2.遞迴與迴圈的區別與聯絡 
3.遞迴的經典應用

1.簡單遞迴定義

什麼叫遞迴?(先定義一個比較簡單的說法,為了理解,不一定對)

遞迴:無限呼叫自身這個函式,每次呼叫總會改動一個關鍵變數,直到這個關鍵變數達到邊界的時候,不再呼叫。

比如說我要你先求一個N!的結果

你說我會用迴圈啊(沒錯,但是現在是學遞迴)

int factorial(int x,int ans)
{
    if(x==1)
       return  ans;
    factorial(x-1,ans*x
); }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

怎麼樣,對於C基礎如果掌握的還行的話,這段程式碼應該很好理解。遞迴,顧名思義就是“遞”和“歸”。也就是說,寫每一個遞迴函式的時候,都應該在寫之前考慮清楚,哪裡體現了“遞”,哪裡體現了“歸”。

但是常常遞迴函式會比較複雜, “遞”和“歸”看起來並不是那麼明顯,這就需要我們進一步來理解遞迴演算法的思想。

比如說我現在要你用輾轉相除法求出兩個數的最大公約數,遞迴函式如下:

int gcd(int a,int b)
{
    return a%b==0?b:gcd(b,a%b);
}
  • 1
  • 2
  • 3
  • 4

這是一段很常用的程式碼,我們知道,在學習過程中不求甚解是最不應該的。因此現在來仔細看一看。這裡的“遞”和“歸”放在同一行。首先進行判斷a==b?

(我們可以想象成“歸”的內容,如果這個條件符合的話)。當然,如果不符合這個判斷,那就繼續“遞”,也就是繼續進行gcd(b,a%b);

看到這裡,你就會發現,遞迴不就是迴圈的另一種方式麼?

說對了一半,不過遞迴是一種思想,現在還暫時不能說透,需要大家先比較一下迴圈和遞迴的相同點和不同點(飯一口一口吃,彆著急)

2.遞迴與迴圈的區別於聯絡

相同點: 
(1)都是通過控制一個變數的邊界或者多個),來改變多個變數為了得到所需要的值,而反覆而執行的; 
(2)都是按照預先設計好的推斷實現某一個值求取;(請注意,在這裡迴圈要更注重過程,而遞迴偏結果一點)

不同點: 
(1)遞迴通常是逆向思維居多,“遞”和“歸”不一定容易發現(比較難以理解);而迴圈從開始條件到結束條件,包括中間迴圈變數,都需要表達出來(比較簡潔明瞭)。

簡單的來說就是:用迴圈能實現的,遞迴一般可以實現,但是能用遞迴實現的,迴圈不一定能。因為有些題目①只注重迴圈的結束條件和迴圈過程,而往往這個結束條件不易表達(也就是說用迴圈並不好寫);②只注重迴圈的次數而不注重迴圈的開始條件和結束條件(這個迴圈更加無從下手了)。

3.遞迴的經典應用

來看看“漢諾塔問題”

如圖,漢諾塔問題是指有三根杆子A,B,C。C杆上有若干碟子,把所有碟子從A杆上移到C杆上,每次只能移動一個碟子,大的碟子不能疊在小的碟子上面。求最少要移動多少次?

這裡寫圖片描述

這是一個迴圈只注重迴圈次數的常見例子,我們知道,用迴圈有點無從下手(就目前作者水平來看),但是遞迴就很好寫了。

漢諾塔,什麼鬼,我不會啊?

別急,慢慢來。

我們首先需要一點思維:解決n塊盤子從A移動到C,那麼我只需要先把n-1塊盤子從A移到B,然後把最下面的第n塊盤子從A移到C,最後把n-1塊盤子從B移到C(這就完成了)。

等等,那麼如何把n-1塊盤子從A移到B?

很好,這說明你已經開始遞迴入門了。

同樣這樣去想:解決n-1塊盤子從A移動到B,那麼我只需要先把n-2塊盤子從A移動到C,然後把倒數第二塊盤子從A移到B,最後把n-2塊盤子從C移到B(這就完成了)。

這就是遞迴的“遞”!

那麼“歸”呢?n==1的時候?

Bingo

int i;    //記錄步數  
//i表示進行到的步數,將編號為n的盤子由from柱移動到to柱(目標柱)  
void move(int n,char from,char to){  
    printf("第%d步:將%d號盤子%c---->%c\n",i++,n,from,to);  
}  

//漢諾塔遞迴函式  
//n表示要將多少個"圓盤"從起始柱子移動至目標柱子  
//start_pos表示起始柱子,tran_pos表示過渡柱子,end_pos表示目標柱子  

void Hanio(int n,char start_pos,char tran_pos,char end_pos)
{  
    if(n==1)      //很明顯,當n==1的時候,我們只需要直接將圓盤從起始柱子移至目標柱子即可.  
        move(n,start_pos,end_pos);   
    else
    {  
        Hanio(n-1,start_pos,end_pos,tran_pos);   //遞迴處理,一開始的時候,先將n-1個盤子移至過渡柱上  
        move(n,start_pos,end_pos);                //然後再將底下的大盤子直接移至目標柱子即可  
        Hanio(n-1,tran_pos,start_pos,end_pos);    //然後重複以上步驟,遞迴處理放在過渡柱上的n-1個盤子  
                                                  //此時藉助原來的起始柱作為過渡柱(因為起始柱已經空了)  
    }  
}  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

實際上這裡面已經使用到了一點點棧的思想(即最上面的最先考慮變化),但其實遞迴有的時候就是真的可以理解為棧!

到了這一步,相信大家應該已經有所明白。迴圈其實就是一個控制變數從開始條件走到結束條件的過程(在迴圈的過程順帶把其他變數也改變一下),因此需要控制變數,開始條件,結束條件(缺一不可)。但是遞迴只要告訴你“歸”是什麼,如何去“遞”,不管過程如何,只要計算結果即可。

(2)遞迴可以是多個“遞”,也可以是多個“歸”;而迴圈由始至終都只由一個變數控制(就算有幾個變數同時控制)也只有一個出口,每次迴圈也只是一個“遞”。

再看一個例子

用二分思想建立二叉樹(通常的是遞迴實現),比如說線段樹

//root   節點序號  
//left   節點維護的左邊界  
//right  節點維護的右邊界
void build(int root,int left,int right)
{
    if(left==right)
      return ;
    int mid=(left+right)/2;
    build(root*2,left,mid);
    build(root*2+1,mid+1,right);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

如果你是新手看不太懂也沒關係,現在最主要的是明白:在這個程式裡面只有一個“歸”,但是有兩個“遞”。

那麼如果學過一點但是對這一塊還不明白的怎麼辦呢?別急,聽我來解釋:

實際上,這兩個“遞”是按照先後分別進行的,等到第一個“遞”執行完(也就是到了“歸”的條件之後),才開始執行第二個“遞”。也就是說,通常在建樹的時候,都不是一層一層同時建的,而是先建一棵子樹,等到這棵子樹全部建完之後,才開始建立另外一棵子樹。

那就會問了,一棵子樹建完了之後root值不會變麼,root值變了之後還怎麼建另外一棵子樹呢?

root值不會變!大家請注意,這裡root*2是寫在遞迴函式裡面的,實際上並沒有賦值?為什麼要這樣寫?因為如果不這樣寫,你直接寫在外邊的話,一棵子節點到達葉子節點之後,需要一層一層往上回溯(在這裡提到了回溯的思想),而回溯就會無故產生很多不必要的時間複雜度,降低了遞迴效率(實際上遞迴的時間效率本來就有一點偏低)。

所以到目前為止,我只是介紹一些很常見的簡單的遞迴,但是在接下來,我就需要說一些比較深層一點的知識了。

首先要理解一下什麼是回溯(寫的不好,大佬勿噴)

回溯:在遞迴的過程中由於改變的量需要倒退到某一個位置而執行的步驟。

先來看一個簡單的素數環問題:

給出1到n的n個連續的正整數(這裡n暫時等於6),並且把這n個數填寫在如下圖的n個圓圈裡面(當然是不重複不遺漏了)。要求是每一個位置上面的數跟他相鄰的數之和都為一個素數,列印並輸出最後滿足條件的情況。

這裡寫圖片描述

首先明白,開始條件是1,把1填寫在第一個位置,然後在剩下的n-1個數字裡找到一個滿足與1的和是一個素數的數(當然如果有多個,先靠前的先考慮)。接下來再繼續從剩下n-2個數字裡找到一個與這個數的和又是一個素數的數(當然如果有多個,同上。)。。。最後的一個數只要滿足與最開始的數1之和是一個素數的話,這個情況就滿足了(就可以列印輸出這樣一個例子了)

但事情並沒有想象的那麼簡單。。。(告訴我如果在中途尋找的過程中從剩下的數裡找不到與當前數的的和是一個素數的情況出現怎麼辦?線上等)

這就表明這樣一條路終歸是一條思路,你要往回走了!這就很符合我們給回溯的定義了,此時這個改變的量需要倒退的前面一步從另外一個方向尋找了。(還是舉栗子吧)

比如說:

1->2->3->4 突然發現5和6都不滿足要求了

那麼就倒退,準備找另外滿足要求的數

1->2->3 又發現除了4以外3跟5或者3跟6也不滿足要求

那就繼續倒退,繼續準備找另外滿足要求的數

1->2->5->6 接下來發現6跟3或者6跟4不滿足要求

…(還想繼續下去?你是要玩死我?別這樣,我也累啊,看一兩個就行了,啊!) 
最後發現滿足條件的一個是

1->4->3->2->5->6

大家應該已經懂了,上面的倒退,實際上就是回溯。(暫時這樣簡單的理解吧,錯了也不能怪你們)

實際上,遞迴+回溯就已經是dfs(深度優先搜尋)的內容範疇了。

void dfs(int x)
{
    if(x==n+1&&prime(a[x-1]+a[1]))    //如果滿足條件就可以列印輸出資料了,這裡就是“歸”
    {
        for(int i=1;i<n;i++)
          cout<<a[i]<<" ";
        cout<<a[n]<<endl;
    }
    else                        //否則就繼續“遞”
    {
        for(int i=2;i<=n;i++)
        {
            if(!vis[i]&&prime(i+a[x-1]))
            {
                vis[i]=1;            //vis[]是一個標記陣列,表示當前的數字已經被使用過了
                a[x]=i;
                dfs(x+1);   //“遞”的入口
                vis[i]=0;          //請注意,回溯點在這裡
            }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

大家可能前面都看懂了,比如說“遞”和“歸”,vis[]標記陣列什麼的。但是最後一個vis[i]=0是啥意思?難道不多餘麼?

不多餘!前面我已經拿建樹給大家講過遞迴的“工作原理”,它是先無限遞迴,然後到達某個條件之後,回溯到上面一個位置,繼續向其他方向遞迴。而這個vis[i]=0就是清楚當前數字的標記,表示從當前節點開始,之後遞迴過的內容統統清空(也就是回溯)。然後根據迴圈,進行下面一個方向的繼續遞迴。

這也是dfs的經典思想所在!

因此,講到這裡,不說說dfs似乎也是吊大家胃口了。所以接下來,就聊一聊dfs中的遞迴。

我簡單說一下意思,如下圖,判斷一個圖內包括@符號在內的所有‘.’和‘@’的個數。有個限制條件,如果‘.’被‘#’封死,則‘.’不可訪問。

6 9 (分別表示行和列)

....#.
.....#
......
......
......
......
......
#@...#
.#..#.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

45

比如說這一個資料就有三個‘.’被邊界和‘#’困死而不可訪問,因此只有45個點滿足要求。

本題的思路就是從’@’點開始,bfs搜尋每一個點,分成上下左右四個方向分別遞迴搜尋。

int cnt,a[4]={-1,0,1,0},b[4]={0,1,0,-1},n,m,vis[22][22];
char s[22][22];      
void dfs(int x,int y)
{
    for(int i=0;i<4;i++)      //四個方向迴圈搜尋
    {
        int p=x+a[i];
        int q=y+b[i];
        if(p>=0&&p<m&&q>=0&&q<n&&!vis[p][q]&&s[p][q]=='.')     //判斷遞迴條件,包括在陣列邊界之內,該點未被標記 
        {
            vis[p][q]=1;    //標記該點 
            cnt++;      //計數變數加一 
            dfs(p,q);     //遞迴搜尋 
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

請注意:本題中因為可以提前判斷下一個要搜尋的點是否為‘#’而免於回溯,降低時間複雜度。

並且大家可以看出,上面的程式碼實際上是稍微複雜一點的遞迴演算法(把從‘@’出發的每一個方向看成一條線段,而這條線段的另外一個終點就是邊界或者’#’),因此這就是可以看成迴圈了四次的遞迴演算法,而每一次遞迴呼叫的過程,每一方向又看成從當前點出發的一條線段。如此反覆。(中間的cnt用來計數)

請注意,cnt就是就是遞迴的次數(因為沒有回溯,如果有回溯,計數的話不一定等於遞迴的次數)

到此,基本知識點已經全部講完,下面給出一點個人關於寫遞迴演算法的建議吧:

(1)把遞迴當成複雜的迴圈來寫,如果不明白過程,多模擬幾遍資料;

(2)把遞迴逆向寫的時候當做一個棧來實現(即符合先進先出的思想);

(3)當遞迴和回溯結合在一起的時候需要明白遞迴次數和統計次數之間的練習和區別;

(4)但遞迴有多個“遞”和“歸”的時候,選擇一個重點的“遞”和“歸”作為匹配,即時題目即時分析,注意隨機應變即可。