【笔面试算法学习专栏】双指针专题·简单难度两题精讲:167.两数之和II、283.移动零

本文聚焦两个简单难度但极具代表性的题目:167.两数之和II与283.移动零。通过深入剖析这两题的经典解法,帮助读者掌握双指针技术的核心思想与应用场景,为后续中等、困难难度题目打下坚实基础。

一、引言:为什么从简单难度开始?

双指针(Two Pointers)是算法竞赛与面试中最高频的技巧之一,能够在 O ( n ) O(n) O(n) 时间内解决许多看似复杂的数组与链表问题。但对于初学者而言,直接挑战中等或困难难度的题目往往容易陷入细节泥潭,反而忽略了算法思想的本质。

因此,本专题采用由浅入深、循序渐进的教学策略,从简单难度题目入手,帮助读者:

  1. 建立直觉:理解双指针移动的基本逻辑与边界条件
  2. 掌握模板:熟悉对撞指针、快慢指针等经典模式
  3. 避免误区:识别常见错误,养成严谨的编码习惯

今天的两道题目虽标注为"简单",但蕴含了双指针技术的精髓,是通往更高阶算法的必经之路。

二、题目一:167.两数之和II - 输入有序数组

2.1 题目概述与链接

题目链接167. 两数之和 II - 输入有序数组

问题描述

给定一个下标从 1 开始的整数数组 numbers,该数组已按非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index₁]numbers[index₂],则 1 ≤ i n d e x 1 < i n d e x 2 ≤ n u m b e r s . l e n g t h 1 \le index₁ < index₂ \le numbers.length 1≤index1<index2≤numbers.length。

以长度为 2 的整数数组 [index₁, index₂] 的形式返回这两个整数的下标 index₁index₂

关键约束

  • 数组已排序(非递减)
  • 必须使用常量级额外空间( O ( 1 ) O(1) O(1) 空间复杂度)
  • 答案唯一
  • 不能重复使用相同元素

示例

python 复制代码
输入:numbers = [2,7,11,15], target = 9
输出:[1,2]  # 2 + 7 = 9,下标转换为1-based

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

输入:numbers = [-1,0], target = -1
输出:[1,2]  # -1 + 0 = -1

2.2 核心解题思路:双指针两端逼近法

2.2.1 算法思想

本题的核心突破口在于数组已排序 。对于有序数组,我们可以使用两个指针分别指向数组的起始位置末尾位置,通过比较两数之和与目标值的大小关系,动态调整指针位置,逐步逼近正确答案。

算法步骤

  1. 初始化 :左指针 left = 0(指向数组起始),右指针 right = len(numbers)-1(指向数组末尾)
  2. 循环条件 :当 left < right 时继续搜索(确保两个指针不指向同一元素)
  3. 计算和值sum = numbers[left] + numbers[right]
  4. 比较与移动
    • sum == target:找到答案,返回 [left+1, right+1](转换为1-based下标)
    • sum < target:说明当前和偏小,需要增大较小数 → 左指针右移left++
    • sum > target:说明当前和偏大,需要减小较大数 → 右指针左移right--
  5. 终止:题目保证有唯一解,循环必然找到答案
2.2.2 正确性证明

为什么这种"两端逼近"的方法不会错过正确答案?

假设正确答案是 numbers[i] + numbers[j] = target,其中 0 ≤ i < j ≤ n − 1 0 \le i < j \le n-1 0≤i<j≤n−1。

  • 初始时,left = 0 ≤ iright = n-1 ≥ j
  • 如果 left 先到达 i,此时 right 仍在 j 右侧,那么 sum = numbers[i] + numbers[right] > numbers[i] + numbers[j] = target,因此会执行 right--(右指针左移),left 不会越过 i
  • 如果 right 先到达 j,此时 left 仍在 i 左侧,那么 sum = numbers[left] + numbers[j] < numbers[i] + numbers[j] = target,因此会执行 left++(左指针右移),right 不会越过 j

因此,在整个移动过程中,left 不可能越过 iright 不可能越过 j,最终必然在 left = iright = j 时找到答案。

:本题的"答案唯一"假设至关重要。如果存在多组解,双指针方法只能找到其中一组(通常是最左/最右组合),但题目明确保证唯一性,因此算法完全适用。

2.2.3 边界条件处理
  • 负数与零:算法完全兼容,因为比较的是和值大小而非元素正负
  • 大数溢出 :题目限制元素范围在 [-1000, 1000] 内,和值不会溢出32位整数
  • 数组长度最小为2:无需特殊处理空数组或单元素数组

