本文聚焦两个简单难度但极具代表性的题目:167.两数之和II与283.移动零。通过深入剖析这两题的经典解法,帮助读者掌握双指针技术的核心思想与应用场景,为后续中等、困难难度题目打下坚实基础。
一、引言:为什么从简单难度开始?
双指针(Two Pointers)是算法竞赛与面试中最高频的技巧之一,能够在 O ( n ) O(n) O(n) 时间内解决许多看似复杂的数组与链表问题。但对于初学者而言,直接挑战中等或困难难度的题目往往容易陷入细节泥潭,反而忽略了算法思想的本质。
因此,本专题采用由浅入深、循序渐进的教学策略,从简单难度题目入手,帮助读者:
- 建立直觉:理解双指针移动的基本逻辑与边界条件
- 掌握模板:熟悉对撞指针、快慢指针等经典模式
- 避免误区:识别常见错误,养成严谨的编码习惯
今天的两道题目虽标注为"简单",但蕴含了双指针技术的精髓,是通往更高阶算法的必经之路。
二、题目一: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 算法思想
本题的核心突破口在于数组已排序 。对于有序数组,我们可以使用两个指针分别指向数组的起始位置 和末尾位置,通过比较两数之和与目标值的大小关系,动态调整指针位置,逐步逼近正确答案。
算法步骤:
- 初始化 :左指针
left = 0(指向数组起始),右指针right = len(numbers)-1(指向数组末尾) - 循环条件 :当
left < right时继续搜索(确保两个指针不指向同一元素) - 计算和值 :
sum = numbers[left] + numbers[right] - 比较与移动 :
- 若
sum == target:找到答案,返回[left+1, right+1](转换为1-based下标) - 若
sum < target:说明当前和偏小,需要增大较小数 → 左指针右移 (left++) - 若
sum > target:说明当前和偏大,需要减小较大数 → 右指针左移 (right--)
- 若
- 终止:题目保证有唯一解,循环必然找到答案
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 ≤ i,right = 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 不可能越过 i,right 不可能越过 j,最终必然在 left = i 且 right = 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)
- 仅使用了两个指针变量
left和right,以及临时变量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]
代码要点解析:
- 指针初始化 :
left = 0,right = len(numbers)-1,覆盖数组两端 - 循环条件 :
left < right确保不重复使用同一元素 - 和值计算:每次循环计算当前两数之和
- 条件分支 :
- 相等 → 返回1-based下标
- 偏小 →
left++(增大和值) - 偏大 →
right--(减小和值)
- 兜底返回:理论上不会执行,但保持代码完整性
注: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 高频考点
- 双指针移动逻辑:为什么和值偏小时左指针右移?为什么偏大时右指针左移?
- 正确性证明:如何证明算法不会错过正确答案?
- 边界处理:负数、零、重复元素如何处理?
- 空间复杂度 :为什么必须使用 O ( 1 ) O(1) O(1) 空间?哈希表为什么不行?
2.6.2 常见错误
- 下标转换错误:忘记将0-based下标转换为1-based下标
- 指针越界 :在移动指针时未检查
left < right条件 - 忽略唯一性:假设多组解存在,导致逻辑复杂化
- 错误的空间优化:试图使用排序后恢复原序等复杂技巧
2.6.3 实战建议
- 先提问:确认数组是否真的有序?答案是否唯一?
- 画图辅助:在白板上画出指针移动过程,帮助面试官理解思路
- 测试用例:至少覆盖正数、负数、零、边界长度等场景
- 复杂度分析:明确说明时间/空间复杂度,并对比其他解法优劣
注:在面试中,即使题目看起来简单,也要完整阐述算法思想、正确性证明和复杂度分析,这体现了工程师的严谨思维习惯。
三、题目二: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):指向下一个非零元素应该放置的位置,负责"写入"非零元素
算法步骤:
- 初始化 :慢指针
slow = 0 - 遍历数组 :快指针
fast从0到n-1遍历每个元素 - 处理非零元素 :
- 如果
nums[fast] != 0:将nums[fast]赋值给nums[slow],然后slow++ - 如果
nums[fast] == 0:跳过,快指针继续前进
- 如果
- 补零操作 :遍历结束后,将
slow到n-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)
- 仅使用两个指针变量
slow和fast - 原地修改数组,没有额外空间开销
注 :如果采用交换法,时间复杂度同样是 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
代码要点解析:
- 双指针初始化 :
slow指向下一个非零元素应放置的位置,fast遍历数组 - 非零处理 :遇到非零元素时,复制到
slow位置(或交换),然后slow++ - 补零操作:遍历结束后,剩余位置补零(赋值法特有)
- 原地修改:题目要求不返回任何值,直接修改原数组
注 :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 变式问题
- 移动特定值 :将数组中所有等于
val的元素移动到末尾 - 稳定分区:将数组按条件分成两部分,保持每部分元素的相对顺序
- 颜色分类(LeetCode 75):荷兰国旗问题,三向分区
3.6 面试考点与注意事项
3.6.1 高频考点
- 快慢指针原理:为什么慢指针指向下一个非零位置?
- 稳定性保证:如何证明算法保持非零元素的相对顺序?
- 操作次数优化:赋值法与交换法哪种更优?如何进一步优化?
- 边界条件:空数组、全零数组、全非零数组如何处理?
3.6.2 常见错误
- 额外空间:创建新数组或使用临时列表
- 顺序破坏:使用不稳定的分区算法(如简单交换)
- 遗漏补零:赋值法中忘记补零,导致残留旧值
- 无效交换 :交换法中未判断
fast != slow,增加冗余操作
3.6.3 实战建议
- 先阐述思路:说明快慢指针的分区思想,画图辅助理解
- 对比解法:简要说明赋值法与交换法的优劣
- 代码简洁:优先编写可读性高的代码,必要时再优化
- 测试全面:覆盖各种边界情况,特别是全零/全非零
注:对于进阶要求"尽量减少操作次数",可以向面试官展示不同解法的操作次数分析,体现你的优化思维。但不必过度追求微优化,清晰正确的解法比极致优化更重要。
四、双指针技巧总结
通过以上两题的深入剖析,我们可以总结出双指针技术的核心模式与应用场景:
4.1 对撞指针(相向指针)
- 模式 :两个指针分别指向序列的起始 和末尾,向中间移动直至相遇
- 适用场景 :
- 有序数组的两数之和、三数之和问题
- 反转字符串、验证回文串
- 盛最多水的容器等最优值问题
- 关键要点 :
- 利用有序性,通过比较大小决定移动哪个指针
- 确保不会错过正确答案(需要正确性证明)
- 时间复杂度通常为 O ( n ) O(n) O(n)
4.2 快慢指针(同向指针)
- 模式 :两个指针从同一起点出发,快指针 遍历整个序列,慢指针标记处理位置
- 适用场景 :
- 原地修改数组(移动零、删除重复项)
- 链表环检测、寻找链表中点
- 滑动窗口问题的变体
- 关键要点 :
- 快指针负责"读取",慢指针负责"写入"
- 保持元素相对顺序(稳定性)
- 时间复杂度通常为 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)
4.3 双指针技术的优势
- 时间复杂度优化 :将 O ( n 2 ) O(n²) O(n2) 暴力解优化到 O ( n ) O(n) O(n)
- 空间复杂度优化 :实现 O ( 1 ) O(1) O(1) 额外空间的原地操作
- 思路清晰简洁:代码通常短小精悍,逻辑直观
- 应用广泛:覆盖数组、链表、字符串等多种数据结构
4.4 刷题路径建议
对于希望系统掌握双指针技术的读者,建议按以下顺序刷题:
- 简单难度:167.两数之和II、283.移动零、26.删除有序数组中的重复项
- 中等难度:15.三数之和、11.盛最多水的容器、3.无重复字符的最长子串
- 困难难度:42.接雨水、76.最小覆盖子串、30.串联所有单词的子串
五、结语
双指针技术看似简单,实则蕴含着深刻的算法思想。通过今天对两题简单难度题目的深度解析,我们不仅掌握了具体的解题方法,更理解了算法设计中的关键原则:
- 利用数据结构特性:有序数组的两端逼近、原地修改的快慢指针
- 正确性证明的重要性:不能仅凭直觉,需要严谨的逻辑推导
- 复杂度分析的意识:明确时间与空间代价,选择最优解法
- 编码细节的把控:下标转换、边界条件、冗余操作优化
在后续的算法学习专栏中,我们将继续深入双指针的中等与困难难度题目,并扩展到其他算法技术(滑动窗口、动态规划、回溯等)。希望读者能坚持刷题,积累经验,最终在面试与竞赛中游刃有余。