对撞指针入门:有序数组中的两数之和

摘要

对撞指针是最容易上手的双指针模型:两个指针从数组两端出发,根据当前两端的和与目标值的大小关系,决定哪一端内缩。本文从 Two Sum II 出发,严格证明对撞指针的正确性(为什么每次移动不会错过答案),然后推广到 Valid Palindrome 和 Reverse String,揭示对撞指针在”从两端向中间消解”问题中的统一模式。


第 1 章 问题引入:无序版 Two Sum 的局限

1.1 LeetCode 1 vs LeetCode 167

LeetCode 1(Two Sum)和 LeetCode 167(Two Sum II)看起来极其相似,但有一个关键差别:

LeetCode 1(Two Sum)LeetCode 167(Two Sum II)
输入无序数组升序数组(保证有序)
返回下标(0-indexed)下标(1-indexed)
标准解法哈希表, 时间, 空间对撞指针, 时间, 空间

LeetCode 1 用哈希表:扫描数组,对每个元素 x,检查 target - x 是否在哈希表中,不在则将 x 加入哈希表。时间 ,但空间

LeetCode 167 的输入已经有序,这个额外的保证让我们可以用 空间的对撞指针。有序性才是对撞指针的关键,不是题目本身。

1.2 为什么有序才能用对撞指针

先看一个对比实验。给无序数组 [3, 1, 4, 2],target = 5,从两端出发:

  • left=0(3), right=3(2),和为 5,返回。(巧合正确)

再看 [3, 1, 4, 2],target = 3:

  • left=0(3), right=3(2),和为 5 > 3,right--
  • left=0(3), right=2(4),和为 7 > 3,right--
  • left=0(3), right=1(1),和为 4 > 3,right--
  • left=0(3), right=0left == right,循环结束,输出”无解”

但实际上 nums[1] + nums[3] = 1 + 2 = 3 是有解的!对撞指针在无序数组上直接失效。

失效的根本原因:在无序数组中,right-- 之后新的 nums[right] 不一定更小,“和偏大就缩小右端”的推论失去依据。


第 2 章 LeetCode 167:两数之和 II

2.1 题目

LeetCode 167 - Two Sum II - Input Array Is Sorted(中等)

给你一个下标从 1 开始的整数数组 numbers,该数组已按 非递减顺序 排列。请你从数组中找出满足相加之和等于目标数 target 的两个数。设这两个数分别是 numbers[index1]numbers[index2],其中 1 <= index1 < index2 <= numbers.length

以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标,index1index2 从 1 开始

你可以假设每个输入只对应唯一的答案,且你不可以重复使用相同的元素。你所设计的解决方案必须只使用常量级额外空间。

输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 + 7 = 9。因此 index1 = 1, index2 = 2。

输入:numbers = [2,3,4], target = 6
输出:[1,3]

输入:numbers = [−1,0], target = −1
输出:[1,2]

2.2 对撞指针解法

public int[] twoSum(int[] numbers, int target) {
    int left = 0, right = numbers.length - 1;
 
    while (left < right) {
        int sum = numbers[left] + numbers[right];
 
        if (sum == target) {
            return new int[]{left + 1, right + 1};  // 转换为 1-indexed
        } else if (sum < target) {
            left++;   // 和偏小,左指针右移,换一个更大的数
        } else {
            right--;  // 和偏大,右指针左移,换一个更小的数
        }
    }
 
    return new int[]{-1, -1};  // 题目保证有解,实际不会到这里
}

时间复杂度:;空间复杂度:

代码极简,但其正确性需要严格证明。

2.3 正确性证明:为什么对撞指针不会错过答案?

这是面试中常被追问的关键问题。直觉上很明显,但能说清楚证明过程的人并不多。

命题:若数组中存在下标对 )满足 numbers[i*] + numbers[j*] == target,对撞指针算法一定能找到它(或找到另一个合法的答案对)。

证明(反证法)

假设对撞指针在某步执行了 left++(即从 left 移到 left+1),此时 left == lright == r,且 numbers[l] + numbers[r] < target

我们想证明:此步操作不会错过以 l 为左端点的任何解。

