【双指针】算法之双管齐下 2019-10-11(未经允许禁止转载)

1.双指针算法总述

  • 1.1 双指针算法是什么

双指针算法,是指通过各司其职互相配合两枚指针,解决线性表(线性表包括链表和顺序表两类)问题的一类算法
其中,各司其职指两个指针背负不同的任务进行移动,互相配合指两个指针的移动也需要协同配合。就像产品和开发,产品负责设计而开发负责实现,两者的任务不同,但同时为了实现产品的落地,他们需要相互配合

  • 1.2 双指针可应用的问题

双指针应用在线性表问题,即链表问题和顺序表问题

  • 1.3 双指针的优势(知道双指针算法好在哪里,才算有一定的理解)

双指针间的协同配合自带剪枝buff,能自动淘汰明显的不可行解,从而降低问题求解的时间复杂度到O(n)甚至O(log n)级别(因为双指针算法只对线性表进行了单次循环的顺序扫描,理解这一点很重要)

  • 1.4 双指针算法的分类(重点)

基于不同双指针算法的适用情况,我将其分为3类:

  • 快慢双指针。用于解决链表问题,两指针一快一慢从链表头节点head扫描至链表末节点(从数据结构上也很容易理解,因为链表结构只能顺藤摸瓜,只能从头扫到尾,木有其他扫描方式)
  • 首尾双指针,特点是【夹逼】。用于解决顺序表问题,两个指针一个指头、一个指尾,向中间夹逼,两指针相遇时结束,此时刚好扫描整个顺序表一次
  • 蠕动双指针。也就是滑动窗口,特点是【一伸一缩】。用于解决顺序表问题,两个指针都从顺序表头部出发,一个指针做蠕虫头,另一指针做蠕虫尾,一伸一缩蠕动前进,直到蠕虫抵达顺序表末尾。我们都知道蚯蚓,蚯蚓怎么蠕动的呢?当蚯蚓前进时,身体后部的刚毛钉入土里,使后部不能移动,这时身体就向前伸长;接着身体前部的刚毛钉入土里,使前部不能移动,这时向前缩短。就这样,蚯蚓一伸一缩完成向前移动。那么我们想象一条蚯蚓爬过一个顺序表(如一个数组),这就是蠕动双指针的工作模式

2. 三种双指针算法的实例剖析

以下例子部分来源于https://leetcode-cn.com/circle/article/GMopsy/

  • 2.1解决链表问题的快慢双指针

2.1.1 判定链表是否含环


含环链表.png

判断链表含环是一个很基本很经典的问题,一快一慢双指针从head出发,如果有环,那么快指针必定先进入环然后一直转圈圈,然后慢指针也进来转圈圈,它们一定会相遇;如果不含环,那么就是一条直线,快慢指针永远不会相遇。python实现如下:

class DetectCycle(object):
    def detect(self, head):
        # 快慢指针
        slow = fast = head
        # 令快指针每次走2个node,慢指针每次走一个node
        while fast is not None and fast.next is not None:
            fast = fast.next.next
            slow = slow.next
            if fast == slow:
                return True
        # 跳出while说明指针能够走到最后,没有环
        return False

2.1.2 如果链表有环,找出环的起点位置。如上图中的3就是环的起点
还是假设快指针速度是慢指针的2倍,假设它们相遇在环的某个节点A,慢针走了s步,快针就走了2s步,因此快针比慢针多走的s步刚好在环里绕了n圈(n是整数)。这时让其中任一个指针重新指向头节点,另一个指针从相遇点A开始继续在环里转圈,这时回到头节点的指针走s步后可以到达A,继续转圈的指针走s步也能重新回到A,如果让它们以相同速度前进(速度具体是多少无所谓,同速就行),它们就能在环的起点B相遇,然后携手走过B-A回到A。不清楚的可以画图。python实现:

class DetectCycle(object):
    def detect(self, head):
        # 快慢指针
        slow = fast = head
        # 令快指针每次走2个node,慢指针每次走一个node
        while fast is not None and fast.next is not None:
            fast = fast.next.next
            slow = slow.next
            if fast == slow:
                break
        # 慢针回头
        slow = head
        while slow != fast:
            fast = fast.next
            slow = slow.next
        return slow

