双指针技巧:对撞指针、快慢指针、滑动窗口

双指针,说白了就是两个指针在数据结构里来回跑。听起来简单,但用好了能解决一大批让人头疼的问题。我个人习惯把双指针分成三类:对撞指针、快慢指针、滑动窗口。这三类各有各的脾气,咱们一个一个聊。

一、对撞指针

对撞指针,也叫左右指针。一个指针在最左边,一个在最右边,然后往中间走。什么时候用?数组是有序的时候,或者你需要从两端往中间逼近的时候。

经典场景:

  • 两数之和(有序数组版)
  • 三数之和
  • 盛最多水的容器
  • 反转字符串

我在项目中遇到过一个问题:需要从一个有序数组中找出所有和为 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;
}

为什么移动较矮的?因为水的高度由较矮的柱子决定。如果你移动较高的柱子,宽度变小了,高度不变或变小,面积只会更小。所以移动较矮的柱子才有机会找到更大的面积。

五、知识体系总览

下面这张图把双指针的三种类型和对应的经典例题串起来了,方便你复习时快速回忆。

双指针技巧 对撞指针 两数之和(有序) 三数之和 盛最多水的容器 快慢指针 判断链表是否有环 寻找链表中点 删除倒数第 n 个节点 滑动窗口 无重复字符的最长子串 长度最小的子数组 字符串的排列 核心:两个指针 + 一个循环 = O(n) 或 O(n²)

六、总结

双指针这东西,说白了就是让两个指针在数据结构里配合着走。对撞指针适合有序数组,快慢指针适合链表,滑动窗口适合子数组/子串问题。

我个人觉得,掌握双指针的关键在于理解指针移动的时机。什么时候移动?往哪个方向移动?移动多少?想清楚这三个问题,大部分双指针题都能迎刃而解。

嗯,今天就聊到这儿。记住模板,多练几道题,你会发现双指针其实挺有意思的。


公众号:蓝海资料掘金营,微信deep3321