由于数组有序,numbers[l] + numbers[r'] ≤ numbers[l] + numbers[r] < target。因此,以 l 为左端点、任意 为右端点的所有组合,和都小于 target,都不是解。

对撞指针在执行 left++ 之前,right 是当前最大的右端点 ;以 为左端点的所有可能右端点(包括 在内所有更小的右端点)的和都 。因此,以 为左端点的所有对都不是解,可以安全地将 l 排除,即 left++

对称地,若 numbers[l] + numbers[r] > target,则对任意 numbers[i] + numbers[r] >= numbers[l] + numbers[r] > target,以 为右端点的所有对也都不是解,可以安全 right--

结论:每次 left++right-- 都排除了当前左端或右端的所有合法组合,不遗漏任何解。

核心概念:排除的逻辑是行/列级别的

left++ 排除的是”以当前 left 为左端点的所有可能右端点”(整行);right-- 排除的是”以当前 right 为右端点的所有可能左端点”(整列)。两种操作合计排除量 ,而指针总移动步数也是 ,因此时间复杂度是

2.4 边界条件分析

边界一:while (left < right) 还是 while (left <= right)

题目要求两个数的下标不同(index1 < index2),所以 leftright 不能相等。用 left < right 确保循环内 left != right,自然避免”同一个元素用两次”的问题。

边界二:数组中存在负数时是否影响正确性?

不影响。对撞指针的正确性证明只依赖”数组有序”,不要求元素为正数。负数完全可以处理,如样例 [-1, 0],target = -1。

边界三:只有两个元素时

left = 0right = 1,直接检查 numbers[0] + numbers[1],正好等于 target 就返回 [1, 2],否则退出循环(因为 left < right 不成立)。题目保证有解,所以不会走到返回 -1 的分支。


第 3 章 LeetCode 125:验证回文串

3.1 题目

LeetCode 125 - Valid Palindrome(简单)

如果在将所有大写字符转换为小写字符、并移除所有非字母数字字符之后,短语正着读和反着读都一样,则可以认为该短语是一个 回文串

字母和数字都属于字母数字字符。

给你一个字符串 s,如果它是 回文串,返回 true;否则,返回 false

输入: s = "A man, a plan, a canal: Panama"
输出:true
解释:"amanaplanacanalpanama" 是回文串。

输入:s = "race a car"
输出:false

输入:s = " "
输出:true
解释:在移除非字母数字字符之后,s 是一个空字符串 ""。空字符串正着反着读都一样,所以是回文串。

3.2 解题思路

回文判断是对撞指针的另一个经典场景。两个指针从两端出发,依次向中间移动,比较字符是否相等。

本题的额外挑战是:需要忽略非字母数字字符和大小写。处理策略:

  1. left 从左向右跳过非字母数字字符
  2. right 从右向左跳过非字母数字字符
  3. 双方都停下来之后,比较字符(忽略大小写)
  4. 不相等则返回 false;相等则继续
public boolean isPalindrome(String s) {
    int left = 0, right = s.length() - 1;
 
    while (left < right) {
        // 左指针跳过非字母数字字符
        while (left < right && !Character.isLetterOrDigit(s.charAt(left))) {
            left++;
        }
        // 右指针跳过非字母数字字符
        while (left < right && !Character.isLetterOrDigit(s.charAt(right))) {
            right--;
        }
 
        // 比较(忽略大小写)
        if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
            return false;
        }
        left++;
        right--;
    }
    return true;
}

时间复杂度,每个字符最多被访问两次(一次被跳过,一次被比较)。 空间复杂度,原地操作,不需要先构造去除非字母数字字符后的新字符串。

3.3 为什么不先预处理字符串再判断?

另一种思路:先用一次遍历过滤出字母数字字符,构造新字符串 filtered,再直接比较 filteredfiltered.reverse()

这个思路完全正确,但空间复杂度是 (新字符串)。对撞指针的优势正是 空间——直接在原字符串上操作。

在空间敏感的场景(如处理超大字符串流)下, 空间的对撞指针明显优于 空间的预处理方案。

3.4 边界条件分析

边界一:空字符串或全是非字母数字字符

s = " ",左右跳过非字母数字字符后 left >= right,循环不进入,返回 true。这符合题意(空字符串是回文)。

边界二:内层 while 的边界

while (left < right && ...) 中的 left < right 判断不能省略。假设字符串全是非字母数字字符(如 "!!!"),如果不判断 left < right,左指针会一直向右越界。

边界三:奇数长度字符串

"aba"left=0(a), right=2(a) 相等,left=1, right=1left < right 为假,退出循环,返回 true。中间的字符 b 不需要判断,正确。


第 4 章 LeetCode 344:反转字符串

4.1 题目

LeetCode 344 - Reverse String(简单)

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。

不要给另外的数组分配额外的空间,你必须原地修改输入数组,使用 的额外空间解决这一问题。

输入:s = ['h','e','l','l','o']
输出:['o','l','l','e','h']

输入:s = ['H','a','n','n','a','h']
输出:['h','a','n','n','a','H']

4.2 解法

反转字符串是对撞指针最基础的应用:两个指针从两端出发,交换元素,相向移动。

public void reverseString(char[] s) {
    int left = 0, right = s.length - 1;
    while (left < right) {
        char temp = s[left];
        s[left] = s[right];
        s[right] = temp;
        left++;
        right--;
    }
}

这道题本身足够简单,但它揭示了对撞指针的原型操作:“从两端操作,向中间收缩”