2.3 时间复杂度与空间复杂度分析

2.3.1 时间复杂度: O ( n ) O(n) O(n)
  • 最坏情况下,左指针从0移动到n-1,右指针从n-1移动到0,总移动次数不超过 n n n 次
  • 每次循环执行常数时间操作(加法、比较、指针移动)
  • 因此整体时间复杂度为线性 O ( n ) O(n) O(n)
2.3.2 空间复杂度: O ( 1 ) O(1) O(1)
  • 仅使用了两个指针变量 leftright,以及临时变量 sum
  • 没有使用额外数据结构,满足题目"常量级额外空间"要求

:如果采用哈希表解法(无序数组的两数之和标准解法),空间复杂度为 O ( n ) O(n) O(n),不满足本题约束。因此双指针法是本题的最优解

2.4 Python代码实现

python 复制代码
from typing import List

class Solution:
    def twoSum(self, numbers: List[int], target: int) -> List[int]:
        """
        双指针两端逼近法求解两数之和II
        时间复杂度:O(n),空间复杂度:O(1)
        """
        # 初始化双指针:左指针指向起始,右指针指向末尾
        left, right = 0, len(numbers) - 1
        
        # 当左指针小于右指针时继续搜索
        while left < right:
            current_sum = numbers[left] + numbers[right]
            
            if current_sum == target:
                # 找到答案,转换为1-based下标返回
                return [left + 1, right + 1]
            elif current_sum < target:
                # 和偏小,左指针右移以增大和值
                left += 1
            else:  # current_sum > target
                # 和偏大,右指针左移以减小和值
                right -= 1
        
        # 题目保证有解,此行理论上不会执行
        return [-1, -1]


# 测试代码
if __name__ == "__main__":
    solution = Solution()
    
    # 示例测试
    print(solution.twoSum([2, 7, 11, 15], 9))   # 输出: [1, 2]
    print(solution.twoSum([2, 3, 4], 6))        # 输出: [1, 3]
    print(solution.twoSum([-1, 0], -1))         # 输出: [1, 2]
    
    # 边界测试
    print(solution.twoSum([1, 2, 3, 4], 5))     # 输出: [1, 4]
    print(solution.twoSum([0, 0, 3, 4], 0))     # 输出: [1, 2]

代码要点解析

  1. 指针初始化left = 0, right = len(numbers)-1,覆盖数组两端
  2. 循环条件left < right 确保不重复使用同一元素
  3. 和值计算:每次循环计算当前两数之和
  4. 条件分支
    • 相等 → 返回1-based下标
    • 偏小 → left++(增大和值)
    • 偏大 → right--(减小和值)
  5. 兜底返回:理论上不会执行,但保持代码完整性

:Python的列表索引是0-based,但题目要求返回1-based下标,因此在返回时需要将指针下标加1。这是本题最常见的疏忽点之一。

2.5 优化思路与变式讨论

2.5.1 二分查找法(备选解法)

对于有序数组,另一种思路是固定一个数,在剩余部分进行二分查找

python 复制代码
def twoSum_binary(numbers: List[int], target: int) -> List[int]:
    n = len(numbers)
    for i in range(n):
        complement = target - numbers[i]
        # 在i+1到n-1范围内二分查找complement
        left, right = i + 1, n - 1
        while left <= right:
            mid = left + (right - left) // 2
            if numbers[mid] == complement:
                return [i + 1, mid + 1]
            elif numbers[mid] < complement:
                left = mid + 1
            else:
                right = mid - 1
    return [-1, -1]

复杂度分析

  • 时间复杂度: O ( n log ⁡ n ) O(n \log n) O(nlogn),外层循环 n n n 次,每次二分查找 O ( log ⁡ n ) O(\log n) O(logn)
  • 空间复杂度: O ( 1 ) O(1) O(1)

对比双指针法

  • 双指针: O ( n ) O(n) O(n) 时间,更优
  • 二分查找: O ( n log ⁡ n ) O(n \log n) O(nlogn) 时间,但思路直观,适合初学者理解
2.5.2 哈希表法(不满足约束)

如果数组未排序,标准解法是使用哈希表:

python 复制代码
def twoSum_hash(numbers: List[int], target: int) -> List[int]:
    seen = {}  # 存储值到索引的映射
    for i, num in enumerate(numbers):
        complement = target - num
        if complement in seen:
            return [seen[complement] + 1, i + 1]
        seen[num] = i
    return [-1, -1]

但本题要求 O ( 1 ) O(1) O(1) 空间,因此哈希表解法不满足题目约束,仅作为对比参考。

