遞歸講解


 

遞歸——調用“自己”的函數

1. 調用“自己”是新開一個函數,而不是真的調用 “自己”. 

2. 可以看作每一個函數都是“不同”的,即要么輸入的參數不同,要么全局變量有變化.

3. 明白一個函數的作用並相信它能完成這個任務,千萬不要跳進這個函數里面企圖探究更多細節, 否則就會陷入無窮的細節無法自拔

int func(傳入數值) {
  if (終止條件) return 最小子問題解;
  return func(縮小規模);
}

  

遞歸——遞與歸

遞歸與遞推的區別:遞推只有歸,遞歸先有一步遞.   

考慮一道題:

$\mathrm{f[x] = f[x - 1] + x},x \geqslant 1$.  

$\mathrm{f[x] = 1}, x = 1$.   

遞推:

void calc() {
    f[1] = 1; 
    for(int i = 2; i <= n ; ++ i) 
        f[i] = f[i - 1] + i;   
}

遞歸:

int f(int x) {
    if(x == 1) return x; 
    else return x + f(x - 1);   
} 

  

考慮二者不同:

遞推由邊界出發,逆向從終點走到起點.  

遞歸由起點出發,走到終點,並從終點逆向退回起點.  

遞歸 = 遞(找終點)+ 歸(從終點遞推回起點)    

 

源自 詳解遞歸思想_dreamispossible的博客-CSDN博客_遞歸思想

 

遞歸函數的書寫

遞歸的定義可能會有點繞,但是遞歸函數的書寫確是不難的.   

為了便於理解,這里舉幾個簡單遞歸函數.  

設遞歸函數 $\mathrm{f(sta)}$ 表示運算規則為函數 $\mathrm{f}$, 傳入參數為 $\mathrm{sta}$.      

$\mathrm{f}$ 函數不涉及全局變量,傳入的是數值參數(不改變傳入的變量)時,這樣的遞歸是易於理解的.

在書寫函數 $\mathrm{f}$ 時,只需考慮這幾個事情: 

1. $\mathrm{f}$ 的功能 

2. $\mathrm{f(x)}$ 可以由哪些 $\mathrm{f(y)}$ 轉移得來.      

3. 邊界   

正確性可以類比數學歸納法:

1. 邊界條件正確.

2. $\mathrm{f(y)}$ 正確.

3. $\mathrm{f}$ 本身書寫正確.(即轉移狀態正確)

由這三個條件可以歸納推得 $\mathrm{f(x)}$ 正確.   

 

例題:

1. 斐波那契數列:  

$\mathrm{f(x)=1, x \leqslant 2}$. 

$\mathrm{f(x)=f(x-1)+f(x-2)}$.    

思考步驟:

1. $\mathrm{f(x)}$ 干什么?

——計算斐波那契數列第 $\mathrm{x}$ 項的值.

2. 轉移 ? 

——$\mathrm{f(x)=f(x-1)+f(x-2), x \geqslant 3}$.   

3. 邊界條件 ?

——$\mathrm{f(x)=1}, x \leqslant 2$.  

代碼:

int f(int x) {
    if(x <= 2) {
        return 1;
    }
    else {
        return f(x - 1) + (x - 2); 
    }
}

  

2. 計算 $\mathrm{n!}$.   

1. $\mathrm{f}$ 要實現什么功能 ? 

——計算 $\mathrm{n!}$.

2. $\mathrm{f}$ 的轉移是什么?  

——$\mathrm{f(n)=f(n-1) \times n}$.  

3. $\mathrm{f}$ 的邊界是什么?  

——$\mathrm{f(1)=1}$.  

int f(int n) {
    if(n == 1) return 1; 
    else return n * f(n - 1);   
}

  

為什么要用遞歸?

上文也提到過遞歸 = 遞 + 歸,即如果知道函數的轉移方向則和遞推是等價的.

但是在很多情況,我們並不能很清楚的得知函數的遞推方向.   

在計算斐波那契函數和階乘函數時,遞推方向是下標由小到大.  

但是在 $\mathrm{DAG}$, 樹上進行逆向遞推時遞推的順序就是有講究的(至少並不那么顯然)

這個時候遞歸來做就顯得更加無腦,更加方便.          

 

