ChatGPT、Claude、Copilot 代码写得飞快,似乎能掌握基本语法能给 AI 下正确的指令能判断 AI 生成的代码是否靠谱能理解你自己所做领域的核心逻辑,对绝大多数非计算机科班出身的我们来说,够用了。

        但这并不意味着代码能力本身不再重要。从写一段代码,转向构建一个可运行、可维护、可解释的系统。不只是语法层面的熟练度,而是系统逻辑、程序架构、数据结构背后的抽象方式,并行、异步的性能优化与取舍,以及如何将一个科学问题转化为可执行程序。

        这样来看,我们还是需要学一点算法,或言之一种代码和解决问题的底层直觉,本文介绍双指针。使用 Python 语言


1. 为什么会有双指针这种思路

面对数组或字符串这类线性结构的问题,我们最自然的反应往往是枚举。两个数的关系,就用两个下标去试;一个区间的性质,就枚举所有可能的子区间。很多时候只是下标挪了一小步,却重新计算了一大堆已经算过的内容。前一次枚举得到的信息,在下一次枚举中被完全丢弃,仿佛失忆一般。于是,时间复杂度很容易从线性膨胀到平方,甚至立方。这样算太老实了,每一步都从零开始。

在一个线性结构上反复扫描时,有没有可能让已经付出的计算,在后续步骤中继续发挥作用?双指针正是在这种背景下用到的。通过两个指针的配合,把原本重复的扫描过程压缩成一次有方向的线性推进。

双指针并不是为某几道题量身定做的技巧,也不是因为题目名字里出现了two才用它。决定是否能使用双指针的是问题本身的结构特征。只要数据以线性的形式排列,并且允许我们在移动指针时不断排除不可能的情况,就能把暴力枚举压缩为线性过程。


2. 什么时候用

与二分思想类似,判断一个问题是否适合用双指针,在于单调性。单调性并不一定体现在数组本身递增递减。当随着某个方向推进,答案的可能性只会变少而不会反复变化,就有可能通过移动指针来逐步逼近答案。

最典型的情况是有序数组。它本身带有明确的方向感。指针每移动一步,都会排除掉一批不可能的情况,不需要回头。更普遍的一类来自区间的变化。很多问题关注的不是单个元素,而是一段连续区间的某种性质,比如区间和、区间内不同元素的数量、是否满足某个约束条件。在这类问题中,区间向右扩展时,某些量往往只会变大或变小;向左收缩时又会朝相反方向变化。这种变化本身就是单调的,即使数组本身完全无序,也不妨碍我们使用双指针。还有一种更隐蔽的单调性体现在指针移动本身。当右指针向右移动时,我们关心的某个指标是否只会增加、不可能减少?如果答案是肯定的,就可以放心地让指针一直向前走。概括来说,向着某个方向推进,答案的可能性只会变少而不会反复变化,双指针的思想就是自然的

确认了单调约束之后,还需要检查能不能用指针的移动替代重复计算。暴力方法中,每一次枚举一个新区间,意味着从头到尾重新统计一遍。而在双指针思路下,区间的变化是连续的,右端点向右移动,相当于引入一个新元素;左端点向右移动,相当于移除一个旧元素。只要区间性质可以通过这种引入和移除来更新,就不需要重算整段内容。具体形式是相向、同向,还是逐步结算,只是表现不同。


3. LeetCode 例子

双指针问题的本质,是一个不回头的线性推进过程。面对这类题目,应当从最直觉的暴力解法出发,先弄清楚它在枚举什么。几乎所有能用双指针优化的问题,都会在相邻枚举中反复计算同一类信息。接下来要寻找单调性,判断是否存在某个量会随着指针向某个方向移动而只增不减或只减不增,从而保证被跳过的那一部分答案空间不可能再成为解。一旦这种单调性成立,就可以用指针去裁剪答案空间,并且要求指针的每一次移动都能在逻辑上否定或结算一整类情况,且能够清楚说明为什么这样的移动不会漏解

