动态规划——问题特征与步骤精解
动态规划是一种高效的算法思想,广泛应用于计算机科学和优化领域,能够有效解决复杂的决策和优化问题。其核心概念在于将一个复杂问题分解为多个简单的子问题,通过递推和记忆化搜索的方式避免重复计算,最终求得问题的最优解。动态规划主要依赖两大特性:最优子结构和重叠子问题。最优子结构指的是问题的最优解可以通过子问题的最优解递归构建,而重叠子问题则说明某些子问题会在多个阶段中被反复使用,动态规划通过保存这些子问题的解来提升效率。在实际应用中,动态规划被广泛用于路径规划、背包问题、股票买卖问题以及字符串匹配等场景。其算法能够有效降低时间复杂度,从指数级下降到多项式时间,使得原本耗时的问题能够快速解决。通过深入理解动态规划的原理,我们不仅能够更加灵活地设计出复杂问题的最优解算法,还能提升处理大型问题时的计算效率,从而在不同领域中广泛应用这一强大的工具。
一、动态规划问题的特征
最优化原理(最优子结构): 最优子结构是动态规划能够应用的基础。最优子结构意味着一个问题的最优解可以通过其子问题的最优解来构造。如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构。换句话说,当前问题的最优解可以通过对较小子问题的最优解进行组合而得出。
例子:在背包问题中,背包总容量为 ,物品集合为 个。我们可以将这个问题划分为两个子问题:是否选择某个物品 。如果选择该物品,问题就转化为容量为 的最优解;如果不选择,问题则变为容量为 时剩下 个物品的最优解。两个子问题的最优解决定了整体问题的最优解。
无后效性: 无后效性指的是某阶段的状态一旦确定,就不受这个状态以后决策的影响。这意味着某个状态所涉及的决策只依赖于当前的状态和前面的决策,而不会受到将来决策的影响。通俗地讲,一旦状态 被选定,那么系统从当前阶段 开始的后续行为只与 相关,与此前的决策无关。
例子:在路径规划问题中,选择到达某一个点 的最短路径不需要考虑之前是通过哪条路径到达的,只需要知道从点 到终点的距离以及经过 的最短路径即可。因此,当前阶段的决策只取决于当前状态,而不会依赖之前如何走到这个状态。
重叠子问题: 重叠子问题指的是在递归求解的过程中,不同的阶段可能会遇到相同的子问题。为了避免重复计算,可以将这些子问题的结果保存起来,以供后续使用,从而降低算法复杂度。相比于贪心算法或分治法,动态规划的优势在于重叠子问题的存在。
例子:在斐波那契数列求解问题中,计算 时需要计算 和 ,但是在计算 时又会再次计算 ,这就是重叠子问题。如果不进行保存,计算过程中会出现大量的重复计算。通过动态规划保存这些中间结果,可以极大地提高效率。
这些特征决定了动态规划这一类问题的可分性,从而可用递归算法展开求解。
二、动态规划求解的步骤
划分阶段: 将问题按照一定的顺序划分为若干个阶段,通常每个阶段表示某种子问题。划分阶段的原则是确保子问题之间可以递推,并且后续的决策只依赖当前的状态。
选择状态变量: 选择合适的状态变量来描述每个阶段的状态。状态变量要尽量全面地描述当前的情况,并确保能够通过状态转移方程递推。
建立状态转移方程: 确定状态之间的递推关系,即状态转移方程。状态转移方程是动态规划的核心,明确了如何从一个状态过渡到另一个状态。
定义边界条件: 为了递推求解,必须定义初始状态的解,通常是最小阶段的解。边界条件为动态规划的起始点,递推从这里开始。
自底向上求解: 动态规划采用自底向上的方式求解,即从最小的子问题开始递推,逐步解决更大的问题。通过存储子问题的解,避免重复计算,提高效率。
输出最优解: 最终,动态规划输出的是最优值函数的值,即问题的最优解。
三、动态规划问题求解
3.1 医疗队分配问题
世界卫生组织要求将五支医疗队分配到三个国家,分配的医疗队的数目对各国家的效益如表11.1所示。(注:效益指延长该国家人的寿命)
Medical Teams | Country 1 | Country 2 | Country 3 |
---|---|---|---|
0 | 0 | 0 | 0 |
1 | 45 | 20 | 50 |
2 | 70 | 45 | 70 |
3 | 90 | 75 | 80 |
4 | 105 | 110 | 100 |
5 | 120 | 150 | 130 |
- 动态规划问题四要素
阶段划分:该问题是分配有限的医疗团队给不同国家,以最大化额外的总寿命年数。阶段的划分可以根据国家数量来进行。每个国家代表一个阶段。我们要逐步分配给每个国家不同数量的医疗队。
决策变量: 表示第第 个国家分配的医疗队数量,取值范围为 0 到剩余的医疗队数量。
状态变量:状态变量可以表示在第 阶段(即第个国家)选择分配的医疗队数量。
状态转移方程:;;
- 动态规划递推方程
递推方程的核心就是根据上一阶段的状态和当前可做的决策,来确定当前阶段的最优解。设 为第 个国家分配后剩余 支医疗队所能获得的最大寿命年数。则递推关系为:
其中: 是给第 个国家分配 支医疗队获得的寿命年数(来自表格); 是对国家 的分配方案,即将 支医疗队分配给国家。
- 逆推求解过程
表格中给出的是不同国家在不同数量医疗队分配下的增益值。我们要从最后一个国家(国家3)开始,逆推各个阶段的最优分配。初始条件是我们有 5 支医疗队,可以分配给 3 个国家。我们从国家 3 开始,逆推国家 2 和国家 1 的最优分配。
阶段 3(国家 3): 对国家 3 的分配进行最大化计算:
0 | 1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|---|
0 | 50 | 70 | 80 | 100 | 130 | |
0 | 1 | 2 | 3 | 4 | 5 |
阶段 2(国家 2): 对国家 2 进行逆推时,要考虑分配给国家2 后,剩下的团队数量,以及剩余团队数可用于国家 3 所带来的收益。
0 | 1 | 2 | 3 | 4 | 5 | |||
0 | 0 | 0 | 0 | |||||
1 | 50 | 20 | 50 | 0 | ||||
2 | 70 | 70 | 45 | 70 | 0 or 1 | |||
3 | 80 | 90 | 95 | 75 | 95 | 2 | ||
4 | 100 | 100 | 115 | 125 | 110 | 125 | 3 | |
5 | 130 | 120 | 125 | 145 | 160 | 150 | 160 | 4 |
阶段 1(国家 1): 最后,计算国家 1 的分配时同样要考虑国家 2 和 3 的收益。
0 | 1 | 2 | 3 | 4 | 5 | |||
5 | 160 | 170 | 165 | 160 | 155 | 120 | 170 | 1 |
# 数据初始化
teams = 5 # 总的医疗队数量
countries = 3 # 总的国家数量
# 各个国家对不同数量医疗队的增益矩阵
# 第一维是国家,第二维是分配的医疗队数,值是获得的增益
gains = [
[0, 45, 70, 90, 105, 120], # 国家1
[0, 20, 45, 75, 110, 150], # 国家2
[0, 50, 70, 100, 110, 130] # 国家3
]
# 动态规划表格,用来存储在不同分配下的最大增益
dp = [[0] * (teams + 1) for _ in range(countries + 1)]
# path 用来记录分配方案
path = [[0] * (teams + 1) for _ in range(countries + 1)]
# 从最后一个国家开始逆推,动态规划过程
for c in range(1, countries + 1): # 遍历每个国家
for t in range(teams + 1): # 遍历每种医疗队分配情况
max_value = -1
best_team_allocation = 0
# 遍历当前国家可分配的每种医疗队数量
for x in range(t + 1):
current_value = gains[c-1][x] + dp[c-1][t-x]
if current_value > max_value:
max_value = current_value
best_team_allocation = x
dp[c][t] = max_value
path[c][t] = best_team_allocation
# 输出最大增益
print("最大寿命年数:", dp[countries][teams])
# 输出最优的分配方案
t = teams
allocation = []
for c in range(countries, 0, -1):
allocation.append((f"国家{c}", path[c][t]))
t -= path[c][t]
allocation.reverse() # 倒序输出
for country, team_alloc in allocation:
print(f"{country} 分配医疗队数量: {team_alloc}")
最大寿命年数: 170
国家1 分配医疗队数量: 1
国家2 分配医疗队数量: 3
国家3 分配医疗队数量: 1
3.2 资源分配问题
按问题的变量个数划分阶段,把它看作为一个三阶段决策问题。设状态变量为 , 并记 ; 取问题中的变量 为决策变量; 各阶段指标函数按乘积方式结合。令最优值函数 表示第 阶段的初始状态为 , 从 阶段到 3 阶段所得到的最大值。
则有
用逆推解法, 从后向前依次有
由 , 得 和 (舍去)
又 , 而 , 故 为极大值点。
所以 及最优解 。
同样利用微分法易知 , 最优解 。
由于 已知, 因而按计算的顺序反推算, 可得各阶段的最优决策和最优值。即
由
所以
由
所以
因此得到最优解为: ;
最大值为: 。
from scipy.optimize import minimize
def objective(u):
"""目标函数"""
u1, u2, u3 = u
return -(u1 * u2**2 * u3) # 负号表示求最大值
def constraint(u, c):
"""约束条件: u1 + u2 + u3 = c"""
return c - sum(u)
# 参数设置
c = 10 # 可以调整
u0 = [1, 1, 1] # 初始猜测值
# 设置约束
con = {'type': 'eq', 'fun': lambda u: constraint(u, c)}
# 边界条件,u1, u2, u3 都必须大于等于 0
bnds = [(0, None), (0, None), (0, None)]
# 求解优化问题
solution = minimize(objective, u0, method='SLSQP', bounds=bnds, constraints=con)
# 输出结果
u1, u2, u3 = solution.x
max_value = -solution.fun # 注意这里取负,因为之前目标函数前面加了负号
# 格式化输出保留两位小数
print(f"最优解: u1 = {u1:.2f}, u2 = {u2:.2f}, u3 = {u3:.2f}")
print(f"最大值: z = {max_value:.2f}")
#注意c=10
最优解: u1 = 2.50, u2 = 5.00, u3 = 2.50
最大值: z = 156.25
3.3 机器分配问题
某公司有 5 台新设备, 将有选择地分配给 3 个工厂, 所得收益如下表 (表中"—"表示不存在这样的方案)。请用动态规划求出收益最大的分配方案。
新设备台数 | 工厂1 | 工厂2 | 工厂3 |
---|---|---|---|
0 | 0 | 0 | 0 |
1 | - | - | 4 |
2 | 6 | 5 | 7 |
3 | 8 | 7 | 10 |
4 | 9 | 9 | - |
5 | - | - | - |
考虑问题的顺序:
建立动态规划数学模型:
(1) 阶段
(2) 状态
(3) 决策:
(4) 状态转移方程:
(5) 指标函数:
(6) 指标递推方程:
利用表格计算,当 时,
0 | 1 | 2 | 3 | 4 | 5 | |||
0 | 0 | 0 | 0 | |||||
1 | 4 | 4 | 1 | |||||
2 | 7 | 7 | 2 | |||||
3 | 10 | 10 | 3 | |||||
4 | - | - | - | |||||
5 | - | - | - |
当 时, 。
0 | 1 | 2 | 3 | 4 | 5 | |||
0 | 0 | 0 | ||||||
1 | - | 4 | 0 | |||||
2 | - | 7 | 0 | |||||
3 | - | 10 | 3 | |||||
4 | — | - | 12 | 2 | ||||
5 | — | - | — | 15 | 2 |
当 时, 。
0 | 1 | 2 | 3 | 4 | 5 | |||
5 | - | - | 16 | 2 |
所以
# 定义收益表,profit[i][j] 表示 i 台设备分配到第 j 个工厂的收益,-50表示没有这个方案
profit = [
[0, 0, 0], # 0台设备分配
[-50, -50, 4], # 1台设备分配
[6, 5, 7], # 2台设备分配
[8, 7, 10], # 3台设备分配
[9, 9, -50], # 4台设备分配
[-50, -50, -50] # 5台设备分配
]
# 总设备数量
total_equipment = 5
# 工厂数量
factories = 3
# dp[i][j] 表示分配 i 台设备给前 j 个工厂的最大收益
dp = [[0] * (factories + 1) for _ in range(total_equipment + 1)]
# track_back 用于记录设备分配方案
track_back = [[0] * (factories + 1) for _ in range(total_equipment + 1)]
# 动态规划求解
for j in range(1, factories + 1): # 遍历工厂
for i in range(1, total_equipment + 1): # 遍历设备
for k in range(i + 1): # 分配 k 台设备给当前工厂
if k <= len(profit) - 1 and profit[k][j - 1] != -50: # 保证可行的分配方案
current_profit = dp[i - k][j - 1] + profit[k][j - 1]
if current_profit > dp[i][j]:
dp[i][j] = current_profit
track_back[i][j] = k # 记录当前工厂分配了 k 台设备
# 输出最大收益
print(f"最大收益: {dp[total_equipment][factories]}")
# 追踪最优分配方案
solution = []
remaining_equipment = total_equipment
for j in range(factories, 0, -1):
allocated_equipment = track_back[remaining_equipment][j]
solution.append((j, allocated_equipment))
remaining_equipment -= allocated_equipment
solution.reverse() # 反转得到正确的顺序
print("最优分配方案: ")
for factory, num_equipment in solution:
print(f"工厂 {factory}: 分配 {num_equipment} 台设备")
最大收益: 16
最优分配方案: 工厂 1: 分配 2 台设备;工厂 2: 分配 0 台设备;工厂 3: 分配 3 台设备
3.4 青蛙跳台阶
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 10 级的台阶总共有多少种跳法。
假设青蛙跳上第级台阶,有两种方式:从第级跳上来;从第级跳上来。因此,要跳上第级台阶的总跳法数等于跳上第级台阶的跳法数加上跳上第级台阶的跳法数。动态规划建模过程如下:
- 阶段(Stage):每一阶段对应跳上某一级台阶。共有阶台阶,从 1 到阶。
- 状态(State):状态可以用表示跳上第级台阶的总跳法数。
- 状态转移方程(State Transition Equation):如上分析,状态转移方程为:
- 边界条件:
- 跳上第 1 级台阶时,只有一种跳法,即;
- 跳上第 2 级台阶时,有两种跳法,即(可以一次跳 1 级,也可以一次跳 2 级)。
- 根据状态转移方程和边界条件,递推方程为:
def frog_jumps(n):
# 边界条件
if n == 1:
return 1
elif n == 2:
return 2
# 动态规划数组
dp = [0] * (n + 1)
dp[1] = 1 # 跳上第 1 级台阶只有 1 种方法
dp[2] = 2 # 跳上第 2 级台阶有 2 种方法
# 递推求解每一级台阶的跳法数
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# 求解青蛙跳上 10 级台阶的跳法总数
n = 10
result = frog_jumps(n)
print(f"跳上 {n} 级台阶的总跳法数为:{result}")
3.5 矩形最小路径和
矩形最小路径和问题是经典的动态规划问题。题目通常要求我们从一个矩形网格的左上角出发,经过某些路径到达右下角,使得经过的路径上的数值和最小。只能向下或者向右移动。假设一个的网格,其每个格子中的数值表示路径的权重,网格数据如下:
[1, 3, 1, 4, 2]
[1, 5, 1, 2, 3]
[4, 2, 1, 7, 2]
[1, 2, 3, 1, 1]
设 表示到达坐标 的最小路径和。
状态转移方程
- 若 ,则 (只能从左边过来)
- 若 ,则 (只能从上面过来)
- 否则,(可以从上方或左方过来,取较小者)
初始状态:
目标:计算出 ,即从左上角到右下角的最小路径和。
def minPathSumWithTrace(grid):
rows = len(grid)
cols = len(grid[0])
# 创建 dp 数组和路径数组
dp = [[0 for _ in range(cols)] for _ in range(rows)]
path = [[None for _ in range(cols)] for _ in range(rows)] # 用于记录路径
# 初始化起点
dp[0][0] = grid[0][0]
path[0][0] = (-1, -1) # 用于标记起点
# 初始化第一行
for j in range(1, cols):
dp[0][j] = dp[0][j-1] + grid[0][j]
path[0][j] = (0, j-1) # 只能从左边过来
# 初始化第一列
for i in range(1, rows):
dp[i][0] = dp[i-1][0] + grid[i][0]
path[i][0] = (i-1, 0) # 只能从上边过来
# 填充 dp 数组
for i in range(1, rows):
for j in range(1, cols):
# 比较从上方和左方的路径,选择最小的
if dp[i-1][j] < dp[i][j-1]:
dp[i][j] = dp[i-1][j] + grid[i][j]
path[i][j] = (i-1, j) # 记录从上方来的路径
else:
dp[i][j] = dp[i][j-1] + grid[i][j]
path[i][j] = (i, j-1) # 记录从左方来的路径
# 回溯路径
min_path = []
i, j = rows - 1, cols - 1 # 从右下角开始
while (i, j) != (-1, -1): # 一直到起点
min_path.append((i, j))
i, j = path[i][j]
# 返回最小路径和以及路径(逆序后输出路径)
return dp[rows-1][cols-1], min_path[::-1] # 逆序输出路径
# 测试数据
grid = [
[1, 3, 1, 4, 2],
[1, 5, 1, 2, 3],
[4, 2, 1, 7, 2],
[1, 2, 3, 1, 1]
]
# 调用函数,输出最小路径和和路径
result, min_path = minPathSumWithTrace(grid)
print("矩形最小路径和为:", result)
print("最小路径为:", min_path)
3.6 打家劫舍问题(House Robber)
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
动态规划分析
策略选择:对于第个房屋,有两种选择:
- 不偷第个房屋,那么可以偷第个房屋的最大金额。
- 偷第个房屋,那么不能偷第个房屋,只能偷第个房屋的最大金额,并加上当前房屋的金额。
状态转移方程:令表示偷到第个房屋时的最大金额。
- 如果不偷第个房屋,最大金额是。
- 如果偷第个房屋,最大金额是。
因此状态转移方程为:
边界条件:
- 当只有一个房屋时,最大金额为。
- 当有两个房屋时,最大金额是。
递推方程:我们从第三个房屋开始遍历,使用上述状态转移方程递推,最终可以求得最大金额。
def rob(nums):
# 如果房屋数量为 0,直接返回 0
if not nums:
return 0
n = len(nums)
# 如果只有一个房屋,则直接返回该房屋的金额
if n == 1:
return nums[0]
# dp数组,dp[i] 表示偷窃到第 i 个房屋时的最大金额
dp = [0] * n
# 初始化前两个房屋的最大金额
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
# 从第三个房屋开始进行状态转移
for i in range(2, n):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
# 返回最后一个房屋的最大金额
return dp[-1]
# 测试
nums = [2, 7, 9, 3, 1]
print("能够偷窃到的最高金额:", rob(nums))
3.7 雇佣工人
某工厂在一年四季对工人的需求量不同,下表中展示了每季对工人的最低需求,若实际雇佣人数多于最低需求则要为多雇的工人支付2000美元工资。换季时每次雇佣或解雇工人均需支付()元的手续费。
Season | Spring | Summer | Autumn | Winter | Spring |
---|---|---|---|---|---|
Requirements | 255 | 220 | 240 | 200 | 255 |
这个问题可以看作是四阶段的动态规划问题。
决策变量 表示该阶段需要雇佣的人数
状态变量 表示上一阶段的雇佣人数,即
表示每季对工人的最低需求
生产从春季开始,因此春季雇佣的人数是确定的 255 人,即 ,而由于由于这四个季节是一个循环,且最后一阶段的最优值必须是已知的或者不依㭥于其他阶段,因此我们将春季作为最后一个阶段。可以列出春季的决策表:
Range | ||
---|---|---|
255 |
其中的 为冬季的雇佣人数,需满足最低需求且没必要大于各季最低需求的上界。
接着可以列出上一个阶段 (冬季) 的决策表
Range | ||
---|---|---|
其中
可以看作已知,因此可以求出 最优值的表达式
解得
带入 中即可。注意,因为 即为 ,因此需要检查一下 的范围,此处符合要求。
进入上一个阶段 (秋季)
Range | ||
---|---|---|
240 |
同理,先写出 的表达式
将 看作已知,求出 最优值的表达式
注意,此处 的范围是 ,但 的范围应当是 ,对应 的范围是 ,因此对 分段,分别求出 最优值的表达式。当 的范围是 时,
因此 时函数取最小值
最后是第一阶段 (夏季) 的决策表
255 | 185,000 | 247.5 |
其中 是已知的春季雇佣人数
因为 是分段的, 所以
对每一段分别求出最小值,并进行比较
对 ,
因此
即 时取最小值
对 ,
解得
将两个 值分别代入比较,发现 时 的值更小,是 185,000。因此这个问题就解决了,最优解为 。
总结
动态规划是一种高效的算法思想,在计算机科学和优化问题领域得到了广泛的应用。其核心在于解决具有最优子结构和重叠子问题的问题。最优子结构意味着一个问题的最优解可以通过其子问题的最优解递归构建,这使得问题可以被逐步分解为较小的子问题来求解。而重叠子问题的特性使得相同的子问题在递归过程中可能会被多次求解,因此动态规划通过保存子问题的解来避免重复计算,大大提高了算法的效率。动态规划通常通过构建一个递推关系式(状态转移方程)来求解问题,自底向上地逐步计算每个子问题的最优解,并最终获得整体问题的解。典型的动态规划问题包括背包问题、最短路径、股票买卖等。它的效率优势在于将原本指数级复杂度的问题,降为多项式时间复杂度,是解决复杂优化问题的有效方法。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!