python双指针

双指针技巧再分为两类,一类是**「快慢指针」,一类是「左右指针」**。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。

一、快慢指针的常见算法

快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。

1、判定链表中是否含有环

这属于链表最基本的操作了,学习数据结构应该对这个算法思想都不陌生。

单链表的特点是每个节点只知道下一个节点,所以一个指针的话无法判断链表中是否含有环的。

如果链表中不含环,那么这个指针最终会遇到空指针 null 表示链表到头了,这还好说,可以判断该链表不含环:

力扣第 141 题就是这个问题,解法代码如下:

class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        slow = fast =head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if slow == fast:
                return True
        return False

2、已知链表中含有环,返回这个环的起始位置

img

这是力扣第 142 题,其实一点都不困难,有点类似脑筋急转弯,先直接看代码:

class Solution:
    def detectCycle(self, head: ListNode) -> ListNode:
        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if slow==fast:
                break
        if not fast or not fast.next:
            return None

        slow = head
        while slow!=fast:
            slow = slow.next
            fast = fast.next
        return slow

可以看到,当快慢指针相遇时,让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。这是为什么呢?

第一次相遇时,假设慢指针 slow 走了 k 步,那么快指针 fast 一定走了 2k 步:

img

fast 一定比 slow 多走了 k 步,这多走的 k 步其实就是 fast 指针在环里转圈圈,所以 k 的值就是环长度的「整数倍」

说句题外话,之前还有读者争论为什么是环长度整数倍,我举个简单的例子你就明白了,我们想一想极端情况,假设环长度就是 1,如下图:

img

那么 fast 肯定早早就进环里转圈圈了,而且肯定会转好多圈,这不就是环长度的整数倍嘛。

言归正传,设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。

巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。你甭管 fast 在环里到底转了几圈,反正走 k 步可以到相遇点,那走 k - m 步一定就是走到环起点了:

img

所以,只要我们把快慢指针中的任一个重新指向 head,然后两个指针同速前进,k - m 步后就会相遇,相遇之处就是环的起点了。

3、寻找链表的中点

类似上面的思路,我们还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。

力扣第 876 题就是找链表中点的题目,解法代码如下:

class Solution:
    def middleNode(self, head: ListNode) -> ListNode:
        slow = fast = head
        while fast and fast.next: # 前者是偶数长度终止条件,后者是奇数长度终止条件
            slow = slow.next # 移动一步
            fast = fast.next.next # 移动两步
        return slow

4、寻找链表的倒数第 n 个元素

这是力扣第 19 题「删除链表的倒数第 n 个元素」,先看下题目:

img

我们的思路还是使用快慢指针,让快指针先走 n 步,然后快慢指针开始同速前进。这样当快指针走到链表末尾 null 时,慢指针所在的位置就是倒数第 n 个链表节点(n 不会超过链表长度)

(为了方便,我们在原有链表前面设置一个哑结点,哑结点的好处在于,因为这里我们是要删除一个结点,所以我们可以定位到被删除结点的前置结点,然后将前置结点的后续指针指向被删除结点的后续结点,则可完成删除。

我们设置两个指针,两个指针初始状态都指向哑结点,指针fast 先走n步,然后指针fast和指针slow同步往前继续遍历链表,直至fast的后续结点为空,此时指针slow到达被删除结点的前置结点。)

class Solution:
    def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
        if not head:
            return head
        slownode = ListNode(None)
        slownode.next = head
        fastnode = slownode
        for i in range(n):
            fastnode = fastnode.next
        while fastnode.next:
            slownode = slownode.next
            fastnode = fastnode.next
        if slownode.next == head:
            return head.next
        else:
            slownode.next = slownode.next.next
        return head

二、左右指针的常用算法

左右指针在数组中实际是指两个索引值,一般初始化为 left = 0, right = nums.length - 1

1、二分查找

前文 二分查找框架详解 有详细讲解,这里只写最简单的二分算法,旨在突出它的双指针特性:

int binarySearch(int[] nums, int target) {
    int left = 0; 
    int right = nums.length - 1;
    while(left <= right) {
        int mid = (right + left) / 2;
        if(nums[mid] == target)
            return mid; 
        else if (nums[mid] < target)
            left = mid + 1; 
        else if (nums[mid] > target)
            right = mid - 1;
    }
    return -1;
}
2、两数之和

直接看力扣第 167 题「两数之和 II」吧

给定一个已按照升序排列 的有序数组,找到两个数使得它们相加之和等于目标数。函数应该返回这两个下标值 index1 和 index2,其中 index1 必须小于 index2。

说明:

返回的下标值(index1 和 index2)不是从零开始的。你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素。
示例:

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

