双指针
1. 讲解
一、双指针的两种常见形式
双指针并不是一定要用 C/C++ 中的指针,很多情况下用数组下标、链表节点引用也可以充当指针。
1. 对撞指针
对撞指针也叫左右指针,通常用于顺序结构,比如有序数组、字符串等。
-
一个指针从最左端开始,另一个指针从最右端开始。
-
两个指针向中间移动,逐渐逼近。
-
终止条件一般是:
-
left == right:两个指针指向同一个位置; -
left > right:两个指针错开; -
或者在循环内部找到结果后直接跳出。
-
常见应用场景:
-
有序数组的两数之和;
-
判断回文串;
-
三数之和、四数之和;
-
盛最多水的容器等。
对撞指针的核心思想是:
利用左右两端的信息,不断缩小搜索区间,从而减少不必要的枚举。
2. 快慢指针
快慢指针又叫龟兔赛跑算法。
基本思想:
使用两个移动速度不同的指针,在数组、链表等序列结构上移动。
常见实现方式:
-
慢指针每次向后移动一位;
-
快指针每次向后移动两位;
-
通过速度差来判断是否存在环、找到中间节点、处理循环往复问题等。
常见应用场景:
-
判断链表是否有环;
-
找链表中间节点;
-
环形数组;
-
一些循环替换问题。
快慢指针的核心思想是:
用两个不同速度的指针,在序列中形成相对位置差或速度差,从而发现循环、中点、重复模式等规律。
二、双指针的做题思想
步骤:
-
用两个指针划分区间;
-
明确指针及划分区间的含义;
-
根据题目条件决定两个指针如何移动;
-
在移动过程中更新答案或调整区间。
常见作用:
-
将暴力枚举
O(n²)优化为O(n); -
原地完成数组划分、去重、移动元素;
-
利用单调性减少重复计算;
-
发现循环结构、找中点等。
三、例题讲解:移动零
我们以力扣上的移动零为例题讲解双指针的思想
1. 题目解析
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
要求:
- 必须原地操作,不能复制数组。
示例:
输入:nums = [0,1,0,3,12]
输出:[1,3,12,0,0]
输入:nums = [0]
输出:[0]
2. 算法原理:数组划分思想
这道题本质上是数组划分问题。
我们可以把数组分成三个区间:

[0, dest] 非零元素区间
[dest + 1, cur - 1] 零元素区间
[cur, n - 1] 待处理区间
两个指针的含义:
-
cur:从左往右扫描数组,遍历数组; -
dest:已处理区间内,最后一个非零元素的位置。
初始状态:
dest = -1
cur = 0
dest = -1 表示一开始没有处理过任何非零元素。
结束状态:
dest = 最后一个非零元素位置
cur = n
cur = n 表示数组全部被处理完
3. 移动规则
在 cur 从前往后遍历的过程中:
情况一:遇到 0
说明这个元素应该放在中间的零区间,不需要交换,直接移动 cur:
cur++
情况二:遇到非零元素
需要把这个非零元素放到左边非零区间的末尾,也就是 dest + 1 的位置。
操作:
要让非零元素加入到最左边 0, dest 区间,由于该区间会多一个元素,因此要右移 dest ,但这个非零元素不能直接覆盖(真的不能直接覆盖吗?后续优化代码会给出答案),因此我们这里做一个交换操作,交换完成后,cur右移,继续处理下一个元素
swap(nums[dest + 1], nums[cur])
dest++
cur++
交换后:
-
dest + 1位置变成了非零元素; -
原来的零被交换到后面;
-
非零区间向右扩展一位。
4. 演示流程
以 nums = [0, 1, 0, 0, 3, 12] 为例:

5. 代码实现
java
class Solution {
public void moveZeroes(int[] nums) {
for (int cur = 0, dest = -1; cur < nums.length; cur++) {
if (nums[cur] != 0) {
dest++;
int tmp = nums[cur];
nums[cur] = nums[dest];
nums[dest] = tmp;
}
}
}
}
6. 复杂度分析
-
时间复杂度:
O(n)因为
cur从头到尾遍历一次数组。 -
空间复杂度:
O(1)只使用了常数级别的额外空间,是原地操作。
7.优化
前面我们提到,遇到非零元素时不能直接覆盖,用到了交换,但观察发现,交换操作永远是将一个非零元素与零元素交换,因此,此处我们可以将 cur 位置的非零元素直接覆盖 dest 位置上的 0 ,然后将 cur 位置的元素置为 0 :
java
public static void moveZeroes(int[] nums) {
int dest = -1, cur = 0;
int len = nums.length;
while (cur < len) {
if (nums[cur] != 0) {
dest++;
// 当dest和cur指向同一位置时,如果还进行下面的操作会报错
// 比如:[1] dest和cur都指向1,如果还进行nums[cur]=0会将原来的1覆盖掉,就会报错
if (dest != cur) {
nums[dest] = nums[cur];
nums[cur] = 0;
}
}
cur++;
}
}
优点:
-
该代码减少了不必要的交换,当
dest == cur时,说明当前非零元素已经在正确位置,不需要做任何写操作,直接跳过。 -
遇到非零元素时,
swap需要进行三次赋值,该代码只需要两次赋值,减少了一次赋值操作。 -
时间复杂度依然是
O(n),空间复杂度O(1)。但常数项更小(数组读取次数少,且没有临时变量),实际运行更快。
当然,我们原来的代码如果为了减少交换次数也可以加上 if(dest != cur) 的判断:
java
public void moveZeroes(int[] nums) {
for (int cur = 0, dest = -1; cur < nums.length; cur++) {
if (nums[cur] != 0) {
dest++;
if (dest != cur) {
int tmp = nums[cur];
nums[cur] = nums[dest];
nums[dest] = tmp;
}
}
}
}
8. 扩展:数组划分与快速排序
这种「数组分块」的思想非常重要,它可以解决快速排序中最核心的一步:partition。
快速排序的划分可以抽象为:
[0, dest] 小于等于基准值的区间
[dest + 1, cur - 1] 大于基准值的区间
[cur, n - 1] 待处理区间
通过类似的双指针遍历和交换,就能把数组按基准值划分成左右两部分。
四、总结
「移动零」这道题本质上是:
用两个下标
cur和dest维护数组区间,cur负责扫描,dest负责记录非零区间的末尾。遇到非零元素就交换到前面,遇到零就跳过。
它体现的是双指针中常见的"划分区间"思想:
-
cur相当于快指针,负责遍历; -
dest相当于慢指针,负责记录已经处理好的区域边界。
掌握这种区间划分方法,对后续学习快速排序、数组去重、移除元素等问题都很有帮助。