双指针

一、介绍

双指针指的是在遍历对象的过程中,使用两个指针进行相同或相反方向的扫描,从而达到相应的目的。此处的指针并非C语言中的指针,而是索引。

双指针算法是一个遍历对象的过程,因而其常应用于数组、链表

双指针算法的最重要的目的是,将较高时间复杂度(O(n^2))降为线性的时间复杂度(O(n)),是一种对暴力搜索算法的优化。

二、场景引入

问题:给定一个升序排列的数组和一个目标值,从该数组中找出两个元素,使它们的和等于目标值。

【暴力搜索】
两层 for 循环直接KO

def twoSum(nums: List[int], target: int) -> List[int]:
    for i in range(len(nums)):
        for j in range(i+1, len(nums)):
            if nums[i] + nums[j] == target:
                return [i, j]

很明显,O(n^2)的时间复杂度可能很难AC。如何优化?在暴力搜索算法中,内外层指针均扫描一遍整个数组,在最终解决问题的过程中,扫描数组是必须的步骤,但暴力搜索是一个一个比对,在此过程中进行了许多不必要的比较,如 数组为 [1,2,4,5,7,9] ,target=8 时,首先对于元素 9 不需用再寻找,即不用有内循环的过程。其次,当外循环扫描到 4 时,[5,7,9] 均大于target,当再扫描到 5时,[5,7,9] 肯定也是大于target的,不需用再遍历。

【双指针】
同样扫描整个数组,使用一个指针从前向后扫描,另一个指针从后向前扫描,当二者相遇停止,停止时刚好遍历完整个数组。而在此过程中,根据数组的特性以及目标值,因此左边指针指向较小元素,右边指向大的,当二者和大于目标值时,右指针向左移动一位,从而使二者和减小;当二者和小于目标值时,左指针右移一位,从而使二者和增大。

双指针算法和暴力搜索算法有联系吗?怎样从暴力过度(联想)到双指针?对于双指针算法可以这样理解:左指针看作暴力搜索的外层循环指针,右指针看作内循环指针。如 数组为 [1,2,4,5,7,9] ,target=12 时,左指针指向 1,右指针指向 9,其和小于 12,则左指针指向 2。此过程相当于只用比较了一次,就代替了暴力搜索中内循环遍历整个数组的过程,即内层扫描都不满足结果,外层指针移向下一个位置。元素 2同理。当左指针指向 4,右指针指向 9,其和大于 12,则右指针左移指向 7,继续和左指针元素 4 求和。此过程相当于暴力搜索过程,外指针指向 4,内层循环先和 9求和不满足结果,再和 7求和比较结果的过程。而当外指针指向 5 后,也不用在和 9 进行求和,因为其必然大于目标值。整个的过程都是在减少不必要的比较,且整个数组只用遍历一遍,甚至不需用遍历完,从而降低时间复杂度。

def pair_with_targetsum(arr, target_sum):
  left, right = 0, len(arr) - 1
  while(left < right):
    current_sum = arr[left] + arr[right]
    if current_sum == target_sum:
      return [left, right]

    if target_sum > current_sum:
      left += 1# 移动左指针
    else:
      right -= 1# 移动右指针
  return [-1, -1]

 

三、双指针问题解题流程

1.什么样的问题适合用双指针技巧?

通常问题中,当是从一个数组(尤其是有序的情况)链表中,找到一个元素的子集,该子集需要满足某种限制。 这时候就特别适合用双指针。这个子集可能是某两个元素,某三个元素,甚至是一个子数组。

如引例中,在一个升序数组中,找到两个元素,这两个元素满足其和等于目标值。

2.双指针如何移动?

(1)方向

双指针的移动方向分为同向异向

【同向】 即两个指针向同一个方向移动,多用于判断子(连续)数组是否满足某个条件或根据要求判断出需要对数组从前向后遍历且需要两个指针记录,链表的双指针操作通常都是同向。通常该情况下,使用两个指针 l 和 r 都从起始位置开始,直到 r 指针移动到数组末尾,l 指向满足条件的子数组(或是需要进行操作的起始位置)起始位置,r 指向满足条件的子数组结束位置(或是需要进行操作的结束位置),两个指针的移动方向都是从前向后。

