首页 / 第四章

04

搜索策略

现实中的许多智能问题——路径规划、任务安排、棋局决策——都没有直接公式可套, 只能在庞大的可能空间里试。如何试得聪明、试得快,就是搜索策略。 本章实现 BFS/DFS/UCS、A*、与或树搜索和博弈树 α-β 剪枝,它们是规划与决策算法的共同骨架。

4.1 搜索的基本概念

搜索是根据问题的实际情况,不断寻找可利用的知识,从而构造一条代价最小的行动路线, 使问题得以解决的过程。它产生的根本原因是:现成的、确定性的求解步骤往往不存在。

4.1.1 状态空间法

状态空间用三元组 (S, O, G) 表示:

求解问题 = 在状态空间图中,寻找一条从 S₀ 到某个目标状态的路径。 例如八数码问题:状态是九宫格布局,操作是空格上下左右移动,目标是指定排列。

4.1.2 问题归约法与与或图

另一种思路是问题归约:把复杂问题分解为若干子问题。若所有子问题都解决父问题才解决, 用与节点(弧间加连线);若任一子问题解决即可,用或节点。 由此得到与或图,搜索目标是找到一棵解树(4.6 节)。

4.1.3 盲目搜索与启发式搜索

类别是否利用问题知识代表算法
盲目搜索(无信息)按预定顺序展开,不挑节点BFS、DFS、一致代价、迭代加深
启发式搜索(有信息)用估价函数挑选"最有希望"的节点最好优先、A*、IDA*、AO*

4.2 图搜索的通用框架

几乎所有状态空间搜索都可以统一到 OPEN / CLOSED 两表框架:

  1. 把初始节点放入 OPEN 表;CLOSED 表初始为空;
  2. 若 OPEN 为空则失败退出;
  3. 从 OPEN 表取第一个节点 n,移入 CLOSED;
  4. 若 n 是目标,回溯指针得到解路径,成功退出;
  5. 扩展 n:生成后继节点,更新指针,按策略排序后放入 OPEN;
  6. 转第 2 步。

不同算法的区别,本质上只在第 5 步如何排序 OPEN 表。 先用 Python 搭建统一框架,后续算法只替换"取下一个节点"的策略。

graph_search.py
"""通用状态空间搜索框架:OPEN/CLOSED 两表法"""
from collections import deque
import heapq

class Graph:
    """带权无向(或有向)图的邻接表表示。"""
    def __init__(self):
        self.adj = {}

    def add_edge(self, u, v, cost=1, bidirectional=True):
        self.adj.setdefault(u, []).append((v, cost))
        if bidirectional:
            self.adj.setdefault(v, []).append((u, cost))

    def neighbors(self, node):
        return self.adj.get(node, [])


def generic_search(graph, start, goal, pop_func, record_order=False):
    """pop_func(open_container) -> (节点, 累计代价, 父节点表更新用)
    不同搜索策略只需提供不同的容器与弹出方式。"""
    parent = {start: None}
    g_cost = {start: 0}
    closed = set()
    order = []

    open_container = pop_func.init(start)
    while open_container:
        node, g = pop_func.pop(open_container)
        if node in closed:
            continue
        closed.add(node)
        order.append(node)
        if node == goal:
            path = []
            while node is not None:
                path.append(node)
                node = parent[node]
            return list(reversed(path)), g, order
        for nxt, c in graph.neighbors(node):
            ng = g + c
            if nxt not in closed and (nxt not in g_cost or ng < g_cost[nxt]):
                g_cost[nxt] = ng
                parent[nxt] = node
                pop_func.push(open_container, nxt, ng)
    return None, None, order


class BFS策略:
    def init(self, start): return deque([(start, 0)])
    def pop(self, q): return q.popleft()
    def push(self, q, node, g): q.append((node, g))

class DFS策略:
    def init(self, start): return [(start, 0)]
    def pop(self, q): return q.pop()
    def push(self, q, node, g): q.append((node, g))

class UCS策略:
    """一致代价:优先展开累计代价最小的节点。"""
    def init(self, start): return [(0, id(start), start, 0)]
    def pop(self, h):
        _, _, node, g = heapq.heappop(h)
        return node, g
    def push(self, h, node, g):
        heapq.heappush(h, (g, id(node), node, g))