class Solution:
    def twoSum(self, numbers: List[int], target: int) -> List[int]:
        left = 0
        right = len(numbers)-1
        while left<right:
            sum = numbers[left] + numbers[right]
            if sum == target:
                return [left+1, right+1]#题目要求的索引是从 1 开始的
            elif sum>target:
                right = right-1
            elif sum<target:
                left = left+1
3、反转数组

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[]的形式给出。

示例 1:

输入:[“h”,“e”,“l”,“l”,“o”]
输出:[“o”,“l”,“l”,“e”,“h”]
示例 2:

输入:[“H”,“a”,“n”,“n”,“a”,“h”]
输出:[“h”,“a”,“n”,“n”,“a”,“H”]

class Solution:
    def reverseString(self, s: List[str]) -> None:
        """
        Do not return anything, modify s in-place instead.
        """
        left = 0
        right = len(s)-1
        while left<right:
            s[left], s[right] = s[right], s[left]
            left +=1
            right -=1
4、滑动窗口的思想

用i,j表示滑动窗口的左边界和右边界,通过改变i,j来扩展和收缩滑动窗口,可以想象成一个窗口在字符串上游走,当这个窗口包含的元素满足条件,即包含字符串T的所有元素,记录下这个滑动窗口的长度j-i+1,这些长度中的最小值就是要求的结果。

步骤一
不断增加j使滑动窗口增大,直到窗口包含了T的所有元素

步骤二
不断增加i使滑动窗口缩小,因为是要求最小字串,所以将不必要的元素排除在外,使长度减小,直到碰到一个必须包含的元素,这个时候不能再扔了,再扔就不满足条件了,记录此时滑动窗口的长度,并保存最小值

步骤三
让i再增加一个位置,这个时候滑动窗口肯定不满足条件了,那么继续从步骤一开始执行,寻找新的满足条件的滑动窗口,如此反复,直到j超出了字符串S范围。

面临的问题:
如何判断滑动窗口包含了T的所有元素?
我们用一个字典need来表示当前滑动窗口中需要的各元素的数量,一开始滑动窗口为空,用T中各元素来初始化这个need,当滑动窗口扩展或者收缩的时候,去维护这个need字典,例如当滑动窗口包含某个元素,我们就让need中这个元素的数量减1,代表所需元素减少了1个;当滑动窗口移除某个元素,就让need中这个元素的数量加1。
记住一点:need始终记录着当前滑动窗口下,我们还需要的元素数量,我们在改变i,j时,需同步维护need。
值得注意的是,只要某个元素包含在滑动窗口中,我们就会在need中存储这个元素的数量,如果某个元素存储的是负数代表这个元素是多余的。比如当need等于{‘A’:-2,‘C’:1}时,表示当前滑动窗口中,我们有2个A是多余的,同时还需要1个C。这么做的目的就是为了步骤二中,排除不必要的元素,数量为负的就是不必要的元素,而数量为0表示刚刚好。
回到问题中来,那么如何判断滑动窗口包含了T的所有元素?结论就是当need中所有元素的数量都小于等于0时,表示当前滑动窗口不再需要任何元素。
优化
如果每次判断滑动窗口是否包含了T的所有元素,都去遍历need看是否所有元素数量都小于等于0,这个会耗费O(k)O(k)的时间复杂度,k代表字典长度,最坏情况下,k可能等于len(S)。
其实这个是可以避免的,我们可以维护一个额外的变量needCnt来记录所需元素的总数量,当我们碰到一个所需元素c,不仅need[c]的数量减少1,同时needCnt也要减少1,这样我们通过needCnt就可以知道是否满足条件,而无需遍历字典了。
前面也提到过,need记录了遍历到的所有元素,而只有need[c]>0大于0时,代表c就是所需元素

图示
以S=“DOABECODEBANC”,T="ABC"为例
初始状态:

img

步骤一:不断增加j使滑动窗口增大,直到窗口包含了T的所有元素,need中所有元素的数量都小于等于0,同时needCnt也是0

image.png

步骤二:不断增加i使滑动窗口缩小,直到碰到一个必须包含的元素A,此时记录长度更新结果

image.png

步骤三:让i再增加一个位置,开始寻找下一个满足条件的滑动窗口

image.png

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        from collections import defaultdict
        need = defaultdict(int)#将t变为哈希表
        for i in t:
            need[i] += 1  #记录t中的哈希表
        min_len, res = float('inf'), ""
        l, needCnt = len(s), len(t)
        left = right = 0
        #------------------------------
        while right < l:#向右滑动窗口
            if need[s[right]] > 0:#如果s中的值满足t的字符
                needCnt -= 1# needCnt就减少
            need[s[right]] -= 1# 如果s中的值满足t的字符, need减少
            right += 1		# 向右滑动
            while needCnt == 0:	#当此时窗口包含所有t字符
                if min_len > right - left:#将最小间距赋值给res
                    min_len = right - left
                    res = s[left:right]
                if need[s[left]] == 0:#刚好满足t所有字符的左边字符被删除
                    needCnt += 1  # 所需要的字符的总数目加1
                need[s[left]] += 1 #所需要的字符数目加1
                left += 1 # 移动左指针
        #-------------------------------------
        return res