【异向】 即两个指针向相反方向移动,多用于题目中明显需要对数组前后两部分进行操作的情况或对数组某个区间的最大值(可以是区间最大,也可以是通过区间求得的值最大)情况,当数组有序,且是与数组中不同元素的和、乘积等相关的判断,通常先考虑异向。通常该情况下,使用两个指针 l 和 r 分别从数组的起始和末尾开始,直到两个指针相遇。l 指向数组前部分需要操作的位置,r 指向数组后部分需要操作的位置,两个指针移动方向相反,l 从前向后,r 从后向前。

(2)时间

同向的情况下,l 指向满足条件的子数组的起始位置,r 一直向后移动直到子数组 [l...r] 不满足条件后,l 开始向后移动,移动到子数组 [l...r] 满足条件再移动 r,重复进行直至指针 r 移动到数组末尾。

异向的情况下,l 移动到数组前部分需要操作的位置,r 移动到数组后部分需要操作的位置,重复进行直至二者相遇。
 

四、实例

数组

1. 题目链接:盛最多水的容器

[题目分析]:盛水的容量等于两个板(端点值)的最小值乘以两个板之间的距离。因此,当两个端点距离最大时,盛水容量可能会较大。当然还与端点值的大小有关,为了使得端点值中最小值尽可能的大,每次更新较小的端点值从而找寻更大的较小值。该要求相当于找数组中最大的区间长于区间端点最小值的乘积,因此可以使用双指针技巧,并且指针为异向。

[算法]:设置双指针,分别指向数组的起始和末尾位置,记录其盛水量,移动较小端点值的指针,每次移动后都更新最大盛水量。移动前期端点值之间的距离大,但端点值的最小值不一定大;移动后期端点值之间的距离小,但端点值的最小值不一定小。在整个过程会对盛水量有记录,因此不会遗漏前期可能的最大值。

点击查看代码

class Solution:
    def maxArea(self, height: List[int]) -> int:
        
        n=len(height)
        l,r=0,n-1
        res=0
        while l<r:
            #更新最大盛水量
            res=max(res,min(height[l],height[r])*(r-l))
            #指针移动——每次移动端点值较小的指针
            if height[l]>height[r]:
                r-=1
            else:
                l+=1
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组height的长度。
  • 空间复杂度:S(n)=O(1)。
     

2. 题目链接:删除有序数组中的重复项

[题目分析]:删除过程不可使用额外空间,即在原数组上进行删除操作。则与删除数组中元素过程相同,即删除一个元素,其位置之后的元素依次前移一位,删除移动的过程中最关键的在于确定好删除位置和移动位置。因此,在有序数组中,从前向后遍历若有重复元素,定位第一个重复元素和最后一个重复元素位置进行删除移动操作,可以使用双指针技巧同向移动,分别指向上述两个位置进行操作。

[算法]:两个指针 l、r 均从起始位置开始,当两个指针所指元素相同时,r 指针右移直至二者不相同,否则二者同时右移。当找到相同元素后,依次将r 指针后的元素向前移动,移动到 l+1 的位置后,l 指针向右移动一位。

点击查看代码

class Solution:
    def removeDuplicates(self, nums: List[int]) -> int:
        n=len(nums)
        l=r=0
        while r<n:
            #找到相同元素区间,r指向最后一个相同元素的后一个位置
            #即l指针之后第一个与nums[l]不同的元素位置,即需要进行移动的位置
            while r<n and nums[l]==nums[r]:
                r+=1
            if r<n:
                #将指针r的元素移动到 l+1 位置上,移动后l 右移一位
                l+=1
                nums[l]=nums[r]
        return l+1

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组nums的长度。
  • 空间复杂度:S(n)=O(1)。
     

【类似题目】
2.1 题目链接:移除元素

方法1:排序+双指针

[题目分析]:原地删除,与实例 2 的过程相同,并且排完序后最多只有一个与目标值相同的区间,因此直接双指针定位到目标值区间的起始,再进行删除移动即可。

[算法]:数组排序后,两个指针 l、r 均从数组起始位置开始,右指针 r 的值不等于目标值则两个指针同时向右移动,相等时 r 指针右移至不等于目标值的位置,之后依次将指针 r 的值移动到 指针 l 处,两个指针再同时右移一位直至指针 r 移动到数组末尾。