if __name__ == "__main__":
    g = Graph()
    # 一张示例城市公路网(边权=距离)
    edges = [("A", "B", 4), ("A", "C", 2), ("B", "D", 3), ("C", "D", 6),
             ("C", "E", 5), ("D", "E", 1), ("D", "F", 7), ("E", "F", 2)]
    for u, v, w in edges:
        g.add_edge(u, v, w)

    for name, strat in [("广度优先 BFS", BFS策略()),
                        ("深度优先 DFS", DFS策略()),
                        ("一致代价 UCS", UCS策略())]:
        path, cost, order = generic_search(g, "A", "F", strat)
        print(f"{name}: 路径 {path},代价 {cost},扩展顺序 {order}")
运行输出
广度优先 BFS: 路径 ['A', 'B', 'D', 'F'],代价 14,扩展顺序 ['A', 'C', 'B', 'E', 'D', 'F']
深度优先 DFS: 路径 ['A', 'C', 'E', 'F'],代价 9,扩展顺序 ['A', 'C', 'E', 'F']
一致代价 UCS: 路径 ['A', 'C', 'E', 'F'],代价 9,扩展顺序 ['A', 'C', 'B', 'D', 'E', 'F']

注意 BFS 在边权不等时不能保证最优(它给出的 A→B→D→F 代价为 14), UCS 才找到了真正的最短路径 A→C→E→F,代价 9。DFS 碰巧也找到了该路径,但它不保证最优、也不保证完备。

4.3 盲目搜索

4.3.1 广度优先搜索(BFS)

BFS 按"先产生的节点先扩展"(先进先出)逐层推进。在边权相等时,它找到的路径经过节点最少, 完备且最优;但需要把整层节点保存在内存中,空间复杂度高。

4.3.2 深度优先搜索(DFS)

DFS 按"后产生的节点先扩展"(后进先出)沿一个分支走到底,再回溯。 内存开销小,但可能落入很深的分支而错过浅层的目标,不完备、不最优。 改进方案是有界深度优先与迭代加深搜索(IDS):逐次放宽深度上限重复 DFS,兼顾小内存与完备性。

ids_maze.py
"""迷宫上的 DFS(递归回溯)与迭代加深搜索 IDS"""

def dfs_path(grid, start, goal, path=None, visited=None):
    """grid: 二维列表,0 可通行,1 障碍。递归 DFS 找一条可行路径。"""
    if path is None:
        path, visited = [start], {start}
    if start == goal:
        return path
    r, c = start
    for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
        nr, nc = r + dr, c + dc
        if (0 <= nr < len(grid) and 0 <= nc < len(grid[0])
                and grid[nr][nc] == 0 and (nr, nc) not in visited):
            visited.add((nr, nc))
            ans = dfs_path(grid, (nr, nc), goal, path + [(nr, nc)], visited)
            if ans:
                return ans
    return None

def ids(grid, start, goal, max_depth=30):
    """迭代加深:深度限制 0,1,2,... 逐次尝试 DFS。"""
    def dls(node, depth, path, visited):
        if node == goal:
            return path
        if depth == 0:
            return None
        r, c = node
        for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
            nr, nc = r + dr, c + dc
            if (0 <= nr < len(grid) and 0 <= nc < len(grid[0])
                    and grid[nr][nc] == 0 and (nr, nc) not in visited):
                visited.add((nr, nc))
                ans = dls((nr, nc), depth - 1, path + [(nr, nc)], visited)
                if ans:
                    return ans
                visited.discard((nr, nc))
        return None

    for limit in range(max_depth):
        ans = dls(start, limit, [start], {start})
        if ans is not None:
            return ans, limit
    return None, max_depth

if __name__ == "__main__":
    maze = [
        [0, 1, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 1, 0],
        [1, 1, 0, 0, 0],
        [0, 0, 0, 1, 0],
    ]
    print("DFS 路径:", dfs_path(maze, (0, 0), (4, 4)))
    path, used_limit = ids(maze, (0, 0), (4, 4))
    print(f"IDS 路径(深度上限 {used_limit}):", path)

4.3.3 一致代价搜索(UCS)

