ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

双指针优化核心:单调性如何让复杂度从O(n²)降到O(n)

双指针优化核心:单调性如何让复杂度从O(n²)降到O(n)

双指针刷到一定数量,你大概率会有一种感觉:明明同一道题,暴力法是两层循环,换成双指针之后,一趟扫完就出结果,复杂度直接 O(n²) 降到 O(n)。但如果让你去给别人讲清楚“凭什么能这么剪枝”,往往又会卡住。其实绝大部分双指针题能优化成功,背后靠的都是同一个底层性质:单调性。这篇是双指针专题的第 7 篇,我想认真聊聊“单调性”到底指什么,它在滑动窗口、两数之和、三数之和、盛水容器这些经典题里是怎么起作用的,以及新手最容易在哪些地方翻车。适合已经写过几道双指针题、想彻底弄懂原理的人,也适合第一次听说“指针单调”但已经有一定代码基础的新手,照着本文的思路重新梳理一遍,会很有收获。

1. 单调性到底指什么:指针为什么可以不回退

1.1 先承认暴力解法的浪费在哪

以字符串中找最长无重复子串为例。最笨的解法是枚举每个起点 i,再从 i 往后枚举每个终点 j,然后检查 s[i..j] 这一段里有没有重复字符。这个过程中有一个很容易被忽略的浪费点:当外层循环从 i 变成 i+1 时,内层循环里的 j 又要从 i+1 开始重新扫一遍。可是你已经在上一次外层循环里把 j 一路扫到很远了,后一段的字符情况和刚才相比,只是开头少了一个字符,其他内容几乎没变。这种“内容基本相同却从头再扫”的重复劳动,就是 O(n²) 的来源。

如果用一个具体例子来看会更清楚。假设字符串是 abcabcbb,i=0 的时候,j 要扫到 3 才能发现自己碰上了重复字符。i 变成 1 之后,j 又从 1 开始扫。可是 s[1..3] 这段内容,在上一轮里明明已经被完整看过一次,现在又重新检查一遍,这就是冗余。暴力法的问题不在于“做了比较”,而在于比较对象几乎没变,却要重复建窗口、重复判断。

1.2 单调性的严谨定义

双指针里的“单调性”,我习惯用一句话概括:当一个指针移动并排除掉某些位置之后,这些被排除的位置在后续过程中永远不需要再被考虑。指针只朝一个方向走,搜索空间只缩不扩,不存在“先往前走,后面又回退”的情况。

对同向滑动窗口来说,left 和 right 都只会越走越远;对相向夹逼的双指针来说,left 往右、right 往左,最终在中间相遇。因为每个指针全程移动的距离不超过 n,所以总时间就是 O(n)。如果发现自己的写法里面某一个指针有时候会往回走,那基本可以断定这份代码退化成了 O(n²),或者解法本身还没理解透。

这里要强调一点:单调性并不是所有双指针题的必然属性。比如链表里用快慢指针找环,slow 和 fast 并没有严格意义上的“只增不减”,它的正确性来自速度差和环的结构。但凡是“滑动窗口”“有序数组相向夹逼”这类把 O(n²) 优化成 O(n) 的题,背后一定有一个单调的剪枝逻辑,否则线性复杂度不可能成立。你写出来的 while 循环看起来只是每次移动一个指针,实际上每一次移动都隐含了一个决策:我要放弃掉被排除掉的那一整批可能性。

1.3 一个统一视角:把搜索空间想象成矩阵

如果上面的表述还是有点抽象,我再提供一个自己很爱用的视角。假设我们要在数组上找满足条件的某种组合,把答案想象成一个由所有 (i, j) 组成的矩阵。暴力法会去检查矩阵里的每一个格子,所以是 O(n²)。双指针优化的本质,是每一轮移动都排除掉一整行或一整列,而且排除是有依据的:这一行或这一列剩下的格子已经不可能产生目标答案。因为指针单调移动,被割掉的区域不会重新长出来,所以总共割掉的行列数量是 O(n),而不是矩阵格子数 O(n²)。