点击查看代码

class Solution:
    def removeElement(self, nums: List[int], val: int) -> int:
        nums.sort()
        n=len(nums)
        l=r=0
        while r<n and nums[r]!=val:
            l+=1
            r+=1
        while r<n and nums[r]==val:
            r+=1
        while r<n:
            nums[l]=nums[r]
            l+=1
            r+=1
        return l

[复杂度分析]

  • 时间复杂度:T(n)=O(nlogn),n 为数组nums的长度。
  • 空间复杂度:S(n)=O(logn),排序所需空间。
     

方法2:直接双指针

[算法]:不用提前排序,直接使用双指针,则数组中会存在多个与目标值相同的区间,即将方法一中的过程作为循环体即可,整体的循环仍是右指针 r 遍历完整个数组。只是再删除移动的过程中,删除元素移动指针后如果新的指针指向的元素等于目标值,则只单独移动指针 r,l 指针不变即可,这样之后该位置要么被 r 指针之后不等于目标值的元素替换,要么就不包含在删除后数组中。

点击查看代码

class Solution:
    def removeElement(self, nums: List[int], val: int) -> int:
        n=len(nums)
        l=r=0
        while r<n:
            while r<n and nums[r]!=val:
                l+=1
                r+=1
            while r<n and nums[r]==val:
                r+=1
            while r<n:
                #删除移动过程中又遇到目标值,则只移动指针 r
                if nums[r]==val:
                    r+=1
                    continue
                nums[l]=nums[r]
                l+=1
                r+=1
        return l

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组nums的长度。
  • 空间复杂度:S(n)=O(1)。
     

2.2. 题目链接:压缩字符串

[题目分析]:对数组中连续重复字符进行压缩,即遍历整个数组,找到若干个重复字符的区间,再原地压缩,因此可以考虑同向的双指针方法。过程与实例 2 类似,定位区间的开始和结尾,将其压缩为 “[字符,个数]” 的子串,而当个数为 1 时,不进行压缩,即子串不变。

[算法]:两个指针 l、r 均从数组起始位置开始,指针 l 指向需要压缩的字符,指针 r 指向即将需要压缩的字符。对于每一个重复字符的区间,初始化个数为 0,右指针 r 右移至与指针 l的元素值不同时停止,在此过程中记录重复字符的个数。当个数大于 1 时,将个数变成字符加在该重复字符的后面,即指针 l+1 的位置,个数有多少位,指针 l 右移几位。压缩完后,l 指针右移一位,作为存放新的压缩字符的位置,之后将 r 指针位置的元素直接放在指针 l 处开始新的字符压缩。则在每一次循环开始前,需将指针 r 的元素放在指针 l 处作为压缩的开始,因为初始 l、r 均为 0,所以也适应该过程。

点击查看代码

class Solution:
    def compress(self, chars: List[str]) -> int:
        n=len(chars)
        l=r=0
        while r<n:
            #将即将要压缩的元素放在指针 l 处
            chars[l]=chars[r]
            cnt=0
            while r<n and chars[r]==chars[l]:
                cnt+=1
                r+=1
            #当个数大于 1 时进行压缩
            if cnt>1:
                #将个数的每一位变成字符,构成压缩的形式
                for c in str(cnt):
                    chars[l+1]=c
                    l+=1
            #更新新的压缩字符的位置
            l+=1
        return l

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组chars的长度。
  • 空间复杂度:S(n)=O(1)。
     

2.3. 题目链接:唯一元素的和

方法1:排序+双指针

[题目分析]:该题与实例 2 的本质相同,数组排完序后有若干个重复元素的区间,即该区间中重复元素个数大于 1,因此使用个双指针定位到目标值区间的起始,只不过定位到该区间后不进行操作,而当不在这样的区间时再操作。

[算法]:数组排序后,两个指针 l、r 均从数组起始位置开始,右指针 r 的值等于左指针 l的值则 r 指针右移至不等于目标值的位置且不能超过数组长度,即指针 r 指向包含指针 l 的元素的重复子数组结束位置的下一个位置,r-l 即为重复元素子数组的长度,若该长度大于 1,则不予操作,直接将指针 l 指向 r;若等于 1,则将指针 l 的元素值加入结果中,l 指针右移一位。

