Swift 解 LeetCode 390:消除游戏的数学规律与 O(log n) 优化
我第一次见“LeetCode 390 消除游戏”这道题的时候第一反应是这不就是模拟吗维护一个数组从左往右删一轮再从右往左删一轮循环到只剩一个数就完事。然后我看了眼数据范围n 最大能到 10^9瞬间清醒。Swift 里Array(1...1_000_000_000)这一行就能让内存和时限一起爆炸。所以这道题标着 Medium实际上考的完全不是“会不会写循环”而是“能不能看穿消除过程背后的数学结构”。这篇文章我就用 Swift 把这道题的规律推导、代码实现、边界陷阱完整拆一遍顺便分享一个很实用的对拍脚本适合正在刷 LeetCode 的 Swift 选手也适合准备面试时想搞清楚“为什么暴力解不行”的人。1. 为什么暴力解法在 n 10^9 面前直接阵亡1.1 暴力模拟的复杂度其实不算差但依然不够看很多人以为暴力解法是 O(n^2)其实不是。你每次把数组长度减半遍历的元素总量加起来是 n n/2 n/4 ... 等比数列求和约等于 2n。所以单纯从遍历次数看暴力解法是 O(n)。问题在于 n 的上限是 10^9。Swift 一秒大概能执行几千万到一亿次简单操作2n 意味着大约 20 亿次操作在 LeetCode 的时限下基本是几十秒级别。更要命的是内存一个包含 10^9 个 Int 的数组在 64 位平台上直接占 8GB 内存LeetCode 的 Swift 环境根本不可能分给你这么多空间。我最初在 Playground 里试了let arr Array(1...100_000_000)几乎是瞬间内存暴涨然后被系统杀掉。所以这条路从根上就走不通。1.2 关键观察每轮剩下的序列依然是等差数列暴力为什么浪费因为每一轮我们其实不需要知道“具体哪些数剩下了”只需要知道“剩下的序列长什么样”。仔细观察第一轮从 1 开始每隔一个删一个剩下的是 2, 4, 6, 8, ... 这是一个首项为 2、公差为 2 的等差数列。第二轮从右往左删假设当前序列是 [2, 4, 6, 8, 10, 12]从右往左每隔一个删一个删掉的是 12、8、4剩下的是 2、6、10这依然是等差数列首项 2、公差 4。第三轮从左往右删剩下 6还是等差数列只是只剩一项罢了。这个观察是整个题解的基石任何一轮操作之后剩余序列都能用一个“首项 公差 项数”的三元组完整描述。我们真正要求的就是“最终首项”的值。既然如此完全没必要把整个数组搬来搬去。2. 把序列压缩成三元组首项、公差、项数的变化规则2.1 三个状态变量的定义设当前序列为a序列的第一个元素d公差也就是相邻两个元素的间隔cnt序列中元素的个数初始状态是a 1、d 1、cnt n。每一次消除操作之后项数会减半cnt cnt / 2向下取整公差会翻倍d 2 * d因为删掉一半元素后两个保留元素之间隔了两个原公差唯一需要仔细推导的是首项a到底变不变、怎么变。2.2 首项变化的唯一难点从右往左删时要看奇偶从左往右删除时规律非常稳定删除第 1、3、5... 项剩下的第一个元素是从左往右数第 2 个也就是原来的a d。这个结论跟cnt是奇数还是偶数没有关系。但从右往左删除时情况就微妙了如果cnt是偶数比如 [2, 4, 6, 8]从右往左每隔一个删一个删掉 8、4剩下 2、6。首项 2 没有被删依然保留所以a不变。如果cnt是奇数比如 [2, 4, 6, 8, 10]从右往左每隔一个删一个删掉 10、6、2首项 2 被删掉了剩下的第一个元素变成 4所以a a d。结合两种情况判断条件可以合并成一句当fromLeft true或cnt % 2 1时a a d否则a保持不变。2.3 用 n 8 完整走一遍推导拿 n 8 来验证这个规则初始序列 [1, 2, 3, 4, 5, 6, 7, 8]三元组是 a1, d1, cnt8方向是从左往右。第一轮从左往右删删除 1、3、5、7剩 [2, 4, 6, 8]。计算cnt 变成 4d 变成 2首项 a 因为是从左往右所以 a 1 1 2。第二轮从右往左删当前 cnt4 是偶数首项不变a 还是 2。剩 [2, 6]此时 d 变成 4cnt 变成 2。第三轮从左往右删a 2 4 6。cnt 变成 1循环结束。最终答案就是 6。手动模拟一下确实是 6没问题。2.4 另一种等价的极短递归公式上面这套三元组迭代法已经很清晰了不过 LeetCode 讨论区还有一种更“吓人”的递归写法func lastRemaining(_ n: Int) - Int { if n 1 { return 1 } return 2 * (n / 2 1 - lastRemaining(n / 2)) }我第一次看到这个公式时愣了半天后来想明白了。核心思路是“映射”第一轮从左往右删除奇数位置后剩下的序列是 [2, 4, 6, ..., 2 * m]其中 m n / 2。整体除以 2 之后问题就变成在 [1, 2, ..., m] 上从右往左开始做同样的消除。而从右往左开始消除的结果恰好等于 m 1 减去从左往右开始消除的结果因为把序列镜像对称之后删除规则就反过来了。所以f(n) 2 * (m 1 - f(m))。这个公式非常优雅但理解和记忆成本比迭代三元组高。日常写题我更推荐迭代版不容易在递归边界上翻车。3. Swift 实现核心代码、逐行拆解与 Int 溢出防线3.1 可提交的完整代码先给出可以直接粘贴到 LeetCode 的代码class Solution { func lastRemaining(_ n: Int) - Int { var first 1 var diff 1 var count n var fromLeft true while count 1 { if fromLeft || count % 2 1 { first diff } diff * 2 count / 2 fromLeft.toggle() } return first } }整个函数只有十几行时间复杂度和空间复杂度分别如下时间复杂度 O(log n)因为每轮 count 减半空间复杂度 O(1)只用了几个变量n 取 10^9 时循环次数大约是 30 次这个性能在 Swift 里可以忽略不计。3.2 更新顺序是很多人写错的重灾区这个代码看起来短但有一个隐藏的坑first diff的判断必须基于“当前轮”的 count 和 fromLeft不能先更新 count 再判断。我举个例子你就明白了。假设 n 12初始 [1...12]第一轮从左往右删剩 [2, 4, 6, 8, 10, 12]此时 a2, d2, cnt6。第二轮从右往左删cnt6 是偶数所以首项应该保持 2。如果你在判断之前先执行了count / 2count 变成 33 % 2 1就会错误地把 first 变成 4最终结果直接崩掉。所以正确的顺序必须是用当前 count 判断首项要不要变再更新 diff 和 count最后翻转方向每轮循环里这三步的顺序只要错一个答案就歪了。3.3 Int 溢出与位运算优化Swift 的Int在 64 位平台上占 64 位取值范围非常大。这道题的 diff 初始为 1每轮乘以 2n 最大 10^9 时 diff 最多到 2^30 左右完全在安全范围内。但如果把这段代码移植到 32 位平台比如某些嵌入式 Swift 环境diff 在 n 超过 2^30 时就会溢出。LeetCode 的 Swift 环境是 64 位可以放心但作为一个严谨的题解我还是建议用位运算把风险降到最低if fromLeft || count 1 1 { first diff } diff 1 count 1count 1 1等价于count % 2 1diff 1等价于diff * 2count 1等价于count / 2。位运算除了效率更高还明确告诉读者这里就是在操作二进制位级别的状态变化。3.4 函数式风格的替代方案如果你偏好简洁也可以把状态打包成一个元组用递归写func lastRemaining(_ n: Int) - Int { func f(_ first: Int, _ diff: Int, _ count: Int, _ fromLeft: Bool) - Int { if count 1 { return first } let newFirst (fromLeft || count % 2 1) ? first diff : first return f(newFirst, diff 1, count 1, !fromLeft) } return f(1, 1, n, true) }递归深度是 O(log n)完全不用担心栈溢出。两种写法在 LeetCode 上都能过选自己顺手的那种就行。4. 边界用例、反直觉陷阱和一个对拍脚本4.1 从 n 1 到 n 10 的规律表写这类规律题建议先把小规模的结果手工列出来看有没有明显模式。我用暴力模拟跑了前 10 个值n结果112232425264748696108这个表最直观的价值是验证推导是否正确。比如 n8 时结果是 6n10 时结果是 8都跟我们在 2.3 节手算的结果对得上。还有一个容易忽略的点n1 时循环压根不执行直接返回 first1。这个边界虽然简单但如果你把循环条件写成while count 1就会变成死循环。4.2 两个反直觉的坑第一个坑是“从右往左删且 cnt 为偶数时首项不变”。很多人会想当然地认为从右往左删肯定会动到第一个元素于是每次都让 first diff。只有在 cnt 为奇数时从右往左才会把首项删掉这个反直觉点不亲自推一遍很难记住。第二个坑是递归公式里的整数除法。lastRemaining(n / 2)的 n / 2 是向下取整Swift 的整数除法对正数本来就是向下取整但如果你改写的时候不小心用了ceil或者浮点数除法结果就会错。整数运算在算法题里是默认规则可一旦代码从整数改成浮点就会出现精度问题。4.3 对拍脚本用暴力解验证数学解我刷题有一个习惯凡是用数学规律写的题必须写一个暴力解作为“对照实验”。Xcode Playground 里用暴力解跑小数据用数学解跑同样的小数据逐项比对。下面这个脚本可以直接跑func bruteForce(_ n: Int) - Int { var arr Array(1...n) var fromLeft true while arr.count 1 { if fromLeft { arr stride(from: 1, to: arr.count, by: 2).map { arr[$0] } } else { let newArr stride(from: arr.count - 2, through: 0, by: -2).map { arr[$0] } arr newArr.reversed() } fromLeft.toggle() } return arr[0] } for n in 1...100 { let expected bruteForce(n) let actual lastRemaining(n) if expected ! actual { print(Mismatch at n\(n): expected \(expected), got \(actual)) break } }这里有个细节从右往左删的时候我把保留的元素先反向收集再reversed()这样才能得到从左到右的正确顺序。注意不是arr.count - 1开始收集因为从右往左每隔一个删一个第一个被删的是最右边的元素第一个被保留的是右边第二个对应索引arr.count - 2。这个细节我一开始就写错了对拍脚本立刻帮我揪了出来。暴力解在 n 超过 10000 时会明显变慢所以只用来验证小数据就够。数学解的每一轮推导是否正确都可以靠这个小脚本迅速确认。5. 这类“假装是模拟”的题到底在考什么5.1 方法论用一个状态变量组描述整个序列LeetCode 390 表面上是一个模拟题实际上是一个“状态压缩”题。它真正考察的能力是你能不能发现虽然序列很长但每一轮之后整个序列的信息量并没有爆炸只需要少数几个变量就能完全描述。这种思维方式在算法题里非常常见。比如线段树用节点区间描述大数组快速幂用底数和指数描述幂运算约瑟夫环用“当前起点 人数 步长”描述圆桌状态。遇到“给你一个很大的结构反复做某种操作”的题目我的第一反应永远是能不能用一个或者几个变量把这个结构在每次操作后的“不变量”抓住对这道题来说不变量就是“剩余序列仍然是等差数列”。只要抓住这个复杂度就从 O(n) 降到了 O(log n)。5.2 同类题的横向对比LeetCode 上还有几道题和 390 的思考方式很像LeetCode 1823约瑟夫环也是用一个“当前位置 剩余人数”递推而不是真的去删除数组元素。LeetCode 1351 / 378 这类矩阵题用“起点在右上角慢慢挪”代替二维遍历。LeetCode 50实现 pow(x, n)用快速幂把乘法次数从 O(n) 降到 O(log n)。它们的共同点都是不要用第一直觉去模拟完整过程先想想能不能用数学结构压缩状态。从刷题效率的角度看这比多背模板有用得多。5.3 给 Swift 刷题党的几点建议Swift 在某些 LeetCode 题里确实没 C 或者 Python 那么顺手但它也有一些独特优势。就拿这道题来说toggle()方法翻转布尔值语义比fromLeft !fromLeft更清晰。位运算符、、在 Swift 里类型要求严格不会出现整型隐式转换的坑。元组和递归天然契合适合写 3.4 节那种函数式风格。我自己做这道题的心得是花 10 分钟手推规律比花 10 分钟写一个注定超时的暴力模拟要值。先用小 n 打表再用对拍脚本验证最后再整理成 Swift 代码提交整个过程下来你对等差数列和状态压缩的理解会比单纯背题解深得多。最后分享一个小技巧以后遇到任何“每轮去掉一半元素”的题先画一个 n8 或者 n10 的执行过程图把每一轮的剩余序列写出来很多规律会自己跳出来。LeetCode 390 这个题我能在几分钟内写出最优解靠的就是这张手写推演表。

