"""
=============================================================================
第 14 讲 · 代码 1：前向搜索——博弈树、极小极大、α-β 剪枝与有限深度搜索
=============================================================================

对应讲义：lectures/lecture14_mcts_alphazero.md 第 1–2 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
模型（游戏规则）已知时，最直接的决策方法是**前向搜索**：从当前局面出发，
把"我走这步、对手走那步、我再走……"的所有可能展开成一棵树，从叶子往回算出每步的价值。

    - 第 2 讲的动态规划要遍历**所有**状态；前向搜索只关心**从当前局面能到达**的状态。
    - 但即使如此，树也会随深度指数爆炸——围棋的博弈树比宇宙中的原子还多。

本文件：
    1. 井字棋的博弈树有多大？记忆化（把相同的局面合并）能省多少？
    2. 极小极大：井字棋的完美解；一个需要"看两步"的例子
    3. α-β 剪枝：不改变结果，只跳过"不可能影响决策"的分支；着法顺序的影响
    4. 有限深度搜索 + 启发式评估：搜索越深越好，但代价指数增长——这就是 MCTS 和 AlphaZero 要解决的问题

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    cd code/lecture14
    python 01_minimax_search.py      （约 5 秒）
"""

import numpy as np

from tictactoe import (EMPTY, LINES, critical_positions, legal_moves, optimal_moves, play, render,
                       solve, to_move, winner)


# =============================================================================
# 第 1 部分：博弈树的大小
# =============================================================================
def count_tree(board):
    """
    不做任何合并，完整展开博弈树，数一共有多少个节点、多少局完整的对局（叶子）。
    【返回】(节点数, 叶子数)
    【注意】这是故意写得"笨"的：同一个局面从不同的走棋顺序到达，会被重复计算很多次。
    """
    if winner(board) is not None:
        return 1, 1
    nodes, leaves = 1, 0
    for m in legal_moves(board):
        n, l = count_tree(play(board, m))
        nodes += n
        leaves += l
    return nodes, leaves


# =============================================================================
# 第 2 部分：α-β 剪枝
# =============================================================================
def alphabeta(board, alpha, beta, counter, order=None):
    """
    带 α-β 剪枝的 negamax。返回从"轮到的一方"看的价值；counter[0] 累计访问的节点数。

    【思想】我们在搜索时维护一个窗口 [α, β]：
        α = "我已经确定能拿到的最好结果"，β = "对手已经确定能把我压到的最坏结果"。
        如果某个分支的价值已经 ≥ β，对手绝不会让棋局走到这里（他有更好的选择），
        这个分支剩下的着法就不用看了——这就是"剪枝"。
    【结果】与普通极小极大**完全相同**，只是少看很多节点。
    【参数】order : 着法顺序（位置编号的列表）。好的顺序（先看好棋）能剪掉更多分支。
    """
    counter[0] += 1
    w = winner(board)
    if w is not None:
        return 0 if w == 0 else -1
    moves = legal_moves(board)
    if order is not None:
        moves = sorted(moves, key=order.index)
    best = -2
    for m in moves:
        v = -alphabeta(play(board, m), -beta, -alpha, counter, order)
        best = max(best, v)
        alpha = max(alpha, v)
        if alpha >= beta:
            break                                  # 剪枝：对手不会让棋局走到这里
    return best


# =============================================================================
# 第 3 部分：有限深度搜索 + 启发式评估
# =============================================================================
def heuristic(board):
    """
    一个简单的"人类经验"评估函数：从"轮到的一方"看，局面有多好（-1 到 1 之间）。

    对每一条线（横、竖、斜共 8 条）：
        - 只有我的棋子、没有对手的棋子：我有机会，按棋子数加分（1 子 +1，2 子 +3）
        - 只有对手的棋子：对手有机会，按同样的方式扣分
    最后缩放到 [-1, 1] 之间，避免与"真的赢了"（1）混淆。
    【RL 联系】这就是"价值函数的近似"——AlphaZero 用神经网络**学出**这个函数，而不是人来设计。
    """
    me = to_move(board)
    score = 0.0
    for a, b, c in LINES:
        cells = (board[a], board[b], board[c])
        mine, theirs = cells.count(me), cells.count(-me)
        if theirs == 0 and mine > 0:
            score += {1: 1, 2: 3}[mine]
        elif mine == 0 and theirs > 0:
            score -= {1: 1, 2: 3}[theirs]
    return 0.9 * np.tanh(score / 6)


def depth_limited(board, depth, counter):
    """
    只往前看 depth 步的 negamax：到达深度限制时，用 heuristic() 估计局面的价值。
    depth = 9 时等于完整的极小极大（井字棋最多 9 步）。
    """
    counter[0] += 1
    w = winner(board)
    if w is not None:
        return 0 if w == 0 else -1
    if depth == 0:
        return heuristic(board)
    return max(-depth_limited(play(board, m), depth - 1, counter) for m in legal_moves(board))


def depth_limited_move(board, depth, counter, rng):
    """用有限深度搜索选一步（并列时随机选）。"""
    vals = {m: -depth_limited(play(board, m), depth - 1, counter) for m in legal_moves(board)}
    best = max(vals.values())
    cands = [m for m, v in vals.items() if v >= best - 1e-12]
    return cands[rng.integers(len(cands))]