点击查看代码

class Solution:
    def sumOfUnique(self, nums: List[int]) -> int:
        #【排序+双指针】
        nums.sort()
        l=r=res=0
        n=len(nums)

        while r<n:
            while r<n and nums[l]==nums[r]:
                r+=1
            #元素nums[l]出现多次
            if r-l>1:
                l=r
            #元素nums[l]只出现一次
            else:
                res+=nums[l]
                l+=1
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(nlogn),n 为数组nums的长度。
  • 空间复杂度:S(n)=O(logn),排序所需空间。
     

方法2:哈希计数

[算法]:直接统计数组中每一个元素出现次数,将出现一次的值加起来即可

点击查看代码

class Solution:
    def sumOfUnique(self, nums: List[int]) -> int:
        #【哈希计数】
        c=collections.Counter(nums)
        res=0
        for num,cnt in c.items():
            if cnt==1:
                res+=num
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组nums的长度。
  • 空间复杂度:S(n)=O(n),哈希表的空间。
     

3. 题目链接:按奇偶排序数组

[题目分析]:将数组变成数组前半部为偶数,后半部分为奇数。即将前半部分的奇数与后半部分的偶数进行互换,是一种明显的对数组前后部分进行操作的过程,因此可以使用异向的双指针方法。

[算法]:两个指针 l、r 分别指向数组的起始与末尾,指针 l 的元素只存放偶数,指针 r 的元素只存放奇数,当二者移动到不满足条件的位置后,进行值交换直至左右指针相遇。其实该过程就是 “快速排序” 的整体思路,即快排的本质为双指针。

点击查看代码

class Solution:
    def sortArrayByParity(self, nums: List[int]) -> List[int]:
        n=len(nums)
        l,r=0,n-1
        while l<r:
            while l<r and nums[r]%2:
                r-=1
            while l<r and not nums[l]%2:
                l+=1
            nums[l],nums[r]=nums[r],nums[l]
        return nums

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组nums的长度。
  • 空间复杂度:S(n)=O(1)。
     

【类似题目】
3.1 题目链接:仅仅反转字母

[算法]:与实例 3 完全相同,从数组的前后出发,唯一的区别在于,当前后字符交换后,两个指针还需要各自左右移动一位。

点击查看代码

class Solution:
    def reverseOnlyLetters(self, s: str) -> str:
        lis=list(s)
        n=len(lis)
        l,r=0,n-1
        while l<r:
            while l<r and not lis[r].isalpha():
                r-=1
            while l<r and not lis[l].isalpha():
                l+=1
            lis[l],lis[r]=lis[r],lis[l]
            #继续移动一位
            l+=1
            r-=1

        return "".join(lis)

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为字符串 s 的长度。
  • 空间复杂度:S(n)=O(n)。
     

4. 题目链接:删除最外层的括号

[题目分析]:找到数组中若干个有效括号字符子串,将其括号中的内容输出,则主要是要定位有效括号字符子串的起始结束位置,从而这两个位置之间的内容即为所需。因此可以使用同向的双指针方法。

[算法]:两个指针 l、r 均指向字符子串的起始位置,l 指针指向有效括号字符串的起始位置,r 指针指向有效括号字符子串的结束位置。则移动指针 r 直至 [l...r] 为一个有效括号字符子串,将指针之间的内容记录后,l 指针指向下一个有效括号字符子串的起始位置,即 r 指针的后一个位置。如何判断有效括号字符子串?使用一个初始为 0 标记,对于待验证子串中出现 “(” 标记加一,出现 “)” 标记减一。当标记为 0 时,[l...r] 为有效括号字符子串。

点击查看代码

class Solution:
    def removeOuterParentheses(self, s: str) -> str:
        n=len(s)
        l=r=cnt=0
        res=""
        while r<n:
            if s[r]=='(':
                cnt+=1
            else:
                cnt-=1
            #cnt为0 则找到一个有效括号字符子串
            if not cnt:
                res+=s[l+1:r]
                #l移动到前一个有效括号字符子串的下一个位置,即新的有效括号字符子串的起始位置
                l=r+1
            r+=1
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为字符串 s 的长度。
  • 空间复杂度:S(n)=O(n)。
     

