上周六的 LeetCode 第 512 场周赛,我侥幸拿到了国服 22 名,并且难得地实现了“无伤 AK”(即四道题全部一次提交通过,没有罚时)。这个成绩本身不算顶尖,但“无伤”的过程,却让我这个老选手感触颇深——不是感慨自己变强了,而是清晰地感觉到,解题的“战场”正在悄然改变。
过去,周赛的挑战主要在于“算法思维”和“编码实现”。但现在,随着题目描述越来越长、场景越来越生活化、边界条件越来越隐蔽,“阅读理解”和“细节把控”正成为决定胜负的关键,甚至比想出一个巧妙的解法更重要。这次周赛的四道题,几乎每一道都在考验选手的耐心和细心。一个词看错,一个边界没想清,就可能从“无伤”变成“罚时坐牢”。
如果你也经常在周赛中因为看错题、漏条件而痛失好局,或者感觉题目越来越“绕”,那么这篇文章或许能给你一些不一样的视角。我不打算简单罗列题解代码,而是想结合这次“无伤 AK”的实战经历,和你深入聊聊:面对越来越“狡猾”的 LeetCode 周赛,我们该如何调整备赛策略,从“读题”开始就建立优势,并稳定地将思路转化为无 Bug 的代码。
本文将包含完整的四道题目的思路分析、关键陷阱、代码实现以及赛后复盘。更重要的是,我会分享一套我自己在用的、用于对抗“老年痴呆”和“读题吃力”的实战检查清单。这套方法能帮助你在紧张的比赛时间内,最大限度地减少非算法性失误。
1. 周赛趋势洞察:为什么“无伤”越来越难?
在深入具体题目之前,我们有必要先理解当前周赛出题的一个明显趋势:题目正从“纯算法模板题”向“综合应用题”演变。
这带来的直接影响是:
- 信息密度增加:题目描述中夹杂了更多的背景故事、场景说明,核心的算法约束条件可能分散在多个段落中。
- 陷阱设计更隐蔽:边界条件(如数组为空、结果为0、整数溢出)不再是摆在明面上,而是需要你从场景中自行推导。
- 实现细节要求更高:即使算法思路正确,如果在实现时对数据结构的API不熟、或者循环边界写错,也会导致失败。
以本次周赛为例,没有用到特别高深的数据结构或算法(最高到二分查找和前缀和),但每一题都有“坑”。比赛的竞争,在很大程度上变成了“谁更细心”的竞争。因此,我们的备赛重心也应该从“狂刷难题”向“提升稳定性和熟练度”倾斜。
2. 题目一:找出输掉零场或一场比赛的玩家
题目链接:通常为第一题,编号如 3042 之类(具体以平台为准)。题目大意:给定一个整数n和一个二维整数数组matches,其中matches[i] = [winneri, loseri]表示一场比赛。需要返回一个长度为 2 的列表,第一个列表是所有从未输过的玩家,第二个列表是只输过一场的玩家。两个列表都需要按递增顺序排序。
2.1 思路分析与关键陷阱
这是一道典型的计数+模拟题,考察哈希表(字典)的基本使用。
核心思路:
- 我们需要统计每个玩家的输场次数。
- 因为题目只关心输场,所以赢家如果没输过,也会出现在结果中。我们只需关注
loser。 - 遍历
matches数组,用哈希表lose_count记录每个玩家的输场次数。 - 同时,为了找出“从未输过”的玩家,我们需要知道所有出现过(无论是赢是输)的玩家。可以用一个集合
all_players记录所有在matches中出现的玩家。 - 最后,遍历
all_players:- 如果该玩家不在
lose_count中或其输场次数为 0,则加入“从未输过”列表。 - 如果该玩家在
lose_count中的次数恰好为 1,则加入“只输一场”列表。
- 如果该玩家不在
- 对两个列表进行排序后返回。
关键陷阱与易错点:
- 玩家编号范围:玩家编号是从
1到n吗?题目描述是1 <= winneri, loseri <= 10^5,但n参数可能表示玩家总数或别的含义。仔细读题发现,n在此题中可能没有直接用于限制玩家编号范围,所有玩家信息应从matches中获取。这是一个常见的迷惑点,不要默认玩家编号就是1..n。 - 排序要求:结果列表必须递增排序。这是一个非常常见的要求,但紧张时容易忘记。
- 从未输过的定义:一个玩家只要在
matches中出现过(即使是作为赢家),且没有输过,就算“从未输过”。所以我们需要all_players集合。
2.2 代码实现与注释
from typing import List from collections import defaultdict class Solution: def findWinners(self, matches: List[List[int]]) -> List[List[int]]: # 使用 defaultdict(int) 可以方便地计数,默认值为0 lose_count = defaultdict(int) # 使用集合记录所有出现过的玩家 all_players = set() for winner, loser in matches: lose_count[loser] += 1 all_players.add(winner) all_players.add(loser) never_lost = [] lost_one = [] for player in sorted(all_players): # 提前排序可以保证结果有序,但分开排序更清晰 if lose_count[player] == 0: never_lost.append(player) elif lose_count[player] == 1: lost_one.append(player) # 按题目要求返回两个列表 return [never_lost, lost_one]代码要点:
- 使用
defaultdict(int)简化计数逻辑。 - 遍历时同时维护
all_players集合。 - 最后遍历已排序的
all_players,一次性构建两个结果列表。也可以先构建再分别排序。 - 时间复杂度 O(N log N),主要来自排序,其中 N 为不同玩家的数量。
3. 题目二:求出加密整数的和
题目链接:通常为第二题。题目大意:定义一种加密操作:对于一个整数x,将其每个数字d替换为(d + k) % 10,其中k是一个密钥整数。现在给你一个整数数组nums和一个整数k,要求返回数组中所有元素加密后的和。
3.1 思路分析与关键陷阱
这是一道简单的模拟题,考察数字的逐位处理。
核心思路:
- 定义一个函数
encrypt(x, k),用于计算单个整数x加密后的结果。 - 在函数内部,可以将
x转换为字符串,然后遍历每个字符(数字),将其转换为整数,加上k后对 10 取模,再转换回字符,最后拼接成新的字符串,再转换回整数。 - 或者,通过数学运算逐位取出
x的每一位数字进行处理。 - 遍历
nums数组,对每个元素调用encrypt函数,累加结果。
关键陷阱与易错点:
- 负数处理:题目中
nums[i]和最终结果是否可能为负数?仔细读题,通常输入是非负整数,但也要确认。本题通常规定为非负整数。 - 前导零:加密后数字的高位可能变成 0,例如
x=123, k=7,加密后是890,这没问题。但如果x=100, k=9,加密后是?需要逐位计算:(1+9)%10=0, (0+9)%10=9, (0+9)%10=9,结果是099,转换为整数是99。这里的关键是,加密操作是逐位独立进行的,不存在“前导零被忽略”的问题,因为我们是先得到数字序列再组合成整数。用字符串处理可以自然地保留每一位。 - 大数溢出:Python 整数无溢出问题,但如果是其他语言(如 Java),需要注意累加和可能超出 32 位整数范围,应使用长整型。
k可能很大:(d + k) % 10,k可能远大于 10,直接加k再取模是正确的。
3.2 代码实现与注释
from typing import List class Solution: def sumOfEncryptedInt(self, nums: List[int], k: int) -> int: def encrypt(x: int) -> int: # 将数字转换为字符串以便逐位处理 s = str(x) encrypted_chars = [] for ch in s: # 将字符转换为数字,加密,再转回字符 new_digit = (int(ch) + k) % 10 encrypted_chars.append(str(new_digit)) # 将加密后的字符列表拼接并转换回整数 return int(''.join(encrypted_chars)) total = 0 for num in nums: total += encrypt(num) return total代码要点:
- 内部函数
encrypt封装了加密逻辑,使主逻辑清晰。 - 使用字符串处理可以避免复杂的数学取位操作,代码更易读。
- 时间复杂度 O(N * L),其中 N 是数组长度,L 是数字的平均位数。
4. 题目三:求出所有子序列的能量和
题目链接:通常为第三题,难度提升。题目大意:给定一个整数数组nums和一个整数k。定义数组的能量为:如果数组所有元素的和能被k整除,则能量为sum(nums),否则为 0。现在要求nums的所有子序列的能量之和。由于答案可能很大,需要取模10^9 + 7。
注意:子序列不要求连续,但顺序需要保持原数组中的顺序。空子序列的和为 0,其能量也为 0(因为 0 能被任何 k 整除,但 sum=0,所以能量是 0)。
4.1 思路分析与关键陷阱
这是一道动态规划(DP)结合模运算的题目,是本周赛的核心难点。
核心思路:
- 暴力枚举所有子序列(共
2^n个)不可行,n最大可能为10^5。 - 关键转化:我们并不关心子序列具体是什么,只关心它的和模 k 的余数以及它的和。
- 定义
dp[i][r]表示考虑前i个元素时,能够组成和模 k 余数为 r的所有子序列的原始和的总和(注意,是原始和,不是模后的)。 - 状态转移:对于第
i个元素(0-indexed,值为num):- 不选第
i个元素:dp[i+1][r] += dp[i][r] - 选第
i个元素:新的子序列和 = 旧子序列和 +num。设旧余数为r,新余数new_r = (r + num) % k。那么dp[i+1][new_r] += dp[i][r] + (子序列个数) * num。这里(子序列个数) * num是因为每个包含当前num的子序列,其和都增加了num。
- 不选第
- 为了计算“子序列个数”,我们可以同时维护另一个数组
cnt[i][r],表示考虑前i个元素时,和模 k 余数为 r 的子序列的个数。 - 最终,所有
dp[n][0]的和(即余数为 0 的子序列的原始和总和)就是答案,因为只有这些子序列的能量等于其和。 - 初始状态:
dp[0][0] = 0,cnt[0][0] = 1(空子序列)。
关键陷阱与易错点:
- 模运算:所有加法、乘法操作都需要对
10^9+7取模。 - 状态定义:
dp存储的是“原始和的总和”,而不是“模 k 后的和的总和”。这是因为能量等于原始和,我们需要累加的是原始和。 - 转移方程:选当前元素时,新增的和不仅仅是
dp[i][r],还要加上cnt[i][r] * num。这是本题动态规划的核心难点。 - 空间优化:由于
dp[i+1]只依赖于dp[i],可以使用滚动数组将空间复杂度从 O(n*k) 优化到 O(k)。 - 空子序列:空子序列的和为0,余数也为0,它应该被计入
cnt[0][0]=1,但其能量为0,所以不影响最终结果(因为dp[0][0]=0)。
4.2 代码实现与注释
from typing import List class Solution: def sumOfPowers(self, nums: List[int], k: int) -> int: MOD = 10**9 + 7 n = len(nums) # dp[r]: 当前考虑下,余数为r的所有子序列的原始和的总和 dp = [0] * k # cnt[r]: 当前考虑下,余数为r的子序列的个数 cnt = [0] * k # 初始化:空子序列,和为0,余数为0,个数为1 dp[0] = 0 cnt[0] = 1 for num in nums: # 需要基于上一轮的状态进行转移,所以先复制 new_dp = dp[:] new_cnt = cnt[:] for r in range(k): if cnt[r] == 0: continue # 没有这个余数的子序列,跳过 # 选择当前元素 num new_r = (r + num) % k # 子序列个数增加 cnt[r] new_cnt[new_r] = (new_cnt[new_r] + cnt[r]) % MOD # 原始和总和增加:原来的和(dp[r]) + 每个子序列都加num (cnt[r] * num) new_dp[new_r] = (new_dp[new_r] + dp[r] + cnt[r] * num) % MOD dp, cnt = new_dp, new_cnt # 最终,余数为0的子序列的原始和总和即为答案 return dp[0] % MOD代码要点:
- 使用滚动数组
dp和cnt,空间复杂度 O(k)。 - 内层循环遍历所有可能的余数
r,但通过if cnt[r] == 0进行剪枝。 - 转移时,先更新
new_cnt,再更新new_dp,因为new_dp的计算用到了cnt[r](上一轮的)。 - 所有加法和乘法操作后都立即取模。
- 时间复杂度 O(n * k),在本题约束下(n, k 通常不超过一定范围)可以接受。
5. 题目四:找出有效子序列的最大长度
题目链接:通常为第四题,压轴题。题目大意:给定一个字符串s和一个字符串t。需要从s中找出一个最长的子序列,使得这个子序列中不包含t作为子序列。返回这个最大长度。
注意:子序列定义同上。t作为子序列意味着在s的子序列中,能按顺序找到t的所有字符。
5.1 思路分析与关键陷阱
这是一道字符串子序列匹配的变种题,可以转化为动态规划或贪心结合二分查找(最长递增子序列 LIS 思想)。
核心思路:
- 最暴力的想法是枚举
s的所有子序列,检查是否包含t,复杂度无法接受。 - 逆向思维:我们要求的是不包含
t作为子序列的最长子序列。那么,如果一个子序列包含了t作为子序列,它就不是有效的。 - 如何判断一个子序列是否包含
t?这等价于在子序列中按顺序匹配t的每一个字符。如果匹配完了t的所有字符,就说明包含了。 - 因此,我们可以考虑在
s中尽可能少地匹配t的字符。我们希望找到一个最长的子序列,使得在这个子序列中,无法按顺序匹配出完整的t。 - 这可以转化为:在
s中选一个子序列,使得我们最多只能匹配到t的前m-1个字符(假设t长度为m)。因为一旦匹配到第m个字符,就包含了t。 - 一种高效的方法是预处理
s中每个位置之后,每个字母下一次出现的位置(Next Position Array)。然后进行动态规划。 - 更巧妙的贪心+二分思路:
- 我们维护一个数组
dp,dp[len]表示当匹配了t的前len个字符时,在s的子序列中所需的最短前缀长度(即在s中至少需要多长的前缀才能按顺序找到t的前len个字符作为子序列)。 - 这个
dp数组是单调递增的。 - 遍历
s的每个字符c,我们尝试更新dp。对于t中所有等于c的位置j,如果我们已经匹配了前j-1个字符(即dp[j-1]有定义),那么我们可以用当前的位置i去更新dp[j],使其更小(因为我们在更早的位置就匹配到了前j个字符)。 - 实际上,这就是在维护一个“最小匹配位置”的数组。最终,我们找到最大的
len,使得dp[len]有定义(即len < m),那么这个len就是我们能匹配的t的最大前缀长度。而我们能选的最长子序列长度,就是n(因为我们可以通过跳过一些字符来避免匹配到第m个字符)?不,我们需要更精确的计算。
- 我们维护一个数组
- 更直接的正向DP思路:
- 定义
f[i][j]表示考虑s的前i个字符,当前匹配到t的第j个字符时(即已经匹配了t的前j个字符),所能选出的最长有效子序列长度。 - 状态转移:
- 不选
s[i]:f[i+1][j] = max(f[i+1][j], f[i][j]) - 选
s[i]:- 如果
s[i] == t[j],那么匹配状态可以推进到j+1。但是,如果j+1 == m(即匹配完了t),那么这个选择就是非法的(因为子序列包含了t),所以不能转移。 - 如果
s[i] != t[j],那么匹配状态不变,仍然是j。
- 如果
- 不选
- 答案就是
max(f[n][j]),其中0 <= j < m(即最终没有完全匹配t)。 - 初始状态:
f[0][0] = 0。 - 这个 DP 是 O(n * m) 的,如果
m很大(比如t很长)可能会超时。但本题中t的长度可能有一定限制。
- 定义
关键陷阱与易错点:
- 子序列匹配的定义:必须按顺序匹配,但不要求连续。
- “不包含”的含义:只要子序列中能按顺序提取出
t的所有字符,就算包含。即使子序列更长、中间插入了其他字符,也算包含。 - 空子序列:空子序列是有效的(它不包含任何字符串)。
- 算法选择:需要根据
s和t的长度范围选择合适的方法。如果m较小,O(n*m) 的 DP 可行;如果m较大,可能需要更优的贪心方法。 - 初始化与边界:DP 的初始状态和非法状态处理要小心。
5.2 代码实现与注释(采用 O(n*m) DP 方法,假设 m 不大)
class Solution: def maxSubsequenceLength(self, s: str, t: str) -> int: n, m = len(s), len(t) # f[i][j]: 考虑 s 的前 i 个字符,当前匹配到 t 的第 j 个字符时的最长有效子序列长度 # 初始化为 -inf,表示不可达状态 NEG_INF = -10**9 f = [[NEG_INF] * (m + 1) for _ in range(n + 1)] f[0][0] = 0 # 空串,匹配0个字符,长度为0 for i in range(n): for j in range(m + 1): if f[i][j] == NEG_INF: continue # 1. 不选 s[i] f[i + 1][j] = max(f[i + 1][j], f[i][j]) # 2. 选 s[i] if j < m and s[i] == t[j]: # 可以匹配 t 的第 j 个字符 if j + 1 == m: # 匹配完了 t,非法!不能选这个字符 pass else: f[i + 1][j + 1] = max(f[i + 1][j + 1], f[i][j] + 1) else: # 不匹配,状态 j 不变 f[i + 1][j] = max(f[i + 1][j], f[i][j] + 1) # 答案:最终匹配状态 j 不能等于 m(即不能完全匹配),取最大值 ans = 0 for j in range(m): ans = max(ans, f[n][j]) return ans代码要点:
f[i][j]中j表示已经匹配了t的前j个字符(从0开始计数)。j=m表示完全匹配,是非法状态。- 初始化所有状态为负无穷(
NEG_INF),表示不可达。只有f[0][0]=0是起点。 - 状态转移时,不选字符很简单。选字符时,分为两种情况:当前字符能推进匹配状态(且推进后不能达到
m),或不能推进匹配状态。 - 最终遍历
j=0..m-1,取最大值。 - 时间复杂度 O(nm),空间复杂度 O(nm) 可通过滚动数组优化为 O(m)。
6. 无伤 AK 的实战检查清单
通过以上四道题的详细分析,你可以看到,除了算法本身,对题意的精确理解和细节的严密把控至关重要。下面是我在比赛中会默默过一遍的检查清单,它帮助我减少了大量低级错误:
6.1 读题阶段 (3-5分钟)
- [ ]明确输入输出:数据类型(整数、字符串、数组)、范围(上下界)、是否可能为负、是否可能为空。
- [ ]理解关键术语:确认“子序列”、“子数组”、“能量”、“加密”等操作的精确定义。自己用1-2个小例子验证理解。
- [ ]识别边界条件:数据范围极大/极小时(如 n=0, k=0, 数组为空)应该输出什么?题目是否说明?
- [ ]找出所有约束:将题目中的“必须”、“不能”、“至少”、“至多”等条件用笔标记出来。
6.2 思路设计阶段 (2-3分钟)
- [ ]暴力法是否可行?先想最直接的解法,评估复杂度,这有助于理解问题本质。
- [ ]核心转化:能否将问题转化为已知模型(如DP、贪心、二分、图论)?转化过程是否有信息丢失?
- [ ]状态定义:如果用DP,状态表示什么?转移方程是否覆盖所有情况?初始状态和最终答案如何对应?
- [ ]复杂度估算:在给定数据范围下,你的算法能否在1-2秒内运行?如果接近极限,是否有常数优化空间?
6.3 编码实现阶段 (5-15分钟)
- [ ]变量命名:使用有意义的变量名(如
dp,cnt,lose_count),避免a,b,c。 - [ ]模块化:将复杂逻辑封装成函数(如
encrypt),提高可读性和可调试性。 - [ ]循环边界:
for i in range(n)还是range(1, n+1)?while l <= r还是<?用具体例子验证。 - [ ]初始化:DP数组、累加和、最大值/最小值变量的初始值是否正确?特别是
-inf或0的选择。 - [ ]取模操作:如果需要取模,是否在所有加、乘、减(注意负数)操作后都正确取模?模数
MOD是否正确定义? - [ ]数据类型:在Python中通常没问题,但心里要清楚是否会溢出(在其他语言中尤为重要)。
6.4 测试与提交前 (1-2分钟)
- [ ]自测样例:至少用题目给的样例跑一遍,确保输出完全一致。
- [ ]边缘测试:在脑中或草稿上快速过一遍:空输入、单个元素、最大值、最小值、全部相同元素等特殊情况。
- [ ]代码复审:快速扫一遍代码,重点看:
- 条件判断中的
==是否写成了=? - 循环后是否误用了循环变量?
- 数组索引是否可能越界?
- 返回值类型和格式是否正确(如返回列表的列表)?
- 条件判断中的
- [ ]心态检查:如果这是一道“简单题”,但你的解法看起来很复杂,很可能想复杂了。暂停一下,重新读题。
7. 常见错误类型与排查指南
即使有了检查清单,比赛中仍可能出错。下表总结了周赛中高频的错误类型和第一时间排查方向:
| 错误类型 | 典型表现 | 可能原因 | 排查步骤 |
|---|---|---|---|
| Wrong Answer | 样例通过,提交失败 | 1. 边界条件未处理。 2. 题意理解偏差。 3. 算法逻辑漏洞。 | 1. 构造极小、极大、全零、反序等特殊用例测试。 2. 重新逐句读题,确认关键词。 3. 用纸笔模拟算法流程,寻找反例。 |
| Time Limit Exceeded | 运行超时 | 1. 算法复杂度太高。 2. 存在死循环。 3. 输入读取效率低(Python中少见)。 | 1. 分析代码最内层循环,估算操作次数。 2. 检查 while循环终止条件。3. 考虑是否存在O(n²)嵌套循环,能否优化。 |
| Runtime Error | 运行时崩溃 | 1. 数组/字符串索引越界。 2. 除以零。 3. 递归过深。 4. 空指针/未初始化访问。 | 1. 检查所有索引,特别是i-1,i+1,n-1在边界处的行为。2. 检查作为除数的变量是否可能为0。 3. 将递归改为迭代,或增加递归深度限制(Python可设置)。 |
| Memory Limit Exceeded | 内存超限 | 1. 使用了过大的数据结构(如超大二维数组)。 2. 缓存了不必要的数据。 | 1. 计算所需内存是否远超限制(如n=10^5时开int[n][n])。2. 尝试使用滚动数组、惰性计算、迭代代替递归。 |
当遇到错误时,不要慌张。按照上表定位问题类型,然后结合第6节的检查清单,从最可能的原因开始排查。通常,重新仔细读题和构造极端测试用例能解决大部分 Wrong Answer 问题。
8. 备赛策略与能力提升建议
基于本次“无伤 AK”的经验和当前的周赛趋势,我建议调整备赛策略,在以下方面投入更多精力:
- 强化阅读理解训练:每周至少精做2-3道题干较长的题目(尤其是带场景描述的)。练习时,先不写代码,而是用一句话概括问题,并列出所有输入输出约束和边界条件。
- 建立错误案例本:将每次周赛或练习中因粗心、读错题导致的错误记录下来,并注明错误原因和正确的理解。定期回顾,形成条件反射。
- 专题突破代替盲目刷题:如果某类题(如DP、贪心、字符串)经常出错,进行为期一周的专题训练。重点理解这类问题的通用思考框架,而不是背模板。
- 模拟赛环境练习:定期在固定时间内(如1.5小时)完成一次虚拟竞赛。使用与正式比赛相同的环境(如关闭自动补全),训练时间管理和压力下的编码稳定性。
- 复盘大于刷题:做完一道题,尤其是做错的题,花比解题更多的时间去复盘。思考:最优解是什么?我为什么没想到?有哪些陷阱?下次如何避免?
LeetCode 周赛不仅是算法能力的比拼,更是工程素养(细心、严谨、稳健)的试金石。随着题目设计越来越注重综合能力,“无伤”完成比赛的价值甚至可能超过解出难题。希望这篇结合具体赛题和实战心得的文章,能帮助你构建起更强大的竞赛防御体系。下次周赛,期待你也能稳定发挥,拿下属于自己的“无伤 AK”。