news 2026/8/19 8:29:55

从暴力递归到记忆搜索:动态规划入门与C++实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从暴力递归到记忆搜索:动态规划入门与C++实战

1. 从“暴力递归”到“记忆搜索”:一个思维范式的转变

很多刚接触算法的新手,甚至一些有经验的开发者,在面对“动态规划”这四个字时,常常会感到一种莫名的压力。教科书和教程里充斥着各种“状态定义”、“状态转移方程”、“最优子结构”和“无后效性”的术语,让人望而生畏。但如果你写过递归,那么恭喜你,你已经半只脚踏入了动态规划的大门。今天我们不谈那些晦涩的定义,就从你最熟悉的递归开始,聊聊如何通过一个简单的技巧——“记忆搜索”,让那些原本慢到无法忍受的递归程序瞬间起飞,并自然地过渡到标准的动态规划解法。我们将全程使用C++来演示,因为它的性能特性能让我们更直观地感受到优化前后的巨大差异。

想象这样一个场景:你要计算斐波那契数列的第n项。最直观的写法就是递归:fib(n) = fib(n-1) + fib(n-2),基准条件是fib(1)=fib(2)=1。这个代码写起来非常优雅,但当你尝试计算fib(50)时,程序可能会陷入漫长的等待,甚至因为递归栈过深或超时而崩溃。为什么?因为其中存在大量的重复计算。fib(5)的计算需要fib(4)fib(3),而fib(4)的计算又需要fib(3)fib(2)…… 你会发现fib(3)被计算了无数次。这种指数级的时间复杂度是灾难性的。

“记忆搜索”要解决的核心就是这个问题。它的本质不是一种全新的算法,而是对递归算法的一种优化技术,也被称为“自顶向下的动态规划”。思路直白得惊人:既然重复计算是罪魁祸首,那我们用一个数组(或哈希表)把已经计算过的结果存起来不就行了?下次再遇到相同的参数时,直接返回存储的结果,避免重复的递归调用。这个存储用的数组,就是我们常说的“记忆化数组”或“DP表”。从暴力递归到记忆搜索,代码改动往往很小,但效果是降维打击式的——时间复杂度通常能从指数级降低到多项式级。

2. 斐波那契数列:解剖第一个记忆化案例

让我们用代码来具体感受一下这个转变。这是最经典的入门案例,能清晰地展示问题所在和解决方案。

2.1 暴力递归的代价

我们先写出那个简洁但低效的版本:

#include <iostream> using namespace std; long long fib_naive(int n) { if (n <= 2) return 1; // 基准情况 return fib_naive(n - 1) + fib_naive(n - 2); // 递归调用 } int main() { int n = 45; // 尝试一个稍大的数 cout << "fib(" << n << ") = " << fib_naive(n) << endl; return 0; }

你可以试试运行计算fib(45)。在普通的机器上,这可能需要好几秒甚至更长时间。如果我们画出递归树(以计算fib(6)为例),就会看到一棵庞大的、枝节蔓延的树,其中fib(3)fib(2)等节点重复出现了很多次。时间复杂度是 O(2^n),空间复杂度是 O(n)(递归调用栈的深度)。

2.2 引入记忆化:效率的飞跃

现在,我们引入一个全局的memo数组。这个数组的初始值可以设为 -1,表示该位置的结果还未被计算。

#include <iostream> #include <vector> using namespace std; vector<long long> memo; // 记忆化数组 long long fib_memo(int n) { // 1. 查表:如果已经计算过,直接返回结果 if (memo[n] != -1) { return memo[n]; } // 2. 基准情况 if (n <= 2) { memo[n] = 1; return 1; } // 3. 计算并存储:递归计算,但结果存入数组 memo[n] = fib_memo(n - 1) + fib_memo(n - 2); return memo[n]; } int main() { int n = 45; memo.resize(n + 1, -1); // 初始化大小为 n+1,所有值设为 -1 cout << "fib(" << n << ") << ") = " << fib_memo(n) << endl; return 0; }

