简介:这份PDF资料系统梳理人工智能搜索技术,面向AI初学者、算法课程学习者及需要复习搜索算法的开发者,帮助建立从问题求解到状态空间建模的完整认知。内容涵盖搜索技术概述、状态空间表示、盲目搜索、启发式搜索、A算法与A算法,并延伸至博弈搜索与α-β剪枝法,结合八数码、农夫过河、迷宫等经典实例讲解状态向量、操作算符与估价函数的设计思路。资源包共1个PDF文件,大小约6.54MB,单文件结构便于通读与检索,适合作为课堂笔记或考前复习提纲。目前已有500人学习下载,读者可从中掌握盲目搜索与启发式搜索的差异、A算法中f(n)=g(n)+h(n)的评估逻辑,以及α-β剪枝如何压缩博弈搜索空间,为路径规划、机器人行动规划等场景打下算法基础。
1. 从八数码到农夫过河:这套搜索技术课件到底能解决什么
如果你正在学人工智能,或者准备面试时被问到 A* 算法的估价函数怎么设计,又或者带课的老师想找一套能把状态空间讲透的素材,那这份《人工智能搜索技术》课件值得你花时间拆一遍。它覆盖了从搜索技术概述、状态空间建模、盲目搜索,到启发式搜索、A 算法与 A* 算法,再到博弈搜索和 α-β 剪枝的完整链路。不是泛泛而谈的科普,而是带着八数码、农夫过河、汉诺塔这些经典案例,一步步把状态图怎么画、算符怎么定义、OPEN 表和 CLOSED 表怎么维护讲清楚。适合刚入门 AI 的本科生、考研复习的备考生,以及需要快速捡起搜索算法的一线开发者。我见过太多人学搜索时卡在“知道 BFS 和 DFS 的区别,但拿到具体问题不知道怎么建状态空间”,这套资料恰好补的就是这个断层。
2. 状态空间建模:把农夫过河拆成四维向量
2.1 状态、算符与状态图的三件套
搜索技术的地基是状态空间法。任何能用搜索求解的问题,第一步都是把问题描述成一个状态图:节点是状态,边是操作(算符)。课件里用农夫过河问题做了非常干净的示范。问题本身不复杂——农夫带狼、羊、白菜过河,船只能载农夫和一样东西,没有农夫看管时狼吃羊、羊吃白菜。但它的状态空间表示法值得反复看。
状态用四维向量(人, 狼, 羊, 菜)表示,每个维度取 0 或 1,0 表示在左岸(出发点),1 表示在右岸。初始状态是(0,0,0,0),目标状态是(1,1,1,1)。非法状态是那些没有农夫看管时会出现捕食关系的状态,比如(0,0,1,1)表示农夫在左岸但羊和白菜在右岸,羊会吃白菜,所以非法。
算符就是农夫带一样东西过河的动作。因为船必须由农夫驾驶,所以每次移动农夫必然改变位置,同时可能带一样东西。算符可以形式化为:
- 农夫单独过河:改变第一个分量
- 农夫带狼过河:同时改变第一个和第二个分量
- 农夫带羊过河:同时改变第一个和第三个分量
- 农夫带菜过河:同时改变第一个和第四个分量
每个算符都有前提条件:农夫必须和要带的东西在同一岸。这个约束在代码里体现为状态转移的合法性检查。
2.2 用 Python 把状态空间跑起来
光看向量不够直观,我一般会写一段代码把状态空间图生成出来,顺便验证哪些状态可达、哪些非法。下面这段代码可以直接抄:
from collections import deque # 状态用元组表示 (人, 狼, 羊, 菜),0 左岸,1 右岸 # 初始状态和目标状态 start = (0, 0, 0, 0) goal = (1, 1, 1, 1) # 判断状态是否合法:如果农夫不在某岸,该岸不能同时出现狼羊或羊菜 def is_valid(state): person, wolf, sheep, cabbage = state # 如果农夫不在左岸,左岸不能有狼羊或羊菜同时存在 if person != 0: if wolf == 0 and sheep == 0: return False if sheep == 0 and cabbage == 0: return False # 如果农夫不在右岸,右岸不能有狼羊或羊菜同时存在 if person != 1: if wolf == 1 and sheep == 1: return False if sheep == 1 and cabbage == 1: return False return True # 生成当前状态的所有合法后继状态 def get_successors(state): person, wolf, sheep, cabbage = state successors = [] # 农夫要过河,目标岸是 person 取反 new_person = 1 - person # 农夫单独过河 new_state = (new_person, wolf, sheep, cabbage) if is_valid(new_state): successors.append(('单独过河', new_state)) # 农夫带狼过河:前提是狼和农夫在同一岸 if wolf == person: new_state = (new_person, new_person, sheep, cabbage) if is_valid(new_state): successors.append(('带狼过河', new_state)) # 农夫带羊过河 if sheep == person: new_state = (new_person, wolf, new_person, cabbage) if is_valid(new_state): successors.append(('带羊过河', new_state)) # 农夫带菜过河 if cabbage == person: new_state = (new_person, wolf, sheep, new_person) if is_valid(new_state): successors.append(('带菜过河', new_state)) return successors # 用 BFS 找最短路径 def bfs(start, goal): queue = deque([(start, [])]) visited = {start} while queue: state, path = queue.popleft() if state == goal: return path for action, next_state in get_successors(state): if next_state not in visited: visited.add(next_state) queue.append((next_state, path + [(action, next_state)])) return None # 执行并打印路径 path = bfs(start, goal) if path: print(f"找到最短路径,共 {len(path)} 步:") for i, (action, state) in enumerate(path, 1): print(f"第 {i} 步:{action} -> {state}") else: print("无解")这段代码的逻辑说明:is_valid函数是状态合法性检查,核心是判断农夫不在的那一岸是否会出现捕食关系。get_successors生成所有可能的下一步状态,注意带东西过河的前提是东西和农夫在同一岸。BFS 部分用队列实现逐层扩展,visited集合防止重复访问。参数方面,状态元组的四个分量顺序不能乱,0和1的含义要统一。跑出来的最短路径是 7 步,和课件里给出的答案一致。
注意:非法状态列表里有些状态看起来重复,比如
(0,0,1,1)和(1,1,0,0)是对称的,但它们在状态空间里是不同的节点,因为农夫的位置不同。
3. 盲目搜索:BFS 和 DFS 的 OPEN 表与 CLOSED 表怎么维护
3.1 宽度优先与深度优先的取舍逻辑
盲目搜索也叫无信息搜索,搜索过程中获得的中间信息不用来改进控制策略。课件里重点讲了宽度优先搜索(BFS)和深度优先搜索(DFS),这两种是状态图搜索的基础。BFS 从初始节点开始逐层扩展,先访问所有深度为 1 的节点,再访问深度为 2 的节点,以此类推。DFS 则沿着一条路径一直走到底,走不通再回溯。
选 BFS 还是 DFS,取决于问题的性质。如果目标节点离初始节点比较近,BFS 能保证找到最短路径(在边权相等的情况下),但空间复杂度是指数级的,因为要保存所有已扩展节点的子节点。DFS 空间复杂度低,但可能陷入很深的子树,而且找到的解不一定是最优的。课件里提到组合爆炸问题——魔方、皇后问题、旅行商问题,状态空间随问题规模指数增长,盲目搜索很容易翻车。
我一般会这样判断:如果问题有明确的深度限制,或者解路径很短,用 BFS;如果状态空间很大但解路径可能很深,用 DFS 配合深度限制。实际工程里纯 BFS 和纯 DFS 都很少单独用,更多是作为启发式搜索的底层框架。
3.2 OPEN 表和 CLOSED 表的代码实现
状态图搜索的核心数据结构是 OPEN 表和 CLOSED 表。OPEN 表存放待扩展的节点,CLOSED 表存放已扩展的节点。BFS 的 OPEN 表是队列,DFS 的 OPEN 表是栈。下面用 Python 实现一个通用的状态图搜索框架:
from collections import deque class SearchFramework: def __init__(self, start, goal, get_successors): self.start = start self.goal = goal self.get_successors = get_successors self.closed = set() # CLOSED 表,记录已扩展节点 def search(self, strategy='bfs'): # OPEN 表:BFS 用队列,DFS 用栈 if strategy == 'bfs': open_list = deque([(self.start, [self.start])]) else: open_list = [(self.start, [self.start])] while open_list: if strategy == 'bfs': current, path = open_list.popleft() else: current, path = open_list.pop() # 目标检测 if current == self.goal: return path # 如果已在 CLOSED 表中,跳过 if current in self.closed: continue # 加入 CLOSED 表 self.closed.add(current) # 扩展后继节点 for _, next_state in self.get_successors(current): if next_state not in self.closed: open_list.append((next_state, path + [next_state])) return None # 无解 # 复用上一章的 is_valid 和 get_successors # 这里省略,实际使用时需要导入或定义 # 测试 BFS 和 DFS # framework = SearchFramework(start, goal, get_successors) # bfs_path = framework.search('bfs') # framework.closed.clear() # 重置 CLOSED 表 # dfs_path = framework.search('dfs')逻辑说明:closed集合就是 CLOSED 表,每次从 OPEN 表取出节点后先检查是否已扩展过。BFS 用deque.popleft()实现先进先出,DFS 用list.pop()实现后进先出。path参数记录从初始节点到当前节点的路径,方便找到目标后直接返回。参数方面,strategy控制搜索策略,get_successors是外部传入的后继生成函数,这样框架可以复用于不同问题。
提示:DFS 实现里有个细节——节点加入 OPEN 表时没有立即加入 CLOSED 表,而是在取出时才加入。这样可以避免同一节点被多次加入 OPEN 表,但代价是 OPEN 表可能变大。另一种做法是扩展时就标记 CLOSED,各有利弊。
4. 启发式搜索:A 算法和 A* 算法的估价函数设计
4.1 启发式信息怎么选:h(n) 的设计原则
启发式搜索和盲目搜索的本质区别在于是否使用与问题有关的启发性信息。A 算法和 A* 算法是启发式搜索的代表,核心是估价函数f(n) = g(n) + h(n)。g(n)是从初始节点到当前节点的实际代价,h(n)是从当前节点到目标节点的估计代价,也叫启发函数。
h(n)的设计直接决定搜索效率和解的最优性。A* 算法要求h(n)是可采纳的(admissible),即h(n)永远不超过从 n 到目标的实际最小代价。如果h(n)满足这个条件,A* 保证找到最优解。如果h(n)还满足一致性(单调性),即h(n) <= c(n, n') + h(n'),那么 A* 在扩展节点时不会重复扩展已扩展的节点,效率更高。
以八数码问题为例,常用的启发函数有两种:一种是错位棋子数,即当前状态与目标状态相比位置不对的棋子数量;另一种是曼哈顿距离之和,即每个棋子当前位置到目标位置的曼哈顿距离之和。曼哈顿距离的启发式信息更强,搜索效率更高,但计算量也更大。课件里没有展开讲这两种启发函数的对比,但这是面试和考试的高频考点。
4.2 A* 算法在八数码问题上的完整实现
下面用八数码问题演示 A* 算法的完整实现。八数码的状态用一个 3x3 的元组表示,0 代表空格:
import heapq # 目标状态 GOAL = (1, 2, 3, 4, 5, 6, 7, 8, 0) # 曼哈顿距离启发函数 def manhattan(state): distance = 0 for i, val in enumerate(state): if val == 0: continue # 目标位置 target_row, target_col = (val - 1) // 3, (val - 1) % 3 # 当前位置 current_row, current_col = i // 3, i % 3 distance += abs(target_row - current_row) + abs(target_col - current_col) return distance # 错位棋子数启发函数 def misplaced(state): return sum(1 for i, val in enumerate(state) if val != 0 and val != GOAL[i]) # 生成后继状态 def get_neighbors(state): neighbors = [] zero_idx = state.index(0) row, col = zero_idx // 3, zero_idx % 3 # 四个方向:上、下、左、右 directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] for dr, dc in directions: new_row, new_col = row + dr, col + dc if 0 <= new_row < 3 and 0 <= new_col < 3: new_idx = new_row * 3 + new_col new_state = list(state) new_state[zero_idx], new_state[new_idx] = new_state[new_idx], new_state[zero_idx] neighbors.append(tuple(new_state)) return neighbors # A* 算法 def a_star(start, heuristic='manhattan'): h_func = manhattan if heuristic == 'manhattan' else misplaced # 优先队列:(f, g, state, path) open_list = [(h_func(start), 0, start, [start])] closed = {} # 记录已扩展节点的最小 g 值 expanded_count = 0 # 扩展节点计数 while open_list: f, g, current, path = heapq.heappop(open_list) if current == GOAL: return path, expanded_count # 如果当前节点的 g 值比已记录的大,跳过 if current in closed and closed[current] <= g: continue closed[current] = g expanded_count += 1 for neighbor in get_neighbors(current): new_g = g + 1 new_f = new_g + h_func(neighbor) heapq.heappush(open_list, (new_f, new_g, neighbor, path + [neighbor])) return None, expanded_count # 测试:用一个可解的初始状态 start_state = (2, 8, 3, 1, 6, 4, 7, 0, 5) path, count = a_star(start_state, 'manhattan') print(f"曼哈顿距离启发:扩展 {count} 个节点,路径长度 {len(path)-1} 步") path2, count2 = a_star(start_state, 'misplaced') print(f"错位棋子数启发:扩展 {count2} 个节点,路径长度 {len(path2)-1} 步")逻辑说明:manhattan函数计算每个非零棋子到目标位置的曼哈顿距离之和,这是可采纳的启发函数。get_neighbors生成空格上下左右移动后的状态。A* 主循环用优先队列按f值排序,每次取出f最小的节点。closed字典记录已扩展节点的最小g值,如果同一状态以更大的g值再次出现,直接跳过。expanded_count统计扩展节点数,用来对比不同启发函数的效率。
参数方面,heuristic控制使用哪种启发函数。跑出来的结果通常显示曼哈顿距离扩展的节点数明显少于错位棋子数,这就是启发式信息强度的差异。实际调参时,如果发现 A* 扩展节点数接近 BFS,说明启发函数太弱;如果找到的解不是最优,说明启发函数不可采纳。
注意:八数码问题有解的必要条件是逆序数奇偶性一致。如果初始状态的逆序数和目标状态不同奇偶,问题无解,A* 会遍历完整个状态空间后返回 None。这个判断应该在搜索前做,避免浪费时间。
5. 避坑与排查:搜索算法实现中的五个血泪教训
5.1 状态重复访问导致死循环
现象:DFS 实现时程序跑了几分钟没输出,内存持续增长。原因:没有维护 CLOSED 表,或者 CLOSED 表的判断逻辑写错了,导致同一状态被反复加入 OPEN 表。解决:在从 OPEN 表取出节点时立即检查是否在 CLOSED 表中,如果是则跳过;扩展后继节点时也要检查是否已在 CLOSED 表中。更稳妥的做法是用一个visited集合记录所有已加入过 OPEN 表的状态,避免重复入队。
5.2 启发函数不可采纳导致解非最优
现象:A* 算法找到了解,但路径长度比 BFS 找到的长。原因:h(n)超过了实际代价,不满足可采纳性。比如在八数码问题里用“曼哈顿距离乘以 2”作为启发函数,虽然搜索更快,但可能错过最优解。解决:检查启发函数是否满足h(n) <= 实际最小代价。如果不确定,先用 BFS 跑一个小规模案例,对比 A* 的结果。曼哈顿距离和错位棋子数都是可采纳的,可以放心用。
5.3 OPEN 表排序不稳定导致扩展顺序混乱
现象:A* 算法扩展节点数比预期多很多,或者同一f值的节点扩展顺序每次运行不一样。原因:优先队列里只按f值排序,当f值相同时没有稳定的比较规则,导致扩展顺序随机。解决:在堆元素里加入一个递增的计数器作为第二排序键,保证先进先出。或者用(f, g, state)三元组,g值小的优先扩展,这样更倾向于靠近目标的节点。
5.4 状态表示不一致导致比较失败
现象:明明两个状态看起来一样,但程序认为它们不同,CLOSED 表没起作用。原因:状态的数据类型不一致,比如一个是元组一个是列表,或者列表里嵌套了列表导致不可哈希。解决:统一用元组表示状态,确保所有分量都是不可变类型。如果状态里有嵌套结构,先转换成扁平元组再比较。Python 里列表不可哈希,不能直接放进set或dict的键。
5.5 忽略问题无解的情况导致程序卡死
现象:A* 或 BFS 跑了很久没结果,最后发现问题是不可解的。原因:没有在搜索前做可解性判断,搜索算法遍历完整个状态空间才返回无解。解决:对于八数码问题,先计算初始状态和目标状态的逆序数奇偶性,不一致则直接判定无解。对于农夫过河问题,可以先用 BFS 跑一遍确认目标可达。一般做法是在搜索框架里加一个最大扩展节点数限制,超过阈值就报错退出,避免无限等待。
6. 博弈搜索与 α-β 剪枝:从极小极大到剪枝优化
博弈搜索是搜索技术的另一个重要分支,课件里提到了极小极大法和 α-β 剪枝。极小极大法假设对手总是选择对自己最有利的走法,所以 AI 在决策时要考虑对手的最优应对。具体来说,AI 层取最大值,对手层取最小值,交替进行直到终端节点,然后回溯评估值。
α-β 剪枝是对极小极大法的优化。α 表示当前节点下界,β 表示上界。在搜索过程中,如果某个节点的评估值超出了 α-β 范围,就可以剪掉该分支,不再继续搜索。课件里强调,当预先考虑的棋步比较多时,极小极大法的计算量会大大增加,α-β 剪枝通过减少需评估的节点范围来提高效率。
我一般会用一个简单的井字棋来验证 α-β 剪枝的效果。下面是一个简化的实现框架:
# 井字棋的极小极大与 α-β 剪枝 # 棋盘用长度为 9 的列表表示,'X' 表示 AI,'O' 表示对手,None 表示空位 def is_terminal(board): # 检查是否有三连 lines = [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)] for a, b, c in lines: if board[a] and board[a] == board[b] == board[c]: return board[a] if all(cell is not None for cell in board): return 'draw' return None def minimax(board, depth, is_max, alpha, beta): result = is_terminal(board) if result == 'X': return 10 - depth if result == 'O': return depth - 10 if result == 'draw': return 0 if is_max: best = -float('inf') for i in range(9): if board[i] is None: board[i] = 'X' score = minimax(board, depth + 1, False, alpha, beta) board[i] = None best = max(best, score) alpha = max(alpha, best) if beta <= alpha: break # β 剪枝 return best else: best = float('inf') for i in range(9): if board[i] is None: board[i] = 'O' score = minimax(board, depth + 1, True, alpha, beta) board[i] = None best = min(best, score) beta = min(beta, best) if beta <= alpha: break # α 剪枝 return best # 测试:空棋盘,AI 先手 empty_board = [None] * 9 score = minimax(empty_board, 0, True, -float('inf'), float('inf')) print(f"空棋盘 AI 先手的最优评估值:{score}")逻辑说明:is_terminal检查游戏是否结束,返回赢家或平局。minimax递归搜索,is_max为 True 时是 AI 层取最大值,否则是对手层取最小值。alpha和beta是剪枝边界,当beta <= alpha时剪掉当前分支。参数方面,depth用来调整评估值,让 AI 倾向于快速获胜或延迟失败。剪枝的效果在棋盘较大时非常明显,井字棋状态空间小,差异不明显,但换成五子棋或国际象棋,剪枝能减少大量节点扩展。
从那以后我每次实现搜索算法,都会先确认三件事:状态表示是否可哈希、启发函数是否可采纳、有没有做可解性预判。这三步走完,大部分玄学问题都能定位。希望帮到你。
本文还有配套的精品资源,点击获取