相向双指针(有趣的算法讲解)

225 阅读11分钟

前言

确实,我还在于面试的准备😩😩😩,并抽空回顾与总结算法知识。在查阅网络资料时,我发现大多数的解释都显得枯燥无味。因此,我灵光一闪,决定以一种轻松幽默的方式,让我自己和大家都能愉快地学习算法。在这篇文章中,我尝试了漫画的形式来阐述,个人觉得还挺有趣的😏😏😏。

简介(来源科大讯飞)

相向双指针,即对撞指针,是双指针算法中的一种有效技巧,通常用于解决数组或字符串相关的问题。其中一个指针从起始位置开始移动,另一个从末尾开始,两个指针向中间靠拢,直到相遇或者找到满足特定条件的元素对。以下是具体介绍:

  1. 应用场景

    • 当数组或字符串是有序的时候,可以使用相向双指针来高效查找、配对或修改元素。例如在有序数组中寻找两个数之和等于特定值的对,或者在字符串中反转元音字母的位置等。
    • 当题目要求原地操作或者使用常数级的额外空间时,相向双指针特别有用。因为不需要额外的存储结构,只需要不断地更新两个指针的位置即可实现各种操作。
    • 在解决一些需要判断或统计满足特定条件的一对元素(如最接近的三数之和)时,相向双指针可以简化问题并减少不必要的比较。
  2. 算法原理

    • 利用数组的有序性,通过左右指针的对撞来逐步缩小查找范围,并在适当的时机进行元素的交换或比较。这种方法比单纯的线性查找或嵌套循环更高效,因为它最多只遍历数组一次。
    • 对于链表相关问题,比如检测链表中是否有环或者寻找环的起始位置,快慢指针方法同样适用。快慢指针以不同的速度在链表上移动,如果链表有环,则它们最终会相遇;如果无环,快指针会先到达链表尾部。
  3. 实际应用

    • 在两数之和、三数之和、最接近的三数之和等问题中,可以通过排序和相向双指针的结合来避免高时间复杂度的穷举。
    • 在进行字符串操作,如反转字符串中的元音字母时,通过设置左右两个指针从两端向中间扫描,遇到元音字母就交换,直到两个指针相遇或者左右指针交错,这样可以保证每个元音字母恰好被处理一次。
    • 在滑动窗口问题中,虽然主要是单指针的应用,但在一些变种问题中,也可以通过引入另一个指针作为对撞指针来解决更复杂的子问题。

总的来说,相向双指针是一种灵活且强大的技巧,它充分利用了数组或字符串的有序性和连续性,通过两个指针的相对运动来解决一系列问题。在实际应用中,需要根据问题的具体条件来巧妙安排两个指针的运动规则,以达到最优的解决效果。

漫画展示流程

设想相向双指针的两个指针分别为一个男人和一个女人,他们的任务是快速统计指定红球的数量,且每人仅能选择一列中的红球进行计数(红球数量按从小到大的顺序排列)。

image.png

如果只有一个指针,即只有男人或女人参与,那么他/她需要遍历两次以获取所有的红球。

image.png

拿到的组合是(1,3),(1, 4),(1, 5)······(8,9)显然这种方式太耗时。如果结果是(1,3)的话还可以接受,但如果是(8, 9)的话那真的好废人哦!

此刻,一位妹子闪亮登场,提出要帮忙,毕竟人们常说“男女搭配,干活不累”(开个玩笑,轻松一下)。

image.png

此时,男人和女人开始交替选择红球。男人先选到1个红球,女人则选到9个。如果目标要求红球总数量是20,那么显然无法达到这个要求。但如果目标数量是5,此时女人拿到的9个加上男人的1个,总数显然超过了需求。女人便提出,她选择的红球太多了,需要向男人的方向移动,以减少两人红球的总和。当女人调整至选取4个红球时,他们的数量完美达到了要求!

image.png

如果要求是7,情况依旧从男孩先取到1个红球,女孩取到9个红球开始。显然,9加1大于7,女孩于是开始向男人的方向移动,直到她选取的红球数量减少到5个。此时,女孩说:“现在我们拿的红球数量是5加1等于6,小于要求。你往我这边走一步吧!”于是男人向女孩的方向走了一步。 image.png 最后,女人向男人的方位迈出了一步,缔造了完美的结局。 image.png

大白话讲算法

通过之前的示例,大家应该对相向双指针有了初步理解。但可能有人会问:为什么在两人所选红球总数超过目标值时,女性要向男性方向移动,而当总数小于目标值时,男性要向女性方向移动呢?关键在于红球是按从小到大的顺序排列的,这意味着队列中的位置隐含了大小信息,而双向双指针恰好能利用这一点。

以要求总数为7的例子来说,初始时男性取1个红球,女性取9个。为了让总数减少,女性需要向男性方向移动。如果女性不动,让男性向右移动,由于球是顺序排列的,男性得到的红球数只会越来越多,从而使得总和进一步增加。因此,女性向左移动会使红球的总数减少,男性向右移动则会使总数增加。这样便巧妙地利用了红球位置的信息。