【类似题目】
4.1 题目链接:分割平衡字符串

[算法]:与实例 4 完全相同,平衡字符串即有效括号串。

点击查看代码

class Solution:
    def balancedStringSplit(self, s: str) -> int:
        n=len(s)
        l=r=bal=res=0

        while r<n:
            if s[r]=='L':
                bal-=1
            else:
                bal+=1
            #标记 bal 为 0 时,找到一个平衡字符串      
            if not bal:
                res+=1
                l=r+1
            r+=1
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为字符串 s 的长度。
  • 空间复杂度:S(n)=O(1)。
     

5. 题目链接:三数之和

[题目分析]:三个数字同时进行选择判断时比较困难,可以使用 “定一议二” 的思想,确定一个数,然后选择两个和为第一个数相反数的两个数字即可。可以先对数组进行排序再操作,这样即可以避免重复和防止遗漏,也可以使用异向的双指针快速确定另外两个数。

[算法]:数组先排序,遍历整个数组,即先确定一个数,每次在这个数后面的区间内寻找另外两个符合条件的数,因此当确定的数大于 0 时,则直接返回结果,因为数组有序,确定的数之后的数均大于该数,和必然大于 0。而如果确定的数与其前一个数相等,则不再对该数进行寻找,因为之前已经以该数在更大的区间范围内寻找过结果,避免重复寻找。当开始寻找另外两个数时,使用双指针 l、r 分别指向确定数(位置为 i)之后区间的起始(i+1)和末尾(n-1,n为数组长度),之后整个过程和引例完全相同——有序数组和一个目标值,找到两个数的和等于目标值。只是在过程中,要进行避免重复寻找的操作。

点击查看代码

class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        n=len(nums)
        if(not nums or n<3):
            return []
        nums.sort()
        res=[]
        #遍历有序数组,即先确定一个数nums[i]
        for i in range(n):
            if(nums[i]>0):
                return res
            #去重
            if(i>0 and nums[i]==nums[i-1]):
                continue
            #在[i+1...n-1]子数组内寻找和等于目标值的两个数
            l=i+1
            r=n-1
            while(l<r):
                if(nums[i]+nums[l]+nums[r]==0):
                    res.append([nums[i],nums[l],nums[r]])
                    #避免重复
                    while(l<r and nums[l]==nums[l+1]):
                        l=l+1
                    while(l<r and nums[r]==nums[r-1]):
                        r=r-1
                    l=l+1
                    r=r-1
                elif(nums[i]+nums[l]+nums[r]>0):
                    r=r-1
                else:
                    l=l+1
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(n^2),n 为数组nums的长度.
  • 空间复杂度:S(n)=O(logn),排序的空间占用。
     

6. 题目链接:一次编辑

[题目分析]:两个字符串的“差距”大于一则不可以一次编辑,小于等于一则可以。该“差距”表示两个字符串不同字符的个数,当然该个数也包含了长度差,两字符串长度差大于一同样也不可一次编辑。同时从前向后遍历两个字符串,比较对应位置的字符是否相等,判断不相等的次数。很明显,同时遍历两个字符串则需要使用两个指针,这与合并两个有序数组类似。

[算法]:两个指针 i、j 分别指向两个字符串的起始位置,每一次比较对应字符是否相等,不相等则差距加一,当差距大于一时,直接返回 False,即不可以一次编辑,而关键在于,在对应字符不相等但差距不大于一的情况下,字符串短的指针不变,长的指针右移一位,而如果长度相等,则同时右移一位。只要比较过一次,两个指针都会向右移动一位,因此要指针不变,可以先向左移动一位即可实现。

点击查看代码

class Solution:
    def oneEditAway(self, first: str, second: str) -> bool:

        n,m=len(first),len(second)
        if abs(n-m) >= 2:
            return False
        i=j=dis=0

        while i<n and j<m:
            if first[i]!=second[j]:
                dis+=1
                if dis>1:
                    return False
                #比较字符不相等时,字符串短的指针位置不变,长的右移一位
                #相等则在之后同时右移一位
                if n>m:
                    j-=1
                elif n<m:
                    i-=1
            i+=1
            j+=1
        return True