这个视角对相向双指针尤其好用。排序后的数组,左指针 i 从左边来,右指针 j 从右边来。如果 nums[i] + nums[j] 小于目标值,说明把 j 往左换成更小的数只会让和更小,那么由 i 作为左端点搭配任意更靠右的 j 的组合全部不成立,于是直接砍掉矩阵的第 i 行。同理,如果和大于目标值,就砍掉第 j 列。砍着砍着,两个指针就相遇了。滑动窗口也一样:left 前进一次,等价于放弃掉所有以旧 left 开头、窗口起点在当前 left 之前的那些组合。

一旦形成这个“砍行砍列”的画面,你再看双指针代码里的 left++ 和 right--,就不会觉得它们是随意的移动了。

2. 滑动窗口里的单调性:最长无重复子串一次讲透

2.1 题目和暴力解法的问题

题目本身很经典:给定一个字符串 s,找出其中不含重复字符的最长子串的长度。比如 pwwkew,答案应该是 3,对应子串 wke 或 kew。

暴力解法在 1.1 里已经说过,枚举起点和终点,每次用 set 检查窗口内是否有重复。外层 i 每加一次,内层 j 就要从新的起点重新扫一遍,很多窗口内容被重复检查,整体耗时自然就成了 O(n²)。更麻烦的是,如果你的代码里每次都新建一个 set,那么连“上次已经判断过哪些字符”的信息也全部丢掉了。暴力法最核心的问题不是判断本身,而是它没有利用“窗口变化很小时,大部分字符状态可以保留”这个事实。

2.2 核心推理:为什么右指针不需要回头

直接看 pwwkew 这个例子。

初始 left=0,right=0。我们让 right 一路往右扫,同时用一个 set 记录窗口里的字符。当 right 走到下标 2 时,s[2] 是 w,而 set 里已经有 w 了,于是窗口里出现了重复字符。这时候正确的做法不是把 right 回退,也不是放弃当前窗口,而是把 left 往右移,把重复的字符从窗口里慢慢移出去。在这个例子里,需要先移除下标 0 的 p,再移除下标 1 的 w,left 最终来到下标 2,此时 set 清空,再加回当前的 w。

注意一个关键点:right 在这个过程中完全没有后退,它始终停在重复字符的位置,等 left 把窗口收拾好了再继续前进。为什么 right 不需要退回更靠前的位置?因为 right 之前能探测的窗口,都是在 left 更小时完成的,那些状态已经被上一轮考察过了。现在 left 变大,窗口范围整体右移,旧位置就算再让 right 回去,形成的窗口要么更小、要么重复,已经完全不可能比之前考察过的候选更优。搜索空间是单调收缩的:left 往右增加,right 只能维持原位置或继续向前,回退只会产生一个被上一次状态严格包含的窗口,没有意义。

整个扫描过程里,left 从 0 一路走到 2,right 从 0 一路走到 5,两个指针都只往前,从不回头。这就是滑动窗口单调性的最直观体现。

2.3 可运行的实现与复杂度证明

基于上面的推理,代码写出来非常短:

def lengthOfLongestSubstring(s: str) -> int: n = len(s) left = 0 seen = set() ans = 0 for right in range(n): while s[right] in seen: seen.remove(s[left]) left += 1 seen.add(s[right]) ans = max(ans, right - left + 1) return ans

这段代码里,外层 for 循环让 right 每次至少前进一格,内层 while 每执行一次 left 就加 1。left 总共最多加 n 次,right 总共最多加 n 次,所以两个指针的移动量加起来是 O(2n)。set 的插入和删除在平均情况下是 O(1),所以整个算法是 O(n) 时间、O(字符集大小) 空间。

这里有一个新手特别容易踩的坑:while 写完,有人顺手写成 if,因为脑子里的逻辑是“遇到重复字符,移除一次就好了”。但重复字符不一定只出现一次,比如 pwwkew 里 right 走到第二个 w 时,需要连续移除 p 和 w 两次,窗口才恢复干净。如果写成 if,set 里还会残留一个 w,后续判断全部都会出错。所以必须用 while,让 left 一直向右移动,直到当前 right 指向的字符不再出现在 set 里为止。

