美文网首页
算法(2):链表

算法(2):链表

作者: 大王叫我来巡老和山 | 来源:发表于2019-03-08 11:00 被阅读0次

      终于开始提笔写算法(2)了,我先为自己鼓个小手,然后决定,这一波写个常见的数据结构,链表,还望各位小伙伴多多支持,有简书号的就帮忙捧个人场,点个赞留个言啥的,你们的关注就是我继续写下去的动力~
      上一篇递归写了三天,这一波链表,估计时间也不会短,,,



      数组我想大家都很熟悉,链表和数组都属于线性数据结构,但是长相差别很大,具体长啥样请看下图:


    单向链表

      链表的每一个元素存储空间不是相邻的,而数组则是存放在同一块相邻的空间下。如上图所示,链表每一个元素(后面会称之为节点)分两部分,一部分存放值(value),另一部分存放下一个元素的地址(reference field)。
      上图为单向链表(singly linked list),还有一种链表,叫双向链表(doubly linked list),其结构如下:


    双向链表
    单向链表(Singly Linked List):

      首先,我先给大家看一段用 python 定义链表的代码,很简单,简单到你可能只会瞟一眼便略过:

    class ListNode:   #定义节点类
        def __init__(self, x):
            self.val = x
            self.next = None
    

      在大部分情况里,我们一般使用头节点(head node)来表示整个链表。也正是由于链表的这个特性,我们不可能在常数时间O(1)里访问链表当中的某个随机元素,因为我们要从头节点遍历下来才行,所以查找元素所用时间复杂度为O(n)。虽然查找的时候和数组相比(数组时间复杂度为O(1))弱爆了,但是链表自有其可取之处,比如当你插入和删除元素时,你会看到,链表正在将数组按在地上摩擦。

    插入元素:

      插入操作主要分为三步:首先,构建好一个节点,其次,将当前节点指向下一个节点,最后,将先前的节点指向新构建的节点。


    1.png 2.png 3.png

      插入一个元素时间复杂度只需要O(1),数组则是O(n),毕竟他要让排在后面的元素一个个给新元素挪位置。但是这里其实有一个问题,一般情况下,我们要找到这个要插入的位置再执行插入操作,而该查找操作,时间复杂度却是O(n)。比如,我给一个要求,让你在头节点新增一个元素,那很简单,直接新建节点,指向头节点,再将该新节点设置为头节点即可,很明显,这是O(1)。我看你完成的这么轻松,就使坏了,让你在链表末端插入一个节点。这个时候,你只能从头节点开始,不停的 next 到下一节点,在经过了 O(n) 时间后,你终于来到了链表的尾端,感叹道:“今天晚上就要去看惊奇队长电影了!”
      以免大家看了我上面的分析陷入迷糊,这里再说一下,一般我们说插入的时候,不考虑那么多,是默认已经给出插入位置了的,所以记住这句话,单向链表插入一个元素的时间复杂度为O(1)

    删除操作:

      删除的时候只需要一步,被删节点的前一个节点,指向被删节点的后一个节点即可~


    1.png 2.png

      那么删除一个元素的时间复杂度是多少呢?O(n)。假设我们现在知道被删节点 (cur node),那么找下一个节点(next node)很简单,但是如何找它之前的那个节点(prev node)呢?答案是从头遍历。。。。这样子,虽然删除操作简单,但是为了找到 pred node,平均时间复杂度就又到了O(n)级别。
      所以再记住这句话,单向链表删除一个元素的时间复杂度为O(n)


      当然还是老样子,没有 coding 的算法讲解都是耍流氓!我虽然是个流氓,例题是肯定得放上它三五个,才像回事。
      那么,接下来便是代码时刻(coding time)!


    问题1:判断一个链表是否有环
      何为环?首尾相连即为环。请看下图:


      调皮的同学可能会说,你这玩意儿不是首尾相连,我一个大嘴巴子......,这时我会温柔的告诉他,上图链表不是环结构(多了个小尾巴),但是它有环结构。好,闲话少说,接下来让你们见识一个厉害的家伙,它叫双指针技术(two-pointer technique),常用来解决快慢指针问题以及滑窗问题。大家可以通过这道题来跟它搞好关系~
      首先,我跟大家讲个龟兔赛跑的故事。话说有一天,小乌龟和小白兔在一条笔直的公路上赛跑,结局是小白兔被车撞死了,那么小白兔会先到达目的地;如果他们两个在操场绕圈跑步,结局是小白兔被操场散步的大爷带回家吃了,那么小白兔会超小乌龟一圈又一圈。
      双指针,便是如此,有两个指针,一个运动速度快,一个运动速度慢,如果链表无环,则快指针先到达链表尾端;如果链表有环,则两个指针必会碰头,快指针超慢指针一圈或N圈。在一般情况下,我们设定慢指针一次一步,slow_pointer = slow_pointer.next ;快指针一次两步,fast_pointer = fast_pointer.next.next
      那么,了解了双指针技术后,习题走起!

    输入:链表
    输出:True(有环) or False(无环)
    代码如下:

    class ListNode(object):
        def __init__(self, x):
            self.val = x
            self.next = None
    
    def hasCycle(head):
        """
        :type head: ListNode
        :rtype: bool
        """
        if head == None: return False
    
        fast = slow = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if slow == fast:
                return True
        else: return False
    
    if __name__ == '__main__':
        head1 = node1 = ListNode(1)  # 无环链表头节点
        head2 = node2 = ListNode(1)  # 有环链表头节点
        for i in range(2,5):
            node1.next = ListNode(i)
            node2.next = ListNode(i)
            node1 = node1.next
            node2 = node2.next
    
        node2.next = head2.next   # 加个环
        
        ans1 = hasCycle(head1)
        ans2 = hasCycle(head2)
        print(ans1,ans2)
    
    

    问题2:该问题为问题1的延申,如果链表有环,则返回环开始的那个节点。
    例子:
    输入:1->2->3->4(->2>3->4......)
    输出: ListNode(2)
    解决思路:
      该问题我们仍继续用双指针的方法来做,判断有没有环很简单,但是如何找到环开始的节点呢(突然想到了万恶之源这个词...)?操作很简单,当快慢两指针相遇时,再让一个指针从头节点开始,和慢指针一同以相同步伐运动(这是快指针已经没用了),当新指针和慢指针相遇时,该位置便为所求之点!
      那么到底是为啥呢?各位看官请准备好瓜子花生小板凳,听我慢慢道来:
      当快慢指针碰头时,咱假设慢指针走了K步,那么快指针便走了2K步。此时快指针比慢指针多运动n个环的距离,我们假设一个环有节点s个,那么可以得到式子:2K - K = ns,即K = ns。啥意思呢,也就是说,这个时候,慢指针走的步数刚好也是该环的整数倍!这个时候,我们搞一个和慢指针一样速度的指针(暂且称之为小白兔把)从头节点开始走,最终小白兔在走了K步时,和慢指针一定会又在该地相遇(加上之前的,此时慢指针共走了2K步)。但这不是它们首次相遇之地,它们因为速度一样,所以是在环开始的地方相遇,然后肩并肩手挽手的走到了第K个节点这里~(不信你让它们在这个第K个节点开始原路返回,你发现它们先是一起倒着走,在某个节点分道扬镳,小白兔继续朝头节点方向倒着走,而慢指针则回到了该链表尾部,继续它的绕圈圈)。
      so,got it?懂了的话就来看代码把~

    class ListNode(object):
        def __init__(self, x):
            self.val = x
            self.next = None
    
    
    def detectCycle( head):
        """
        :type head: ListNode
        :rtype: ListNode
        """
        fast = slow = head
        while fast and fast.next:
            fast = fast.next.next
            slow = slow.next
            if fast == slow:
                break
        else:
            return None
    
        while head != slow:
            head = head.next
            slow = slow.next
    
        return slow
    
    if __name__ == '__main__':
        head1 = node1 = ListNode(1)  # 无环链表头节点
        head2 = node2 = ListNode(1)  # 有环链表头节点
        for i in range(2,5):
            node1.next = ListNode(i)
            node2.next = ListNode(i)
            node1 = node1.next
            node2 = node2.next
    
        node2.next = head2.next   # 加个环
    
        ans1 = detectCycle(head1)
        ans2 = detectCycle(head2)
        print(ans1)
        print(ans2.val)
    
    

    问题3:这个问题还需要我们的双指针大侠出手。如下图所示,给两个连在一起的链表,我们需要找到这个连接点,即c1。

    问题三
      看到这个问题,客官老爷不知道有没有想法?啥?你说你觉得红烧兔头更好吃?我问的不是你对兔子的想法...... 其实很简单,我们继续让指针绕圈圈即可。不过两个指针此时不分快慢,步伐一致,一个沿着A链表绕圈,另一个沿着B绕。当他们两个指针最终首次相遇,那么相遇节点一定是c1(嗑瓜子的你吐了口中瓜子皮,说不一定?那好,假设在c2处首次相遇,那么他们因为步伐一致,所以在c1处一定也已经见面,故在c2处首次相遇的假设不成立)。
      但是,它们会绕多少圈才能首次相遇呢?有可能是很多圈。那么,有没有其他优化方法呢?(这时你又要跃跃欲试,想要发言,我赶快打断了你,继续讲下去)当然有!方法便是指针a在遍历完A链表之后,不从a1节点继续绕圈,而是跳到B链表头部b1,作为新的征程!(指针b亦然,第二圈从a1开始)这时你会发现,在第二次遍历时,他们必定会在c1处相遇。这时,每个指针都刚好把A和B所有不重复的节点走了一遍。
      接下来,上代码!(功夫不负有心人,你看到机会终于来了,憋了半天的话脱口而出:“瓜子吃完了,再给我端一盘。”)
    class ListNode(object):
        def __init__(self, x):
            self.val = x
            self.next = None
    
    def printListNode(node):   #辅助函数,打印链表
        while node:
            print(node.val, end=' ')
            if node.next:
                print('->', end=' ')
            node = node.next
        print()
    
    def getIntersectionNode( headA, headB):
        """
        :type headA, headB: ListNode
        :rtype: ListNode
        """
        pa = headA
        pb = headB
        while pa != pb:
            pa = pa.next if pa != None else headB
            pb = pb.next if pb != None else headA
    
        return pa
    
    if __name__ == '__main__':
        head1 = node1 = ListNode(1)
        head2 = node2 = ListNode(1)
    
        node1.next = ListNode(2);node1 = node1.next
        node2.next = ListNode(2);node2 = node2.next
        node2.next = ListNode(3);node2 = node2.next
        node1.next = node2.next = ListNode(5);node1 = node1.next;node2 = node2.next
        for i in range(10,12):
            node1.next = ListNode(i)
            node1 = node1.next
            # node2 = node2.next
    
        printListNode(head1)
        printListNode(head2)
        ans1 = getIntersectionNode(head1,head2)
    
        print(ans1.val)
    
    

    问题4:翻转链表。上一章我们翻转了字符串,这一章,链表羡慕的紧,也想翻一翻。那咱就给它个机会~
      翻转列表有多种方法,遍历列表,把每个节点装入列表当中,然后再反向拼接这些节点便可轻松做到。但是这里的空间复杂度应该是O(n^2)(如果小编没理解错的话,设链表长度为n,装入第一个节点时相当于装了整个链表,占用空间n,第二个n-1......最后1,共占用n(n+1)/2个空间,但是不确定这些中间节点是否是共享的,如果共享,则为O(n)复杂度),但这里说一个复杂度没那么高的,空间复杂度为O(1)、时间复杂度为O(n)的方法,供大家参考。
      如下图所示,我们的做法是从头节点开始,依次取出头节点后面的节点,放到头节点位置。第一步,先将头节点23放到头节点位置(其实就是不做任何操作......),第二步,将节点6扣下来,然后连接23与15,最后将6放在头节点位置;第三步,将15扣下来,然后连接23与None,最后将15放到头节点位置。就这么循环到链表尾部,就结束啦~

    1.png 2.png 3.png

      当然,代码还是得拉出来溜溜才行~

    class ListNode(object):
        def __init__(self, x):
            self.val = x
            self.next = None
    
    def printListNode(node):   #辅助函数,打印链表
        while node:
            print(node.val, end=' ')
            if node.next:
                print('->', end=' ')
            node = node.next
        print()
    
    def reverseList(head):
        """
        :type head: ListNode
        :rtype: ListNode
        """
        if head == None: return
        node = head
        while node.next:
            cur = node.next   # cur 即为被抠出来的节点 
            node.next = node.next.next   # 将 node 的下一个节点指向更后面的那个
            cur.next = head   # 将 cur 跟链表拼接起来
            head = cur   # 保存头节点信息
    
        return head
    
    if __name__ == '__main__':
        head1 = node1 = ListNode(1)
    
        for i in range(2,6):
            node1.next = ListNode(i)
            node1 = node1.next
    
        printListNode(head1)
        ans = reverseList(head1)
        printListNode(ans)
    

    问题5:奇偶链表
    输入一个链表,将第奇数个节点取出放在一起,第偶数个节点取出放在一起,然后偶数组接在奇数组后面,返回该合成后的链表。
    例子:
    输入:1 -> 2 -> 4 -> 6 -> 8
    输出:1 -> 4 -> 8 -> 2 -> 6

    class ListNode(object):
        def __init__(self, x):
            self.val = x
            self.next = None
    
    def printListNode(node):   #辅助函数,打印链表
        while node:
            print(node.val, end=' ')
            if node.next:
                print('->', end=' ')
            node = node.next
        print()
    
    
    def oddEvenList(head: ListNode) -> ListNode:
        if head:
            odd = head
            even = evenHead = odd.next
            while even and even.next:
                odd.next = odd.next.next;
                odd = odd.next;
                even.next = even.next.next;
                even = even.next;
                # odd.next = odd = even.next   #也可以试试把上面四句换成下面两句,结果是一样的呦~
                # even.next = even = odd.next
    
            odd.next = evenHead
            return head
    
    if __name__ == '__main__':
        head1 = node1 = ListNode(1)
    
        for i in range(2,10,2):
            node1.next = ListNode(i)
            node1 = node1.next
    
        printListNode(head1)
        ans = oddEvenList(head1)
        printListNode(ans)
    
    

    小贴士:

      1. 在你调用一个节点的 next 操作时,要首先确认该节点是不是空节点。即if node: node = node.next,或者if node and node.next: node = node.next.next
      2. 要考虑循环的边界条件,不要让调用陷入死循环当中。诸如一个有环链表,当你遍历打印该链表的节点时,要写好跳出循环的条件。
      3. 可以尝试同时使用多个指针(pointer)来追踪节点。
      4. 很多场景下,可能需要追踪当前节点的前一个节点(previous node)。
      5. 有时候指针来回交换几次之后比较迷,可以自己准备几个链表样本来测试,查看指针变换后的结果。(如上面程序中的ListNode()函数以及printListNode()函数,都可以方便我进行测试)

    (还没写完,大家可以再准备点瓜子,我才刚刚说完单向链表,,,)


    双向链表(Doubly Linked List):

      大家还记得单向链表的定义吗?你说我没讲?我一套组合升龙拳......跳个舞给你们看......双向链表跟它相同,但是每个节点都多了一部分,一个储存前一个节点位置的空间(reference field)。具体案例如下图所示:

    双向链表

      具体代码定义如下所示:

    class ListNode(object):
        def __init__(self, x):
            self.val = x
            self.next = None
            self.prev = None   # 其实没啥,就多了行这(请不要质疑我的注释水平)
    

      双向和单向链表在遍历、查找、添加节点时,时空复杂度都是一样的,唯独删除节点,时间复杂度由O(n) 降低至 O(1)(因为可以直接找到 prev node)。内容较为简单,后面不会细说,大家如果有什么不懂的,你自己解决啊!, 热烈欢迎留言讨论~ 小编可是极其期待有人给我留言的(苦逼小编写了快二十篇文章,至今还未打破 0 评论惨案)~

    添加节点
      双向链表添加节点操作和单向链表很相似,只不过原来是断开一个连接、新增两个连接,变成了断开两个连接、新增四个连接罢了~ 来,让小编我找个例子逗大伙乐乐~
      下图所示,共执行两步操作:Step1.新增节点9,将该节点的 next 和 prev分别连上节点15 和节点6。Step2.断开节点6和节点15的连接,让这两个节点分别与节点9相恋。

    1.png 2.png

    删除节点
      那啥,删除操作非常简答,cur.prev.next = cur.next; cur.next.prev = cur.prev,只需要更改两个连接即可~

    1.png

    *例题:展平多级双向链表
      该双向链表多了一个功能,叫子节点(child node)。而本题的目标便是让子链表插入主链表,将链表平铺。如下例子所示,节点3和8都有自己的子节点,而我们要做的就是将节点11、12插入节点8和9之间,再将7、8、11、12、9、10插入节点2和3之间,得到一个展平的双向链表。

    输入:
     1---2---3---4---5---6--NULL
             |
             7---8---9---10--NULL
                 |
                 11--12--NULL
    
    输出:
    1-2-3-7-8-11-12-9-10-4-5-6-NULL
    

      实现代码如下(这里使用了深度优先搜素思想(DFS),我会在后面的算法系列讲到该知识点,如果我不坑的话,后面DFS、BFS、回溯、动规等系列算法应该都会给各位看官一一奉上):

    class ListNode(object):
        def __init__(self, x):
            self.val = x
            self.next = None
            self.prev = None
            self.child = None
    
    def printListNode(node):   #辅助函数,打印链表
        while node:
            print(node.val, end=' <-> ')
            if node.child:
                print('(', end=' ')
                tmp = node.child
                while tmp:
                    print(tmp.val, end=' <-> ')
                    tmp = tmp.next
                print('None) <->', end=' ')
            node = node.next
        print('None')
    
    def flatten(head):
        def dfs(node):
            if not node:
                return None, None
            trav = node
            head = node
            tail = node
            while trav:
                if trav.child:
                    nex = trav.next
                    insert, last = dfs(trav.child)
                    trav.next = insert
                    insert.prev = trav
                    if nex:
                        last.next = nex
                        nex.prev = last
                    trav.child = None
                    trav = last
                tail = trav
                trav = trav.next
            return head, tail
    
        return dfs(head)
    
    if __name__ == '__main__':
        head1 = node = ListNode(1)
    
        for i in range(5,15,2):
            node.next = ListNode(i)
            node.next.prev = node
            if i % 3 == 0:
                node.child = ListNode(i + 0.1)
                node.child.prev = node
                node.child.next = ListNode(i + 0.2)
                node.child.next.prev = node.child
            node = node.next
    
        printListNode(head1)
        head,tail = flatten(head1)
        printListNode(head)
    

    附录:在LeetCode上找到了一张好图,贴在这里,侵删。

    时间复杂度对比图

    从上图可以看出,如果你频繁添加或者删除节点,那么可以使用链表结构;如果你倾向于通过索引(index)查找元素,那么数组效果会更好~


    震惊!二十来岁妙龄小伙竟然在电脑前做那种事!
    小伙竟然在电脑前不停求赞求赞求赞!

    相关文章

      网友评论

          本文标题:算法(2):链表

          本文链接:https://www.haomeiwen.com/subject/ernlpqtx.html