567,字符串的排列

题目理解;
给定两个字符串 s1 和 s2,写一个函数来判断 s2 是否包含 s1 的排列。
换句话说,第一个字符串的排列之一是第二个字符串的子串。
示例1:
输入: s1 = “ab” s2 = “eidbaooo”
输出: True
解释: s2 包含 s1 的排列之一 (“ba”).
示例2:
输入: s1= “ab” s2 = “eidboaoo”
输出: False

方法五 滑动窗口(更新边界法) [通过,在方法四基础上进行实现更快,也可以在方法三上实现加速]:
可以为 s2中的第一个窗口创建一次哈希表,而不是为 s2中考虑的每个窗口重新生成哈希表。
此时,滑动窗口每次滑动,其实只改变了边界情况,即删除了一个最前面的字符,加入了一个最后面的字符。

方法六 优化的滑动窗口(变量统计法) [通过]:
如果面试官还让你优化,那么就考虑哪些信息是无用,保留更少的信息,时间复杂度就越低,
显然并不用保存一个哈希表,哪怕只是更新边界都没有必要,只需要统计26个小写字母,哪些符合要求即可,如下;
不是比较每个更新的 s2map 的哈希表的所有元素,而是对应于 s2考虑的每个窗口,
我们会跟踪先前哈希表中已经匹配的元素数量当我们向右移动窗口时,只更新匹配元素的数量。
(这里就是26个字母出现的次数,比如s1有ab,那么对于s2滑动窗口,保证ab次数出现为1,其余为0,用一个cnt变量记录即可)
为此,我们维护一个 count变量,该变量存储字符数(s1出现的全部字母),这些字符在 s1中具有相同的出现频率,
当前窗口在 s2中。当我们滑动窗口时,如果扣除第一个元素并添加新元素导致任何字符的新频率匹配,我们将 count递增1.
如果不是,我们保持 count 不变或者-1。如果在移动窗口后,count的计算结果为26,则表示所有字符的频率完全匹配,返回True。

方法1:其实就是滑动窗口哈希表(更新边界法),对应上面的方法五

class Solution(object):
    def checkInclusion(self, s1, s2):
        """
        :type s1: str
        :type s2: str
        :rtype: bool
        """
        l1, l2 = len(s1), len(s2)
        c1 = collections.Counter(s1) #s1的哈希表,实质是字典
        c2 = collections.Counter() #实例化一个counter类
        p = q = 0  #设定下标初始化为0,滑动窗口就是[p,q]
        #下面就是不断在s2上面进行滑动窗口,不断更新哈希表进行比较,这是采用的边界更新法哦,因此是方法五,而不是方法三
        #这里补充一下,为什么滑动窗口用while没用for,其实都是一样的,你也可以改成for
        #但是对于有些情况,就只能用while,比如在回溯算法里面,即循环变量需要频繁的加减,显然此题并不是
        #因此对于此题,用for也可以,整体来说while的应用场合更加广泛
        while q < l2:
            c2[s2[q]] += 1   #统计字典哈希表
            if c1 == c2:
                return True  #注意,这种结果性条件判断一定是写在前面
            q += 1           #s2滑动窗口,下标后移
            if q - p + 1 > l1:   #为什么有这个呢?因为第一个滑动窗口比较特殊,要先构造第一个完整的滑动窗口,后面才是更新边界
                c2[s2[p]] -= 1   #字典哈希表移除最前面的字符
                if c2[s2[p]] == 0:  #由于counter特性,如果value为0,就删除它
                #否则会出现s1的map没有a,但是s2的map的a为0,此时是成立的,但是导致了这两个map不相等,结果出错
                    del c2[s2[p]]
                p += 1     #最前面的下标右移动
        return False  #遍历所有滑动窗口过后,仍然没返回true,那就是不合题意
    

方法2:优化的滑动窗口(变量统计法),其实就是上面的方法六