逐个回答这四个问题:

暴力枚举慢在哪里?有没有一个量在某个方向上是单调变化的?能不能通过移动指针一次性否定一整类情况?被否定的部分是否永远不可能是答案?


3.1  LeetCode 26 删除有序数组中的重复项

最直观的暴力想法是遍历数组,遇到重复就把它删掉,但每删一次都要把后面所有元素整体左移一位,无疑这样同一批元素被重复移动了,如何优化这种重复呢

答案是不移动。题目中非严格递增提供了两个信息,1.相同的值一定挤在一起;2.一旦走出某个值的连续段,不可能再遇到它。这是一种抽象的单调结构,我们需要处理的数只会越来越少,而重复这件事是成段出现的,仅需保留每段的第一个。

因此只需要跳过重复部分,将不重复的1个元素写入即可。写在哪里呢,需要一个位置

这里用同向双指针:fast 是扫描指针,slow 写入指针,指向下一个该写的位置,移动规则为:

如果 nums[fast] != nums[slow-1],说明 nums[fast] 是一个新值,保留nums[slow] = nums[fast],然后 slow += 1

否则说明还在同一段重复里,只让 fast += 1 继续扫

最后我们考虑会不会漏解,被跳过的那些元素,有没有可能应该被保留?当 nums[fast] == nums[slow-1] 时,我们跳过的是和最后一个保留值相同的元素。且数组有序,相同值是连续段,所以跳过的就是整段的多余部分,很干净。