1. 求解兩個數字的最大公約數.

有公式:

$\mathrm{if}$ $\mathrm{y=0}$ $\mathrm{gcd(x, y) = x}$

$\mathrm{else}$ $\mathrm{gcd(x,y) = gcd(y, x \ mod y)}$.   

int gcd(int x, int y) {
	return y ? gcd(y, x % y) : x;  
}

 

2. 給定一棵有根樹,求點 $\mathrm{i}$ 的子樹大小 $\mathrm{size[i]}$.       

樹:$\mathrm{n}$ 個點,$\mathrm{n-1}$ 條邊的無向圖(無環)     

對於有根樹,我們可以將每個點的兒子(直接與 $\mathrm{x}$ 相鄰的點且並不是 $\mathrm{x}$ 的父親)存儲起來.     

子樹的概念:

 (圖片源自 OI - wiki)

 

遞推做法:   

對於一個點 $\mathrm{x}$, 若 $\mathrm{x}$ 能使 $\mathrm{y}$ 的子樹大小 +1, 則 $\mathrm{y}$ 必為 $\mathrm{x}$ 的祖先.  

裸做的話要暴力跳祖先一個一個貢獻.             

遞歸做法:

考慮定義遞歸函數 $\mathrm{f(x)}$ 表示 $\mathrm{x}$ 點的子樹大小.  

顯然有 $\mathrm{f(x)=1 + \sum_{v \in son[x]} f(v)}$.   

 

int dfs(int x) {
	int cur = 1; 
	for(int i = 0; i < G[x].size() ; ++ i) {
		cur += dfs(G[x][i]); 
	}
	return cur; 
}

  

3. 全排列問題  

給定 $\mathrm{n}$, 輸出 $\mathrm{n}$ 的所有排列.  

按照遞歸三部曲,定義函數 $\mathrm{dfs(now, a)}$ :

輸出”給定 $[1, now]$ 中的數字情況下“的排列,即 $1$ ~ $\mathrm{now-1}$ 中的數字都確定好的情況下輸出剩下的排列.    

那么,我們枚舉 $\mathrm{now}$ 位置上該填什么:即 $\mathrm{now-1}$ 及之前沒有加入過的數字就是要加入的.   

void dfs(int cur, vector<int>g) {
	if(cur == n + 1) {
		for(int i = 0; i < n ; ++ i) {
			printf("    %d", g[i]);  
		}
		printf("\n"); 
		return ; 
	}
	for(int i = 1; i <= n ; ++ i) {
		int flag = 0; 
		for(int j = 0; j < g.size() ; ++ j) {
			if(g[j] == i) { flag = 1; break; } 
		}
		if(!flag) {
			g.pb(i);   
			dfs(cur + 1, g);   
			g.pop_back();   
		}
	}
}

  

但是,我們每次都要在函數里有一個 $\mathrm{a}$, 在空間上就顯得很不方便,不妨用全局變量來代替.   

那么,在這個時候我們就要改變一下函數的定義方式:

即令 $dfs(now)$ 表示:

當前已添加完 $\mathrm{now-1}$ 及之前的數字,且填入數字數組為 $\mathrm{a}$,狀態為 $\mathrm{vis}$.     

且:執行完本函數之后 $\mathrm{a}$ 與 $\mathrm{vis}$ 的狀態不改變.  

如果后面覺得懵的話反復朗讀這兩句話.  

那么函數調用就好寫了: 

#include <bits/stdc++.h>
#define ll long long 
#define pb push_back 
using namespace std;  
int n , a[20], vis[20]; 
void dfs(int now) {
	if(now > n) {
		for(int i = 1; i <= n ; ++ i) {
			printf("    %d", a[i]); 
		}
		printf("\n"); 
		return ; 
	}
	for(int i = 1; i <= n ; ++ i) {
		if(!vis[i]) {
			vis[i] = 1;
			a[now] = i;     
			dfs(now + 1); 
			vis[i] = 0; 
			a[now] = 0; 
		}
	}   
}
int main() {
	scanf("%d", &n); 
	dfs(1);  
	return 0; 
}

  

既然我們已經學會了遞歸,就利用遞歸做題吧!

1. Painting Fence

