C++刷 LeetCode Hot100 笔记(五):数组题五连,从暴力解到双指针的思维跃迁
Hot100 里数组题的分量,不用我多说。两数之和、移动零、盛最多水的容器、三数之和、无重复字符的最长子串,这五道题放在一起刷特别有意思——它们看起来各自独立,实际上是一套递进的数组处理思维链:先学会用哈希表换时间,再理解双指针的碰撞与快慢,然后掌握滑动窗口的边界维护。这五道题我第一轮刷完的最大感受是:看题解十分钟,自己写一晚上。几乎所有坑都藏在边界条件和去重逻辑里,不亲手跑一遍根本体会不到。这篇笔记就把我每道题的思考路径、踩坑记录、以及后来理顺的思路完整写出来,不重复官方题解,只讲一个普通人在第一轮实战中最真实的反应。
1. 两数之和:把查找从 O(n) 变成 O(1),哈希表是唯一正解
1.1 为什么暴力双重循环走到头了
题目很简单:给定一个整数数组 nums 和一个整数目标值 target,找出数组中和为目标值的两个整数,返回它们的下标。很多人第一反应就是两层循环:
cpp复制for (int i = 0; i < nums.size(); ++i) {
for (int j = i + 1; j < nums.size(); ++j) {
if (nums[i] + nums[j] == target) {
return {i, j};
}
}
}
这个写法能过样例,但放到 LeetCode 的评测环境里,当数组长度到 10^4 量级时,O(n^2) 的时间复杂度是致命的。我见过不少初学者在这里纠结"反正数组也不大",但刷题的目的从来不是让这道题 AC,而是建立一种对数据规模的敏感度。一看到 10^5 这个量级,就应该立刻意识到:任何 O(n^2) 的算法都悬,更别说 O(n^3)。
暴力的本质是"在剩余元素里查找差值"。查找这个动作,朴素做法是线性扫描,那浪费就浪费在每次都得从头遍历。哈希表的意义就是把这步查找从 O(n) 降到 O(1) 摊还,整体复杂度从 O(n^2) 变成 O(n)。这不是优化,是换了一种数据结构来思考问题。
1.2 一次遍历边查边登记,为什么不会把自己算进去
正确的哈希解法不需要先建表再查询,而是一次遍历中边查边存:
cpp复制class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> hash;
for (int i = 0; i < nums.size(); ++i) {
int complement = target - nums[i];
if (hash.find(complement) != hash.end()) {
return {hash[complement], i};
}
hash[nums[i]] = i;
}
return {};
}
};
这个写法有一个很关键的顺序:先查表,再把当前元素插入哈希表。为什么顺序不能反?因为题目要求同一个元素不能使用两次。如果先把 nums[i] 存进去再查,当 target = 2 * nums[i] 时,complement 恰好等于 nums[i],你会查到刚插入的自己,返回两个相同的下标,直接报错。
这里还有一个 C++ 特有的细节:hash.find(complement) != hash.end() 和 hash.count(complement) 都能做存在性判断,但面试里我建议用 find,因为找到之后还需要取出对应的下标值;用 count 只能得到真伪,还得再查一次 hash[complement],多一次哈希运算。虽然差别微乎其微,但代码风格上,find 一次到位更干净。
1.3 用 unordered_map 时容易忽略的几个小坑
第一,unordered_map 的底层是哈希表,操作是摊还 O(1),但遇到坏散列或负载因子过高时会有 rehash 开销。如果数组特别大,可以提前 hash.reserve(nums.size() * 2) 减少扩容,性能会好看一些。第二,operator[] 与 find 的行为不同:operator[] 在 key 不存在时会插入一个默认值,如果你想查询一个可能不存在的 key,直接用 [] 会把数据污染掉。初学的时候我在一个循环里不小心用 hash[complement] 判断存在性,结果把一堆不存在的键插进去了,内存和耗时都飙升。
第三,注意返回下标的顺序。虽然题目对两个下标的顺序一般不做要求,但习惯上返回 {hash[complement], i},即先返回先出现的、后返回当前的,这样和暴力解的下标顺序一致,调试时更直观。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 移动零:快慢指针交换法里的顺序稳定性,一招吃透
2.1 题目本身不难,难的是"保持非零元素相对顺序"
移动零的题目要求是:将所有 0 移动到数组末尾,同时保持非零元素的相对顺序,并且必须在原数组上操作,不能拷贝额外数组。这道题的难度标的是 Easy,但它在面试中出现频率极高,因为它的约束条件刚好击中了数组题最核心的两个命题:原地操作 和 稳定性。
如果允许复制到一个新数组,问题就非常直白:遍历一遍把非零元素收集起来,再补零。但一旦要求原地,思路就得切换成"用双指针维护一个分区"。
2.2 快慢指针的两种写法,我推荐交换法
先说覆盖法,这是新手最容易想到的:用一个 slow 指针记录下一个非零元素应该放置的位置,第一遍遍历把非零元素前移,第二遍把数组尾部补零。
cpp复制class Solution {
public:
void moveZeroes(vector<int>& nums) {
int slow = 0;
for (int fast = 0; fast < nums.size(); ++fast) {
if (nums[fast] != 0) {
nums[slow++] = nums[fast];
}
}
for (int i = slow; i < nums.size(); ++i) {
nums[i] = 0;
}
}
};
覆盖法的优点是逻辑清晰,缺点是"覆盖"会破坏原值,所以必须用一个额外的 pass 来补零。更优雅的写法是交换法,在一次遍历中同时完成移动和置零:
cpp复制class Solution {
public:
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;
}
}
}
};
我第一轮刷的时候用的是覆盖法,后来跟朋友讨论才发现交换法更妙。交换法的核心逻辑是:slow 始终指向"当前已经整理好的非零区域"的下一个位置,fast 扫过去遇到非零就往前交换。这样每完成一次交换,[0, slow) 区间内都是有序的非零元素,[slow, fast] 区间内是已经被换过去的零。因为零本身相互之间没有区别,交换不会破坏任何顺序,所以非零元素的相对位置天然被保住了。
2.3 一个小细节:slow == fast 时的自交换值得处理吗
交换法在 slow == fast 时其实是在跟自己交换,swap(nums[slow], nums[fast]) 完全无害但也没有意义。很多优化派的代码会加一个 if (slow != fast) 的判断,来省掉无意义的交换。我实际测下来,在刷题层面这点性能差异完全可以忽略,但如果在面嵌入式或性能敏感的场景,加上这个判断是个不错的亮点。
另外有个测试用例很容易踩坑:nums = [0] 或 nums = [0, 0, 1]。前者 slow 全程不移动,循环结束后数组还是 [0],没有越界,这个要心里有数。后者 fast 扫到索引 2 时,slow 还是 0,交换后得到 [1, 0, 0],正确。
这道题让我真正理解的模式是:双指针不一定非得是一个从头、一个从尾,也可以是一个慢、一个快。慢指针维护"已完成区域"的边界,快指针负责探索新区域——这个模式在以后很多题里都会反复出现。
3. 盛最多水的容器:贪心夹逼的正确性不是背结论,是推出来的
3.1 暴力枚举双线组合为什么不可行
题目给出一个长度为 n 的非负整数数组 height,每个元素代表坐标 (i, height[i]) 处的一条垂线,需要找出两条线使得它们与 x 轴构成的容器能容纳最多的水。容器水量 = min(height[left], height[right]) * (right - left)。
最直观的思路是枚举所有 left 和 right 组合:
cpp复制int ans = 0;
for (int i = 0; i < height.size(); ++i) {
for (int j = i + 1; j < height.size(); ++j) {
ans = max(ans, min(height[i], height[j]) * (j - i));
}
}
组合数有 C(n, 2) 个,量级是 O(n^2),直接超时。但我想强调的是:这道题的难点根本不在于从暴力到双指针的跳跃,而在于如何确信双指针移动规则是对的。
3.2 为什么移动短的那一侧永远不会错过最优解
双指针解法是经典的两端向中间夹逼:
cpp复制class Solution {
public:
int maxArea(vector<int>& height) {
int left = 0, right = height.size() - 1;
int ans = 0;
while (left < right) {
int area = min(height[left], height[right]) * (right - left);
ans = max(ans, area);
if (height[left] < height[right]) {
++left;
} else {
--right;
}
}
return ans;
}
};
规则很简单:每次比较两侧高度,移动较矮的那一边。但它背后的逻辑值得说透。
假设 height[left] < height[right],当前水量由 height[left] 决定(短板效应),宽度是 right - left。如果我们把 right 向左移动,宽度必定减小;而新高度 height[right - 1] 无论多大,容器的盛水量依然受限于左侧的 height[left](因为左边界没动),所以 min(height[left], height[right - 1]) 不会超过 height[left]。宽变小、高不增,水量必然变小。
反过来说,既然改变 right 不可能让当前 left 作为左边界的解变得更好,那么以当前 left 为边界的所有可能组合就可以全部排除,于是可以放心地把 left 向右移动。这个论证过程不是"贪心直觉",而是严格的排除法:每次移动都剪掉了一整批不可能成为最优解的组合,剩下的解空间仍然覆盖全局最优。
我第一次想通这个点之后,再看双指针类题目就开始习惯性地问自己:移动一侧指针时,那些被跳过的组合为什么不可能更优?问得多了,就能慢慢养成"先证明再写题"的习惯。
3.3 两边等高时,移动哪边都行,但有一个更好的写法
当 height[left] == height[right] 时,移动哪一侧都合理,因为无论移动哪边,宽度都在减少,而高度上限不变。标准答案里用 else 统一移动右指针即可 AC。
不过我见过一个更高效的写法:在移动指针之后,再加一层 while 跳过比当前侧更矮的柱子。因为当前侧柱子已经决定了高度上限,如果下一个柱子更矮,容器高度只会更低,宽度也更小,肯定不是更优解。实际效果在随机数据上提升有限,但作为一种优化思路可以了解。
3.4 容易忽略的溢出问题
height 的取值范围在题目里是 [0, 10^4],数组长度最大是 10^5,理论上 min(height[left], height[right]) * (right - left) 最大可以到 10^4 * 10^5 = 10^9,刚好在 int 的边界内(INT_MAX ≈ 2.1 * 10^9)。但如果在扩展题里 height 值域变大,或者坐标距离变成 10^6,乘积就会溢出 int。稳妥的做法是用 long long 计算面积再比较,或者至少在心里对这道题的 int 安全性有个数。
4. 三数之和:排序让复杂度降一维,但真正的难点全在去重
4.1 从两数之和到三数之和,降维是排序给的
三数之和要求在一个整数数组 nums 中找出所有三元组 [nums[i], nums[j], nums[k]],满足 i != j != k 且三数之和为 0,同时答案中不能包含重复三元组。
暴力三层循环是 O(n^3),很容易想到。但优化方向比两数之和更有意思——如果数组是无序的,你几乎没法避免三重枚举;但一旦排序,事情就变得可管理了。排序之后,固定第一个数 nums[i],剩下的问题就退化成"在 [i+1, n-1] 区间内找两个数,使它们的和等于 -nums[i]",这恰好是两数之和的双指针变体:因为数组有序,可以用 left 和 right 两个指针从两端向中间逼近。
cpp复制class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> ans;
sort(nums.begin(), nums.end());
int n = nums.size();
for (int i = 0; i < n - 2; ++i) {
// 对第一个数去重
if (i > 0 && nums[i] == nums[i - 1]) continue;
// 两个常见剪枝
if (nums[i] + nums[i + 1] + nums[i + 2] > 0) break;
if (nums[i] + nums[n - 1] + nums[n - 2] < 0) continue;
int left = i + 1, right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
ans.push_back({nums[i], nums[left], nums[right]});
while (left < right && nums[left] == nums[left + 1]) ++left;
while (left < right && nums[right] == nums[right - 1]) --right;
++left;
--right;
} else if (sum < 0) {
++left;
} else {
--right;
}
}
}
return ans;
}
};
排序带来的好处不只是降维,还有去重可管理。无序数组里判断三元组是否重复很麻烦,排序后相同元素永远相邻,跳过重复元素只需要比较相邻位置,这是我能想到的最优雅去重方案。
4.2 去重的正确姿势:跳过的是相同元素,不是相同结果
这道题我被卡最久的就是去重。初版代码我写成收集答案后只移动一边指针,结果同一个三元组反复出现。后来才归纳出三处缺一不可的去重点:
- 第一层:固定元素
nums[i]。如果nums[i] == nums[i - 1],说明以这个值为首的搜索已经在上一轮做过了,直接continue。注意判断条件用的是nums[i] == nums[i - 1],不是nums[i + 1]。用后者会把包含重复值的合法组合跳过,比如[-1, -1, 2]这种本身用到了两个相同数字的组合会被错误丢掉。 - 第二层:找到
sum == 0的组合后,left要向右跳过所有与当前nums[left]相同的元素。 - 第三层:同步地,
right要向左跳过所有与当前nums[right]相同的元素。
之前我图省事,找到 sum == 0 后只执行 ++left 或 --right,没有连续跳过,结果输出里冒出一堆重复三元组。去重必须在收集答案之后立刻做,而且要跳跃到最后一个重复元素的下一个位置,不能在同一个值时原地 ++。这里有个非常容易踩的坑:跳过之后还要再手动 ++left; --right; 一次,否则会停在重复区间里出不来回死循环。
4.3 两个剪枝被很多人忽略,却能让效率提升一个量级
排序之后不仅能双指针,还能在枚举固定元素时剪枝。第一,如果 nums[i] + nums[i + 1] + nums[i + 2] > 0,说明当前 nums[i] 已经是这个数组当前状态下能组成的最小和,再往后更大,所有后面的三元组都不可能等于 0,直接 break 出整个循环。第二,如果 nums[i] + nums[n - 1] + nums[n - 2] < 0,说明以 nums[i] 开头的最大可能和都小于 0,那这个 nums[i] 不可能凑出和为 0 的三元组,直接 continue 到下一个固定元素。
这两个剪枝在数组有大量负数或大量正数时提升非常明显,而且逻辑完全成立,不是玄学。我第一次写出这两个剪枝后,运行时间比不加时快了不少,在讨论区看到有人叫它"最优性剪枝",其实没有多玄,就是"最小组合已经超了,后面的更超"这种朴素推理。
4.4 边界情况:数组长度小于 3 时直接返回空
这道题的边界判断最容易被忽略:nums.size() < 3 时不可能有任何三元组,直接返回空 vector。我第一版代码忘了写这个,跑样例时数组长度正好为 3 没暴露问题,换了长度 2 的用例就崩了。虽然 for 循环的 i < n - 2 在 n < 3 时不会进入(这是 C++ 无符号整数的行为,n - 2 会变成一个巨大的正数,循环反而会进入),但这属于碰巧没出事,不代表逻辑正确。显式写出 if (n < 3) return ans; 才是稳妥的。
5. 无重复字符的最长子串:滑动窗口左边界的维护,差一行代码就是错的
5.1 窗口直觉:不重复区间如何优雅伸展和收缩
题目:给定一个字符串 s,找出其中不含重复字符的最长子串的长度。
暴力做法是枚举所有子串并检查是否含重复字符,复杂度 O(n^3)。正解是滑动窗口:维护一个区间 [left, right],保证区间内没有重复字符,然后不断右移 right 扩展窗口。如果新字符 s[right] 和窗口内的某个字符重复,就把 left 移动到重复字符上一次出现位置的右边,使窗口重新合法。每次扩展后都更新答案 ans = max(ans, right - left + 1)。
这个思路不难理解,但实现方式有两种,差异在细节。
5.2 用哈希表记录"上一次出现位置",关键判断是 >= left
我采用的是记录字符最后一次出现下标的写法:
cpp复制class Solution {
public:
int lengthOfLongestSubstring(string s) {
vector<int> lastPos(128, -1);
int left = 0, ans = 0;
for (int right = 0; right < s.size(); ++right) {
char c = s[right];
if (lastPos[c] >= left) {
left = lastPos[c] + 1;
}
lastPos[c] = right;
ans = max(ans, right - left + 1);
}
return ans;
}
};
这里最关键的一行是 if (lastPos[c] >= left)。如果漏掉 >= left 这个判断,就会出现一个经典错误:字符虽然在整个字符串里出现过,但上一次出现位置已经在 left 左边,根本不在当前窗口内,此时移动 left 会把它错误地往回拉。
我举例说明。s = "abba",遍历过程:
- right=0,c='a',lastPos['a']=-1,不满足条件,记录 lastPos['a']=0,窗口 [0,0]。
- right=1,c='b',lastPos['b']=-1,不满足,记录 lastPos['b']=1,窗口 [0,1]。
- right=2,c='b',lastPos['b']=1 >= left(0),left=2,记录 lastPos['b']=2,窗口 [2,2]。
- right=3,c='a',lastPos['a']=0,如果不加
>= left判断,left 会被更新为 1,窗口变成 [1,3],包含了两个 b,错误!加上判断后,0 >= 2 为 false,left 保持 2,窗口 [2,3] 是合法的最长子串 "ba" 或 "ab"。
这个坑我刷的时候踩得很彻底,当时打印了 left 和 right 的变化才想明白。所以看到 lastPos[c] >= left 这个条件,一定要理解它是"该字符上一次出现位置是否在当前窗口内"的判据,而不是"该字符是否出现过"的判据。
5.3 另一种实现:用 unordered_set 存窗口字符,直观但更啰嗦
面试时很多人会先想到用 unordered_set<char> 保存窗口内的字符,遇到重复就从窗口左边开始删,直到删掉重复字符为止:
cpp复制class Solution {
public:
int lengthOfLongestSubstring(string s) {
unordered_set<char> window;
int left = 0, ans = 0;
for (int right = 0; right < s.size(); ++right) {
while (window.count(s[right])) {
window.erase(s[left]);
++left;
}
window.insert(s[right]);
ans = max(ans, right - left + 1);
}
return ans;
}
};
这种写法的优点是更好理解,缺点是 while 循环可能一次性删除多个字符,虽然每个字符最多被删除一次,总体仍是 O(n),但代码的"为什么不会死循环"解释起来比第一种麻烦。而且涉及 unordered_set 的动态操作,常数更大。我个人更推荐 lastPos 数组写法,它用 vector<int>(128, -1) 替代了哈希表,既省内存又省时间,而且 left 一次跳到正确位置,不用逐个删除中间字符。
注意这里用 vector<int> 长度为 128 是因为 ASCII 字符集只有 128 个字符(不算扩展 ASCII),如果用 unordered_map<char, int> 也可以,但常量开销更大。实际开发中如果字符串可能包含 Unicode 字符,那就不能直接开固定长度数组,得换 unordered_map 处理 codepoint 或 UTF-8 序列,这是工程和刷题的一个差异点。
5.4 进阶:如果想返回最长子串本身,而不是长度
LeetCode 原题只问长度,但面试官经常追加:那你把子串本身找出来。只需在更新 ans 时同步记下 left 和 right,最后用 s.substr(bestLeft, bestLen) 截取。这个附加要求考察的是你能不能从"长度思维"切换到"区间思维",建议顺手练一下。
我自己的体会是:滑动窗口这类题,代码写起来短,但很多人栽在"窗口合法性的维护时机"上——是先移动窗口再判断,还是先判断再移动窗口?这道题的答案是先把 left 调整到合法位置,再把 s[right] 纳入窗口,最后才更新答案。顺序错了,窗口就永远不合法。
6. 第一轮刷完后的三点反思
五道题刷完,最直观的感受是:数组题虽然看着基础,但它们把所有常见优化思维都过了一遍——两数之和教会我用哈希表做查找加速,移动零让我开始认真理解双指针的分区含义,盛水容器锻炼了"排除法证明贪心"的数学直觉,三数之和逼我把去重逻辑理到极致,无重复字符长子串则让我真正掌握了滑动窗口的边界维护。
有几条经验是这轮刷完才慢慢沉淀下来的。
第一,先确定量级再选算法。看到 n 的范围,心里立刻要有 O(n)、O(n log n)、O(n^2) 的可行性判断。这不是应试技巧,而是工程里评估接口性能的第一步。
第二,双指针类题目永远要论证移动指针的正确性。不是背下"矮的往高走"或"小的往大走"这种口诀,而是要能在心里快速推一遍:当前状态能排除哪些解,为什么排除它们不影响最优解。这个习惯一旦养成,遇到新的双指针题就不用再靠猜。
第三,去重和边界是 C++ 实现里最容易出 bug 的地方。三数之和的三层去重、滑动窗口的 lastPos[c] >= left、两数之和的先查后插,每一个都曾经让我打印调试半天。建议刷这类题时故意写错几个边界,观察一下错误输出长什么样,这对记忆比看题解深刻得多。
接下来我的计划是继续把 Hot100 里剩下几类数组题刷完,再回头做二刷。一刷的目标是"见过所有题型的通用套路",二刷才是真正把细节揉碎吞下去的过程。这篇笔记就当是第一轮实战的存档,下次再遇到这些题时,直接对照自己当时的思路,看有没有新的理解。
