动态规划(Dynamic Programming)是一种通过将复杂问题分解为子问题来解决优化问题的算法思想。它适用于具有“最优子结构”和“重叠子问题”性质的问题。比如,从面额不定的20个硬币中任意选取多个凑成20元,求怎样选取硬币才可以使最后选取的硬币数最少又刚好凑够了20元。这是一个典型的动态规划问题。
优点
缺点
(1)分析问题是否满足以下条件:
(2) 如果满足上述条件,则可以考虑使用动态规划。
(1)状态是动态规划的核心,表示问题在某一阶段的状态。
(2)状态通常由一个或多个变量表示,例如:
dp[i]:表示前 i 个元素的某种最优值。
dp[i][j]:表示从第 i 到第 j 的某种最优值。
根据问题的逻辑,推导出如何从子问题的解转移到当前问题的解。
状态转移方程通常形式为:
dp[i] = f(dp[i-1], dp[i-2], …, 其他信息)
或者是
dp[i][j] = f(dp[i-1][j], dp[i][j-1], …, 其他信息)
确定动态规划的初始状态(即最简单的子问题)。
边界条件通常是问题的起点,例如:
dp[0] = 0
dp[i][0] = 某个值
根据状态转移方程,确定状态的计算顺序。
通常按照从小到大的顺序计算(如从左到右、从上到下)。
动态规划的最终结果通常存储在某个特定的状态中,例如 dp[n] 或 dp[m][n]。
(1) 定义状态
根据问题的特点,选择合适的状态表示方法。
状态的选择直接影响后续的状态转移方程。
(2) 推导状态转移方程
找到状态之间的关系,明确如何从子问题的解推导出当前问题的解。
(3) 初始化
设置动态规划的初始值,确保状态转移方程能够正确运行。
(4) 填表(自底向上)
使用迭代的方式填充动态规划表,避免递归带来的栈溢出问题。
(5) 输出结果
根据问题要求,返回最终结果。
问题描述:计算第 n 个斐波那契数。
状态定义:dp[i] 表示第 i 个斐波那契数。
状态转移方程:
dp[i] = dp[i-1] + dp[i-2]
初始化:
dp[0] = 0, dp[1] = 1
解答:
def fibonacci(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[0], dp[1] = 0, 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# 调用函数
print(fibonacci(10))
# 输出结果:55
问题描述:给定一个数组,求最长的严格递增子序列的长度。
状态定义:dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。
状态转移方程:
dp[i] = max(dp[j] + 1) for j in range(i) if nums[j] < nums[i]
初始化:
dp[i] = 1 for all i
解题:
def lengthOfLIS(nums):
n = len(nums)
dp = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
# 调用函数
nums = [10, 9, 2, 5, 3, 7, 101, 18]
print(lengthOfLIS(nums))
# 输出结果:4
问题描述:给定物品重量和价值,以及背包容量,求能装入背包的最大价值。
状态定义:dp[i][j] 表示前 i 个物品在容量为 j 时的最大价值。
状态转移方程:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) if j >= w[i] else dp[i-1][j]
初始化:
dp[0][j] = 0 for all j
解题:
def knapsack(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(capacity + 1):
if j >= weights[i - 1]:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1])
else:
dp[i][j] = dp[i - 1][j]
return dp[n][capacity]
# 调用函数
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 8
print(knapsack(weights, values, capacity))
# 输出结果:10
问题描述:给定两个字符串,求将一个字符串转换为另一个字符串所需的最少操作次数(插入、删除、替换)。
状态定义:dp[i][j] 表示将字符串 word1[:i] 转换为 word2[:j] 所需的最少操作次数。
状态转移方程:
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
初始化:
dp[i][0] = i, dp[0][j] = j
解题:
def minDistance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
return dp[m][n]
# 调用函数
word1 = "horse"
word2 = "ros"
print(minDistance(word1, word2))
# 输出结果:3
计算机解决问题其实没有任何特殊的技巧,它唯一的解决办法就是穷举,穷举所有可能性。
因此,可以将动态规划的解题套路概括为以下几个步骤:
(1)确定问题是否适合动态规划。
(2)明确状态定义。
(3)推导状态转移方程。
(4)初始化边界条件。
(5)确定计算顺序并填表。
(6)返回最终结果。
(1)状态定义要清晰
状态的选择应尽量简洁且能涵盖所有可能的情况。
(2)状态转移方程要准确
确保状态转移方程覆盖所有可能的子问题,并且逻辑正确。
(3)初始化不能遗漏
初始化是动态规划的基础,错误的初始化会导致整个算法失败。
(4)优化空间复杂度
在某些情况下,可以通过滚动数组或单变量优化空间复杂度。
最后,动态规划,我认为最核心的就是列出状态转移方程,而这个过程就是在解决“如何穷举”的问题。且这一步也比较困难和不容易理解,之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。