2.6 面试考点与注意事项

2.6.1 高频考点
  1. 双指针移动逻辑:为什么和值偏小时左指针右移?为什么偏大时右指针左移?
  2. 正确性证明:如何证明算法不会错过正确答案?
  3. 边界处理:负数、零、重复元素如何处理?
  4. 空间复杂度 :为什么必须使用 O ( 1 ) O(1) O(1) 空间?哈希表为什么不行?
2.6.2 常见错误
  1. 下标转换错误:忘记将0-based下标转换为1-based下标
  2. 指针越界 :在移动指针时未检查 left < right 条件
  3. 忽略唯一性:假设多组解存在,导致逻辑复杂化
  4. 错误的空间优化:试图使用排序后恢复原序等复杂技巧
2.6.3 实战建议
  1. 先提问:确认数组是否真的有序?答案是否唯一?
  2. 画图辅助:在白板上画出指针移动过程,帮助面试官理解思路
  3. 测试用例:至少覆盖正数、负数、零、边界长度等场景
  4. 复杂度分析:明确说明时间/空间复杂度,并对比其他解法优劣

:在面试中,即使题目看起来简单,也要完整阐述算法思想、正确性证明和复杂度分析,这体现了工程师的严谨思维习惯。

三、题目二:283.移动零

3.1 题目概述与链接

题目链接283. 移动零

问题描述

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

请注意:必须在不复制数组的情况下原地对数组进行操作。

关键约束

  • 原地操作(不能创建新数组)
  • 保持非零元素的相对顺序(稳定)
  • 尽量减少操作次数(进阶要求)

示例

python 复制代码
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]

输入: nums = [0]
输出: [0]

进阶:你能尽量减少完成的操作次数吗?

3.2 核心解题思路:快慢指针(读写指针)技术

3.2.1 算法思想

本题的核心是将数组原地分区:将所有非零元素移动到数组前端,零元素自然被挤到后端。由于要求保持非零元素的相对顺序,不能使用不稳定的分区算法(如快速排序的partition)。

快慢指针法(也叫读写指针法):

  • 快指针(fast):遍历数组的每个元素,负责"读取"当前元素
  • 慢指针(slow):指向下一个非零元素应该放置的位置,负责"写入"非零元素

算法步骤

  1. 初始化 :慢指针 slow = 0
  2. 遍历数组 :快指针 fast0n-1 遍历每个元素
  3. 处理非零元素
    • 如果 nums[fast] != 0:将 nums[fast] 赋值给 nums[slow],然后 slow++
    • 如果 nums[fast] == 0:跳过,快指针继续前进
  4. 补零操作 :遍历结束后,将 slown-1 的所有位置赋值为 0
2.2.2 正确性证明

为什么这种"快慢指针"方法能保持非零元素的相对顺序?

  • 稳定性保证:快指针从左到右遍历,遇到非零元素就按序复制到慢指针位置。由于遍历顺序与原始顺序一致,且复制操作不会改变元素间的前后关系,因此相对顺序保持不变。
  • 完整性保证:遍历结束后,所有非零元素都被复制到数组前端,原位置可能还保留着旧值,但随后的补零操作会覆盖这些残留值,确保最终结果正确。

:本题也可以使用交换法,即遇到非零元素时与慢指针位置交换。虽然交换法也能保持相对顺序(因为交换的是非零元素与零),但赋值法(复制+补零)通常操作次数更少,尤其是当非零元素较多时。