UCS 每次扩展从根到当前代价 g(n) 最小的节点(用优先队列实现),可看作 Dijkstra 算法。 当边代价均为正时,它完备且最优,但仍是无信息搜索,会向各个方向均匀扩展。

指标BFSDFSUCSIDS
完备性是否是是
最优性边权相等时否是边权相等时
时间O(b^d)O(b^m)与代价范围有关O(b^d)
空间O(b^d),大O(bm),小大O(bd),小

注:b 为分支因子,d 为最浅目标深度,m 为最大深度。

4.4 启发式搜索与 A* 算法

启发式搜索利用与问题相关的启发信息(经验知识)引导搜索朝着最有希望的方向前进。 核心工具是估价函数:

f(n) = g(n) + h(n)
g(n):从初始节点到 n 的实际代价(已知)
h(n):从 n 到目标节点的估计代价(启发函数,未知)

◈ A* 算法及其可采纳性

若启发函数满足 h(n) ≤ h*(n)(估计代价永远不高于实际最短代价,称"可采纳的/admissible"), 则 A 算法称为 A* 算法,它一定能找到最优解。 直觉:低估只会让 A* 多搜一些节点,永远不会错过真正的最优路径;高估则可能过早把最优路径上的节点排到后面。

a_star_map.py
"""A* 算法在带权地图上的实现(直线距离作为启发函数)"""
import heapq
import math
from graph_search import Graph

def astar(graph, start, goal, heuristic):
    open_set = [(heuristic(start, goal), 0, start)]   # (f, g, node)
    parent = {start: None}
    g_score = {start: 0}
    expanded = 0

    while open_set:
        f, g, node = heapq.heappop(open_set)
        expanded += 1
        if node == goal:
            path = []
            while node is not None:
                path.append(node)
                node = parent[node]
            return list(reversed(path)), g, expanded
        for nxt, cost in graph.neighbors(node):
            ng = g + cost
            if ng < g_score.get(nxt, float("inf")):
                g_score[nxt] = ng
                parent[nxt] = node
                heapq.heappush(open_set, (ng + heuristic(nxt, goal), ng, nxt))
    return None, None, expanded

# 城市坐标,用于计算欧氏距离启发函数
COORDS = {
    "A": (0, 0), "B": (3, 2), "C": (1, 3), "D": (5, 4),
    "E": (4, 7), "F": (6, 8),
}
def euclidean(a, b):
    x1, y1 = COORDS[a]; x2, y2 = COORDS[b]
    return math.hypot(x1 - x2, y1 - y2)

if __name__ == "__main__":
    g = Graph()
    for u, v, w in [("A", "B", 4), ("A", "C", 2), ("B", "D", 3), ("C", "D", 6),
                    ("C", "E", 5), ("D", "E", 1), ("D", "F", 7), ("E", "F", 2)]:
        g.add_edge(u, v, w)
    path, cost, expanded = astar(g, "A", "F", euclidean)
    print(f"A* 最短路径:{path},代价 {cost},扩展节点数 {expanded}")
    print("(欧氏距离 ≤ 实际道路长度,启发函数可采纳,故结果最优)")
运行输出
A* 最短路径:['A', 'C', 'E', 'F'],代价 9,扩展节点数 4
(欧氏距离 ≤ 实际道路长度,启发函数可采纳,故结果最优)

常见的可采纳启发函数:网格寻路用曼哈顿距离、欧氏距离; 八数码用错位棋子数、曼哈顿距离之和。h 越接近真实代价(信息量越大), A* 扩展的节点越少——极端情况下 h = h* 时 A* 沿最优路径直达目标。

4.5 实战:A* 求解八数码问题

八数码(3×3 拼图)问题:棋盘上有 8 个编号棋子和 1 个空格,每次只能把与空格相邻的棋子移入空格, 要求从初始排列移动到目标排列。这是演示状态空间搜索的经典问题。

eight_puzzle.py
"""A* 求解八数码:启发函数 = 所有棋子到目标位置的曼哈顿距离之和"""
import heapq