[复杂度分析]

  • 时间复杂度:T(n)=O(n+m),n m 分别为字符串first、second的长度。
  • 空间复杂度:S(n)=O(1)。
     

7. 题目链接:单词距离

方法1:哈希+双指针

[题目分析]:统计目标单词出现的位置,用哈希表存储。之后相对于分别从两个有序的数组中选择一个数,二者差值最小。两个有序数组中选择,则必然使用两个指针进行遍历。

[算法]:哈希表中键为单词,值为列表,即单词出现的所有位置。两个单词对应的位置列表是有序的,两个指针 i、j 分别指向两个列表的起始位置,对应做差,并更新结果。若差值大于 0,则移动较小值的指针;若差值小于 0,则移动较大者的指针。因为要使得距离最短,即差值尽可能的趋向 0。

点击查看代码

class Solution:
    def findClosest(self, words: List[str], word1: str, word2: str) -> int:

        #【哈希+双指针】
        dic=defaultdict(list)
        for idx,word in enumerate(words):
            dic[word].append(idx)
        lis1,lis2=dic[word1],dic[word2]

        n,m=len(lis1),len(lis2)
        i=j=0
        res=100000
        while i<n and j<m:
            dis=lis1[i]-lis2[j]
            res=min(res,abs(dis))
            if dis>0:
                j+=1
            else:
                i+=1
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组words的长度。
  • 空间复杂度:S(n)=O(n),哈希表的空间。
     

方法2:直接双指针

[算法]:不使用哈希记录目标单词所有位置,直接遍历整个words数组,使用两个指针分别指向两个目标单词最新的位置,然后更新结果。此时双指针只作为记录,而不作为移动变化的标志。

点击查看代码

class Solution:
    def findClosest(self, words: List[str], word1: str, word2: str) -> int:
        idx1=idx2=-1
        res=100000
        for idx,word in enumerate(words):
            #更新word1 最新位置
            if word==word1:
                idx1=idx
            #更新word2 最新位置
            elif word==word2:
                idx2=idx
            #更新结果
            if idx1>=0 and idx2>=0:
                res=min(res,abs(idx1-idx2))
        return res

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为数组words的长度。
  • 空间复杂度:S(n)=O(1)。
     

8. 题目链接:找到 K 个最接近的元素

方法1:排序

[题目分析]:只要找到数组中 abs(arr[i] - x)最小的 k 个数即可。

[算法]:先将数组按照abs(arr[i] - x)从小到大排序,然后取出前 k 个数,再将这 k 个数排序。

点击查看代码

class Solution:
    def findClosestElements(self, arr: List[int], k: int, x: int) -> List[int]:
        arr.sort(key = lambda num: abs(num - x))
        return sorted(arr[:k])

[复杂度分析]

  • 时间复杂度:T(n)=O(nlogn),n 为数组 arr 的长度。
  • 空间复杂度:S(n)=O(logn),排序所需栈的空间。
     

方法2:二分 + 双指针

[算法]:先通过二分法找到位置 idx,当 i < idx 时,arr[i] < x;当 i >= idx 时,arr[i] >= x。设置双指针 l、r 分别指向 idx - 1、idx。比较 arr[r] - xx - arr[l] 之间的关系,从而根据条件决定哪个指针移动,移动指针则表示选取该位置的数,一共比较 k 次。

【关键点】:

  1. 边界问题
    l < 0 以及 r >= n 时,则不用考虑该侧的数据,直接加入对方的数据
  2. 指针移动
    以一个方向(左侧)为主,则当 r >= len(arr) or arr[r] - x >= x - arr[l] 时,l -= 1,即取了 l 变化之前对应位置的数。此时,对于右侧,不能直接认为为左侧条件的对立条件就可以了,因为要结合边界问题,如果直接用 if...else...,则会出现 l < 0 的情况。因此要将左侧判定条件拆分开,一个为 l < 0,一个则为剩余的情况,并且要把 l < 0 放在最前面判断。
  3. 最终取哪些值
    首先,每一个 k > 0 代表取一个数,共取 k 个数,l、r 变化表示取值,则每一次循环结束后l、r对应的位置为取完值后的位置,因此在取完 k 个数后,最终取 [l + 1 ,... ,r - 1]之间的数(包含边界),因此最终为 arr[l + 1, r]