2.2.3 边界条件处理
  • 全零数组:遍历时不会复制任何元素,最后补零操作将整个数组置零(实际保持不变)
  • 全非零数组 :每个元素都会复制到原位置,补零操作不会执行(因为 slow == n
  • 单个元素:算法自然处理,无需特殊分支

3.3 时间复杂度与空间复杂度分析

3.3.1 时间复杂度: O ( n ) O(n) O(n)
  • 快指针遍历数组一次: O ( n ) O(n) O(n)
  • 补零操作遍历部分数组:最坏 O ( n ) O(n) O(n)
  • 总时间复杂度: O ( 2 n ) = O ( n ) O(2n) = O(n) O(2n)=O(n)
3.3.2 空间复杂度: O ( 1 ) O(1) O(1)
  • 仅使用两个指针变量 slowfast
  • 原地修改数组,没有额外空间开销

:如果采用交换法,时间复杂度同样是 O ( n ) O(n) O(n),但赋值次数可能更少(赋值法需要 m m m 次复制 + k k k 次补零,交换法需要 m m m 次交换,其中 m m m 是非零元素个数, k k k 是零元素个数)。具体哪种更优取决于语言特性和数据分布。

3.4 Python代码实现

3.4.1 标准解法(赋值+补零)
python 复制代码
from typing import List

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        """
        快慢指针法:赋值+补零
        时间复杂度:O(n),空间复杂度:O(1)
        不返回任何值,原地修改nums
        """
        n = len(nums)
        if n <= 1:
            return  # 长度0或1无需处理
        
        # 第一步:用慢指针收集所有非零元素
        slow = 0
        for fast in range(n):
            if nums[fast] != 0:
                nums[slow] = nums[fast]
                slow += 1
        
        # 第二步:将剩余位置补零
        for i in range(slow, n):
            nums[i] = 0


# 测试代码
if __name__ == "__main__":
    solution = Solution()
    
    # 示例测试
    nums1 = [0, 1, 0, 3, 12]
    solution.moveZeroes(nums1)
    print(nums1)  # 输出: [1, 3, 12, 0, 0]
    
    nums2 = [0]
    solution.moveZeroes(nums2)
    print(nums2)  # 输出: [0]
    
    # 边界测试
    nums3 = [1, 2, 3, 4]  # 全非零
    solution.moveZeroes(nums3)
    print(nums3)  # 输出: [1, 2, 3, 4]
    
    nums4 = [0, 0, 0]  # 全零
    solution.moveZeroes(nums4)
    print(nums4)  # 输出: [0, 0, 0]
3.4.2 优化解法(交换法)
python 复制代码
def moveZeroes_swap(nums: List[int]) -> None:
    """
    快慢指针交换法
    优点:无需补零操作,代码更简洁
    缺点:可能增加不必要的交换(当fast==slow时)
    """
    slow = 0
    for fast in range(len(nums)):
        if nums[fast] != 0:
            # 避免原地交换的微优化
            if fast != slow:
                nums[slow], nums[fast] = nums[fast], nums[slow]
            slow += 1

代码要点解析

  1. 双指针初始化slow 指向下一个非零元素应放置的位置,fast 遍历数组
  2. 非零处理 :遇到非零元素时,复制到 slow 位置(或交换),然后 slow++
  3. 补零操作:遍历结束后,剩余位置补零(赋值法特有)
  4. 原地修改:题目要求不返回任何值,直接修改原数组

:Python中交换两个变量的值可以使用 a, b = b, a,这是原子操作且效率很高。但在移动零问题中,当 fast == slow 时(即非零元素已经在正确位置),交换是多余的,因此可以添加 if fast != slow: 进行优化。

3.5 优化思路与变式讨论

3.5.1 减少操作次数

赋值法(复制+补零)与交换法的操作次数对比:

  • 赋值法 :非零元素复制 m m m 次 + 零元素写入 k k k 次,总操作 m + k = n m + k = n m+k=n 次
  • 交换法 :交换 m m m 次(每次交换涉及3次赋值),总操作 3 m 3m 3m 次

当 k k k 较大时(零较多),赋值法更优;当 m m m 较大时(非零较多),交换法更优。但实际差异不大,两种方法在面试中都可接受。

3.5.2 一次遍历交换法(最优)

结合赋值与交换的优点,可以在一次遍历中完成:

python 复制代码
def moveZeroes_optimal(nums: List[int]) -> None:
    slow = 0
    for fast in range(len(nums)):
        if nums[fast] != 0:
            if fast != slow:
                # 将非零元素复制到slow位置,并将原位置置零
                nums[slow] = nums[fast]
                nums[fast] = 0
            slow += 1

这种方法在遍历过程中同时完成了"收集非零"和"置零",避免了后续的补零循环,是理论上的最优解。

3.5.3 变式问题
  1. 移动特定值 :将数组中所有等于 val 的元素移动到末尾
  2. 稳定分区:将数组按条件分成两部分,保持每部分元素的相对顺序
  3. 颜色分类(LeetCode 75):荷兰国旗问题,三向分区

3.6 面试考点与注意事项

3.6.1 高频考点
  1. 快慢指针原理:为什么慢指针指向下一个非零位置?
  2. 稳定性保证:如何证明算法保持非零元素的相对顺序?
  3. 操作次数优化:赋值法与交换法哪种更优?如何进一步优化?
  4. 边界条件:空数组、全零数组、全非零数组如何处理?
3.6.2 常见错误
  1. 额外空间:创建新数组或使用临时列表
  2. 顺序破坏:使用不稳定的分区算法(如简单交换)
  3. 遗漏补零:赋值法中忘记补零,导致残留旧值
  4. 无效交换 :交换法中未判断 fast != slow,增加冗余操作
3.6.3 实战建议
  1. 先阐述思路:说明快慢指针的分区思想,画图辅助理解
  2. 对比解法:简要说明赋值法与交换法的优劣
  3. 代码简洁:优先编写可读性高的代码,必要时再优化
  4. 测试全面:覆盖各种边界情况,特别是全零/全非零

:对于进阶要求"尽量减少操作次数",可以向面试官展示不同解法的操作次数分析,体现你的优化思维。但不必过度追求微优化,清晰正确的解法比极致优化更重要。

四、双指针技巧总结

通过以上两题的深入剖析,我们可以总结出双指针技术的核心模式与应用场景:

4.1 对撞指针(相向指针)

  • 模式 :两个指针分别指向序列的起始末尾,向中间移动直至相遇
  • 适用场景
    1. 有序数组的两数之和、三数之和问题
    2. 反转字符串、验证回文串
    3. 盛最多水的容器等最优值问题
  • 关键要点
    • 利用有序性,通过比较大小决定移动哪个指针
    • 确保不会错过正确答案(需要正确性证明)
    • 时间复杂度通常为 O ( n ) O(n) O(n)

4.2 快慢指针(同向指针)

  • 模式 :两个指针从同一起点出发,快指针 遍历整个序列,慢指针标记处理位置
  • 适用场景
    1. 原地修改数组(移动零、删除重复项)
    2. 链表环检测、寻找链表中点
    3. 滑动窗口问题的变体
  • 关键要点
    • 快指针负责"读取",慢指针负责"写入"
    • 保持元素相对顺序(稳定性)
    • 时间复杂度通常为 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)

