期末实战指南:用Python代码“手撕”经典AI算法,告别纸上谈兵
又到了学期末,翻开《人工智能导论》的教材,满眼都是“状态空间”、“启发函数”、“A算法”、“归结原理”……这些概念在PPT和课本上看起来条理清晰,但一到自己动手,是不是总觉得隔着一层纱,代码不知从何写起?考试时面对“请简述A算法流程”的题目能侃侃而谈,但若题目变成“请用代码实现一个解决八数码问题的A*算法”,恐怕很多人就要挠头了。
这正是理论与实践之间的鸿沟。人工智能,尤其是其核心的搜索与推理部分,本质上是一套精妙的计算思维和问题求解框架。仅仅记忆定义和步骤,就像只看了游泳教程却从未下水,永远无法真正学会。对于理工科学生而言,真正的理解始于指尖敲下的代码,终于程序成功运行那一刻的豁然开朗。
本文正是为你准备的“实战急救包”。我们不重复教材上的理论推导,而是直接切入代码,用Python将那些经典的符号智能算法——从状态空间搜索到知识推理——一一复现。你会看到搜索树如何动态生长,启发函数如何引导方向,逻辑公式如何一步步化为子句并进行归结。我们提供可直接运行的Jupyter Notebook代码块,辅以动画演示(使用matplotlib)和详尽的Debug技巧,旨在帮你打通从理论到实现的“最后一公里”,让期末复习从被动记忆变为主动构建,不仅为了考试,更为了获得能带走的、可验证的工程化理解。
1. 环境准备与核心工具栈
在开始“手撕”算法之前,需要一个趁手的编码环境。对于算法学习和快速原型开发,Jupyter Notebook 是不二之选。它能将代码、运行结果、可视化图表和说明文字完美地组织在一起,非常适合分步骤验证算法逻辑。
1.1 快速搭建Python数据科学环境
如果你还没有安装Python,推荐使用 Miniconda 来管理环境,它能有效避免包依赖冲突。
# 1. 安装Miniconda (从官网下载对应系统版本安装)
# 2. 创建一个专用于AI算法学习的环境
conda create -n ai_algo python=3.9
conda activate ai_algo
# 3. 在新环境中安装核心库
pip install jupyterlab numpy matplotlib ipywidgets
提示:使用
conda环境可以让你为不同的项目隔离依赖。ai_algo环境名可以自定义。
接下来,启动JupyterLab,我们就可以开始工作了。
jupyter lab
浏览器会自动打开一个本地页面,这就是你的交互式编程工作台。
1.2 理解我们将要使用的关键库
虽然我们要实现的是算法本身,但一些基础库能让我们事半功倍。
- NumPy: 虽然本教程侧重于逻辑和搜索,但高效的数组操作在更复杂的算法(如神经网络)中必不可少。这里我们主要用它进行一些基础数学运算。
- Matplotlib: 这是我们的“可视化利器”。我们将用它来绘制搜索树的状态演变、路径变化,让抽象的算法过程变得肉眼可见。
- IPyWidgets: 可选。如果你想让Notebook更具交互性,比如通过滑块动态调整启发函数的权重,这个库会很有用。
我们的代码将坚持一个原则:核心算法逻辑尽量使用纯Python标准库实现,以凸显算法本质,仅在外围辅助(如可视化、性能计时)时调用第三方库。
2. 状态空间搜索:从盲目到启发
搜索是AI的基石。我们从一个经典问题入手:八数码问题(8-puzzle)。在一个3x3的棋盘上,有8个编号为1-8的方块和一个空格,目标是通过滑动方块,将棋盘从任意初始状态恢复到目标状态(如12345678空格)。
2.1 问题建模与状态表示
首先,我们需要在代码中定义“状态”。一个直观的方法是使用一个二维列表(或展平的一维列表)。
class PuzzleState:
"""表示八数码问题的一个状态"""
def __init__(self, board, parent=None, action=None, g=0):
"""
Args:
board: 一个3x3的列表,例如 [[1,2,3],[4,5,6],[7,8,0]]
parent: 生成此状态的父状态
action: 从父状态到此状态所执行的操作('up', 'down', 'left', 'right')
g: 从起始状态到当前状态的实际代价(步数)
"""
self.board = [row[:] for row in board] # 深拷贝,避免状态污染
self.parent = parent
self.action = action
self.g = g
self.blank_pos = self._find_blank()
def _find_blank(self):
"""找到空格(0)的位置"""
for i in range(3):
for j in range(3):
if self.board[i][j] == 0:
return (i, j)
def __eq__(self, other):
"""重载相等运算符,用于判断两个状态是否相同"""
return self.board == other.board
def __hash__(self):
"""重载哈希函数,使得状态可被放入集合(set)或作为字典键"""
return hash(tuple(tuple(row) for row in self.board))
def get_successors(self):
"""生成当前状态的所有合法后继状态"""
successors = []
i, j = self.blank_pos
moves = [('up', i-1, j), ('down', i+1, j), ('left', i, j-1), ('right', i, j+1)]
for action, new_i, new_j in moves:
if 0 <= new_i < 3 and 0 <= new_j < 3:
new_board = [row[:] for row in self.board]
# 交换空格和相邻数字
new_board[i][j], new_board[new_i][new_j] = new_board[new_i][new_j], new_board[i][j]
successors.append(PuzzleState(new_board, parent=self, action=action, g=self.g + 1))
return successors
def is_goal(self, goal_board=[[1,2,3],[4,5,6],[7,8,0]]):
"""判断当前状态是否为目标状态"""
return self.board == goal_board
这个 PuzzleState 类封装了状态的所有信息,并提供了关键方法:get_successors 用于状态扩展,is_goal 用于目标测试。__eq__ 和 __hash__ 方法是为了后续能使用集合来记录已访问状态,防止重复探索,这是实现高效搜索的关键一步。
2.2 广度优先搜索(BFS)与深度优先搜索(DFS)实现
有了状态表示,我们就可以实现经典的盲目搜索算法。它们的核心区别在于 OPEN表(待探索节点队列)的数据结构。
from collections import deque
import time
def breadth_first_search(initial_state):
"""广度优先搜索"""
if initial_state.is_goal():
return initial_state, 0, 0
open_queue = deque([initial_state]) # OPEN表:使用队列(FIFO)
closed_set = set() # CLOSED表:记录已访问状态
visited_count = 0
while open_queue:
current_state = open_queue.popleft() # 从队列左侧取出(先进先出)
visited_count += 1
closed_set.add(current_state)
for successor in current_state.get_successors():
if successor.is_goal():
print(f"BFS 找到目标!共探索了 {visited_count} 个状态。")
return successor, visited_count, len(closed_set)
if successor not in closed_set and successor not in open_queue:
open_queue.append(successor) # 新节点加入队列尾部
return None, visited_count, len(closed_set)
def depth_first_search(initial_state, depth_limit=50):
"""深度优先搜索(带深度限制)"""
if initial_state.is_goal():
return initial_state, 0, 0
open_stack = [initial_state] # OPEN表:使用栈(LIFO)
closed_set = set()
visited_count = 0
while open_stack:
current_state = open_stack.pop() # 从栈顶取出(后进先出)
visited_count += 1
closed_set.add(current_state)
if current_state.g > depth_limit: # 深度限制,防止无限递归
continue
for successor in current_state.get_successors():
if successor.is_goal():
print(f"DFS (limit={depth_limit}) 找到目标!共探索了 {visited_count} 个状态。")
return successor, visited_count, len(closed_set)
if successor not in closed_set and successor not in open_stack:
open_stack.append(successor) # 新节点压入栈顶
return None, visited_count, len(closed_set)
运行一下,对比效果:
# 测试一个中等难度的初始状态
initial_board = [[1,2,3], [4,0,5], [7,8,6]]
init_state = PuzzleState(initial_board)
print("=== 广度优先搜索 BFS ===")
goal_bfs, visited_bfs, closed_bfs = breadth_first_search(init_state)
print("\n=== 深度优先搜索 DFS (深度限制50) ===")
goal_dfs, visited_dfs, closed_dfs = depth_first_search(init_state, depth_limit=50)
# 回溯路径函数
def trace_path(goal_state):
path = []
costs = []
current = goal_state
while current is not None:
path.append(current.action)
costs.append(current.g)
current = current.parent
path.reverse()
costs.reverse()
return list(zip(path, costs))[1:] # 去掉起点的None动作
if goal_bfs:
print(f"\nBFS找到的路径(动作,累计步数): {trace_path(goal_bfs)}")
if goal_dfs:
print(f"DFS找到的路径(动作,累计步数): {trace_path(goal_dfs)}")
你会立刻看到两种策略的鲜明对比:BFS通常能找到最短路径(最少步数),但探索的节点数可能非常多;DFS可能更快地“碰巧”找到一条路径,但这条路径很可能不是最优的,而且有陷入深处无限循环的风险(因此我们加了depth_limit)。
2.3 启发式搜索的灵魂:A*算法实现
盲目搜索在状态空间稍大时就会力不从心。A*算法通过一个估价函数 f(n) = g(n) + h(n) 来智能地引导搜索方向。其中g(n)是实际代价,h(n)是启发函数,估计从当前状态到目标的代价。
对于八数码问题,两个常用的启发函数是:
- 错位数(Misplaced Tiles):不在目标位置上的方块数量。
- 曼哈顿距离(Manhattan Distance):每个方块当前位置到其目标位置的行列距离之和。
import heapq # 用于实现优先队列(OPEN表按f值排序)
def misplaced_tiles(state, goal_board=[[1,2,3],[4,5,6],[7,8,0]]):
"""启发函数h1:错位数"""
count = 0
for i in range(3):
for j in range(3):
if state.board[i][j] != 0 and state.board[i][j] != goal_board[i][j]:
count += 1
return count
def manhattan_distance(state, goal_board=[[1,2,3],[4,5,6],[7,8,0]]):
"""启发函数h2:曼哈顿距离"""
distance = 0
goal_pos = {goal_board[i][j]: (i, j) for i in range(3) for j in range(3)}
for i in range(3):
for j in range(3):
tile = state.board[i][j]
if tile != 0:
goal_i, goal_j = goal_pos[tile]
distance += abs(i - goal_i) + abs(j - goal_j)
return distance
def a_star_search(initial_state, heuristic_func):
"""A*搜索算法"""
# 每个状态都有一个唯一的ID,用于优先队列比较(当f值相同时)
counter = 0
open_heap = [] # OPEN表:优先队列(最小堆)
heapq.heappush(open_heap, (0, counter, initial_state))
counter += 1
g_score = {initial_state: 0}
f_score = {initial_state: heuristic_func(initial_state)}
closed_set = set()
visited_count = 0
while open_heap:
_, _, current_state = heapq.heappop(open_heap)
visited_count += 1
if current_state.is_goal():
print(f"A* ({heuristic_func.__name__}) 找到目标!共探索了 {visited_count} 个状态。")
return current_state, visited_count
closed_set.add(current_state)
for successor in current_state.get_successors():
if successor in closed_set:
continue
tentative_g_score = g_score[current_state] + 1 # 每一步代价为1
if successor not in g_score or tentative_g_score < g_score[successor]:
# 找到一条到达successor的更优路径
successor.parent = current_state
successor.action = successor.action # 已由get_successors设置
successor.g = tentative_g_score
g_score[successor] = tentative_g_score
f_score[successor] = tentative_g_score + heuristic_func(successor)
heapq.heappush(open_heap, (f_score[successor], counter, successor))
counter += 1
return None, visited_count
现在,让我们用同一个问题测试不同启发函数的A*算法:
print("=== A* 搜索 (启发函数:错位数) ===")
goal_a1, visited_a1 = a_star_search(init_state, misplaced_tiles)
if goal_a1:
print(f"路径: {trace_path(goal_a1)}")
print("\n=== A* 搜索 (启发函数:曼哈顿距离) ===")
goal_a2, visited_a2 = a_star_search(init_state, manhattan_distance)
if goal_a2:
print(f"路径: {trace_path(goal_a2)}")
为了更直观地对比这几种算法的效率,我们可以用一个表格来总结:
| 搜索算法 | 是否完备 | 是否最优 | 探索节点数 (示例) | 适用场景 |
|---|---|---|---|---|
| 广度优先搜索 (BFS) | 是 | 是(当边代价为1) | 通常最多 | 状态空间小,要求最短路径 |
| 深度优先搜索 (DFS) | 否(无限深度下不完备) | 否 | 可能最少,也可能无限循环 | 树形结构,解分布深,内存有限 |
| A (错位数启发)* | 是(启发函数可采纳时) | 是 | 远少于BFS | 需要启发信息,h函数设计简单 |
| A (曼哈顿距离启发)* | 是 | 是 | 通常最少 | 八数码等网格问题的最优启发函数之一 |
注意:可采纳性(Admissibility) 是A*算法能找到最优解的关键,它要求启发函数h(n)永远不高估到达目标的实际代价。曼哈顿距离对于八数码问题是可采纳的,错位数也是。但曼哈顿距离通常更“紧致”(更接近真实代价),因此搜索效率更高。
通过这个完整的八数码问题求解案例,你已经亲手实现了状态空间搜索的核心框架。这个框架可以很容易地迁移到其他问题,比如路径规划(将棋盘换成网格,方块换成移动的智能体)、华容道等。
3. 知识表示与逻辑推理:从命题到归结
人工智能的另一大支柱是符号推理。我们如何让机器基于已知的事实和规则,推导出新的结论?这需要形式化的知识表示和推理机制。我们从最简单的命题逻辑开始,最终实现自动推理的利器——归结原理(Resolution)。
3.1 命题与谓词的形式化表示
首先,我们需要在Python中表示逻辑公式。我们将定义一个简单的类来表示合取范式(CNF) 中的子句,因为归结原理通常在CNF上进行。
class Literal:
"""表示一个文字(原子命题或其否定)"""
def __init__(self, name, is_positive=True):
self.name = name # 命题符号,如 'P', 'Q', 'Rain'
self.is_positive = is_positive # True表示正文字,False表示负文字
def __neg__(self):
"""重载取反运算符 ~"""
return Literal(self.name, not self.is_positive)
def __eq__(self, other):
return self.name == other.name and self.is_positive == other.is_positive
def __hash__(self):
return hash((self.name, self.is_positive))
def __repr__(self):
return self.name if self.is_positive else f'~{self.name}'
class Clause:
"""表示一个子句,即多个文字的析取(disjunction)"""
def __init__(self, literals):
self.literals = frozenset(literals) # 使用集合,避免重复文字,且无序
def is_empty(self):
return len(self.literals) == 0
def contains_complementary_pair(self):
"""检查子句是否包含互补对(如P和~P),这样的子句永真,可删除"""
seen = set()
for lit in self.literals:
if (~lit) in seen:
return True
seen.add(lit)
return False
def __repr__(self):
if self.is_empty():
return "□" # 空子句符号
return ' ∨ '.join(str(lit) for lit in self.literals)
# 示例:创建一些命题和子句
P = Literal('P')
Q = Literal('Q')
R = Literal('R')
not_P = ~P
not_Q = ~Q
clause1 = Clause([P, Q, not_R]) # 表示 P ∨ Q ∨ ~R
clause2 = Clause([not_P, R]) # 表示 ~P ∨ R
clause3 = Clause([P, not_P]) # 永真子句
print(f"子句1: {clause1}")
print(f"子句2: {clause2}")
print(f"子句3(永真): {clause3}, 包含互补对吗?{clause3.contains_complementary_pair()}")
3.2 归结原理的核心算法实现
归结原理的基本思想是:如果两个子句中分别包含互补的文字,那么就可以消去这对互补文字,将两个子句的剩余部分合并成一个新的子句(归结式)。如果通过归结能推导出空子句(□),则说明原子句集是不可满足的(存在矛盾),从而证明原结论。
def resolve(clause_a, clause_b):
"""对两个子句进行归结,返回所有可能的归结式集合"""
resolvents = set()
for lit_a in clause_a.literals:
for lit_b in clause_b.literals:
if lit_a == ~lit_b: # 找到一对互补文字
# 合并两个子句,去掉这对互补文字
new_literals = (clause_a.literals | clause_b.literals) - {lit_a, lit_b}
new_clause = Clause(new_literals)
# 如果新子句包含互补对,它是永真的,无需加入
if not new_clause.contains_complementary_pair():
resolvents.add(new_clause)
return resolvents
def resolution(clauses):
"""基于归结原理的定理证明主函数"""
new = set()
while True:
# 生成所有可能的归结式
clause_list = list(clauses)
for i in range(len(clause_list)):
for j in range(i+1, len(clause_list)):
resolvents = resolve(clause_list[i], clause_list[j])
# 如果生成空子句,证明完成
if any(res.is_empty() for res in resolvents):
print("推导出空子句 □!原子句集不可满足,结论得证。")
return True
new.update(resolvents)
# 如果new是clauses的子集,说明没有新的子句产生,无法证明
if new.issubset(clauses):
print("无法再生成新的子句,无法证明结论。")
return False
# 将新子句加入集合,继续下一轮归结
clauses.update(new)
print(f"本轮生成新子句: {new}")
new.clear()
# 一个经典的例子:证明 (P -> Q) ∧ P => Q
# 即:证明 {~P ∨ Q, P, ~Q} 不可满足
print("=== 归结原理示例:证明 (P -> Q) 且 P 能推出 Q ===")
# 将前提和结论的否定化为子句集
# P -> Q 等价于 ~P ∨ Q
# 结论 Q 的否定是 ~Q
clause_set = {
Clause([~P, Q]), # ~P ∨ Q
Clause([P]), # P
Clause([~Q]) # ~Q (要证明的结论的否定)
}
print(f"初始子句集 S: {clause_set}")
proved = resolution(clause_set)
print(f"证明结果: {proved}")
运行这段代码,你会看到归结的步骤:
- 初始子句集:
{~P ∨ Q, P, ~Q} - 用
P和~P ∨ Q归结,得到新子句Q。 - 用新得到的
Q和~Q归结,得到空子句 □。 - 推导出空子句,证明原子句集矛盾,因此原结论
Q成立。
这个过程自动化了逻辑推理。虽然例子简单,但算法可以处理更复杂的谓词逻辑子句(需要先进行斯柯伦化消除存在量词,再化为CNF)。
3.3 从自然语言到子句集:一个简单的知识库问答模拟
让我们把知识表示和推理结合起来,模拟一个微型知识库。假设我们有如下知识:
- 如果下雨,则草地是湿的。
- 如果洒水器开了,则草地是湿的。
- 今天草地是湿的。
- 今天没有下雨。
我们能推断出“洒水器开了”吗?
# 定义原子命题
Rain = Literal('Rain')
Sprinkler = Literal('Sprinkler')
GrassWet = Literal('GrassWet')
# 将知识编码为子句
# 1. Rain -> GrassWet 等价于 ~Rain ∨ GrassWet
# 2. Sprinkler -> GrassWet 等价于 ~Sprinkler ∨ GrassWet
# 3. GrassWet
# 4. ~Rain
# 要查询的结论: Sprinkler (其否定为 ~Sprinkler)
kb_clauses = {
Clause([~Rain, GrassWet]),
Clause([~Sprinkler, GrassWet]),
Clause([GrassWet]),
Clause([~Rain]),
Clause([~Sprinkler]) # 结论的否定,加入知识库
}
print("=== 知识库推理示例 ===")
print("知识库子句集(包含待证结论的否定):")
for c in kb_clauses:
print(f" {c}")
print("\n开始归结...")
proved = resolution(kb_clauses.copy()) # 使用拷贝,避免修改原集合
if proved:
print("\n推理成功:从‘草地湿了’和‘没下雨’,可以推出‘洒水器开了’。")
else:
print("\n无法确定洒水器是否开了。")
这个简单的例子展示了如何将现实世界中的规则和事实形式化,并通过自动推理引擎得到新的结论。虽然工业级的专家系统要复杂得多,但核心思想一脉相承。
4. 常见报错与Debug实战指南
在动手实现这些算法的过程中,你一定会遇到各种错误。下面是一些典型问题及其解决方案,这比算法本身更能体现你的工程能力。
4.1 状态搜索中的无限循环与状态爆炸
- 问题现象:程序长时间运行不结束,内存占用不断增长。
- 根本原因:
- 未记录已访问状态(CLOSED表):这是新手最常犯的错误。搜索树会无限重复访问相同的状态。
- 状态哈希错误:自定义的State类没有正确实现
__eq__和__hash__方法,导致set()或dict无法正确识别重复状态。 - 状态空间本身巨大:例如,15数码问题的状态数超过10^13,盲目搜索不可能完成。
- Debug技巧:
- 打印搜索轨迹:在扩展节点时,打印当前状态和已探索节点数,观察其增长是否合理。
def bfs_with_trace(initial_state, max_steps=1000): open_queue = deque([initial_state]) closed_set = set() step = 0 while open_queue and step < max_steps: current = open_queue.popleft() step += 1 if step % 100 == 0: # 每100步打印一次 print(f"Step {step}: Open size={len(open_queue)}, Closed size={len(closed_set)}") # 可选:打印当前状态的前几个字符表示 print(f" Current state hash: {hash(current)}") # ... 其余逻辑- 验证哈希函数:手动创建两个相同的状态对象,检查
s1 == s2是否为True,以及hash(s1) == hash(s2)是否成立。 - 使用可视化:对于八数码问题,可以定期将当前搜索的“前沿”(OPEN表)状态可视化出来,看看算法在探索哪些区域。
4.2 启发函数设计不当导致非最优解
- 问题现象:A*算法找到的路径不是最短的。
- 根本原因:启发函数
h(n)不是可采纳的(Admissible),它高估了到达目标的实际代价。例如,在八数码问题中使用“曼哈顿距离乘以2”作为启发函数。 - 检查与验证:
- 数学证明:对于你设计的h(n),尝试证明对于所有状态n,都有
h(n) <= h*(n)(其中h*(n)是真实最优代价)。对于网格问题,曼哈顿距离是可采纳的,因为方块每次只能移动一步。 - 实验对比:用BFS(保证最优)在小规模问题上运行,将BFS得到的最优代价与A*搜索中每个节点的
h(n)值进行比较。如果发现某个节点的h(n)大于BFS得到的从该节点到目标的最短路径长度,则h(n)不可采纳。
def verify_heuristic(state, heuristic_func, true_optimal_cost): """辅助验证函数:比较启发式估计值与真实最优代价""" h_val = heuristic_func(state) if h_val > true_optimal_cost: print(f"警告:在状态 {state.board} 上,h={h_val} > 真实代价{true_optimal_cost},启发函数不可采纳!") return False return True - 数学证明:对于你设计的h(n),尝试证明对于所有状态n,都有
4.3 逻辑推理中的效率低下与死循环
- 问题现象:归结过程产生大量冗余子句,甚至无限循环。
- 根本原因:
- 未应用删除策略:没有删除永真子句(包含互补对)和被子句集包含的子句。
- 归结组合爆炸:每轮都让所有子句两两归结,产生大量无用甚至重复的新子句。
- 优化策略:
- 在
resolve函数中立即过滤:就像我们代码中做的,如果新生成的子句包含互补对,直接丢弃(它是永真的)。 - 实现子句包含(subsumption)检查:如果子句C1的所有文字都包含在子句C2中(即C1 ⊆ C2),那么C2是冗余的,可以删除。这需要更复杂的数据结构。
- 采用更高效的归结策略:
- 支持集策略(Set of Support):每次归结至少有一个子句来自“支持集”(通常包含目标结论的否定及其衍生子句)。这能大幅减少不必要的归结。
- 线性归结:每次归结都使用上一轮产生的新子句和原始子句集中的一个子句进行。这能保证推导过程是线性的,易于跟踪。
- 在
def resolution_sos(clauses, sos):
"""带支持集策略的归结"""
# clauses: 原始知识库子句集
# sos: 支持集,初始为待证结论的否定
used = set()
while sos:
# 从支持集中选一个子句
clause = sos.pop()
used.add(clause)
# 与知识库中的每个子句归结
for kb_clause in clauses:
resolvents = resolve(clause, kb_clause)
for new_clause in resolvents:
if new_clause.is_empty():
return True
# 简单的包含检查:如果新子句比任何现有子句都“大”,可能冗余
if not any(new_clause.literals.issuperset(c.literals) for c in clauses | used | set(sos)):
sos.add(new_clause)
return False
4.4 Jupyter Notebook中的交互式调试技巧
- 使用
%debug魔法命令:在出现异常后,在单元格中输入%debug,会进入交互式调试器(ipdb),可以逐行检查变量。 - 善用
print和logging:在关键函数入口和出口打印参数和返回值。对于循环,可以设置条件打印,避免输出刷屏。 - 可视化中间状态:对于搜索算法,定期将OPEN表和CLOSED表的大小、当前最佳路径代价画成折线图,能直观看出算法进展和潜在问题(如内存增长过快)。
- 单元测试:为
PuzzleState.get_successors()、resolve()等核心函数编写小的测试用例,确保其行为符合预期,这能极大降低整体调试难度。
# 一个简单的单元测试示例
def test_successors():
board = [[1,0,3],[4,2,5],[7,8,6]]
state = PuzzleState(board)
succ = state.get_successors()
print(f"初始状态: {board}")
print(f"生成的后继状态数: {len(succ)}")
for s in succ:
print(f" 动作 {s.action}: {s.board}")
assert len(succ) == 2 # 空格在中间,应有2个合法移动
print("测试通过!")
test_successors()
将这些代码块复制到你的Jupyter Notebook中,逐个运行、修改、调试,是理解算法最有效的方式。遇到报错不要慌,利用上述技巧定位问题,理解错误信息(如 KeyError, TypeError, 无限递归等),你解决问题的能力会在这个过程中飞速提升。
算法实现的魅力在于,当你亲手敲下的代码成功运行,并可视化出搜索路径或完成逻辑推导时,那些课本上生硬的概念瞬间变得鲜活而具体。这份通过调试和实战获得的理解,远比死记硬背要牢固得多。
346



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



