从Fibonacci到DFS:C++记忆化搜索实战与优化指南

📅 2026/7/12 12:22:19
从Fibonacci到DFS:C++记忆化搜索实战与优化指南
1. 项目概述从递归到搜索的算法实战之旅如果你正在学习C算法或者刷题时总感觉递归和搜索是两道难以逾越的坎那么这篇文章就是为你准备的。我见过太多初学者一看到Fibonacci数列的递归解法就头疼更别提复杂的深度优先搜索问题了。但我想告诉你从最简单的Fibonacci到看似复杂的DFS中间有一条清晰、可循的路径。这条路径的核心就是理解递归的本质并学会用“记忆化”这把利器将暴力搜索从指数级的时间复杂度中拯救出来。今天我们就以C为工具手把手走完这条路。你会发现无论是蓝桥杯的真题还是面试中的经典问题其内核都离不开我们今天要讲的这几个核心思想。这篇文章不仅会给你代码更会给你一套思考问题和优化代码的“肌肉记忆”。2. 算法核心递归、搜索与记忆化的三位一体要打通从Fibonacci到DFS的任督二脉我们必须先理解三个核心概念递归、深度优先搜索和记忆化。它们不是孤立的而是一个层层递进、相互支撑的体系。2.1 递归算法世界的“俄罗斯套娃”递归简单说就是函数自己调用自己。它把一个大规模问题分解成一个或几个规模更小的、但形式完全相同的子问题直到子问题小到可以直接求解。Fibonacci数列的定义F(n) F(n-1) F(n-2)就是递归的完美体现要知道F(n)你得先知道F(n-1)和F(n-2)。递归代码写起来非常优雅符合人类的直觉思维。但它的致命伤是效率。以最朴素的Fibonacci递归实现为例计算F(5)的过程就像一棵疯狂生长的二叉树F(5) / \ F(4) F(3) / \ / \ F(3) F(2) F(2) F(1) / \ F(2) F(1)你会发现F(3)被计算了2次F(2)被计算了3次。当n增大到40或50时这棵树的节点数即函数调用次数将接近2^n这是一个天文数字程序会陷入漫长的等待甚至直接崩溃。这就是所谓的“重叠子问题”——同一个子问题被反复计算多次。识别出“重叠子问题”是我们进行优化的第一步。2.2 深度优先搜索一条道走到黑的探索者深度优先搜索是一种用于遍历或搜索树、图结构的算法。它的策略如其名尽可能深地搜索图的分支。当节点v的所在边都已被探寻过搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。在算法题中DFS通常以递归的形式实现。比如在一个迷宫中找路从起点开始选择一个方向走下去直到走到死胡同再退回上一个岔路口选择另一个方向。这种“尝试-回溯”的机制天然适合用递归来表达。递归函数中的每一层调用都对应着搜索树中的一层而函数调用栈则自动帮我们记录了“回溯点”。2.3 记忆化搜索给递归加上“备忘录”记忆化搜索是解决上述“重叠子问题”的银弹。它的思想极其朴素既然同一个子问题会被计算多次那我何不第一次算出来的时候就把结果记下来下次再遇到直接查表返回省去重复计算。这个“备忘录”通常是一个数组DP数组或哈希表如unordered_map其下标或键与递归函数的参数一一对应。在递归函数开始时先检查备忘录里是否有当前参数对应的结果有则直接返回没有则正常计算并在返回前将结果存入备忘录。记忆化搜索不是一种独立的算法而是递归和动态规划之间的桥梁。它保留了递归的直观性又拥有了动态规划的高效性。可以说带记忆化的递归就是自顶向下的动态规划。理解这一点对你后续学习更复杂的动态规划问题至关重要。3. 从Fibonacci出发理解记忆化的第一块敲门砖让我们用代码来直观感受记忆化的魔力。我们将实现三个版本的Fibonacci数列求解并对比它们的性能。3.1 朴素递归优雅但低效的起点#include iostream using namespace std; using ll long long; const ll MOD 1e9 7; ll fib_naive(int n) { if (n 2) return 1; return (fib_naive(n - 1) fib_naive(n - 2)) % MOD; } int main() { int n 50; // 试试看50 // clock_t start clock(); cout fib_naive(n) endl; // clock_t end clock(); // cout Time: (double)(end - start) / CLOCKS_PER_SEC s endl; return 0; }这段代码简洁明了但当n50时在我的测试机上需要近一分钟才能算出结果。你可以取消注释计时代码感受一下。问题就在于之前分析的指数级重复计算。3.2 记忆化递归效率的飞跃现在我们引入一个dp数组作为备忘录。#include iostream #include cstring using namespace std; using ll long long; const ll MOD 1e9 7; const int MAX_N 10000; ll dp[MAX_N 5]; // 备忘录dp[i]记录F(i)的值 ll fib_memo(int n) { if (n 2) return 1; // 关键检查如果已经计算过直接返回存储的结果 if (dp[n] ! -1) return dp[n]; // 否则计算并存储结果 dp[n] (fib_memo(n - 1) fib_memo(n - 2)) % MOD; return dp[n]; } int main() { int n 10000; // 现在可以轻松计算到10000 memset(dp, -1, sizeof(dp)); // 初始化为-1表示未计算 // 初始化基础情况 dp[1] dp[2] 1; cout fib_memo(n) endl; return 0; }核心要点解析dp数组初始化我们使用memset(dp, -1, sizeof(dp))将数组初始化为-1。这里选择-1是因为Fibonacci数列的值都是正数-1可以作为一个安全的“未计算”标志。你也可以用-1或0如果0不是有效结果。检查备忘录在递归函数开头if (dp[n] ! -1) return dp[n];这行代码是记忆化的灵魂。它拦截了所有重复的计算请求。存储结果在计算完结果后dp[n] ...将结果保存起来。注意这里我们直接修改了dp[n]然后返回它。也可以写成return dp[n] (fib_memo(n-1) fib_memo(n-2)) % MOD;更加简洁。经过记忆化优化后每个F(i)只会被计算一次时间复杂度从恐怖的O(2^n)降到了O(n)。计算F(10000)也只是一瞬间的事情。3.3 迭代法另一种视角作为补充我们再看一下自底向上的迭代法也就是标准的动态规划写法ll fib_iter(int n) { if (n 2) return 1; ll a 1, b 1, c; for (int i 3; i n; i) { c (a b) % MOD; a b; b c; } return b; }这种方法同样高效且只使用了常数空间。它和记忆化递归是等价的只是思考方向不同一个从顶向下分解一个从底向上构建。实操心得在面试或竞赛中如果问题有明显的递推关系且n很大优先考虑迭代法它通常常数更小且没有递归栈溢出的风险。但记忆化递归在思维上更直观尤其是对于状态定义复杂的问题。4. 记忆化搜索实战破解“混境之地”迷宫问题掌握了Fibonacci的记忆化我们来看一个更贴近实际算法题的案例。假设有一个n x m的网格迷宫每个格子有高度h[i][j]。你从起点(ix, iy)出发只能走到相邻上下左右且高度严格低于当前格子的格子。此外你有一个“背包”道具可以使用一次使用后下一次移动可以走到高度低于当前格子高度k的格子。问能否到达终点(ex, ey)。4.1 问题分析与状态定义这是一个典型的路径存在性问题。没有背包时移动规则是h[nx][ny] h[x][y]。使用背包后当次移动规则变为h[nx][ny] h[x][y] k且使用后背包状态改变。关键洞察这个问题的状态不仅取决于当前位置(x, y)还取决于背包是否已经使用过t。因为从同一个点出发带着背包和没带背包能走的路径是完全不同的。因此我们的状态应该是一个三元组(x, y, t)。其中t0表示背包未使用t1表示已使用。记忆化数组dp[x][y][t]就用来记录从状态(x, y, t)出发能否到达终点。它的值可以是-1未计算0不能到达1可以到达。4.2 暴力DFS实现与瓶颈我们先写出最直观的暴力DFS版本理解搜索过程bool dfs(int x, int y, int t) { if (x ex y ey) return true; // 到达终点 // 向四个方向尝试 int dx[] {1, -1, 0, 0}; int dy[] {0, 0, 1, -1}; for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; if (nx 1 || nx n || ny 1 || ny m) continue; // 越界检查 if (t 0) { // 背包未使用 // 选择1不用背包正常移动 if (h[nx][ny] h[x][y] dfs(nx, ny, 0)) return true; // 选择2使用背包进行跳跃 if (h[nx][ny] h[x][y] k dfs(nx, ny, 1)) return true; } else { // 背包已使用 // 只能正常移动 if (h[nx][ny] h[x][y] dfs(nx, ny, 1)) return true; } } return false; // 四个方向都走不通 }这个版本逻辑清晰但效率极低。考虑一个所有格子高度都相同且无法使用背包的迷宫DFS会尝试所有可能的路径其复杂度在最坏情况下可达O(4^(n*m))因为每个点有最多4个方向可走。4.3 引入记忆化化指数为多项式现在我们加入记忆化数组dp。#include iostream #include cstring using namespace std; const int N 1005; int h[N][N]; int dp[N][N][2]; // -1:未计算0:false1:true int n, m, k, ix, iy, ex, ey; int dx[] {1, -1, 0, 0}; int dy[] {0, 0, 1, -1}; bool dfs(int x, int y, int t) { if (x ex y ey) return true; // 记忆化检查如果状态已计算直接返回 if (dp[x][y][t] ! -1) return dp[x][y][t]; bool canReach false; for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; if (nx 1 || nx n || ny 1 || ny m) continue; if (t 0) { if (h[nx][ny] h[x][y] dfs(nx, ny, 0)) canReach true; if (!canReach h[nx][ny] h[x][y] k dfs(nx, ny, 1)) canReach true; // 短路优化 } else { if (h[nx][ny] h[x][y] dfs(nx, ny, 1)) canReach true; } if (canReach) break; // 找到一个能到达的路径即可提前结束 } // 存储结果将bool转换为int存储 dp[x][y][t] canReach ? 1 : 0; return canReach; } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); memset(dp, -1, sizeof(dp)); cin n m k; cin ix iy ex ey; for (int i 1; i n; i) for (int j 1; j m; j) cin h[i][j]; cout (dfs(ix, iy, 0) ? Yes : No) endl; return 0; }代码精讲与避坑指南状态存储dp[x][y][t]存储的是从(x,y,t)出发的最终结果能否到终点。一旦计算出结果无论后续搜索如何调用到这个状态都直接返回避免了以该状态为起点的所有子搜索的重复计算。初始化memset(dp, -1, sizeof(dp))将所有状态标记为“未计算”。注意memset按字节赋值-1的二进制表示是0xFF对于int类型来说四个字节都是0xFF其值仍然是-1这是安全的。返回值处理在递归函数中我们用一个局部变量canReach记录本层搜索的结果最后统一存入dp数组并返回。这样逻辑更清晰也便于调试。短路优化在t0的分支里如果不用背包已经能到达(canReach为真)就没必要再尝试用背包的路径了。这是一个小小的优化在某些情况下能减少递归调用。经过记忆化后每个状态(x, y, t)最多只被计算一次。总状态数是n * m * 2每个状态需要尝试最多4个方向因此时间复杂度优化到了O(n * m)这是一个质的飞跃。注意事项记忆化搜索有效的前提是在相同的输入状态下函数的输出结果是确定且唯一的。在这个问题中给定起点、背包状态和固定的迷宫结果就是唯一确定的。如果问题中存在“随机性”或“后效性”当前决策影响之前的状态则不能直接使用记忆化。5. 高阶应用记忆化搜索解决“地宫取宝”计数问题我们来看一个更复杂的例子它来自蓝桥杯真题。问题可以抽象为在一个n x m的网格中每个格子有宝物价值c[i][j]。你从左上角(1,1)出发只能向右或向下走走到右下角(n,m)结束。沿途你可以拿宝物但必须满足1. 当前要拿的宝物价值必须严格大于手中已有宝物的最大价值2. 最终手中的宝物数量恰好为k。求不同的行动方案总数结果对1e97取模。5.1 状态设计与暴力DFS这个问题不再是简单的“能否到达”而是要求“方案数”。因此我们的DFS函数应该返回一个数值方案数而不是布尔值。状态设计当我们走到(x, y)时能影响后续决策的因素有哪些当前位置(x, y)。当前手中宝物的最大价值max_val。这决定了下一个宝物能不能拿。当前手中宝物的数量cnt。这决定了我们还能拿多少宝物以及最终是否满足条件。因此状态可以定义为(x, y, max_val, cnt)。DFS函数dfs(x, y, max_val, cnt)的含义是从(x,y)出发手中宝物最大价值为max_val数量为cnt走到终点且最终宝物数恰好为k的方案数。暴力DFS框架ll dfs(int x, int y, int max_val, int cnt) { // 到达终点 if (x n y m) { return (cnt k) ? 1 : 0; } ll res 0; // 只能向右或向下走 int dx[] {1, 0}; int dy[] {0, 1}; for (int i 0; i 2; i) { int nx x dx[i]; int ny y dy[i]; if (nx n || ny m) continue; // 越界 // 情况1拿当前格子的宝物如果可以拿 if (c[nx][ny] max_val cnt k) { res (res dfs(nx, ny, c[nx][ny], cnt 1)) % MOD; } // 情况2不拿当前格子的宝物 res (res dfs(nx, ny, max_val, cnt)) % MOD; } return res; } // 初始调用从(1,1)开始有两种选择拿或不拿第一个宝物 ll ans (dfs(1, 1, 0, 0) dfs(1, 1, c[1][1], 1)) % MOD;一个关键陷阱注意“拿”和“不拿”是两种独立的选择不是互斥的。只要满足拿的条件价值更大且未拿满我们就可以选择拿同时无论能否拿我们总是可以选择不拿。因此这两个res (res dfs(...))是顺序执行、累加的关系绝对不能写成if...else。写成else就变成了“能拿就必须拿不能拿才不拿”这完全错误会漏掉大量“能拿但选择不拿”的合法方案。5.2 引入记忆化与细节处理暴力DFS的复杂度是指数级的必须记忆化。我们的记忆化数组dp需要能覆盖所有状态。#include iostream #include cstring using namespace std; using ll long long; const int N 55; // 注意题目规模n,m50数组开55足够 const ll MOD 1e9 7; int c[N][N]; int n, m, k; ll dp[N][N][14][14]; // dp[x][y][max_val][cnt] ll dfs(int x, int y, int max_val, int cnt) { // 记忆化检查 if (dp[x][y][max_val][cnt] ! -1) return dp[x][y][max_val][cnt]; if (x n y m) { // 到达终点只有宝物数量刚好为k才算一种方案 return dp[x][y][max_val][cnt] (cnt k) ? 1 : 0; } ll res 0; // 方向右、下 if (y 1 m) { // 向右走 int nx x, ny y 1; // 拿 if (c[nx][ny] max_val cnt k) { res (res dfs(nx, ny, c[nx][ny], cnt 1)) % MOD; } // 不拿 res (res dfs(nx, ny, max_val, cnt)) % MOD; } if (x 1 n) { // 向下走 int nx x 1, ny y; // 拿 if (c[nx][ny] max_val cnt k) { res (res dfs(nx, ny, c[nx][ny], cnt 1)) % MOD; } // 不拿 res (res dfs(nx, ny, max_val, cnt)) % MOD; } return dp[x][y][max_val][cnt] res; } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); memset(dp, -1, sizeof(dp)); cin n m k; for (int i 1; i n; i) { for (int j 1; j m; j) { cin c[i][j]; c[i][j]; // 关键操作价值统一1 } } // 初始状态在(1,1)处可以选择拿或不拿 ll ans (dfs(1, 1, 0, 0) dfs(1, 1, c[1][1], 1)) % MOD; cout ans endl; return 0; }几个至关重要的细节数组大小dp[N][N][14][14]。为什么第三、四维是14因为k的最大值是12题目给定而max_val是宝物价值。题目说宝物价值在[0, 12]之间。但我们做了c[i][j]操作将价值映射到了[1, 13]。所以max_val的范围是0表示还没拿任何宝物到13共14种可能。cnt的范围是0到12k最大值共13种但开到14更安全。这里如果开成dp[N][N][13][13]当max_val13或cnt12时会数组越界导致程序出现不可预知的错误。价值1操作c[i][j]。为什么因为我们的max_val初始值可以是0表示手中没有宝物。如果地图中有价值为0的宝物那么条件c[nx][ny] max_val即0 0就不成立无法拿起价值为0的宝物。但题目逻辑是允许拿的价值0大于“没有”的状态。一个巧妙的处理办法就是将所有价值加1这样价值范围变为[1,13]而初始状态max_val0就能顺利拿起价值为1原0的宝物了。这是一种常见的偏移技巧。方向处理这里将方向循环拆成了两个独立的if判断向右、向下逻辑更清晰也避免了在循环内重复判断越界。初始化与取模dp数组初始化为-1。每次累加方案数后立即取模% MOD防止溢出。经过记忆化状态总数是n * m * 14 * 14每个状态进行常数次转移时间复杂度是O(nmk*V)完全可以在规定时间内运行。6. 记忆化搜索的通用范式与调试技巧通过以上三个例子我们可以总结出记忆化搜索的通用代码框架和心法。6.1 通用四步法定义状态与DP数组仔细分析问题找出影响决策的所有变量它们共同构成一个“状态”。用这些变量作为维度定义记忆化数组dp。数组类型根据返回值确定bool, int, long long等。设计递归函数函数参数与状态变量一一对应。函数返回值就是从该状态出发的答案能否到达、方案数、最优值等。实现递归逻辑边界条件首先处理递归终点如到达目标、超出范围、不合法状态直接返回结果。记忆化检查在函数开头检查dp[状态]是否已计算。是则直接返回。状态转移根据题意枚举当前状态的所有可能选择或下一步状态递归调用函数并根据子问题的结果计算当前状态的答案。保存并返回将计算出的答案存入dp[状态]然后返回。初始化与调用在主函数中初始化dp数组为“未计算”值如-1或一个不可能出现的值。然后以初始状态调用递归函数。6.2 常见问题与调试技巧即使掌握了框架实际编码中还是会遇到各种坑。下面是一些高频问题及解决方法。问题1记忆化为什么没生效时间复杂度还是很高检查状态定义是否完整是否漏掉了影响结果的隐藏变量比如“混境之地”的背包状态t。检查DP数组初始化是否正确地用memset或循环初始化为了“未计算”标志这个标志不能是函数可能返回的有效值。检查记忆化代码位置if (dp[state] ! uncalculated) return dp[state];这行代码必须放在递归函数开头在边界条件判断之后但在任何复杂的计算或循环之前。打印调试在递归函数入口打印状态参数如果看到大量相同的状态被重复调用说明记忆化没起作用。问题2方案数巨大结果不对或溢出及时取模像“地宫取宝”这类计数问题结果通常要对一个大数如1e97取模。必须在每次加法或乘法运算后立即取模而不是最后才取模因为中间结果可能已经溢出long long范围。检查取模运算(a b) % MOD和(a % MOD b % MOD) % MOD是等价的但前者更简洁。确保MOD是const常量。问题3递归深度过大导致栈溢出估算深度递归深度通常等于搜索树的最大深度。在网格DFS中深度可能是nm。对于n,m50深度100是安全的。但对于n,m1000的网格深度2000可能接近或超过系统栈限制通常1-8MB。解决方法改用迭代DP如果问题可以用循环顺序求解优先用迭代法。调整系统栈竞赛中可能不允许在C中可以用编译指令或ulimit -s unlimitedLinux来扩大栈空间但这并非良策。优化状态看是否能减少状态维度或者用BFS队列来避免深层递归。问题4如何选择“未计算”标志如果结果范围是[0, INF)可以用-1。如果结果可能是任意整数包括负数可以用一个特殊值如-INF或0x3f3f3f3f一个很大的数但要确保这个值不会与合法结果冲突。更通用的方法是使用一个单独的bool visited数组来记录状态是否被计算过。6.3 记忆化搜索 vs 动态规划这是初学者常问的问题。本质上记忆化搜索就是动态规划的一种实现方式自顶向下而通常说的动态规划指的是自底向上的迭代填表法。特性记忆化搜索 (Top-Down DP)迭代动态规划 (Bottom-Up DP)思维方式从目标问题出发分解为子问题更符合自然思维。从基础子问题开始逐步构建到目标问题。代码实现通常用递归实现代码更简洁直观。用循环实现需要仔细设计循环顺序。计算效率只计算必要的子问题可能比迭代法计算更少的子状态。通常会计算所有子状态即使有些可能用不到。栈空间需要递归调用栈深度过大可能溢出。不需要递归栈空间使用更可控。适用场景状态转移关系复杂或依赖关系不是简单的线性顺序如DAG上的DP。状态转移清晰依赖关系有明确的拓扑序如线性DP、区间DP。个人建议对于新手从记忆化搜索入手来理解动态规划的思想是极好的。它让你专注于“状态”和“转移”而不用纠结于循环的顺序。当你对问题足够熟悉后可以尝试将其改写为迭代形式这往往能获得更好的常数性能并且是面试官更期待的写法。7. 举一反三记忆化搜索的变体与拓展掌握了基础模型我们可以看看记忆化搜索还能解决哪些变体问题这有助于你形成知识网络。7.1 结合其他算法DFS with Memoization on Graph记忆化搜索不只用于网格它可以用于任何有向无环图上的DP问题。例如在有向图中找最长路径关键路径问题// 假设图用邻接表G存储dp[u]表示从节点u出发能到达的最长路径长度 vectorint dp(n, -1); int dfs_longest(int u) { if (dp[u] ! -1) return dp[u]; int max_len 0; for (int v : G[u]) { max_len max(max_len, dfs_longest(v) 1); // 边权为1 } return dp[u] max_len; } // 对所有入度为0的节点调用dfs_longest取最大值这里图必须是无环的否则递归会陷入死循环。对于有环图需要先进行拓扑排序或使用其他算法。7.2 状态压缩与记忆化当状态中的某个维度是集合比如哪些点访问过时可以用位运算进行状态压缩然后用整数作为DP数组的下标。经典的“旅行商问题”的DP解法就是状态压缩记忆化搜索。// 假设有n个城市从0号城市出发最后回到0号求最短环路。 // 状态dp[mask][u] 表示已经访问过的城市集合为mask二进制位表示当前位于城市u走完剩余城市回到0号点的最短距离。 int dp[1n][n]; int dfs_tsp(int mask, int u) { if (mask (1n)-1) { // 所有城市都访问过 return dist[u][0]; // 返回当前城市到起点的距离 } if (dp[mask][u] ! -1) return dp[mask][u]; int res INF; for (int v 0; v n; v) { if (!(mask (1v))) { // 城市v未访问 res min(res, dfs_tsp(mask | (1v), v) dist[u][v]); } } return dp[mask][u] res; } // 初始调用dfs_tsp(1, 0); // 从0号城市出发mask中第0位为17.3 博弈类问题中的记忆化记忆化搜索也常用于解决双方轮流操作、最优策略的博弈问题。例如经典的“取石子游戏”有一堆石子每次可以取1个、3个或4个无法取者输。判断先手是否必胜。// dp[stones]剩余stones个石子时当前操作者是否必胜 vectorint dp(n1, -1); // -1未知0必败1必胜 bool canWin(int stones) { if (stones 0) return false; // 没石子可取了当前操作者输 if (dp[stones] ! -1) return dp[stones]; // 当前操作者可以尝试取1,3,4个 // 如果存在一种取法使得对手处于必败状态则当前操作者必胜 if (stones 1 !canWin(stones - 1)) return dp[stones] 1; if (stones 3 !canWin(stones - 3)) return dp[stones] 1; if (stones 4 !canWin(stones - 4)) return dp[stones] 1; // 所有取法都会让对手进入必胜状态则当前操作者必败 return dp[stones] 0; }这类问题的核心是一个状态是“必胜态”当且仅当存在一个操作能将它变为一个“必败态”一个状态是“必败态”当且仅当所有操作都将它变为“必胜态”。从Fibonacci数列的递归优化到迷宫路径的判定再到复杂方案的计数记忆化搜索像一根金线串起了算法学习中这些看似离散的珍珠。它的核心思想——用空间换时间存储重复子问题的解——是算法优化中最朴素也最强大的思想之一。我建议你在理解本文代码的基础上去找LeetCode或蓝桥杯真题练习比如“不同路径”、“零钱兑换”、“最长递增子序列”等问题都可以用记忆化搜索优雅地解决。最开始可能会在状态设计上卡壳多练几次你就能快速抓住问题的关键变量。记住写递归函数时先别管效率把暴力的、正确的逻辑写出来然后再思考这个函数的输出是不是只由输入参数决定如果是就大胆地加上那个dp数组吧。