文档目录

一、快慢指针——解决"环"与"中间位置"

核心思想:两个指针从同一位置出发,一个走得快(每次两步),一个走得慢(每次一步)。

场景 1:检测环形链表(LeetCode 141)

问题:给你一个链表,判断是否有环。

为什么快慢指针能检测环?

想象两个人在环形操场上跑步。快的人跑两步,慢的人跑一步。如果有环,快的人最终会"套圈"慢的人(再次相遇)。如果没环,快的人先到终点(nullptr)。

不带环的链表:
1 → 2 → 3 → 4 → 5 → nullptr
快指针先到 nullptr → 无环

带环的链表:
1 → 2 → 3 → 4 → 5 → 3(回到 3)
快指针走两步,慢指针走一步:
1: 快=3, 慢=2
2: 快=5, 慢=3
3: 快=3, 慢=4  ← 回到 3 了!
4: 快=5, 慢=5  ← 相遇!有环

整体操作流程:

  1. 边界检查:如果 head 为空或只有一个节点,不可能有环,直接返回 false。
  2. 初始化:快慢两个指针都指向链表头节点 head。
  3. 遍历移动:每次循环中,慢指针向前走一步,快指针向前走两步。
  4. 相遇检测:每走一步后,检查两个指针是否指向同一节点。如果是,说明快指针"套圈"了慢指针,链表有环,返回 true。
  5. 终止条件:如果快指针到达了链表末尾(fast == nullptr 或 fast->next == nullptr),说明链表无环,返回 false。
struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

bool hasCycle(ListNode* head) {
    if (!head || !head->next) return false;      // ① 空链表或单节点 → 无环

    ListNode* slow = head;                        // ② 慢指针从 head 出发
    ListNode* fast = head;                        // ③ 快指针也从 head 出发

    while (fast && fast->next) {                  // ④ 快指针没到终点就继续
        slow = slow->next;                        // ⑤ 慢指针走一步
        fast = fast->next->next;                  // ⑥ 快指针走两步

        if (slow == fast) {                       // ⑦ 快慢相遇 → 有环
            return true;
        }
    }

    return false;                                 // ⑧ 快指针到尽头 → 无环
}

时间复杂度:$O(N)$。有环时最多走一圈半就相遇;无环时 fast 先到终点。 空间复杂度:$O(1)$。只用两个指针。

场景 2:找链表中间节点

整体操作流程:

  1. 初始化:快慢指针都指向链表头。
  2. 同步移动:慢指针每次走一步,快指针每次走两步。
  3. 循环终止:当快指针到达末尾(fast == nullptr 或 fast->next == nullptr)时停止。
  4. 返回结果:此时慢指针正好指向链表的中间节点。

为什么 slow 正好在中间?

  • 奇数长度:1→2→3→4→5,fast 停在 5,slow 停在 3(正中间)。
  • 偶数长度:1→2→3→4,fast 停在 nullptr,slow 停在 3(后半段起点)。
ListNode* middleNode(ListNode* head) {
    ListNode* slow = head;                        // ① 慢指针从 head 出发
    ListNode* fast = head;                        // ② 快指针也从 head 出发

    while (fast && fast->next) {                  // ③ 快指针能走两步就继续
        slow = slow->next;                        // ④ 慢指针走一步
        fast = fast->next->next;                  // ⑤ 快指针走两步
    }

    return slow;                                  // ⑥ slow 正好在中间
}

二、对撞指针——解决"两数之和"类问题

核心思想:两个指针分别指向数组的两端,向中间移动。适合有序数组。

场景 1:盛最多水的容器(LeetCode 11)

问题:数组 height 表示垂线高度,找出两条线和 x 轴组成的容器能装最多水。

直观解法:穷举所有 (i, j) 组合,$O(N^2)$ 太慢。

对撞指针思路:

height = [1, 8, 6, 2, 5, 4, 8, 3, 7]

初始:left=0 (高度1), right=8 (高度7)
面积 = min(1,7) × (8-0) = 1×8 = 8
移动较矮的那一侧:left++ → left=1 (高度8)

现在:left=1 (高度8), right=8 (高度7)
面积 = min(8,7) × (8-1) = 7×7 = 49  ← 最大!
移动较矮的那一侧:right-- → right=7 (高度3)

继续...

整体操作流程:

  1. 初始化:左指针指向数组开头(left = 0),右指针指向数组末尾(right = len-1)。
  2. 计算面积:当前容器的宽度 = right - left,高度 = 两根垂线中较矮的那根,面积 = 高 × 宽。
  3. 更新最大值:用当前面积更新全局最大面积。
  4. 移动指针:移动较矮的那一侧——如果 height[left] < height[right],左指针右移;否则右指针左移。
  5. 重复:重复步骤 2-4,直到左右指针相遇。
  6. 返回:返回记录的最大面积。

为什么移动较矮的那一侧? 如果移动较高的那一侧,新高度 ≤ 原高度,且宽度减少,面积一定变小。移动较矮的一侧才有机会遇到更高的柱子,让面积增大。

