贝尔曼方程实战:用Python手把手教你实现动态规划求解最短路径问题
如果你曾经在算法面试或者实际项目中遇到过“最短路径”问题,大概率会想到Dijkstra或者A*这类经典算法。但今天我想带你换个视角,从动态规划和贝尔曼方程的角度重新审视这个问题。这不仅仅是理论上的炫技,在实际项目中,当问题规模可控、状态转移明确时,用动态规划实现最短路径求解,代码往往更简洁、逻辑更统一,甚至能自然扩展到更复杂的随机决策场景中。
我在处理一些物流调度和游戏AI寻路模块时,就曾多次采用这个思路。相比于直接调用现成的图算法库,自己实现一套基于值迭代的动态规划求解器,能让我对问题有更深的掌控力,方便后续加入各种定制化的约束和奖励机制。这篇文章,我就把自己在实战中积累的经验和代码细节分享给你,目标是让你读完就能动手实现一个属于自己的、可扩展的“动态规划路径规划器”。
1. 从最短路径到马尔可夫决策过程:重新定义问题
我们通常理解的网格世界最短路径问题很简单:给定一个二维网格,有些格子是障碍物,从起点到终点,找一条移动步数最少的路径。动作通常是上、下、左、右四个方向。这本质上是一个确定性的寻路问题。
但如果我们用马尔可夫决策过程的框架来建模,就能为这个问题注入新的可能性。在MDP中,我们不仅有状态(网格位置)和动作,还有转移概率和奖励函数。在经典的最短路径问题中,我们可以做如下设定:
- 状态集合 S:所有非障碍物的网格坐标,加上一个特殊的终止状态(比如到达终点后的状态)。
- 动作集合 A:
['up', 'down', 'left', 'right']。 - 转移概率 P:在确定性环境中,执行一个动作会确定性地到达预期相邻格子(如果该格子有效且非障碍)。为了普适性,我们也可以建模一个小的随机性,比如有0.1的概率滑向其他方向。
- 奖励函数 R:每走一步,给予一个小的负奖励(例如-1),代表时间或成本的消耗。到达终点给予一个大的正奖励(例如0),而撞到障碍物或边界则给予一个负奖励(例如一个较大的负值,或者直接停留在原地并给予惩罚)。
这样定义后,寻找最短路径就转化为了寻找一个能最大化累计奖励的策略。而贝尔曼方程正是描述状态价值与后续状态价值之间递归关系的核心方程。对于最优价值函数 V*(s),贝尔曼最优方程告诉我们:
V*(s) = max_a [ R(s, a) + γ * Σ_{s'} P(s' | s, a) * V*(s') ]
其中γ是折扣因子,在最短路径问题中通常设为1(或非常接近1),因为我们关心总步数,未来的每一步代价都与当前同等重要。但在某些需要避免无限循环的场景,可设为略小于1的值,如0.99。
注意:在完全确定性的最短路径问题中,转移概率P是退化的(非0即1),贝尔曼方程会简化为更简单的形式:
V*(s) = max_a [ R(s, a) + V*(s_next) ]。但我们依然按通用MDP框架实现,以保持代码的扩展性。
为了更清晰地对比传统视图与MDP视图下的问题要素,我整理了下面这个表格:
| 要素 | 传统最短路径视图 | MDP/强化学习视图 | 在本实践中的具体设定 |
|---|---|---|---|
| 目标 | 最小化从起点到终点的总步数或距离。 | 最大化从起点到终点的累计折扣奖励。 | 每步奖励-1,终点奖励0,最小化总步数等价于最大化总奖励(负值)。 |
| 状态 | 网格坐标 (x, y)。 | 智能体所在的环境状况,包括坐标。 | 二维网格索引 (i, j),并区分是否为终止状态。 |
| 动作 | 移动方向。 | 智能体可以做出的决策。 | {上,下,左,右},每个动作尝试改变坐标。 |
| 转移 | 确定性的位置变化。 | 以某种概率分布到达新状态。 | 默认设定为确定性转移(概率1.0)。代码支持定义随机转移矩阵。 |
| 奖励 | 通常不显式定义,目标隐含在路径长度中。 | 对状态-动作对的即时评价信号。 | 步进惩罚:-1;到达终点:0;撞墙:-1并保持原位(可调整)。 |
| 解 | 一条由坐标序列构成的路径。 | 一个策略π(s),指定在每个状态该采取什么动作。 | 通过值迭代得到最优价值函数V*,再派生出贪婪策略。 |
2. 核心引擎:值迭代算法的Python实现
值迭代是求解贝尔曼最优方程的一种经典动态规划算法。其核心思想非常直接:不断用贝尔曼最优方程去更新所有状态的价值估计,直到估计值收敛。算法步骤如下:
- 初始化:将所有状态的价值V(s)设为一个初始值(比如全0)。
- 迭代更新:对每一个状态s,计算其新的价值估计V_new(s):
V_new(s) = max_a Σ_{s'} P(s'|s,a) * [ R(s,a,s') + γ * V_old(s') ] - 检查收敛:计算本次迭代所有状态价值更新的最大变化量
delta = max_s |V_new(s) - V_old(s)|。如果delta小于一个预设的很小阈值(如1e-4),则认为价值函数已收敛,停止迭代;否则,令V_old = V_new,返回步骤2。 - 提取策略:一旦价值函数V收敛,最优策略π(s)可以通过对每个状态选择使得动作价值Q(s,a)最大的动作获得:
π*(s) = argmax_a Σ_{s'} P(s'|s,a) * [ R(s,a,s') + γ * V*(s') ]
下面,我将结合一个具体的网格世界例子,给出值迭代的完整Python实现。我们假设有一个5x5的网格,起点在(0,0),终点在(4,4),中间有一些障碍物。
import numpy as np
from typing import Tuple, List, Dict
class GridWorldMDP:
"""
定义一个网格世界的马尔可夫决策过程。
"""
def __init__(self, grid_size: Tuple[int, int] = (5, 5),
start: Tuple[int, int] = (0, 0),
goal: Tuple[int, int] = (4, 4),
obstacles: List[Tuple[int, int]] = None,
step_reward: float = -1.0,
goal_reward: float = 0.0,
obstacle_penalty: float = -1.0,
gamma: float = 0.99):
self.rows, self.cols = grid_size
self.start = start
self.goal = goal
self.obstacles = obstacles if obstacles is not None else [(2, 2), (1, 3), (3, 1)]
self.step_reward = step_reward
self.goal_reward = goal_reward
self.obstacle_penalty = obstacle_penalty
self.gamma = gamma # 折扣因子
# 动作映射:动作名 -> (行变化, 列变化)
self.actions = {
'up': (-1, 0),
'down': (1, 0),
'left': (0, -1),
'right': (0, 1)
}
self.action_list = list(self.actions.keys())
# 初始化状态空间(所有非障碍格子)
self.states = []
self.state_to_idx = {}
idx = 0
for i in range(self.rows):
for j in range(self.cols):
if (i, j) not in self.obstacles:
self.states.append((i, j))
self.state_to_idx[(i, j)] = idx
idx += 1
# 将终点也视为一个普通状态,但到达后转移至终止态(或自身,奖励不同)
self.num_states = len(self.states)
# 初始化价值函数 V(s)
self.V = np.zeros(self.num_states)
# 策略 π(s),初始化为随机策略或None
self.policy = [None] * self.num_states
def is_terminal(self, state: Tuple[int, int]) -> bool:
"""判断一个状态是否为终止状态(终点)。"""
return state == self.goal
def get_next_state_and_reward(self, state: Tuple[int, int], action: str) -> Tuple[Tuple[int, int], float]:
"""
给定状态和动作,返回(下一个状态,即时奖励)。
这是确定性的转移。如果要模拟随机性,需要修改此函数。
"""
if self.is_terminal(state):
# 终止状态,不再转移,奖励为0
return state, 0.0
dr, dc = self.actions[action]
nr, nc = state[0] + dr, state[1] + dc
# 检查边界和障碍物
if nr < 0 or nr >= self.rows or nc < 0 or nc >= self.cols:
# 撞墙,停留在原地,并给予惩罚
return state, self.obstacle_penalty
if (nr, nc) in self.obstacles:
# 撞到障碍物,停留在原地,并给予惩罚
return state, self.obstacle_penalty
next_state = (nr, nc)
# 如果到达终点,给予目标奖励
if next_state == self.goal:
reward = self.goal_reward
else:
reward = self.step_reward
return next_state, reward
def value_iteration(self, theta: float = 1e-4, max_iter: int = 1000) -> Dict:
"""
执行值迭代算法。
theta: 收敛阈值
max_iter: 最大迭代次数
返回包含收敛信息和最终价值函数的字典。
"""
history = {'deltas': [], 'iterations': 0}
for i in range(max_iter):
delta = 0.0
V_new = self.V.copy()
# 遍历所有非终止状态(也可以遍历所有状态,但终止状态价值固定)
for idx, state in enumerate(self.states):
if self.is_terminal(state):
# 终止状态的价值通常定义为0(或目标奖励)
V_new[idx] = self.goal_reward
continue
# 计算当前状态所有可能动作的Q值
q_values = []
for a in self.action_list:
next_state, reward = self.get_next_state_and_reward(state, a)
next_idx = self.state_to_idx[next_state]
# 贝尔曼方程:Q(s,a) = R(s,a) + γ * V(s')
q_value = reward + self.gamma * self.V[next_idx]
q_values.append(q_value)
# 最优价值是Q值的最大值
best_value = max(q_values)
# 记录本次迭代中该状态价值的变化量
delta = max(delta, abs(best_value - self.V[idx]))
V_new[idx] = best_value
# 更新价值函数
self.V = V_new
history['deltas'].append(delta)
history['iterations'] = i + 1
# 检查收敛
if delta < theta:
print(f"值迭代在 {i+1} 次迭代后收敛,最终 delta = {delta:.6f}")
break
else:
print(f"值迭代在达到最大迭代次数 {max_iter} 后停止,最终 delta = {delta:.6f}")
# 从收敛的价值函数中提取最优策略
self.extract_policy()
return history
def extract_policy(self):
"""根据当前价值函数V,提取贪婪最优策略。"""
for idx, state in enumerate(self.states):
if self.is_terminal(state):
self.policy[idx] = 'terminal'
continue
best_action = None
best_value = -float('inf')
for a in self.action_list:
next_state, reward = self.get_next_state_and_reward(state, a)
next_idx = self.state_to_idx[next_state]
q_value = reward + self.gamma * self.V[next_idx]
if q_value > best_value:
best_value = q_value
best_action = a
self.policy[idx] = best_action
def get_policy_grid(self) -> np.ndarray:
"""将策略表示为网格形式的字符数组,便于可视化。"""
policy_grid = np.full((self.rows, self.cols), ' ', dtype='object')
for idx, state in enumerate(self.states):
i, j = state
if self.policy[idx] == 'terminal':
policy_grid[i, j] = 'G' # Goal
elif self.policy[idx] is not None:
# 用箭头表示方向
arrow_map = {'up': '↑', 'down': '↓', 'left': '←', 'right': '→'}
policy_grid[i, j] = arrow_map.get(self.policy[idx], '?')
# 标记障碍物
for (i, j) in self.obstacles:
policy_grid[i, j] = '█'
# 标记起点
si, sj = self.start
if policy_grid[si, sj] not in ['█', 'G']:
policy_grid[si, sj] = 'S'
return policy_grid
def get_value_grid(self) -> np.ndarray:
"""将价值函数表示为网格形式的数组,便于可视化。"""
value_grid = np.full((self.rows, self.cols), np.nan)
for idx, state in enumerate(self.states):
i, j = state
value_grid[i, j] = self.V[idx]
return value_grid
# 实例化并运行值迭代
if __name__ == "__main__":
mdp = GridWorldMDP(
grid_size=(5,5),
start=(0,0),
goal=(4,4),
obstacles=[(2,2), (1,3), (3,1)],
step_reward=-1,
goal_reward=0,
obstacle_penalty=-1,
gamma=0.99
)
history = mdp.value_iteration(theta=1e-6)
print("\n最优策略网格(S:起点, G:终点, █:障碍, 箭头:最优动作):")
print(mdp.get_policy_grid())
print("\n状态价值函数网格:")
print(np.round(mdp.get_value_grid(), 2))
运行这段代码,你会看到算法输出一个由箭头组成的最优策略网格,以及每个格子对应的价值(负数,代表从该格子到终点的最小代价)。从起点(0,0)开始,沿着箭头走,就是最短路径。
3. 收敛性调试与性能优化技巧
在实际编码中,值迭代可能会遇到不收敛或收敛慢的问题。下面是一些关键的调试和优化点:
收敛性检查:
- 折扣因子γ:如果γ=1且环境中存在零代价或正奖励的循环,价值函数可能发散到无穷大。确保γ<1,或者确保所有策略的预期回报都是有限的(比如每步都有负奖励)。
- 奖励设置:确保奖励函数的设计能引导智能体走向终点。步进惩罚(负奖励)是必须的。
- 终止状态:必须正确定义终止状态,并在迭代中将其价值固定(不更新),否则算法可能无法收敛。
性能优化:
值迭代的时间复杂度是 O(|S|^2 * |A|)(对于每个状态,计算每个动作的期望需要遍历可能的下一个状态)。在网格世界中,由于每个状态的下一个状态很少(最多4个),所以效率很高。但如果状态空间巨大,可以考虑以下优化:
- 异步值迭代:不必每次迭代都更新所有状态。可以按照某种顺序(如随机顺序、状态重要性顺序)逐个更新状态。一旦某个状态更新,新的价值立即用于后续状态的更新计算。这通常能加快收敛。
- 高斯-赛德尔风格更新:在遍历状态更新V(s)时,直接使用本次迭代中已经更新过的邻居状态的新价值,而不是全部使用旧价值。这其实就是一种异步更新,通常收敛更快。
- 优先扫描:维护一个优先级队列,优先更新那些价值变化可能最大的状态。计算每个状态的“贝尔曼误差”(当前价值与用贝尔曼方程计算出的新价值之间的差),误差大的状态优先更新。这能显著减少达到收敛所需的总更新次数。
下面是一个**异步值迭代(就地更新)**的简单修改示例,只需修改 value_iteration 函数中的更新循环部分:
def value_iteration_async(self, theta=1e-4, max_iter=1000):
"""异步值迭代(就地更新)。"""
for i in range(max_iter):
delta = 0.0
# 随机顺序遍历状态
state_indices = list(range(self.num_states))
np.random.shuffle(state_indices)
for idx in state_indices:
state = self.states[idx]
if self.is_terminal(state):
continue
old_value = self.V[idx]
# 计算Q值,注意这里计算next_state价值时,self.V可能已经包含了本轮迭代的更新
q_values = []
for a in self.action_list:
next_state, reward = self.get_next_state_and_reward(state, a)
next_idx = self.state_to_idx[next_state]
q_value = reward + self.gamma * self.V[next_idx]
q_values.append(q_value)
new_value = max(q_values)
self.V[idx] = new_value
delta = max(delta, abs(old_value - new_value))
if delta < theta:
break
self.extract_policy()
4. 从理论到扩展:随机环境与广义策略
我们之前实现的转移是确定性的。但贝尔曼方程的强大之处在于它能优雅地处理随机性。比如,在一个湿滑的网格中,执行“向上”的动作,有80%的概率成功向上,10%的概率向左滑,10%的概率向右滑。我们只需要修改 get_next_state_and_reward 函数,使其返回一个可能的下一个状态和概率的列表,并在计算Q值时进行加权求和。
def get_stochastic_next_states(self, state, action):
"""返回一个列表,元素为(概率, 下一个状态, 奖励)。"""
dr, dc = self.actions[action]
intended_next = (state[0] + dr, state[1] + dc)
# 检查目标格子是否有效
# ... 有效性检查逻辑 ...
# 定义随机转移:主要方向概率高,滑向两侧概率低
transitions = []
if action == 'up':
transitions = [
(0.8, intended_next, self._get_reward(state, intended_next)),
(0.1, self._apply_action(state, 'left'), self.step_reward),
(0.1, self._apply_action(state, 'right'), self.step_reward)
]
# ... 其他动作类似 ...
# 还需要处理概率归一化和无效状态回退
return transitions
在计算Q值时,则变为:
q_value = 0.0
for prob, next_state, reward in transitions:
next_idx = self.state_to_idx[next_state]
q_value += prob * (reward + self.gamma * self.V[next_idx])
此外,我们得到的策略π(s)是一个确定性的策略(每个状态一个最优动作)。但在某些情况下,随机策略可能更有优势(例如在探索与利用的权衡中)。我们的框架可以很容易地输出一个基于动作价值Q(s,a)的softmax随机策略,其中选择动作a的概率与 exp(Q(s,a)/τ) 成正比,τ是温度参数。
最后,这个动态规划求解器不仅仅能用于网格寻路。任何可以建模为有限状态MDP的序列决策问题,比如简单的库存管理、资源调度,甚至某些游戏中的战术选择,都可以用类似的框架来求解。关键在于如何定义状态、动作、转移和奖励。这需要你对问题领域有深入的理解,而贝尔曼方程和值迭代为你提供了一个强大且通用的计算工具。

1542

被折叠的 条评论
为什么被折叠?