class Puzzle:
    goal = (1, 2, 3, 4, 5, 6, 7, 8, 0)     # 0 表示空格

    def __init__(self, board):
        self.board = tuple(board)

    def neighbors(self, state):
        """生成空格上下左右移动后的后继状态。"""
        idx = state.index(0)
        r, c = divmod(idx, 3)
        result = []
        for dr, dc, move in [(-1, 0, "上"), (1, 0, "下"),
                             (0, -1, "左"), (0, 1, "右")]:
            nr, nc = r + dr, c + dc
            if 0 <= nr < 3 and 0 <= nc < 3:
                j = nr * 3 + nc
                lst = list(state)
                lst[idx], lst[j] = lst[j], lst[idx]
                result.append((tuple(lst), move))
        return result

    @staticmethod
    def manhattan(state):
        """曼哈顿距离之和:可采纳启发函数。"""
        dist = 0
        for i, tile in enumerate(state):
            if tile == 0:
                continue
            goal_pos = tile - 1
            dist += abs(i // 3 - goal_pos // 3) + abs(i % 3 - goal_pos % 3)
        return dist

    def solve(self):
        start = self.board
        h0 = self.manhattan(start)
        heap = [(h0, 0, start, [])]        # (f, g, 状态, 走法)
        best_g = {start: 0}
        while heap:
            f, g, state, moves = heapq.heappop(heap)
            if state == self.goal:
                return moves, g
            if g > best_g.get(state, 1e9):
                continue
            for nxt, move in self.neighbors(state):
                ng = g + 1
                if ng < best_g.get(nxt, 1e9):
                    best_g[nxt] = ng
                    heapq.heappush(heap,
                                   (ng + self.manhattan(nxt), ng, nxt, moves + [move]))
        return None, None

    @staticmethod
    def show(state):
        for i in range(0, 9, 3):
            print("  " + " ".join(str(x) if x else "·" for x in state[i:i+3]))

if __name__ == "__main__":
    p = Puzzle([2, 8, 3, 1, 6, 4, 7, 0, 5])
    print("初始布局:")
    p.show(p.board)
    moves, steps = p.solve()
    print(f"\n最少 {steps} 步复原:{' → '.join(moves)}")
运行输出
初始布局:
  2 8 3
  1 6 4
  7 · 5

最少 5 步复原:上 → 右 → 下 → 右 → 上

✦ 动手实验

把 manhattan 换成"错位棋子数"(更弱的启发函数),统计扩展节点数的变化; 再换成恒为 0(退化为 UCS),体会启发信息量如何决定搜索效率。 注意:八数码只有一半的排列有解,可通过逆序数奇偶性判定。

4.6 问题归约与与或树搜索

问题归约法把初始问题通过等价变换分解为子问题集合,直到归约到本原问题 (不能再分解、可直接解答的问题)。归约操作有三种:分解(与,所有子问题都要解决)、 等价变换(或,任一子问题解决即可)。它们构成与或树:

解树的代价

设 h(n) 为节点 n 到终叶集合的代价,边代价记 c(n, nᵢ):
与节点:h(n) = Σᵢ [ c(n, nᵢ) + h(nᵢ) ] (和代价)
或节点:h(n) = minᵢ [ c(n, nᵢ) + h(nᵢ) ]

4.6.1 Python 实现:与或树的解树搜索与代价计算

下面用节点对象显式区分 AND / OR 节点,递归完成可解性标记与最小代价解树的计算。

and_or_search.py
"""与或树:可解性回传 + 解树最小代价计算(自底向上动态规划)"""
import math

class AO_Node:
    def __init__(self, name, kind="OR", terminal=False, cost=0):
        self.name = name
        self.kind = kind            # 'AND' 或 'OR'
        self.terminal = terminal    # 是否为本原(终叶)问题
        self.cost = cost            # 终叶自身代价
        self.children = []          # [(子节点, 边代价)]

    def add(self, child, edge_cost=1):
        self.children.append((child, edge_cost))
        return self


def solve(node):
    """返回 (是否可解, 最小代价, 解树结构)。"""
    if node.terminal:
        return True, node.cost, (node.name, [])

    if not node.children:
        return False, math.inf, None       # 非终叶却没有子节点:不可解

    results = []
    for child, edge in node.children:
        ok, c, tree = solve(child)
        results.append((ok, edge + c, tree))

    if node.kind == "AND":
        if all(ok for ok, _, _ in results):
            total = sum(c for _, c, _ in results)
            subtrees = [t for _, _, t in results]
            return True, total, (node.name + "[AND]", subtrees)
        return False, math.inf, None
    else:  # OR
        feasible = [(c, t) for ok, c, t in results if ok]
        if not feasible:
            return False, math.inf, None
        c, t = min(feasible, key=lambda x: x[0])
        return True, c, (node.name + "[OR]", [t])


def print_tree(tree, depth=0):
    name, subs = tree
    print("  " * depth + name)
    for s in subs:
        print_tree(s, depth + 1)


if __name__ == "__main__":
    # 问题 P 分解为两条或路径:
    #   路径一:P -> A(AND 子问题 A1、A2)
    #   路径二:P -> B(AND 子问题 B1、B2)
    A1 = AO_Node("A1", terminal=True, cost=2)
    A2 = AO_Node("A2", terminal=True, cost=3)
    B1 = AO_Node("B1", terminal=True, cost=1)
    B2 = AO_Node("B2", terminal=True, cost=1)
    A = AO_Node("A", kind="AND"); A.add(A1).add(A2)
    B = AO_Node("B", kind="AND"); B.add(B1, edge_cost=4).add(B2, edge_cost=4)
    P = AO_Node("P", kind="OR");  P.add(A).add(B)

    ok, cost, tree = solve(P)
    print(f"问题可解:{ok},最小解树代价:{cost}")
    print("最优解树结构:")
    print_tree(tree)
运行输出
问题可解:True,最小解树代价:7
最优解树结构:
P[OR]
  A[AND]
    A1
    A2

计算说明:路径一(经 A)代价 = 边(1) + A1(2) + 边(1) + A2(3) = 7; 路径二(经 B)代价 = 边(4) + B1(1) + 边(4) + B2(1) = 10。 AND 节点先把所有子问题代价求和,OR 节点再在两条路径中取最小, 因此程序选择了路径一。动手实验:把 B 的两条边权都改为 1,则路径二代价降为 4, 程序将改选 B——可借此验证 AND 求和、OR 取 min 的回传逻辑。 更复杂的显式与或图(含环)则使用 AO* 算法:自顶向下选最好的局部解图、自底向上修正代价,循环直到解图不再变化。

◈ 与或树搜索的典型应用

定理证明(归结反演的目标树)、机器人规划(STRIPS 中的合取目标分解)、 符号积分、故障诊断中的"假设—证据"树,本质上都是与或树搜索。

4.7 博弈搜索:极小极大与 α-β 剪枝

博弈是智能的试金石。在井字棋、象棋、围棋这类二人零和、全信息、非偶然博弈中, 双方轮流行动,一方之得即另一方之失。可以把所有可能的对弈过程展开为一棵博弈树: 初始局面为根,每个走法产生一个子节点,交替出现 MAX 节点(我方,取最大值)和 MIN 节点(对手,取最小值)。

4.7.1 极小极大算法(Minimax)

算法思想:把博弈树展开到指定深度,对叶节点用静态估值函数打分 (分数越高对我方越有利);然后自叶向根回传——MIN 节点取所有子节点的最小值,MAX 节点取最大值。 根节点得分最高的走法就是最优决策。

minimax_tree.py
"""极小极大:在一棵深度为 4 的二叉博弈树上求根节点的最优值"""
import math

LEAVES = [3, 5, 6, 9, 1, 2, 0, -1, 7, 4, -2, 8, 2, 3, 6, 1]
_index = 0

def evaluate_leaf():
    """模拟读取叶节点的静态估值。"""
    global _index
    v = LEAVES[_index]
    _index += 1
    return v

def minimax(depth, is_max):
    if depth == 4:                          # 到达叶节点
        return evaluate_leaf()
    if is_max:
        value = -math.inf
        for _ in range(2):                  # 二叉博弈树
            value = max(value, minimax(depth + 1, False))
        return value
    else:
        value = math.inf
        for _ in range(2):
            value = min(value, minimax(depth + 1, True))
        return value

if __name__ == "__main__":
    best = minimax(0, True)
    print(f"极小极大最优值:{best},共访问叶子 {_index} 个")
运行输出
极小极大最优值:3,共访问叶子 16 个

4.7.2 Alpha-Beta 剪枝

Minimax 必须把整棵树全部展开。观察到:若某个走法已被证明不可能优于已知走法, 就不必继续评估它的其余分支。这就是 α-β 剪枝,维护两个边界:

alphabeta_tree.py
"""Alpha-Beta 剪枝:与 minimax 结果完全相同,但访问的叶子更少"""
import math

LEAVES = [3, 5, 6, 9, 1, 2, 0, -1, 7, 4, -2, 8, 2, 3, 6, 1]
_index = 0

def evaluate_leaf():
    global _index
    v = LEAVES[_index]
    _index += 1
    return v

def alphabeta(depth, is_max, alpha, beta):
    if depth == 4:
        return evaluate_leaf()
    if is_max:
        value = -math.inf
        for _ in range(2):
            value = max(value, alphabeta(depth + 1, False, alpha, beta))
            alpha = max(alpha, value)
            if value >= beta:                 # β 剪枝
                break
        return value
    else:
        value = math.inf
        for _ in range(2):
            value = min(value, alphabeta(depth + 1, True, alpha, beta))
            beta = min(beta, value)
            if value <= alpha:                # α 剪枝
                break
        return value

if __name__ == "__main__":
    best = alphabeta(0, True, -math.inf, math.inf)
    print(f"α-β 剪枝最优值:{best},共访问叶子 {_index} 个(无剪枝需 16 个)")
运行输出
α-β 剪枝最优值:3,共访问叶子 8 个(无剪枝需 16 个)

剪枝不改变决策结果,只减少计算量。理想走子顺序下,分支因子从 b 降到约 √b, 同样时间内搜索深度可以翻倍——这是国际象棋程序(如深蓝)能实用的关键。下面把它用于真实的井字棋:

tictactoe.py
"""井字棋:极小极大 + α-β 剪枝的完美 AI(执 O,后手)"""
import math

class TicTacToe:
    def __init__(self):
        self.board = [" "] * 9
        self.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)]

    def winner(self):
        for a, b, c in self.lines:
            if self.board[a] == self.board[b] == self.board[c] != " ":
                return self.board[a]
        return "平局" if " " not in self.board else None

    def minimax(self, is_ai_turn, alpha, beta):
        """AI=O 最大化,玩家=X 最小化。返回 (局面得分, 最佳落子)。"""
        w = self.winner()
        if w == "O":
            return 1, None
        if w == "X":
            return -1, None
        if w == "平局":
            return 0, None

        best_move = None
        if is_ai_turn:
            best = -math.inf
            for i in range(9):
                if self.board[i] == " ":
                    self.board[i] = "O"
                    score, _ = self.minimax(False, alpha, beta)
                    self.board[i] = " "
                    if score > best:
                        best, best_move = score, i
                    alpha = max(alpha, best)
                    if best >= beta:
                        break
            return best, best_move
        else:
            best = math.inf
            for i in range(9):
                if self.board[i] == " ":
                    self.board[i] = "X"
                    score, _ = self.minimax(True, alpha, beta)
                    self.board[i] = " "
                    if score < best:
                        best, best_move = score, i
                    beta = min(beta, best)
                    if best <= alpha:
                        break
            return best, best_move

    def ai_move(self):
        _, move = self.minimax(True, -math.inf, math.inf)
        self.board[move] = "O"
        return move

    def play_demo(self):
        """演示:玩家按固定策略在 0,2,6,8 落子,AI 应对。"""
        for player_pos in [0, 2, 6, 8]:
            if self.winner():
                break
            self.board[player_pos] = "X"
            if not self.winner():
                self.ai_move()
        for i in range(0, 9, 3):
            print(" | ".join(self.board[i:i+3]))
            if i < 6:
                print("---------")
        print("结果:", self.winner())

if __name__ == "__main__":
    TicTacToe().play_demo()
运行输出
X | O | X
---------
  | O |  
---------
X | O | X
结果: O

该 AI 搜索了完整博弈树,永远不败(双方完美时为平局)。 扩展到更复杂的博弈时,还需配合有限深度 + 局面评估函数(深度处用启发估值代替胜负)、 置换表(棋形缓存)和迭代加深等技术。

本章小结

← 上一章
第三章 · 确定性推理