动态规划(Dynamic Programming)是算法面试中的高频考点,也是许多实际工程问题的解决方案。今天我们将深入探讨三个经典问题:零钱兑换、完全平方数和单词拆分。这三个问题看似不同,实则都体现了动态规划的核心思想——将复杂问题分解为子问题,通过存储和重用子问题的解来提高效率。
对于准备技术面试的同学来说,这三个问题都来自LeetCode的热门题库(Hot100),掌握它们不仅能帮助你在面试中脱颖而出,更能培养解决实际问题的思维模式。我们将从问题分析、状态定义、转移方程到代码实现,一步步拆解每个问题的解决思路。
在深入具体问题前,让我们先建立动态规划解题的标准框架。这套方法论适用于绝大多数动态规划问题,是面试中的解题利器。
dp数组是动态规划的核心数据结构,其设计直接影响问题的可解性。定义时需要明确:
例如在零钱兑换问题中,我们使用一维数组dp,其中dp[i]表示凑出金额i所需的最少硬币数。
状态转移方程是动态规划的灵魂,它描述了问题如何从较小规模的子问题构建出更大规模的解。推导时需要考虑:
合理的初始化是正确求解的基础。需要注意:
遍历顺序影响状态转移的正确性。需要考虑:
在编码前,建议通过具体例子手动模拟dp数组的填充过程。这能帮助:
零钱兑换问题(LeetCode 322)要求找出凑成指定金额所需的最少硬币数量。给定不同面额的硬币和一个总金额,如果没有任何硬币组合能组成该金额,则返回-1。
输入:coins = [1,2,5], amount = 11
输出:3(5+5+1)
这是一个典型的完全背包问题:
我们定义dp数组:
关键点:
java复制class Solution {
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 j = coin; j <= amount; j++) {
if (dp[j - coin] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j], dp[j - coin] + 1);
}
}
}
return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}
}
时间复杂度:O(n×amount),其中n是硬币种类数
空间复杂度:O(amount)
优化方向:
注意:必须检查dp[j-coin]是否为MAX_VALUE,否则+1会导致溢出
完全平方数问题(LeetCode 279)要求找出和为n的完全平方数的最少数量。例如n=12,返回3(4+4+4)。
这个问题可以转化为:
对于每个完全平方数s=k²:
dp[i] = min(dp[i], dp[i-s]+1)
java复制class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int k = 1; k * k <= n; k++) {
int s = k * k;
for (int j = s; j <= n; j++) {
if (dp[j - s] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j], dp[j - s] + 1);
}
}
}
return dp[n];
}
}
这个问题实际上有数学上的最优解,基于拉格朗日四平方和定理:
因此最优解只可能是1、2、3或4。基于这个性质,可以设计更高效的算法。
单词拆分问题(LeetCode 139)给定一个字符串s和一个单词字典,判断s是否能被拆分为字典中单词的空格分隔序列。
示例:
输入:s = "leetcode", wordDict = ["leet","code"]
输出:true
对于每个i,检查所有j < i:
dp[i] = dp[j] && (s[j..i-1] in wordDict)
java复制class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> dict = new HashSet<>(wordDict);
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i && !dp[i]; j++) {
if (dp[j] && dict.contains(s.substring(j, i))) {
dp[i] = true;
}
}
}
return dp[n];
}
}
在实际面试中,建议先阐述暴力解法,再引出动态规划思路,最后讨论优化空间。清晰的解题思路比完美的代码更重要。