相关新闻

地下城封号查询源码解析:3步搞定项目搭建

地下城封号查询源码解析:3步搞定项目搭建

地下城封号查询源码解析:3步搞定项目搭建 刚把Python语法背得滚瓜烂熟,一动手写个地下城封号查询接口,直接卡壳。 变量定义会了,函数也写了,怎么连数据库、怎么返回JSON,全懵圈。 这就是典型的“纸上谈兵”,懂原理却搭不起架子。…

2026/9/21 19:46:09 阅读更多 →
u支付高并发场景下性能优化完整示例与实战避坑指南

u支付高并发场景下性能优化完整示例与实战避坑指南

u支付高并发场景下性能优化完整示例与实战避坑指南 面试被问“为什么你的支付接口在高峰期会超时”,如果只能回答“加缓存”或“扩容”,基本就挂了。很多开发者对…

2026/9/21 19:46:09 阅读更多 →
3招搞定魔导英雄传安卓存档,避开高频面试题陷阱

3招搞定魔导英雄传安卓存档,避开高频面试题陷阱

3招搞定魔导英雄传安卓存档,避开高频面试题陷阱 刷过《魔导英雄传》安卓版的玩家都知道,想换个强力角色或者跳过前期枯燥的刷怪流程,改存档是最直接的办法。但很多人一上手就懵了,不是找不到文件,就是改完数据进游戏直接闪退,白白浪费了周末的时间。其…