相向双指针正是利用这种位置信息来快速完成查找任务,特别适用于有序的情况。然而,这并非绝对,例如在反转字符串中的元音字母时,利用的是字符串连续性的特性。深入理解算法的原理才能灵活应用并发挥其最大效用。

具体实例

1、两数之和 II - 输入有序数组(位置信息)

给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列  ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1] 和 numbers[index2] ,则 1 <= index1 < index2 <= numbers.length 。

以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1 和 index2

你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。

你所设计的解决方案必须只使用常量级的额外空间。

示例 1:

输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

示例 2:

输入:numbers = [2,3,4], target = 6
输出:[1,3]
解释:2 与 4 之和等于目标数 6 。因此 index1 = 1, index2 = 3 。返回 [1, 3] 。

示例 3:

输入:numbers = [-1,0], target = -1
输出:[1,2]
解释:-1 与 0 之和等于目标数 -1 。因此 index1 = 1, index2 = 2 。返回 [1, 2]

实现代码如下(如果看不懂可以看看文章中的漫画部分)

class Solution:
    def twoSum(self, redBall: List[int], target: int) -> List[int]:
        man, woman = 0, len(redBall) - 1 # 男人和女人开始位置时的红球
        while man < woman: # 男人如果和女人重合则退出因为不可能两个都选一种红球
            redBalls = redBall[woman] + redBall[man] # 男人和女人选的红球总数
            if redBalls == target: # 红球总数和目标值一样则返回男女人的下标
                return [man + 1, woman + 1] # 题目中下标是从一开始的所以结果加一
            if redBalls > target:# 红球总数大于目标,女人往男人方向走
                woman -= 1
            else: # 红球总数小于目标,男人往女人方向走
                man += 1

2、反转字符串中的元音字母(利用字符串的连续性)

给你一个字符串 s ,仅反转字符串中的所有元音字母,并返回结果字符串。

元音字母包括 'a''e''i''o''u',且可能以大小写两种形式出现不止一次。

示例 1:

输入:s = "hello"
输出:"holle"

示例 2:

输入:s = "leetcode"
输出:"leotcede"

解题思路

首先分析题目要求我们对字符串中的元音字母进行反转,那么我们选择使用相向双指针来进行实现。在这个过程中,我们会遇到以下几种情况:

  • 当左边指针指向元音字母,右边指针也指向元音字母时,交换这两个元素,并使左右指针都向中间移动一格。
  • 当左边指针指向元音字母,而右边指针不指向元音字母时,仅将右边指针向中间移动一格。
  • 当左边指针不指向元音字母,而右边指针指向元音字母时,仅将左边指针向中间移动一格。
  • 当两个指针都不指向元音字母时,两个指针都向中间移动一格。

这样的操作确保了只有在必要的时候才进行字符交换,并且能够有效地通过字符串来查找和交换元音字母。

实现代码如下

class Solution:
    vWord = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U']
    def reverseVowels(self, s: str) -> str:
        sList = list(s)
        left = 0 
        right = len(s) - 1
        while left < right:
            if sList[left] in self.vWord and sList[right] in self.vWord:
                sList[left], sList[right] = sList[right], sList[left]
                left += 1
                right -= 1
            elif sList[left] in self.vWord:
                right -= 1
            elif sList[right] in self.vWord:
                left += 1
            else:
                left += 1
                right -= 1
        return ''.join(sList)

3、盛最多水的容器(利用连续性的性质)

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。

找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

返回容器可以储存的最大水量。

说明:你不能倾斜容器。

示例 1:

image.png

输入:[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

解题思路

在面对如何设计一个能够盛放最多水的容器的问题时,我们首先需要明确,在二维平面上,这实质上是寻找容器可能的最大面积。通过基础的几何知识,我们知道面积可以通过底乘以高来计算。进一步分析,容器的底和高与构成容器边界的线段高度及其间距密切相关。

为了精确计算最大面积,我们选择采用双向双指针策略。这种方法的核心在于动态调整指针位置来探索不同的底和高组合,从而找到产生最大面积的最优解。具体操作中,我们比较两条边界线的高度,始终选择移动较矮的那条线对应的指针。若两边界线高度相同,则可任意选择移动其中一条线的指针。

实现代码如下

class Solution:
    def maxArea(self, height: List[int]) -> int:
        n = len(height)
        left, right = 0, n - 1
        res = 0
        while left < right:
            res = max(res, min(height[left], height[right]) * (right - left))
            if height[left] < height[right]:
                left += 1
            else:
                right -= 1
        return res

文章最后的碎碎念

写完这篇文章后,我发现自己真的很享受创作漫画的乐趣。虽然我之前从未涉足这个领域,但这次尝试让我倍感兴奋。然而,当我在网上搜索学习资源时,却感到有些失望。因此,如果你对这方面有了解,并愿意分享学习路线的建议,我将非常感激。