1. 从“暴力穷举”到“优雅递推”:动态规划的核心思想
如果你写过一些算法题,或者面试时被问到过“最长公共子序列”、“零钱兑换”这类问题,大概率听说过“动态规划”这个名字。它听起来很高深,很多初学者一看到状态转移方程就头疼。但在我看来,动态规划的本质,其实是一种用空间换时间的“备忘录”思想,核心目标是把一个看似复杂的大问题,拆解成一系列有重叠子问题的小问题,然后聪明地避免重复计算。
想象一下这个场景:你要计算斐波那契数列的第100项。最笨的方法是递归:fib(100) = fib(99) + fib(98),然后fib(99)又去计算fib(98)和fib(97)……你会发现fib(98)被计算了无数次,效率低得可怕。动态规划的做法是,开一个数组dp,从dp[1]=1, dp[2]=1开始,用循环一步步算出dp[3],dp[4]……直到dp[100]。每个值只算一次,结果存起来供后面使用。这个数组dp,就是我们的“备忘录”或者说“状态表”。
所以,动态规划不是某种具体的算法,而是一种解决问题的思想框架。它通常适用于具有“最优子结构”(大问题的最优解包含小问题的最优解)和“重叠子问题”特性的场景。今天,我就用Java带大家手撕三个经典到不能再经典的动态规划问题:Levenshtein编辑距离、0-1背包问题和旅行商问题。这三个问题分别代表了字符串处理、组合优化和图论中的经典DP应用,搞懂它们,你对DP的理解会上一个大台阶。我会从问题定义、为什么能用DP解、状态如何设计、递推方程怎么来,再到代码实现和优化,一步步拆开揉碎了讲。
2. Levenshtein编辑距离:量化字符串的“相似度”
编辑距离,也叫莱文斯坦距离,它衡量的是两个字符串之间,由一个转换成另一个所需的最少单字符编辑操作次数。允许的操作通常有三种:插入一个字符、删除一个字符、替换一个字符。这个概念在拼写检查、DNA序列比对、自然语言处理等领域应用极广。
2.1 问题定义与DP状态设计
假设我们有两个字符串:word1和word2,长度分别为m和n。我们的目标是求出将word1转换为word2所需的最少操作数。
为什么能用动态规划?我们考虑从两个字符串的开头逐步匹配到结尾。假设我们已经知道了word1的前i个字符转换成word2的前j个字符的最小编辑距离,记为dp[i][j]。那么,如何从这个“已知”的子问题,推导出dp[i][j]呢?这完全取决于word1的第i个字符(word1.charAt(i-1))和word2的第j个字符(word2.charAt(j-1))是否相等。
这里的状态设计很直观:dp[i][j]表示word1的前i个字符和word2的前j个字符之间的编辑距离。注意,i和j可以为零,代表空字符串。
2.2 状态转移方程的推导
状态转移是DP的灵魂。对于dp[i][j],我们有三种可能的“最后一步操作”:
- 删除:如果
word1的前i个字符已经能匹配word2的前j-1个字符,那么我只需要在word1末尾插入word2的第j个字符即可。这对应从状态dp[i][j-1]加上一次插入操作(成本为1)。所以,cost_insert = dp[i][j-1] + 1。 - 插入:如果
word1的前i-1个字符已经能匹配word2的前j个字符,那么我只需要删除word1的第i个字符即可。这对应从状态dp[i-1][j]加上一次删除操作(成本为1)。所以,cost_delete = dp[i-1][j] + 1。 - 替换:如果
word1的前i-1个字符已经能匹配word2的前j-1个字符,那么我只需要看word1的第i个字符和word2的第j个字符是否相同。- 如果相同,不需要额外操作,直接继承
dp[i-1][j-1]的值,cost_replace = dp[i-1][j-1]。 - 如果不同,则需要一次替换操作,
cost_replace = dp[i-1][j-1] + 1。
- 如果相同,不需要额外操作,直接继承
我们的目标是找最小操作数,所以dp[i][j]就是这三种可能情况的最小值。
初始化是DP的基石。dp[0][j]表示空字符串转换成word2的前j个字符,显然需要j次插入操作。同理,dp[i][0]表示word1的前i个字符转换成空字符串,需要i次删除操作。
2.3 Java实现与代码逐行解析
理解了原理,代码就水到渠成了。这里给出标准的二维DP实现。
public class LevenshteinDistance { public static int minDistance(String word1, String word2) { int m = word1.length(); int n = word2.length(); // dp[i][j] 表示 word1 前i个字符 和 word2 前j个字符 的编辑距离 int[][] dp = new int[m + 1][n + 1]; // 初始化:空串到空串距离为0,空串到长度为j的串需要j次插入 for (int i = 0; i <= m; i++) { dp[i][0] = i; // 删除所有字符 } for (int j = 0; j <= n; j++) { dp[0][j] = j; // 插入所有字符 } // 状态转移 for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { // 计算替换操作的代价 int replaceCost = (word1.charAt(i - 1) == word2.charAt(j - 1)) ? 0 : 1; // 状态转移方程 dp[i][j] = Math.min( dp[i - 1][j - 1] + replaceCost, // 替换或匹配 Math.min( dp[i - 1][j] + 1, // 删除 word1[i-1] dp[i][j - 1] + 1 // 插入 word2[j-1] ) ); } } return dp[m][n]; } public static void main(String[] args) { String word1 = "intention"; String word2 = "execution"; System.out.println("编辑距离是: " + minDistance(word1, word2)); // 输出 5 // 操作序列示例:intention -> inention (删除 t) -> enention (替换 i 为 e) // -> exention (替换 n 为 x) -> exection (替换 n 为 c) -> execution (插入 u) } }注意:在代码中,
dp数组的下标i和j对应的是前i/j个字符,因此访问字符串时索引是i-1和j-1,这是初学者最容易出错的地方之一。
2.4 空间优化与实战心得
上面的算法时间复杂度和空间复杂度都是O(m*n)。在很多实际场景中,比如比较长文档或实时拼写检查,m和n可能很大,O(m*n)的空间开销可能成为瓶颈。观察状态转移方程可以发现,dp[i][j]只依赖于上一行 (dp[i-1][...]) 和本行左边 (dp[i][j-1]) 的状态。因此,我们可以将二维数组压缩成两个一维数组(甚至一个,但需要临时变量)。
优化为两个一维数组:
public static int minDistanceOptimized(String word1, String word2) { int m = word1.length(); int n = word2.length(); int[] prev = new int[n + 1]; // 代表 dp[i-1][...] int[] curr = new int[n + 1]; // 代表 dp[i][...] // 初始化第一行(对应dp[0][j]) for (int j = 0; j <= n; j++) { prev[j] = j; } for (int i = 1; i <= m; i++) { curr[0] = i; // 初始化当前行的第一列(对应dp[i][0]) for (int j = 1; j <= n; j++) { int replaceCost = (word1.charAt(i - 1) == word2.charAt(j - 1)) ? 0 : 1; curr[j] = Math.min( prev[j - 1] + replaceCost, Math.min(prev[j] + 1, curr[j - 1] + 1) ); } // 滚动数组:当前行变为下一轮的“上一行” int[] temp = prev; prev = curr; curr = temp; // 或者直接重新 new int[n+1],但复用更环保 } return prev[n]; // 循环结束后,prev 指向最后一行 }实战心得:
- 理解优先于记忆:不要死记硬背
dp[i][j]的定义和方程。多画表格,手动推导一下dp[1][1],dp[1][2]是怎么算出来的,比看十遍代码都管用。 - 边界检查:务必处理好空字符串的情况,这是初始化环节的关键。
- 操作权重:标准的编辑距离每种操作成本为1。但在某些场景下(如OCR纠错,插入空格可能比替换字母更容易),你可以为插入、删除、替换赋予不同的权重,只需修改状态转移方程中的
+1为+weight即可。 - 回溯操作序列:如果不仅需要距离,还需要知道具体的操作步骤,可以在填表时额外维护一个
operation[][]数组,记录每个状态是从哪个操作转移过来的(插入、删除、替换/匹配),最后从dp[m][n]反向回溯即可。
3. 0-1背包问题:在约束中寻求价值最大化
0-1背包问题是动态规划的“必修课”。问题描述很简单:你有一个容量为W的背包,和N件物品。第i件物品的重量是weight[i],价值是value[i]。每件物品要么完整放入(1),要么不放入(0),不能分割。问在不超过背包容量的前提下,能装入物品的最大总价值是多少?
3.1 为什么是“0-1”以及DP状态设计
“0-1”指的就是物品的取舍状态,非0即1。这和我们后面会提到的“完全背包”(物品无限取用)、“多重背包”(物品有限个)形成对比。
我们定义状态dp[i][w]:表示考虑前i件物品(物品编号从1到i),在背包容量恰好为w时,所能获得的最大价值。这里“恰好为w”的定义有时会让初学者困惑,另一种更常见的定义是“容量不超过w”,两种定义在初始化上稍有不同,但核心思想一致。我这里采用“不超过”的定义,因为它更直观。
那么,dp[i][w]这个状态是怎么来的呢?对于第i件物品,我们只有两种选择:
- 不放入:那么最大价值就是考虑前
i-1件物品、容量为w时的最大价值,即dp[i-1][w]。 - 放入:前提是当前背包容量
w必须大于等于物品i的重量weight[i-1]。如果放入,那么背包剩余的容量为w - weight[i-1],这个剩余容量下考虑前i-1件物品能获得的最大价值是dp[i-1][w - weight[i-1]]。再加上物品i本身的价值value[i-1],总价值就是dp[i-1][w - weight[i-1]] + value[i-1]。
我们的目标是价值最大,所以dp[i][w]就是这两种选择中价值更大的那个。
3.2 从二维DP到一维优化的演进
我们先写出最直观的二维DP解法。
public class Knapsack01 { public static int maxValue(int W, int[] weight, int[] value) { int N = weight.length; // dp[i][w] 表示考虑前i件物品,在容量不超过w时的最大价值 int[][] dp = new int[N + 1][W + 1]; // 初始化:考虑0件物品时,任何容量下价值都为0 (dp[0][*] = 0) // Java数组默认初始化为0,所以可以省略显式初始化第一行 for (int i = 1; i <= N; i++) { int w_i = weight[i - 1]; int v_i = value[i - 1]; for (int w = 0; w <= W; w++) { // 默认选择:不放入物品i dp[i][w] = dp[i - 1][w]; // 如果可以放入物品i,则比较放入和不放入哪个更优 if (w >= w_i) { dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - w_i] + v_i); } } } return dp[N][W]; } public static void main(String[] args) { int W = 4; int[] weight = {2, 1, 3}; int[] value = {4, 2, 3}; System.out.println("最大价值: " + maxValue(W, weight, value)); // 输出 6 (物品1和物品2) } }现在,我们进行关键的空间优化。观察状态转移方程:dp[i][w]只依赖于dp[i-1][w]和dp[i-1][w - w_i]。也就是说,当前第i行的数据,完全由第i-1行的数据推导而来。那么,我们是否可以只用一个一维数组dp[w]来表示“当前考虑物品阶段”下,不同容量对应的最大价值呢?
答案是肯定的,但必须注意遍历顺序。如果我们从左到右遍历容量w,那么在计算dp[w]时,dp[w - w_i]可能已经被当前物品i更新过了(因为w - w_i小于w)。这相当于物品i被重复放入了多次,违背了“0-1”的原则。为了解决这个问题,我们必须从右向左遍历容量w。这样,在计算dp[w]时,dp[w - w_i]存储的仍然是“上一个物品阶段” (i-1) 的值,保证了每个物品只被考虑一次。
public static int maxValueOptimized(int W, int[] weight, int[] value) { int N = weight.length; // dp[w] 表示容量不超过w时的最大价值 int[] dp = new int[W + 1]; for (int i = 0; i < N; i++) { // 遍历每个物品 int w_i = weight[i]; int v_i = value[i]; // 关键:必须从右向左遍历容量 for (int w = W; w >= w_i; w--) { // 状态转移:dp[w] = max(不放入i, 放入i) // dp[w] 本身代表不放入i的旧值 // dp[w - w_i] + v_i 代表放入i dp[w] = Math.max(dp[w], dp[w - w_i] + v_i); } // 可以打印每一轮后的dp数组,观察变化 // System.out.println("考虑物品" + i + "后: " + Arrays.toString(dp)); } return dp[W]; }提示:内层循环的条件是
w >= w_i,因为容量小于物品重量时,根本不可能放入,dp[w]保持原值即可。
3.3 变种问题与常见陷阱
0-1背包的框架非常灵活,可以解决很多变种问题:
- 恰好装满背包的最大价值:只需修改初始化,令
dp[0]=0,其他dp[w]=-INF(负无穷,表示不可达)。最终dp[W]就是答案,若为-INF则表示无法恰好装满。 - 方案总数:问有多少种方式能恰好装满背包。将状态定义为方案数,
dp[0]=1,转移方程变为dp[w] += dp[w - w_i]。 - 最优方案的具体物品:需要额外记录路径,通常用一个二维布尔数组
path[i][w]记录在状态(i, w)时是否选择了物品i,最后从(N, W)回溯。
常见陷阱:
- 遍历顺序错误:一维优化时,忘记逆序遍历容量,导致变成“完全背包”。
- 下标混淆:物品数组下标从0开始,而
dp定义中的i从1开始,对应关系要清晰。在一维优化代码中,我直接用了i遍历物品数组,更简洁。 - 初始化理解不透:“不超过容量”和“恰好装满”的初始化完全不同,务必根据问题要求选择。
4. 旅行商问题:状态压缩DP的典型战场
旅行商问题是一个经典的NP-Hard问题:给定一系列城市和每对城市之间的距离,要求找到一条最短的路径,使得一个旅行商从某个城市出发,访问每个城市恰好一次,最后回到出发城市。
对于n个城市,暴力枚举所有排列((n-1)!)的复杂度是不可接受的。动态规划提供了一个O(n^2 * 2^n)的解法,虽然仍是指数级,但对于n <= 20左右的问题规模是可行的。其核心思想是状态压缩。
4.1 状态压缩:用比特位表示集合
我们如何用DP来描述“已经访问了哪些城市”这个状态呢?一个直观的想法是用一个集合S。但集合不好直接作为数组下标。状态压缩的精妙之处在于,用一个整数的二进制位来表示集合。假设有n个城市,编号为0到n-1。那么一个整数mask的二进制表示中,如果第i位是1,就表示城市i已经在集合S(即已访问)中。
例如,n=4,mask = 6(二进制0110),表示城市1和城市2已被访问,城市0和城市3未被访问。
我们定义状态dp[mask][i]:表示从城市0出发,已经访问过的城市集合为mask,并且当前位于城市i时,所走过的最短路径长度。这里我们固定从城市0出发,因为环路是闭合的,从任何城市出发结果都一样,固定一个起点可以简化问题。最终答案是访问完所有城市(mask的所有位都为1)后,从最后一个城市i回到城市0的距离之和的最小值,即min(dp[fullMask][i] + dist[i][0]),其中fullMask = (1 << n) - 1。
4.2 状态转移与算法实现
状态转移如何发生?考虑状态dp[mask][i],它表示我们已经以某种顺序走过了mask表示的城市集合,并且现在停在i。那么,这个状态可能是从哪个状态转移过来的呢?一定是从某个状态dp[mask_without_i][j]转移过来的,其中j是mask集合中(除了i以外的)某个城市,并且我们最后一步是从j走到了i。也就是说,我们之前访问了除i外的其他城市(集合为mask_without_i),停在了j,然后从j走到i,形成了当前状态。
因此,状态转移方程为:dp[mask][i] = min{ dp[mask_without_i][j] + dist[j][i] },对于所有j属于mask且j != i。 其中mask_without_i = mask ^ (1 << i),即把mask中代表城市i的位清零。
初始化:dp[1 << 0][0] = 0。表示从城市0出发,只访问了城市0(集合中只有城市0),当前就在城市0,走过的距离为0。
public class TSP { public static int tsp(int[][] dist) { int n = dist.length; if (n == 0) return 0; int fullMask = (1 << n) - 1; // 所有城市都访问过的状态,二进制位全为1 int INF = Integer.MAX_VALUE / 2; // 防止加法溢出 // dp[mask][i] int[][] dp = new int[1 << n][n]; for (int[] row : dp) Arrays.fill(row, INF); // 初始化:从城市0出发,只访问了城市0,当前位置是0,距离为0 dp[1][0] = 0; // 遍历所有状态mask for (int mask = 1; mask <= fullMask; mask++) { // 遍历所有可能当前所在城市i for (int i = 0; i < n; i++) { // 如果状态mask中不包含城市i,则这个dp状态无效,跳过 if ((mask & (1 << i)) == 0) continue; // 如果dp[mask][i]还是无穷大,说明这个状态还没被有效更新过,无法作为前驱 if (dp[mask][i] == INF) continue; // 尝试从当前状态(mask, i)出发,去下一个未访问的城市j for (int j = 0; j < n; j++) { // 如果城市j已经在mask中,跳过 if ((mask & (1 << j)) != 0) continue; int nextMask = mask | (1 << j); dp[nextMask][j] = Math.min(dp[nextMask][j], dp[mask][i] + dist[i][j]); } } } // 寻找答案:遍历所有城市i作为终点,加上从i回到起点0的距离 int ans = INF; for (int i = 0; i < n; i++) { if (dp[fullMask][i] != INF) { ans = Math.min(ans, dp[fullMask][i] + dist[i][0]); } } return ans; } public static void main(String[] args) { // 距离矩阵,dist[i][j]表示从i到j的距离 int[][] dist = { {0, 10, 15, 20}, {10, 0, 35, 25}, {15, 35, 0, 30}, {20, 25, 30, 0} }; System.out.println("最短回路长度: " + tsp(dist)); // 输出 80 (0->1->3->2->0) } }4.3 性能分析与优化方向
这个算法的时间复杂度是O(n^2 * 2^n),空间复杂度是O(n * 2^n)。当n=20时,2^20 ≈ 1e6,n^2=400,总操作量约4亿,在Java中勉强可算(需要几秒到几十秒)。n=25就非常吃力了。
一些优化思路:
- 记忆化搜索(递归+备忘录):对于某些状态子集,可能很多是无效或重复计算的。用递归函数
dfs(mask, i)配合memo[mask][i]数组,有时比递推更直观,且可以利用剪枝。 - 对称性剪枝:因为环路,路径
0->A->B->C->0和0->C->B->A->0长度相同。可以强制规定第二个访问的城市编号小于最后一个访问的城市编号,减少状态数。 - 启发式算法:对于更大的
n,DP就不适用了,需要转向模拟退火、遗传算法、蚁群算法等启发式算法求近似解。 - 使用更高效的数据结构:
dp数组可以用HashMap来存储有效状态,避免遍历大量无效的mask(特别是稀疏时)。但访问速度会下降,需要权衡。
实现细节注意:
dist矩阵需要提前准备好,如果给出的是坐标,则需要先计算欧几里得距离等。INF的值要设得足够大,但要避免加法溢出,所以用Integer.MAX_VALUE/2。- 循环中
if ((mask & (1 << i)) == 0) continue;这个判断至关重要,它确保了dp[mask][i]中的i必须属于集合mask,这是状态定义的一致性要求。
5. 举一反三:动态规划的思维训练
通过上面三个例子,我们可以看到动态规划虽然题目千变万化,但核心的思考步骤是相通的:
- 定义状态:这是最难也最关键的一步。状态需要能够描述问题的某个“阶段”或“局面”,并且包含做出后续决策所需的全部信息。通常状态参数包括:序列/数组的索引(如编辑距离的
i, j)、容量/限制条件(如背包的w)、集合或位掩码(如TSP的mask)。 - 找出状态转移方程:思考如何从已知的、规模更小的子问题(状态)的值,推导出当前状态的值。这通常对应着在当前位置做出的一个“决策”(编辑距离的三种操作、背包的放与不放、TSP的下一个城市选择)。
- 确定初始状态(边界条件):规模最小、不可再分的问题的解是什么?比如编辑距离中空串到空串,背包中考虑0件物品,TSP中从起点出发只访问了起点。
- 确定计算顺序:要保证在计算一个状态时,它所依赖的子状态都已经被计算出来。通常是循环遍历状态参数,从小到大。
- 考虑优化:主要是空间优化(滚动数组、降维),有时也有时间优化(斜率优化、四边形不等式等高级技巧,面试一般不要求)。
要掌握DP,光看懂例题不够,必须大量练习。建议从简单的序列型DP(如爬楼梯、最大子数组和)开始,再到背包问题,最后挑战区间DP、树形DP和状态压缩DP。每做一道题,都强迫自己把上述5个步骤在心里或纸上过一遍,尤其是状态定义和转移方程,要能清晰地讲出来。遇到难题没有思路时,一个实用的技巧是先假设状态dp[x]表示什么,然后倒推它可能从哪些状态转移过来,这往往能帮你找到正确的状态定义。
动态规划的魅力在于,它将指数级的暴力搜索,优化成了多项式级(有时是指数级但底数较小,如TSP)的优雅递推。这种化繁为简、通过记录历史来避免重复计算的思想,不仅适用于算法竞赛,在软件开发、系统设计(如缓存、Memoization模式)中也无处不在。希望这篇长文能帮你打通动态规划的任督二脉,在下次遇到相关问题时,能自信地说出:“这可以用DP解。”