动态规划入门 | 青训营

117 阅读6分钟

简介

动态规划(Dynamic programming,简称 DP),是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。

这个来自维基百科的定义有点抽象,简单来说,动态规划其实就是,给定一个问题,我们把它拆成一个个子问题,直到子问题可以直接解决。然后呢,把子问题答案保存起来,以减少重复计算。再根据子问题答案反推,得出原问题解的一种方法。

本文接下来从一个例子出发,探索其记忆化搜索的解法,再逐步转化为动态规划。

编辑距离

问题是 Leetcode 72. 编辑距离

给你两个单词 word1word2请返回将 word1 转换成 word2 所使用的最少操作数

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

示例 1:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例 2:

输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')

提示:

  • 0 <= word1.length, word2.length <= 500
  • word1word2 由小写英文字母组成

递归

为了解决这个问题,我们可以使用递归的方法。我们定义一个辅助函数 helper(i, j),表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符所需的最少操作次数。我们可以通过以下步骤递归地计算 helper(i, j)

  • 如果 i == 0,说明 word1 为空,我们需要插入 j 个字符使得 word1 变为 word2,所以 helper(i, j) = j

  • 如果 j == 0,说明 word2 为空,我们需要删除 i 个字符使得 word1 变为 word2,所以 helper(i, j) = i

  • 如果 word1[i-1] == word2[j-1],那么我们不需要进行任何操作,所以 helper(i, j) = helper(i-1, j-1)

  • 否则,我们需要执行插入、删除或替换操作中的一种。我们选择这三种操作中所需操作次数最少的那个,即:

    helper(i, j) = min(helper(i-1, j), helper(i, j-1), helper(i-1, j-1)) + 1
    

    其中,helper(i-1, j) 对应删除操作,helper(i, j-1) 对应插入操作,helper(i-1, j-1) 对应替换操作。

记忆化搜索

递归方法可能会导致重复计算相同子问题,从而降低算法的效率。为了解决这个问题,我们可以使用记忆化搜索的方法。具体实现如下:

  1. 初始化一个二维数组 memo,用于存储已计算的结果。将所有 memo[i][j] 初始化为 -1。
  2. 定义 helper 函数,输入参数为 i、jmemo。在计算 helper(i, j) 之前,首先检查 memo[i][j] 是否已经计算过。如果已经计算过,直接返回 memo[i][j]。否则,按照上述递归思路计算 helper(i, j),并将结果存储在 memo[i][j] 中。
  3. 调用 helper(len(word1), len(word2), memo) 计算最终结果。

以下是使用 Golang 实现的代码:

func minDistance(word1 string, word2 string) int {
	m, n := len(word1), len(word2)
	memo := make([][]int, m+1)
	for i := 0; i <= m; i++ {
		memo[i] = make([]int, n+1)
		for j := 0; j <= n; j++ {
			memo[i][j] = -1
		}
	}
	return helper(word1, word2, m, n, memo)
}

func helper(word1, word2 string, i, j int, memo [][]int) int {
	if i == 0 {
		return j
	}
	if j == 0 {
		return i
	}
	if memo[i][j] != -1 {
		return memo[i][j]
	}

	if word1[i-1] == word2[j-1] {
		memo[i][j] = helper(word1, word2, i-1, j-1, memo)
	} else {
		memo[i][j] = int(math.Min(math.Min(float64(helper(word1, word2, i-1, j, memo)), float64(helper(word1, word2, i, j-1, memo))), float64(helper(word1, word2, i-1, j-1, memo)))) + 1
	}

	return memo[i][j]
}

动态规划

记忆化搜索是一种自上而下的方法,大的问题分解成小的,解决后合成原来大的问题的答案。既然能自上而下,那就能自下而上,这就是动态规划。

  1. 初始化一个二维数组 dp,其中 dp[i][j] 表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符所需的最少操作次数。
  2. 初始化 dp 数组的第一行和第一列。对于所有 0 <= i <= len(word1),dp[i][0] = i;对于所有 0 <= j <= len(word2),dp[0][j] = j。这是因为当 word1 或 word2 为空时,我们需要插入或删除若干字符使得两个字符串相等。
  3. 使用嵌套循环遍历 dp 数组,计算每个 dp[i][j] 的值。遍历顺序为从左到右,从上到下。根据递归思路,我们可以得到以下状态转移方程:
    • 如果 word1[i-1] == word2[j-1],那么 dp[i][j] = dp[i-1][j-1]
    • 否则,dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
  4. dp[len(word1)][len(word2)] 即为最终结果。

Golang 代码

func minDistance(word1 string, word2 string) int {
	m, n := len(word1), len(word2)
	dp := make([][]int, m+1)
	for i := 0; i <= m; i++ {
		dp[i] = make([]int, n+1)
	}

	for i := 0; i <= m; i++ {
		dp[i][0] = i
	}
	for j := 0; j <= n; j++ {
		dp[0][j] = j
	}

	for i := 1; i <= m; i++ {
		for j := 1; j <= n; j++ {
			if word1[i-1] == word2[j-1] {
				dp[i][j] = dp[i-1][j-1]
			} else {
				dp[i][j] = int(math.Min(math.Min(float64(dp[i-1][j]), float64(dp[i][j-1])), float64(dp[i-1][j-1]))) + 1
			}
		}
	}

	return dp[m][n]
}

这个问题里,动态规划的空间还能继续优化,优化至 2*n,因为 dp[i][j] 只与上一轮迭代的 dp[i-1][j]dp[i-1][j-1] 以及这一轮的上一个 dp[i][j-1] 有关;还能优化至一维。

小结

当我们拿到动态规划问题时,从记忆化搜索思考是很好的切入点,因为从整体的大问题出发,更容易把它分解成更小的问题,即列出最重要的动态转移方程。动态规划与记忆化搜索本质基本一样,都是带记忆(缓存)的暴力搜索,二者各有各的优点:

  • 记忆化搜索可能面临很深的递归函数调用栈,造成溢出,本身入栈出栈也消耗性能,而动态规划在一个函数哪递推,能节省递归调用带来的开销。
  • 动态规划由小问题递推解决更大的问题,不能跳过一些在问题背景下不可能的case,而记忆化搜索在一些问题里通过判断能起到剪枝的效果。记忆化搜索也能支持非整数的问题。

所以二个方法没有绝对优劣,具体问题具体分析。

参考

教你一步步思考动态规划!

动态规划基础