【前端研究算法的一天 】AI 的背后离不开这些技术算法 | 图搜索算法的运用、Minmax、αβ Pruning、Negamax

971 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

代码仓库,走过路过点一个 Star ✨

做这个的动机

其实一开始我并不打算做一个 AI 出来,因为我觉得挺难写的,实际上真的挺难写的,我的小伙伴在看到我的国际象棋之后,给仓库提了一个 issue 让我 add AI。也提了挺久了,我寻思着清明节在家可以写上一写。实际上我昨天写了一整天。

项目的改造

我们在增加 AI 之前,需要考虑到一写功能,比如 AI 玩黑棋还是白棋,关闭 AI,选择 Ai 等级这些功能。

对于 Context 部分的改造

export interface IAIProps {
    value: boolean;
    level: number;
    turn: string;
}
export interface HAIProps {
    playWhite: () => void;
    playBlack: () => void;
    human: () => void;
    setLevel: (level: number) => void;
}
// ...
interface BoardContextProps {
  AI: IAIProps;
  aiFns: HAIProps;
  ...
}
const BoardContenxtProvider: FC = ({ children }) => {
  const [isAI, setToAI] = useState({
    value: false,
    level: 0,
  });
  return <BoardContext.Provider
           value={{ 
        ...,
        AI,
        aiFns,
   }}>
    {children}
  </BoardContext.Provider>
}

其实在 Context 中大篇幅的去写这个功能是会让以后代码产生心智负担的,所以我把这个封装成了一个 Custom Hooks,这样上述代码就会变得非常干净,就是一个 Context。

import { useState, useMemo } from "react";

export interface IAIProps {
    value: boolean;
    level: number;
    turn: string;
}
export interface HAIProps {
    playWhite: () => void;
    playBlack: () => void;
    human: () => void;
    setLevel: (level: number) => void;
}
const useAI = (init: IAIProps): [ai: IAIProps, fns: HAIProps] => {
    const [ai, setAI] = useState(init);
    const fns = useMemo(() => ({
        playWhite: () => {
            setAI({
                ...ai,
                value: true,
                turn: "w"
            });
        },
        playBlack: () => {
            setAI({
                ...ai,
                value: true,
                turn: "b"
            });
        },
        human: () => {
            setAI({
                ...ai,
                value: false,
                turn: "w"
            });
        },
        setLevel: (level: number) => {
            setAI({
                ...ai,
                level
            });
        }
    }), [ai])
    return [ai, fns]
}

export default useAI;


UI 改造

所以在这个 UI 里面调用这些方法就非常简单,我并不需要在 tsx 中大篇幅的去操作 Context 的数据,这样代码的可读性也会大幅的提上,因为它看起来就和 html 没有什么区别。

在调用的地方对于 Context 中的值来操作会导致你对于这个 Context 值修改的代码散落在各个文件中。因为 useEffect 的存在,可能你会遇到一个问题,那就是 useEffect 调用的顺序,如果在大型工程中,你可能会觉得我明明没有写这一段逻辑,为什么我的代码里面值变化了?为什么就执行了?这时候一个全新的 Bug 就产生了,有时候修改了起来可能需要花上一天时间。

所以我们把操作数据的方法都放到一个地方去统一的执行,这样前人栽树后人乘凉。

<h4>AI? {ai.value ? 'ON' : 'OFF'}</h4>
<h5>AI Play {ai.turn === 'w' ? 'White' : 'Black'}</h5>
<div className="row">
  <button className='btn-small ' onClick={aiFns.playWhite}>AI Play White</button>
  <button className='btn-small ' onClick={aiFns.playBlack}>AI Play Black</button>
  <button className='btn-small ' onClick={aiFns.human}>Human</button>
</div>

STUPID VERSION

设计原理

一个愚蠢的AI需要怎么设计呢,当然是随机啦,从当前可以移动的 moves 中随机的 pick 一个出来就行了。

代码实现

chess.js 实际上提供了除了 AI 之外所有可以使用的方法,而在我之前几篇文章中就写到了 FEN 这个记法,这是一个用于描述当前棋局的格式。我们这里主要用到 chess.js 提供的两个方法 moves()fen()

import Chess from 'chess.js';
import shuffle from 'lodash/shuffle';

export const stupidVersion = (fen: string) => {
    const chessboard = new Chess(fen);
    const moves = chessboard.moves();
    return shuffle(moves)[0];
}
 useEffect(() => {
   // play black
   if(isAI.value && turn === 'b') {
     // use AI
     const fen = chessboard.fen();
     const stupidMove = stupidVersion(fen);
     console.log(fen);
     console.log(stupidMove);
     setTimeout(() => {
       chessboard.move(stupidMove);
       setTurn(chessboard.turn());
     }, 1000);
   }
 }, [turn, isAI, chessboard, setTurn]);

关于shuffle这个方法,实际上我更喜欢这种能直观表达的函数名,而不愿意去写 Math.random()这个方法的,虽然达到的效果可能是一样的。

一种头铁的优化

在熟悉了规则之后,我们其实知道各个棋子的战术价值,比如后最🐂🍺,那么我们是否可以优先吃掉这个后,Checkmate 出现在可能的 moves 中,我们会优先选择 Checkmate,那么我们在判断这些字符串的时候,就应该是 # > + > x 这样的一种规则,那么我愚蠢的 AI 会提高那么一丢丢的胜率。