4.3 从反转到回文:统一的视角

反转字符串和判断回文,本质上是同一个对撞指针框架:

操作反转字符串判断回文
初始化left=0, right=n-1left=0, right=n-1
每次操作交换 s[left]s[right]比较 s[left]s[right]
终止条件left >= rightleft >= right 或不相等

理解了这个统一框架,遇到”从两端向中间处理”的问题,首先想到对撞指针。


第 5 章 变体:有序数组的其他对撞指针问题

5.1 平方后有序数组(LeetCode 977)

LeetCode 977 - Squares of a Sorted Array(简单)

给你一个按 非递减顺序 排序的整数数组 nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。

输入:nums = [-4,-1,0,3,10]
输出:[0,1,9,16,100]

直觉:平方后,原来的最大正数和最小负数(绝对值最大的负数)的平方是最大的,即”最大的平方在两端”。用对撞指针从两端取较大的平方,填入结果数组(从后往前填)。

public int[] sortedSquares(int[] nums) {
    int n = nums.length;
    int[] result = new int[n];
    int left = 0, right = n - 1;
    int pos = n - 1;  // 从结果数组末尾开始填
 
    while (left <= right) {
        int leftSq = nums[left] * nums[left];
        int rightSq = nums[right] * nums[right];
        if (leftSq > rightSq) {
            result[pos--] = leftSq;
            left++;
        } else {
            result[pos--] = rightSq;
            right--;
        }
    }
    return result;
}

时间复杂度(比先平方再排序的 更优)。

这道题的特别之处在于:对撞指针的”单调性”来源不是”和偏大则收缩右端”,而是”平方值在两端最大,从两端取较大值”。不同的单调性,相同的框架。

5.2 移动零(LeetCode 283)

LeetCode 283 - Move Zeroes(简单)

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

这道题通常用同向双指针(也叫快慢指针在数组上的应用):slow 指向下一个应该填写非零元素的位置,fast 扫描数组找非零元素。

public void moveZeroes(int[] nums) {
    int slow = 0;  // 指向下一个应该填写非零元素的位置
    for (int fast = 0; fast < nums.length; fast++) {
        if (nums[fast] != 0) {
            // 将非零元素放到 slow 位置
            int temp = nums[slow];
            nums[slow] = nums[fast];
            nums[fast] = temp;
            slow++;
        }
    }
}

注意这里不是对撞指针,而是同向双指针(slow 和 fast 都从左向右)。但其核心思路依然是”fast 扫描,slow 维护结果区间”。

这提示了一个重要的模式:双指针不一定要从两端出发,“同向双指针”在原地去重/过滤类问题中同样常用。


第 6 章 对撞指针的核心边界总结

6.1 三个必须确认的边界条件

使用对撞指针时,以下三个问题必须在写代码前想清楚:

问题一:循环条件是 left < right 还是 left <= right

  • 找两个不同位置的元素:用 left < right,避免同一元素使用两次
  • 两端向中间消解(如反转字符串):用 left < right,奇数长度时中间元素不需处理
  • 特殊情况下 left == right 也需要处理:用 left <= right(较少见)

问题二:循环体内移动指针后是否需要 continue

在 Two Sum II 中,找到答案后直接 return,不需要 continue。但如果有重复元素需要去重(如 3Sum),找到答案后还需要跳过重复元素再继续循环,逻辑更复杂(详见下一篇)。

问题三:输入数组是否保证非空?

numbers.length >= 2 是 Two Sum II 的题目保证,如果输入可能为空或只有一个元素,需要额外的边界检查。面试中主动询问或添加防御性判断。

6.2 常见错误模式

错误一:在无序数组上直接用对撞指针

未经排序的数组不具备全局有序性,对撞指针的正确性证明失效,会产生错误答案。解决方案:先排序(),再用对撞指针。

错误二:漏了”跳过非合法字符”的内层循环边界

在 Valid Palindrome 中,内层 while 跳过非字母数字字符时,必须同时判断 left < right,否则在全非字母数字字符的字符串上会越界。

错误三:返回值是 1-indexed 时忘记 +1

Two Sum II 要求返回 1-indexed 下标,忘记 +1 是常见的低级错误。写代码时用注释标注清楚。


小结

对撞指针的精髓可以用一句话概括:利用有序性,每次移动一端,永久消除以当前端点为左/右端点的所有组合。

这篇文章覆盖了三道题:

  • Two Sum II:最标准的对撞指针模板,含正确性证明
  • Valid Palindrome:对撞指针结合字符过滤,注意内层循环边界
  • Reverse String:对撞指针最基础的原型操作

下一篇将把对撞指针推广到三个数甚至四个数的情形(3Sum、4Sum),核心思路是固定最外层循环,把多数之和问题逐步降维为两数之和问题。去重逻辑是那里最需要仔细处理的细节。