链表经典题目

282 阅读7分钟

典型题目来自: 【精挑细讲】这 9 道链表相关算法题,将助你告别链表问题,不信你打我 - 帅地的文章 - 知乎
注:我这个和原文不太一样哈,可以都看看

1 如何优雅地反转单链表

刷题链接: 206. 反转链表

递归做法1

定义 work(head) 操作一个链表节点,让它和它之后的指针箭头反转,返回反转后的链表的头结点(原尾节点)

class Solution:
    def reverseList(self, head: ListNode) -> ListNode:
        def work(head):
            if head.next is None:   # 一个节点的情况
                return head
            # 两个以上节点。两个的时候最特殊,想的时候首选按两个想。
            nxt = head.next
            start = work(nxt)
            nxt.next = head
            # 在中间链的时候无所谓,因为会被覆盖。但最后一层递归的时候必须要置为None呀
            head.next = None	
            return start

        if head is None:
            return head
        return work(head)

递归做法2

定义 work(head, tail) 操作一个链表的头和尾巴,返回反转链表的头和尾巴。

class Solution:
    def reverseList(self, head: ListNode) -> ListNode:
        def work(head, tail):
            if head == tail:
                return head, tail
            # 两个以上
            newhead = head.next
            # 使得“去头部分”成为真正的链表。删除这句话会死循环
            head.next = None
            rehead, retail = work(newhead, tail)
            retail.next = head
            return rehead, head
        
        if head is None:
            return None 
        cur = head
        while cur.next:
            cur = cur.next
        rehead, retail = work(head, cur)
        return rehead

为什么递归做法1里,不考虑外部入边呢?因为外部入边对 work(head)根本不可见,且work(head)的返回值也不会作用于外部结点,且这个思路本身就是在一条直线上翻转箭头方向而已,并不会形成环。

与之相反, work(head, tail) 中有 tail 对外部节点的操作,就有出bug的可能。

可以看出,思路的简洁,引领操作的简洁,和 bug 的有效避免。

迭代做法

先断链,再操作。把尾巴用个变量存起来就放心了。不要拖着尾巴甩来甩去.

class Solution:
    
    def reverseList(self, head: ListNode) -> ListNode:
        dummy = ListNode(-1)
        if not head:
            return None      
        # head not None
        cur = head
        while cur:
            nxt = cur.next	# 先断链!再操作!
            cur.next = dummy.next
            dummy.next = cur
            cur = nxt
        return dummy.next

举一反三:一趟扫描反转从位置 m 到 n 的链表

92. 反转链表 II

class Solution:
    def reverseBetween(self, head: ListNode, m: int, n: int) -> ListNode:

        dummy = ListNode(-1)	# 引入dummy是为了便于处理从第1个开始反转的情况
        dummy.next = head
        cur = dummy
        for _ in range(m-1): 	# 到达第m个节点的前方节点
            cur = cur.next
        start = cur
        
        # 第一个需要插入的节点,也是将来的局部尾节点
        pur = cur.next
        end = pur
        
        # 倒序插入:保存下一个节点;摘下本节点插入;移至下一个节点
        start.next = None
        for _ in range(n-m+1):
            # 摘除pur节点
            nxt = pur.next
            pur.next = None
            # 在start后面插入pur
            pur.next = start.next
            start.next = pur
            # 进入下一个pur节点
            pur = nxt
        
        end.next = pur
        return dummy.next

举一反三:将单链表的每K个节点之间逆序

25. K 个一组翻转链表

迭代方法
  • 定义函数 work(pre) -> new_pre
  • 其中,pre是开始逆序的地方(它后面k个被逆序)
  • new_pre是下一个开始被逆序的地方
  • dummy(pre) -> [ 1,2,3,4,5 ] -> [6,7,8,9,10]
  • dummy -> [ 5,4,3,2,1(new_pre) ] -> [6,7,8,9,10]
  • 1(pre) -> [6,7,8,9,10] -> ...
# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def reverseKGroup(self, head: ListNode, k: int) -> ListNode:
        
        def work(pre):  # pre is not None and pre.next is not None
            c1 = pre.next
            # if not enought 这题贼讨厌 非得分类讨论 只好预先多扫一遍
            for _ in range(k-1):
                if c1.next:
                    c1 = c1.next
                else:
                    return c1   # c1 is the tail
                    
            # if enough
            c1 = pre.next
            c2 = pre.next.next  
            for _ in range(k-1):
                tmp = c2.next
                c2.next = c1
                c1 = c2
                c2 = tmp
            tail = pre.next
            pre.next = c1
            tail.next = c2	# 这一步非常关键,要把 tail连接到下一个node上
            return tail  
                

        dummy = ListNode(-1)
        dummy.next = head
        pre = dummy
        while pre.next is not None: # 至少后面有一个节点
            pre = work(pre)

        return dummy.next
递归方法

递归方法比较显然。 就是 reverseKGroup(all) => reverse(k) + reverseKGroup(all - k) 这样