2.1.3 给一个无环链表,寻找链表的n等分点
求n等分点就设置n个指针,速度分别是1/n, 2/n, ..., (n-1)/n, 1,一起从head出发,当最快的指针走到尾部的时候,其他指针所处的位置就是n等分点

2.1.4 给一个无环链表,寻找倒数第n个元素
设置两个同速指针,一个先移动n个节点,然后另一个开始移动;当先出发的指针到达末尾时,后出发的指针所在的位置即为所求

从上面的例子可以看到,无环链表的快慢指针算法都是快指针扫遍链表后结束,时间复杂度O(n)

  • 2.2 解决顺序表问题的首尾指针

用两个经典的例子,二分查找两数和问题
2.2.1 二分查找
对于已经排好序的数组,可以用二分查找,利用bottom和top双指针,解决一些搜索的问题
如:
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其最小索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置(leetcode第35题改编),下面的实现代码特别是注释要认真看!

class Solution(object):
    def searchInsert(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: int
        """
        # 二分查找的区间统一设置为左闭右开的形式
        # bottom的作用是从左到右找到小于target的元素的上界
        bottom = 0
        # top的作用是从右到左找到大于等于target的元素的下界
        top = len(nums)
        while bottom < top:
            mid = bottom + (top - bottom)//2
            if target > nums[mid]:
                bottom = mid + 1
                # 注意,这时bottom = mid + 1,但mid+1指向的元素可能大于等于target值。
                # 这种情况下,原bottom才是小于target的元素的上界,现bottom已经变成大于等于target的元素的下界。
                # 这个质变是好的,bottom的功能已经变成了top的功能,从此以后再也不会出现target > nums[mid]的情况,top会一直向bottom靠近直到重合
            else:
                # 因为区间设为左闭右开,所以这里很简洁,不用mid减1
                top = mid
        # 当bottom等于top时,bottom或者top的位置大于等于target值的元素下界
        # nums.insert(bottom, target)
        return bottom
            

成绩:
执行用时 :40 ms, 在所有 Python 提交中击败了91.73%的用户
内存消耗 :12.4 MB, 在所有 Python 提交中击败了5.47%的用户

原题默认给定数组无重复元素,我的解法同样适用于有重复元素的有序数组。但是不知道为什么内存消耗这么大。。。明明就额外存了top bottom 和 mid。。。

关于二分查找,知乎这篇文章也讲得挺好,知乎-二分查找

可以看到,对利用top bottom双指针对有序的顺序表进行搜索,并不需要暴力地逐一搜索其中的每一个元素,而是折半搜索,丢弃明显不可能的另一半区间,这就是自动剪枝的功能。对于长度为N的有序数组,二分查找只需要进行O(log2 N)+1次

2.2.2 两数和问题
给定一个有序整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标
可以假设每种输入只会对应一个答案。但是,不能重复利用这个数组中同样的元素

示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

只要数组有序,双指针一般都能发挥作用。这道题的解法有点类似二分查找,通过调节 left 和 right 可以调整 sum 的大小:

class Solution(object):
    def twoSum(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: List[int]
        """
        nums = sorted(nums)
        left = 0
        right = len(nums) - 1
        while left < right:
            if nums[left] + nums[right] > target:
                # 和大了,right左移
                right -= 1
            elif nums[left] + nums[right] < target:
                # 和小了,left右移
                left += 1
            else:
                return [left, right]
        return None

2.3 蠕动双指针
关于蠕动双指针,here已经说得比较详细了,有需要可以进去看
蠕动双指针非常适合解决子串之类的问题,本质上,每一次伸是为了求可行解,每一次缩是为了优化这个可行解,6啊
抽象出来的蠕动双指针代码模板:

left = 0
right = 0
while right < len(s):
    # 右指针先伸
    hash.add(s[right])   or   do other things
    right += 1
    # 左指针配合缩
    while condition is valid:
        hash.remove(s[left])   or   do other things
        left += 1

leetcode上的例题:
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度
示例:
输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3
我的解法,利用蠕动双指针,i指子串头,j指子串尾,固定i,j右移,直到子串中出现重复元素暂停,这是将i缩进至当前子串中重复元素的下一位;如此反复,直到j指向初始字符串的最后一个字符:

class Solution(object):
    def lengthOfLongestSubstring(self, s):
        """
        :type s: str
        :rtype: int
        """
        i = j = 0
        max_len = 0
        # 当多于1个元素时
        if len(s) > 1:
            # 核心代码在while代码块
            while j < len(s) - 1:
                current_sub = s[i:j+1]
                j += 1
                if s[j] in current_sub:
                    # i指针移到子串中重复元素的下一位
                    i = i + current_sub.index(s[j]) + 1
                    max_len = max(max_len, len(current_sub))
                else:
                    max_len = max(max_len, len(current_sub) + 1)
            return max_len
        elif len(s) == 1:
            return 1
        else:
            return 0

成绩:
执行用时 :44 ms, 在所有 Python 提交中击败了92.34%的用户
内存消耗 :12 MB, 在所有 Python 提交中击败了46.11%的用户

再看看leetcode中的另外一题-三数之和,这题坑很多,指针的移动需要仔细考虑

给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。
注意:答案中不可以包含重复的三元组

例如, 给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为:
[
[-1, 0, 1],
[-1, -1, 2]
]

我的解法是
1.先将三元组分类,三元组中最小值相同的归为同一类三元组
2.将原数组排序,使用指针i从左到右遍历数组,指向的元素作为三元组中的最小值。这样,指针i的每一次移动都对应一类三元组,时间复杂度O(N)
3.p q两个指针分别从i+1和数组末尾相向移动,相遇时停止移动,时间复杂度O(N)
因此,我的解法的时间复杂度是O(n2)

class Solution(object):
    def threeSum(self, nums):
        """
        :type nums: List[int]
        :rtype: List[List[int]]
        """
        # 先排序,方便接下来使用首尾双指针
        nums_sorted = sorted(nums)
        result = []
        
        # 规定i指针指向三元组中最小的元素
        for i in range(len(nums_sorted)-2):
            # 如果i指针指向的元素和上次指向的元素相等,则跳过
            if i > 0 and nums_sorted[i] == nums_sorted[i-1]:
                continue
            second_num = float('inf')
            p = i + 1
            q = len(nums_sorted) - 1
            while p < q:                
                total_sum = nums_sorted[i] + nums_sorted[p] + nums_sorted[q]
                if total_sum > 0:
                    q -= 1            
                elif total_sum < 0:
                    p += 1
                else:
                    if nums_sorted[p] != second_num:
                        three_element = [nums_sorted[i], nums_sorted[p], nums_sorted[q]]
                        # 这里遍历result判断当前三元组是否在答案中太耗费时间了,会超出时间限制,应该操作指针遇到相同元素时跳过
                        # if three_element not in result:
                        #     result.append(three_element)
                        result.append(three_element)
                        second_num = nums_sorted[p]
                    p += 1
                    q -= 1                   
        return result
        

成绩:
执行用时 :628 ms, 在所有 python 提交中击败了76.44%的用户
内存消耗 :15.1 MB, 在所有 python 提交中击败了32.76%的用户

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,265评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,274评论 1 288
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,087评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,479评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,782评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,218评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,594评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,316评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,955评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,274评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,803评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,177评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,732评论 3 229
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,953评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,687评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,263评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,189评论 2 258

推荐阅读更多精彩内容

  • 一些概念 数据结构就是研究数据的逻辑结构和物理结构以及它们之间相互关系,并对这种结构定义相应的运算,而且确保经过这...
    Winterfell_Z阅读 5,525评论 0 13
  • 1. 找出数组中重复的数字 题目:在一个长度为n的数组里的所有数字都在0到n-1的范围内。数组中某些数字是重复的,...
    BookThief阅读 1,563评论 0 2
  • 1.把二元查找树转变成排序的双向链表 题目: 输入一棵二元查找树,将该二元查找树转换成一个排序的双向链表。 要求不...
    曲终人散Li阅读 3,235评论 0 19
  • 线性表中的双指针法是指通过两个指针(游标)来指示线性表中的元素的方法。双指针的使用本身并没有什么神奇之处,但是通过...
    Like_eb56阅读 493评论 0 0
  • 题图:车和家创始人及CEO 李想先生 告别汽车之家,李想再创业。十几年来,他一直忙不停歇。也许是时间的锻造让他在创...
    投资人说阅读 478评论 1 6