class Solution(object):
    def checkInclusion(self, s1, s2):
        """
        :type s1: str
        :type s2: str
        :rtype: bool
        """
        l1, l2 = len(s1), len(s2)
        c1 = collections.Counter(s1)
        c2 = collections.Counter()
        cnt = 0 #统计变量,全部26个字符,频率相同的个数,当cnt==s1字母的个数的时候,就是全部符合题意,返回真
        p = q = 0 #滑动窗口[p,q]
        while q < l2:
            c2[s2[q]] += 1
            if c1[s2[q]] == c2[s2[q]]: #对于遍历到的字母,如果出现次数相同
                cnt += 1               #统计变量+1
            if cnt == len(c1):         #判断结果写在前面,此时证明s2滑动窗口和s1全部字符相同,返回真
                return True
            q += 1                     #滑动窗口右移
            if q - p + 1 > l1:         #这是构造第一个滑动窗口的特殊判断,里面内容是维护边界滑动窗口
                if c1[s2[p]] == c2[s2[p]]:    #判断性的if写在前面,因为一旦频率变化,这个统计变量就减1
                    cnt -= 1
                c2[s2[p]] -= 1                #字典哈希表移除最前面的字符
                if c2[s2[p]] == 0:            #由于counter特性,如果value为0,必须删除它
                    del c2[s2[p]]
                p += 1                        #最前面的下标右移动
        return False

"""
import collections
class Solution:
    def checkInclusion(self, s1: str, s2: str) -> bool:
        l1, l2 = len(s1), len(s2)
        need = collections.Counter(s1)
        window = collections.Counter()
        cnt = 0  # 统计变量,统计window中字母和 need 中字母 频率相同的个数,
        left = right = 0  # 滑动窗口[left,right]
        while right < l2:
            window[s2[right]] += 1
            if need[s2[right]] == window[s2[right]]: #对于遍历到的字母,如果出现次数和need Dict相同, 统计变量+1
                cnt += 1              
            if cnt == len(need):         # 如果s2滑动窗口字符频率和s1全部字符频率相同,返回真
                return True
            right += 1                     # 滑动窗口右移
            if right - left + 1 > l1:         # 判断左侧窗口是否要收缩
                if need[s2[left]] == window[s2[left]]:    # 判断删除的left字母是否会影响cnt,若是则统计变量就减1
                    cnt -= 1
                window[s2[left]] -= 1                # 字典哈希表移除left字符
                if window[s2[left]] == 0:            # 由于counter特性,如果value为0,必须删除它
                    del window[s2[left]]
                left += 1                        # 最前面的下标右移动
        return False  
"""

438. 找到字符串中所有字母异位词

给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。

字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。

说明:

字母异位词指字母相同,但排列不同的字符串。
不考虑答案输出的顺序。
示例 1:

输入:
s: “cbaebabacd” p: “abc”

输出:
[0, 6]

解释:
起始索引等于 0 的子串是 “cba”, 它是 “abc” 的字母异位词。
起始索引等于 6 的子串是 “bac”, 它是 “abc” 的字母异位词。

class Solution:
    def findAnagrams(self, s: str, p: str) -> list:
        res = []
        window = {}     # 记录窗口中各个字符数量的字典
        needs = {}      # 记录目标字符串中各个字符数量的字典
        for c in p: needs[c] = needs.get(c, 0) + 1  # 统计目标字符串的信息

        length, limit = len(p), len(s)
        left = right = 0                    # 定理两个指针,分别表示窗口的左、右界限

        while right < limit:
            c = s[right]
            if c not in needs:              # 当遇到不需要的字符时
                window.clear()              # 将之前统计的信息全部放弃
                left = right = right + 1    # 从下一位置开始重新统计
            else:
                window[c] = window.get(c, 0) + 1            # 统计窗口内各种字符出现的次数
                if right-left+1 == length:                  # 当窗口大小与目标字符串长度一致时
                    if window == needs: res.append(left)    # 如果窗口内的各字符数量与目标字符串一致就将left添加到结果中                                   
                    window[s[left]] -= 1                    # 并将移除的字符数量减一
                    left += 1                               # left右移
                right += 1                                  # right右移
        return res #返回的是第一个字符的索引,所以只需要符合条件的子字符串的第一个字符的索引即可
3. 无重复字符的最长子串

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

输入: s = “abcabcbb”
输出: 3
解释: 因为无重复字符的最长子串是 “abc”,所以其长度为 3。
示例 2:

输入: s = “bbbbb”
输出: 1
解释: 因为无重复字符的最长子串是 “b”,所以其长度为 1。

class Solution(object):
    def lengthOfLongestSubstring(self, s: str) -> int:
        # 字符串为空则返回零
        if not s:
            return 0
        window = []     # 滑动窗口数组
        max_length = 0  # 最长串长度
        # 遍历字符串
        for c in s:
            # 如果字符不在滑动窗口中,则直接扩展窗口
            if c not in window:
                # 使用当前字符扩展窗口
                window.append(c)
            # 如果字符在滑动窗口中,则
            # 1. 从窗口中移除重复字符及之前的字符串部分
            # 2. 再扩展窗口
            else:
                # 从窗口中移除重复字符及之前的字符串部分,新字符串即为无重复字符的字符串
                window[:] = window[window.index(c) + 1:]
                # 扩展窗口
                window.append(c)
            # 更新最大长度
            max_length = max(len(window), max_length)
        return max_length 
  • 4
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值