高级的版本

简介

对于高级版本来说,其实就是对于状态图搜索如何更快更准确,这个地方其实需要两个东西,一是估值函数,而是搜索算法。当然之前头铁的优化其实也是估值函数,只不顾答案可能不那么尽如人意。

一个愚蠢的 AI 需要具备什么❓

  • 一个愚蠢的估值函数
  • 一个慢的和🐢一样的算法

在这里,对于棋类游戏双方对弈的零和游戏,其实我们都是用的差不多的搜索算法,深度优先搜索,而一个国际象棋 AI 可能有的算法大概有三种

对于棋盘价值的 Evaluation 函数

在下面代码中,其实也只是简单的计算了每个棋子的战术价值,然后得出了一个棋盘总体的评价,实际上一个聪明的 AI 中这个函数会非常复杂,我昨天边写边看也看了很久,可以参考 Chess Programming Wiki 这个对于一个写国际象棋 AI 是有很大帮助的。

evaluate(fen: string) {
  const chessboard = new Chess(fen);
  const scoresArray = chessboard.board().map((row: any[]) => {
    return row.map((piece) => {
      if (piece && piece.color === 'w') {
        return PLAY_WHITE_SCORE[piece.type.toUpperCase()];
      }
      if (piece && piece.color === 'b') {
        return PLAY_BLACK_SCORE[piece.type];
      }
      return 0;
    })
  })
  const score = sum(flatten(scoresArray));
  console.log('score: ', score);
  return score;
}

Minmax

极大极小值算法,将棋盘可能的局势都列出来,第一层 min 代表当前我方可能的 Moves,第二层 max 代表对方可能的 Moves,第三层 min 代表当前我方可能的 Moves,以此类推,我们需要做的就是对这棵树进行一遍深度优先搜索,在 max 层我们选择下一层节点中较大值,在 min 层我们选择下一层节点中最小值,我这里用 Wiki 上的图可能会更加直观一些。

2880px-Minimax.svg.png

这里贴上一些我的代码

minmax() {
  const switchType = (type: string) =>{
    if (type === 'min') {
      return 'max';
    }
    if (type === 'max') {
      return 'min';
    }
  }
  console.log(`target depth: ${this.targetDepth} current depth: ${this.depth}`);
  console.log(`current leaf nodes: ${this.leafNodes().length}`);
  if (this.depth >= this.targetDepth) {
    return this.evaluate(this.fen);
  }
  if (this.type === 'max') {
    let max = -Infinity;
    for (let i = 0; i < this.leafNodes().length; i++) {
      const childFen = this.leafNodes()[i];
      const child = new Node(this.targetDepth, this.depth + 1, childFen, switchType(this.type));
      const value = child.minmax();
      if (value > max) {
        max = value;
      }
    }
    return max;
  }
  if (this.type === 'min') {
    let min = Infinity;
    for (let i = 0; i < this.leafNodes().length; i++) {
      const childFen = this.leafNodes()[i];
      const child = new Node(this.targetDepth, this.depth + 1, childFen, switchType(this.type));
      const value = child.minmax();
      if (value < min) {
        min = value;
      }
    }
    return min;
  }
}

测试计算 3 层,所需要的时间,看这个时间是非常慢的,因为需要计算很多节点。

test('minmax', () => {
    const node = new Node(3, 0, 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', 'max');
    node.minmax();
})
​
PASS  src/operations/ai.test.js (155.997 s)
✓ evaluate boards (37 ms)
✓ minmax (154670 ms)
​
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        158.036 s

Alpha-beta pruning (αβ剪枝)

为什么会有这个算法,因为 Minmax 算法需要遍历每一个节点,你的节点数量多了,自然也就慢了。

Q:所以在遇到这个问题的时候,你能想到的是什么❓

A: 减少节点数量,对咯。所以就会有下面这个图。

2880px-AB_pruning.svg.png

有很多节点是不需要被遍历的,那么我们就需要从这棵树上面修剪掉,对代码稍微的进行一些改造

if (this.type === 'max') {
  // max node edit alpha
  for (let i = 0; i < this.leafNodes().length; i++) {
    const [childFen, move] = this.leafNodes()[i];
    const child = new ABPruningNode(this.targetDepth, this.depth + 1, childFen, this.switchType(this.type), this.alpha, this.beta);
    const childValue = child.minmaxab();
    if (childValue > this.alpha) {
      this.chosenMove = move;
      this.alpha = childValue;
    }
    // this.alpha = Math.max(this.alpha, value);
    if (this.alpha >= this.beta) {
      break;
    }
  }
  return this.alpha;
}
​
PASS  src/operations/ai.test.js (183.808 s)
✓ minmax (152114 ms)
✓ minmax with pruning (29636 ms)
​
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        185.226 s
Ran all test suites related to changed files.

从以上结果来看,剪枝之后的计算速度大幅的提高,因为需要计算的节点数量变得更少了。

Negamax

这个其实和 Minmax 算法类似,不过这个算法会更加简单,写起来也更加方便。

function negamax(node, depth, color) is
    if depth = 0 or node is a terminal node then
        return color × the heuristic value of node
    value := −∞
    for each child of node do
        value := max(value, −negamax(child, depth − 1, −color))
    return value

参考

掘金专栏:如何写一个国际象棋游戏

MinMax

Alpha-beta pruning