2026/9/21 19:46:09 阅读更多 →

最新新闻

3个坑让你面试翻车:第一徻所性能优化完整示例

3个坑让你面试翻车:第一徻所性能优化完整示例

3个坑让你面试翻车:第一徻所性能优化完整示例 面试被问原理答不上来,那种大脑一片空白的感觉,真的比写不出代码还难受。很多转岗的朋友,简历上写着精通Java或Go,面试官随口一问“这个模块为什么慢”,你只能支支吾吾说“可能是GC”,或者直接愣…

2026/9/21 20:22:27 阅读更多 →
Readest 后台朗读会话解耦架构解析:关闭书本后 TTS 继续播放的设计与实现

Readest 后台朗读会话解耦架构解析:关闭书本后 TTS 继续播放的设计与实现

Readest 后台朗读会话解耦架构解析:关闭书本后 TTS 继续播放的设计与实现 【免费下载链接】readest Readest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive inter…

2026/9/21 20:22:27 阅读更多 →
Linux版QQ图解原理:3步搞定版本升级后API全变的痛点

Linux版QQ图解原理:3步搞定版本升级后API全变的痛点

Linux版QQ图解原理:3步搞定版本升级后API全变的痛点 刚把服务器上的QQ机器人从 9.x 升到 10.x,结果脚本直接报 AttributeError: 'QQ' object has no attribute…

