【动态规划】LeetCode174.地下城游戏-Hard

422 阅读5分钟

题目

一些恶魔抓住了公主(P)并将她关在了地下城的右下角。地下城是由 M x N 个房间组成的二维网格。我们英勇的骑士(K)最初被安置在左上角的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。

骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。

有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。

为了尽快到达公主,骑士决定每次只向右或向下移动一步。

编写一个函数来计算确保骑士能够拯救到公主所需的最低初始健康点数。

例如,考虑到如下布局的地下城,如果骑士遵循最佳路径 右 -> 右 -> 下 -> 下,则骑士的初始健康点数至少为 7。

-2 (K) -3 3

-5 -10 1

10 30 -5 (P)

说明:

骑士的健康点数没有上限。

任何房间都可能对骑士的健康点数造成威胁,也可能增加骑士的健康点数,包括骑士进入的左上角房间以及公主被监禁的右下角房间。

动态规划四步走

1. 定义状态
2. 初始状态
3. 状态转移方程
4. 从dp[]中获取结果

具体到题目

错误分析示例

错误算法采用的是自顶向下的分析方法

定义状态

定义数组dp[i][j]记录到达位置dungeon[i][j]的所需要的最小骑士点数。

定义数组memo[i][j]记录到达位置dungeon[i][j]剩余的生命值。

初始状态

dp[0][0] = dungeon[0][0] > 0 ? 1 : 1 - dungeon[0][0];

memo[0][0] = dungeon[0][0] > 0 ? 1 + dungeon[0][0] : 1;

对于首行的初始化

for (let i = 1; i < m; i++) {
    if (dungeon[0][i] < 0) {
      if (memo[0][i - 1] + dungeon[0][i] > 1) {
        dp[0][i] = dp[0][i - 1];
        memo[0][i] = memo[0][i - 1] + dungeon[0][i];
      } else {
        dp[0][i] = 1 + dp[0][i - 1] - (dungeon[0][i] + memo[0][i - 1]);
        memo[0][i] = 1;
      }
    } else {
      dp[0][i] = dp[0][i - 1];
      memo[0][i] = memo[0][i - 1] + dungeon[0][i];
    }
  }

对于首列是初始化

for (let i = 1; i < n; i++) {
    if (dungeon[i][0] < 0) {
      if (memo[i - 1][0] + dungeon[i][0] > 1) {
        dp[i][0] = dp[i - 1][0];
        memo[i][0] = memo[i - 1][0] + dungeon[i][0];
      } else {
        dp[i][0] = 1 + dp[i - 1][0] - (dungeon[i][0] + memo[i - 1][0]);
        memo[i][0] = 1;
      }
    } else {
      dp[i][0] = dp[i - 1][0];
      memo[i][0] = memo[i - 1][0] + dungeon[i][0];
    }
  }

状态转移方程

由画图分析得出dp[i][j]可以由上方dp[i-1][j]和左边dp[i][j-1]的位置到达,要取其最小值,然后加上dungeon[i][j]计算得出,memo也是同样的道理

完整代码

// @lc code=start
/**
 * @param {number[][]} dungeon
 * @return {number}
 */
