Soma Zero Tutorials
🔍 搜索功能尚未开启,敬请期待。

2.2 搜索框架工程化(α-β 实战)

上一节我们论证了“五子棋搜不到底”。这一节就把第一章的 α-β 老老实实搬过来,亲眼看它在大盘上慢到不可用,然后动两刀工程化改造:先用“候选生成”把分支从 225 砍到几十,再用“迭代加深”把搜索装进一个时间预算里。这两刀是后面一切的前提——评估函数、威胁搜索都要架在这个跑得动的框架上。

一、把第一章的 α-β 搬过来,亲眼看它崩

第一章的 α-β 几乎能直接用,只需两个来自 1.4、1.5 的小改动:其一,五子棋到不了终局,所以必须配一个评估函数(下一节细讲,这里先假设有个 evaluate());其二,必须设深度上限,搜到就用评估收尾。

改完一跑,问题立刻暴露:它慢得令人发指。根子在 legal_moves()——在五子棋里它返回所有空点,开局就是 225 个。α-β 每一层都要在一两百个分支里打转,于是哪怕只想搜 3、4 层,节点数也轻松上亿,一步棋要算好几分钟。第一章那个在井字棋上眨眼出招的引擎,到这儿基本动不了。我们必须先解决“分支太多”这个最致命的瓶颈。

二、第一刀:只在已有棋子附近落子

这一刀靠的是一条朴素却极有效的领域知识在五子棋里,把子下在远离所有棋子的空旷角落,几乎永远是废棋。有意义的着法,必然是在已有棋子的附近——要么接着自己的连子进攻,要么贴着对手的棋防守。

于是我们不再把 225 个空点都当候选,而是只考虑“距离某个已有棋子一两格以内”的空点。这一刀下去,分支因子常常从 200 多直接掉到几十,搜索量呈指数级缩小:

def candidate_moves(board, radius=1):
    """只考虑'已有棋子附近'的空点,大幅缩小分支。"""
    N = len(board)
    stones = list(zip(*np.nonzero(board)))      # 所有已落子的位置
    if not stones:
        return [(N // 2, N // 2)]               # 空盘:直接下天元(正中)

    cand = set()
    for (r, c) in stones:
        for dr in range(-radius, radius + 1):
            for dc in range(-radius, radius + 1):
                rr, cc = r + dr, c + dc
                if 0 <= rr < N and 0 <= cc < N and board[rr, cc] == 0:
                    cand.add((rr, cc))          # 邻域里的空点才算候选
    return list(cand)

把搜索里的 legal_moves() 换成 candidate_moves(),引擎立刻“活”了过来——同样的时间,它能搜得深得多。这是我们对“树太大”动的第一刀,也是最立竿见影的一刀。当然它略有取舍:极少数“脱先”到远处的妙手会被漏掉,但在 v1 里这点损失完全值得。(radius 设 1 还是 2 是个权衡:大一点更安全、但分支更多。)

三、第二刀:迭代加深,把搜索装进时间预算

还有个现实问题:到底该搜几层?局面有简有繁,写死一个深度并不好——简单局面本可以搜更深,复杂局面搜浅一点才不超时。更聪明的办法叫迭代加深(Iterative Deepening)先搜 1 层,再搜 2 层,再 3 层……一旦时间用完,就返回目前搜得最深的那次给出的最佳着法。

import time

def best_move(engine, time_budget=2.0):
    """迭代加深:从浅到深逐层搜,时间一到就返回目前最好的一步。"""
    start = time.time()
    best = None
    depth = 1
    while time.time() - start < time_budget:
        best = alphabeta_root(engine, depth)    # 搜 depth 层,返回最佳走法
        depth += 1                              # 没超时就再往深搜一层
    return best

你可能会担心:从 1 层重搜不是白费功夫吗?其实几乎不浪费——因为搜索量随深度指数增长,最深的那一次就占了绝大部分开销,前面那些浅层搜索的成本加起来都微不足道。而迭代加深换来两个大好处:一是随时可叫停(anytime),给多少时间就用多少;二是个意外之喜——上一层搜出的最佳着法,可以在下一层优先尝试,这恰好喂给了 α-β 它最爱的“好棋先搜”,让剪枝更狠。这条线,我们下下节(2.4 走子排序)会继续做文章。

四、小结与下一节

  • 瓶颈:朴素 α-β 在五子棋上因 legal_moves() 返回 ~225 个空点而慢到不可用。
  • 候选生成:只在已有棋子邻域内取候选,分支从 200+ 砍到几十,最立竿见影的一刀。
  • 迭代加深:逐层加深、超时即返回;几乎不浪费,还带来 anytime 特性和“上层最佳着法优先”的排序红利。

现在引擎跑得动了,但它还很“蠢”——因为我们一直假装有个 evaluate(),却还没真正写它。而评估函数正是五子棋的灵魂:它决定了引擎到底懂不懂棋。下一节 2.3 启发式评估函数,我们就来认识“活四、冲四、活三”这些棋型,把它们变成分数。