前言
最近正在深入学习递归,初学递归时,会发现递归算法虽然代码简洁,但是真要理解或者上手编写还是有一定难度的。在此给出课本上的递归练习题的解题报告,并对几类递归问题做一个总结。由于时间有限,暂时并未完成所有题目的报告,但是已完成的题目可以给大家作个参考。
3-1 猴子吃枣
思路
用递归算法求解问题时,难点在于找到递归边界与状态转移方程(或称作递推公式),而此题这两个条件都非常好得到。
设第 n 天吃枣前剩余的枣子为 D(n),则递归边界为 D(10)=1,状态转移方程为 D(n+1)=D(n)−D(n)/2−1(1<=n<=9),推得 D(n)=2D(n+1)+2。
根据这两个条件编程实现即可。
代码
#include <bits/stdc++.h>
using namespace std;
int D(int n){
if(n == 10) return 1;
return 2*D(n+1)+2;
}
int main(){
cout << D(1);
}
输出
1534
3-2 三齿轮问题
思路
经过分析可知,实际上就是求 a,b,c 三个数的最小公倍数 x,三个齿轮的次数分别为 x/a,x/b,x/c。
最小公倍数(LCM)与最大公因数(GCD)有关系:LCM(a,b)×GCD(a,b)=a×b,而我们可以用递归实现辗转相除法计算 GCD。
代码
#include <bits/stdc++.h>
using namespace std;
int gcd(int m, int n){
return n ? gcd(n, m % n) : m;
}
int main(){
int a, b, c;
cin >> a >> b >> c;
int x = a*b*c/gcd(a, gcd(b, c));
cout << x/a << " " << x/b << " " << x/c;
}
3-3 整数因子分解问题
思路
朴素算法:首先给出整数分解的递推公式: factor(n)=∑i=2nfactor(n/i),(i|n)。接着对该公式进行解释,直观地看,整数因子分解式有多少个,就是对它所有因子的因子分解式数求和。
备忘录法:备忘录法实际上就是记忆化。很显然,朴素算法的实现过程会有非常多的重复计算,而在每次计算一个因子有多少分解式时,将其通过 STL 提供的 unordered_map 容器离散化存储,下次可通过复杂度 O(1) 的查找,达到剪枝的目的。
注:输入 2e9 时,朴素算法需要 10 秒左右,而记忆化算法运行速度为毫秒级。实际上当输入多组数据时,除了记忆化之外,还可以筛出 1e9 以内的所有质数,将其的分解式数 1 提前存入容器,达到进一步优化的目的。如果理解备忘录法有难度,可以只掌握朴素算法。
朴素算法
#include <bits/stdc++.h>
using namespace std;
int factor(int n){
int cnt = 1, i;
for(i = 2; i*i < n; ++i)
if(n%i == 0)
cnt += factor(i) + factor(n/i);
if(i*i == n) cnt += factor(i);
return cnt;
}
int main(){
int n;
cin >> n;
cout << factor(n);
}
备忘录法
#include <bits/stdc++.h>
using namespace std;
unordered_map<int, int> mp;
int factor(int n){
int cnt = 1, i;
for(i = 2; i*i < n; ++i)
if(n%i == 0)
cnt += (mp[i] ? mp[i] : (mp[i] = factor(i))) + (mp[n/i] ? mp[n/i] : (mp[n/i] = factor(n/i)));
if(i*i == n) cnt += mp[i] ? mp[i] : (mp[i] = factor(i));
return cnt;
}
int main(){
int n;
cin >> n;
cout << factor(n);
}
3-4 有重复元素的排列问题
思路
给定一组序列,利用逐位确定元素的方法,为避免排序重复,确定某一位时不得有重复数字。使用递归模拟下图过程即可实现。
代码
#include <bits/stdc++.h>
using namespace std;
int n, ans = 0;
string s;
void arr(int cnt){
if(cnt == n-1){
ans++;
cout << s << endl;
}
bool vis[26]{};
for(int i = cnt; i < n; ++i){
if(!vis[s[i] - 'a']){
vis[s[i] - 'a'] = 1;
swap(s[cnt], s[i]);
arr(cnt+1);
swap(s[cnt], s[i]);
}
}
}
int main(){
cin >> n >> s;
arr(0);
cout << ans;
}
3-5 求集合的所有子集问题
思路
采用递归实现求集合子集问题无非在每次递归时求解两个子问题:将该元素放入子集后进行下一个元素的递归;不将该元素放入子集直接进入下一个元素的递归。
采取非递归实现该问题只需将某一元素存在与否存储在一个位中,递推遍历所有状态即可。
递归代码
#include <bits/stdc++.h>
using namespace std;
int a[100], b[100], n;
void subset(int p, int cnt){
if(p == n) return; // 注意递归边界
b[cnt++] = a[p];
for(int i = 0; i < cnt; ++i) cout << b[i] << " ";
cout << endl;
subset(p+1, cnt);
subset(p+1, cnt-1);
}
int main(){
cin >> n;
for(int i = 0; i < n; ++i) cin >> a[i];
cout << endl; //表示空集
subset(0, 0);
}
非递归代码
#include <bits/stdc++.h>
using namespace std;
int a[100];
int main(){
int n;
cin >> n;
for(int i = 0; i < n; ++i) cin >> a[i];
for(int i = 0; i < (1 << n); ++i){
int t = i;
for(int j = 0; j < n; ++j){
if(t&1) cout << a[j] << " ";
t >>= 1;
}
cout << endl;
}
}
对比总结
就代码量而言,二者并无太大区别,但是递推代码更加直观,而且二者输出顺序不同,由此引出递归与递推求解问题的顺序不同。递推是由子问题出发,正向到达目标问题的过程;而递归分解目标问题向子问题转移。
3-6 求 n 个数中 r 个数的全部组合问题
思路
对 3-5 求递归子集的代码稍作修改即可实现。既然已经找到所有子集,那么 n 个数中的 r 个数的全部组合问题不过就是当子集长度为 r 时进行输出即可。
代码
#include <bits/stdc++.h>
using namespace std;
int a[100], b[100], n, m;
void combine(int p, int cnt){
if(p == n) return;
b[cnt++] = a[p];
if(cnt == m) { // 加上判断条件,当子集长度为m时输出
for(int i = 0; i < cnt; ++i) cout << b[i] << " ";
cout << endl;
}
combine(p+1, cnt);
combine(p+1, cnt-1);
}
int main(){
cin >> n >> m;
for(int i = 0; i < n; ++i) cin >> a[i];
combine(0, 0);
}
3-7 二叉树的三种遍历的推导关系
思路
注:在二叉树中,遍历顺序表示父结点与其左右两子结点的访问顺序,前序表示父结点早于左右子结点,中序表示在二者之间,后序表示在二者之后,而对于三种遍历方式,左结点的访问总是早于右结点。(以下说明来自 OI-wiki)
已知中序遍历序列和另外一个序列可以求第三个序列。
- 前序的第一个是 root,后序的最后一个是 root。
- 先确定根节点,然后根据中序遍历,在根左边的为左子树,根右边的为右子树。
- 对于每一个子树可以看成一个全新的树,仍然遵循上面的规律。
上述描述显然是一段递归的描述,我们可以根据该描述利用递归实现后序序列的推导。后序序列的访问顺序是先访问所有的左结点,再访问所有的右结点,最后访问父结点,前序的父结点优先与左右结点。
算法思路:
- 前序序列的首元素为子树的根节点,在中序序列中找到根结点并记录下标。
- 通过下标划分左右子树,如果左右子树结点数大于 1,则回到第 1 步。
- 输出子树根节点。
代码
#include <bits/stdc++.h>
using namespace std;
string preorder, inorder;
void printPost(int inindex, int preindex, int len){
if(!len) return;
int offset = 0;
while(offset < len && preorder[preindex] != inorder[inindex + offset]) offset++; // 在中序序列中找到结点
printPost(inindex, preindex + 1, offset); // 划分左子树
printPost(inindex + offset + 1, preindex + offset + 1, len - offset - 1); // 划分右子树
cout << preorder[preindex]; // 打印结点
}
int main(){
cin >> preorder >> inorder;
printPost(0, 0, inorder.length());
}
3-8 阶梯问题
思路
模拟上楼梯的过程,从步长 1 到 3 尝试上楼,走一次增加一次步数,如果满足所上台阶数等于 n 则输出上楼方法,若大于则跳出,若小于则继续递推。
代码
#include <bits/stdc++.h>
using namespace std;
int a[10], n;
void step(int cnt, int t){ // cnt 为上台阶数, t为步数
if(cnt > n) return;
if(cnt == n) {
for(int i = 0; i < t; ++i) cout << a[i] << " ";
cout << endl;
return;
}
for(int i = 1; i <= 3; ++i){
a[t] = i;
step(cnt + i, t+1);
}
}
int main(){
cin >> n;
step(0, 0);
}
3-9 集合划分问题
思路
首先给出状态转移方程:s(n,m)=m×s(n−1,m)+s(n−1,m−1)
接着解释该状态转移方程:问题 s(n,m) 可由两个子问题 m×s(n−1,m) 与 s(n−1,m−1) 构成。其中前者表示 n−1 个元素被划分进 m 个集合中,我们将第 n 个元素放入 m 个集合中的任何一个集合;后者表示 n−1 个元素被划分成 m−1 个集合,我们只能将第 n 个元素放入一个新的集合,这样才会划分出 m 个集合。反过来思考,我们对 s(n,m)移除一个元素,只可能向 s(n−1,m) 和 s(n-1,m-1)两种状态空间转移,所以状态转移方程成立。
代码
#include <bits/stdc++.h>
using namespace std;
int s(int n, int m){
if(m == 0) return 0;
if(m == n) return 1;
return m*s(n-1, m) + s(n-1, m-1); // 保证无重复?
}
int main(){
int n, m;
cin >> n >> m;
cout << s(n, m);
}
3-10 整数划分问题
思路
与 3-8 是一类问题,不过根据输出可知,划分数字按字典序排列,各数字出现数量相同而顺序不同视为同种划分。划分数 S(n) 满足递推方程:S(n)=∑i=1n/2S(i)×S(n−i)。而递归可模仿这一操作,例如:输入 6,因为 6>1 故 6=1+5 输出 1+5,其中 5>1,继续划分 5=1+4,输出 1+1+4·······依此类推,递归边界为不可再划分的 1。
代码
#include <bits/stdc++.h>
using namespace std;
int ans = 0, a[100] = {1};
void dfs(int n, int cnt){
if(n == 1) return;
for(int i = a[cnt-1]; i <= n/2; ++i){
a[cnt] = i;
ans++;
for(int j = 1; j <= cnt; ++j) cout << a[j] << "+";
cout << n-i << endl;
dfs(n-i, cnt+1);
}
}
int main(){
int n;
cin >> n;
dfs(n, 1);
cout << ans;
}
3-11 n 皇后问题
思路
n 皇后问题是一个经典的问题,其解法无非就是遍历所有状态空间,搜索符合条件的解。建立两个数组 bool 类型 viss[],visc[],分别存储对角线和列是否存在一名皇后。对角线的存储用到平面直角坐标系下直线方程 x+y=c 与 x−y=c,3n+r−i 与 r+i 中的 +3n 是为了防止出现下标负数且避免与r+i重复的情况。
代码
#include <bits/stdc++.h>
using namespace std;
bool viss[100]{}, visc[100]{};
int a[100]{}, n, ans = 0;
void dfs(int r){
if(r == n+1){
ans++;
for(int i = 1; i <= n; ++i) cout << a[i] << " ";
cout << endl;
return;
}
for(int i = 1; i <= n; ++i){
if(!viss[r+i] && !viss[3*n+r-i]&& !visc[i]){
viss[r+i] = viss[3*n+r-i] = visc[i] = 1; // 标记该列及对应两条斜边
a[r] = i;
dfs(r+1);
viss[r+i] = viss[3*n+r-i] = visc[i] = 0; // 注意清除标记
}
}
}
int main(){
cin >> n;
dfs(1);
cout << ans;
}
3-12 分书问题
思路
思路同 n 皇后问题,不再赘述。
代码
#include <bits/stdc++.h>
using namespace std;
bool like[5][5] = {{0, 0, 1, 1, 0}, {1, 1, 0, 0, 1}, {0, 1, 1, 0, 1}, {0, 0, 0, 1, 0}, {0, 1, 0, 0, 1}}, vis[5];
int a[5], ans = 0;
void dfs(int n){
if(n == 5){
for(int i = 0; i < 5; ++i)
cout << a[i] + 1 << " ";
cout << endl;
ans++;
return;
}
for(int i = 0; i < 5; ++i){
if(like[n][i] && !vis[i]){
vis[i] = 1;
a[n] = i;
dfs(n+1);
vis[i] = 0;
}
}
}
int main(){
dfs(0);
cout << ans;
}
3-13 迷宫问题
思路
简单的递归实现 dfs 的过程。但是有几处细节需要注意。首先该问题计算可行路径,即只要路径中有至少一点不一样,则视为两条路径。为避免走回头路同时存储路径途径结点,我们只需在访问到某点时将其打上标记并存入路径 s 中,在结束访问时清除标记并从路径 s 中删除。
代码
#include <bits/stdc++.h>
using namespace std;
int n, m, a[100][100], ans = 0;
int dr[] = {0, 0, 1, -1};
int dc[] = {1, -1, 0, 0};
vector<pair<int, int> > s;
void dfs(int r, int c){
s.push_back({r, c});
a[r][c] = 1; // 若已访问则标记
if(r == n-1 && c == m-1){
ans++;
for(int i = 0; i < s.size(); ++i) printf("(%d, %d)\n", s[i].first, s[i].second);
cout << "------\n";
a[r][c] = 0; // 返回前清除标记
return;
}
for(int i = 0; i < 4; ++i){
int tr = r+dr[i], tc = c+dc[i];
if(tr >= 0 && tr < n && tc >= 0 && tc < m && a[tr][tc] == 0)
dfs(tr, tc);
}
s.pop_back();
a[r][c] = 0; // 返回前清除标记
}
int main(){
cin >> n >> m;
for(int i = 0; i < n; ++i)
for(int j = 0; j < m; ++j)
cin >> a[i][j];
dfs(0, 0);
cout << ans;
}