另一个很常见的优化写法是用字典记录每个字符最近出现的位置。遇到重复时不需要一步步移除,而是直接把 left 跳到重复字符上一个位置加 1,代码可以再快一点:

def lengthOfLongestSubstring(s: str) -> int: n = len(s) left = 0 pos = {} ans = 0 for right in range(n): if s[right] in pos: left = max(left, pos[s[right]] + 1) pos[s[right]] = right ans = max(ans, right - left + 1) return ans

注意这里更新 left 时要取 max,不能直接赋 pos[s[right]] + 1。如果不取 max,left 可能被字典里的旧位置往回拉,破坏单调性,窗口的起点就会出问题。这一点很重要:虽然优化了写法,但“left 只增不减”的原则一点都没变。

pwwkew:ab

等等,上面一行是我刻意标注的例子名,可以保留也可以去掉。我更正一下,不输出这种容易让人误解的简写。

2.4 一点额外说明:left 的移动带有“顺序性修复”作用

用 set 版做最长无重复子串,还有一个容易忽略的细节:set 本身是无序的,但我们通过 left 逐个移除字符来保证窗口内容是连续的。比如窗口里有 a、b、c,现在 right 遇到了 b,set 里判断 b 已存在,但我们不知道 b 在窗口里的具体位置。没关系,left 从最左边开始移动,先删 a,再删 b,删完 b 之后窗口里只剩 c,这时新的 b 可以被安全加进来。整个过程看似多删了一个 a,其实 a 本来就已经被排除到新的窗口之外了,这是窗口收缩的必然结果。

如果你调试过 set 版代码,会发现 set 里的元素和窗口中的元素是严格同步的,原因就在这里。这也是为什么我说 set 版虽然不如 dict 版“优雅”,但它和最原始的指针移动逻辑一一对应,最适合用来理解单调性。

3. 从“同向”到“相向”:两数之和、三数之和与盛水容器

3.1 两数之和(有序数组):每轮砍行还是砍列

如果说滑动窗口是同向双指针的典型,那有序数组上的两数之和就是相向双指针的入门题。给定升序数组 nums 和目标值 target,要找到两个下标使 nums[i] + nums[j] == target。常见解法不一定用哈希表,而是用左右指针从两端往中间夹:

def two_sum(nums: list[int], target: int) -> list[int]: left, right = 0, len(nums) - 1 while left < right: cur = nums[left] + nums[right] if cur == target: return [left, right] elif cur < target: left += 1 else: right -= 1 return []

用单调性的视角看:如果当前和小于 target,说明 nums[right] 不够大。此时如果把 right 往左移,nums[left] 不变,nums[new_right] 一定更小,和只会更小,更不可能达到 target。也就是说,当前 left 搭配右侧任何一个位置都不可能成立。这不是空想,而是基于“数组有序”推出来的严格结论。于是我们可以安全地 left++,排除掉一整行。反过来,如果当前和大于 target,则说明 nums[left] 太大,把 left 往右挪只是让和变得更大,那当前 right 搭配左侧任何位置也都已经没必要看了,于是 right--,排除掉一整列。

这个和 1.3 节的矩阵视角完全对上了。左右指针每走一步,都排除掉一行或一列,总的排除次数是 n 级,而不是矩阵单元数 n² 级。

3.2 三数之和:排序后从左到右固定,再从两端夹逼

三数之和也是双指针单调性的高频考题。题目要求找出数组中和为 0 的三元组,并且不能重复。思路是先排序,然后固定第一个数 nums[i],再用双指针在 i+1 到末尾这一段里找两数之和等于 -nums[i]。整体复杂度 O(n²),空间 O(1)(不计答案数组)。

代码大概长这样:

def three_sum(nums: list[int]) -> list[list[int]]: nums.sort() n = len(nums) ans = [] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 target = -nums[i] while left < right: cur = nums[left] + nums[right] if cur == target: ans.append([nums[i], nums[left], nums[right]]) left += 1 right -= 1 while left < right and nums[left] == nums[left - 1]: left += 1 while left < right and nums[right] == nums[right + 1]: right -= 1 elif cur < target: left += 1 else: right -= 1 return ans