class Solution(object):
    def removeDuplicates(self, 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

3.2 LeetCode 167 两数之和 II - 输入有序数组

最直观的想法是:固定一个数,再去后面找另一个数,用两层循环,复杂度是O(n^2)

for i in range(n):
    for j in range(i+1, n):
        判断 numbers[i] + numbers[j]

想象数组是有序的,比如固定 i=0,遍历numbers[0] + numbers[1]、numbers[0] + numbers[2]、numbers[0] + numbers[3]...

如果 numbers[0] + numbers[5] > target,numbers[0] + numbers[6] 和只会更大,不可能再等于 target,有序这个条件,让试错具备了方向性,而这是可以被优化掉的部分

数组非递减有序,因而对某一对下标 (i,j),只改变其中一个方向时,numbers[i]+numbers[j] 是单调变化的。我们不用固定 i,再枚举 j,而是把所有可能的 (i, j) 看成一个整体的答案空间。i 从左往右,j 从右往左,要做的不是试点,而是裁剪空间

指针从两端开始,如果和等于 target,直接返回。

若和小于 target,在有序性下,j已经是最大端,再减只会更小/不变,也就是说明左边的数太小。i++ 让左边变大;和大于target即当前右边的数太大, j--,这是指针的移动规则

最后考虑会不会漏解和小了时,放弃的是固定i的所有组合,大了放弃的是固定j的所有组合。由于数组有序,这些组合的和只会朝一个方向偏离 target,答案空间单调收缩,并不会漏解

class Solution(object):
    def twoSum(self, numbers, target):
        i, j = 0, len(numbers) - 1

        while i < j:
            s = numbers[i] + numbers[j]
            if s == target:
                return [i + 1, j + 1]  # 下标从 1 开始
            elif s < target:
                i += 1
            else:
                j -= 1

LeetCode 15 三数之和也是类似思路,排序后,三数之和固定一个数,变成两数之和。


3.3 LeetCode 11 盛最多水的容器

本题最直觉的解法是枚举两条线,枚举左边 i,枚举右边 j>i,面积= (j-i)*min(height[i], height[j]),最后取最大值。但很多换法从一开始就无意义,比如 j-i 变小了,但短板高度没变甚至更低,那面积必然不会变大,类似的情况是可以跳过的

毋庸置疑的是,面积由短板决定。当固定左右两条线(l,r)时,宽度已经确定 r-l ,而高度取决于较矮那条 min(h[l], h[r])。于是单调性便出现了。如果左边更矮,把右边界往左挪不可能让面积更大。每一步都能确定哪一侧是短板,而短板不变时,缩小宽度必亏。这就是能双指针的原因

移动规则

指针从两侧开始,area = (r-l) * min(h[l], h[r]),移动短板那一侧,如果h[l]<h[r],l+=1,否则 r-=1

如前面所说,在左边是短板的前提下,任何保持 l 不动、只移动 r 的组合,都不可能比当前更优。所以把这些组合整片丢掉是安全的,不会漏掉最优解。指针每移动一次,都是在单调地裁掉一大片不可能成为最优解的答案空间。

class Solution(object):
    def maxArea(self, height):
        l, r = 0, len(height) - 1
        ans = 0

        while l < r:
            h = min(height[l], height[r])
            ans = max(ans, (r - l) * h)

            if height[l] < height[r]:
                l += 1
            else:
                r -= 1
        return ans

3.4 LeetCode 42 接雨水

我们很容易想到,每个位置i能装的水=min(左边最高的墙, 右边最高的墙)-height[i],显然没必要对每个 i 分别找 leftMax、rightMax,这样有很多重复计算,相邻的 i,它们的 leftMax/rightMax 大部分是一样的。

本题思考的重点仍是短板,即水位由两边最高墙的较小者决定。如果确定左边最高墙更低,那当前位置的水位就只由左边最高墙决定,右边再高也无所谓。这就是双指针的单调确定性来源,对不同的位置,只需动态维护左右两个最高就行了,用两个指针

如何通过移动指针,一次性否定一整类情况呢

设两个指针从两端向中间走,如果 leftMax <= rightMax,对此时的l来说,不管之后如何移动,左边一定是短板,水位上限由此时的 leftMax决定。可以直接结算l这个位置:能接水 leftMax - height[l],并移动左边界 l+=1。

每一步都在确认某个位置已经被短板锁死,可以立刻结算并排除,从而进行单调裁剪。这样左右边界所夹区域总是未被处理的,指针可以义无反顾地走下去,直到它们碰面,用一个循环实现while l < r

class Solution(object):
    def trap(self, height):
        l, r = 0, len(height) - 1
        leftMax, rightMax = 0, 0
        ans = 0

        while l < r:
            if height[l] < height[r]:
                leftMax = max(leftMax, height[l])
                ans += leftMax - height[l]
                l += 1
            else:
                rightMax = max(rightMax, height[r])
                ans += rightMax - height[r]
                r -= 1

        return ans

3.5 LeetCode 611 有效三角形的个数

三角形条件(a≤b≤c)只要看 a+b>c 即可

我们知道不应该枚举所有三元组 (i, j, k),判断能不能成三角形,那样有很多不必要重复

如果数组排好序,一旦固定了最大边 c,当 a 或 b 变大时,a+b 只会变大;当 a 或 b 变小时,a+b 只会变小。这与两数之和如出一辙。

固定最大边 k 从后往前,在 [0..k-1] 里找有多少对 (i, j) 满足 nums[i] + nums[j] > nums[k],这里的单调性十分明显。维护 i=0、j=k-1,如果 nums[i] + nums[j] > nums[k] 成立,对同一个 j,所有更大的i都成立,直接计数 ans += (j - i),并让j往左:j -= 1

不成立时,对这个固定的 j ,所有 i'≤i 的和只会更小,更不可能成立。所以这整片 (0..i, j) 都可以直接丢掉,只能增大 i 才可能成立,不会遗漏

class Solution(object):
    def triangleNumber(self, nums):
        nums.sort()
        n = len(nums)
        ans = 0

        for k in range(n - 1, 1, -1):  # 固定最大边 nums[k]
            i, j = 0, k - 1
            while i < j:
                if nums[i] + nums[j] > nums[k]:
                    ans += (j - i)   # 批量计数
                    j -= 1
                else:
                    i += 1
        return ans

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