「博弈算法」卧龙vs冢虎 天意已定

417 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 7 天,点击查看活动详情

背景

本文是笔者学习博弈算法的沉淀,先介绍一个经典的博弈问题:

给定一个整型数组 arr,代表数值不同的纸牌排成一条线, 玩家A 和 玩家B 依次拿走每张纸牌。

规定:

1. 玩家A先拿,玩家B后拿
2. 每个玩家每次只能拿走最左或最右的纸牌
3. 玩家A和玩家B都绝顶聪明

请返回最后获胜者的分数。

博弈问题中,数组给定,先后手给定以后,谁赢谁输已经有结果了,任你再怎么聪明,也无济于事。

举个例子:给定数组 [2, 100, 6],虽然先手绝顶聪明,但是ta不管选26后手只要选走100就赢了(后手也聪明,不会选那个少的...),先手就只能选最后剩下的数字,也就是说,先手拿到的分数永远只能是 2+6=8,而后手可以拿到100

笔者觉得这个场景很像三国演义诸葛亮(卧龙)司马懿(冢虎)之争:

两人都是绝顶聪明,摆在眼前的三国局势就是给定的这个数组诸葛亮先手司马懿后手

局势一定,胜负已分,这场争斗一开始就注定了卧龙之败,天意难违。

好了,接下来分享思路🍺。

博弈算法的思路

笔者的思路是,先准备 2 个函数,一个是先手函数,一个是后手函数

它们都是接收一个数组,返回自己的最好分数(体现绝顶聪明)。由于算法流程两者需要相互调用,因此需要 base case 以触底返回,这里笔者设计的 base caseL === R,也就是只剩一张牌的时刻。

先手函数

🔥功能:在 arr[L..R]上先手,返回先手获得的最好分数。

比如:

  1. 传入[100], 先手选走100, 返回100
  2. 传入[100,3], 先手选走100, 后手选走3, 返回100
  3. 传入[2,100,6], 先手选走2, 后手选走100, 先手选走6,返回2+6=8

后手函数

🔥功能:在arr[L..R]上后手, 获得的最好分数,也就是说,先手留给你一个情况,你被动接受,然后全力以赴地去算,得到你的最好分数。

这里能得出两个信息🍺:

  1. 先手先选,那么[L..R]跟你没有关系,先手选完的 [L+1,R][L,R-1]才跟你有关系。
  2. 先手绝顶聪明, 那么你能赢的唯一情况就是: 先手怎么选都输。

比如:

  1. 传入[100], 先手选走100, 后手没有得到数, 返回0.
  2. 传入[100,3], 先手选走100, 后手选走3, 返回3.
  3. 传入[2,100,6], 先手选走2, 后手选走100, 先手选走6,返回100.

实现

设计好这两个函数的功能,就可以开始实现了,实现过程把对方看成黑盒即可,只有到达 base case才会返回:

// 先手函数
function f(arr, L, R) {
  // 你是先手,现在是你选。

  if (L === R) {
    // 如果只剩一张牌,先手直接拿走
    return arr[L];
  }

  // 不止一张牌

  // 先手的第一种选择:`拿走最左侧的牌`, 最终分数=左牌分数+我在[L+1,R]上后手获得的最好分数 (我在[L+1,R]上后手,意味着现在[L+1,R]上对方先选)
  // 左牌分数+后续分数
  // 把g看成普通的函数,本体只有先手一个人,只是在g里它不能主动,只能被迫接收对方留下的结果。
  const p1 = arr[L] + g(arr, L + 1, R);
  // 先手的第二种选择:`拿走最右侧的牌`, 最终分数=右牌分数+我在[L,R-1]上后手获得的最好分数 (我在[L,R-1]上后手,意味着现在[L,R-1]上对方先选)
  // 右牌分数+后续分数
  const p2 = arr[R] + g(arr, L, R - 1);
  // 选最大的
  return Math.max(p1, p2);
}


function g(arr, L, R) {
  // 注意⚠️⚠️ 你是后手,现在不是你选!! 主动权在先手的手上!

  if (L === R) {
    // 先手选走唯一的一张牌,后手没有得到数,返回0。
    return 0;
  }

  // 不止一张牌
  // 你是后手,现在你不能选。先手可以选,他有两种选择:

  // 1.先手选走 arr[L],让后手 (就是我) 只能在 [L+1,R] 上`先手`得到最好分数
  const p1 = f(arr, L + 1, R);
  // 2.先手选走 arr[R],让后手 (就是我) 只能在 [L,R-1] 上`先手`得到最好分数
  const p2 = f(arr, L, R - 1);
  // 重点来了: 先手绝顶聪明,他会算出: 选哪边使得`你在剩下的范围得到最优分`比较小,给你留一个小的,所以返回两者的 min。
  // 🔥感受到被动了吗?
  return Math.min(p1, p2);

  // 那是不是后手一定输?
  // 不是!!!  如果这两种可能性都大于先手获得的分,后手就赢了!!   
  // 比如: 我在[2,100,6]上后手,先手的两种选择是: `给我留 [2,100]` 或 `给我留[100,6]`,给我留的这两种情况我都能拿到 100, 我作为后手, 赢了。 
  // 也就是说,后手能赢的情况是: 先手选哪边都会输。
}

最后,实现博弈算法的入口:

function game(arr) {
  if (!arr || arr.length === 0) {
    return 0;
  }
  const first = f(arr, 0, arr.length - 1);
  const second = g(arr, 0, arr.length - 1);

  console.log('=================🔥结果==================');
  console.log(first > second ? `先手赢了🎉` : `后手赢了🎉`);

  return Math.max(first, second);
}

总结

本文介绍了笔者对博弈算法的思考,分享了解题思路,代码有点绕,掘友们可以从简单的 arr(只包含1/2/3个数字这种) 开始 debug,慢慢找到感觉就理解了。

曾国藩说:「有恒,则断无不成之事」,保持学习,主动思考,坚持输出,我是前端涤生,希望这篇文章能帮到您🍺🍺