var calculateMinimumHP = function (dungeon) {
  const n = dungeon.length;
  if (!n) {
    return;
  }
  const m = dungeon[0].length;
  const dp = new Array(n).fill(0).map(() => new Array(m).fill(0));
  const memo = new Array(n).fill(0).map(() => new Array(m).fill(0));
  dp[0][0] = dungeon[0][0] > 0 ? 1 : 1 - dungeon[0][0];
  memo[0][0] = dungeon[0][0] > 0 ? 1 + dungeon[0][0] : 1;
  // 横向初始化
  for (let i = 1; i < m; i++) {
    if (dungeon[0][i] < 0) {
      if (memo[0][i - 1] + dungeon[0][i] > 1) {
        dp[0][i] = dp[0][i - 1];
        memo[0][i] = memo[0][i - 1] + dungeon[0][i];
      } else {
        dp[0][i] = 1 + dp[0][i - 1] - (dungeon[0][i] + memo[0][i - 1]);
        memo[0][i] = 1;
      }
    } else {
      dp[0][i] = dp[0][i - 1];
      memo[0][i] = memo[0][i - 1] + dungeon[0][i];
    }
  }
  // 纵向初始化
  for (let i = 1; i < n; i++) {
    if (dungeon[i][0] < 0) {
      if (memo[i - 1][0] + dungeon[i][0] > 1) {
        dp[i][0] = dp[i - 1][0];
        memo[i][0] = memo[i - 1][0] + dungeon[i][0];
      } else {
        dp[i][0] = 1 + dp[i - 1][0] - (dungeon[i][0] + memo[i - 1][0]);
        memo[i][0] = 1;
      }
    } else {
      dp[i][0] = dp[i - 1][0];
      memo[i][0] = memo[i - 1][0] + dungeon[i][0];
    }
  }

  // 计算dp[j][i]

  for (let i = 1; i < n; i++) {
    for (let j = 1; j < m; j++) {
      const topVal = dp[i - 1][j];
      const leftVal = dp[i][j - 1];
      const curItem = dungeon[i][j];
      if (leftVal < topVal) {
        if (curItem >= 0) {
          dp[i][j] = leftVal;
          memo[i][j] = memo[i][j - 1] + curItem;
        } else {
          if (memo[i][j - 1] + curItem > 1) {
            dp[i][j] = leftVal;
            memo[i][j] = memo[i][j - 1] + curItem;
          } else {
            dp[i][j] = 1 + dp[i][j - 1] - (memo[i][j - 1] + curItem);
            memo[i][j] = 1;
          }
        }
      } else if (topVal < leftVal) {
        if (curItem >= 0) {
          dp[i][j] = topVal;
          memo[i][j] = memo[i - 1][j] + curItem;
        } else {
          if (memo[i - 1][j] + curItem > 1) {
            dp[i][j] = topVal;
            memo[i][j] = memo[i - 1][j] + curItem;
          } else {
            dp[i][j] = 1 + dp[i - 1][j] - (memo[i - 1][j] + curItem);
            memo[i][j] = 1;
          }
        }
      } else {
        dp[i][j] = topVal;
        memo[i][j] =
          Math.max(memo[i - 1][j], memo[i][j - 1]) + curItem > 0
            ? Math.max(memo[i - 1][j], memo[i][j - 1]) + curItem
            : 1;
      }
    }
  }

  return dp[n - 1][m - 1];
};

结果分析

运行后发现当输入

 [1, -3, 3],
  [0, -2, 0],
  [-3, -3, -3]

运行的结果是5,而期望的结果是3,分析原因,是局部最优解不能保证是全局最优解。

以上面的输入为例,对于上面算法得到走到第二行第三列的0位置时,局部最优解的结果是从左边走过来,因为左边的数字是2,上面的数字是3,但是因为上面的剩余的生命值更高,导致从上面走到右下角-3位置时,反而上方3位置的线路消耗更低。

正确做法

这里需要明确动态规划一般都是只需要建立一个数组dp,上面错误做法建立了两个关联性很强的数组反而影响了。

本题的正确做法是自底向上的动态规划做法,从右下角慢慢向左上角分析,最后dp[0][0]的位置就是最后结果。

完整代码

// @lc code=start
/**
 * @param {number[][]} dungeon
 * @return {number}
 */
var calculateMinimumHP = function (dungeon) {
  const n = dungeon.length;
  if (!n) {
    return;
  }
  const m = dungeon[0].length;
  const dp = new Array(n).fill(0).map(() => new Array(m).fill(0));
  dp[n - 1][m - 1] = Math.max(1, 1 - dungeon[n - 1][m - 1]);

  // 横向初始化
  for (let i = m - 2; i >= 0; i--) {
    dp[n - 1][i] = Math.max(1, dp[n - 1][i + 1] - dungeon[n - 1][i]);
  }
  // 纵向初始化
  for (let i = n - 2; i >= 0; i--) {
    dp[i][m - 1] = Math.max(1, dp[i + 1][m - 1] - dungeon[i][m - 1]);
  }

  // 计算dp[j][i]

  for (let i = n - 2; i >= 0; i--) {
    for (let j = m - 2; j >= 0; j--) {
      dp[i][j] = Math.max(
        Math.min(dp[i + 1][j], dp[i][j + 1]) - dungeon[i][j],
        1
      );
    }
  }
  console.log(dp);
  return dp[0][0];
};

总结

感觉诸如类似的题目,分析应该采用自底向上还是自顶向下,需要看题目的结果是顶部还是顶部,如果是顶部的是最后的结果,采用自底向上更加合理。