int maxArea(vector<int>& height) {
    int left = 0;                               // ① 左指针指向开头
    int right = height.size() - 1;               // ② 右指针指向末尾
    int max_area = 0;

    while (left < right) {                       // ③ 指针未相遇时循环
        int h = min(height[left], height[right]);// ④ 取较矮的高度
        int w = right - left;                    // ⑤ 计算宽度
        max_area = max(max_area, h * w);         // ⑥ 更新最大面积

        if (height[left] < height[right]) {      // ⑦ 移动较矮的一侧
            left++;
        } else {
            right--;
        }
    }

    return max_area;
}

为什么移动较矮的那一侧? 假设 height[left] < height[right],当前面积 = height[left] × (right-left)。

如果移动 right(较高侧):

  • 新高度 ≤ height[right],但 height[left] 不变。
  • 宽度减少,高度 ≤ 原高度,面积一定变小。

如果移动 left(较矮侧):

  • 新高度可能变大,宽度减少,面积可能变大。
  • 虽然不一定变大,但至少有变大的可能。

时间复杂度:$O(N)$。左右指针总共走 N 步。

场景 2:三数之和(LeetCode 15)

问题:数组中找到所有 a + b + c = 0 的三元组,不能重复。

nums = [-1, 0, 1, 2, -1, -4]

排序后:[-4, -1, -1, 0, 1, 2]

固定第一个数 i,用对撞指针找剩下的两个:
i=0 (-4): 需要在 [-1, -1, 0, 1, 2] 中找两数和为 4
  对撞指针:left=1 (-1), right=5 (2) → sum=-1+2=1,太小 → left++
            left=2 (-1), right=5 (2) → sum=1,太小 → left++
            left=3 (0),  right=5 (2) → sum=2,太小 → left++
            left=4 (1),  right=5 (2) → sum=3 → 找不到

i=1 (-1): 需要在 [-1, 0, 1, 2] 中找两数和为 1
  对撞指针:left=2 (-1), right=5 (2) → sum=1 ✅ 找到 (-1, -1, 2)
            left=3 (0),  right=4 (1) → sum=1 ✅ 找到 (-1, 0, 1)

整体操作流程:

  1. 排序:先将数组从小到大排序,这是后续使用对撞指针的前提。
  2. 固定第一个数:遍历数组,将 nums[i] 作为三元组的第一个数。
  3. 去重:如果 nums[i] 和前一个数相同,跳过(避免重复三元组)。
  4. 对撞指针找剩下两个:在 [i+1, n-1] 区间内:
    • 计算 target = -nums[i],问题转化为"找两数之和等于 target"。
    • 左指针指向 i+1,右指针指向末尾。
    • 如果 nums[left] + nums[right] == target:记录结果,跳过重复元素,同时收缩指针。
    • 如果和小于 target:左指针右移(增大和)。
    • 如果和大于 target:右指针左移(减小和)。
  5. 返回结果集。
vector<vector<int>> threeSum(vector<int>& nums) {
    sort(nums.begin(), nums.end());              // ① 排序(对撞指针的前提)
    vector<vector<int>> result;

    for (int i = 0; i < (int)nums.size() - 2; i++) {// ② 固定第一个数
        if (i > 0 && nums[i] == nums[i-1]) continue; // ③ 跳过重复的第一个数

        int left = i + 1;                        // ④ 左指针从 i+1 开始
        int right = nums.size() - 1;              // ⑤ 右指针从末尾开始
        int target = -nums[i];                    // ⑥ 需要找两数和为 -nums[i]

        while (left < right) {                    // ⑦ 对撞指针找两数
            int sum = nums[left] + nums[right];

            if (sum == target) {                  // ⑧ 找到一组解
                result.push_back({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 < target) {
                left++;                           // ⑩ 和太小 → 左指针右移
            } else {
                right--;                          // ⑪ 和太大 → 右指针左移
            }
        }
    }
    return result;
}

时间复杂度:$O(N^2)$。排序 $O(N\log N)$ + 外层循环 $O(N)$ × 内层对撞 $O(N)$。 如何避免重复:排序 + 跳过相邻的相同元素。


三、面试追问 Q&A

Q1:快慢指针中,为什么 fast 要走两步而不是三步? A:三步可能导致快指针跳过慢指针(比如环很小),而且步幅越大越容易在边界处出错。两步是最安全的——保证每走一次,快慢指针的距离 ±1。

Q2:对撞指针为什么要求数组有序? A:无序时,移动 left 或 right 你无法预测新的 sum 是变大还是变小。有序时,left++ 一定让 sum 增大(或不变),right– 一定让 sum 减小,你才能根据 sum 与 target 的关系决定移动方向。

Q3:三数之和能不能不用排序? A:排序是为了让对撞指针工作 + 去重。不排序需要用哈希表(两数之和的扩展),处理去重更复杂,时间仍然是 $O(N^2)$ 但空间 $O(N)$。排序法空间 $O(1)$ 更优。