刨析双指针(Two Pointers)本质:从碰撞、快慢到滑动窗口的降维打击

刨析双指针(Two Pointers)本质:从碰撞、快慢到滑动窗口的降维打击

在算法竞赛(如 ICPC、LeetCode、洛谷等)中,有一种技巧由于其代码极其精简、时间复杂度往往能从 \(O(N^2)\) 降维到 \(O(N)\),而被奉为“基础神器”——它就是双指针(Two Pointers)。

必须强调的是,算法意义上的“双指针”并不一定是 C/C++ 中操作内存地址的真指针(Pointer),它更多指代的是两个充当索引(Index)的游标。通过这两个游标在数组或链表中的协同移动,我们可以巧妙地利用数据的单调性或局部连续性,避免大量无效的重复枚举。

本文将剥开双指针的表象,将其分为三大核心变种:碰撞指针、快慢指针与滑动窗口,并提供极致精简的 C++ 实战代码。

一、 碰撞指针(对撞指针):单调性与收缩的艺术

适用信号:在已排序的数组中寻找满足某种组合条件(如两数之和、盛水最多等)的元素对。

1. 核心思想

设置两个指针,分别指向数组的头部(left = 0)和尾部(right = n - 1)。 由于数组是有序的,每一次将 left 右移,都会使两数之和变大;每一次将 right 左移,都会使两数之和变小。 根据当前两数之和与目标值的比较结果,我们可以每次确定性地排除掉一行或一列的无效组合,从而将原本 \(O(N^2)\) 的枚举压缩到 \(O(N)\)。

2. C++ 实战:两数之和 (有序数组)

#include <iostream>
#include <vector>

using namespace std;

// 在升序数组 nums 中寻找两个数,使它们相加等于 target,返回它们的下标
vector<int> twoSumSorted(const vector<int>& nums, int target) {
    int left = 0;
    int right = nums.size() - 1;

    while (left < right) { // 注意是 <,不能是 <=,因为通常不能选同一个元素两次
        int sum = nums[left] + nums[right];
        
        if (sum == target) {
            return {left, right}; // 找到目标,直接返回
        } else if (sum < target) {
            left++;  // 当前和太小,左指针右移以增大和
        } else {
            right--; // 当前和太大,右指针左移以减小和
        }
    }
    return {-1, -1}; // 未找到
}

二、 快慢指针:链表循环与步频差距

适用信号:链表判环、寻找链表中点、原地删除数组中的重复元素等。

1. 核心思想

设置两个指针(fast 和 slow),它们都从同一侧起点出发,但移动的速度或者步长不同。

  • 判环/找中点:fast 每次走两步,slow 每次走一步。如果链表有环,它们迟早会相遇;如果没环,当 fast 走到尽头时,slow 正好在中间。
  • 原地去重:fast 负责在前面探路,寻找新的、未重复的元素;slow 负责在后面记录合法元素应该停靠的位置。

2. C++ 实战:原地删除有序数组的重复项

给定一个升序排列的数组,原地删除重复出现的元素,使得每个元素只出现一次,返回删除后数组的新长度。

#include <iostream>
#include <vector>

using namespace std;

int removeDuplicates(vector<int>& nums) {
    if (nums.empty()) return 0;

    int slow = 0; // slow 记录的是最后一个已经去重的元素的下标
    
    // fast 负责在前面探路
    for (int fast = 1; fast < nums.size(); fast++) {
        // 当发现一个新元素(与上一个保留的元素不同)
        if (nums[fast] != nums[slow]) {
            slow++;               // 腾出下一个位置
            nums[slow] = nums[fast]; // 将新元素放进来
        }
    }
    
    // 数组的新长度是 slow 下标 + 1
    return slow + 1;
}

三、 滑动窗口:子数组的动态伸缩

适用信号:求解满足某种条件的连续子数组或子串(如最长无重复子串、和大于等于 target 的最短子数组等)。

1. 核心思想

滑动窗口是双指针中极其重要、也是最容易考察的高阶形态。它维护了一个 [left, right] 的闭区间(即窗口)。

  • 扩张阶段:right 指针主动向右移动,把新元素拉进窗口,直到窗口内的状态满足了题目条件(或者破坏了某种条件)。
  • 收缩阶段:此时,left 指针开始向右移动,把老元素踢出窗口,试图在满足条件的前提下寻找更优解(如寻找最短长度),直到窗口状态不再满足条件。 重复扩张与收缩,就像一个毛毛虫在数组上爬行。每个元素最多进窗口一次、出窗口一次,因此整体复杂度依然是严格的 \(O(N)\)。

2. C++ 实战:长度最小的子数组

给定一个含有 \(n\) 个正整数的数组和一个正整数 target。找出该数组中满足其和 \(\ge target\) 的长度最小的连续子数组,并返回其长度。

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int minSubArrayLen(int target, const vector<int>& nums) {
    int left = 0;
    int sum = 0;       // 维护窗口内的和
    int min_len = 1e9; // 记录最小长度

    // right 负责不断扩张窗口
    for (int right = 0; right < nums.size(); right++) {
        sum += nums[right]; // 新元素进窗口

        // 当窗口满足条件时,尝试收缩左边界,榨干最小长度
        while (sum >= target) {
            min_len = min(min_len, right - left + 1); // 更新最优解
            sum -= nums[left]; // 左端点元素出窗口
            left++;            // 收缩左边界
        }
    }

    return min_len == 1e9 ? 0 : min_len;
}

四、 总结与避坑指南

无论是哪种双指针,其本质都是利用了题目的单调性:

  • 碰撞指针利用的是排序后元素数值分布的单调性。
  • 快慢指针去重利用的是相同元素连续分布的局部单调性。
  • 滑动窗口利用的是随着元素增加,区间和或区间内状态的单调性(这也是为什么普通的滑动窗口不能处理包含负数的子数组和问题)。

避坑铁律: 在编写双指针(特别是滑动窗口)代码时,永远要先想清楚两件事:

  1. 什么条件下右指针进?(扩张)
  2. 什么条件下左指针缩?(收缩) 想清楚这两个判定边界,你就能避开 99% 的死循环和越界 Bug,在 \(O(N)\) 的世界里游刃有余。
posted @ 2026-11-10 11:29  阿尹想学会C++  阅读(1)  评论(0)    收藏  举报