def main():
    rng = np.random.default_rng(0)

    # ---------------------------------------------------------------------
    print("=" * 78)
    print("演示 1：井字棋的博弈树有多大？")
    print("=" * 78)
    nodes, leaves = count_tree(EMPTY)
    from tictactoe import all_positions
    n_pos = len(all_positions())
    print(f"  完整展开的博弈树：{nodes:,} 个节点，{leaves:,} 局不同的完整对局")
    print(f"  但不同的（未结束的）局面只有 {n_pos:,} 个——很多局面可以由不同的走棋顺序到达")
    print(f"  （比如 X 走 0、O 走 4、X 走 8，和 X 走 8、O 走 4、X 走 0，得到同一个局面）。")
    print("  → 把相同的局面合并（记忆化 / 置换表）能省下一百多倍的计算。")
    print("    但对围棋来说，即使合并，局面数也约有 10^170 个，远远超出任何计算机的能力。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 2：极小极大——井字棋的完美解")
    print("=" * 78)
    print(f"  空棋盘的价值（X 先走）：{solve(EMPTY)}  → 双方都完美下棋，结果是和棋。")
    first = {m: -solve(play(EMPTY, m)) for m in range(9)}
    print(f"  X 第一步走每个位置之后的价值：{first}（全都是和棋）")
    example = (1, 0, 0,
               0, -1, 0,
               0, 0, 1)                       # X 走了 0 和 8，O 走了 4，轮到 O
    print("  一个局面（轮到 O）：")
    print("    " + render(example).replace("\n", "\n    "))
    vals = {m: -solve(play(example, m)) for m in legal_moves(example)}
    print(f"  O 的每一步的价值：{vals}")
    print(f"  最优着法：{sorted(optimal_moves(example))}")
    print("  → O 必须走边（1、3、5、7），走角（2 或 6）会输！")
    print("    走角之后，X 可以一步同时制造两个威胁（\"双杀\"），O 只能堵一个。")
    print("    这种\"看起来很自然、其实会输\"的着法，需要往前看好几步才能发现。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 3：α-β 剪枝——同样的结果，少看很多节点")
    print("=" * 78)
    for label, order in [("按编号顺序（0, 1, 2, ...）", None),
                         ("好的顺序（中心、角、边）", [4, 0, 2, 6, 8, 1, 3, 5, 7])]:
        counter = [0]
        v = alphabeta(EMPTY, -2, 2, counter, order)
        print(f"  {label}：价值 = {v}，访问了 {counter[0]:,} 个节点（完整的树有 {nodes:,} 个）")
    print("  → α-β 剪枝的结果与极小极大**完全相同**，但只看了很小一部分节点。")
    print("    着法顺序越好（越早看到好棋），剪掉的越多。最理想时，同样的计算量能搜索大约两倍的深度。")
    print("    国际象棋程序（包括 1997 年击败卡斯帕罗夫的深蓝）的核心就是 α-β 搜索 + 人工设计的评估函数。")

    # ---------------------------------------------------------------------
    print("\n" + "=" * 78)
    print("演示 4：有限深度搜索 + 启发式评估——看得越深越好，但代价指数增长")
    print("=" * 78)
    crit = critical_positions()
    print(f"  在 {len(crit)} 个\"关键\"局面（至少有一步会让结果变差）上，检查每种深度选出的着法是否最优：")
    print("     深度   选到最优着法的比例   关键局面上平均每步的节点数   从空棋盘搜索的节点数")
    for depth in [1, 2, 3, 4, 5, 9]:
        correct, total_nodes = 0, 0
        for b in crit:
            counter = [0]
            m = depth_limited_move(b, depth, counter, rng)
            total_nodes += counter[0]
            correct += m in optimal_moves(b)
        c0 = [0]
        depth_limited_move(EMPTY, depth, c0, rng)
        print(f"      {depth}          {correct / len(crit) * 100:5.1f}%                  {total_nodes / len(crit):9.1f}"
              f"                  {c0[0]:9,d}")
    print("  观察：")
    print("  - 深度 1（只看自己这一步，然后用启发式评估）已经能下对大多数局面，但不是全部。")
    print("  - 深度越大，下得越好；深度 9 就是完整的极小极大，100% 正确。")
    print("  - 但访问的节点数随深度**指数**增长——从空棋盘搜索时最明显（每深一层乘以 5～9，空位越来越少，倍数逐渐减小）。")
    print("    关键局面大多在棋局后半段，剩下的空位少，所以平均节点数增长得慢一些。")
    print("  - 这里的启发式评估设计得不错，深度 1 就有 96% 正确——**好的评估函数能大大减少所需的搜索**。")
    print("    井字棋每步最多 9 种选择，还能搜到底；")
    print("    围棋每步约 250 种选择、一盘棋约 150 步，完整搜索是不可能的。")
    print("  - 两个出路：(1) 不要平均地搜索每个分支，把计算集中在有希望的着法上——**MCTS**；")
    print("    (2) 用更好的评估函数，看得浅一些也能判断准确——**学出来的价值网络**（AlphaGo / AlphaZero）。")


if __name__ == "__main__":
    main()