关键点解析:

  1. memo数组的意义memo[i]存储的就是fib(i)的结果。它的存在使得每个fib(i)在完整的递归过程中只会被计算一次
  2. “剪枝”操作if (memo[n] != -1) return memo[n];这行代码是记忆搜索的灵魂。它像一个缓存系统,命中缓存则直接返回,避免了子树的重复展开。这相当于对递归树进行了“剪枝”。
  3. 计算顺序:注意,我们并没有显式地定义先算谁后算谁。程序的计算顺序依然由递归调用fib_memo(n-1)fib_memo(n-2)决定。但由于记忆化的存在,整体上每个子问题只解决一次。

运行这个版本,计算fib(45)几乎是瞬间完成的。时间复杂度降低到了 O(n),因为每个fib(i)只计算一次;空间复杂度为 O(n) 用于存储数组和递归栈。

注意:这里使用vector<long long>并初始化为 -1,前提是结果不会是 -1。对于更通用的场景,或者结果可能为任何值的情况,可以使用一个单独的bool数组visited来标记是否已计算,或者使用std::optional(C++17) 或std::unordered_map

2.3 对比与思考:它为什么是动态规划?

记忆搜索已经具备了动态规划的核心特征:

  • 最优子结构fib(n)的最优解由fib(n-1)fib(n-2)的最优解构成。
  • 重叠子问题:这是被记忆化数组显式解决了的。
  • 状态:这里的“状态”就是参数n,表示计算斐波那契数列的第几项。
  • 状态转移memo[n] = memo[n-1] + memo[n-2],只不过这个转移是在递归返回过程中隐式完成的。

它与教科书上常见的“自底向上”递推式动态规划(如下所示)在思路上是镜像的:

long long fib_dp(int n) { if (n <= 2) return 1; vector<long long> dp(n + 1); dp[1] = dp[2] = 1; for (int i = 3; i <= n; ++i) { dp[i] = dp[i - 1] + dp[i - 2]; // 显式的状态转移 } return dp[n]; }

自底向上是“我从哪里来”,从小问题开始逐步构建大问题的解。记忆搜索是“我到哪里去”,从大问题出发,遇到小问题就解决并记住。两者最终填满的是同一张DP表,只是填表的顺序不同。记忆搜索更符合人类面对复杂问题时的自然思维(分治),而自底向上递推通常有更优的常数时间和空间优化潜力。

3. 经典问题实战:爬楼梯与零钱兑换

理解了斐波那契,我们来看两个更贴近实际、变化也更多的题目,它们能更好地展示记忆搜索的威力。

3.1 爬楼梯问题

问题描述:假设你正在爬楼梯。需要n阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶?

这几乎就是斐波那契数列的变体。定义dfs(i)为爬到第i阶台阶的方法数。

  • 暴力递归dfs(i) = dfs(i-1) + dfs(i-2),基准是dfs(1)=1,dfs(2)=2
  • 记忆化搜索:直接套用模板。
class Solution { public: int climbStairs(int n) { vector<int> memo(n + 1, -1); return dfs(n, memo); } private: int dfs(int i, vector<int>& memo) { if (i <= 2) return i; // 爬到第1阶1种方法,第2阶2种方法 if (memo[i] != -1) return memo[i]; memo[i] = dfs(i - 1, memo) + dfs(i - 2, memo); return memo[i]; } };

这个实现清晰地将记忆化数组作为参数传递,避免了全局变量。它完美地展示了如何将一个问题转化为“状态”(当前台阶i)和“选择”(走1步或2步)的模型。

3.2 零钱兑换问题

问题描述:给你一个整数数组coins表示不同面额的硬币,以及一个整数amount表示总金额。计算并返回可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回-1

这个问题比爬楼梯更进一步,因为“选择”的数量不再固定(硬币种类数),并且要求的是“最小值”。

第一步:设计暴力递归函数我们定义dfs(target)函数,表示凑出金额target所需的最少硬币数。

  • 基准情况:如果target == 0,需要0枚硬币。如果target < 0,说明当前路径无效,返回一个特殊值(如INT_MAX-1的某种表示)。
  • 状态转移:对于当前金额target,我可以选择使用任意一枚面额小于等于target的硬币coins[i]。选择这枚硬币后,问题就变成了一个子问题:凑出金额target - coins[i]所需的最少硬币数。所以,dfs(target) = 1 + min(dfs(target - coin)),其中coin遍历所有有效的硬币。

第二步:加入记忆化递归过程中,同一个target会被反复计算。例如,硬币[1,2,5],凑11元,dfs(9)可能会从11-211-1-1等多种路径到达。因此,我们需要一个memo数组来存储dfs(target)的结果。

class Solution { public: int coinChange(vector<int>& coins, int amount) { // memo[target] 表示凑出金额 target 的最少硬币数,初始化为 -2 表示未计算 vector<int> memo(amount + 1, -2); return dfs(coins, amount, memo); } private: int dfs(const vector<int>& coins, int target, vector<int>& memo) { if (target < 0) return -1; // 无效路径 if (target == 0) return 0; // 凑齐了,需要0枚硬币 // 查表 if (memo[target] != -2) { return memo[target]; } int minCoins = INT_MAX; for (int coin : coins) { int subResult = dfs(coins, target - coin, memo); if (subResult >= 0) { // 子问题有解 minCoins = min(minCoins, subResult + 1); } } // 存储结果:如果 minCoins 还是 INT_MAX,说明所有选择都无解 memo[target] = (minCoins == INT_MAX) ? -1 : minCoins; return memo[target]; } };

关键细节与踩坑点:

  1. 初始值的选择memo初始化为-2,是为了区分“未计算”和“计算结果为 -1(无解)”。这是一个非常实用的技巧,避免了误判。
  2. 子问题无解的处理dfs返回-1表示无解。在汇总子问题结果时,必须跳过无解的情况(if (subResult >= 0)),否则minCoins可能会被-1+1=0错误地更新。
  3. 最终结果的存储:循环结束后,需要判断minCoins是否被更新过。如果没有(即仍为INT_MAX),则说明所有硬币选择都导致了无效路径,当前target也无解,应存储-1

这个例子充分说明了记忆搜索的通用性。只要你能写出正确的递归函数(定义清楚状态和转移),加上记忆化数组,一个高效的动态规划解法就诞生了。它比直接思考递推公式要直观得多。

4. 记忆搜索的局限性、技巧与优化

虽然记忆搜索强大且直观,但它并非银弹,在实际使用中需要注意以下几点。

4.1 递归深度限制

递归调用会占用系统栈空间。对于问题规模n很大(比如10^5)的情况,即使时间复杂度允许,递归深度也可能导致栈溢出。例如,在爬楼梯问题中,如果递归树是一条单链(理论上),深度就是n。大多数评测系统和生产环境对栈深度都有限制。

解决方案

  1. 转换为递推:这是最根本的解决方法。像斐波那契、爬楼梯问题,可以很容易地改为for循环的递推形式,彻底摆脱递归。
  2. 尾递归优化:某些编译器(如GCC/Clang在较高优化等级下)可以对特定形式的尾递归进行优化,将其转换为循环,但这不是C++标准保证的,且对递归函数写法有严格要求,通用性不强。
  3. 迭代加深搜索:对于某些特定类型的问题(如DFS),这是一种策略,但不适用于标准的记忆化DP。

实操心得:在算法竞赛或面试中,如果问题规模明确可能很大,优先考虑自底向上的递推DP。记忆搜索更适合作为思路推导的工具,或者在确定递归深度可控时使用。

4.2 状态设计与存储结构

记忆化的核心是“状态”。如何设计一个能唯一标识子问题的“状态”是关键。

  • 一维状态:如斐波那契、爬楼梯、零钱兑换(仅金额),直接用数组vector<T>
  • 高维状态:很多问题需要多个参数才能定义子问题。例如经典的“背包问题”,状态需要(i, j)表示考虑前i个物品,在容量为j的背包下的最大价值。这时记忆化数组就需要是二维的vector<vector<T>>
  • 复杂状态或稀疏状态:当状态参数不是连续的整数,或者范围非常大但实际用到的状态很少时,使用数组可能浪费空间。这时可以用unordered_map(哈希表)来存储。例如,状态是一个pair<int, int>或者一个自定义的结构体,unordered_map是更好的选择,尽管查询会有常数开销。
// 使用哈希表进行记忆化的示例框架 unordered_map<long long, int> memo; // key 通常需要编码成一个唯一值 int dfs(State state) { long long key = encode(state); // 将状态编码为唯一key if (memo.count(key)) return memo[key]; // ... 计算过程 memo[key] = result; return result; }

4.3 记忆化与“无后效性”

记忆化能工作的一个隐含前提是:相同的状态参数,无论通过何种路径到达,其对应的最优解是唯一确定的。这就是动态规划的“无后效性”。如果你的问题不满足这个条件(例如,问题解依赖于到达当前状态的路径历史),那么标准的记忆化/动态规划就可能失效,可能需要增加状态维度来包含必要的历史信息。

4.4 从记忆搜索到递推DP的思维桥梁

我强烈建议将记忆搜索作为学习动态规划的第一步。它的步骤非常清晰:

  1. 定义递归函数:明确函数签名dfs(state)和返回值意义。
  2. 思考基准情况:最简单、不可再分的情况直接返回结果。
  3. 枚举所有选择:在当前状态下,列出所有可能的选择,并递归调用dfs(next_state)
  4. 整合子问题结果:根据题目要求(求最大、最小、总数等),合并子问题的结果,得到当前状态的结果。
  5. 加入记忆化:添加一个存储结构,在函数开头查表,在返回前存表。

当你熟练写出记忆搜索后,要尝试着将其转化为递推DP,这是一个重要的思维能力提升。观察记忆化数组的填充顺序。在记忆搜索中,填表顺序是“需要什么才计算什么”,是依赖驱动的。而递推DP需要你主动确定一个正确的计算顺序,保证在计算dp[i]时,它所依赖的所有子状态dp[...]都已经被计算出来。

以零钱兑换为例,记忆搜索是dfs(amount)调用dfs(amount-coin)。对应的递推DP就是:

int coinChange(vector<int>& coins, int amount) { vector<int> dp(amount + 1, amount + 1); // 初始化为一个最大值 dp[0] = 0; // 基准情况 for (int i = 1; i <= amount; ++i) { // 按金额从小到大计算 for (int coin : coins) { if (i - coin >= 0) { dp[i] = min(dp[i], dp[i - coin] + 1); } } } return dp[amount] > amount ? -1 : dp[amount]; }

这里,for (int i = 1; i <= amount; ++i)就是手动确定的计算顺序,确保算dp[i]时,所有dp[i - coin]都已经算好了。这个顺序恰好就是金额从小到大的自然顺序。

5. 综合案例:最长上升子序列(LIS)的记忆化解法

最后,我们用一个中等难度的问题来串联所有知识点:最长上升子序列。给定一个整数数组nums,找到其中最长严格递增子序列的长度。

第一步:设计递归(回溯)思路对于数组中的每个位置i,我们考虑“以nums[i]作为上升子序列的最后一个元素”时,能构成的最长长度dfs(i)

  • 基准情况:每个元素本身至少是一个长度为1的子序列。
  • 状态转移:为了计算dfs(i),我们需要看所有在i之前的索引j(0 <= j < i)。如果nums[j] < nums[i],那么nums[i]可以接在以 nums[j] 结尾的子序列后面,形成一个更长的子序列。所以,dfs(i) = max(dfs(j) + 1),对所有满足nums[j] < nums[i]的 j 取最大值。如果不存在这样的 j,那么dfs(i) = 1

第二步:实现记忆化搜索这个递归存在大量重叠子问题。例如,计算以不同位置i结尾的LIS时,可能会反复计算以某个较早位置j结尾的LIS。

class Solution { public: int lengthOfLIS(vector<int>& nums) { int n = nums.size(); if (n == 0) return 0; vector<int> memo(n, -1); // memo[i] 存储 dfs(i) 的结果 int maxLen = 1; // 计算以每个位置 i 结尾的 LIS 长度 for (int i = 0; i < n; ++i) { maxLen = max(maxLen, dfs(nums, i, memo)); } return maxLen; } private: // 返回以 nums[pos] 结尾的最长上升子序列长度 int dfs(const vector<int>& nums, int pos, vector<int>& memo) { if (memo[pos] != -1) return memo[pos]; int maxSubLen = 1; // 至少包含自己,长度为1 // 遍历 pos 之前的所有位置 for (int i = 0; i < pos; ++i) { if (nums[i] < nums[pos]) { maxSubLen = max(maxSubLen, dfs(nums, i, memo) + 1); } } memo[pos] = maxSubLen; return maxSubLen; } };

第三步:分析与转化这个解法的时间复杂度是 O(n²),因为每个dfs(i)要遍历所有j < i,而共有 n 个状态。空间复杂度是 O(n)。它已经比暴力回溯好太多了。

观察这个记忆化搜索,memo[i]依赖于所有memo[j](j < i 且 nums[j] < nums[i])。这提示我们递推的顺序就是按照索引i从 0 到 n-1 的顺序。我们可以轻松地写出等价的递推DP:

int lengthOfLIS(vector<int>& nums) { int n = nums.size(); if (n == 0) return 0; vector<int> dp(n, 1); // dp[i] 表示以 nums[i] 结尾的 LIS 长度,初始为1 int maxLen = 1; for (int i = 0; i < n; ++i) { for (int j = 0; j < i; ++j) { if (nums[j] < nums[i]) { dp[i] = max(dp[i], dp[j] + 1); } } maxLen = max(maxLen, dp[i]); } return maxLen; }

看,递推的dp数组就是记忆化的memo数组,两层循环明确地按照依赖关系填充了这张表。从记忆搜索到递推DP,思维链路完全打通。

记忆搜索不是动态规划的替代品,而是理解动态规划的一把钥匙,尤其适合解决状态转移不那么直观的复杂问题。它让你专注于问题本身的分解和组合,而把优化交给“记忆”这个简单的机制。下次当你遇到一个看似复杂的动态规划问题时,不妨先忘掉“状态转移方程”这个词,试着问自己:如果我用递归暴力解决,函数应该怎么设计?这个递归树里,有哪些重复的子树?想明白这些,代码自然就水到渠成了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/19 8:29:31

商标设计注册公告期是多长时间要注意什么?

商标设计注册公告期是多长时间&#xff1f;期间要注意什么&#xff1f;“商标通过实质审查了&#xff0c;接下来就是公告期——这段时间到底要多长&#xff1f;我该做什么&#xff1f;”这是很多商标申请人在拿到初步审定通知后的第一反应。公告期看似是“等待期”&#xff0c;…

作者头像 李华
网站建设 2026/8/19 8:28:40

AI技术落地中的工程摩擦:从数据到部署的实战挑战与应对策略

大家好&#xff0c;我是专注于技术实战与经验分享的博主。今天我们来探讨一个在AI浪潮下&#xff0c;开发者与架构师们必须直面的深层问题&#xff1a; AI技术在实际落地过程中产生的“技术摩擦”会消失吗&#xff1f; 这不仅是一个经济或哲学问题&#xff0c;更是一个关乎我…

作者头像 李华
网站建设 2026/8/19 8:25:04

智能体系统时间对齐与峰值感知编排:从理论到工程实践

1. 项目概述&#xff1a;当智能体系统遇上“时间对齐”难题 最近在折腾一个长期运行的智能体系统时&#xff0c;我遇到了一个非常棘手的问题&#xff1a;系统里几个负责不同任务的智能体&#xff0c;各自都挺能干&#xff0c;但凑在一起干活时&#xff0c;总感觉“劲儿没往一处…

作者头像 李华
网站建设 2026/8/19 8:22:40

基于英飞凌TC275的电机调速系统开发:从硬件驱动到FOC算法实现

1. 从零开始&#xff1a;为什么选择TC275做电机调速&#xff1f; 如果你正在看这篇文章&#xff0c;大概率是刚拿到一块英飞凌的TC275开发板&#xff0c;或者导师、老板丢给你一个任务&#xff1a;“用这个TC275做个电机控制试试”。面对这块功能强大但略显复杂的芯片&#xff…

作者头像 李华
网站建设 2026/8/19 8:16:32

3步安装markdownReader:在Chrome里优雅阅读Markdown文件

3步安装markdownReader&#xff1a;在Chrome里优雅阅读Markdown文件 【免费下载链接】markdownReader markdownReader is a extention for chrome, used for reading markdown file. 项目地址: https://gitcode.com/gh_mirrors/ma/markdownReader 你有没有过这样的经历&…

作者头像 李华