LeetCode 464 我能赢吗


文章目录

摘要

这道题表面看起来像是个简单的博弈问题,但真正写起来,很多人会直接被「状态爆炸」劝退。
maxChoosableInteger 最大能到 20,看似不大,但所有组合一算,状态数量直接飙到百万级。

这类题非常典型:
规则简单 + 最优策略 + 不能贪心 + 需要记忆化搜索

如果你最近在刷博弈类、状态压缩、DFS + Memo 的题,这道题几乎是绕不开的一关。本文会一步一步拆解思路,并用 Swift 给出一份可运行、好理解的实现。

描述

游戏规则可以简单总结成几句话:

  • 有一个公共数字池,数字范围是 1 ~ maxChoosableInteger
  • 两个玩家轮流选数
  • 每个数字 只能用一次
  • 选中的数字会累加到当前总和
  • 谁先让累计和 达到或超过 desiredTotal,谁就赢
  • 两个玩家都足够聪明,永远走最优解

问题是:
先手玩家,是否一定能赢?

这里有几个关键点很容易被忽略:

  1. 这是一个典型的「零和博弈」
  2. 玩家不会随机选,而是"我选了你会怎么反制"
  3. 不能重复选数,意味着状态不仅和"当前和"有关,还和"哪些数已经用过"有关

这就直接决定了:
贪心是行不通的,暴力 DFS 会超时,必须配合记忆化。

题解答案

核心思路一句话总结:

把"当前还能不能赢"这个问题,抽象成一个函数,用「已经选过的数字集合」作为状态,用 DFS + 记忆化搜索判断是否存在一个必胜选择。

几个重要剪枝先说清楚:

  1. 如果 desiredTotal <= 0,先手啥都不选就已经赢了,直接返回 true
  2. 如果 1 + 2 + ... + maxChoosableInteger < desiredTotal
    那无论怎么选,总和都达不到目标,先手必输

真正的博弈逻辑是:

  • 当前玩家尝试选择一个还没用过的数字 i
  • 如果 i >= 剩余目标,当前玩家立刻赢
  • 否则,把这个数标记为已使用,递归判断 对手是否会输
  • 只要存在一个选择,能让对手输,那当前玩家就是稳赢

题解代码分析

下面是完整 Swift 实现,支持直接运行测试。

swift 复制代码
class Solution {
    func canIWin(_ maxChoosableInteger: Int, _ desiredTotal: Int) -> Bool {
        // 特殊情况:目标本身 <= 0,先手直接赢
        if desiredTotal <= 0 {
            return true
        }

        // 所有数加起来都不够,必输
        let maxSum = (1 + maxChoosableInteger) * maxChoosableInteger / 2
        if maxSum < desiredTotal {
            return false
        }

        var memo = [Int: Bool]()
        return dfs(usedMask: 0,
                   remaining: desiredTotal,
                   maxNum: maxChoosableInteger,
                   memo: &memo)
    }

    private func dfs(usedMask: Int,
                     remaining: Int,
                     maxNum: Int,
                     memo: inout [Int: Bool]) -> Bool {

        // 如果这个状态已经算过,直接返回
        if let cached = memo[usedMask] {
            return cached
        }

        // 尝试选择每一个还没用过的数字
        for i in 1...maxNum {
            let bit = 1 << (i - 1)
            if (usedMask & bit) != 0 {
                continue
            }

            // 如果当前选 i 就能赢,直接返回 true
            if i >= remaining {
                memo[usedMask] = true
                return true
            }

            // 否则,看对手在新状态下是否会输
            let nextMask = usedMask | bit
            let opponentWin = dfs(
                usedMask: nextMask,
                remaining: remaining - i,
                maxNum: maxNum,
                memo: &memo
            )

            // 对手输,说明我赢
            if !opponentWin {
                memo[usedMask] = true
                return true
            }
        }

        // 所有选择都会让对手赢,那我必输
        memo[usedMask] = false
        return false
    }
}

示例测试及结果

我们用题目里的例子来跑一跑。

swift 复制代码
let solution = Solution()

print(solution.canIWin(10, 11)) // false
print(solution.canIWin(10, 0))  // true
print(solution.canIWin(10, 1))  // true

输出结果:

复制代码
false
true
true

结果和题目给的一致。

再举一个直观点的例子

swift 复制代码
print(solution.canIWin(5, 6))

解释一下:

  • 可选数字是 1~5
  • 如果先手选 1,对手选 5,直接赢
  • 如果先手选 2,对手选 4,也能赢
  • 无论先手怎么走,都挡不住对手

结果自然是 false

时间复杂度

状态的核心是 usedMask,它是一个最多 20 位的二进制数。

  • 状态数量最多是 2^20 ≈ 1,048,576
  • 每个状态最多遍历 maxChoosableInteger

所以时间复杂度可以近似认为是:

复制代码
O(2^n * n)

在题目限制 n <= 20 的情况下,配合记忆化是完全能跑过的。

空间复杂度

主要消耗在两个地方:

  1. 记忆化哈希表,最多存 2^n 个状态
  2. DFS 递归栈,深度最多 n

所以空间复杂度是:

复制代码
O(2^n)

这是这类博弈 + 状态压缩题的正常代价。

总结

这道题非常适合用来练三件事:

  1. 如何把「博弈问题」抽象成递归状态
  2. 如何用 bitmask 表示"选择过哪些数"
  3. 如何用记忆化避免指数级重复计算
相关推荐
ths5125 分钟前
测试开发python中正则表达式使用总结(二)
开发语言·python·算法
不爱吃炸鸡柳9 分钟前
5道经典贪心算法题详解:从入门到进阶
开发语言·数据结构·c++·算法·贪心算法
枫叶林FYL15 分钟前
【自然语言处理 NLP】8.3 长文本推理评估与针在大海堆任务
人工智能·算法
智者知已应修善业18 分钟前
【51单片机1,左边4个LED灯先闪烁2次后,右边4个LED灯再闪烁2次:2,接着所用灯一起闪烁3次,接着重复步骤1,如此循环。】2023-5-19
c++·经验分享·笔记·算法·51单片机
xiaoye-duck24 分钟前
《算法题讲解指南:优选算法-队列+宽搜》--70.N叉树的层序遍历,71.二叉树的锯齿形层序遍历,72.二叉树的最大宽度,73.在每个树行中找最大值
数据结构·c++·算法·队列
汀、人工智能26 分钟前
[特殊字符] 第98课:数据流中位数
数据结构·算法·数据库架构··数据流·数据流中位数
Eloudy29 分钟前
不同特征值的特征向量互相正交的矩阵
人工智能·算法·机器学习
人道领域31 分钟前
【LeetCode刷题日记】:从 LeetCode 经典题看哈希表的场景化应用---数组、HashSet、HashMap 选型与算法实战
算法·leetcode·面试
努力努力再努力wz31 分钟前
【C++高阶系列】告别内查找局限:基于磁盘 I/O 视角的 B 树深度剖析与 C++ 泛型实现!(附B树实现源码)
java·linux·开发语言·数据结构·c++·b树·算法