点击查看代码

class Solution:
    def findClosestElements(self, arr: List[int], k: int, x: int) -> List[int]:
        # 二分法找位置
        r = bisect_left(arr, x)
        l = r - 1
        for _ in range(k):
            # 当左侧已经到头时,直接加入右侧数据
            if l < 0:
                r += 1
            # 加入左侧数据的情况
            elif r >= len(arr) or arr[r] - x >= x - arr[l]:
                l -= 1
            else:
                r += 1
        return arr[l + 1:r]

[复杂度分析]

  • 时间复杂度:T(n)=O(logn),n 为数组 arr 的长度。
  • 空间复杂度:S(n)=O(1)。
     

【其余题目】
350. 两个数组的交集 II475. 供暖器541. 反转字符串 II977. 有序数组的平方2047. 句子中的有效单词数  

链表

1. 题目链接:删除链表的倒数第 N 个结点

[题目分析]:删除链表节点,需要确定删除节点的前一个节点。链表不同于数组,可以根据索引直接获取数据,若要删除倒数第 k 个结点,普通方法则是先遍历一遍链表得到链表长度 n,然后再遍历到第 n-k 个结点处进行删除,相当于遍历两边链表。能否只遍历一遍?使用两个指针,一个指针指向头结点,另一个指针指向头结点之后第 k 个结点的位置。之后两个指针同时移动,即二者之间保持 k 个结点的距离。当靠近链表尾的指针指向尾结点时,相当于遍历完一遍链表,此时较前的指针在较后指针的前 k 个结点位置处,而该位置后即为链表倒数第 k 个结点,则可以进行删除。

[算法]:两个指针 l、r 都指向头结点,先将指针 r 向后移动 k个结点,即指针 l、r 之间保持 k 个结点的距离。若移动完后,指针 r 为空,则说明倒数第 N 个结点即为头结点,直接删除头结点即可;若不为空,则两个指针同步向后移动直到指针 r 指向尾结点(即 r.next==None),此时直接删除指针 l之后的结点。

点击查看代码

class Solution:
    def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
        
        l=head
        r=l
        while n and r:
            r=r.next
            n-=1
        #r为空,说明删除头结点
        if not r:
            return head.next
        #删除非头结点
        while r.next:
            l=l.next
            r=r.next
        l.next=l.next.next
        return head

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为链表 head 的长度。
  • 空间复杂度:S(n)=O(1)。
     

2. 题目链接:相交链表

[题目分析]:该相交链表特点是,如果相交,相交结点即之后的结点均相等,即两个链表公用了后半部分或者说是重合了。指向两个链表头结点的指针同时出发,每次移动一个结点。相交情况下,交点之前,若两个链表的结点相同,则直接相遇;若不同,则两个指针在遍历完链表后不会相遇,但因为相交后结点相同,因此在遍历完一遍自身链表后,各自开始从对方链表的头结点开始继续遍历,则第二轮遍历必然相交。

原因是:假设相交链表 A、B,相交前链表 A 有 l 个结点,链表 B 有 r 个结点,重合节点个数为 s,指针 a、b 分别遍历链表 A、B,各自遍历完后指针 a 移动距离为 l+s,指针 b 为 r+s。第二轮遍历,指针 a,b 分别遍历链表 B、A,当遍历到相交点时,指针 a 移动距离 l+s+r,指针 b 移动距离 r+s+l。因为两个指针都是每次移动一个结点,即速度相同,因此遍历到相交点时时间相同,即同时到达相交点。

[算法]:指针 a、b 从链表 A、B 头结点出发,直到各自遍历完自身链表,即 a==None、b==None 时开始遍历对方链表。如果没有相交,则两个指针会在遍历完对方链表后,同时指向空指针,因为两个指针的移动距离均为两个链表的长度和。需要注意的是,当遍历到自己链表最后一个结点时,不用直接移动到对方链表,而是继续向后移动(移动到空指针),在下一次移动时再根据空指针决定移动到对方链表。

点击查看代码

