双指针把两两枚举压成单向扫描,滑动窗口是它在连续区间上的特化,指针总移动次数仍是 O(n)。
前端做字符串 diff 时要同步扫描两份文本,排查线上问题时要在日志里找一段连续时间满足错误率阈值。这两类需求都不需要回头看,指针只朝一个方向走,天然适合双指针与滑动窗口。
| 形态 | 指针方向 | 典型题 | 关键点 |
|---|---|---|---|
| 同向快慢 | 都向右,速度不同 | 判环、删除重复、移动零 | slow 写、fast 读 |
| 相向对撞 | 一个从左、一个从右 | 两数之和 II、三数之和、盛水、反转 | 移动哪侧由单调性决定 |
| 分离双指针 | 各自在自己的序列上前进 | 合并两个有序数组、链表相交 | 两条链互不干扰 |
快慢指针不要求有序,对撞指针才依赖单调性:数组有序时,左指针右移会让和变大,右指针左移会让和变小,才能确定该动哪一边。
Floyd 判圈算法让 slow 每次走一步、fast 每次走两步。slow 进环后,fast 已经在环内,两者距离每轮缩小 1,所以一定会相遇,不会因为相对速度大于 1 而跳过。
function hasCycle(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) return true;
}
return false;
}
找环入口时,相遇后把其中一个指针放回 head,两者同速前进,再次相遇的位置就是入口。设 head 到入口距离 a、入口到相遇点 b、环长 b+c,则 2(a+b) = a+b+n(b+c),化简得 a = c + (n-1)(b+c),所以从 head 走 a 步与从相遇点走 c 步会同时到达入口。
function detectCycle(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) {
let p = head;
while (p !== slow) { p = p.next; slow = slow.next; }
return p;
}
}
return null;
}
适用条件是"连续子数组或子串"加"单调性":窗口右扩时指标单调变化,左缩时能恢复。求最长用 while (不满足) 收缩,求最短用 while (满足) 收缩,两个循环都只在指针移动时执行,总复杂度 O(n)。
function slidingWindow(s) {
let left = 0, ans = 0;
const window = new Map();
for (let right = 0; right < s.length; right++) {
扩张(window, s[right]);
while (不满足条件(window)) {
收缩(window, s[left]);
left++;
}
更新答案(ans, right - left + 1);
}
return ans;
}
定长窗口与不定长窗口的区别在收缩时机:定长窗口大小固定为 k,右指针每走一步,左指针固定跟到 right - k + 1,用 if 减掉离开的元素即可;不定长窗口要靠 while 反复收缩,直到条件重新满足。
// 定长窗口:长度为 k 的子数组最大和
function maxSumWindow(nums, k) {
let sum = 0, ans = 0;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
if (right >= k) sum -= nums[right - k];
if (right >= k - 1) ans = Math.max(ans, sum);
}
return ans;
}
如果每移动一次右指针都把窗口内元素重新统计一遍,单次是 O(k),整体退化成 O(nk)。用 Map 或计数数组增量维护,进入窗口加一、离开窗口减一,每次移动 O(1)。ASCII 字符可以用长度 128 的数组,Unicode 字符必须用 Map。
只维护计数还不够,判断"窗口是否覆盖目标"时要避免每次遍历整个 Map。常见做法是额外维护 valid,当某个字符的计数刚好达到需求时加一,超过时不变,这样判断条件是 O(1)。
很多人以为双指针必须有序,实际上快慢指针和分离双指针都不需要,只有对撞型依赖单调性。
很多人以为窗口不满足条件时用 if 收缩一次就够,实际上可能需要连续收缩多次,必须用 while。
很多人以为滑动窗口能解子序列问题,实际上它只能处理连续区间,子序列要换 DP 或贪心。
记录每个字符最后出现的位置,遇到重复就把 left 跳到上次位置加一。时间 O(n),空间 O(字符集)。
function lengthOfLongestSubstring(s) {
const last = new Map();
let left = 0, ans = 0;
for (let right = 0; right < s.length; right++) {
const c = s[right];
if (last.has(c) && last.get(c) >= left) left = last.get(c) + 1;
last.set(c, right);
ans = Math.max(ans, right - left + 1);
}
return ans;
}
先用 need 记录 t 的字符需求,右扩时更新窗口计数与 valid,覆盖后收缩并记录最短区间。时间 O(n),空间 O(字符集)。
function minWindow(s, t) {
const need = new Map(), win = new Map();
for (const c of t) need.set(c, (need.get(c) || 0) + 1);
let left = 0, valid = 0, start = 0, len = Infinity;
for (let right = 0; right < s.length; right++) {
const c = s[right];
win.set(c, (win.get(c) || 0) + 1);
if (need.has(c) && win.get(c) === need.get(c)) valid++;
while (valid === need.size) {
if (right - left + 1 < len) { start = left; len = right - left + 1; }
const d = s[left++];
if (need.has(d) && win.get(d) === need.get(d)) valid--;
win.set(d, win.get(d) - 1);
}
}
return len === Infinity ? '' : s.slice(start, start + len);
}
数组元素全为正,窗口和随右扩单调增加,一旦达到 target 就收缩并更新答案。时间 O(n),空间 O(1)。
function minSubArrayLen(target, nums) {
let left = 0, sum = 0, ans = Infinity;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
while (sum >= target) {
ans = Math.min(ans, right - left + 1);
sum -= nums[left++];
}
}
return ans === Infinity ? 0 : ans;
}
排序后固定第一个数,剩下两个用对撞指针找相反数,找到后跳过重复元素。时间 O(n²),空间 O(1)(不计排序栈)。
function threeSum(nums) {
nums.sort((a, b) => a - b);
const res = [];
for (let i = 0; i < nums.length - 2; i++) {
if (i > 0 && nums[i] === nums[i - 1]) continue;
let l = i + 1, r = nums.length - 1;
while (l < r) {
const sum = nums[i] + nums[l] + nums[r];
if (sum === 0) {
res.push([nums[i], nums[l], nums[r]]);
while (l < r && nums[l] === nums[l + 1]) l++;
while (l < r && nums[r] === nums[r - 1]) r--;
l++; r--;
} else if (sum < 0) l++;
else r--;
}
}
return res;
}
面积由较矮的一侧决定,移动较高的一侧只会让宽度变小、高度不变或更小,所以每次移动矮的一侧。时间 O(n),空间 O(1)。
function maxArea(height) {
let l = 0, r = height.length - 1, ans = 0;
while (l < r) {
ans = Math.max(ans, Math.min(height[l], height[r]) * (r - l));
if (height[l] < height[r]) l++;
else r--;
}
return ans;
}