双指针(题单顺序)
热题 100 - 双指针分组(按题单顺序):283 / 11 / 15 / 42
本页包含题单「双指针」分组的全部题目,顺序与 list.json 保持一致。
283. 移动零
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
示例 1:
输入: nums =[0,1,0,3,12]输出:[1,3,12,0,0]
示例 2:
输入: nums =[0]输出:[0]
提示:
1 <= nums.length <= 104-231 <= nums[i] <= 231 - 1
进阶:你能尽量减少完成的操作次数吗?
最优解(快慢指针:把非 0 往前压)
正在加载编辑器...
最优解讲解(通俗版)
- 先把题意翻译成大白话:把数组里的 0 全部挪到最后,其他非 0 的元素顺序不能乱,而且要“原地”做(不新建一个同样大的数组)。
- 朴素想法为什么不够好:
- 你可以先把所有非 0 抠出来,再补 0,但这要么用额外数组,要么实现起来容易写成 (O(n^2)) 的移动。
- 题目要的是:一次遍历、原地完成。
- 关键思路:把数组想象成两段:
- 左边是一段“已经整理好的非 0 区”(保持原顺序)
- 右边是“还没处理的区域”
- 怎么维护这两段?用快慢指针:
fast:从左到右扫一遍,负责“发现”非 0 元素slow:永远指向“下一个该放非 0 的位置”(也就是非 0 区的尾巴)
- 不变量(保证你不会写乱):
- 在任何时刻,区间
[0, slow)都是整理好的非 0(顺序与原数组一致) - 区间
[slow, fast]是被扫过但还没完全整理的位置
- 在任何时刻,区间
- 为什么看到非 0 就交换?:
- 当
fast指到一个非 0,说明这个数应该进入左侧非 0 区。 slow指向非 0 区尾巴的下一个位置,把fast的非 0 交换到slow,等于“把非 0 往前收纳”。- 然后
slow++,非 0 区变长。
- 当
- 用例子走一遍:
[0,1,0,3,12]- fast=0 看到 0:不动
- fast=1 看到 1:与 slow=0 交换 →
[1,0,0,3,12],slow=1 - fast=3 看到 3:与 slow=1 交换 →
[1,3,0,0,12],slow=2 - fast=4 看到 12:与 slow=2 交换 →
[1,3,12,0,0],slow=3
- 为什么顺序不会乱?:
fast是从左到右按原顺序发现非 0 的,发现一个就按slow的顺序依次放进去,所以非 0 的相对顺序天然保持。 - 复杂度:时间 (O(n)),空间 (O(1))(原地交换)。
类似题目(快慢指针:稳定压缩)
// 目标:原地删除/移动某类元素,保持其他元素相对顺序let slow = 0for (let fast = 0; fast < arr.length; fast++) {if (keep(arr[fast])) {arr[slow] = arr[fast] // 或 swap(arr, slow, fast)slow++}}// slow 之后的位置按题意补齐(例如补 0、截断等)
11. 盛最多水的容器
给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
说明:你不能倾斜容器。
示例 1:

