石子游戏 图解题解
这道题到底在问什么
- piles
- [5,3,4,5]
- 输出
- true
- 解释
- Alice 有必胜策略,最终石子数严格多于 Bob
先想最直接的笨办法
笨办法是把所有拿法的树全展开,指数级爆炸。关键卡点是:轮到对手时,对手也按最优走,所以我这一步的好坏,取决于「剩下那段」对手能占多少便宜。(动画第 3 步)
最优解:为什么这么做
一句话答案:LeetCode 877 石子游戏:两人从两端轮流取,先手能否赢。区间 DP,dp[i][j] 存 piles[i..j] 上先手的净胜分(分差),dp[0][n-1]>0 即赢。时间 O(n²),空间 O(n²)(可滚一维 O(n))。
两人都绝顶聪明,石子游戏到底在算什么
一排石子堆 piles,堆数偶数、总数奇数。Alice 和 Bob 轮流从最左或最右整堆拿一堆,Alice 先手,两人都绝顶聪明、只走对自己最有利的一端,问 Alice 最后能不能拿得严格多于 Bob。以 piles=[5,3,4,5] 为例答案是 true。
把所有拿法的博弈树全摊开,为什么会爆炸
每步都有拿左端、拿右端两个分叉,走到底博弈树随堆数指数级膨胀,堆数一多就摊不开。而且对手也绝顶聪明,轮到他会挑对自己最有利的一端,所以我这步好坏取决于剩下那段里对手能占多少便宜。同一段石子被反复重算,把每段结果存下来复用就是动态规划(简称 DP,每段石子的先手净胜分算一次存 dp[i][j])。
不记两人各拿多少,只记一个「净胜分」
与其分别记 Alice、Bob 各拿多少,不如只记一个净胜分:当前轮到谁取谁就是先手,净胜分是他能领先对手多少,一个差值就把两人的账合成一笔。定义 dp[i][j]——第 i 到第 j 堆这段(i、j 从 0 数起,i 左端、j 右端)里先手能比对手多拿的石子数。整排答案落在 dp[0][n-1](n 是堆数),大于 0 即先手净赢。
拿一端,为什么要减去对手在剩下那段的净胜分
轮到我取 [i, j] 段,两种拿法。拿左端 piles[i],剩 [i+1, j] 归对手,他那段净胜分 dp[i+1][j] 是领先我的、要减掉,走左端我净胜 piles[i]−dp[i+1][j];拿右端得 piles[j]−dp[i][j-1]。
两条路挑更好的,dp[i][j]=max(piles[i]−dp[i+1][j], piles[j]−dp[i][j-1])。这个减号看着别扭却实在:剩下那段的 dp 站在对手视角算,一减就把「对手也会最优应对」自动算进去,符号一翻攻守对调。地基是单堆 dp[i][i]=piles[i]。
拿 piles=[5,3,4,5] 把整张表填出来
先铺单堆地基 dp[0][0]=5、dp[1][1]=3、dp[2][2]=4、dp[3][3]=5。再填长度 2 的区间:dp[0][1]=max(5−3, 3−5)=2;dp[1][2]=max(3−4, 4−3)=1;dp[2][3]=max(4−5, 5−4)=1。
长度 3:dp[0][2]=max(5−dp[1][2], 4−dp[0][1])=max(5−1, 4−2)=4;dp[1][3]=max(3−1, 5−1)=4。整段 dp[0][3]=max(5−4, 5−4)=1,大于 0,先手净赢、返回 true,和示例对上。
先填 dp[0][1] 而不按区间长度,整张表为什么会连环错
这题最致命的坑是填表顺序:dp[i][j] 要用 dp[i+1][j] 和 dp[i][j-1] 这两个短一格的区间,必须先填完短的。若先填 dp[0][1],它要的 dp[1][1] 还没算、读到初始值 0,整张表一路错下去,所以外层循环一定按区间长度从短到长——这就是区间 DP(大区间由小区间拼出来的动态规划)。复杂度时间与空间都 O(n²),可滚成一维把空间降到 O(n)。
▶ 动画逐步走查(共 24 步)——想跟着动画一帧帧对照就展开
- 3笨办法是把所有拿法的树全展开,指数级爆炸。关键卡点是:轮到对手时,对手也按最优走,所以我这一步的好坏,取决于「剩下那段」对手能占多少便宜。
- 4不去分别记 Alice/Bob 各多少,而是记一个净胜分=先手领先后手多少。最后只要看整段 [0, n-1] 的净胜分是不是大于 0,大于 0 就是先手赢。
- 5我从左端拿 piles[i],剩 [i+1, j] 轮到对手,对手在那段的净胜分是 dp[i+1][j],那是他领先我的,得减掉。拿右端同理。两种拿法取较大的那个。
- 7下标 0~3,值=每堆石子数这就是要玩的四堆石子。下面我们要建一张二维表 dp[i][j],行号 i 是区间左端,列号 j 是区间右端,先把所有「单堆」小区间填好,再逐步拼大。
- 8表格待填,行=左端 i,列=右端 j表里只有右上三角(i≤j)有意义——左端不能超过右端。我们先填主对角线(长度 1 的区间),再一条条往右上方的对角线推。
- 9只剩一堆 → 先手全拿走区间只剩一堆 [0,0] 时,先手直接整堆拿走、对手啥都没有,净胜分就是这堆本身 5。这是不依赖别人的地基。
- 10只剩一堆 → 先手全拿走同理 [1,1] 只剩第 1 堆,先手净胜 3。主对角线上每一格都等于那一堆自己的石子数。
- 11只剩一堆 → 先手全拿走[2,2] 填 4。对角线一格一格往下推,把每堆单独的「净胜分地基」铺满。
- 12长度 1 的区间全部就绪主对角线铺满:5、3、4、5。地基齐了,接下来填长度 2 的区间——它们要靠刚填好的单堆来推。
- 13目标格亮起,先看依赖谁填 dp[0][1] 前先锁定它依赖谁:拿左端 5 就要用右下那格 dp[1][1],拿右端 3 就要用左下那格 dp[0][0]。两个来源都已经填好了,可以算。
- 14区间 [0,1] = 堆 5 与 3区间 [0,1]:拿左端 5、剩 [1,1] 给对手(他净胜 dp[1][1]=3)→ 5−3=2;拿右端 3、剩 [0,0] 给对手(他净胜 5)→ 3−5=−2。取大,dp[0][1]=2:先手拿大堆 5 领先 2。
- 15目标格亮起,先看依赖谁下一格 dp[1][2]:拿左端 3 看 dp[2][2],拿右端 4 看 dp[1][1]。两个来源都在对角线上、已就绪。
- 16区间 [1,2] = 堆 3 与 4区间 [1,2]:拿左端 3 → 3−dp[2][2](=4)=−1;拿右端 4 → 4−dp[1][1](=3)=1。先手该拿那堆 4,领先 1。dp[1][2]=1。
- 17目标格亮起,先看依赖谁最后一个长度 2 区间 dp[2][3]:拿左端 4 看 dp[3][3],拿右端 5 看 dp[2][2]。来源就绪,开算。
- 18区间 [2,3] = 堆 4 与 5区间 [2,3]:拿左端 4 → 4−5=−1;拿右端 5 → 5−4=1。先手拿大堆 5,领先 1。长度 2 的三个区间全部就绪。
- 19目标格亮起,依赖更短的区间进到长度 3。填 dp[0][2] 要用长度 2 的两格:拿左端 5 看 dp[1][2],拿右端 4 看 dp[0][1]。它们上一轮刚填完,正好能用。
- 20区间 [0,2] = 堆 5,3,4区间 [0,2]:拿左端 5、剩 [1,2](对手净胜 dp[1][2]=1)→ 5−1=4;拿右端 4、剩 [0,1](对手净胜 2)→ 4−2=2。取大,dp[0][2]=4:先拿左端那堆 5 最划算。
- 21目标格亮起,依赖更短的区间另一个长度 3 区间 dp[1][3]:拿左端 3 看 dp[2][3],拿右端 5 看 dp[1][2]。两个来源就绪。
- 22区间 [1,3] = 堆 3,4,5区间 [1,3]:拿左端 3、剩 [2,3](对手净胜 1)→ 3−1=2;拿右端 5、剩 [1,2](对手净胜 1)→ 5−1=4。先拿右端那堆 5,领先 4。dp[1][3]=4。
- 23整段 [0,3],靠两个长度3区间整段 [0,3]:拿左端 5、剩 [1,3](对手净胜 dp[1][3]=4)→ 5−4=1;拿右端 5、剩 [0,2](对手净胜 4)→ 5−4=1。两种都领先 1,dp[0][3]=1。
- 24dp[0][3] = 1 > 0 → 先手赢右上角 dp[0][3]=1 就是整排石子的先手净胜分,大于 0 → Alice 严格多拿,返回 true。和示例答案对上了。
- 25dp[0][3] 来自 dp[1][3],后者来自 dp[1][2]顺着箭头回看:右上角答案是一层层从短区间拼上来的——单堆 → 长度2 → 长度3 → 整段。这就是区间 DP「小问题搭大问题」的全貌。
- 26区间 DP 的威力:把「双方都最优」的博弈,变成一张表里从小区间拼大区间的填格子,每格只看相邻两格,线性平方搞定。
- 30若先算 dp[0][1] 时 dp[1][1] 还没填如果不按区间长度、而是顺手先填 dp[0][1],它依赖的 dp[1][1] 这格还没算,读到的是初始 0,整张表就连环错下去。先短后长才能保证依赖项就绪。
⚠️ 容易写错的地方
✗ 错:枚举顺序按 i,j 双重正序
✓ 对:按区间长度从短到长
dp[i][j] 依赖更短的 dp[i+1][j] 和 dp[i][j-1],短的必须先算好
✗ 错:转移写成加号 piles[i]+dp[i+1][j]
✓ 对:减号:piles[端]−dp[剩段]
剩段对手是先手,他的净胜要从我这减掉
✗ 错:判赢写 dp>=0
✓ 对:dp[0][n-1] > 0
题目要 Alice 严格多于 Bob,等于不算赢(本题总数奇必不等)
完整代码(Python / C++ / Java)
Python
class Solution:
def stoneGame(self, piles):
n = len(piles)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = piles[i] # 单堆地基
for length in range(2, n + 1): # 区间从短到长
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = max(piles[i] - dp[i+1][j],
piles[j] - dp[i][j-1])
return dp[0][n-1] > 0C++
class Solution {
public:
bool stoneGame(vector<int>& piles) {
int n = piles.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int i = 0; i < n; i++) dp[i][i] = piles[i]; // 地基
for (int len = 2; len <= n; len++) { // 从短到长
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = max(piles[i] - dp[i+1][j],
piles[j] - dp[i][j-1]);
}
}
return dp[0][n-1] > 0;
}
};Java
class Solution {
public boolean stoneGame(int[] piles) {
int n = piles.length;
int[][] dp = new int[n][n];
for (int i = 0; i < n; i++) dp[i][i] = piles[i]; // 地基
for (int len = 2; len <= n; len++) { // 从短到长
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = Math.max(piles[i] - dp[i+1][j],
piles[j] - dp[i][j-1]);
}
}
return dp[0][n-1] > 0;
}
}复杂度
时间复杂度
O(n²)
二维表共 n² 个格子,每格 O(1) 转移(只看相邻两格)
空间复杂度
O(n²)
dp 二维表;可优化成一维滚动数组降到 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 石子游戏 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
既然有「先手必胜」的数学结论,为什么还要写区间 DP?+
这题的偶数堆加奇数总数是特意构造的,能证明先手总有必胜策略、可以直接 return true。但这只对这一个特例成立。一旦换成奇数堆,或者问的不是「能不能赢」而是「先手最多净赢多少石子」(对应 LeetCode 486 预测赢家),数学结论就失效了,只有区间 DP 这套「拿一端减去对手净胜分」的通用解还能算。面试里能同时说出「这题可以 return true」和「通用 DP 怎么写」,才是完整回答。
dp[i][j] 存的是「净胜分」,为什么不直接存「当前玩家能拿多少石子」?+
存净胜分是为了让那个减号成立。如果存的是「当前玩家能拿的总石子数」,轮到对手时你没法用一个简单的减法把对手的最优应对接进来,得同时知道对手拿多少、还剩多少,账很绕。改存「领先对手多少」这个差值后,剩下那段对手的 dp 直接减掉就行:他领先的就是我落后的,一个减号把双方的博弈合成一笔账。最后 dp[0][n-1] 只要判正负,就知道先手赢没赢。
为什么转移里两条路都是减去对手的 dp,而不是加上自己后续能拿的?+
因为 dp 定义的是「当前该取的玩家」的净胜分,视角始终跟着「谁该动手」走。我拿完左端 piles[i],剩下那段的先手就变成了对手,dp[i+1][j] 记的正是对手(此刻的先手)在那段的净胜分。站在我的角度,对手赢的就是我输的,所以要减。如果你想「加上自己后面还能拿多少」,就得再拆一层「对手拿完又轮到我」,反而绕;用净胜分加一个减号,视角一翻符号就反,一步到位。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 石子游戏 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。