这段代码里有几处和单调性直接相关的细节。第一,外层 i 固定后,内部 left 从 i+1 开始向右移动,right 从 n-1 开始向左移动,它们的移动逻辑和 3.1 完全一样。第二,left 和 right 都只朝一个方向移动,所以内层是 O(n)。第三,去重处理的位置很重要:外层跳过和上一个固定值相同的 i,内层在找到答案之后跳过和当前 left/right 相同的值。为什么要在找到答案之后才跳而不是一开始就跳?因为如果开头就跳过所有“看起来重复”的值,可能会漏掉恰好等于 target 的唯一合法组合。找到一组答案后,窗口两端已经确定,此时跳过的目标是避免同一组三元组被重复记录,这在逻辑上是安全的。

三数之和确实经常因为去重写错而卡壳,但本质上,只要理解了整个搜索空间是靠“固定第一维 + 相向双指针”来单调收缩的,去重时机就变得很好推理。

3.3 盛水容器:短板决定高度,指针移动同样单调

另一个很有意思的题是盛最多水的容器。给你一个整数数组 height,每个下标代表一根柱子的高度,要找出两根柱子之间能盛水的最大面积,面积等于两根柱子之间的距离乘以较矮柱子的高度,也就是 min(height[left], height[right]) * (right - left)。

经典解法是左右指针从两端开始:

def max_area(height: list[int]) -> int: left, right = 0, len(height) - 1 ans = 0 while left < right: area = min(height[left], height[right]) * (right - left) ans = max(ans, area) if height[left] < height[right]: left += 1 else: right -= 1 return ans

很多人刚看到这段代码会问:为什么矮的那边要向内移动而不是移动高的一边?答案依然来自单调性。假设 height[left] 小于 height[right],当前面积由 height[left] 这个短板决定。如果保持 left 不动、把 right 往左移动,容器的宽度只会变小,而高度最多不会超过 height[left](因为短板还是它),所以任何以当前 left 为左边界、搭配更靠左柱子的组合,面积都不可能超过当前已经算过的这个窗口。换句话说,当前 left 和任意更靠右的柱子之间的所有组合,都不需要再考虑了。

注意这里“任意更靠右”的范围其实只到当前 right 为止,因为 right 再往右的柱子已经作为右边界参与过更宽窗口的计算,而当时的面积也不会比现在差。所以 left 可以安全地向右移动一格。反过来,如果右边更矮,就移动 right。两个指针就这样相互靠拢,每一步都严格放弃掉一侧不可能更优的组合,最终总扫描次数 O(n)。

很多讲这道题的人会直接给出“移动较短的边”,但不解释背后的单调剪枝。一旦你把答案搜索过程理解成“每次比较完当前窗口后,必然有一侧的柱子不可能再产生更大面积,于是舍弃一侧”,代码的正确性就非常自然了。

4. 动手前先自查:四个最容易翻车的细节

4.1 没排序就开写相向双指针

两数之和、三数之和这类题,相向双指针的前提是数组有序。很多人看到一个数组题,上来就写 left=0、right=n-1,结果发现答案不对,回头才意识到原数组根本不是有序的。解决方式有两种:如果题目要返回下标,可以先记录原数组的下标映射,或者做成元组 (value, index) 再排序;如果题目只要求数值,直接排序即可。

如果是返回下标的两数之和,排序会改变原来的下标顺序,所以更稳妥的做法是直接用哈希表一次遍历。这里不是哈希表一定比双指针好,而是双指针方案需要额外处理下标映射,代码反而变复杂。做题前先明确:这个题要求的是值还是下标?“先排序”这个行为本身会不会破坏答案所依赖的信息。这个自查能省很多 debug 时间。

4.2 while 边界:left < right 还是 left <= right

双指针的循环条件经常让人纠结。我的经验是:先想清楚“两个指针指向同一位置时这个组合有没有意义”。