class Solution:
    def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
        while a!=b:
            a=a.next if a!=None else headB
            b=b.next if b!=None else headA
        return a

[复杂度分析]

  • 时间复杂度:T(n)=O(n+m),n、m 分别为链表 A、B 的长度。
  • 空间复杂度:S(n)=O(1)。

但如果到尾结点直接移动到对方链表(如下),则相当于多移动了一步,导致指针移动速度不同,从而结果错误。

while a!=b:
            a=a.next
            if not a:
                a=headB
            
            b=b.next
            if not b:
                b=headA
        return a

 

3. 题目链接:环形链表

方法1:哈希表

[题目分析]:遍历每个结点,判断该结点是否被访问过即可。

[算法]:从前向后遍历链表的每个结点,使用哈希表来存储所有已经访问过的节点。当遍历结点已经存在于哈希表中,则说明有环;遍历结束,也就说明没环。

点击查看代码

class Solution:
    def hasCycle(self, head: Optional[ListNode]) -> bool:

        st=set()
        q=head
        while q:
            if q in st:
                return True
            st.add(q)
            q=q.next
        return False

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为链表 head 的长度。
  • 空间复杂度:S(n)=O(n),哈希表的空间。
     

方法2:双指针

[题目分析]:如果链表有环,则在环中存在:两个指针任意位置出发,一个指针一次移动一步,另一个指针一次移动两步,则在出发后某一时刻两个指针一定会相遇。因此反之用之,使用两个指针,前进速度不同,如果某个时刻能相遇,即两个结点相等则存在环。

[算法]:使用快慢指针 slow、quick 均从链表头结点出发,slow 指针一次移动一位,quick 指针一次移动两位,移动过程中如果出现 "slow==quick",则说明有环,因为二者相遇了。如果 quick 指针指向链表的尾结点,即 "quick.next==None" 或者 "quick==None" 则说明不存在环。

点击查看代码

class Solution:
    def hasCycle(self, head: Optional[ListNode]) -> bool:

        slow,quick=head,head
        while quick:
            slow=slow.next
            if quick.next:
                quick=quick.next.next
            #quick 指针为链表尾指针,即说明没环
            else:
                return False
            #两指针相遇,说明存在环
            if slow==quick:
                return True
        return False

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为链表 head 的长度。
  • 空间复杂度:S(n)=O(1)。
     

【类似题目】
4.1 题目链接:环形链表 II

[算法]:与实例 4 整体框架完全一样,关键点在于,找到相遇点后如何找到入环的第一个节点?

假设有环链表的头结点为 A,入环的第一个结点为 B,第一次在环上相遇的结点为 C,当两个指针相遇时,慢指针走的路程为 AB+BC,快指针走的路程为 AB+BC+CB+BC,且慢指针路程长度为快指针路程的一半,则有 2*(AB+BC)= AB+BC+CB+BC,即 AB+BC = CB+BC,因此 AB = CB,即链表的头结点到入环的第一个结点的距离等于环上相遇的结点到入环的第一个结点的距离。

则当快慢指针相遇后,慢指针重新指向头结点,快指针位置不变,二者同步出发且速度相同,均一次移动一个结点位置,当二者相遇时,即为入环的第一个结点。

点击查看代码

class Solution:
    def detectCycle(self, head: ListNode) -> ListNode:
        slow,quick=head,head
        while quick:
            slow=slow.next
            if quick.next:
                quick=quick.next.next
            else:
                return None
            #环中相遇
            if slow==quick:
                #慢指针指向头结点
                slow=head
                #二者同步,同速度移动
                while slow!=quick:
                    slow=slow.next
                    quick=quick.next
                return slow
        return None

[复杂度分析]

  • 时间复杂度:T(n)=O(n),n 为链表 head 的长度。
  • 空间复杂度:S(n)=O(1)。
     

当然也可以使用哈希表方法。

点击查看代码

class Solution:
    def detectCycle(self, head: ListNode) -> ListNode:
        st=set()
        q=head
        while q:
            if q in st:
                return q
            st.add(q)
            q=q.next
        return None

通常链表中的删除操作的原理便是双指针 —— 删除位置的前一个结点 q 以及删除位置的下一个结点 p,q.next = p,但通常都比较简单,只要确定好位置即可。