2026/9/21 20:22:27 阅读更多 →
Relay Data-Driven Dependencies(@module)实战:基于 Union 类型与 MatchContainer 的按需组件加载

Relay Data-Driven Dependencies(@module)实战:基于 Union 类型与 MatchContainer 的按需组件加载

前端开发工具 【免费下载链接】relay Relay is a JavaScript framework for building data-driven React applications. 项目地址: https://gitcode.com/gh_mirrors/relay29/relay 点击查看 免费下载 本篇技术指南围绕 Relay 仓库中一个最小化、可端到端验证的 Dat…

2026/9/21 20:22:27 阅读更多 →
5个高频面试题:炫舞名字空格原理与选型实战

5个高频面试题:炫舞名字空格原理与选型实战

5个高频面试题:炫舞名字空格原理与选型实战 刚毕业时,我盯着Python的 for 循环和Java的 HashMap 看了三天,觉得只要语法滚瓜烂熟,项目随便拿个架子一填就能跑。直到第一次接手实际业务,发现连个简单的用户昵称处理都卡住了:为…

2026/9/21 20:22:27 阅读更多 →
3个坑解决福建移动通信网上营业厅性能瓶颈

3个坑解决福建移动通信网上营业厅性能瓶颈

3个坑解决福建移动通信网上营业厅性能瓶颈 看了一堆教程还是不会写项目?别急,问题往往出在你对底层逻辑的忽视。以福建移动通信网上营业厅这类高并发业务系统为例,很多开发者只盯着业务代码,却忽略了源码解析中的性能陷阱。…

