2026/4/6 9:48:44
网站建设
项目流程
1. 从零理解动态规划为什么硬币找零是个好例子第一次听说动态规划时我和大多数人一样懵——这名字听起来既抽象又吓人。直到遇到硬币找零问题才真正明白它的精妙。想象你站在便利店收银台前钱包里有1元、2元和5元硬币现在要给顾客找零11元怎么搭配才能用最少的硬币数这就是动态规划的完美入门案例。硬币问题之所以经典是因为它同时具备三个关键特征最优子结构大问题的最优解包含小问题的最优解、重叠子问题计算过程中会反复遇到相同的小问题和无后效性当前决策不影响之前的状态。比如要计算凑11元的最优解必须先知道凑10元、6元和9元的最优解——这些子问题会像俄罗斯套娃一样不断嵌套出现。我刚开始尝试用递归暴力破解时发现当金额超过30元程序就卡死了。后来用动态规划重写同样的计算瞬间完成。这个性能差距让我意识到动态规划本质上是用空间换时间的艺术。它通过一个数组记住所有中间结果避免重复计算把指数级复杂度降到了多项式级别。2. 拆解动态规划四步法以硬币问题为例2.1 定义状态设计你的记忆容器状态定义是动态规划最关键的步骤。对于硬币问题我们创建dp数组其中dp[i]表示凑出金额i需要的最少硬币数。这个定义看似简单却是我踩过最多坑的地方——最初错误地定义dp[i][j]表示前i种硬币凑j元结果把问题复杂化了。实际编码时要注意数组大小。比如目标金额是n数组长度应该是n1因为要考虑从0元到n元的所有情况。Java中初始化可以这样写int[] dp new int[amount 1]; Arrays.fill(dp, Integer.MAX_VALUE); dp[0] 0; // 基准情况2.2 状态转移寻找数学规律状态转移方程是动态规划的灵魂。对于硬币问题核心思路是对于每个金额i尝试所有可能的硬币coin如果i-coin这个金额可达那么dp[i]可能就是dp[i-coin]1。用数学表达就是dp[i] min(dp[i], dp[i - coin] 1) 对所有coin∈coins这个方程我花了三天才真正理解。后来发现用具体数字举例就容易多了假设硬币有[1,2,5]要凑11元。当i11时我们比较用5元硬币需要dp[6]1用2元硬币需要dp[9]1用1元硬币需要dp[10]1 取这三个结果的最小值就是最终解。2.3 初始化设置好起点初始化常常被忽视但却决定算法正确性。dp[0]0这个基准条件表示凑0元需要0个硬币看似废话但如果没有它整个算法就无法启动。其他位置初始化为无穷大或一个足够大的数表示暂时无法凑出。在项目中曾遇到一个bug有人把dp[0]初始化为1导致所有结果多算一个硬币。这个错误提醒我们基准情况必须严格符合物理意义。2.4 遍历顺序细节决定成败硬币问题的遍历顺序有两种选择外层遍历金额内层遍历硬币外层遍历硬币内层遍历金额第一种方式更直观但效率较低第二种才是标准做法。因为按硬币遍历可以更好地利用之前计算的结果也更容易处理某些边界条件。具体实现如下for (int coin : coins) { for (int i coin; i amount; i) { if (dp[i - coin] ! Integer.MAX_VALUE) { dp[i] Math.min(dp[i], dp[i - coin] 1); } } }3. 从理论到实践完整代码实现与优化3.1 基础版本实现完整的Java实现应该包含以下要素public int coinChange(int[] coins, int amount) { int[] dp new int[amount 1]; Arrays.fill(dp, Integer.MAX_VALUE); dp[0] 0; for (int coin : coins) { for (int i coin; i amount; i) { if (dp[i - coin] ! Integer.MAX_VALUE) { dp[i] Math.min(dp[i], dp[i - coin] 1); } } } return dp[amount] Integer.MAX_VALUE ? -1 : dp[amount]; }这段代码有几个易错点数组越界当coin比amount大时内层循环不会执行整数溢出如果用Integer.MAX_VALUE1会变成负数返回值处理无法凑出时应返回-1而非MAX_VALUE3.2 空间优化技巧虽然标准解法空间复杂度已经是O(n)但在某些场景还能优化。如果硬币面值有规律比如全是1的倍数可以用滚动数组进一步减少空间使用。更实用的优化是提前对硬币排序Arrays.sort(coins); // 升序排列 for (int i 0; i coins.length coins[i] amount; i) { // 内层循环 }这样当硬币面值超过当前金额时可以直接跳过减少不必要的计算。3.3 打印具体方案有时我们不仅需要硬币数量还需要知道具体组合。可以增加一个prev数组记录路径int[] prev new int[amount 1]; Arrays.fill(prev, -1); // 在更新dp时记录 if (dp[i - coin] 1 dp[i]) { dp[i] dp[i - coin] 1; prev[i] coin; } // 回溯输出方案 ListInteger res new ArrayList(); int curr amount; while (curr 0 prev[curr] ! -1) { res.add(prev[curr]); curr - prev[curr]; }4. 动态规划的变种与扩展应用4.1 不同面值组合问题如果问题变成有多少种不同的组合方式状态定义就需要调整。此时dp[i]表示凑出金额i的方案数转移方程变为dp[i] dp[i - coin]初始化时dp[0]1凑0元有一种方案什么都不选其他为0。这种变体在面试中经常出现考察对状态定义的灵活运用。4.2 带限制条件的找零问题实际场景可能有限制条件比如每种硬币有数量限制不允许使用某些面值的组合需要优先使用大额硬币这类问题通常需要增加状态维度。例如限制硬币数量时可以把dp扩展为二维数组dp[i][j]其中j表示使用的硬币数量。4.3 从硬币到现实问题动态规划的思想可以应用到无数场景文本编辑距离计算类似硬币问题的二维版本背包问题硬币问题的物品价值变体股票买卖问题带状态机的动态规划游戏路径规划二维矩阵中的动态规划我曾用类似方法优化过一个物流配送系统将货物装载问题建模为硬币找零节省了15%的运输成本。这让我明白算法之美在于抽象真正掌握一个算法后你会发现它无处不在。