1. 引言与双指针基础
双指针算法是解决数组、字符串、链表等线性数据结构问题的核心优化技巧。通过在数据遍历过程中维护两个指针(索引),以不同速度、方向或策略协同工作,能将暴力解法的 O ( n 2 ) O(n^2) O(n2) 时间复杂度优化至 O ( n ) O(n) O(n),同时常实现 O ( 1 ) O(1) O(1) 的空间复杂度。这一技巧在力扣hot100、算法面试中高频出现,是必须掌握的基础算法范式。
1.1 三种主要类型及应用场景
1. 对撞指针(Two Pointers)
- 指针移动:一个指针从起始位置开始,另一个从末尾开始,向中间靠拢
- 适用场景 :
- 有序数组查找:两数之和II、三数之和、最接近的三数之和
- 反转问题:反转字符串、验证回文串
- 容器问题:盛最多水的容器
- 时间复杂度 : O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
2. 快慢指针(Fast & Slow Pointers)
- 指针移动:快指针每次移动两步(或更快),慢指针每次移动一步
- 适用场景 :
- 链表问题:环检测、中间节点、相交链表
- 原地修改:移动零、删除排序数组重复项、移除元素
- 时间复杂度 : O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
3. 滑动窗口(Sliding Window)
- 指针移动:维护左右边界定义的窗口,动态调整窗口大小
- 适用场景 :
- 子字符串问题:无重复字符最长子串、最小覆盖子串
- 子数组问题:长度最小子数组、最大连续1的个数
- 时间复杂度 : O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1) 或 O ( k ) O(k) O(k)
1.2 算法优势与核心思想
- 时间复杂度优化 :避免双重循环,将 O ( n 2 ) O(n^2) O(n2) 降至 O ( n ) O(n) O(n)
- 空间复杂度优化 :通常只需 O ( 1 ) O(1) O(1) 额外空间,实现原地操作
- 逻辑清晰简洁:指针移动条件明确,代码易于理解和维护
- 适用性广泛:覆盖数组、字符串、链表等多种数据结构
注:双指针并非独立的算法分类,而是一种优化技巧(或称编程范式)。它常与二分查找、贪心算法、动态规划等经典算法结合使用,核心思想是通过指针的巧妙移动减少不必要的计算,缩小搜索空间。
1.3 本文内容安排
本文将深入解析三道力扣hot100简单难度题目,系统讲解对撞指针和快慢指针的应用:
- 167.两数之和II:对撞指针经典应用,利用有序性高效查找
- 283.移动零:快慢指针原地修改,保持元素相对顺序
- 344.反转字符串 :对撞指针对称交换,实现 O ( 1 ) O(1) O(1) 空间复杂度
每道题目提供完整分析:题目概述、核心思路、复杂度证明、Python实现及优化讨论,帮助读者建立双指针算法的系统性认知。
2. 167.两数之和II-输入有序数组
2.1 题目概述
题目链接 :167. Two Sum II - Input Array Is Sorted
问题描述 :
给定一个已按照非递减顺序排列 的整数数组 numbers,从数组中找出两个数满足相加之和等于目标数 target。假设每个输入只对应唯一的答案,且数组中同一个元素不能使用两遍。返回这两个数的下标值(下标值以 1 为起始)。
示例:
输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于 9,因此 index1 = 1, index2 = 2。
约束条件:
- 2 ≤ numbers.length ≤ 3 × 10 4 2 \le \text{numbers.length} \le 3 \times 10^4 2≤numbers.length≤3×104
- − 1000 ≤ numbers[i] ≤ 1000 -1000 \le \text{numbers[i]} \le 1000 −1000≤numbers[i]≤1000
- 数组按非递减顺序排列
- − 1000 ≤ target ≤ 1000 -1000 \le \text{target} \le 1000 −1000≤target≤1000
- 测试用例保证有且仅有一个有效答案
2.2 核心解题思路:对撞指针
暴力解法分析 :
最直接的思路是双重循环遍历所有可能的数对,检查其和是否等于 target。时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1)。对于长度可达 3 × 10 4 3 \times 10^4 3×104 的数组, O ( n 2 ) O(n^2) O(n2) 的算法会超时。
对撞指针优化 :
利用数组已排序的特性,使用对撞指针将时间复杂度优化到 O ( n ) O(n) O(n):
-
指针初始化:
left = 0(指向数组起始)right = len(numbers) - 1(指向数组末尾)
-
循环条件 :
while left < right -
指针移动策略:
- 计算当前和:
current_sum = numbers[left] + numbers[right] - 若
current_sum == target:找到答案,返回[left+1, right+1] - 若
current_sum < target:当前和太小,需要增大,left右移(left += 1) - 若
current_sum > target:当前和太大,需要减小,right左移(right -= 1)
- 计算当前和:
正确性证明:
- 单调性保证 :数组按非递减顺序排列,即
numbers[left] ≤ numbers[left+1],numbers[right-1] ≤ numbers[right] - 完整性保证 :每次移动指针都会排除一部分不可能的解:
- 当
current_sum < target时,对于固定的right,numbers[left]与numbers[left+1]...numbers[right]的组合都不可能等于target(因为最小的numbers[left]已经太小) - 当
current_sum > target时,对于固定的left,numbers[left]与numbers[left]...numbers[right-1]的组合都不可能等于target(因为最大的numbers[right]已经太大)
- 当
- 收敛性保证 :每次迭代
right - left严格减小,最终必然相遇或找到解
注:题目要求下标从1开始,因此返回时需要将 left 和 right 分别加1。这是力扣题目常见的细节要求,面试时需特别注意。循环条件必须使用 left < right,不能使用 left <= right,以避免使用同一元素两次。
2.3 时间复杂度与空间复杂度分析
时间复杂度 : O ( n ) O(n) O(n)
- 最坏情况下,
left和right指针从两端向中间移动,总共移动 n − 1 n-1 n−1 次 - 每次迭代执行常数时间操作(计算和、比较、移动指针)
- 因此总时间复杂度为 O ( n ) O(n) O(n)
空间复杂度 : O ( 1 ) O(1) O(1)
- 只使用了常数个额外变量(
left、right、current_sum) - 没有使用与输入规模相关的额外数据结构
算法效率:
- 相比于暴力解法的 O ( n 2 ) O(n^2) O(n2),对撞指针的 O ( n ) O(n) O(n) 算法性能提升显著
- 对于 n = 30000 n=30000 n=30000 的数组,暴力解法需要约 4.5 × 10 8 4.5 \times 10^8 4.5×108 次操作,而对撞指针只需约 30000 30000 30000 次操作,相差约 15000 15000 15000 倍
2.4 Python代码
python
from typing import List
def twoSum(numbers: List[int], target: int) -> List[int]:
left, right = 0, len(numbers) - 1
while left < right:
current_sum = numbers[left] + numbers[right]
if current_sum == target:
return [left + 1, right + 1]
elif current_sum < target:
left += 1
else:
right -= 1
return [-1, -1] # 题目保证有解,不会执行
注:循环条件 left < right 确保不使用同一元素,下标加1满足题目要求。
2.5 优化与变式
二分查找 : O ( n log n ) O(n \log n) O(nlogn),代码更简单。
哈希表 : O ( n ) O(n) O(n) 时间, O ( n ) O(n) O(n) 空间,适用于无序数组。
三数之和:固定一数,对撞指针找另外两数,需去重处理。
3. 283.移动零
3.1 题目概述
题目链接 :283. Move Zeroes
问题描述 :
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。必须在原数组上操作,不能拷贝额外的数组,尽量减少操作次数。
示例:
输入:nums = [0,1,0,3,12]
输出:[1,3,12,0,0]
约束条件:
- 1 ≤ nums.length ≤ 10 4 1 \le \text{nums.length} \le 10^4 1≤nums.length≤104
- − 2 31 ≤ nums[i] ≤ 2 31 − 1 -2^{31} \le \text{nums[i]} \le 2^{31} - 1 −231≤nums[i]≤231−1
进阶:你能尽量减少完成的操作次数吗?
3.2 核心解题思路:快慢指针
问题分析 :
题目要求将所有零移动到数组末尾,同时保持非零元素的相对顺序。关键约束是原地修改 (不能使用额外数组)且要最小化操作次数。
暴力解法分析 :
一种朴素思路是遇到0时,将其后的所有元素向前移动一位,在末尾补0。这种方法时间复杂度为 O ( n 2 ) O(n^2) O(n2)(每个0可能需要移动 O ( n ) O(n) O(n) 个元素),操作次数过多,不符合要求。
快慢指针优化 :
使用快慢指针可以在 O ( n ) O(n) O(n) 时间内完成操作,且操作次数最优:
方法一:两次遍历法(复制+补零)
- 慢指针
slow:指向下一个非零元素应该放置的位置 - 快指针
fast:遍历整个数组,寻找非零元素 - 算法步骤 :
- 初始化
slow = 0 - 遍历数组,
fast从0到len(nums)-1:- 如果
nums[fast] != 0:将nums[fast]复制到nums[slow],然后slow += 1
- 如果
- 遍历结束后,
slow之前的位置都是非零元素,且保持了原始顺序 - 将
slow到数组末尾的所有位置设为0
- 初始化
方法二:一次遍历法(交换)
- 优化版本,通过交换操作在一次遍历中完成:
slow指向下一个非零元素应该放置的位置fast遍历数组寻找非零元素- 当
nums[fast] != 0时:交换nums[slow]和nums[fast],然后slow += 1 - 零元素被逐渐"推"到数组末尾
正确性证明:
- 顺序保持:快指针按顺序遍历,遇到非零元素就复制/交换到慢指针位置,因此非零元素的相对顺序不变
- 零元素处理:交换法直接在一次遍历中将零元素逐渐"推"到数组末尾;复制+补零法则先收集所有非零元素,再统一补零
- 操作次数最小化 :每个非零元素最多被移动一次(交换或复制),零元素可能被多次交换但总操作次数为 O ( n ) O(n) O(n)
注:使用交换法时,当 fast == slow 且 nums[fast] != 0,元素会和自己交换,这是不必要的操作。可以通过添加判断条件 if fast != slow 来优化,减少少量赋值操作。但算法复杂度不变,仍是 O ( n ) O(n) O(n)。
3.3 时间复杂度与空间复杂度分析
两次遍历法:
- 时间复杂度 : 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)
- 空间复杂度 : O ( 1 ) O(1) O(1)
- 只使用常数个额外变量
一次遍历(交换)法:
- 时间复杂度 : O ( n ) O(n) O(n)
- 只遍历数组一次,每次迭代执行常数时间操作
- 空间复杂度 : O ( 1 ) O(1) O(1)
- 只使用常数个额外变量
操作次数比较:
- 两次遍历法:非零元素移动一次(复制),零元素移动一次(赋值)
- 一次遍历法:每个非零元素最多交换一次(可能包含自我交换)
- 实际选择:一次遍历法代码更简洁,推荐使用
3.4 Python代码
python
from typing import List
def moveZeroes(nums: List[int]) -> None:
# 一次遍历交换法
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
注:添加 if fast != slow 判断可避免自我交换,减少不必要操作。
3.5 优化与变式
避免自我交换 :判断 fast != slow 后再交换。
移除元素 :类似逻辑,移除等于 val 的元素。
颜色分类:三指针法处理0、1、2排序。
4. 344.反转字符串
4.1 题目概述
题目链接 :344. Reverse String
问题描述 :
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。你必须原地修改输入数组,使用 O ( 1 ) O(1) O(1) 的额外空间解决这一问题。
示例:
输入:s = ["h","e","l","l","o"]
输出:["o","l","l","e","h"]
约束条件:
- 1 ≤ s.length ≤ 10 5 1 \le \text{s.length} \le 10^5 1≤s.length≤105
s[i]都是 ASCII 码表中的可打印字符
进阶:
- 不要给另外的数组分配额外的空间,你必须原地修改输入数组
- 使用 O ( 1 ) O(1) O(1) 的额外空间解决这一问题
4.2 核心解题思路:对撞指针
问题分析 :
题目要求原地反转字符数组,即第一个字符与最后一个字符交换,第二个字符与倒数第二个字符交换,依此类推。关键约束是原地修改 且只能使用** O ( 1 ) O(1) O(1)额外空间**。
暴力解法分析 :
最直接的思路是创建一个新数组,从后向前复制元素。但这违反了原地修改的要求,且空间复杂度为 O ( n ) O(n) O(n),不符合题目要求。
对撞指针优化 :
使用对撞指针可以在 O ( n ) O(n) O(n) 时间内原地完成反转:
-
指针初始化:
left = 0:指向数组开头right = len(s) - 1:指向数组末尾
-
算法步骤:
while left < right:- 交换
s[left]和s[right] left += 1right -= 1
- 交换
-
终止条件分析:
- 当字符串长度为奇数时,中间元素不需要交换(
left == right) - 当字符串长度为偶数时,所有元素都完成交换(
left > right) - 因此循环条件为
left < right
- 当字符串长度为奇数时,中间元素不需要交换(
正确性证明:
- 对称性:每次迭代交换对称位置的两个字符
- 完整性:从外向内逐对交换,直到中心位置
- 原地性:只使用交换操作,不需要额外数组
- 收敛性 :每次迭代
left增加,right减少,最终left >= right
注:Python中字符串是不可变对象,但题目输入是字符列表(List[str]),可以直接修改。在实际编程中,如果输入是真正的字符串,需要先转换为列表,反转后再转换为字符串,但这会增加 O ( n ) O(n) O(n) 的空间开销。本题明确输入是字符数组,因此可以直接操作。
4.3 时间复杂度与空间复杂度分析
时间复杂度 : O ( n ) O(n) O(n)
- 需要交换 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋ 对字符
- 每次交换执行常数时间操作(赋值、增减指针)
- 因此总时间复杂度为 O ( n ) O(n) O(n)
空间复杂度 : O ( 1 ) O(1) O(1)
- 只使用了常数个额外变量(
left、right、临时交换变量) - 完全符合题目要求的 O ( 1 ) O(1) O(1) 额外空间
操作次数详细分析:
- 总交换次数: ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋ 次
- 每次交换:3次赋值操作(使用临时变量)或元组解包(内部实现类似)
- 指针操作:
left和right各增减 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋ 次 - 总赋值次数:约 3 × ⌊ n / 2 ⌋ = O ( n ) 3 \times \lfloor n/2 \rfloor = O(n) 3×⌊n/2⌋=O(n)
4.4 Python代码
python
from typing import List
def reverseString(s: List[str]) -> None:
left, right = 0, len(s) - 1
while left < right:
s[left], s[right] = s[right], s[left]
left += 1
right -= 1
注:Python元组解包交换简洁高效,实际使用临时元组但空间复杂度仍为 O ( 1 ) O(1) O(1)。
4.5 优化与变式
递归解法 :交换首尾后递归处理子串,但栈空间 O ( n ) O(n) O(n)。
反转单词 :定位单词边界,分别反转。
旋转数组:三次反转法实现右旋k位。
5. 总结与面试考点
5.1 双指针算法核心思想总结
通过本文对三道简单难度双指针题目的深入分析,我们可以系统总结双指针算法的核心思想与应用模式:
1. 对撞指针(Two Pointers)
- 核心机制:指针从数组/字符串两端向中间移动,每次迭代根据比较结果移动其中一个指针
- 适用场景 :
- 有序数组查找:两数之和、三数之和、最接近的三数之和
- 反转问题:反转字符串、验证回文串
- 容器问题:盛最多水的容器
- 时间复杂度 : O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
- 关键技巧:利用数据有序性,每次移动排除一半不可能解
2. 快慢指针(Fast & Slow Pointers)
- 核心机制:快指针以更快速度(通常为两步)移动,慢指针以正常速度(一步)移动
- 适用场景 :
- 链表问题:环检测、中间节点、相交链表
- 原地修改:移动零、删除排序数组重复项、移除元素
- 时间复杂度 : O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1)
- 关键技巧:通过速度差定位特定位置或实现原地修改
3. 滑动窗口(Sliding Window)
- 核心机制:维护左右边界定义的动态窗口,根据条件调整窗口大小
- 适用场景 :
- 子字符串问题:无重复字符最长子串、最小覆盖子串
- 子数组问题:长度最小子数组、最大连续1的个数
- 时间复杂度 : O ( n ) O(n) O(n),空间复杂度: O ( 1 ) O(1) O(1) 或 O ( k ) O(k) O(k)
- 关键技巧:通过窗口扩张和收缩寻找满足条件的极值
5.2 时间复杂度优化策略与性能分析
双指针算法的主要优势在于显著的时间复杂度优化:
1. O ( n 2 ) → O ( n ) O(n^2) → O(n) O(n2)→O(n) 的降维优化
- 两数之和 :暴力双重循环 O ( n 2 ) O(n^2) O(n2) → 对撞指针 O ( n ) O(n) O(n),性能提升 O ( n ) O(n) O(n) 倍
- 移动零 :暴力移位 O ( n 2 ) O(n^2) O(n2) → 快慢指针 O ( n ) O(n) O(n),性能提升 O ( n ) O(n) O(n) 倍
- 性能对比 :对于 n = 10000 n=10000 n=10000 的数组,暴力解法需要约 10 8 10^8 108 次操作,双指针只需 10 4 10^4 104 次操作,相差 10 4 10^4 104 倍
2. O ( n ) → O ( 1 ) O(n) → O(1) O(n)→O(1) 的空间优化
- 反转字符串 :额外数组 O ( n ) O(n) O(n) 空间 → 对撞指针 O ( 1 ) O(1) O(1) 空间
- 原地修改:避免创建新数据结构,降低内存占用
- 实际意义:在大数据场景下,空间优化与时间优化同等重要
3. 算法效率的数学证明
- 对撞指针 :每次迭代排除至少一个不可能解, n n n 次迭代内必然找到解或遍历完
- 快慢指针 :快指针比慢指针快一倍, n n n 步内快指针必然遍历完或检测到环
- 收敛性保证:所有双指针算法都满足单调收敛条件,确保算法终止
5.3 面试常见考点与应对策略
1. 基础概念理解类问题
- 典型问题 :
- "双指针算法有哪几种类型?各自适用什么场景?"
- "对撞指针和快慢指针的时间复杂度分别是多少?如何证明?"
- "滑动窗口算法的核心思想是什么?"
- 应对策略 :
- 清晰分类:对撞、快慢、滑动窗口三大类
- 举例说明:每类给出2-3个经典力扣题目
- 复杂度分析:结合数学公式和具体证明
2. 代码实现能力类问题
- 典型问题 :
- "手写两数之和的对撞指针解法"
- "实现移动零的快慢指针算法"
- "处理边界条件:空数组、单元素、全零等特殊情况"
- 应对策略 :
- 模版化代码:掌握核心代码结构
- 边界测试:显式测试各种边界情况
- 代码优化:减少不必要的操作和变量
3. 问题变式分析类问题
- 典型问题 :
- "如果数组无序,两数之和问题如何解决?"
- "如果需要保持零元素的相对顺序,移动零问题如何修改?"
- "如果字符串包含中文(多字节字符),反转字符串需要注意什么?"
- 应对策略 :
- 算法转换:哈希表解决无序两数之和
- 条件修改:快慢指针保持相对顺序
- 编码处理:考虑Unicode多字节字符特性
4. 算法扩展思考类问题
- 典型问题 :
- "双指针算法与二分查找、贪心算法、动态规划的关系是什么?"
- "双指针算法能否用于二维数组或图结构?"
- "双指针算法在分布式系统或数据库查询中有哪些应用?"
- 应对策略 :
- 算法对比:分析异同点和适用场景
- 结构扩展:讨论在复杂数据结构中的应用可能
- 实际应用:结合系统设计和工程实践
5.4 面试准备建议与学习路径
1. 核心技能掌握
- 必会题目 :
- 对撞指针:167.两数之和II、15.三数之和、11.盛最多水的容器
- 快慢指针:283.移动零、26.删除排序数组重复项、141.环形链表
- 滑动窗口:3.无重复字符最长子串、76.最小覆盖子串、209.长度最小子数组
- 掌握程度:每题能够独立完成,理解算法原理,处理边界情况
2. 编码能力训练
- 关键要点 :
- 指针初始化:正确设置起始和结束位置
- 循环条件:使用
while left < right而非while left <= right - 交换操作:掌握元组解包和临时变量两种方式
- 代码简洁:避免冗余变量和操作
- 实践方法:在力扣平台反复练习,追求一次通过
3. 变式问题准备
- 准备方向 :
- 同一问题的不同约束条件
- 相似问题的双指针解法差异
- 双指针与其他算法的结合应用
- 学习资源:力扣专题、算法书籍、技术博客
4. 进阶学习路径
- 困难题目 :
- 42.接雨水:对撞指针的复杂应用
- 76.最小覆盖子串:滑动窗口的高级技巧
- 239.滑动窗口最大值:单调队列与双指针结合
- 相关领域 :
- 二分查找:有序数组的快速定位
- 贪心算法:局部最优导致全局最优
- 动态规划:状态转移与最优子结构
- 实际应用 :
- 数据库查询优化:双指针合并有序结果
- 系统设计:滑动窗口限流算法
- 机器学习:特征选择与数据预处理
5.5 本文内容回顾与学习价值
本文通过三道力扣hot100简单难度题目,系统讲解了双指针算法的核心思想与应用:
1. 167.两数之和II-输入有序数组
- 展示了对撞指针在有序数组中的应用
- 将时间复杂度从 O ( n 2 ) O(n^2) O(n2) 优化到 O ( n ) O(n) O(n)
- 关键技巧:利用单调性,每次移动排除不可能解
2. 283.移动零
- 展示了快慢指针在数组原地修改中的应用
- 保持了非零元素的相对顺序
- 关键技巧:通过交换或复制实现零元素后移
3. 344.反转字符串
- 展示了对撞指针在字符串反转中的应用
- 实现了 O ( 1 ) O(1) O(1) 的空间复杂度
- 关键技巧:对称位置字符交换
学习价值:
- 系统性认知:从基础概念到高级应用,建立完整知识体系
- 实战导向:每道题目提供完整分析、代码实现及优化讨论
- 面试准备:涵盖常见考点、应对策略和学习路径
- 技术深度:深入算法原理、复杂度分析和正确性证明
后续学习建议:
- 巩固基础:熟练掌握本文三道题目,达到举一反三
- 拓展练习:完成对撞指针、快慢指针、滑动窗口的进阶题目
- 系统学习:结合算法书籍和在线课程,深入算法理论
- 实战应用:在项目开发中应用双指针思想解决实际问题
双指针算法是算法面试中的高频考点,也是解决实际工程问题的重要工具。通过系统学习和反复练习,掌握其核心思想和应用技巧,能够显著提升算法能力和面试表现,为职业发展打下坚实基础。