2026/9/21 20:21:26 阅读更多 →

日新闻

agents-generator 决策矩阵全解析:从项目检测到 AGENTS.md 规则生成的 16 步判定流程

agents-generator 决策矩阵全解析:从项目检测到 AGENTS.md 规则生成的 16 步判定流程

agents-generator 决策矩阵全解析:从项目检测到 AGENTS.md 规则生成的 16 步判定流程 【免费下载链接】agentic-awesome-skills AAS Core is the local, agent-first control plane for complete catalog discovery, agent-owned selection, stack validation, and …

2026/9/21 0:00:01 阅读更多 →
gin-vue-admin 前端工具函数全景指南:src/utils 复用规范与源码级解析

gin-vue-admin 前端工具函数全景指南:src/utils 复用规范与源码级解析

gin-vue-admin 前端工具函数全景指南:src/utils 复用规范与源码级解析 【免费下载链接】gin-vue-admin 🚀ViteVue3Gin拥有AI辅助的基础开发平台,企业级业务AI开发解决方案,内置mcp辅助服务,内置skills管理,…

2026/9/21 0:00:01 阅读更多 →
Wox 全功能插件开发实战指南:基于 Python / Node.js 宿主与 WebSocket 的持久化插件体系

Wox 全功能插件开发实战指南:基于 Python / Node.js 宿主与 WebSocket 的持久化插件体系

桌面应用AI 应用插件系统 【免费下载链接】Wox A cross-platform launcher that simply works 项目地址: https://gitcode.com/gh_mirrors/wo/Wox 点击查看 免费下载 全功能插件(Full-featured Plugin)是 Wox 三类插件实现方式中能力最完整的…

2026/9/21 0:00:01 阅读更多 →

周新闻

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

直接铺开项目本身吧。这几个月我一直在折腾一件事:用Flutter给OpenHarmony做一款游戏集合类的App,说白了就是把若干小游戏塞进一个壳里,用统一入口分发。这个方向本身不算新鲜,真正让我花了不少心思的,是首页那堆游戏卡…

2026/9/21 3:13:20 阅读更多 →
Word表格编号全攻略:从列表编号到题注交叉引用

Word表格编号全攻略:从列表编号到题注交叉引用

写Word文档,最让人头疼的往往是那些“看起来不起眼”的小问题。比如表格编号这事:今天在表后面多加了两个空白行,明天给客户交稿前发现整个章节的编号全部错位,光是挨个改序号就能耗掉大半个下午。我前阵子帮人整理一份上百页的技…

2026/9/21 2:19:36 阅读更多 →
从第一个站到第二个站:独立开发者的静态网站选型与落地实践

从第一个站到第二个站:独立开发者的静态网站选型与落地实践

1. 项目概述1.1 核心需求解析做独立开发者这几年,说实话,第一个网站上线的那天晚上我兴奋得没睡着。但等它跑了半年,流量惨淡、功能臃肿、代码自己都懒得看第二遍之后,我才慢慢琢磨明白一个道理:第一个网站是练手&…

2026/9/21 4:51:05 阅读更多 →

月新闻

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能分类:[AI/大模型]细分主题:AI 增强型 CI/CD 流水线自动化与 GitOps 实践:Agent 工作流、工具调用与任务拆解:从原型到生产的验收清单很多团队在尝试用大…

2026/9/21 15:36:51 阅读更多 →
容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场分类:[工程技术]细分主题:Kubernetes 生产环境运维与排障实战:可复制的项目复盘模板与决策记录大部分团队的事故复盘报告,最后都变成了躺在 Confluence 或钉…

2026/9/21 15:36:51 阅读更多 →
容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步分类:[工程技术]细分主题:Docker 容器化技术与镜像安全管理:核心链路的逐步实现与关键代码取舍面对一个积累了五六年历史包袱的单体架构应用(包含 Web 接口、后台…

2026/9/19 23:35:34 阅读更多 →