双指针算法复盘总结:从元素思维到边界思维
双指针算法复盘总结:从元素思维到边界思维
核心主题:从”元素思维”转向”边界思维”。
刷双指针的题,最大的坎不是不熟悉模板,而是身份认知的问题:你总把指针当成”当前元素的下标”,但它实际上是”区域的边界”。这篇复盘把双指针完整分类、每类的区域定义与不变式、错题解剖、防错检查单整理成一份可复用笔记。
一、双指针完整分类地图
双指针
├── 同向(两个指针朝同一方向走)
│ ├── 快慢指针:速度不同,fast探路,slow守家
│ └── 滑动窗口:距离动态变化,right扩张找可行解,left收缩找最优解
│
├── 对向(两个指针从两头往中间走)
│ ├── 对撞指针:left←————→right,直到相遇
│ └── 三指针/荷兰国旗:对撞 + 中间加侦察兵i
│
└── 分离(两个指针在两条数组/链表上)
└── 分离双指针:各走各的,谁小谁移动
面试核心就三类:快慢指针、对撞指针、滑动窗口。荷兰国旗是对撞的扩展。
二、每类指针的”区域定义”与不变式
2.1 快慢指针(Fast & Slow)
本质:fast 在未知区探路,slow 标记有效区边界。
区域图:
[ 有效区 ) [ 废弃/未知区 )
0 slow fast n
不变式:[0, slow) 永远是处理好的有效区,slow 是”下一个待写入位置”(不是最后一个有效元素的下标)。
代码骨架:
int slow = 0;
for (int fast = 0; fast < n; fast++) {
if (nums[fast] 满足条件) {
nums[slow] = nums[fast]; // 先写入边界位置
slow++; // 再扩界
}
}
return slow; // slow就是有效区长度
致命易错点:
- 必须先
nums[slow] = ...再slow++,不能反过来 -
slow是”边界/长度”,不是”下标”
2.2 对撞指针(Two Pointers from Both Ends)
本质:left 和 right 从两头往中间挤,利用有序性或对称性。
区域图:
[ 已排除 ) [ 待处理 ] [ 已排除 )
0 left right n-1
不变式:[0,left) 和 (right,n-1] 是已处理/已排除区,[left,right] 是待处理区。
代码骨架:
int left = 0, right = n - 1;
while (left < right) { // 注意是 <,不是 <=
if (条件满足) left++;
else if (另一条件) right--;
else { left++; right--; }
}
2.3 滑动窗口(Sliding Window)
本质:快慢指针的变体。right 扩张找”可行解”,left 收缩找”最优解”。
窗口是连续区域 [left, right)。整套算法的节奏只有三个动作,每个动作各回答一个问题——记清楚这三个问题,模板就不会写乱。
① 什么时候扩张 right?加入字符时更新什么数据?
扩张是默认动作:right 是”读指针”,每个字符都必须被它看过一次,所以外层循环每轮无条件走一步。真正要判断的不是”什么时候扩张”,而是”什么时候停止扩张”——信号不在扩张侧,而在收缩侧:一旦加入字符后窗口状态翻转,就暂停扩张,转入收缩(两种题型的”翻转信号”正好相反,见 ③ 的表)。
窗口加入字符时,更新的是记账数据(window state)——描述”窗口现状”的变量,不是答案:
| 记什么账 | 对应题目 |
|---|---|
频次表 window[c]++ | 无重复字符最长子串、最小覆盖子串 |
不同字符计数 ++ | 至多 K 个不同字符的最长子串 |
窗口和 sum += x | 长度最小子数组(和 ≥ target) |
| 已覆盖 target 的字符数 | 最小覆盖子串的”够不够数” |
规则:扩张 = 入账。入账只改状态,不碰答案。
② 什么时候暂停扩张、开始收缩 left?移出字符时更新什么数据?
收到”翻转信号”后,
while收缩:移出s[left]→ 更新记账 →left++,直到窗口达到目标状态(最长型:重新合法;最短型:即将非法)。
窗口移出字符时,更新同一套记账数据,方向与入账严格相反:
| 销什么账 | 对应入账 |
|---|---|
window[d]-- | window[c]++ |
不同字符计数 -- | ++ |
sum -= x | sum += x |
覆盖计数视情况 -- | ++ |
规则:收缩 = 销账,与入账严格对称——从哪个字符入的账,移出它时就要原样退回。
关键:收缩必须是 while 不是 if——移出一个字符可能仍不满足条件,需要连续收缩。
③ 答案在哪里更新?——最长型 vs 最短型
候选解必须是”合法窗口”,所以答案只在窗口合法的时刻更新。 但”合法时刻”落在哪一侧,决定了两种题型的写法——这是滑动窗口唯一需要分类的地方:
| 最长型(最长子串/子数组) | 最短型(最小覆盖/最短子数组) | |
|---|---|---|
| 例题 | 无重复最长子串、至多 K 个不同字符 | 最小覆盖子串、长度最小子数组 |
| 收缩循环的条件 | while (窗口非法) —— 缩到重新合法 | while (窗口合法) —— 缩到即将非法 |
| 合法时刻在哪 | 收缩循环的出口(缩回合法的瞬间) | 收缩循环的体内(每缩一步仍合法) |
| 答案更新位置 | 收缩循环结束后:max(ans, right - left) | 收缩循环内部:min(ans, right - left) |
| 为什么 | 出口处的窗口 = 以 right 为右端的最长合法窗 | 体内每个瞬间都是合法窗,且越来越短 |
口诀:最长型在”恢复合法”时取 max,最短型在”维持合法”时取 min。
反例验证(为什么不能乱放):
- max 放在扩张时 → 把非法窗口(含重复字符 / 超过 K 个 distinct)也算进去,答案虚高
- min 放在收缩循环外 → 循环退出时窗口已经非法,最小值永远取不到
两种题型,同一副骨架:
// 最长型:收缩条件 = 非法,更新在收缩循环之后(合法出口)
int left = 0, right = 0, ans = 0;
while (right < n) {
add(nums[right]); // 扩张:入账
right++;
while (窗口非法) { // 缩到重新合法
remove(nums[left]); // 收缩:销账
left++;
}
ans = max(ans, right - left); // 合法时刻(出口):取 max
}
// 最短型:收缩条件 = 合法,更新在收缩循环之内(每个合法瞬间)
int left = 0, right = 0, ans = INT_MAX;
while (right < n) {
add(nums[right]); // 扩张:入账
right++;
while (窗口合法) { // 缩到即将非法
ans = min(ans, right - left); // 合法时刻(体内):取 min
remove(nums[left]); // 收缩:销账
left++;
}
}
走一遍(无重复字符最长子串,s = “abcabcbb”):
- right=0、1、2:依次入账 a、b、c → 窗口合法 → 出口取 max → ans = 1 → 2 → 3
- right=3:入账 a → 频次 a 变 2 → 窗口非法 → 收缩:销掉 s[0]=’a’ → 重新合法(left=1,窗口 “bca”)→ 出口取 max(3, 2) = 3
- 全程节奏:入账 → 可能非法 → 销账到合法 → 出口取 max
走一遍(最小覆盖子串,LeetCode 76——最短型的完整代码):
想找「覆盖 t 的最短子串」,用的正是最短型骨架,和上面的模板一一对照:
| 骨架占位 | 76 里的对应 |
|---|---|
add(入账) | window[c]++; if (window[c] == need[c]) valid++; |
| 合法条件 | valid == need.size()(每种字符都凑齐) |
while (合法) 收缩 | while (valid == need.size()) |
收缩内更新 min | if (right - left < len) { start = left; len = right - left; } |
remove(销账) | if (window[d] == need[d]) valid--; window[d]--; |
class Solution {
public:
string minWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++; // need:t 的需求清单
int left = 0, right = 0;
int valid = 0; // 已「数量凑齐」的字符种类数
int start = 0, len = INT_MAX; // 答案:起始下标 + 长度
while (right < s.size()) {
char c = s[right]; // ① 即将入窗口的字符
right++; // 扩张(默认动作)
if (need.count(c)) { // 入账:只记 need 关心的字符
window[c]++;
if (window[c] == need[c]) valid++; // 这种字符凑齐了,种类数 +1
}
while (valid == need.size()) { // ② 合法 = 每种字符都凑齐 → 收缩
if (right - left < len) { // ③ 最短型:在收缩循环内更新 min
start = left; // (每步都合法,且越缩越短)
len = right - left;
}
char d = s[left]; // ② 即将出窗口的字符
left++; // 收缩
if (need.count(d)) { // 销账:对称退回
if (window[d] == need[d]) valid--; // 减之前先判断(减完就不够了)
window[d]--;
}
}
}
return len == INT_MAX ? "" : s.substr(start, len);
}
};
valid 的计数技巧:valid 不是「覆盖了几个字符」,而是「数量恰好凑齐」的字符种类数。入账时数量刚好到 need[c] 就 valid++;销账时这个字符在移出前数量刚好等于 need[d],移出后就差一个,所以先 valid-- 再 window[d]--。这一收一放,正是 ② 里说的「入账销账严格对称」,也把 ② 表格里「已覆盖 target 的字符数」这一行落到了实处。
一句话收束:入账(扩张)只改状态,销账(收缩)对称退回,答案只在”合法”的瞬间诞生——最长型诞生在缩回合法的出口,最短型诞生在维持合法的体内。
2.4 荷兰国旗(三指针 / Dutch National Flag)
本质:对撞指针的扩展,处理”3 类元素原地三分区”。
区域图(必须刻进 DNA):
[0, left) → 0区(已搞定)
[left, i) → 1区(已搞定)
[i, right] → 未知区(待探索)
(right, n-1] → 2区(已搞定)
不变式:i 是未知区的左边界,right 是未知区的右边界。i 绝对不可以踏入 (right, n-1] 这个已完工的 2 区。
代码骨架:
int left = 0, right = n - 1, i = 0;
while (i <= right) { // 关键:未知区是[i,right],不是[0,n)
if (nums[i] == 0) {
swap(nums[i], nums[left]);
left++; i++; // 换过来的是1(来自1区),安全
} else if (nums[i] == 1) {
i++; // 1就在中间,不动
} else { // nums[i] == 2
swap(nums[i], nums[right]);
right--; // i不动!换过来的来自未知区,必须再检查
}
}
致命易错点:
- 循环条件必须是
i <= right,绝不能是i < n - swap 2 时
i绝对不能动
2.5 分离双指针
本质:两个指针在两个数组上,协作而非竞争。
铁律:谁小谁移动,相等时一起移动。
三、错题解剖
3.1 Remove Duplicates(保留最多 k 个重复)
错误代码:
int slow = 0;
unordered_map<int,int> check; // 错误1:不需要HashMap
for(int fast = 0; fast < nums.size(); fast++) {
if(check[fast]!=check[slow] && check[nums[fast]]<2) { // 错误2:索引和值混用
slow++; // 错误3:先移动
nums[slow]=nums[fast]; // 再写入,导致偏移
check[nums[fast]]++;
}
}
return slow+1; // 错误4:返回slow+1来弥补偏移
错误根因:
- 没利用”有序数组”特征:有序数组去重不需要 HashMap,直接和边界比
- slow 语义错误:把 slow 当成”最后一个有效元素下标”,实际上 slow 是”下一个待写入位置”
- 写入顺序错误:必须先
nums[slow] = ...再slow++
正确通用模板(保留 k 个重复):
int removeDuplicates(vector<int>& nums, int k) {
int n = nums.size();
if (n <= k) return n;
int slow = k; // 前k个无条件保留
for (int fast = k; fast < n; fast++) {
if (nums[fast] != nums[slow - k]) { // 和有效区倒数第k个比(警戒线)
nums[slow] = nums[fast]; // 先写入
slow++; // 再扩界
}
}
return slow; // 边界就是长度
}
3.2 Sort Colors(荷兰国旗)
错误代码:
while(i < nums.size()) { // 致命错误
if(nums[i]==0) { swap(nums[slow],nums[i]); slow++; i++; }
else if(nums[i]==1) { i++; }
else { swap(nums[i],nums[fast]); fast--; }
}
错误根因:
- 循环条件错误:用了
i < n,导致i冲进已经排好的 2 区,把 2 又 swap 回中间 - 没理解区域定义:
i不是”遍历整个数组的游标”,而是”未知区的左哨兵”;fast右边是已完工区,禁止踏入
正确代码:
while (i <= fast) { // 未知区是[i,fast]
if (nums[i] == 0) { swap(nums[i], nums[slow]); left++; i++; }
else if (nums[i] == 1) { i++; }
else { swap(nums[i], nums[fast]); right--; /* i不动 */ }
}
3.3 其他常见错误模式
| 坑 | 表现 | 本质 |
|---|---|---|
| 原地修改依赖症 | merge 题用 insert、squares 题直接覆盖 nums[right] | 把”数据源”和”结果容器”当成同一个东西 |
| 正向遍历思维定式 | 总想从左到右、边遍历边处理 | 忽略了”从后往前”往往没有覆盖风险 |
| 边界条件前置化 | 写一堆 if(size==0) 特判 | 没有把边界判断融入循环条件 |
四、核心根因:”元素思维” vs “边界思维”
你总把指针当成”当前元素的下标”,但它实际上是”区域的边界”。
| 题目 | 错误表现 | 指针的真实身份 |
|---|---|---|
| Remove Duplicates | slow++ 后再写入,返回 slow+1 凑长度 | slow 是边界,指向”下一个待写入位置”,它本身就是长度 |
| Sort Colors | i < n 遍历全数组 | i 是未知区左哨兵,不是普通遍历游标 |
| Merge Sorted Array | 想原地 insert 或正向覆盖 | 没意识到 nums1 尾部有”空白缓冲区”,应该从后往前填 |
| Squares of Sorted Array | 直接覆盖原数组 | 没意识到”结果数组”和”数据源”可以是两个东西 |
一句话总结:写代码时脑子里想的是”这个元素怎么处理”,但双指针要求你想的是”这个区域怎么变化”。
为什么容易混淆?
这是非常正常的编程思维惯性:
- 平时写
for (int i = 0; i < n; i++),i就是”当前元素” - 但双指针里,
slow/left/i往往是左闭右开区间的右边界 - 这个转变是反直觉的,所以你会用
slow+1去”修正”,用i < n去”遍历”
slow+1 和 i < n 不是代码错误,是”身份认知错误”的补丁。 补丁打多了,逻辑就乱了。
五、防错检查单
写代码前必须回答:
- 我画出了区域图吗?(
[搞定区) ptr [待处理区) ptr [搞定区]) - 每个指针是”位置”还是”边界”?(边界 = 指向区域外第一个元素)
- 循环条件是从”待处理区是否为空”推导的吗?
- 指针移动后,区域定义还成立吗?
- 返回的是边界(长度)还是下标?
六、一句话决策树
看到"原地修改/分区" + "一个数组"
│
├─ 找连续子串/子数组的最长最短 → 滑动窗口
├─ 3类固定值原地三分区 → 荷兰国旗(left/i/right)
├─ 有序数组 + 去重/分区 → 快慢指针(slow/fast)
└─ 两头往中间凑 → 对撞指针(left/right)
看到"两个数组/链表"找关系
└─ 分离双指针(谁小谁走)
七、核心口诀
两类对撞挤,三类荷兰旗;
换零两边走,换二右边缩;
中间是净土,遇一直接过。
slow是边界,先写再扩界;
i是侦察兵,只逛未知区。
补充口诀:
“怕覆盖,就倒着填;两头大,取一边;同方向,快慢走。”
八、关键认知转变
8.1 数组是廉价的,思维是昂贵的
总想原地操作(insert、直接覆盖),是因为觉得”开新数组浪费空间”。但算法题里:
- O(n) 额外空间是常规操作
- 破坏原始数据才是致命错误
下次拿到数组题,先问自己:“我能不能倒着填?能不能开个新数组?” 如果答案是可以,大概率这就是正解。
8.2 进步轨迹
对比第一题和难题(对角线排序):
- 第一题:
insert导致长度爆炸,逻辑完全失控 - 难题:已经会利用数学特征做分组,会用
back()+pop_back()优雅回填
难题的难度其实更高,但核心逻辑写对了。这说明不是不会双指针,只是还没习惯”从后往前”和”另开数组”这两个 trick。
九、练习建议
9.1 每日必做:看到数组题,先画两个箭头
- 箭头从两端往中间走 → 对向双指针(平方和、两数之和)
- 箭头从后往前走 → 逆向双指针(合并、替换空格)
- 一快一慢往右走 → 快慢双指针(移除元素、链表判环)
画 5 分钟箭头,再写代码。代码写对了,再把箭头擦掉。
9.2 专项突破计划
- 接下来 3 天,只做快慢指针和荷兰国旗,不碰滑动窗口和对撞
- 每道题强制画区域图,用
[ )符号标出”已搞定区”和”未知区” - 写代码前,先口头解释:
slow是下标还是边界?i能踏进right右边吗? - 允许自己开新数组,先放弃”原地修改”的执念,把逻辑跑通再优化
9.3 画区域图的正确姿势
错误画法(直觉):
[1, 1, 2, 2, 3]
↑
slow(指向1)
正确画法(双指针语义):
[1, 1) [2, 2, 3)
↑
slow(边界,指向2)
把指针画成”栅栏”而不是”箭头”。
十、边界认知自测题
数组
[1,1,1,2,2,3],保留最多 2 个重复,用快慢指针。
- 初始时
slow应该等于几?fast从几开始?- 第一次满足
nums[fast] != nums[slow-2]时,slow指向的位置会发生什么?
正确答案:
-
slow初始是 2(前 2 个无条件保留,边界停在第 3 个位置) -
fast从 2 开始探路 - 第一次满足条件时,
nums[slow] = nums[fast],然后slow++扩界
如果第一反应是 slow=1、fast=0、先 slow++ 再赋值 → 身份认知还没转过来,需要再练。
十一、更高纬度:双指针到底在干嘛
前面十节是”术”,这一节是”道”。学会之后,你看到的不再是三道题,而是一个原理的三个投影。
11.1 为什么双指针都是 O(n):单调前沿扫描
暴力做法是在 O(n²) 的 (i,j) 配对上找答案。双指针做的事从来不是”扫得聪明”,而是:证明绝大多数配对永远不可能是答案,然后永久丢髥它们。
每个指针单调移动(永不回头)→ 每个指针总共只走 O(n) 步 → 摊还 O(n)。
剩下的候选解排成一条单调阶梯(staircase)——l 每前进一步,对应的 r 只往一个方向动。沿着这条阶梯走一遍就是 O(n)。滑动窗口、对撞、分离,全是这个图景,区别只是那条前沿长什么样。
11.2 指针移动 = 支配证明
把 l++ / r-- 从”移动变量”重命名为”删除候选解集”:
“我删掉了所有以当前
l(或r)为界的候选解,并证明它们不可能最优。”
接雨水那道题:leftMax < rightMax 时 left 的水量被左墙锁死,右边再高也救不了这个位置——所以以它为左界的全部候选解都是死的,可以理直气壮地 left++。每次移动前问一句:”我删掉了什么,凭什么?” 答不上来的移动,就是靠背模板在写。
11.3 “双指针”其实是三个家族
宽度上记住三张面孔,别再当一个东西背:
| 家族 | 代表 | 利用的结构 |
|---|---|---|
| 原地划分 | 数组型快慢、荷兰旗 | 不需要有序!靠”值分类 + 区域不变式”做 partition |
| 合并/配对 | 对撞、分离、归并 | 需要有序性(或支配关系),比大小决定谁走 |
| 结构检测 | Floyd 判环 | 相对采样差 + 鸽巢,跟数组派无共享机制 |
两个彩蛋:荷兰国旗本质是三路划分 = quicksort 3-way partition 的内核;归并那道题本质是归并排序的 merge 步骤。你刷的每道双指针,都是在单练排序/检索里切出来的一刀。
11.4 数组型快慢 = 读写分离流水线
忘掉”快慢”,改叫”读写”:fast 是读指针(唯一信息源,必须遍历每个元素),slow 是写指针(有效区边界)。
fast (迭代器,读) → [ 过滤规则 ] → slow (输出流,写)
这是 in-place 版的 filter 流水线,输出流和输入流在同一个数组上重叠。真正的问题只有一个:什么时候原地安全?
写指针永远不能越过尚未读取的数据源。
正向写(去重/移除,写 ≤ 读)安全;结果比源长时必须倒着填(Merge Sorted Array、替换空格),从尾部空白缓冲区出发——正写和反写是同一枚硬币,方向反转只是为了让写指针继续躲着源走。别再纠结”开新数组是不是浪费”,画两个箭头问:“这样写我会踩到还没读的元素吗?”
11.5 决策所需的最小状态量(错题一的最终解释)
第三章的错题一,当时说是”有序数组不需要 HashMap”。现在可以讲透:区别是做一次留/删决策,最少要记住多少东西。
| 情况 | 决策规则 | 所需状态 |
|---|---|---|
| 移除元素(无序也行) | 值 == target → 删,纯函数 | 无状态,O(1) 都不需要 |
| 去重(无序) | “这个值我见过第 k+1 次了吗?” | 完整历史(multiset),O(n) |
| 去重(有序) | nums[fast] != nums[slow-k] | 一个位置,O(1) |
关键的表述:有序性把”全局支配”压成”局部支配”。移除元素是全局恒真命题(值本身决定去留,不依赖任何前缀);去重依赖历史。有序后,相同的值被迫相邻——”这个值出现过几次”的完整历史坍缩成一个下标(slow-k 哨兵)。排序没有替你推导,排序让记忆降维成指针。
11.6 这架电梯还能升到别的题
“结构单调 → 决策状态可压缩 → 摊还 O(n)”不止双指针:滑动窗口 while 收缩、单调栈每个元素进出一次、KMP 失配回退有界,全是同一架电梯的不同楼层。
下次做题别只问”这题是不是双指针”,改问:“这题的决策,最少的必要记忆是什么?” 答案是常数 ⇒ 存在一趟流水的做法;答案必须是全部历史 ⇒ 要么排序/建结构把它压下来,要么这题就吃定 O(n) 空间。
十二、通俗版专项:对撞指针的”瓶颈锁定额水”
第十一章讲了道,这一章把对撞家族里的”瓶颈锁死型”(盛水、接雨水)用最舒服的方式再讲一遍——全部浓缩成一个原理:木桶效应,瓶颈锁定额水。
12.1 核心原理:矮的是天花板
不管盛水还是接雨水,全篇只有一句话:水能存多少,由矮的那边决定。
这是木桶效应——杯壁再高也没用,水从矮的那边溢出去。所以”矮墙 = 天花板”,谁矮谁定水。
12.2 盛最多水:矮墙淘汰(最单纯的版本)
一排墙,选两堵当杯壁,装水量 = 矮的那堵墙的高度 × 两墙距离。双指针从最左和最右开始(距离最大),每轮都”淘汰”对里比较矮的那堵墙。
为什么淘汰矮的?把道理说得特别白:
左边这堵墙矮。把它留着,右边换任何一堵墙——宽度只会更窄(右边只能往左挪),水量天花板永远是矮的那边的高度。所以这堵矮墙跟谁配都赢不了,直接枪毙它这一整列候选。
注意这个动作不是”试试别的”,而是一次性判死刑:以矮墙为边的所有组合都不可能超过当前这对——因为当前这对的宽度已经拉满(两端最远),矮墙又封了顶。这个角度上已经到达它的人生巅峰,往里走埋伏更高墙去了。
12.3 接雨水:同一个原理,搬到每个坑上
接雨水麻烦一点:水不是装在选定的两堵墙之间,而是积在每一格坑里,左右都得有墙才存得住水。每个格子能存多少,公式一句话:
水量 = min(左边最高的墙, 右边最高的墙) − 我这格的高度
还是木桶效应:左边最高墙 5、右边最高墙 8,水面最多到 5(从矮那边流走),减去坑深 1,存 4 格水。
难点:每个坑都要”左右最高的墙”,难道每个坑都往左右扫一遍(O(n²))?双指针的笨办法变聪明之处——从两头往中间边走边记,只记两个数:
leftMax = 左指针一路走来见过的墙里最高的
rightMax = 右指针一路走来见过的墙里最高的
现在盯住左指针脚下的格子,要做判断:它右边到底有没有够高的墙?
这里是最容易卡住的一步,请勾重点:
rightMax不是”右边那堵墙有多高”,而是”右边这一路已经看过的墙里最高的”。右边那片还没看过的区域里,真正的右最高只会比rightMax高(或持平),绝不可能更矮。
所以可以放心地说一句:右边至少有一堵 rightMax 这么高的墙。
判断就清脆了:
-
leftMax < rightMax→ 右边保证有比leftMax更高的墙 → 右边当不了瓶颈 → 瓶颈就是leftMax→ 这格水量 =leftMax − 自己,定死,左指针往前走 - 反过来,右边一格的积水量被
rightMax锁定,右指针往前走
12.4 一张表收尾:淘汰 vs 锁定,都是木桶效应
| 盛最多水 | 接雨水 | |
|---|---|---|
| 每次动作 | 矮墙淘汰,换更近的墙 | 矮边锁定水面,算完走人 |
| 比较的对象 | 当前两堵墙的高度 | 两侧 running max(压缩的历史) |
| 依赖结构 | 两端最远 + 矮边封顶(无需排序) | 逐个格子的左右最高墙(无需排序) |
| 木桶效应形态 | 全局砍掉整列候选 | 局部钉死一个格子的水位 |
慢节奏上手建议:在纸上随便画一排墙的高度数组,用手模拟 leftMax / rightMax 从左到右蠕动一遍,每个格子停下来算一次——走完一遍,代码自然就默写得出来了。
总结
不是不会双指针,是还没习惯”用边界思考,而不是用元素思考”。
这个转变没有捷径,但每次写代码前问一句 “这个指针是箭头,还是栅栏?”,一周之内就能彻底改过来。
好算法往往是反直觉的,而直觉正在被慢慢修正。 这个过程就叫学习。
Enjoy Reading This Article?
Here are some more articles you might like to read next:
- Google Gemini updates: Flash 1.5, Gemma 2 and Project Astra
- Displaying External Posts on Your al-folio Blog
- Agent 评测体系与评测集构建——美团《评测漫谈》+《评测白皮书 01》笔记
- 多模态 LLM 用户智能体做推荐系统离线 A/B 测试
- 自我改进 Agent 统一拆解:θ / Σ 双路线
- CS146S 学习笔记(Week 4-8):从智能体管理者到多栈 AI 构建
- CS146S 学习笔记:从 Prompt 技术全景到 AI IDE 设计文档规范
- 二分查找双模板 + searchInsert 逐行拆解:从模板到边界
- Agent Memory 全景:30 个记忆技术的模块化拆解
- LightRAG 深度解析:简单快速的图增强 RAG