Note - 递推 & 递归
远古博文,希望能对 beginners 有所帮助吧,毕竟现在看来太难绷了。
引入#
假设我们想计算 。除了简单的 for 循环,我们也可以使用递归。
递归是什么意思呢?我们可以把 用 表示,即 。这样,我们就可以不断地递归下去。
但是,这是永远不会停止的。我们需要设立一个边界条件,。这样,我们就可以写出代码了。
int f(int x) {return x ? x * f(x - 1) : 1;}
实际上,递归有两大要点:
- 自己调用自己;
- 回溯时还原现场。
如果你看到这里,你或许已经懂了递归。那么,递推是什么呢?把上面的计算阶乘的递归改成递推就是这样的:
f[0] = 1;
for (int i = 1; i <= n; i++)
f[i] = f[i - 1] * i;
一个有趣的例子:什么是递归。
「例题」数楼梯#
原题目链接:Link。
我们用递推的视角思考一下。假设我们要走到第 阶台阶,我们应该怎样走?
记走第 阶台阶的走法为 ,则显然,我们可以从第 阶台阶上一阶台阶到第 阶台阶,也可以从第 阶台阶上二阶台阶到第 阶台阶。根据加法原理,有 。
递推边界可以根据题意得到。一阶台阶有 种走法,二阶台阶有 种走法,即 。
// Python Version
n = int(input())
a, b = 1, 2
if n < 3:
print(n)
for i in range(n - 2):
c = a + b
a, b = b, c
if n >= 3:
print(c)
# 可以使用滚动数组来优化,这里 a 相当于 f[i - 2],b 相当于 f[i - 1],c 相当于 f[i]
# 注意特判
// C++ Version
#include <bits/stdc++.h>
using namespace std;
int n;
long long f[5005] = {0, 1, 2};
int main() {
cin >> n;
for (int i = 3; i <= n; i++)
f[i] = f[i - 1] + f[i - 2];
cout << f[n] << endl;
return 0;
}
这里 C++ 只能得到 分。这是因为我们得到的结果超出了 long long 的表示范围,需要使用高精度。
五大递推模型#
Fibonacci 数列#
Fibonacci 数列,一般译为斐波那契数列。Fibonacci 数列的边界为 ,递推柿为 。上面的数楼梯就是 Fibonacci 数列从第 项开始的数列。
Fibonacci 数列还有一种表现形式,就是可爱的小兔子。
「例题」兔子的数量#
原题目链接:Link。
可以轻松写出代码。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 25;
int a[MAXN] = {0, 1, 1}, n;
int main() {
cin >> n;
for (int i = 3; i <= n; i++)
a[i] = a[i - 1] + a[i - 2];
cout << a[n] << endl;
return 0;
}
Fibonacci 数列还有一个通项公式:。
Catalan 数#
Catalan 数的实际应用很多。这里我们来通过一道 NOIP 的题目来引出 Catalan 数。
「例题」栈#
原题目链接:Link。
我们可以设 个小球时,出管方式为 。我们于是可以按最后一个出管的小球来讨论。假设最后一个出管的为第 个小球,那么比 早入管的小球为 个,比 晚入管的小球为 个。这些小球全部出管之后,第 个小球才出管。根据乘法原理,最后一个出管的小球为第 个时,取法总数为 。而 可以取 到 。所以总共的取法总数就是 。这就是 Catalan 数的递推柿了。递推边界显然:。
#include <bits/stdc++.h>
using namespace std;
int n, h[20] = {1, 1};
int main() {
cin >> n;
for (int i = 2; i <= n; i++)
for (int j = 1; j <= i; j++)
h[i] += h[j - 1] * h[i - j];
cout << h[n] << endl;
return 0;
}
Catalan 数列的应用:
- 把一个凸 边形划分为数个三角形的方案数(不相交)。
- 一个栈的进栈序列为 ,不同的出栈序列数。
- 个节点可以构造多少个不同的二叉树的方案数。
- 在圆上选择 个点,把这些点成对连接起来使得所得到的 条线段不相交的方法数。
- 从点 到点 ,不穿越但可以碰到对角线的最短走法数。
除了递推,Catalan 数 还可以通过通项公式得到:。
Hanoi 塔#
Hanoi 塔是一个非常经典的递推(递归)问题。或许很多人在感受 C++ 的强大时,教练展示的第一个程序就是 Hanoi 塔。
「例题」Hanoi 塔问题#
原题目链接:Link。
我们设 为移动 个盘子所需要的次数。显然地,我们可以先把上面的 个盘子移动到 B 柱上,再把第 的盘子移到 C 柱上,最后把 B 盘上的 个盘子移到 C 柱上。那么,递推柿就出来了:,边界 。Hanoi 塔同样有通项公式 。
# Python Version
n = int(input())
# print(2 ** n - 1)
# 这样可以直接输出
a = 1
for i in range(n - 1):
a = a * 2 + 1
print(a)
// C++ version
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 65;
long long a[MAXN] = {0, 1};
int n;
int main() {
cin >> n;
for (int i = 2; i <= n; i++)
a[i] = a[i - 1] << 1 | 1;
cout << a[n] << endl;
return 0;
}
第二类 Stirling 数#
「例题」合理放球#
原题目链接:Link。
我们可以设 为 个不相同的球放入 个相同的盒子(不为空)里的放法总数。
思考对于第 个球的放法。我们可以把这第 个球单独放一个盒子,那么前面的 个球就要挤占 个盒子,方案数为 ;也可以把这第 个球与其他的球共占一个盒子,那么前面的 个球就要挤占 个盒子(第 个球没有额外占用盒子),那么方案数为 。由加法原理知,。
递推边界:。
#include <bits/stdc++.h>
using namespace std;
int n, m;
long long S(int n, int m) {
if (n == 1 || m == 1 || n == m) return 1;
return S(n - 1, m - 1) + m * S(n - 1, m);
}
int main() {
scanf("%d %d", &n, &m);
printf("%lld\n", S(n, m));
return 0;
}
分割平面问题#
这类问题比较多变,有时很困难,重点是要去找到规律(废话)。
「例题」分割平面#
题意简述:设有 条封闭曲线画在平面上,而任何两条封闭曲线恰好相交于两点,且任何三条封闭曲线不相交于同一点,问这些封闭曲线把平面分割成的区域个数。
数据范围:。
观察图片,可以发现,如果设 表示由 条封闭曲线时,题意中的区域个数,则有 。。
证明也比较显然。当画第 条曲线时,会与每条之前画的 条曲线形成 个交点,多一个交点就多一个区域,由此可得。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 46350;
int n;
long long a[MAXN] = {0, 2};
int main() {
scanf("%d", &n);
for (int i = 2; i <= n; i++)
a[i] = a[i - 1] + ((i - 1) << 1);
printf("%lld\n", a[n]);
return 0;
}
让我们再来看一道题,加深印象。
「例题」分割平面之二#
题意简述:同一平面内有 条直线,已知其中 条直线相交于同一点,则这 条直线最多能将平面分割成多少个不同的区域?
数据范围:。
与上一题类似地设出 。画图容易发现 条直线相交于同一点会产生 个区域,第 条直线会相交多产生 个区域,所以有 ,边界为 。
#include <bits/stdc++.h>
using namespace std;
int n, p;
int f[505];
int main() {
cin >> n >> p;
f[p] = p << 1;
for (int i = p + 1; i <= n; i++)
f[i] = f[i - 1] + i;
cout << f[n] << endl;
return 0;
}
记忆化#
有些时候,使用递归会超时,原因是进行了大量的重复运算导致效率低下。而记忆化就可以提升效率。甚至于你可以用记忆化搜索来代替 DP。
「例题」Function#
原题目链接:Link。
我们可以按题意容易地写出朴素代码:
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 25;
#define LL long long
LL a, b, c;
int w(LL a, LL b, LL c) {
if (a <= 0 || b <= 0 || c <= 0) return 1;
if (a > 20 || b > 20 || c > 20) return w(20, 20, 20);
if (a < b && b < c) return w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
return w(a - 1, b, c) + w(a - 1, b - 1, c) + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
}
int main() {
while (~scanf("%lld %lld %lld", &a, &b, &c)) {
if (a == -1 && b == -1 && c == -1) break;
printf("w(%lld, %lld, %lld) = %d\n", a, b, c, w(a, b, c));
}
return 0;
}
试一试样例,哇,只用了 毫秒就过了!
然后交上去,快乐 TLE。
这是为什么呢?如果我们把 函数加上 cout << a << " " << b << " " << c << endl;
,再运行,你会发现输出了很多行,但其中有许多是重复的。
这启发我们,可以用一个数组 来存储 ,如果已经得到过这个值就可以直接返回,避免无效运算。这就是记忆化。在这道题中,我们使用 表示没有算过。具体地说,如果 ,那么没有计算过,我们就直接计算,并把结果存储在 中;否则算过就直接 return 即可。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 25;
#define LL long long
int f[MAXN][MAXN][MAXN];
LL a, b, c;
int w(LL a, LL b, LL c) {
if (a <= 0 || b <= 0 || c <= 0) return 1;
if (a > 20 || b > 20 || c > 20) return w(20, 20, 20);
if (a < b && b < c) {
if (f[a][b][c] != -1) return f[a][b][c];
// 如果已经得到值,直接返回
return f[a][b][c] = w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
// 否则计算这个值,并存储起来,下一次就不会继续递归了
}
if (f[a][b][c] != -1) return f[a][b][c];
// 同上
return f[a][b][c] = w(a - 1, b, c) + w(a - 1, b - 1, c) + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
}
int main() {
memset(f, -1, sizeof(f)); // 全部初始化为 -1 表示没有得到值
while (~scanf("%lld %lld %lld", &a, &b, &c)) {
if (a == -1 && b == -1 && c == -1) break;
printf("w(%lld, %lld, %lld) = %d\n", a, b, c, w(a, b, c));
}
return 0;
}
怎么样,你学废了吗?
让我们一起来看一道 IOI 的题目(好吧是 年的)。
「例题」数字三角形 Number Triangles#
原题目链接:Link。
假设你不会递推。我们可以设 表示从 走到最底下的最大的和。那么,显然有 。写成代码就是这样的:
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e3 + 5;
int n;
int w[MAXN][MAXN];
int f(int i, int j) {
if (i == n) return w[i][j]; // 已经不能再走了,直接返回 w[i][j]
return w[i][j] + max(f(i + 1, j), f(i + 1, j + 1));
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
cin >> w[i][j];
cout << f(1, 1) << endl;
return 0;
}
但这还是会超时,怎么办呢?
没错,我们可以使用记忆化。类似地,我们用 表示没搜过 ,如果不等于 直接 return 即可。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e3 + 5;
int n;
int w[MAXN][MAXN], ans[MAXN][MAXN];
int f(int i, int j) {
if (i == n) return w[i][j];
if (ans[i][j] != -1) return ans[i][j];
return ans[i][j] = w[i][j] + max(f(i + 1, j), f(i + 1, j + 1));
}
int main() {
memset(ans, -1, sizeof(ans));
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
cin >> w[i][j];
cout << f(1, 1) << endl;
return 0;
}
其实,这已经有点 DP 的意味了。
杂题#
「例题」铺砖#
题意简述:有一个 的走道,现在用 , 的砖去铺满。问有多少种不同的铺法?
数据范围:多组数据,。
先设 表示铺成 的走道的方案数。 的走道是怎么铺成的?可以由一个 的砖竖着铺在最右边,问题就转化为了铺剩下的 的走道,方案数即为 ;也可以由两个 的砖横着铺在最右边,问题就转化为了铺剩下的 的走道,方案数即为 ;还可以直接由一个 的砖铺在最右边,问题就转化为了铺剩下的 的走道,方案数也为 。综上,我们可以得到递推柿 。边界显然为 。
注意这里是多测,所以可以提前处理出 至 ,一一回答即可。需要使用高精度。
「例题」统计奇数和偶数个 3#
原题目链接:Link。
这里有两个维度:位数和奇偶。我们就可以把这两个维度设出来:设 表示 位正整数中有多少个数含偶数个数字 , 表示 位正整数中有多少数含个奇数个数字 。
位正整数中,含偶数个数字 的数是怎么来的呢?有两种情况:
- 前面 位含奇数个数字 ,第 位是 。个数为 。
- 前面 位含偶数个数字 ,第 位不是 (是 )。个数为 。
所以可以得到 。同理有 。
「例题」螺旋矩阵#
原题目链接:Link。
思考如何递归,发现矩阵外面那一圈儿都是可以直接算出来的(知道 )。接着观察除去外面那一圈儿后剩下的矩阵,发现只要减去 后就是一个新的矩阵。
拿这个图举个例子:
可以直接算出,而剩下的 就相当于 。
代码如下:
#include <cstdio>
int n, i, j;
int f(int m, int x, int y) {
if (x == 1) return y;
if (x == m) return 3 * m - y - 1;
if (y == 1) return 4 * m - x - 2;
if (y == m) return m + x - 1; // 边界情况,可以自己推导
return f(m - 2, x - 1, y - 1) + 4 * m - 4;
// 规模缩小,记得加上 4 * m - 4
}
int main() {
scanf("%d %d %d", &n, &i, &j);
printf("%d\n", f(n, i, j));
return 0;
}
「例题」无限的路#
原题目链接:Link。
给出两个点 ,要求连接的折线长度,为了方便计算,我们可以统一成到点 的折线长度,两者相减就会抵消,剩下我们要求的答案。
现在,问题就转化为给出点 ,求它到点 的折线长度。
我们可以发现(结合图食用更佳):
- 当 时,我们可以从 走到 ,再加上点 到点 的折线长度。
- 当 时,我们可以从 走到 ,再加上点 到点 的折线长度。
- 当 且 时,我们可以从 走到 ,再加上点 到点 的折线长度。
然后用上我们小学二年级就学过的两点间直线距离 就行了。边界显然是 。
由于这里是 double 类型,不好直接记忆化,所以我们可以开一个 bool 数组打标记。代码如下:
#include <cstdio>
#include <cmath>
double ans[205][205];
bool flag[205][205];
double f(int x1, int my1, int x2, int y2) {
return sqrt(pow(x1 - x2, 2) + pow(my1 - y2, 2));
} // 两点间直线距离
int n, x1, x2, my1, y2; // y1 是关键字,随便加个字母
double g(int x, int y) {
if (!x && !y) return 0; // (0, 0) 边界
if (flag[x][y]) return ans[x][y]; // 已经搜过
flag[x][y] = true;
if (!x) return ans[x][y] = g(y - 1, 0) + f(x, y, y - 1, 0);
if (!y) return ans[x][y] = g(0, x) + f(x, y, 0, x);
return ans[x][y] = g(0, x + y) + f(x, y, 0, x + y); // 三种情况
}
int main() {
scanf("%d", &n);
while (n--) {
scanf("%d %d %d %d", &x1, &my1, &x2, &y2);
printf("%.3lf\n", fabs(g(x1, my1) - g(x2, y2))); // 记得加绝对值
}
return 0;
}
「例题」最大奇约数#
原题目链接:Link。
每一个数都能表示成 的形式(),这道题的 相当于求出 。答案为 。
如果老老实实地算 会超时,加上记忆化又会 MLE,这启发我们令一个函数 。
然后我们推公式。
- 当 ,显然 。
- 当 ,发现 可以根据奇偶性分成两类,,而 。从而也可以把 分成两类,一类是 ,一类是 ;前者可以等差数列求和暴算,而后者发现就等于 ,直接递归即可。
#include <cstdio>
int n;
long long f(int x) {
if (x == 1) return 1; // 边界条件
if (x & 1) return f(x - 1) + x;
return f(x >> 1) + (long long)x * x / 4;
// 因为 x * x 可能会溢出,所以先转换成 long long 再乘
}
int main() {
scanf("%d", &n);
printf("%lld\n", f(n));
return 0;
}
「例题」Strange Towers of Hanoi#
原题目链接:Link。
题目要求我们求 Hanoi 塔的移动步数。这个简单,秒了!——真的这么简单吗?啊,是 座塔!(一般的 Hanoi 塔是 座。)
不过,题目中有这样一段话:
此算法适用于 :塔 A 上的 ()个盘子是固定的,其余 个盘子使用四塔算法从 A 移到 B。然后用三塔算法把 A 上的 个盘子移到 D,最后使用四塔算法将 B 上的 个盘子再次移动到 D。
可以设 个盘子时用三塔需要 步,我们有这样的方案:把 A 最上面的 个盘子移到 B,A 上的第 个盘子移到 C,然后把 B 上的 个盘子移到 C。从而有 。
再设 个盘子时用四塔需要 步。因为题目已经告诉了我们公式,遍历 取 即可,不用白不用哇:
「例题」棋子移动#
原题目链接:Link。
首先,我们发现 时仅有这一种固定移动方法:
4,5-->9,10
8,9-->4,5
2,3-->8,9
7,8-->2,3
1,2-->7,8
这显然就是递归边界。那当 时呢(下面设 )?
〇〇〇〇〇●●●●●
容易想到,先把中间的移到最右边。
〇〇〇〇[ ][ ]●●●●〇●
然后把最右边的两个黑棋子移到空位里去。
〇〇〇〇●●●●[ ][ ]〇●
发现左边缩小规模成了 的情况,继续递归即可。
代码十分简单:
#include <cstdio>
#include <iostream>
int n;
void print(int n) {
if (n == 4) {
puts("4,5-->9,10\n\n8,9-->4,5\n\n2,3-->8,9\n\n7,8-->2,3\n\n1,2-->7,8");
return;
}
std::cout << n << "," << n + 1 << "-->" << (n << 1 | 1) << "," << (n * 2 + 2) << std::endl << std::endl;
std::cout << 2 * n - 1 << "," << (n << 1) << "-->" << n << "," << n + 1 << std::endl << std::endl;
print(n - 1);
}
int main() {
std::cin >> n;
print(n);
return 0;
}
By the way,建议大家输出复杂格式时还是用 printf,cout 要打死了。。。
「例题」分形图1#
原题目链接:Link。
题目要求我们输出一个奇怪的 X 形分形图。观察发现,当规模为 时,其实就是由 个规模为 的 X 形组合而成。一般对于这种分形图,我们都会递归规模和坐标。
代码非常简单:
#include <bits/stdc++.h>
using namespace std;
int n;
char ch[735][735];
void print(int n, int x, int y) { // n 是规模,(x, y) 是图形左上角坐标
if (n == 1) {
ch[x][y] = 'X';
return;
} // 边界情况
int t = pow(3, n - 2); // n - 1 规模图形的长(宽)
print(n - 1, x, y); // 打印左上
print(n - 1, x + (t << 1), y); // 打印左下
print(n - 1, x + t, y + t); // 打印中间的
print(n - 1, x, y + (t << 1)); // 打印右上
print(n - 1, x + (t << 1), y + (t << 1)); // 打印右下
}
int main() {
while (cin >> n) {
memset(ch, 0, sizeof(ch)); // 记得清零
print(n, 1, 1);
int len = pow(3, n - 1);
for (int i = 1; i <= len; i++) {
for (int j = 1; j <= len; j++)
if (ch[i][j]) putchar(ch[i][j]);
else putchar(' ');
// 没填充到的地方默认是 0,直接输出的话,肉眼看不出与空格的差别,但是会 WA
// 所以这里特判一下
putchar('\n');
}
putchar('-');
putchar('\n');
}
return 0;
}
附上一个奇怪分形图的代码:
#include <iostream>
using namespace std;
int n;
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++)
putchar((i & j) ? '#' : ' ');
putchar('\n');
}
return 0;
}
可以试试用递归实现。
Posted by liuzimingc
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端