小白也能看懂的算法笔记:Leetcode.486 预测赢家(零和博弈)

问题描述

题目如下:

给定一个表示分数的非负整数数组。 玩家 1 从数组任意一端拿取一个分数,随后玩家 2 继续从剩余数组任意一端拿取分数,然后玩家 1 拿,…… 。每次一个玩家只能拿取一个分数,分数被拿取之后不再可取。直到没有剩余分数可取时游戏结束。最终获得分数总和最多的玩家获胜。
 给定一个表示分数的数组,预测玩家1是否会成为赢家。你可以假设每个玩家的玩法都会使他的分数最大化。
示例 1:
 输入:[1, 5, 2]
 输出:False
 解释:一开始,玩家1可以从1和2中进行选择。如果他选择2(或者1),那么玩家2可以从1(或者2)和5中进行选择。如果玩家2选择了5 ,那么玩家1则只剩下1(或者2)可选。所以,玩家1的最终分数为1+2=3,而玩家2为5。因此,玩家1永远不会成为赢家,返回False 。
示例 2:
 输入:[1, 5, 233, 7]
 输出:True
 解释:玩家1一开始选择1。然后玩家2必须从5和7中进行选择。无论玩家2选择了哪个,玩家1都可以选择233。最终,玩家1(234 分)比玩家 2(12分)获得更多的分数,所以返回True,表示玩家1可以成为赢家。
提示:
 · 1 <= 给定的数组长度 <= 20.
 · 数组里所有分数都为非负数且不会大于 10000000 。
 · 如果最终两个玩家的分数相等,那么玩家 1 仍为赢家。

这道题目我改了两次思路,都是错误的,现在把错误的思路也记录下来。

拿到题目首先想到的就是暴力遍历所有的情况,玩家1能赢一次就算赢。以示例1为例,如果玩家1取走1,玩家2取走2,玩家1再取走5,这样玩家1就能胜出。这种思路就是把玩家2当成傻子,玩家1取走1后,玩家2只剩2和5可以选择,玩家2在明明知道取走5可以赢的情况下取走2,显然是不符合题目要求的。

于是改进了一下方法,每一个玩家都能选择数列头部或尾部的数字,那么每次让他都取两者中较大的那个——这算是一种贪心策略。但这种方法在示例2出现问题,玩家1取走7,玩家2必然取走233,后面就不用看了,玩家2稳赢。

在示例2中,玩家1第一步就必须为接下来的步骤进行谋划,玩家1一定不能上来就取走7,取走7就会把233暴露出来,玩家2必然会取走233并获胜。

综上,玩家1和玩家2都不能是傻子。显然,玩家1和玩家2得分的总和是固定的,因此,玩家1得到的分数越多,玩家2得到的分数就越少。每一步玩家都在规划如何让自己得到最高分数的同时,让对方得到最少的分数——这就是一个零和博弈问题。

如果我们用score表示玩家1得到的分数减去玩家2减去的分数,那么玩家1就要让score尽可能大,玩家2就要让score尽可能小。以示例2为例,如果将所有情况都通过二叉树的形式表示出来,很容易得到下图。


图1

从叶子节点向上回溯,计算score的值。注意,玩家1会让score尽可能大,玩家2会让score尽可能小(极大极小算法)。

先计算叶子节点的score,叶子节点的情况是固定的。


图2

向上一层回溯。这一步是玩家1取数,玩家1希望score越大越好,因此两个子节点中取score更大的那个赋给父节点。

图3

倒数第二层由玩家2取数,玩家2希望score更小。


图4

最高的一层。计算出的score为222,score>0,说明玩家1可以获胜。


图5

观察上面的过程,我们是如何求max层节点(即玩家1取数)的score值呢?score = max(取走头部数字后其余数字的score + 头部数字,取走尾部数字后其余数字的score + 尾部数字)。

求min层节点(即玩家2取数)的score,score = min(取走头部数字后其余数字的score - 头部数字,取走尾部数字后其余数字的score - 尾部数字)。

实际上,这个max和min的过程统一成max,每一层都计算score = max(头部数字 - 取走头部数字后其余数字的score,尾部数字 - 取走尾部数字后其余数字的score)。当然这样计算出来的score就不再表示玩家1得到的分数减去玩家2得到的分数了,但最终的结果和前面的方法是完全等价的。

用这样的方法来回溯树得到的结果如下。


图6

根据这个过程编写代码。

#include 

class Solution {
public:
    int Score(vector& nums, int begin, int end) {
        if (begin == end) 
            return nums[begin];
        else {
            int beginScore = nums[begin] - Score(nums, begin + 1, end);
            int endScore = nums[end] - Score(nums, begin, end - 1);
            return max(beginScore, endScore);
        }
    }

    bool PredictTheWinner(vector& nums) {
        int len = nums.size();
        int score = Score(nums, 0, len - 1);
        return score >= 0;
    }
};

运行结果如下。


接下来思考一下如何减小时间消耗。观察图1中我们构建的树,可以发现有一些节点是重复的。其实我们发现,得分情况score和剩余的数列是相关的,比如如果剩余的数列是{5, 233},那么score就一定是228,这很好理解,因为剩下{5, 233}时轮到玩家1取数,只要玩家1不犯傻,就一定先取走233,把5留给玩家2。但重复计算了两次。如下图。


图7

因此我们使用动态规划算法,减少重复计算的次数。

动态规划问题的核心三个要点:

  1. 确定dp数组的含义。我们看到每个节点都是数组中连续的子列,因此用dp[i][j]表示下标从i到j的子列的score值,。参考图5。
  2. 找到初始状态。当i==j时,就表示叶子节点,此时 。
  3. 确定转移方程。时,。

代码如下。

#include 

class Solution {
public:
    bool PredictTheWinner(vector& nums) {
        int len = nums.size();
        int dp[len][len];
        for (int i = 0; i < len; i++)
            dp[i][i] = nums[i];
        for (int i = len - 2; i >= 0; i--)
            for (int j = i + 1; j < len; j++)
                dp[i][j] = max(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]);
        return dp[0][len - 1] >= 0;
    }
};

结果如下。


空间上,这个算法还可以继续优化。还是以示例2为例,dp矩阵的变化如下。


这里的dp是一个二维矩阵,但完全可以被优化成一个长度为len的一维矩阵,优化后的迭代过程如下。


代码如下

#include 

class Solution {
public:
    bool PredictTheWinner(vector& nums) {
        int len = nums.size();
        int dp[len];
        for (int i = 0; i < len; i++)
            dp[i] = nums[i];
        for (int i = len - 2; i >= 0; i--) {
            for (int j = i + 1; j < len; j++) {
                dp[j] = max(nums[i] - dp[j], nums[j] - dp[j - 1]);
            }
        }
        return dp[len - 1] >= 0;
    }
};

运行结果如下。


这是一道最简单的最大最小原理的应用,事实上,在下棋类问题(五子棋、象棋)中最大最小原理的应用更加深入广泛,并使用α-β剪枝来节约时间开销,这里就不再赘述了。

这篇文章写起来不易,如果它对你有帮助的话,麻烦给个赞吧!!

你可能感兴趣的:(小白也能看懂的算法笔记:Leetcode.486 预测赢家(零和博弈))