做算法题最怕什么?最怕刷一道忘一道,今天看懂了题解,过三天看到原题又一脸懵。LeetCode上双指针的题没有两百也有一百,但如果你只把它们当零散的题目挨个刷,那确实容易越刷越乱。反过来,如果你能看穿这一批题目背后的公共骨架,双指针其实是最好拿分的一类题。
代码随想录Day03选的这四道题——283.移动零、844.比较含退格的字符串、977.有序数组的平方、209.长度最小的子数组,我愿称之为双指针技术最好的四节入门课。它们不是随便凑在一起的四道题,而是把双指针从最基础的形态到进阶的滑动窗口完整串了一遍。很多人一天刷完这四道觉得只是做了四道题,但实际上你已经把双指针的三大范式全覆盖了:快慢指针、相向双指针、滑动窗口。
这篇我就把这四道题放在一起复盘,不只讲每道题的解法,更想拆清楚它们之间的递进关系,以及每道题背后那个让你“知其然也知其所以然”的关键点。这四道题适合所有在按代码随想录刷题路线走的人,也适合刷了一段时间双指针但总觉得差点体系感的同学。看完这篇,你至少能建立起一个属于自己的双指针分析框架。
1. 283.移动零:快慢指针是双指针的地基
1.1 题目到底在考察什么
移动零的原题描述很简单:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
这个题的第一直觉是什么?很多人会想,我碰到0就往后交换呗,像冒泡一样把0一点一点挪到末尾。这个思路没错,但代价是O(n²)的时间复杂度,而且操作非常繁琐。还有人是新建一个数组,把非零元素按顺序放进去,后面补0,再复制回来,这样空间复杂度又成了O(n)。
这两类解法本质上都没抓住题目的隐藏要求。题目强调了“原地操作”,并且要求保持“非零元素的相对顺序”,其实就是在引导你:能不能通过一次遍历,把非零元素尽量往前放,0自然就落在后面了。
这个“把一类元素往前放”的动作,正是快慢指针的经典使用场景。慢指针指向下一个非零元素该放的位置,快指针负责往前扫描找非零元素。
1.2 快慢指针的正确打开方式
先看一次遍历的写法:
cpp复制void moveZeroes(vector<int>& nums) {
int slow = 0;
for (int fast = 0; fast < nums.size(); fast++) {
if (nums[fast] != 0) {
swap(nums[slow], nums[fast]);
slow++;
}
}
}
核心就是 swap(nums[slow], nums[fast]) 这一行。fast 每找到一个非零元素,就把它和 slow 位置的元素交换,然后 slow 后移一位。交换之后,slow 之前的位置就全部是非零元素了,而 slow 到 fast 之间,如果 fast 扫描过程中路过0,那么这些0就会因为交换被“挤”到后面去。
举个例子,nums = [0, 1, 0, 3, 12]:
- fast=0时,nums[0]=0,不交换,slow仍然指向0。
- fast=1时,nums[1]=1,交换nums[0]和nums[1],数组变成[1, 0, 0, 3, 12],slow变成1。
- fast=2时,nums[2]=0,不交换。
- fast=3时,nums[3]=3,交换nums[1]和nums[3],数组变成[1, 3, 0, 0, 12],slow变成2。
- fast=4时,nums[4]=12,交换nums[2]和nums[4],数组变成[1, 3, 12, 0, 0]。
看到没,整个过程确实只遍历了一遍,而且不需要额外的数组。为什么交换而不是直接赋值?因为交换天然就把0送到了后面,不需要你最后再统一补0;如果用赋值就得像前面提到的两遍扫描版本,第一遍把非零搬前面,第二遍把剩下的位置全部填0。
1.3 两遍扫描和一遍交换,到底该写哪个
我在刷题群里见过关于这道题“最优写法”的争论。有人坚持两遍扫描(第一遍把非零元素搬到前面,第二遍从slow开始把后面全部补0)更好理解,有人觉得交换写法更优雅。
我的建议是:两种都要会写,但是面试中优先用交换版本。原因有两个:
第一,交换版本更体现“双指针”的精髓,面试官看到你写两遍扫描的时候,很容易判断你只是背过这道题,而不是真正理解了快慢指针;而交换版本里 slow 和 fast 的关系能证明你有建模能力。
第二,两道扫描虽然好理解,但有个隐蔽的问题:如果你在处理一个全是非零元素的数组,比如[1, 2, 3],两遍扫描第二遍会无意义地执行 nums[1]=0、nums[2]=0,把已经是0的位置再赋一遍0,这虽然没有逻辑错误,但属于多余操作。交换版本天然规避了这个问题——每次交换都是有效的。
当然,这不意味着交换版本没有坑。有一个细节很多人会忽略:slow 和 fast 可能指向同一个位置,比如 nums = [1, 2, 3],fast=0时交换的是nums[0]和nums[0],相当于自己和自己交换,没问题,但如果你用的是“先赋值再清零”的写法,这种自己和自己交换的情况就会出错。所以如果要写优化版,建议老老实实用 swap 函数,或者加一个 if (slow != fast) 的判断。
1.4 这一题做完,你应该带走什么
移动零本质上解决的是“把符合某种条件的元素集中到数组一侧”的问题。这个思路可以扩展到很多题目,比如:
- 把数组中的某个指定元素全部移到末尾;
- 把奇数和偶数分开,奇数在前偶数在后;
- 把负数移到前面正数移到后面,或者反过来。
这些统统是快慢指针的应用场景。你不需要记住每一道题的代码,只要记住一个模板:慢指针维护区间边界,快指针负责探测,碰到该保留的元素就往慢指针位置放,后面遇到任何“分组”类的问题都能往这个模型上套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 844.比较含退格的字符串:反向双指针如何省下O(n)空间
2.1 常规解法,以及它的空间瓶颈
原题大意:给定 s 和 t 两个字符串,# 代表退格键,要求判断两个字符串经过退格处理后是否相等。
这道题第一次见,九成人都会想到用栈模拟。遇到普通字符就入栈,遇到 # 就弹出栈顶(如果栈不为空的话),最后把两个字符串分别处理完的结果比较一下。
cpp复制string process(string s) {
string res;
for (char c : s) {
if (c != '#') {
res.push_back(c);
} else if (!res.empty()) {
res.pop_back();
}
}
return res;
}
bool backspaceCompare(string s, string t) {
return process(s) == process(t);
}
这个解法正确性一目了然,时间复杂度O(n+m),空间复杂度也是O(n+m)。如果题目不限制空间,这么写完全没问题,代码也短。
但是LeetCode这道题的Follow Up问得很刁:你能否用O(1)空间解决? 也就是说,不借助栈,不借助额外的字符串,只靠有限几个变量在原字符串上做判断。
2.2 为什么从前往后走不通,从后往前却柳暗花明
先想想为什么栈方案要从前往后处理。从前往后扫描时,一个字符最终会不会被保留,取决于它后面有多少个 #,而这些 # 在扫描到该字符时还没出现。所以从前往后你必须真实地“记录”已经处理过的内容,栈就是这个记录工具。
那如果从后往前扫呢?这时候你看到的 # 是在你当前位置后面的,也就是说你心里清楚当前字符会被多少个退格删除,计数就行,不需要真正维护一个栈。
这就是反向遍历的巧妙之处:
- 从后往前遍历,遇到
#就把skip计数加1; - 遇到普通字符时,如果
skip > 0,说明这个字符会被后面(或者说前方)的退格删除,直接跳过,同时skip减1; - 遇到普通字符且
skip == 0,说明这个字符是最终保留下来的有效字符,拿出来比较。
用两个指针 i 和 j 分别从 s 和 t 的末尾移动,各自跳过被删除的字符,找到各自第一个有效字符后进行比较;如果相同就继续移动找下一个;如果不同就返回false;如果其中一个字符串已经遍历完了,另一个还有有效字符,也返回false。
2.3 代码实现与边界梳理
cpp复制bool backspaceCompare(string s, string t) {
int i = s.size() - 1, j = t.size() - 1;
int skipS = 0, skipT = 0;
while (i >= 0 || j >= 0) {
// 分别找到 s 中下一个有效字符
while (i >= 0) {
if (s[i] == '#') {
skipS++;
i--;
} else if (skipS > 0) {
skipS--;
i--;
} else {
break; // 找到有效字符
}
}
// 分别找到 t 中下一个有效字符
while (j >= 0) {
if (t[j] == '#') {
skipT++;
j--;
} else if (skipT > 0) {
skipT--;
j--;
} else {
break;
}
}
if (i >= 0 && j >= 0) {
if (s[i] != t[j]) return false;
} else if (i >= 0 || j >= 0) {
return false; // 一个字符串有有效字符,另一个已经没有了
}
i--;
j--;
}
return true;
}
这里有几个边界情况容易写错:
第一个边界:字符串末尾连着多个 #。 比如 s = "ab##c",从后往前先遇到两个 #,skipS变成2,然后遇到 b,skipS>0,跳过;遇到 a,skipS>0,跳过;最后遇到 c 才停下来。这个逻辑在代码里就是那个 else if (skipS > 0) 分支,很容易漏掉,一旦漏了,a 和 b 就会被误判为有效字符。
第二个边界:# 出现在最前面。 比如 s = "#a",从后往前先遇到 a,skipS=0,a 有效;继续往前遇到 #,skipS变成1,再往前没有字符了,循环结束。最前面 # 因为前面没有字符可以删除,就等于什么都没发生。代码里把它归入跳过逻辑没有问题,# 本身绝对不会作为有效字符输出。
第三个边界:两个字符串长度不同。 比如 s = "a",t = "a#",处理完退格后两个都是空字符串。如果 i 和 j 里面有一个已经小于0,另一个还在扫描有效字符,就说明两个字符串不可能相等。代码最后的 else if 就是干这个的。
2.4 空间复杂度O(1)的价值在哪
有人可能会觉得,LeetCode又不是面试环境,空间O(n)和O(1)有什么关系?这么想就亏了。这种从“栈解法”到“反向双指针O(1)解法”的演进,在真实面试里非常常见:面试官先让你做出来,然后问你“能不能优化空间”,然后看你能否理解为什么反向扫描能省掉栈。
其实反向遍历的核心洞察可以提炼成一个通用原则:如果一个元素的最终状态依赖于它与“操作符”之间的相对位置,而操作符的作用方向是单向的,那从操作符出现的位置反向往回处理,往往能省去状态记录的开销。 这个原则在很多地方都成立,比如逆波兰表达式求值、编辑距离的滚动数组优化、甚至一些括号匹配问题。
3. 977.有序数组的平方:相向双指针的数学根因
3.1 暴力解为什么会被嫌弃
题目描述:给你一个按非递减顺序排序的整数数组 nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。
最直白的解法是先平方再排序:
cpp复制vector<int> sortedSquares(vector<int>& nums) {
for (int& x : nums) {
x = x * x;
}
sort(nums.begin(), nums.end());
return nums;
}
一行平方,一行排序,代码简洁,结果正确,复杂度O(n log n)。但问题是:题目给出的“非递减排序”这个条件你就完全没用上。
你仔细想想,原始数组是非递减的,但是这个数组里可能包含负数,负数平方之后大小关系就跟原来的排序关系不一致了。比如 [-4, -1, 0, 3, 10],平方后是 [16, 1, 0, 9, 100],原来的顺序已经被打乱,所以需要重新排序。
但这里有一个非常关键的性质:平方后最大的数一定在数组的两端,而不在中间。 因为数组本身是有序的,左端是“绝对值最大的负数”,右端是“值最大的正数”,两者平方后一定占据最大值的候选位置。越往中间走,绝对值越小,平方值也越小。
这就是典型的“两端最大、中间最小”结构,直接用相向双指针处理。
3.2 相向双指针:从两端往中间收敛
算法思路:
- 准备
left = 0、right = nums.size() - 1; - 准备一个和原数组等长的
result数组; - 因为每次我们选出的是“当前剩余部分的最大平方值”,所以
result要从后往前填,保证最终结果是升序; - 比较
nums[left] * nums[left]和nums[right] * nums[right],哪个大就填入result[pos],pos从n-1递减,然后移动对应的指针; - 直到
left > right循环结束。
cpp复制vector<int> sortedSquares(vector<int>& nums) {
int n = nums.size();
vector<int> result(n);
int left = 0, right = n - 1;
int pos = n - 1;
while (left <= right) {
int leftSquare = nums[left] * nums[left];
int rightSquare = nums[right] * nums[right];
if (leftSquare > rightSquare) {
result[pos] = leftSquare;
left++;
} else {
result[pos] = rightSquare;
right--;
}
pos--;
}
return result;
}
这里有一个很多人第一次写会踩的坑:while循环里写的是 left <= right 而不是 <。 如果写 <,最后 left == right 时那个元素就漏掉了。在我看过的一堆题解里,这个 <= 经常被一笔带过,但恰恰是边界条件里的决定性问题。建议每次写双指针循环前,先在草稿纸上划几笔,看看最后指针相遇时那个元素是否已经被处理。
实际上,这道题还有一个等价写法:每次取两端较小的平方值,从 result 的头部开始填。两种写法都能得到正确答案,本质区别只是“每次取最小值放前面”还是“每次取最大值放后面”。我个人的习惯是取最大值放后面,因为思维上更贴近“数组两端是最大候选”这个直觉,不容易乱。
3.3 从后往前填充的通用价值
如果你之前刷过88题“合并两个有序数组”,你可能会觉得977和它神似。没错,这两道题都涉及“从后往前填充”这个技巧。
88题要求把两个有序数组合并到第一个数组中,并且第一个数组末尾预留了足够的空间。如果你从前往后合并,就会面临一个问题:把 nums2 的元素插入到 nums1 前面时,会把 nums1 里还没处理的元素覆盖掉。这时候你就需要额外的空间来暂存,或者疯狂地移动元素。但如果从后往前合并,每次取两个数组当前末尾的最大值,放在 nums1 尾部预留位置,整个过程不需要额外空间,也不会覆盖任何还没处理的元素。
977题也是一样的逻辑:result 数组从后往前填,天然规避了“插到前面导致后面元素移位”的问题。从后往前填充的本质,是给数组尾部“留白”,让新元素永远写到已经处理完的区域的后面。 这个技巧你掌握之后,遇到“合并两个数组”“前半段后半段调整”这类问题,会多一个非常好用的思路。
3.4 这题最常见的错误写法
我观察到很多人写这道题时容易犯一个错:先遍历原数组求平方,再把平方后的数组排序。上面说过了,这是O(n log n),大错谈不上,但完全没有利用题目的有序条件。
再有一个容易出错的点是:比较平方值的时候,有人会用 abs 比较绝对值,然后仍然算平方填入。这样写也没错,但要注意 abs 对 INT_MIN 这种极值可能导致溢出,虽然LeetCode本题用例一般不会覆盖,但面试中讨论边界情况时这是个不错的加分细节。
4. 209.长度最小的子数组:从双指针到滑动窗口的跃迁
4.1 为什么暴力解会超时
题目描述:给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和 ≥ target 的长度最小的连续子数组,并返回其长度。如果不存在符合条件的子数组,返回0。
暴力解法就是枚举所有子数组,两个for循环,一个枚举起点,一个枚举终点,求和之后和target比较。时间复杂度O(n²),在n到10万级别的时候直接超时。
但暴力解法的超时原因值得深挖:内层循环里,每次都要重新计算从i到j的子数组和,这里有大量重复计算。比如你已经算出了 [i, j] 的和,紧接着要去求 [i, j+1] 的和,其实只需要在 [i, j] 的基础上加一个 nums[j+1] 就行,不用从 nums[i] 开始重新加一遍。
这就是前缀和或者滑动窗口的第一层动机:利用相邻子数组的递推关系,把重复计算省掉。
4.2 正整数条件:滑动窗口成立的生命线
比“省重复计算”更关键的是,这道题有一个隐藏的强约束:数组元素全是正整数。 这意味着子数组的和随着子数组的扩张是单调递增的,窗口的右边界向右移动时,窗口内元素和一定增大;窗口的左边界向右收缩时,窗口内元素和一定减小。
这个单调性有多重要?它直接保证了:一旦我们找到一个窗口 [left, right] 使得和 ≥ target,那么这个窗口的右边界 right 如果继续右移,窗口和只会更大;但我们想找的是最短长度,所以此时应该尝试收缩左边界 left,看能不能在维持和 ≥ target 的前提下缩短窗口。
反过来,如果数组里有负数甚至有0,窗口和就不再是单调的了——你继续扩展右边界,窗口和不一定变大;你收缩左边界,窗口和也不一定变小。滑动窗口的整个逻辑就崩了,你不敢放心地收缩左边界,因为收缩之后和可能反而变大,你可能会错过更短的窗口。
这道题的代码模板是滑动窗口的“求最小窗口”范式:
cpp复制int minSubArrayLen(int target, vector<int>& nums) {
int n = nums.size();
int left = 0;
int sum = 0;
int result = INT_MAX;
for (int right = 0; right < n; right++) {
sum += nums[right];
while (sum >= target) {
result = min(result, right - left + 1);
sum -= nums[left];
left++;
}
}
return result == INT_MAX ? 0 : result;
}
外层 for 循环移动右边界 right,负责“扩大窗口”;内层 while 循环在窗口满足条件时尽量收缩左边界 left,并实时更新最小长度。每个元素最多被 left 和 right 各访问一次,所以时间复杂度是O(n),空间复杂度O(1)。
4.3 为什么内层用 while 而不是 if
这是滑动窗口最核心的一个细节。当窗口和 ≥ target 时,我们要“尽可能”地收缩左边界,因为收缩一次左边界,窗口长度就少1,可能得到一个更短的满足条件的窗口。
用 if 的话,每次窗口满足条件时只收缩一格,窗口可能仍然满足 sum ≥ target,但你不再继续收缩,就会漏掉更短的答案。所以必须用 while。
举一个例子,target = 7,nums = [2, 3, 1, 2, 4, 3]。当 right 走到下标3时,窗口是 [2, 3, 1, 2],和是8,≥ 7,此时用一个 if 收缩一格变成 [3, 1, 2],和是6,已经不满足条件了。看上去用 if 就够了。但继续跑,当 right = 4 时,窗口 [3, 1, 2, 4] 和是10,如果只用 if 收缩一格变成 [1, 2, 4],和是7,仍然满足条件,但你没有继续收缩,错失了 [2, 4] 这个长度为2的更优答案。用 while 就能一路收缩到 [2, 4],此时和6 < 7,停止收缩。
所以写滑动窗口题,先想清楚这个窗口满足条件之后,你是要“尽可能收缩”还是“只动一下”,前者用 while,后者可能用 if。大部分求最短窗口的问题都是 while。
4.4 滑动窗口和快慢指针的本质关系
209这道题里的 left 和 right 也是同向移动的,看起来跟283的快慢指针很像。但它们的区别在哪?283的快慢指针,slow 和 fast 的移动节奏是确定的——fast 每次扫描一个元素,slow 只有遇到有效元素才移动。而滑动窗口的 right 每次固定右移一格,但 left 的移动完全由窗口内部的“状态”(这里是和)决定,左指针可能追着右指针连续移动多次,也可能一个窗口周期内完全不动。
所以,滑动窗口本质上是“带条件的快慢指针”,慢指针的移动不再是简单地“遇到目标才动”,而是“每当窗口满足某种性质就尝试收缩”。这个视角统一了很多看起来不相关的题。
代码随想录里的思路也点名了这一点,把滑动窗口归到双指针技巧的范畴,只是它是一种更灵活、更“贪心”的双指针。你可以这样理解:双指针是控制两个位置,滑动窗口是控制一段区间;双指针解决的是“两个点”的问题,滑动窗口解决的是“一段连续区间”的问题。
4.5 变式思考:如果数组不是正整数怎么办
这个follow-up是面试官非常喜欢追问的。如果数组允许出现负数,滑动窗口模板还能用吗?
答案是不能直接用。原因就是4.2里说的单调性被破坏。那针对带负数的数组有什么办法?常见的替代方案是前缀和 + 二分查找(前缀和不满足单调性时通常配合有序数据结构),或者用前缀和 + 哈希表记录最早出现位置。这是一个更难的题目,但如果你在面试中能主动指出“滑动窗口成立依赖正整数条件”,并且能说出如果出现负数对应的替代思路,这已经足以展示你的算法功底了。
5. 四道题横向复盘:双指针的三种范式与刷题心法
5.1 一张表看穿四道题的“题眼”
我刷完这四道题之后,做了一张对比表,每次回顾都觉得很清晰。贴出来供你参考:
| 题目 | 指针形态 | 核心操作 | 时间复杂度 | 空间复杂度 | 题眼 |
|---|---|---|---|---|---|
| 283.移动零 | 快慢指针(同向) | fast扫描,slow承接非零元素 | O(n) | O(1) | 保持相对顺序,所以非零元素必须按序填入前缀 |
| 844.比较含退格的字符串 | 反向双指针 | 从后往前跳过被退格删除的字符 | O(n+m) | O(1) | 退格只影响前面的字符,反向扫描可确定有效字符 |
| 977.有序数组的平方 | 相向双指针(左右对撞) | 两端平方值比较,从后往前填充结果 | O(n) | O(1) | 有序数组平方后最大值在两端,越往中间越小 |
| 209.长度最小的子数组 | 滑动窗口(同向伸缩) | 右边界扩窗口,左边界收缩找最短 | O(n) | O(1) | 正整数保证窗口和单调,可以放心收缩左边界 |
从这张表能看到一个有趣的结构:283和209是“同向双指针”,但一个是快慢节奏固定,一个是窗口伸缩可变;844是反向双指针,977是相向双指针。这四道恰好覆盖了双指针的三种形态:同向快慢、相向对撞、反向扫描。而209则是同向快慢指针的升级版——滑动窗口。
所以代码随想录把这一天设置为“双指针与滑动窗口”的入门日,是很讲究的:先在283里建立同向快慢指针的基本功,再用844告诉你双指针可以反向用,然后用977让你理解相向双指针的价值,最后用209把双指针推向滑动窗口,完成从“两个点”到“一段区间”的思维跃迁。你如果按这个顺序刷,每一道题都会为下一道做铺垫。
5.2 从“背题解”到“建模”:双指针题的分析路径
很多人刷题卡在“看题解秒懂,合上题解不会写”。原因就是没有形成一套自己的分析路径。以双指针为例,我建议你拿到题目后按这个顺序问自己:
第一,这个数组是不是有序的? 如果有序,直接考虑相向双指针是不是更合适;如果数组有序但包含负数,平方之后“最大在两端”这个性质很可能是解题钥匙。
第二,题目要求的结果和数组的相对顺序有没有关系? 如果要求保持原有顺序(比如移动零),快慢指针优先;如果只要求最终结果,不关心原始位置,那相向双指针或排序可能更直接。
第三,是否存在某种单调性? 比如数组全为正整数时窗口和单调递增,这个单调性一旦成立,滑动窗口就可以登场。
第四,操作符有没有方向性? 比如退格键只影响它前面的字符,那么从后往前遍历常常能避免额外的数据结构。
这四个问题覆盖了双指针题的绝大多数情况。它们的作用是帮你把一道新题映射到你已经掌握的范式中。代码随想录的题单本来就是按这种知识结构编排的,如果你能自己在每道题上完成“为什么可以用双指针”“用什么形态的双指针”这两个自问,做题效果会远好于直接抄代码。
5.3 做题中那些被忽略的边界条件,我踩过的坑都在这
写算法题,最怕的不是思路不清晰,而是边界条件没处理好,样例全过,提交就红。这四道题里我踩过的坑,列出来给大家提个醒。
283移动零:
- 数组为空或只有一个元素时,循环应该什么都不做,需要确保代码在这种极端输入下不会越界。
swap前不判断slow != fast也不会出错,但会多做无用交换,个人不推荐为此加判断,因为会破坏代码的简洁性。
844比较含退格的字符串:
skip计数在连续遇到多个#时要累加,否则遇到普通字符时会错误地保留它。- 最后两个字符串比较完,跳出外层
while的条件是i < 0 && j < 0,如果只剩一方有字符,必须在循环里处理掉。我见过有人把这个判断漏掉,结果遇到s="a"t=""这种用例直接错误。 - 字符串为空时
s.size() - 1会得到size_t巨大值,所以需要先判断i >= 0再接s[i],写代码时务必把i >= 0放在条件判断的最前面,避免短路顺序错误。
977有序数组的平方:
left <= right的等号不能丢,否则中间元素会被漏掉。当数组长度为奇数时,最后left和right会重合,此时这个元素必须处理。- 平方计算可能溢出,LeetCode一般不会卡
INT_MIN这种极端值,但面试时提及这个风险会显得很专业。
209长度最小的子数组:
result初始化为INT_MAX,最后判断有没有更新过,没更新过就返回0。用INT_MAX而不是随便一个大数字,是为了避免“刚好有子数组长度超过你的初始值”这种意外。- 内层
while里要先记录长度、更新result,再把nums[left]从sum中减掉,顺序反了会导致计算出的窗口长度少1。 - 如果数组里单个元素就超过
target,比如target=7, nums=[9],窗口长度应为1,你需要确保right - left + 1在left还没来得及移动时已经计算出正确的长度。
这些坑说大不大,但很影响刷题体验。我自己的习惯是:每道题写完代码之后,跑一遍题目给的样例,再手动构造两三个极端样例(空数组、单元素、全相同元素、指针相遇等),基本能覆盖掉大部分边界问题。
5.4 如何把这一天真正消化掉
最后聊一点刷题习惯层面的事。我在之前的文章里也提过,刷算法题最怕浮于表面,一天猛刷十道,结果一道也没真正吸收。代码随想录这套刷题路线的价值,就在于它把题目按知识点编排,每道题之间都有逻辑关联。所以跟着刷的时候,题与题之间一定要主动做横向对比。
针对Day03这四道题,我强烈建议你完成三个动作:
第一,不借助题解,把四道题重新写一遍。 写完之后对照自己的代码,确认每一行都能说清楚为什么存在。说不清楚的地方,就是你没掌握的地方。
第二,把四道题按“指针形态”分类整理到笔记里。 做成像5.1那样的表格,以后再遇到新的双指针题,先想想它属于哪一类,再想想这一类题的通用模板是什么。模板可以帮助你快速进入状态,但不要死记,要理解模板背后的“为什么”。
第三,隔一周再回头写一次,尤其重写209。 滑动窗口是人类思维不太自然的一种算法,因为人脑不习惯“一边扩一边缩”的交替过程。隔几天再写,能帮你检验自己是真的理解了还是背下来了。同理,844的反向双指针如果过一周还能独立写出来,说明你对“方向性”这个概念的把握就到位了。
6. 一点实战心得:为什么我把这四道题看成一套组合拳
最后说点我刷完这一天的真实感受,也算是一点个人经验。
很多人把刷题等同于“看题解 + 默写代码”,这是我特别想劝退的一个路径。就拿这四道题来说,如果你只是背下每个题的解法,你确实能在这四道题上拿分,但换一道变式题,比如把283的0换成指定数字,把209的正整数条件拿掉,把977的有序条件换成部分有序,你可能又会懵。
但如果你愿意花时间理解每一道题“为什么双指针能行”,情况就完全不同了。283和977看似都是数组题,但一个是“同向快慢”一个是“相向对撞”;844看起来是字符串题,却用反向双指针把空间复杂度降到O(1);209虽然一开始会让人本能地想到前缀和,但由于正整数带来的单调性,滑动窗口才是最优解。你会发现这些解法不是凭空蹦出来的,而是由题目条件一步步推导出来的必然结论。
我个人在刷题时非常信奉一个原则:一道题如果只是“会写”,那是及格;如果能讲清楚“为什么这么写”,那才是掌握。 这个原则在面试中尤其适用。面试官让你做一道题,做完之后几乎必然会追问“这个解法的时间复杂度为什么是O(n)”“你为什么觉得这里用双指针而不是用其他方法”“如果数据变了你的思路还成立吗”。这些追问考察的正是你对“为什么”的理解深度。代码随想录这套题单按知识点编排,其实就是逼着你养成“看到题先想它属于哪个知识框架”的习惯,而不是拿到题就无脑模拟。
这篇复盘写到最后,我其实不太想给出什么标准答案式的总结。因为刷题这件事,答案在纸上,知识在脑子里。你如果把这四道题真正吃透了,后面再遇到 LeetCode 76、209、15、18、27、26 这些五花八门的变式,你会发现自己看题的第一反应已经变了,不再是“这题我见过没有”,而是“这题的条件告诉我该往哪个双指针范式上靠”。
那种感觉,才是刷题最上头的时刻。