來源:codeforces round 256 (Div 2)    

有 $\mathrm{n}$ 塊連着的木板,每個木板高度為 $\mathrm{h[i]}$,我們需要把 $\mathrm{n}$ 個木板塗上顏色.  

每次塗色可以有兩種選擇:

1. 豎着塗完一塊木板(若之前這個木板有塗過,則可以繼續塗完)

2. 橫着刷一個高度單位的連續的木板(中間不能空着,不能間斷)

問:最少需要刷多少次,使得所有木板都塗上顏色 ?

數據范圍:$n \leqslant 5000, a_{i} \leqslant 10^9$.  

#include <stdio.h>  
#define N  5009 
#define setIO(s) freopen(s".in","r",stdin)
int min(int x, int y) { return x < y ? x : y; }
int a[N], n;  
int solve(int l, int r, int cur) {
	if(l == r) {
		return 1;   
	}
	int mi = a[l];    
	for(int i = l; i <= r; ++ i) {
		mi = min(mi, a[i]);  
	}  
	int an = mi - cur, i, j;    
	for(i = l; i <= r; ) {
		if(a[i] == mi)  {
			++ i; 
			continue;  
		}
		else {
			for(j = i; j + 1 <= r && a[j + 1] > mi; ++ j);   
			an += solve(i, j, mi);     
			i = j + 1;   
		}
	}
	return min(an, r - l + 1);   
}
int main() {
	//  setIO("input"); 
	scanf("%d",&n); 
	for(int i = 1; i <= n ; ++ i) {
		scanf("%d", &a[i]);   
	}                   
	printf("%d", solve(1, n, 0)); 
	return 0; 
}

  

解:直接做肯定不太好做,因為問題十分復雜,不妨考慮遞歸。  

按照遞歸三部曲:

1.$\mathrm{solve(l, r, cur)}$ :當前 $\mathrm{[l,r]}$ 區間已經塗上 $\mathrm{cur}$ 高度的, 還需要最少多少次可以將這個區間的牆塗完.      

2. 轉移:若已經塗過的高度小於這段區間的最小值,則肯定可以橫着給這個區間都塗上,縮小問題規模.

3. 邊界:$\mathrm{l=r}$ 時直接返回 1.

#include <stdio.h>  
#define N  5009 
#define setIO(s) freopen(s".in","r",stdin)
int min(int x, int y) { return x < y ? x : y; }
int a[N], n;  
int solve(int l, int r, int cur) {
	if(l == r) {
		return 1;   
	}
	int mi = a[l];    
	for(int i = l; i <= r; ++ i) {
		mi = min(mi, a[i]);  
	}  
	int an = mi - cur, i, j;    
	for(i = l; i <= r; ) {
		if(a[i] == mi)  {
			++ i; 
			continue;  
		}
		else {
			for(j = i; j + 1 <= r && a[j + 1] > mi; ++ j);   
			an += solve(i, j, mi);     
			i = j + 1;   
		}
	}
	return min(an, r - l + 1);   
}
int main() {
	//  setIO("input"); 
	scanf("%d",&n); 
	for(int i = 1; i <= n ; ++ i) {
		scanf("%d", &a[i]);   
	}                   
	printf("%d", solve(1, n, 0)); 
	return 0; 
}

 

2. Chloe and the sequence 

來源:codeforces 743B  

給定兩個數 $n$ ,$k$($n \leqslant 50$,$k \leqslant 2^n-1$)
再生成一個長度為 $2^n-1$ 的數列;
這個數列是這樣的:
首先在正中間填上 $n$.
接着在 $n$ 的兩邊的正中間填上 $n-1$.
再在兩個 $n-1$ 的兩邊填上 $n-2$ ………………
當 $n=4$ 時,這個數列是這樣的:
$1,2,1,3,1,2,1,4,1,2,1,3,1,2,1$
問:第 $k$ 位數字是什么 ?

定義遞歸函數 $\mathrm{f(n, k)}$ 表示中間數字為 $\mathrm{n}$ 時第 $\mathrm{k}$ 位是多少. 

1. $\mathrm{k}$ 就是中間位置,則輸出

2. $\mathrm{k}$ 是中間位置左面,則遞歸左面.

