双指针技巧:对撞指针、快慢指针、滑动窗口
双指针,说白了就是两个指针在数据结构里来回跑。听起来简单,但用好了能解决一大批让人头疼的问题。我个人习惯把双指针分成三类:对撞指针、快慢指针、滑动窗口。这三类各有各的脾气,咱们一个一个聊。
一、对撞指针
对撞指针,也叫左右指针。一个指针在最左边,一个在最右边,然后往中间走。什么时候用?数组是有序的时候,或者你需要从两端往中间逼近的时候。
经典场景:
- 两数之和(有序数组版)
- 三数之和
- 盛最多水的容器
- 反转字符串
我在项目中遇到过一个问题:需要从一个有序数组中找出所有和为 target 的数对。用暴力法 O(n²) 肯定不行,数据量一大就崩。对撞指针一上,O(n) 搞定。
核心模板:
function twoSum(nums, target) {
let left = 0, right = nums.length - 1;
while (left < right) {
const sum = nums[left] + nums[right];
if (sum === target) {
return [left, right];
} else if (sum < target) {
left++; // 和太小,左指针右移
} else {
right--; // 和太大,右指针左移
}
}
return [];
}
我的小技巧:对撞指针的 while 条件用 left < right,别用 left <= right。否则当 left 和 right 指向同一个元素时,可能会出问题。嗯,这个坑我踩过。
二、快慢指针
快慢指针,一个走得快,一个走得慢。常用于链表问题,比如判断链表是否有环、找链表中点、找环的入口。
经典场景:
- 判断链表是否有环
- 寻找链表的中点
- 寻找链表的倒数第 k 个节点
- 删除链表倒数第 n 个节点
你想想看,如果链表有环,快指针走两步,慢指针走一步,它们迟早会相遇。为什么?因为快指针每次比慢指针多走一步,在环里就像在追人,总能追上。
核心模板:
function hasCycle(head) {
if (!head || !head.next) return false;
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow === fast) {
return true; // 相遇了,有环
}
}
return false;
}
我曾经踩过的坑:快指针移动时一定要先判断 fast.next 是否存在。如果 fast.next 是 null,再取 fast.next.next 就会报错。所以 while 条件里要写 fast && fast.next。
三、滑动窗口
滑动窗口,说白了就是维护一个区间,然后让这个区间在数组或字符串上滑动。窗口大小可以固定,也可以动态变化。
经典场景:
- 无重复字符的最长子串
- 长度最小的子数组
- 字符串的排列
- 找到字符串中所有字母异位词
滑动窗口的核心是:什么时候扩大窗口?什么时候缩小窗口?我一般用两个指针 left 和 right 来维护窗口边界,right 负责扩大,left 负责缩小。
核心模板:
function slidingWindow(s) {
const window = new Map();
let left = 0, right = 0;
while (right < s.length) {
// 扩大窗口
const c = s[right];
window.set(c, (window.get(c) || 0) + 1);
right++;
// 需要缩小窗口时
while (需要缩小) {
const d = s[left];
window.set(d, window.get(d) - 1);
left++;
}
}
}
我的经验:滑动窗口的模板其实很固定。先扩大 right,然后判断是否需要缩小 left。缩小的时候更新结果。记住这个节奏,大部分滑动窗口题都能套。
四、经典例题详解
例题1:三数之和
给你一个数组,找出所有和为 0 的三元组。不能重复。
思路:先排序,然后固定一个数,剩下的两个数用对撞指针找。
function threeSum(nums) {
nums.sort((a, b) => a - b);
const result = [];
for (let i = 0; i < nums.length - 2; i++) {
// 跳过重复的固定元素
if (i > 0 && nums[i] === nums[i - 1]) continue;
let left = i + 1, right = nums.length - 1;
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) {
result.push([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 result;
}
注意:去重是关键。固定元素要去重,左右指针也要去重。不然你会得到一堆重复的三元组。我曾经因为这个被面试官追问了好久。
例题2:盛最多水的容器
给你一个数组,每个元素代表一个高度。找出两个柱子,使得它们组成的容器能装最多的水。
思路:对撞指针。每次移动高度较小的那一端,因为水的高度取决于较矮的柱子。
function maxArea(height) {
let left = 0, right = height.length - 1;
let max = 0;
while (left < right) {
const area = Math.min(height[left], height[right]) * (right - left);
max = Math.max(max, area);
// 移动较矮的那一端
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return max;
}
为什么移动较矮的?因为水的高度由较矮的柱子决定。如果你移动较高的柱子,宽度变小了,高度不变或变小,面积只会更小。所以移动较矮的柱子才有机会找到更大的面积。
五、知识体系总览
下面这张图把双指针的三种类型和对应的经典例题串起来了,方便你复习时快速回忆。
六、总结
双指针这东西,说白了就是让两个指针在数据结构里配合着走。对撞指针适合有序数组,快慢指针适合链表,滑动窗口适合子数组/子串问题。
我个人觉得,掌握双指针的关键在于理解指针移动的时机。什么时候移动?往哪个方向移动?移动多少?想清楚这三个问题,大部分双指针题都能迎刃而解。
嗯,今天就聊到这儿。记住模板,多练几道题,你会发现双指针其实挺有意思的。