4.3 双指针技术的优势

  1. 时间复杂度优化 :将 O ( n 2 ) O(n²) O(n2) 暴力解优化到 O ( n ) O(n) O(n)
  2. 空间复杂度优化 :实现 O ( 1 ) O(1) O(1) 额外空间的原地操作
  3. 思路清晰简洁:代码通常短小精悍,逻辑直观
  4. 应用广泛:覆盖数组、链表、字符串等多种数据结构

4.4 刷题路径建议

对于希望系统掌握双指针技术的读者,建议按以下顺序刷题:

  1. 简单难度:167.两数之和II、283.移动零、26.删除有序数组中的重复项
  2. 中等难度:15.三数之和、11.盛最多水的容器、3.无重复字符的最长子串
  3. 困难难度:42.接雨水、76.最小覆盖子串、30.串联所有单词的子串

五、结语

双指针技术看似简单,实则蕴含着深刻的算法思想。通过今天对两题简单难度题目的深度解析,我们不仅掌握了具体的解题方法,更理解了算法设计中的关键原则:

  1. 利用数据结构特性:有序数组的两端逼近、原地修改的快慢指针
  2. 正确性证明的重要性:不能仅凭直觉,需要严谨的逻辑推导
  3. 复杂度分析的意识:明确时间与空间代价,选择最优解法
  4. 编码细节的把控:下标转换、边界条件、冗余操作优化

在后续的算法学习专栏中,我们将继续深入双指针的中等与困难难度题目,并扩展到其他算法技术(滑动窗口、动态规划、回溯等)。希望读者能坚持刷题,积累经验,最终在面试与竞赛中游刃有余。

相关推荐
旖-旎3 小时前
分治(库存管理|||)(4)
c++·算法·leetcode·排序算法·快速选择算法
青稞社区.3 小时前
ICLR‘26 Oral | 当 LLM Agent 在多轮推理中迷失时:T3 如何让强化学习重新学会主动推理
人工智能·算法·agi
春花秋月夏海冬雪3 小时前
代码随想录刷题 - 贪心Part1
java·算法·贪心·代码随想录
环黄金线HHJX.3 小时前
Tuan符号系统重塑智能开发
开发语言·人工智能·算法·编辑器
小手指动起来4 小时前
保姆级提示词工程学习总结(含实操示例+工具推荐)
人工智能·学习·自然语言处理
绛橘色的日落(。・∀・)ノ4 小时前
Matplotlib实践学习笔记
笔记·学习
chase。4 小时前
【学习笔记】AGILE:把人形机器人强化学习从“玄学”变成“工程学”
笔记·学习·敏捷流程
汀、人工智能4 小时前
[特殊字符] 第2课:字母异位词分组
数据结构·算法·链表·数据库架构··字母异位词分组
bu_shuo4 小时前
git练习学习网站【中文网站】
git·学习