输入:[1,8,6,2,5,4,8,3,7] 输出:49 解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
示例 2:
输入:height = [1,1] 输出:1
提示:
n == height.length2 <= n <= 1050 <= height[i] <= 104
最优解(左右指针夹逼:短板先动)
正在加载编辑器...
最优解讲解(通俗版)
- 先写出“你在最大化什么”:两条线
l和r形成的容器面积- 宽:
r - l - 高:
min(height[l], height[r])(短板效应) - 面积:
(r-l) * min(...)
- 宽:
- 朴素做法为什么慢:枚举所有 ((l,r)) 组合是 (O(n^2))。
- 关键推理:为什么“短板先动”是正确的?
- 假设当前
height[l] < height[r],也就是左边是短板。 - 你如果移动右指针
r--:- 宽度变小(必然)
- 高度仍然最多是
height[l](因为短板还在左边没变) - 所以面积不可能变大(宽变小,高上限不变)
- 只有移动左指针
l++才有机会找到更高的左边线,让短板变高,从而抵消宽度变小带来的损失。 - 右边短板时同理,移动右指针。
- 假设当前
- 你可以把它理解成一种“剪枝”:每一步都排除一大批“不可能更优”的组合,所以整体只需一趟 (O(n))。
- 用一个小片段走一遍(只展示关键几步):
[1,8,6,2,5,4,8,3,7]- l=0(1), r=8(7) → 面积 8*1=8,短板是左边 → l++
- l=1(8), r=8(7) → 面积 7*7=49,短板是右边 → r--
- 接着继续夹逼,最终最大就是 49
- 为什么不会漏掉最优解?:因为每次移动短板,都是在“保证不会错过更优解”的前提下丢弃一个指针位置(上面的推理已经证明移动长板不可能更优)。
- 复杂度:左右指针各走一遍,时间 (O(n)),空间 (O(1))。
类似题目(双指针夹逼:用“单调性”剪枝)
let l = 0, r = n - 1let best = -Infinitywhile (l < r) {// 用 l,r 计算一次候选答案best = Math.max(best, score(l, r))// 按题目性质移动一侧,使“可能变好”的条件更容易发生if (shouldMoveLeft(l, r)) l++else r--}
15. 三数之和
给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例 1:
输入:nums = [-1,0,1,2,-1,-4] 输出:[[-1,-1,2],[-1,0,1]] 解释: nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。 nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。 nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。 不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。 注意,输出的顺序和三元组的顺序并不重要。
示例 2:
输入:nums = [0,1,1] 输出:[] 解释:唯一可能的三元组和不为 0 。
示例 3:
输入:nums = [0,0,0] 输出:[[0,0,0]] 解释:唯一可能的三元组和为 0 。
提示:
3 <= nums.length <= 3000-105 <= nums[i] <= 105
最优解(排序 + 固定一个数 + 双指针去重)
正在加载编辑器...
最优解讲解(通俗版)
- 先从最笨的思路出发:三层循环枚举 ((i,j,k)) 看和是不是 0,时间 (O(n^3)),直接超时。
- 怎么降维?先排序:
- 排序后数组有序,很多“移动指针就能让和变大/变小”的推理才成立。
- 排序还能让“去重”变得简单(相同值会挨在一起)。
- 固定一个数,把 3Sum 变成 2Sum:
- 固定
nums[i]后,目标变成:在区间[i+1, n-1]找两数之和等于-nums[i]。 - 这就是经典的有序数组 2Sum:左右指针夹逼。
- 固定
- 双指针为什么能工作?(推理过程)
- 当前和
s = nums[i] + nums[l] + nums[r] - 如果
s < 0,说明总和太小;因为数组有序,想让和变大,最直接就是让l++(换一个更大的数)。 - 如果
s > 0,说明总和太大;想让和变小,就r--(换一个更小的数)。 s == 0就记录答案,然后两边都收缩继续找。
- 当前和
- 两个重要剪枝:
nums[i] > 0时可以break:因为后面都 ≥nums[i],三数之和不可能再等于 0。i去重:nums[i] === nums[i-1]时跳过,否则会产生重复三元组。
- 去重为什么必须做(而且怎么做):
- 找到一个解后
l++/r--,然后把连续相同的nums[l]、nums[r]跳过去,这样每一种值组合只会记录一次。
- 找到一个解后
- 用例子走一遍关键步骤:
[-1,0,1,2,-1,-4]- 排序后
[-4,-1,-1,0,1,2] - i=-1(第一个 -1)时,用 l/r 夹逼能找到
[-1,-1,2]和[-1,0,1] - i 到第二个 -1 会被去重跳过,所以不会重复输出
- 排序后
- 总体复杂度:排序 (O(n\log n)) + 外层循环 (n) 次、内层双指针整体均摊 (O(n)) → 总体 (O(n^2))
- 最常见坑:去重一定要在
i、l、r三处都做(尤其是找到解后要用 while 跳过连续相同值),否则会产生重复三元组。
类似题目(kSum:排序 + 固定 + 双指针)
// 经典套路:先排序,然后递归固定前 k-2 个数,最后用双指针做 2Sumfunction kSum(nums, start, k, target) {// k === 2: two pointers// k > 2: for i in [start..] 固定 nums[i],递归 k-1Sum(target-nums[i]),并注意去重}
42. 接雨水
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1:

输入:height = [0,1,0,2,1,0,1,3,2,1,2,1] 输出:6 解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例 2:
输入:height = [4,2,0,3,2,5] 输出:9
提示:
n == height.length1 <= n <= 2 * 1040 <= height[i] <= 105
最优解(双指针:维护左右最高墙)
正在加载编辑器...
最优解讲解(通俗版)
- 先把“能装多少水”说清楚:在位置
i上方能装的水量是min(左侧最高, 右侧最高) - height[i]- 直觉:水位由两边挡板里更矮的那一边决定(短板效应),再减去当前位置本身的高度。
- 朴素做法为什么慢:
- 对每个
i都去左边扫一遍找最高、右边扫一遍找最高 → (O(n^2))。 - 或者用两个数组预处理
leftMax[i] / rightMax[i]→ (O(n)) 但要额外 (O(n)) 空间。
- 对每个
- 双指针的核心思想:一边走一边“结算”可以确定的那一侧:
- 我们维护
leftMax:从左边走过的最高墙 - 维护
rightMax:从右边走过的最高墙 - 指针
l/r从两端往里收缩
- 我们维护
- 为什么可以只结算一侧?(推理过程,抓住“谁是短板”)
- 如果当前
height[l] < height[r],说明右边此刻至少有一堵高度为height[r]的墙挡着。 - 对
l这个位置来说,真正决定水位上限的,已经变成“左边最高leftMax能到多高”(因为右边这堵墙不比height[l]低,右侧不会先漏)。 - 所以这一步可以安全地把
l的水量算出来:leftMax - height[l](先更新leftMax)。 - 反过来,当
height[l] >= height[r]时,同理结算右边,用rightMax - height[r]。
- 如果当前
- 用小例子走一遍直觉:
[2,0,2]- l=0,r=2,左右一样高,走右边:rightMax=2,位置 2 结算 0
- r=1,height[l]=2 >= height[r]=0,继续结算右边:rightMax=2,水量
2-0=2 - 最终答案 2
- 为什么是 (O(n)) 且 (O(1)) 空间?
l/r每次至少移动一个,总共最多移动 n 次- 只维护几个变量,不需要辅助数组
- 边界情况:空数组或长度 < 3 直接是 0;实现时要注意先更新
leftMax/rightMax再结算,避免负数加到答案里。
类似题目(两端夹逼 + 维护边界信息)
let l = 0, r = n - 1let leftBest = init, rightBest = initlet ans = 0while (l < r) {if (arr[l] < arr[r]) {leftBest = update(leftBest, arr[l])ans += calc(leftBest, arr[l])l++} else {rightBest = update(rightBest, arr[r])ans += calc(rightBest, arr[r])r--}}