如果你刷过一阵算法题,肯定对“双指针”不陌生。尤其在题目里出现“原地”“不额外用数组”“把数组按某个条件分成两块”这些字眼时,十有八九就是在考双指针的数组分块。双指针不是一个花哨技巧,它背后只有一句话:用两个指针维护“已处理区域”和“未处理区域”的边界。理解这句话,比背十种模板都管用。
我见过很多开发者一上来就刷题,见到双指针就开始死记“左右指针、快慢指针、滑动窗口”,结果题目稍微变一下就不会了。根本原因是没有把“分块”这个底层模型想透。这篇文章我想用数组分块作为切口,把双指针当中最基础、最高频的两类用法讲清楚:一类是左右夹逼,一类是同向快慢扫描,再顺手把手把荷兰国旗这种三分块问题也拆给你看。适合刚接触算法的初学者,也适合那些“刷过却总忘”的朋友。
1. 从“分块”说起:双指针到底在维护什么
1.1 数组分块是什么
数组分块听起来玄乎,其实你早就用过。快速排序里那个partition就是典型的数组分块:选一个基准值,然后让数组左边都是小于等于基准值的数,右边都是大于等于基准值的数。移动零也是分块:把所有非零元素挪到前面,零全部扔到后面,数组被分成“非零区”和“零区”两块。按奇偶排序也是分块:偶数放左半边,奇数放右半边。
所以不要以为分块就是“把数组切成几段那么简单的切片”。这里说的分块,指的是在原数组上,按照某个规则,通过交换或赋值,把元素重新组织成若干连续区间。每个区间满足同一个性质,比如“都小于 pivot”“都等于 0”“都是偶数”。
这种操作最常见的价值有三个:
- 不额外开辟数组,空间复杂度 O(1)
- 一次遍历完成重排,时间复杂度 O(n)
- 边界信息可以复用,比如快排的分区函数、去重后的数组长度
一个朴素新手往往会这么写:新开一个数组,遍历原数组,把符合条件的塞进去,最后再拷回来。这样做当然能过题,但完全没体现出分块的意义。面试或比赛里经常要求你原地处理,就是希望你直接利用数组本身的空间,通过指针来记住“现在写到哪了”“接下来该看哪个位置了”。
1.2 双指针为什么适合分块
分块需要解决一个核心矛盾:你一边要扫描原数组,一边要在原数组上写结果。如果只有一个指针,你写完后可能把还没读过的数据覆盖掉,信息就丢了。
双指针解决这个矛盾的方式很直接。两个指针各管一头,或者一个负责读、一个负责写,把“读”和“写”的节奏错开:
- 左右夹逼时,左指针管左边界,右指针管右边界,中间那段是“还没安排好”的区域。
- 同向扫描时,快指针负责往前探索,慢指针负责记录“下一个合法元素该放的位置”。
每一次循环,双指针做的事情就是把“未处理区域”缩小一点,把“已处理区域”扩大一点。这个过程和数学里的归纳法很像:先保证初始状态没问题,然后证明每次循环后不变量依然成立,最后循环结束时自然得到正确结果。
写代码时如果你觉得某个分块算法很难理解,不要纠结于代码顺序,先问自己三个问题:这个算法维护了什么不变量?两个指针分别代表哪段边界?循环结束条件又是什么?能把这三个问题说清楚,代码基本不会错。
1.3 我把双指针分块理解成“两块地砖”
为了让抽象的东西好记,我经常和学生说一个例子。假设你面前有一排地砖,有的干净、有的脏。你想把所有干净砖挪到左侧。你左手停在“下一个干净砖该放的位置”,右手一路往前翻砖。每翻到一块干净砖,就把它交给左手,左手放到左边,然后左手往右移一格;遇到脏砖,右手继续走,左手不动。
这就是同向双指针,左手和右手都在往一个方向移动,但左手总是慢半拍。再假设你面前有一扇门,门左侧要放小球,门右侧要放大球。你从左右两端同时整理,左手指着左边最后一个位置,右手指着右边最后一个位置,发现不合规矩就交换。这就是左右夹逼。把这两张画面印在脑子里,比记代码有用多了。
2. 左右夹逼:最经典的双向分区模板
2.1 先看懂一份可复制的分区代码
左右夹逼的写法很多,我更喜欢“挖坑法”作为入门版本,因为它每一步都看得见逻辑。
def partition(nums, l, r): pivot = nums[l] while l < r: # 从右往左,找第一个比 pivot 小的数 while l < r and nums[r] >= pivot: r -= 1 nums[l] = nums[r] # 从左往右,找第一个比 pivot 大的数 while l < r and nums[l] <= pivot: l += 1 nums[r] = nums[l] # l 和 r 相遇的位置就是 pivot 的最终位置 nums[l] = pivot return l选了最左边的数作为基准值,那么nums[l]的位置就空出来了,变成一个“坑”。右边的指针先动,找到小于基准值的数,把它填进左边的坑里;填完后右边留下一个新坑,再由左边的指针去找大于基准值的数填回去。就这么来回填坑,直到左右指针相遇。
这里有几个关键点:
- 基准值必须先单独存下来,因为
nums[l]会被后面填坑覆盖。 - 两个内层
while都要带l < r,不然指针可能跑出区间。 - 用
>=和<=而不是>和<,可以让相等的元素也跨过指针,避免在相等元素较多的数组中陷入死循环。
2.2 填坑法背后到底发生了什么
你第一次看这段代码可能会觉得乱:一会儿r -= 1,一会儿l += 1,到底哪个区间是合法的?
理清楚之后其实很简单:
[原来的 l, 当前 l)这个区间里的元素,都已经小于等于 pivot(当前 r, 原来的 r]这个区间里的元素,都已经大于等于 pivot[当前 l, 当前 r]是还没处理的中间区域
每一轮填坑要么让l前进,要么让r后退。中间区域不断缩小,最终变成空集,l和r相遇,这个位置就是 pivot 的家。
这份partition代码是快速排序的分区函数,也是很多双指针题的底层素材。你把它当成一个“按基准值分块”的模板,那么很多看似无关的题其实都是这个模板的变体。
2.3 左右夹逼适合哪些场景
左右夹逼最常见的使用背景是:你希望把数组按照某个条件分成左右两段,并且不关心两段内部的相对顺序。例如:
- 把小于某个数的放左边,大于等于的放右边
- 把偶数放左边,奇数放右边
- 把不等于目标值的放左边,等于目标值的放右边
- 在标准快速排序中,把小于基准值的、等于基准值的分开处理
写左右夹逼代码时,有一个极易踩的坑:两个内层 while 的相等判断。丢掉等于号的后果是什么?我举个简单例子,数组是[2, 2, 2, 2],基准值取 2。如果内层循环用nums[r] > pivot,右指针不会越过等于 pivot 的值,r会一直卡在原地,外层循环变成死循环。所以数组元素大量重复时,等于号的选择是生死问题,不是风格问题。
还有一点要说明:左右夹逼不会保证“稳定”。也就是说,原本相对顺序相同的元素,在交换后可能乱序。如果题目明确说“保持相对顺序不变”,那基本不能用这种双向交换法,要改用同向双指针或者另找稳定分区写法。
3. 同向双指针:快慢指针的“写读分离”
3.1 移动零:快慢指针最经典的入门题
移动零是很多刷题网站的双指针入门第一题。题目很直接:把数组中的 0 全部挪到末尾,同时保持非零元素的相对顺序。
最省事的双指针版本是这样的:
def move_zeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1这里的快指针fast负责从头到尾看完每个元素,慢指针slow指向“下一个非零元素该放的位置”。当快指针遇到非零元素时,就把它和慢指针位置交换,然后slow前进一格;遇到 0 时什么都不做,fast继续走。
slow和fast都在往右走,速度却不一样。slow总是停在已经处理好的非零区间的末尾,fast则一路探索未知区域。如果slow == fast,交换就是自己和自己的交换,代码也不会有问题;如果slow < fast,交换后slow位置得到非零元素,而fast位置拿到一个 0。这个 0 正好是被“甩”到后面的,符合题目要求。
3.2 维护不变量:三个区间一目了然
移动零代码简单,思想却不简单。每一轮循环结束后,数组其实被分成了三段:
[0, slow):所有已经处理完的非零元素[slow, fast):所有已经被扫描到、但属于“待清理”的 0[fast, len):还没扫描的元素
你会发现,快指针每走一步,第三段变短,第二段变长或保持不变,第一段只有当发现非零元素时才变长。如果不变量想明白了,你就会理解为什么交换而不是直接赋值:直接赋值会把nums[fast]原来的 0 信息丢掉,而交换能保证[slow, fast)里面始终都是 0,下一次交换才能把 0 往后挪。
这个“三区间模型”几乎可以套用到所有同向双指针分区题中去。每当你写一个同向双指针,先画一条线,标上slow和fast,然后问自己:[0, slow)是什么,[slow, fast)是什么,[fast, n)是什么?只有三者性质清晰,代码才是真的写懂了。
3.3 原地删除有序数组中的重复项
再举一个高频例子:给定有序数组,原地删除重复出现的元素,返回新长度。比如[0, 0, 1, 1, 1, 2, 2, 3, 3, 4],处理后前五个位置是[0, 1, 2, 3, 4],返回 5。
代码可以这么写:
def remove_duplicates(nums): if not nums: return 0 slow = 1 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow这里slow从 1 开始,是因为第一个元素天然算作保留项。slow - 1代表已经保留部分的最后一个元素。如果fast位置的元素和它不同,说明这是一个新元素,应该放到slow位置。如果相同,说明还是重复,跳过。
注意比较对象是nums[slow - 1],不是nums[fast - 1]。虽然有序数组里这两者在很多场景下结果一样,但在某些变体里会不一样。比较slow - 1才是真正在确保“当前元素和已有结果的最后一个元素不同”,这是更通用的写法。还有一个小细节:这里用赋值而不是交换。因为题目要求去重,数组尾部的脏数据根本不需要清理,只要你返回正确的长度,评测逻辑只会检查前slow个位置。
4. 三指针与荷兰国旗问题:分块从两块到三块
4.1 为什么二块分得好好的,还要三指针
有些题目会把数组分成三块。最典型的就是颜色分类,也常被称为荷兰国旗问题。数组里只有 0、1、2 三种值,你需要在一次遍历内完成排序,让所有 0 在最前面,所有 1 在中间,所有 2 在最后面。
如果用快速排序的 partition 思路,你可以先按“小于 1 和大于等于 1”分一次,再对右边分一次,但那样需要两次遍历。题目要求一次遍历,这就需要三个指针同时维护三个区间。
def sort_colors(nums): left = 0 i = 0 right = len(nums) - 1 while i <= right: if nums[i] == 0: nums[left], nums[i] = nums[i], nums[left] left += 1 i += 1 elif nums[i] == 2: nums[right], nums[i] = nums[i], nums[right] right -= 1 # 注意:i 不能自增 else: i += 1三个指针各有职责:
left指向“下一个 0 该放的位置”right指向“下一个 2 该放的位置”i是当前正在扫描的元素
不变量如下:
[0, left)里的元素全是 0[left, i)里的元素全是 1(right, n-1]里的元素全是 2[i, right]是还没有处理的未知区间
4.2 最关键的细节:遇到 2 时为什么不能立刻前进
这是荷兰国旗问题最迷惑人的地方。当nums[i] == 2时,代码把nums[i]和nums[right]交换,然后只让right -= 1,却不执行i += 1。很多人第一次都会问:为什么这里不像遇到 0 时那样顺手i += 1?
原因很简单:nums[right]自己还没被扫描过。你把它换到i位置后,这个从右边来的数可能是 0,可能是 1,也可能是 2。它到底该放到哪个区间,必须留在原位置再看一次。如果交换后马上i += 1,一个值为 0 的元素就可能被漏掉,排序就会出错。
反过来,nums[i] == 0时为什么可以放心i += 1?因为nums[left]这个位置已经在left区间的边界上,它要么是 1,要么就是当前i本身。从左边交换过来的数不可能是 2,因为在[0, left)都是 0,[left, i)都是 1,这个区间没有 2。所以交换后要么i原本指向 0(自己换自己),要么被换过来一个 1,这两种结果都已经被处理好了,i可以放心前进。
4.3 三指针循环边界:用<=还是<
如果你把条件写成while i < right,最后一次元素可能没处理。而写成while i <= right才能保证i和right指向同一个未知元素时,这个元素也能被正确归位。很多教科书版本会写while i <= right,就是因为这个原因。
你可以拿极端例子验算一下:
- 空数组:
right = -1,i = 0,条件0 <= -1为假,直接结束。 - 全 0:每次遇到 0,
left和i一起走,right不动,最后所有 0 归位。 - 全 2:每次遇到 2,交换后
right一直左移,i停在原地,直到i > right时结束,所有 2 被甩到右侧。 [1, 0, 2]:模拟一遍,你会发现若不使用<=,最后一个位置在某种状态下可能来不及判断。
荷兰国旗问题可以说是所有双指针分块题里最考察“不变量意识”的一道题。代码很短,但你要能讲清楚为什么三个分支的指针步进方式各不相同,才算真的学会。
5. 打开新世界:把分块双指针用到具体题里
5.1 移除元素:不是删除,是覆盖
给定数组nums和目标值val,原地移除所有等于val的元素,返回新长度。这题可以理解成“按是否等于 val 分块”。
def remove_element(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow这和移动零几乎一脉相承。快指针扫描,慢指针指向“下一个合法位置”;只要fast遇到的元素不是val,就覆盖到slow位置,然后slow加一。数组前面的部分是“非 val 区”,后面是不可用的残留数据,不需要清理。
有人问:这里用覆盖而不是交换,会不会丢数据?在你只关心返回值slow的前提下,不会丢。因为后面被覆盖掉的内容已经都是无效值,不参与最终结果。如果你还想保留数组后面的信息,那就应该考虑用交换。这个选择的本质是“尾部数据是否还有价值”。
5.2 按奇偶排序:左右夹逼的典型变体
题目要求把所有偶数放前面,奇数放后面,不要求保持相对次序。这时候左右夹逼就非常合适。
def sort_array_by_parity(nums): left = 0 right = len(nums) - 1 while left < right: # 左指针停在奇数上 while left < right and nums[left] % 2 == 0: left += 1 # 右指针停在偶数上 while left < right and nums[right] % 2 != 0: right -= 1 if left < right: nums[left], nums[right] = nums[right], nums[left] left += 1 right -= 1 return nums这个代码和快速排序 partition 的外层结构很像,只是判断条件从“和 pivot 比较大小”变成了“奇偶判断”。左右两个内层循环不断跳过已经合法的元素,遇到不合法的就停下来交换。注意交换后还要再移动一次指针,否则下一次循环还会停留在原来的位置,虽然不会出错,但会增加无意义的交换。
5.3 有序数组两数之和:左右夹逼的另一个面
你可能觉得这题和分块没关系,但它和左右夹逼用的是同一个不变量:左指针只会让和变大,右指针只会让和变小。
def two_sum_sorted(numbers, target): left = 0 right = len(numbers) - 1 while left < right: cur = numbers[left] + numbers[right] if cur == target: return [left, right] elif cur < target: left += 1 else: right -= 1 return []因为数组已经有序,numbers[left]是最小可能,numbers[right]是最大可能。当前和小于目标,说明左边这个数太小了,只能左指针右移;当前和大于目标,说明右边这个数太大,只能右指针左移。每次移动都会排除一行或一列候选值,时间复杂度降到 O(n)。这一段虽然是“查数”而不是“分块”,但它和分块类题目共享同一套左右指针心智模型。学会一个,另一类题目也能降维打击。
5.4 快排分区函数是万能底子
把上面几种场景放在一起看,你会发现很多双指针题都是partition的换皮。移动零是“按是否为 0 分区”,移除元素是“按是否等于 val 分区”,按奇偶排序是“按奇偶分区”,颜色分类是“三路 partition”。
所以我的建议是:不要把每一道题单独背,而是抽出一个通用函数骨架。遇到新题时先判断它属于“两块分区”还是“三块分区”,再判断能不能在原数组上通过交换完成,最后套用左右夹逼或同向快慢的模板。一旦模板匹配上,剩下的就是改判断条件。
6. 常见问题与排查技巧实录
6.1 死循环到底怎么定位
双指针写挂最典型的表现是死循环。问题往往出在内层 while 缺少边界判断,或者等于号用错。
我举一个真实调试过的例子。有人写快速排序 partition 时,把内层循环写成这样:
while nums[r] >= pivot: r -= 1看着没问题,但当右指针扫过l的位置后,r就会继续往左越界,甚至访问数组负下标。更隐蔽的问题是数组全相等时,外层while l < r永远成立,但内层while也永远成立,最后r一路变成负数。正确姿势是每个内层while都加上l < r,并且根据需求决定要不要加等于号。
排查死循环时不要盯着代码发呆,拿小数组在纸上画指针位置。如果发现某一轮循环后l和r没有任何变化,说明一定有一个条件矛盾了。也可能是外层条件写成了while l <= r而当前代码逻辑永远无法让指针相交,这种错误在荷兰国旗问题里最常见。
6.2 越界和结果不对,多半是不变量没守住
数组分块题里“结果不对”比“死循环”更难查。因为代码能跑完,但返回长度差一位,或者元素顺序不符合要求。
常见的错误表现和原因如下:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 返回长度偏大 | 慢指针初始化位置不对 | 考虑第一个元素是否天然保留 |
| 某个合法元素被覆盖 | 同向双指针里误用赋值而尾部还有用 | 先判断尾部数据是否参与最终结果 |
| 分区后相对顺序乱 | 用了左右交换 | 改用同向双指针保持稳定 |
| 荷兰国旗排序结果不对 | 遇到 2 时错误地自增 i | 右边换来的元素没检查,i 必须原地不动 |
| 快排 partition 返回位置不对 | 基准值没先存下来 | pivot 单独用变量保存 |
| 空数组越界 | 没有判空 | 函数开头处理边界 |
我自己排查时有个习惯:在每个循环结尾打印nums、left、right、slow、fast。比如颜色分类打印完后,可以肉眼检查[0,left)是不是全 0,(right,end]是不是全 2。如果发现某次循环后不变量被打破,就顺着这一次交换找问题,通常很快能定位。
6.3 经典自测用例速查表
刷这类题时,我每次都会准备一套固定用例,写完代码先拿它们跑一遍:
[] 空数组 [1] 单元素 [1,1,1,1] 全等元素 [0,0,0,0] 全零 [2,0,2,1,1,0] 典型的荷兰国旗用例 [1,2,2,0,1,2] 随机混排 [0,1,0,3,12] 移动零经典用例不要小看这些用例。全等数组可以查死循环,空数组查越界,随机混排查不变量。很多看起来“优雅”的分区代码,在全等数组上跑一次就会原形毕露。
我再补一个自己总结的小技巧:同向双指针的slow初值,不是在 0 就是在 1,区别在于第一个元素需不需要特殊处理。移动零从slow = 0开始,因为 0 可能是脏数据;去重从slow = 1开始,因为第一个元素必然保留。做题时先问自己“第一个位置是否无条件合法”,答案直接决定初始化位置。
7. 一点私人体会:双指针不是背模板,而是背不变量
说句实在话,双指针数组分块这块内容,我教过很多人,也见过无数人栽跟头。最容易犯的错误不是不会写代码,而是想靠背模板蒙混过关。可模板只是骨架,真正决定代码对不对的是你心里有没有三个区间。
我到现在写荷兰国旗问题时,还会在心里默念:[0, left)是 0,[left, i)是 1,(right, end]是 2,[i, right]是未知。只要这句话还在,任何一步交换都不会写错。移动零也一样,[0, slow)是非零,[slow, fast)是零,[fast, end]是未知。把区间画出来,代码只是区间边界的移动规则。
如果你现在刚开始学,建议别贪多。先老老实实手动模拟至少五组用例,把每次交换后的区间状态写出来。这个过程很笨,但却是最快的。等你能不假思索地说出每个指针代表什么,再去刷更多的题,你会发现双指针从“背下来的技巧”变成了“长在手上的本能”。
最后分享一个小习惯:每次写完双指针代码,我都会刻意删掉一个边界条件,看代码会不会挂。比如把while i <= right改成while i < right,故意跑一次错误用例。这个过程不是没事找事,而是把边界条件的意义刻在记忆里。踩过一次坑之后,你就再也不会在同样的问题上翻车了。