力扣每日一刷Day 23
Practice Day twenty-three:Leetcode T464
今天,我们来学习状压DP。在他之后,我们就不会再讲DP了,而是转向学习其他算法。
状压 DP(状态压缩动态规划)是动态规划的一种特殊形式,核心思想是通过状态压缩技术将复杂的状态用一个整数表示,从而高效地处理具有「离散选择」特性的问题。
怎么样?听起来就很美味吧!
不过不幸的是,这个状压DP,网络上还是没有博主做可视化教程。现有的教程也比较少。
不过,这回我又做了可视化代码,看到这篇文章的人有福了。按照惯例,我会把可视化代码放到文章结尾,避免影响阅读观感。
现在我就把题目端上来罢
这个题目读起来有些晦涩难懂,我们需要澄清其中一些定义
1. 核心游戏规则:“不能重复使用整数”
原本的 “100 game” 是可重复选 1~10 的数,累计和先到 100 者胜;而本题修改为:
玩家从「公共整数池」(包含 1 到maxChoosableInteger
的所有整数)中轮流选数,但选过的数会从池中移除,后续双方都不能再选,直到累计和≥desiredTotal
,先达到的玩家胜。
2. “不能重复使用” 的具体含义
比如,若maxChoosableInteger=5
,公共池初始是{1,2,3,4,5}
:
- 若先手选了
3
,则公共池变为{1,2,4,5}
,后手只能从剩下的数里选。 - 后续任何玩家都不能再选
3
,每选一个数,可选的范围就会缩小。
3. “稳赢” 的含义
“稳赢” 是指:先手玩家(第一个出手的人)存在一种策略,无论后手玩家如何应对(采取对自己最有利的策略),先手都能保证让累计和达到或超过desiredTotal
。此时函数返回true
;反之,若先手无论怎么选,后手都有办法赢,则返回false
。
4. “最佳(表现最佳)” 的含义
“最佳” 指两名玩家在游戏中,都会采取对自己最有利的策略:
- 若当前玩家有 “选某数就能直接赢” 的选项,会优先选这个数(进攻最优);
- 若当前玩家无法直接赢,会选择 “让对手后续也无法轻易赢” 的数(防守最优)。
好了,当我们对这些规则的定义明晰之后,我们直接来看题目吧!
题目条件:可选最大值、重复不能、累加目标值、回合制
题目规则:判断先手是否稳赢
下面我会提供一个示例,用到的方法如下:
用状态压缩(二进制 mask)表示已选数字集合,通过记忆化搜索(DFS+memo)实现动态规划的状态转移,结合博弈论的必胜 / 必败态逻辑,高效判断先手是否能获胜。
1 我们先来做一些前置工作
int m, target;int totalSum; vector<int8_t> memo;
int m
- 含义:存储 “最大可选整数”(对应题目中的
maxChoosableInteger
),即游戏中可供选择的数字范围是1~m
。 - 作用:限定了可选数字的上限,决定了状态压缩的范围(状态总数为
2^m
,因为每个数字有 “选 / 不选” 两种可能)。
int target
- 含义:存储 “目标和”(对应题目中的
desiredTotal
),即玩家需要通过选数使总和达到或超过这个值才能获胜。 - 作用:作为判断胜负的核心阈值,递归过程中通过 “剩余目标和(
remain
)” 与当前选择的数字比较,决定是否直接获胜。
int totalSum
- 含义:存储
1~m
所有数字的总和(计算公式为m*(m+1)/2
)。 - 作用:用于高效剪枝判断:
- 若
totalSum < target
:所有数字加起来都达不到目标,直接判定先手必输(边界条件)。 - 递归中计算 “未选数字的总和”,若小于剩余目标
remain
,则当前玩家必输(强剪枝)。
- 若
vector<int8_t> memo
- 含义:记忆化数组,用
int8_t
类型(1 字节有符号整数)存储每个状态的计算结果:0
:表示该状态尚未计算(初始值)。1
:表示当前玩家在该状态下能赢。-1
:表示当前玩家在该状态下会输。
- 作用:通过存储已计算状态的结果,避免重复递归(核心优化手段),数组大小为
2^m
(覆盖所有可能的状态)。
2 我们来看看他的核心组件DFS的完整实现吧
bool dfs(int mask, int remain) {int8_t &st = memo[mask];if (st) return st > 0;if (totalSum - (target - remain) < remain) {st = -1; return false;}for (int x = m; x >= 1; --x) {int bit = 1 << (x - 1);if (mask & bit) continue;if (x >= remain) { st = 1; return true; } if (!dfs(mask | bit, remain - x)) { st = 1; return true;}}st = -1; return false;}
这里我们简要说一下我们的思路:DFS 的核心思路是 通过递归探索所有可能的选数路径,并利用 记忆化搜索 和 剪枝优化 来判断当前玩家是否能必胜。
状态转移方程
定义 f(mask, remain)
表示在 mask
状态下,当前玩家能否通过最优策略赢。状态转移方程为:
关键点:
- 胜利条件:当前玩家选一个数
x
直接让remain <= x
。 - 必败条件:所有可能的
x
选择后,对手都能赢。 - 记忆化:通过
memo[mask]
缓存已计算的状态,避免重复递归。
这么一大坨塞进去肯定消化不良,即便DFS是我们学过的方法。接下来,我们来一步步拆解这份DFS代码
①先看看他的脸面
bool dfs(int mask, int remain)
mask
:二进制掩码(整数),用二进制位表示 “已被选择的数字”(第i
位为 1 表示数字i+1
已被选)。remain
:整数,表示当前玩家还需要多少总和才能达到目标(desiredTotal
)。
②好了,再来看看下一样东西,我们在上面定义了一个memo数组,这个数组是用来存储玩家是否能赢的判断的,在这里的处理中,我们使用了引用的方式使用这个数组存储的特定结果
int8_t &st = memo[mask];
最核心的理由是: 明确内存访问路径。当然,还有其他如下的理由
1. 避免重复访问数组
memo[mask]
是一个数组元素,通过引用st
可以直接操作该元素,无需多次写memo[mask]
。你知道的,如果是竞赛的时候,每一秒的时间都珍贵,显然写st比写完整的memo[mask]会简便不少
2. 提升性能(小对象也适用)
- 虽然
int8_t
是小对象(1 字节),但引用可以避免编译器优化时的潜在冗余访问。 - 在递归或频繁访问的场景中,引用能减少指令开销(尤其在嵌套调用中)。对于这个问题,我们来举个例子理解他
- 假设
memo
是一个std::vector<int8_t>
,用于存储状态结果。在递归函数中,频繁访问memo[mask]
,例如:bool dfs(int mask, int remain) {if (memo[mask] != -1) return memo[mask]; // 直接访问 memo[mask]// ... 其他逻辑 ...memo[mask] = 1; // 再次写入 memo[mask] }
问题:冗余访问
多次访问
memo[mask]
在逻辑中,memo[mask]
可能被多次读取或写入(如if (memo[mask] != -1)
和memo[mask] = 1
)。编译器可能无法确定memo[mask]
的地址是否被其他操作修改,导致:- 重复计算索引:每次访问
memo[mask]
时,编译器可能重新计算mask
的位移(如mask * sizeof(int8_t)
)。 - 内存屏障:若
memo
是全局变量或跨函数传递,编译器可能插入额外的内存屏障以确保可见性。
- 重复计算索引:每次访问
潜在优化失败
如果memo
的地址在递归中被修改(例如通过指针传递),编译器可能无法将memo[mask]
优化为局部变量,导致冗余访问。
但是如果我们使用引用的话
可以明确告诉编译器:
st
是memo[mask]
的别名,后续所有对st
的操作等价于对memo[mask]
的操作。- 编译器可以将
st
优化为寄存器变量,避免重复访问内存。
bool dfs(int mask, int remain) {int8_t &st = memo[mask]; // 引用绑定 memo[mask]if (st != -1) return st; // 直接读取 st// ... 其他逻辑 ...st = 1; // 直接写入 st
}
优化后的效果
- 减少内存访问次数:
st
的读写直接作用于memo[mask]
,无需重复计算索引。 - 避免编译器不确定性:引用明确绑定内存地址,消除编译器对
memo
可能被修改的担忧。 - 提升局部性:
st
可能被分配到寄存器,加速访问。
③我们都知道,DFS被设计为迭代的形式。对于这样一些设计,我们可以在开头就进行一些简单的逻辑判断,以避免简单问题经过复杂的流程才能解决,显然这可以帮助我们节省很多时间,让代码效率更高。
if (st) return st > 0;
我们在一开始就查询当前塞入的st是否已经被处理过,即if(st),而st有三种值,-1,0,1,只要不是0(未处理过)就可以进入if下的判断。这里返回值被设计为return st>0,是因为我们的DFS的返回类型被设置为bool,所以我们需要借助比较符号处理一下st的值,使之符合我们设计的返回类型。
以上是记忆化搜索的一种体现,与动态规划的核心思想不谋而合,我们不希望已经被解决的问题重复解决。
识别重复子问题:游戏过程中,不同的选数顺序可能导致「剩余可选数字相同」(即相同
mask
),此时胜负判断逻辑完全一致,属于重复子问题。确定状态表示:用二进制
mask
表示已选数字(如mask=0b101
表示 1 和 3 已被选),能高效压缩状态(m=20 时仅需 2^20=100 万种状态)将时间复杂度从指数级优化为多项式级。缓存计算结果:用
memo
数组存储每个mask
对应的胜负结果,计算过的状态直接返回,避免重复递归。
④在进行正式的逻辑判断之前,我们还需要考虑一个问题:他给的卡牌额度全部加起来,可以达到预设值吗?好吧,事实上,这不应该在我们迭代的考虑范围内,而是把他单独拎出来作为一个流程。这在我们的主函数里会实现。
现在,来进行正式的逻辑判断吧!
for (int x = m; x >= 1; --x) {int bit = 1 << (x - 1);if (mask & bit) continue;if (x >= remain) { st = 1; return true; } // 立即获胜if (!dfs(mask | bit, remain - x)) { // 让对手陷入必败st = 1; return true;}}
int m(DFS函数之外定义)
- 含义:存储 “最大可选整数”(对应题目中的
maxChoosableInteger
),即游戏中可供选择的数字范围是1~m
为什么要定义这个?因为题目给的变量名太长了,弄短一点省事,嘿嘿
此处循环我们从最大数字向小数字递归,原因是我们有一段这样的代码
if (x >= remain) { st = 1; return true; }
显然大数字可以让我们更快的跳出当前循环,加快我们的效率。虽然这个方法十分朴实,但他有个牛逼的名字:启发式排序。
look at this
int bit = 1 << (x - 1);
这是个啥?<<什么符号?没见过啊
<<
是一个位操作运算符,通常被称为“左移运算符”。它将一个数的二进制位整体向左移动指定的位数。在大多数编程语言中(如C、C++、Java、Python等),其基本作用如下:
对于整数
x << y
,表示将x
的二进制形式向左移动y
位。左移相当于将数字乘以 2^y。
是的,看看他的公式:1<<(x-1),我们知道二进制是0-base的,所以x-1,那么。这条公式很明了了,其实就是把十进制的x转化为对应的二进制的bit。
诚然,还有别的方法可以将十进制转化为二进制,但这是 是最常见且高效的方式。
缺点也是存在的:仅适用于小范围的 x。
因为 位运算的位数有限,而 int
类型在 C++ 中通常是 32 位或 64 位,左移超过该位数会溢出。
转化为二进制掩码是为了后续的操作做准备
if (mask & bit) continue;
好的,又来了新朋友,&,位与运算符(Bitwise AND Operator)
运算规则:
两个二进制位进行与运算时,只有当两个位都为 1 时,结果才为 1;否则结果为 0。例如:
1010 (二进制,对应十进制 10)
& 1100 (二进制,对应十进制 12)
--------
1000 (二进制,对应十进制 8)
那么回到上面这行代码,
mask & bit
:检查 mask
的第 x
位是否为 1
- 如果
mask & bit != 0
,说明x
已经被选过,跳过这个数字。 - 如果
mask & bit == 0
,说明x
尚未被选,可以继续处理。
这是因为题目规则的限制:选择后不放回卡池,所以已经选过的卡牌不能重复选择
在确定某张卡牌可以选择后,我们可以判断选择他后,是否必胜
if (x >= remain) { st = 1; return true; }
选择卡牌面额大于等于剩余面额后,我们就令当前处理的memo数组的元素(即st)直接赋值为1,且return true终结此轮判断,哥们,还有啥好说的,直接判你赢得了。
if (!dfs(mask | bit, remain - x)) { // 让对手陷入必败st = 1; return true;}
既然有先手必胜,那也应有先手不能必胜的情况,此时我们就要选择某张卡牌恶心对面,让他不能必胜,或不能胜的那么轻易。
又有新的运算符朋友了
| (位或运算符)
位或运算符(Bitwise OR Operator),用于对两个整数的二进制位进行「或运算」
两个二进制位进行「或运算」时,只要其中一个位为 1,结果就为 1;只有两个位都为 0 时,结果才为 0。
1010 (十进制 10)
| 0110 (十进制 6)
--------
1110 (十进制 14)
mask | bit
:将当前选择的数字x
标记为已选(通过位掩码)这是因为在前面已经验证过x没有被选择过。remain - x
:更新剩余目标总和(因为当前玩家选择了x
)。dfs(...)
:递归调用,判断对手在新状态下的胜负。!dfs(...)
:如果对手在新状态下无法获胜(即返回false
),说明当前玩家选择x
后可以让对手必败
好了,由于我们塞进if里头的是 !bool dfs,所以他只会返回true和false,且只有false,即输了才会执行这条if分支。
输了?什么输了?谁输了?谁会输了?!
我们设计DFS的时候,并没有规定使用的对象。规定使用对象是调用DFS才需要考虑的事情。也就是说,DFS对先手还是后手都一视同仁。
这里的 if (!dfs(mask | bit, remain - x))是处于上一级的DFS之内的,也就是说这是我们第二次调用DFS,那么,一级DFS就是玩家P1,这个处于if (!dfs(mask | bit, remain - x))的次级DFS,就是玩家P2。
也就是说现在塞进if里面的DFS实际上是判断P2能不能赢,而不是P1能不能赢。
这里我们选择了卡牌X,如果P2返回false,也就是P2输了,所以可以判P1赢了,那么就可以标记当前的memo数组元素为1,表示赢了,使用return true终结比赛。
好了,上面我们描述的都是对自己有利的局面,当我们穷尽所有可能都无法取胜,说明这把输了,只能面对现实
st = -1; return false;}
把memo数组设为-1,表示输了,然后return false投降。
至此,DFS的设计全部完成。
3 现在,我们来进行设计caniwin函数的设计吧
先展示完整的caniwin
bool canIWin(int maxChoosableInteger, int desiredTotal) {m = maxChoosableInteger;target = desiredTotal;if (target == 0) return true;totalSum = m * (m + 1) / 2;if (totalSum < target) return false; // 全拿也到不了if (m >= target) return true; // 先手直接拿memo.assign(1 << m, 0);return dfs(0, target);}
①按照惯例,把这些臭长的变量名变得简洁一些
m = maxChoosableInteger;target = desiredTotal;
②判断目标值是否等于0,如果是,则先手必胜
if (target == 0) return true;
③计算所给卡牌的总面额值,判断它是否能达到目标值,如果达不到,先手必输
totalSum = m * (m + 1) / 2;if (totalSum < target) return false;
④判断所给最大面额是否超过目标面额,如果是,先手必胜
if (m >= target) return true;
⑤好了,我们创建了一个memo数组,但还没有对他进行初始化,所以我们要对他进行初始化
memo.assign(1 << m, 0);
这里用了一个我们没怎么见过的函数,assign()函数
事实上他有许多原型
// 1. 填充 n 个值为 val 的元素
void assign(size_type n, const T& val);// 2. 复制 [first, last) 区间内的元素
template <class InputIt>
void assign(InputIt first, InputIt last);// 3. 接收初始化列表
void assign(std::initializer_list<T> init);
这里我们选用了第一种原型,用于填充memo数组
其中:
size_type n
:表示要分配的元素数量(代码中1 << m
计算出状态总数)const T& val
:表示要填充的初始值(代码中用0
初始化所有状态)
该函数会清空 vector
中原有元素,重新分配 n
个元素,并将每个元素初始化为 val
。
⑥完成所有准备工作后,就调用我们的DFS吧,这是最后的可能了
return dfs(0, target);}
以上,我们的函数设计全部结束。
哈哈,近期我们不会再学习DP了,珍惜这最后一节课吧
事实上,我在Leetcode中发现一份不知名代码,没有使用动态规划,但速度和空间使用都达到了极致的解决方法,留给各位自己欣赏吧,我就不做讲解了
class Solution {
public:bool canIWin(int n, int tot) {if (n == 10 && tot == 40) return false;if (n == 3 && tot == 4 || n == 10 && tot == 54 || n == 20 && tot == 209 || n == 20 && tot == 152) return false;if (n * (n + 1) / 2 < tot) return false;if (n >= tot) return true;int k = tot / (n + 1);if (k * (n + 1) == tot) {if (n & 1) return true;return false;}return true;}
};
现在,我附上算法可视化的代码,有需要的伙伴可以拿去用,自学也好,教学也好
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>100 Game 可视化(Can I Win - 位压缩递归记忆化)</title><link rel="stylesheet" href="style.css" /><style>#g100-code { margin-top: 12px; }.code-view { background: #0b1020; color: #e5e7eb; padding: 8px 12px; border-radius: 6px; overflow: auto; max-height: 420px; font-family: Consolas, 'Courier New', monospace; font-size: 12px; line-height: 1.5; border: 1px solid #1f2937; }.code-line { display: block; white-space: pre; padding: 0 6px; border-left: 3px solid transparent; }.code-lno { color: #6b7280; margin-right: 8px; display: inline-block; width: 24px; text-align: right; user-select: none; }.code-line.active { background: rgba(59,130,246,0.15); border-left-color: #3b82f6; }.g100-row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }.g100-num { padding: 6px 10px; border:1px solid #cbd5e1; border-radius:6px; min-width:32px; text-align:center; background:#fff; }.g100-num.used { background:#e5e7eb; color:#6b7280; text-decoration: line-through; }.g100-num.pick { outline: 3px solid #3b82f6; background: #dbeafe; }.g100-meta { font-size: 13px; color:#374151; }.g100-badge { display:inline-block; padding:2px 6px; border-radius: 10px; background:#eef2ff; color:#4338ca; margin-left:6px; font-size:12px; }.stack { background:#f8fafc; border:1px solid #e5e7eb; border-radius:6px; padding:8px; max-height:260px; overflow:auto; font-family:Consolas, monospace; }.stack-item { padding:4px 6px; border-left:3px solid transparent; }.stack-item.active { background:#eff6ff; border-left-color:#3b82f6; }.memo-summary { font-size:12px; color:#6b7280; }</style>
</head>
<body><h1>100 Game 可视化(Can I Win)</h1><div id="g100-controls" class="controls" style="max-width:1100px; width:100%; display:flex; flex-wrap:wrap; gap:12px; align-items:flex-start;"><label>m: <input type="number" id="g100-m" min="1" max="20" value="10" style="width:80px;"/></label><label>target: <input type="number" id="g100-target" min="0" max="300" value="11" style="width:100px;"/></label><button id="g100-build">Build</button><button id="g100-run">Run (Animate)</button><div style="display:flex; gap:8px; align-items:center; border-left:2px solid #ddd; padding-left:12px;"><button id="g100-step">Single Step</button><button id="g100-pause">Pause</button><button id="g100-reset">Reset</button><label>Speed: <input type="range" id="g100-speed" min="80" max="1600" value="500" style="width:120px;"></label></div><div style="display:flex; gap:8px; align-items:center; border-left:2px solid #ddd; padding-left:12px;"><button id="g100-prev">◀ Prev</button><button id="g100-next">Next ▶</button><span id="g100-step-info" style="font-size:12px; color:#6b7280;">Step: 0/0</span></div></div><div id="g100-visual" style="max-width:1100px; width:100%; display:flex; gap:16px; align-items:flex-start;"><div id="g100-main" style="flex:1;"><div><h3>可选数字(从大到小尝试)</h3><div id="g100-nums" class="g100-row"></div><div class="g100-meta">remain: <span id="g100-remain">-</span><span class="g100-badge" id="g100-status">-</span></div></div><div style="margin-top:12px;"><h3>状态栈(mask, remain)</h3><div id="g100-stack" class="stack"></div><div class="memo-summary">memo[mask]: 0=unk, 1=win, -1=lose | 已记录状态:<span id="g100-memo-count">0</span></div></div></div><div id="g100-panel" style="min-width:320px;"><div><h3>信息面板</h3><p><strong>当前 mask:</strong> <span id="g100-mask">-</span></p><p><strong>二进制:</strong> <span id="g100-mask-bin">-</span></p><p><strong>当前尝试 x:</strong> <span id="g100-x">-</span></p><p><strong>剪枝条件:</strong> totalSum - (target - remain) < remain ? <span id="g100-prune">-</span></p></div><div id="g100-code"><h3>代码 (C++)</h3><pre class="code-view" id="g100-code-view"><code>
<span class="code-line" data-line="1"><span class="code-lno">1</span>#include <bits/stdc++.h></span>
<span class="code-line" data-line="2"><span class="code-lno">2</span>using namespace std;</span>
<span class="code-line" data-line="3"><span class="code-lno">3</span></span>
<span class="code-line" data-line="4"><span class="code-lno">4</span>class Solution {</span>
<span class="code-line" data-line="5"><span class="code-lno">5</span> int m, target;</span>
<span class="code-line" data-line="6"><span class="code-lno">6</span> int totalSum; // sum(1..m)</span>
<span class="code-line" data-line="7"><span class="code-lno">7</span> vector<int8_t> memo; // 0=unk, 1=win, -1=lose</span>
<span class="code-line" data-line="8"><span class="code-lno">8</span></span>
<span class="code-line" data-line="9"><span class="code-lno">9</span> bool dfs(int mask, int remain) {</span>
<span class="code-line" data-line="10"><span class="code-lno">10</span> int8_t &st = memo[mask];</span>
<span class="code-line" data-line="11"><span class="code-lno">11</span> if (st) return st > 0;</span>
<span class="code-line" data-line="12"><span class="code-lno">12</span></span>
<span class="code-line" data-line="13"><span class="code-lno">13</span> // 强剪枝:所有未用数字和 < remain,则当前必败</span>
<span class="code-line" data-line="14"><span class="code-lno">14</span> // sumUsed = target - remain</span>
<span class="code-line" data-line="15"><span class="code-lno">15</span> // sumRemaining = totalSum - sumUsed</span>
<span class="code-line" data-line="16"><span class="code-lno">16</span> if (totalSum - (target - remain) < remain) {</span>
<span class="code-line" data-line="17"><span class="code-lno">17</span> st = -1;</span>
<span class="code-line" data-line="18"><span class="code-lno">18</span> return false;</span>
<span class="code-line" data-line="19"><span class="code-lno">19</span> }</span>
<span class="code-line" data-line="20"><span class="code-lno">20</span></span>
<span class="code-line" data-line="21"><span class="code-lno">21</span> // 尝试从大到小拿数,利于早赢和剪枝</span>
<span class="code-line" data-line="22"><span class="code-lno">22</span> for (int x = m; x >= 1; --x) {</span>
<span class="code-line" data-line="23"><span class="code-lno">23</span> int bit = 1 << (x - 1);</span>
<span class="code-line" data-line="24"><span class="code-lno">24</span> if (mask & bit) continue;</span>
<span class="code-line" data-line="25"><span class="code-lno">25</span> if (x >= remain) { st = 1; return true; } // 立即获胜</span>
<span class="code-line" data-line="26"><span class="code-lno">26</span> if (!dfs(mask | bit, remain - x)) { // 让对手陷入必败</span>
<span class="code-line" data-line="27"><span class="code-lno">27</span> st = 1;</span>
<span class="code-line" data-line="28"><span class="code-lno">28</span> return true;</span>
<span class="code-line" data-line="29"><span class="code-lno">29</span> }</span>
<span class="code-line" data-line="30"><span class="code-lno">30</span> }</span>
<span class="code-line" data-line="31"><span class="code-lno">31</span> st = -1;</span>
<span class="code-line" data-line="32"><span class="code-lno">32</span> return false;</span>
<span class="code-line" data-line="33"><span class="code-lno">33</span> }</span>
<span class="code-line" data-line="34"><span class="code-lno">34</span></span>
<span class="code-line" data-line="35"><span class="code-lno">35</span>public:</span>
<span class="code-line" data-line="36"><span class="code-lno">36</span> bool canIWin(int maxChoosableInteger, int desiredTotal) {</span>
<span class="code-line" data-line="37"><span class="code-lno">37</span> m = maxChoosableInteger;</span>
<span class="code-line" data-line="38"><span class="code-lno">38</span> target = desiredTotal;</span>
<span class="code-line" data-line="39"><span class="code-lno">39</span> if (target <= 0) return true;</span>
<span class="code-line" data-line="40"><span class="code-lno">40</span></span>
<span class="code-line" data-line="41"><span class="code-lno">41</span> totalSum = m * (m + 1) / 2;</span>
<span class="code-line" data-line="42"><span class="code-lno">42</span> if (totalSum < target) return false; // 全拿也到不了</span>
<span class="code-line" data-line="43"><span class="code-lno">43</span> if (m >= target) return true; // 先手直接拿</span>
<span class="code-line" data-line="44"><span class="code-lno">44</span></span>
<span class="code-line" data-line="45"><span class="code-lno">45</span> memo.assign(1 << m, 0);</span>
<span class="code-line" data-line="46"><span class="code-lno">46</span> return dfs(0, target);</span>
<span class="code-line" data-line="47"><span class="code-lno">47</span> }</span>
<span class="code-line" data-line="48"><span class="code-lno">48</span>};</span></code></pre></div></div></div><script>(function(){// DOMconst elM = document.getElementById('g100-m');const elT = document.getElementById('g100-target');const btnBuild = document.getElementById('g100-build');const btnRun = document.getElementById('g100-run');const btnStep = document.getElementById('g100-step');const btnPause = document.getElementById('g100-pause');const btnReset = document.getElementById('g100-reset');const btnPrev = document.getElementById('g100-prev');const btnNext = document.getElementById('g100-next');const speed = document.getElementById('g100-speed');const numsDiv = document.getElementById('g100-nums');const stackDiv = document.getElementById('g100-stack');const remainSpan = document.getElementById('g100-remain');const statusSpan = document.getElementById('g100-status');const maskSpan = document.getElementById('g100-mask');const maskBinSpan = document.getElementById('g100-mask-bin');const xSpan = document.getElementById('g100-x');const pruneSpan = document.getElementById('g100-prune');const memoCountSpan = document.getElementById('g100-memo-count');const stepInfo = document.getElementById('g100-step-info');const codeView = document.getElementById('g100-code-view');const codeLines = codeView ? codeView.querySelectorAll('.code-line') : [];function setActive(lines){if (!codeLines || !codeLines.length) return;const S = new Set(lines);codeLines.forEach(el=>{const ln = Number(el.getAttribute('data-line'));el.classList.toggle('active', S.has(ln));});}const st = {m: 10,target: 11,totalSum: 55,memo: [], // int8visited: 0,stepHistory: [],curIdx: -1,running: false,paused: false,spd: 500,};function setNums(m){numsDiv.innerHTML = '';for (let x=m; x>=1; --x){ // 显示从大到小const d = document.createElement('div');d.id = `g100-num-${x}`;d.className = 'g100-num';d.textContent = String(x);numsDiv.appendChild(d);}}function toggleUsed(mask){for (let x=1; x<=st.m; x++){const bit = 1 << (x-1);const el = document.getElementById(`g100-num-${x}`);if (!el) continue;el.classList.toggle('used', !!(mask & bit));el.classList.remove('pick');}}function setPick(x){const el = document.getElementById(`g100-num-${x}`);if (el){ el.classList.add('pick'); }xSpan.textContent = String(x);}function toBin(mask){return '0b' + mask.toString(2).padStart(st.m,'0');}function addStep(s){ st.stepHistory.push(s); }function build(){st.m = Math.max(1, Math.min(20, Number(elM.value)||10));st.target = Math.max(0, Math.min(300, Number(elT.value)||0));st.totalSum = st.m*(st.m+1)/2 | 0;st.memo = new Int8Array(1<<st.m);st.visited = 0;st.stepHistory = [];st.curIdx = -1;setNums(st.m);updateInfo(0, st.target);setActive([36,37,38,39,41,42,43,45,46]);// canIWin 前置判断if (st.target <= 0){ addStep({type:'base_true'}); return; }if (st.totalSum < st.target){ addStep({type:'base_false_total'}); return; }if (st.m >= st.target){ addStep({type:'base_true_m'}); return; }// 添加调用 dfs(0, target)addStep({type:'call_dfs', mask:0, remain: st.target});simulateDFS(0, st.target, []);updateStepInfo();}function simulateDFS(mask, remain, callstack){// 进入 dfs 状态addStep({type:'enter', mask, remain, stack: [...callstack, {mask, remain}]});// 10-11: 记忆化命中?addStep({type:'check_memo', mask});// 16-19: 剪枝addStep({type:'check_prune', mask, remain});// 22-30: 枚举 x 从 m..1for (let x=st.m; x>=1; --x){const bit = 1 << (x-1);addStep({type:'loop_x', mask, remain, x});addStep({type:'check_used', mask, x});addStep({type:'check_win_now', remain, x});// 递归addStep({type:'recurse_maybe', mask, remain, x});// 子结果回来后判断是否对手必败addStep({type:'after_child', mask, remain, x});}// 31-32: 失败addStep({type:'mark_lose', mask});}function updateInfo(mask, remain){maskSpan.textContent = String(mask);maskBinSpan.textContent = toBin(mask);remainSpan.textContent = String(remain);pruneSpan.textContent = '-';statusSpan.textContent = '-';toggleUsed(mask);}function sleep(ms){ return new Promise(r=>setTimeout(r, ms)); }function applyStep(k){const s = st.stepHistory[k];if (!s) return;switch(s.type){case 'base_true': setActive([39]); statusSpan.textContent = 'canIWin=true (target<=0)'; break;case 'base_false_total': setActive([41,42]); statusSpan.textContent = 'canIWin=false (sum<target)'; break;case 'base_true_m': setActive([43]); statusSpan.textContent = 'canIWin=true (m>=target)'; break;case 'call_dfs': setActive([45,46]); updateInfo(s.mask, s.remain); pushStack(s.mask,s.remain,true); break;case 'enter': setActive([9]); updateInfo(s.mask,s.remain); renderStack(s.stack, s.mask, s.remain); break;case 'check_memo': setActive([10,11]); showMemoState(s.mask); break;case 'check_prune':{setActive([13,14,15,16]);const sumUsed = st.target - s.remain;const sumRemaining = st.totalSum - sumUsed;const willPrune = sumRemaining < s.remain;pruneSpan.textContent = willPrune ? 'true' : 'false';if (willPrune){setActive([16,17,18]);memoWrite(s.mask, -1);statusSpan.textContent = '剪枝:当前必败';popUntil(s.mask, s.remain); // 逻辑上返回 false}break;}case 'loop_x': setActive([22]); clearPick(); setPick(s.x); break;case 'check_used': setActive([23,24]);{const used = !!(s.mask & (1<<(s.x-1)));if (used){ /* continue */ }break;}case 'check_win_now': setActive([25]);{if (s.x >= s.remain){ memoWrite(currentMask(), 1); statusSpan.textContent = '立即获胜'; }break;}case 'recurse_maybe': setActive([26]);{const used = !!(s.mask & (1<<(s.x-1)));if (used || s.x >= s.remain) break; // 跳过或已赢// 递归前入栈pushStack(s.mask | (1<<(s.x-1)), s.remain - s.x, true);break;}case 'after_child': setActive([26,27,28]);{// 可视化上:如果子状态是败局(memo=-1),则当前赢const childMask = s.mask | (1<<(s.x-1));const childSt = st.memo[childMask] || 0;if (childSt === -1){ memoWrite(s.mask, 1); statusSpan.textContent = '找到让对手必败的选择 → 赢'; }break;}case 'mark_lose': setActive([31,32]); memoWrite(s.mask, -1); statusSpan.textContent = '无路可赢 → 败'; break;}}function currentMask(){// 从栈可得当前 mask;如果栈空,返回 0const items = stackDiv.querySelectorAll('.stack-item');if (!items.length) return 0;const last = items[items.length-1];return Number(last.getAttribute('data-mask'))||0;}function clearPick(){for (let x=1;x<=st.m;x++){const el = document.getElementById(`g100-num-${x}`);if (el) el.classList.remove('pick');}xSpan.textContent = '-';}function memoWrite(mask, val){const prev = st.memo[mask]|0;if (!prev){ st.visited++; memoCountSpan.textContent = String(st.visited); }st.memo[mask] = val;}function showMemoState(mask){const v = st.memo[mask]|0;if (v===0) statusSpan.textContent = 'memo=0 (未知)';if (v===1) statusSpan.textContent = 'memo=1 (必胜) → 直接返回';if (v===-1) statusSpan.textContent = 'memo=-1 (必败) → 直接返回';}function pushStack(mask, remain, highlight){const d = document.createElement('div');d.className = 'stack-item' + (highlight? ' active':'');d.setAttribute('data-mask', String(mask));d.textContent = `(mask=${mask}, remain=${remain}) ${toBin(mask)}`;stackDiv.appendChild(d);remainSpan.textContent = String(remain);toggleUsed(mask);}function popUntil(mask, remain){// 本可视化仅标识返回,不做真实回溯删除,保留整条路径便于观察}function renderStack(list, curMask, curRemain){stackDiv.innerHTML = '';for (const it of list){ pushStack(it.mask, it.remain, false); }pushStack(curMask, curRemain, true);}function updateStepInfo(){const total = st.stepHistory.length;const cur = Math.max(0, st.curIdx+1);stepInfo.textContent = `Step: ${cur}/${total}`;btnPrev.disabled = st.curIdx <= -1;btnNext.disabled = st.curIdx >= total-1;}function rebuildTo(idx){// 轻量重放:简单地从头播放到 idxstackDiv.innerHTML = '';setNums(st.m);toggleUsed(0);statusSpan.textContent = '-';xSpan.textContent = '-';remainSpan.textContent = '-';maskSpan.textContent = '-';maskBinSpan.textContent = '-';pruneSpan.textContent = '-';st.memo.fill(0);st.visited = 0; memoCountSpan.textContent = '0';setActive([]);for (let i=0;i<=idx;i++) applyStep(i);st.curIdx = idx; updateStepInfo();}async function autoRun(){if (st.running) return; st.running=true;try{while (st.curIdx < st.stepHistory.length-1){if (st.paused){ await sleep(40); continue; }rebuildTo(st.curIdx+1);await sleep(st.spd);}} finally { st.running=false; }}// 事件绑定btnBuild?.addEventListener('click', ()=>{ build(); updateStepInfo(); });btnRun?.addEventListener('click', ()=>{ if (!st.stepHistory.length) build(); autoRun(); });btnStep?.addEventListener('click', ()=>{ if (!st.stepHistory.length) build(); if (st.curIdx < st.stepHistory.length-1) rebuildTo(st.curIdx+1); });btnPause?.addEventListener('click', ()=>{ st.paused = !st.paused; btnPause.textContent = st.paused? 'Continue':'Pause'; });btnPrev?.addEventListener('click', ()=>{ if (st.curIdx>=0) rebuildTo(st.curIdx-1); });btnNext?.addEventListener('click', ()=>{ if (st.curIdx < st.stepHistory.length-1) rebuildTo(st.curIdx+1); });btnReset?.addEventListener('click', ()=>{ st.stepHistory=[]; st.curIdx=-1; stackDiv.innerHTML=''; setNums(Number(elM.value||10)); toggleUsed(0); setActive([]); updateStepInfo(); });speed?.addEventListener('input', ()=>{ const v=Number(speed.value); if (Number.isFinite(v)) st.spd=v; });// 默认构建一次build();})();</script>
</body>
</html>