2 找到环形链表的入口

142. 环形链表 II

双指针法1

  1. 通过快慢指针定位到一个环中点
  2. 计算环的长度n
  3. 让一个指针比另一个早n步出发,则一定会在入口处相遇
class Solution:
    def detectCycle(self, head: ListNode) -> ListNode:
        quick = slow = head
        while True:
            if not quick or not quick.next: # 无环
                return None
            quick = quick.next.next
            slow = slow.next
            if quick == slow:   # 发现环!
                met = quick
                break

        cur = met.next  # 计算环的长度 n
        n = 1
        while cur != met:
            cur = cur.next
            n += 1
        
        quick = slow = head     # 一个指针比另一个早n步出发,则一定会在入口处相遇
        for _ in range(n):
            quick = quick.next
        while quick != slow:
            quick = quick.next
            slow = slow.next
        
        return quick

双指针法2

  1. 通过快慢指针定位到一个环中点
  2. 注意到此时,快指针距离是慢指针的2倍,快指针比慢指针领先整数圈数,则慢指针走了整数圈数kn
  3. 相当于慢指针已经相对于head早出发了 k * n 步。头结点出发和慢指针在入口相遇。

双指针法2比1来说,有点不好想,需要激灵反应。但本质逻辑一样:领先整数圈数的快指针,将在入口与慢指针相遇

3 约瑟夫问题

环形单链表模拟法

这是最直观的方法,不过会超时就是了。。。
注意在初始化后继指针pre的时候,不要初始化成dummy(然后cur是dummy.next)
因为断环+重连时,默认pre在环上。既然代码是这样写的,就要按这套来全程维护!
如果初始化成dummy,在m=1时程序会死循环。

class Solution:
    def lastRemaining(self, n: int, m: int) -> int:
        # 造链表
        dummy = Node(-1)
        cur = dummy
        for i in range(n):
            cur.next = Node(i)
            cur = cur.next
        cur.next = dummy.next
        
        pre = cur       # 最后一个节点
        cur = pre.next  # 第一个节点
        
        while cur.next != cur:  # 停止条件:只剩一个人
            for i in range(m-1):# pre和cur,向前走 m-1 步
                pre = pre.next
            cur = pre.next      
            pre.next = cur.next # 删除并重连环
            cur = pre.next     

        return cur.val

队列模拟法

同样的高时间复杂度模拟法。
出队的元素,数到数字就杀死,不然重排队尾。
优势在于不用自己手动处理环形链表了。

公式法

不超时的好方法。不是链表,在此不多说。

  • 简而言之,每杀一个人,如果从下一个人从0计数,就变成了一个规模更小的问题。这个从0开始的问题肯定是好算的,但是我们要的是最初的编号
  • 通过推导,可以得到从n-1个人的编号回推到这个人在n个人的情况下的编号的对应关系。然后递归求解即可。

4 优雅判断回文链表

栈 1

众所周知,回文:正序 = 倒序。 使用栈保存结果,再依次出栈,和链表比对。可惜空间复杂度为 O(n)

栈 2

众所周知,回文:左半边正序 = 右半边倒序。 因此可以只让半拉链表入栈。如何得到半拉链表?遍历求长度除以2,可以。快慢指针更便捷。

反转半链表

众所周知,回文:左半边链表反转 = 右半边链表正序。 在上一个方法中,我们已经定位到了中点node,那么在这里断开,反转左边链表再和右边比对即可。

这种做法空间复杂度只需要 O(1)。

5 复制含有随机指针节点的链表

138. 复制带随机指针的链表 非常巧妙的一个套路,记住就好。关键点在于:

  • 对 “原地修改题目数据” 这种操作有意识
  • 意识到next指针在复制新的链表主干之后,本质上属于闲置资源了
  • 在感到急切需要 从老链表到新链表的映射 后,发现next指针这个闲置资源可用
  • 操作问题:暂存辅助信息的域,一定要恢复到用于提交的要求内容!
"""
# Definition for a Node.
class Node:
    def __init__(self, x: int, next: 'Node' = None, random: 'Node' = None):
        self.val = int(x)
        self.next = next
        self.random = random
"""

class Solution:
    def copyRandomList(self, head: 'Node') -> 'Node':
        if not head:
            return None
        # copy stem
        dummy = Node(-1)
        c1 = dummy
        c2 = head
        while c2:
            tmp = c2.next
            # 插入新节点
            c1.next = Node(c2.val)
            c1 = c1.next
            # 连接主从节点
            c1.random = c2
            c2.next = c1
            c2 = tmp   # 下一个
        
        c1 = dummy.next
        while c1:
            father = c1.random
            if father.random:	
            	# 也可能存在father没有随机指针的情况,不能连续 father.random.next
                c1.random = father.random.next
            else:
            	# 这一句话千万不能丢!!不然random指针仍然指向旧链表!!
             	# 辅助信息用完了之后,清理干净!!
                c1.random = None	
            c1 = c1.next
            
        return dummy.next