第4章 递推法
4.1 递推法概述
设计思想:
递推法(Recurrence Method)通过已知的初始条件和递推关系,逐步推导出问题的最终结果,常用于序列计算和分阶段问题求解。
示例:猴子和桃子问题
题目描述:
猴子每天吃掉剩余桃子的一半再多吃一个,第 10 天只剩 1 个桃子,问最初有多少个桃子?
思路:
-
设 a[n] 为第 n 天结束后剩余的桃子数;
-
已知 a[10] = 1;
-
从后向前有递推关系:
a[n] = (a[n+1] + 1) × 2
代码实现:
// MonkeyPeach:计算第1天最初的桃子数
int MonkeyPeach(int days) {// days 表示总共天数,本题为 10int peaches = 1; // 从第 days-1 天开始倒推到第 1 天for (int day = days - 1; day >= 1; day--) {// 根据递推关系 a[n] = (a[n+1] + 1) * 2// peaches 此时保存 a[n+1],更新后即为 a[n]peaches = (peaches + 1) * 2;}return peaches;
}
整体解释:
我们先假设最后一天剩余 1 个桃子,然后按照“后一天的桃子数加 1 再乘 2”这个公式,依次向前推算,每一步都将当前天的剩余数计算出来,循环结束后 peaches
即为第 1 天最初的桃子数。
4.2 数学序列中的递推法
4.2.1 斐波那契数列
题目描述:
兔子繁殖问题:第 1、2 个月各有 1 对兔子,从第 3 个月起每月新增的兔子对数等于前两个月兔子对数之和,求第 n 个月的兔子对数。
递推关系:
f(1) = 1, f(2) = 1
f(n) = f(n-1) + f(n-2), n ≥ 3
代码实现:
// Fibonacci:计算第 n 个月的兔子对数
int Fibonacci(int n) {// 前两个月的兔子对数均为 1if (n <= 2) return 1;int prev = 1; // f(i-2)int curr = 1; // f(i-1)int next; // f(i)// 从第 3 个月开始循环for (int i = 3; i <= n; i++) {next = prev + curr; // 应用 f(i) = f(i-1) + f(i-2)prev = curr; // 将 f(i-1) 赋值给 prevcurr = next; // 将 f(i) 赋值给 curr}return curr; // 返回 f(n)
}
整体解释:
我们只记录前两项 prev
和 curr
,每次计算新的一项 next
,然后滚动更新这两个变量,最后 curr
存储的就是第 n 个月的兔子对数。此方法空间复杂度 O(1),时间复杂度 O(n)。
4.2.2 卡塔兰数
题目描述:
凸 n 边形划分成三角形的不同方式数,第 n 个卡塔兰数 C(n) 满足:
C(0) = 1
C(n) = ∑_{i=0}^{n-1} C(i) × C(n-1-i), n ≥ 1
代码实现:
// Catalan:计算第 n 个卡塔兰数
int Catalan(int n) {// 分配数组存储 0...n 的 C 值int C[n+1];C[0] = 1; // C(0) = 1// 依次计算 C(1) 到 C(n)for (int i = 1; i <= n; i++) {C[i] = 0;// 按定义累加for (int k = 0; k < i; k++) {C[i] += C[k] * C[i - 1 - k];}}return C[n];
}
整体解释:
使用数组 C[]
自底向上存储卡塔兰数,通过两层循环,外层决定要计算的下标 i,内层按公式累加前面各项乘积,最终 C[n]
即为答案。时间复杂度 O(n²),空间复杂度 O(n)。
4.3 组合问题中的递推法
4.3.1 错排问题
题目描述:
有 n 封信和 n 个信封,要求没有信件放入正确的信封,求错排方案数 D(n),递推关系:
D(1) = 0
D(2) = 1
D(n) = (n - 1) × (D(n-1) + D(n-2)), n ≥ 3
代码实现:
// Derangement:计算错排数 D(n)
int Derangement(int n) {if (n == 1) return 0; // D(1) = 0if (n == 2) return 1; // D(2) = 1int dn_2 = 0; // D(n-2)int dn_1 = 1; // D(n-1)int dn; // D(n)// 从 n=3 开始迭代for (int i = 3; i <= n; i++) {dn = (i - 1) * (dn_1 + dn_2);dn_2 = dn_1; // 滚动更新 D(n-2)dn_1 = dn; // 滚动更新 D(n-1)}return dn; // 返回 D(n)
}
整体解释:
只需保留前两项 dn_2
、dn_1
,用递推公式计算当前项 dn
,再向后滚动即可,空间复杂度 O(1),时间复杂度 O(n)。
4.3.2 旋转万花筒
题目描述:
起始有 4 个闪光点,每次旋转在每个分支末端增加 2 个闪光点,问 n 次旋转后总闪光点数。
代码实现:
// Kale:计算旋转 n 次后的闪光点总数
int Kale(int n) {int lamps = 4; // 初始闪光点数int addLamp = 2; // 每个分支基础新增数for (int i = 1; i <= n; i++) {// 本次新增数量是上次的两倍addLamp *= 2;// 累加到总闪光点数lamps += addLamp;}return lamps;
}
整体解释:
变量 addLamp
跟踪每次新增的闪光点数,每次翻倍后累加到 lamps
,循环结束后 lamps
为旋转 n 次的总数。时间复杂度 O(n)。
4.4 拓展与演练
4.4.1 整数拆分(2 的幂次划分)
题目描述:
将正整数 n 拆分为若干 2 的幂次之和,求拆分方案数 d(n),递推关系:
d(1) = 1
d(2) = 2
若 i 为奇数: d(i) = d(i - 1)
否则: d(i) = d(i - 1) + d(i / 2)
代码实现:
// PowerSplit:计算 2 的幂次拆分方案数
int PowerSplit(int n) {int d[n + 1]; // 存储从 1 到 n 的方案数d[1] = 1; // d(1) = 1d[2] = 2; // d(2) = 2for (int i = 3; i <= n; i++) {if (i % 2 != 0) {// 奇数只能继承前一个的方案d[i] = d[i - 1];} else {// 偶数可在继承前一个方案基础上,加上包含 i/2 的拆分d[i] = d[i - 1] + d[i / 2];}}return d[n];
}
整体解释:
用一维数组 d[]
自底向上记录每个 i
的方案数,遇到奇数直接复制,偶数则累加前一项和 i/2
的方案即可。时间复杂度 O(n),空间 O(n)。
4.4.2 捕鱼问题
题目描述:
5 人轮流捕鱼,每人将看到的鱼分成 5 份,丢弃 1 条并带走 1 份,其余留给下一人,直到最后一人也按此规则操作,求最少的初始鱼数及每人捕鱼时看到的鱼数。
思路:
从最小可能的初始鱼数开始尝试,依次验证每个人都能整除且满足规则。
代码实现:
// GetFish:计算最少的初始鱼数
int GetFish(int nPeople) {int fish[5]; // fish[i] 记录第 i+1 个人见到的鱼数fish[0] = 1; // 从最少 1 条开始尝试while (1) {fish[0]++; // 逐次增加初始鱼数bool valid = true;// 验证每个人是否都能按规则操作for (int i = 1; i < nPeople; i++) {// (看见数 - 1) 必须能被 5 整除if ((fish[i - 1] - 1) % 5 != 0) {valid = false;break;}// 每人带走1份,留给下一个的人 = (fish[i-1]-1)/5*4fish[i] = (fish[i - 1] - 1) / 5 * 4;}if (valid) break; // 全部满足则结束循环}return fish[0];
}
整体解释:
数组 fish[]
存储每个人见到的鱼数,从第一个人开始试探最小初始值,每次尝试后向下验证,若所有人都满足“(见到数-1) 能被 5 整除”,则该初始值即为答案。时间复杂度较高,但能够找到最小解。