地下城游戏 图解题解
这道题到底在问什么
- 输入
- dungeon=[[-2,-3,3,-5,1],[-5,-10,1,-2,-3],[10,30,-5,4,-1],[1,-7,2,-3,2]]
- 输出
- 6
最优解:为什么这么做
一句话答案:LeetCode 174 地下城游戏:骑士从左上走到右下、全程血量至少剩 1,求最低初始血量。正着推剩血兜不住后路,改从右下往左上倒推,dp[i][j]=max(1, min(右,下)−本格),时间 O(m×n)、空间 O(m×n)。
地下城游戏这道题到底要求什么
题面这张 4×5 网格是 [[-2,-3,3,-5,1],[-5,-10,1,-2,-3],[10,30,-5,4,-1],[1,-7,2,-3,2]],负数是恶魔进去扣血,正数是药水进去回血。骑士从左上出发,每步只能向右或向下走到右下角救公主,全程血量任何时刻都不能掉到 0、至少剩 1 滴。问出发最少带多少血,答案是 6。
为什么把每条走法都试一遍行不通
从左上到右下、每步只能右或下,一条走法就是若干个「右」「下」的排列,走法数是组合数级,网格一大就指数级膨胀,一条条试跑不动。不同走法中间大段重叠,子网格被反复重算。所以把「从某格走到终点」这类子问题算一次存起来复用,正是动态规划(把『从某格活到终点至少要带多少血』存下来不重算)的用武之地。
为什么从左上角正着往下推会算错
顺题意会想从起点往终点推,记「走到每格时还剩多少血」。可这里有撕扯:到某格,你既想让花掉的初始血最少,又想让剩的血最多,两个目标打架。选初始血更省的来路,剩血反而可能更少,往后撑不住,而站在这格根本不知道后面要扣多少血。
动态规划能立住靠的是无后效性(一个状态定下来后,后面怎么走只取决于这状态本身,跟怎么到这儿无关);正着推时「还剩多少血」管不住后面未知的需求,无后效性被打破,递推架不起来。
倒过来定义 dp,从右下角往回推
定义 dp[i][j] 表示「进入第 i 行第 j 列这格前,至少带多少血才能活到终点」,i、j 是行号、列号、从 0 数起。这个量只由它右下方那片子网格决定,跟怎么走到这格无关,无后效性回来了。
进入这格先按本格加减血,再迈进右、下两条后路里要求更低的那条 min(右, 下)。带进来的血抵掉本格增减后不能低于它,即 dp[i][j] ≥ min(右, 下) − 本格。人在本格也得活着、至少留 1 滴,于是 dp[i][j] = max(1, min(右, 下) − 本格)。终点没有后路,套成 max(1, 1 − 本格)。
拿题面示例从终点倒着填一遍
从右下角那格 (3,4) 起,它回血 2,进来前 max(1, 1−2)=1。末行末列只剩一条后路:末行倒数第二格 (3,3) 扣血 3,max(1, 1−(−3))=4。分叉格取两条后路里较小的:(2,3) 后路 4 和 2 挑 2,本格回血 4,2−4=−2 兜底成 1;(1,1) 后路 1 和 2 挑 1,本格扣血 10,1−(−10)=11。倒推到左上角起点 (0,0),后路是下方 6、右方 4 挑 4,本格扣血 2,max(1, 4−(−2))=6,就是答案,和题面对上。
终点那格的基准差 1,倒推出的每一格为什么跟着偏小
每格只算一次,时间 O(m×n);存整张 dp 表空间 O(m×n),但算某格只用到右边和下边紧挨的值,可只留一行倒着覆盖,压到 O(n)。坑几乎全在边界:末列格子右边没格、末行格子下面没格,只能取那唯一一条后路,照搬 min 会读到越界值;终点那格背后无路,「后路要求」要当成 1、即活着进来即可,漏了它整张表基准就塌。最后别手滑改回正着推,后面的路没算根本得不出答案。
▶ 动画逐步走查(共 35 步)——想跟着动画一帧帧对照就展开
- 3思路一句话:后路里要求更低的 - 本格,再兜底≥1。dp[i][j] 是进入该格前要带的血。下面每格都套它,一步步演给你看。
- 4核心直觉:需要的血量由「后面的路」决定,所以从终点倒推,而不是从起点正推。
- 5终点 (3,4) 公主所在:到这里只要活着(血量 ≥1)即可。本格是 回血 2,要让进来后还剩 ≥1,进来前需要 max(1, 1-(2)) = 1。
- 6末行 (3,3):下面没格子,只能往右走。右边那格要求带 1 血,本格扣血 3,所以进本格前需要 max(1, 1-(-3)) = 4。
- 7末行 (3,2):下面没格子,只能往右走。右边那格要求带 4 血,本格回血 2,所以进本格前需要 max(1, 4-(2)) = 2。
- 8末行 (3,1):下面没格子,只能往右走。右边那格要求带 2 血,本格扣血 7,所以进本格前需要 max(1, 2-(-7)) = 9。
- 9末行 (3,0):下面没格子,只能往右走。右边那格要求带 9 血,本格回血 1,所以进本格前需要 max(1, 9-(1)) = 8。
- 10末列 (2,4):右边没格子,只能往下走。下面那格要求带 1 血,本格扣血 1,所以进本格前需要 max(1, 1-(-1)) = 2。
- 11算 (2,3):两条后路——往下 dp[3][3]=4(蓝),往右 dp[2][4]=2(蓝)。先挑要求更低的 2(走右边更省血)。
- 12本格回血 4,能抵掉一部分需求:2-(4)=-2,不足 1 兜底成 1(人至少 1 滴血),填进 (2,3) = 1。
- 13算 (2,2):两条后路——往下 dp[3][2]=2(蓝),往右 dp[2][3]=1(蓝)。先挑要求更低的 1(走右边更省血)。
- 14本格扣血 5,进来前得多攒:1-(-5)=6,填进 (2,2) = 6。
- 15算 (2,1):两条后路——往下 dp[3][1]=9(蓝),往右 dp[2][2]=6(蓝)。先挑要求更低的 6(走右边更省血)。
- 16本格回血 30,能抵掉一部分需求:6-(30)=-24,不足 1 兜底成 1(人至少 1 滴血),填进 (2,1) = 1。
- 17算 (2,0):两条后路——往下 dp[3][0]=8(蓝),往右 dp[2][1]=1(蓝)。先挑要求更低的 1(走右边更省血)。
- 18本格回血 10,能抵掉一部分需求:1-(10)=-9,不足 1 兜底成 1(人至少 1 滴血),填进 (2,0) = 1。
- 19末列 (1,4):右边没格子,只能往下走。下面那格要求带 2 血,本格扣血 3,所以进本格前需要 max(1, 2-(-3)) = 5。
- 20算 (1,3):两条后路——往下 dp[2][3]=1(蓝),往右 dp[1][4]=5(蓝)。先挑要求更低的 1(走下面更省血)。
- 21本格扣血 2,进来前得多攒:1-(-2)=3,填进 (1,3) = 3。
- 22算 (1,2):两条后路——往下 dp[2][2]=6(蓝),往右 dp[1][3]=3(蓝)。先挑要求更低的 3(走右边更省血)。
- 23本格回血 1,能抵掉一部分需求:3-(1)=2,填进 (1,2) = 2。
- 24算 (1,1):两条后路——往下 dp[2][1]=1(蓝),往右 dp[1][2]=2(蓝)。先挑要求更低的 1(走下面更省血)。
- 25本格扣血 10,进来前得多攒:1-(-10)=11,填进 (1,1) = 11。
- 26算 (1,0):两条后路——往下 dp[2][0]=1(蓝),往右 dp[1][1]=11(蓝)。先挑要求更低的 1(走下面更省血)。
- 27本格扣血 5,进来前得多攒:1-(-5)=6,填进 (1,0) = 6。
- 28末列 (0,4):右边没格子,只能往下走。下面那格要求带 5 血,本格回血 1,所以进本格前需要 max(1, 5-(1)) = 4。
- 29算 (0,3):两条后路——往下 dp[1][3]=3(蓝),往右 dp[0][4]=4(蓝)。先挑要求更低的 3(走下面更省血)。
- 30本格扣血 5,进来前得多攒:3-(-5)=8,填进 (0,3) = 8。
- 31算 (0,2):两条后路——往下 dp[1][2]=2(蓝),往右 dp[0][3]=8(蓝)。先挑要求更低的 2(走下面更省血)。
- 32本格回血 3,能抵掉一部分需求:2-(3)=-1,不足 1 兜底成 1(人至少 1 滴血),填进 (0,2) = 1。
- 33算 (0,1):两条后路——往下 dp[1][1]=11(蓝),往右 dp[0][2]=1(蓝)。先挑要求更低的 1(走右边更省血)。
- 34本格扣血 3,进来前得多攒:1-(-3)=4,填进 (0,1) = 4。
- 35算 (0,0):两条后路——往下 dp[1][0]=6(蓝),往右 dp[0][1]=4(蓝)。先挑要求更低的 4(走右边更省血)。
- 36本格扣血 2,进来前得多攒:4-(-2)=6,填进 (0,0) = 6。
- 37表填满了。左上角 dp[0][0] = 6,就是骑士出发时必须带的最低血量——少一滴,某一步就会被恶魔打到 0 阵亡。
⚠️ 容易写错的地方
✗ 错:从起点正着推
✓ 对:必须从终点倒推
进一格前要带多少血,取决于后面的路,正推时后面还没算
✗ 错:忘了和 1 取 max
✓ 对:dp[i][j]=max(1, …)
血量任何时刻都要 ≥1,回血再多也不能让需求降到 0 或负
✗ 错:只看本格血量正负就决定
✓ 对:要先比两条后路再算
本格回血多但后面有大恶魔,照样得攒够血
完整代码(Python / C++ / Java)
Python
def calculateMinimumHP(dungeon):
m, n = len(dungeon), len(dungeon[0])
dp = [[0]*n for _ in range(m)]
for i in range(m-1, -1, -1):
for j in range(n-1, -1, -1):
if i == m-1 and j == n-1: nxt = 1
elif i == m-1: nxt = dp[i][j+1]
elif j == n-1: nxt = dp[i+1][j]
else: nxt = min(dp[i+1][j], dp[i][j+1])
dp[i][j] = max(1, nxt - dungeon[i][j])
return dp[0][0]C++
int calculateMinimumHP(vector<vector<int>>& d){
int m = d.size(), n = d[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
for(int i=m-1;i>=0;i--)
for(int j=n-1;j>=0;j--){
int nxt;
if(i==m-1 && j==n-1) nxt = 1;
else if(i==m-1) nxt = dp[i][j+1];
else if(j==n-1) nxt = dp[i+1][j];
else nxt = min(dp[i+1][j], dp[i][j+1]);
dp[i][j] = max(1, nxt - d[i][j]);
}
return dp[0][0];
}Java
int calculateMinimumHP(int[][] d){
int m = d.length, n = d[0].length;
int[][] dp = new int[m][n];
for(int i=m-1;i>=0;i--)
for(int j=n-1;j>=0;j--){
int nxt;
if(i==m-1 && j==n-1) nxt = 1;
else if(i==m-1) nxt = dp[i][j+1];
else if(j==n-1) nxt = dp[i+1][j];
else nxt = Math.min(dp[i+1][j], dp[i][j+1]);
dp[i][j] = Math.max(1, nxt - d[i][j]);
}
return dp[0][0];
}复杂度
时间
O(m×n)
每格只算一次
空间
O(m×n)
存了整张表(可压成一行 O(n))
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 地下城游戏 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么这道题非得从右下往左上倒推,正着从起点推真的不行吗?+
不行。正着推时每个格子要同时兼顾两个互相打架的量:走到这儿花的初始血要最少、走到这儿剩的血要最多,选了初始血更省的来路,剩血可能反而更少。而站在当前格根本不知道后面还要扣多少血,没法判断哪条来路对后续更划算,这就破坏了动态规划要求的无后效性。倒过来定义 dp[i][j] 为「进入这格前至少要带多少血才能活到终点」,这个量只由它右下方的子网格决定,与来路无关,无后效性成立,递推才立得住。对照 LeetCode 64 最小路径和:那题每格代价固定、只依赖走过的来路,从左上正着推就行;174 每格该带多少血由后面的路决定,正推时后路未知,才必须从右下倒推,两题正好从正反面照出无后效性这条线。
转移式里为什么要和 1 取 max,直接用 min(右,下) 减本格不够吗?+
不够。min(右,下) − 本格 只保证了「进本格、加减血、再迈向后路」这一步的血量够用,但没保证在本格当下血量不掉到 0。题目要求全程任何时刻血量都至少剩 1,如果本格回血很多,min(右,下) − 本格 可能算出 0 甚至负数,意思是理论上带 0 滴血就行,可人一进本格就已经死了。所以要和 1 取较大值兜底:无论后路多宽松,进任何一格前至少得带 1 滴血。
终点那格为什么规定要求是 1,不是 0?+
因为题目要求骑士全程血量至少剩 1,走到终点救公主的那一刻也不例外,不能刚好掉到 0。所以把终点的「后路要求」设成 1,代表进入终点这一格并加减血之后,血量仍要不低于 1。它是整套倒推的起点基准,若误设成 0,等于允许骑士在终点耗尽血量,后面反推出来的每一格需求都会跟着少 1,整个答案偏小。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 地下城游戏 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。