3. $\mathrm{k}$ 是中間位置右面,則遞歸右面.   

#include <bits/stdc++.h> 
#define ll long long 
using namespace std; 
int solve(int n, ll k) {
	ll len = (1ll << n) - 1;   
	if(k == len / 2 + 1) return n;  
	if(k < len / 2 + 1) return solve(n - 1, k); 
	if(k > len / 2 + 1)	return solve(n - 1, k - (len / 2 + 1)); 
}
int main() {
	int n ; 
	ll K; 
	scanf("%d%lld", &n, &K); 
	printf("%d\n", solve(n, K)); 
	return 0; 
}

  

3. How many integers can you find ? 

來源:HDU1796   

給定 $\mathrm{n}$, 一個大小為 $10$ 的正整數集合 $\mathrm{M}$. 

問 $\mathrm{[1,n]}$ 中有多少個數字 $\mathrm{x}$ 滿足 $\mathrm{M}$ 中存在 $\mathrm{x}$ 的因數. 

#include <cstdio>
#define ll long long 
using namespace std;
ll a[20], n,ans;
int m, cnt;
ll gcd(ll a,ll b){
	return b==0 ? a : gcd(b,a % b);
}
void dfs(int cur,ll lcm,int id){
    if(cur > cnt) return;
    lcm = a[cur] / gcd(a[cur], lcm) * lcm;
    if(id) ans += (n - 1) / lcm;
    else ans -= (n - 1) / lcm;
    for(int i = cur + 1; i <= cnt ; ++ i)
        dfs(i, lcm, !id);
}
int main(){
    while(scanf("%lld%d",&n,&m) != EOF)
    {
        ans = cnt = 0;
        for(int i = 1; i <= m ; ++ i){
            ll k;
            scanf("%lld",&k);
            if(k) a[ ++cnt] = k;
        }
        for(int i = 1; i <= cnt ; ++ i)
            dfs(i, a[i], 1);
        printf("%lld\n",ans);
    }
    return 0;
}

  

根據容斥原理,答案 $=$ 被一個數整除 - 被 2 個數整除 + 被 3 個數整除......

即我們要找到 $\mathrm{M}$ 的所有子集,然后判斷這個子集應該加上還是減去.  

這個用遞歸可以方便地進行求解.  

 

4. 平面最近點對

給定平面上 $n$ 個點,問距離最近的點對的距離是多少 ?

用分治法可以做到在 $O(n \log n)$ 的時間復雜度內求解.        

先把所有點按照 $x$ 坐標排序,$x$ 坐標相同則按照 $y$ 來排序.   

每次取中間點 $\mathrm{mid}$, 遞歸求解 $\mathrm{[l,mid]}$ 與 $\mathrm{[mid+1,r]}$.  

考慮如何合並:即一個點在左側,一個點在右側.  

求解完左側后自動按照縱坐標排序,右側同理.

那么這里就有一個結論:對於左側一個點,所需要枚舉到的右側點的個數不大於 $8$.

 

記憶化搜索 

講完這么多遞歸,不講記憶化搜索就可惜了,這里介紹一下:

考慮最開始講得計算斐波那契數列的遞歸函數:

int f(int x) {
    return x <= 2 ? 1: f(x - 1) + f(x - 2);       
}

  

仔細分析一下這個函數,假如說現在要求 $f(5)$.

$f(5)=f(4)+f(3)$  

$f(4)=f(3)+f(2)$

$f(3)=f(2)+f(1)$

$f(3)=f(2)+f(1)$  

在短短的調用關系中,不難發現 $f(3)$ 就要計算兩次.  

在遞推中,每一個 $f(i)$ 只會計算一次,所以時間復雜度是線性的.  

而在這個簡陋的遞歸中,每一個 $f(i)$ 會被重復計算,這個計算量就不是線性了.   

所以這里引入記憶化搜索的概念,即計算過的值就被保留在數組中,下次調用就直接拿來用.  

#include <bits/stdc++.h> 
using namespace std;
int f[40];   
int dfs(int x) {
	if(f[x]) return f[x]; 
	if(x <= 2) return f[x] = 1; 
	else return f[x] = dfs(x - 1) + dfs(x - 2); 
} 

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM