阅读 103

【前端学算法】DP太难写?来学学记忆化搜索!

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

例题

题目描述

LeetCode 1140. 石子游戏 II

亚历克斯和李继续他们的石子游戏。许多堆石子 排成一行,每堆都有正整数颗石子 piles[i]。游戏以谁手中的石子最多来决出胜负。

亚历克斯和李轮流进行,亚历克斯先开始。最初,M = 1

在每个玩家的回合中,该玩家可以拿走剩下的 前 X 堆的所有石子,其中 1 <= X <= 2M。然后,令 M = max(M, X)

游戏一直持续到所有石子都被拿走。

假设亚历克斯和李都发挥出最佳水平,返回亚历克斯可以得到的最大数量的石头。

示例:

输入:piles = [2,7,9,4,4]
输出:10
解释:
如果亚历克斯在开始时拿走一堆石子,李拿走两堆,接着亚历克斯也拿走两堆。在这种情况下,亚历克斯可以拿到 2 + 4 + 4 = 10 颗石子。 
如果亚历克斯在开始时拿走两堆石子,那么李就可以拿走剩下全部三堆石子。在这种情况下,亚历克斯可以拿到 2 + 7 = 9 颗石子。
所以我们返回更大的 10。 
复制代码

数据范围

  • 1 <= piles.length <= 100
  • 1 <= piles[i] <= 10 ^ 4

解题思路

这是一道博弈题,也是一道动态规划题目。动态规划一般需要两个步骤:

  1. 状态(子问题)定义
  2. 状态转移方程

比较难的一般是转移方程,写出来还要考虑正向/反向转移顺序,边界等情况。但如果使用记忆化搜索的方式,就完全不需要考虑这些。

回到这道题,我们先定义状态:dp[i][j] 表示已经拿了 i 堆棋子,且此时 M=j 的情况下,剩下这些石子,先拿的人能取得的最大值。那么我们的初始问题就是求 dp[0][1]

如何计算 dp[i][j]

首先,如果 2j >= 剩下的石子堆数,则可以拿走全部的石子。

否则,我们可以拿的石子堆数为 1 ~ 2j,枚举此时玩家 A 拿走的石子堆数 X,其中 1<=X<=2j,则轮到 B 拿的时候,B 最多可以拿走 dp[i+X][max(j, X)] 个石子。如果我们知道在 A 拿石子前,这些石子的总个数先设为 total,则减去 B 拿走的石子数,就是在 A 选择拿走 X 堆石子的情况下可以获取的石子数,即 total - dp[i+X][max(j, X)]

至于 total 怎么求,也很简单,只需要先预处理前缀和 prefix[1...n],我们知道全部石子数 prefix[n],又知道了前 i 堆石子的前缀和 prefix[i],则拿走了 i 堆石子后,剩下石子数自然就是 prefix[n] - prefix[i]

AC 代码(加了详细注释)

/**
 * @param {number[]} piles
 * @return {number}
 */
var stoneGameII = function(piles) {
    let n = piles.length; // 一共 n 堆石子

    // 预处理前缀和
    let prefix = new Array(n + 1).fill(0); // prefix[i]表示前i个数的前缀和
    for (let i = 0; i < n; i++) {
        prefix[i + 1] = prefix[i] + piles[i];
    }
    // 存储结果 初始化 dp[n+1][n+1] 所有值为 -1
    let dp = new Array(n + 1).fill().map(() => new Array(n + 1).fill(-1));
    // 拿了i个棋子 m=j 的情况下 先拿的人能取得的最大值
    function dfs(i, j) {
        // 用 dp 存储计算结果 如果已经计算过一次则不需要再次计算
        if (dp[i][j] !== -1) {
            return dp[i][j];
        }
        // 当前的全部石子数
        let total = prefix[n] - prefix[i];
        // 剩下的堆数可以全部拿走的话 则一定会直接全部拿走
        if (n - i <= 2 * j) {
            return dp[i][j] = total;
        }
        // 否则枚举拿走的石子堆数
        let answer = 0;
        for (let x = 1; x <= 2 * j; x++) {
            // 先计算在此前提下 另外一个人可以拿到的最大石子数
            let other_max = dfs(i + x, Math.max(x, j));
            // 则当前用户可以拿到此时的全部石子 - 另外一个人拿到的石子数
            answer = Math.max(answer, total - other_max);
        }
        // 返回结果并把结果存储到 dp 
        return dp[i][j] = answer;
    }

    return dfs(0, 1);
};
复制代码

可以看到,用记忆化搜索,不需要考虑计算子问题的先后顺序,只需要用 dfs() 去获取所需要的状态即可,重点在于需要在每次计算结果后都存储起来,因为一个子问题可能被计算多次,不存储的话会超时。

如果看懂了,可以看下这道题 1510. 石子游戏 IV,和本题有类似的思路,不过简单些,一维dp可解。

提示:dp[i]表示剩余 i 个石子时是否先手赢,然后每次枚举当前玩家拿走的石子数即可。

文章分类
前端
文章标签