在滑动窗口里,如果 left == right,窗口里其实只有一个字符,这是合法的,所以很多滑动窗口代码不会把 left==right 当成退出条件。但在两数之和、三数之和里,left 和 right 指向同一个元素时,题目要求“两个不同的数”,所以这个组合不合法,循环条件必须是 left < right。盛水容器同理,一根柱子和自己配对没有意义,也是 left < right。

这个不是死记硬背,而是从题目含义里推出来的。写 while 之前先问问自己:当两个指针重叠时,我还能不能产生一个合法的候选答案?能,就用 <= 或直接放任 left > right 再退出;不能,就用 <。

4.3 去重时机:先跳过还是后跳过

去重时机的坑主要在三数之和这类题里。我见过很多写法是外层遍历时一旦发现 nums[i] 和下一位相同,就立即跳过;内层也是先判断左右指针和下一位相同就跳过,结果在某些用例下会漏答案。

正确习惯是,外层去重要比较 nums[i] 和 nums[i-1],而不是 nums[i+1]。因为前者保证“同一个固定值的第一个元素会被处理到,后续相同的只会被跳过”,后者在固定值第一次出现时反而把它跳过了。内层去重则应该放在找到一组答案之后,移动指针时顺便把相同的值跨过去,而不是在寻找答案之前提前跳。先找到合法的组合,再通过移动指针避免重复记录,逻辑才完整。

我还建议在本地多测几个含大量重复值的用例,比如 [0,0,0,0] 和 [1,1,-2,-2,0,0],快速验证自己的去重逻辑是否严格正确。

4.4 不会调试滑动窗口?打印窗口状态

滑动窗口代码短,很多时候错得很隐蔽。如果你不确定 left 和 right 的移动是否符合预期,我建议不要只盯结果,而是直接打印窗口。

以最长无重复子串为例,可以在加字符和移字符的地方加一段临时日志:

def lengthOfLongestSubstring(s: str) -> int: n = len(s) left = 0 seen = set() ans = 0 for right in range(n): while s[right] in seen: print("before remove: left=%d, right=%d, window=%s" % (left, right, s[left:right+1])) seen.remove(s[left]) left += 1 seen.add(s[right]) ans = max(ans, right - left + 1) print("after add: left=%d, right=%d, window=%s" % (left, right, s[left:right+1])) return ans

跑一个带有重复字符的输入,比如 pwwkew,你会发现 left 和 right 始终只增不减,窗口在 right 碰到重复字符时会短暂收缩,然后继续扩大。如果某个日志里 left 出现了回退,那说明代码里的 left 更新逻辑有问题。对于相向双指针,也可以打印 left、right、当前和、当前面积,一眼就能看到左右指针是否都在朝中间单调逼近。

这种“肉眼检查单调性”的方式,比赛和面试里来不及用,但平时练习时非常管用。它能帮你把抽象的“指针单调”概念变成一次次具体的日志输出,用不了多久就能形成直觉。

5. 写在最后:把它变成一种解题习惯

我在实际刷题过程中,最受益的一件事就是养成“写双指针代码之前先口头证明单调性”的习惯。每拿到一道疑似双指针的题,我先问自己三个问题:第一,我准备移动哪个指针?第二,这一步移动会排除掉哪些候选答案?第三,为什么这些被排除的答案不可能在未来重新变得有用?如果三个问题都能回答,那代码基本不会写出死循环或者 O(n²) 退化;如果其中一个答不上来,那我大概率是背模板而不是真理解题。

另一个很有用的扩展方向是:很多看起来和双指针无关的题,一旦你从单调性的视角去重新审视,也能找到线性解法。比如有序矩阵里搜目标值,从右上角开始,比目标大就往左走,比目标小就往下走,这就是按行和按列的单调剪枝;再比如接雨水问题里常用的“左右最大高度”预处理,本质上也和相向扫描的思想有着千丝万缕的联系。学习双指针专题时,不要只记这道题怎么写,而是把“单调性”当成一把万能钥匙,碰到新题先看排除逻辑是否成立,这会让你的优化能力在之后很多算法题里都发挥作用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进