[ 数据结构菜鸟教程 - II ] 你可能需要重新学习下链表

127 阅读9分钟

# 前言

大家早上好,新的一节数据结构菜鸟课程又更新啦。

在这一节的内容中,带大家学习链表的基础知识和常见题型。

链表作为最常见的数据结构实现形式,也是经常会出现在面试的题目中。那么为什么面试官如此钟爱链表相关的题目呢?下面我们来看主要有下面几点:

  1. 对数据结构的基本理解 面试官通过链表题目可以考察应聘者对基础数据结构的理解,包括指针,引用,内存管理等核心概念
  2. 指针操作与内存管理 链表涉及到大量的指针操作,如节点的插入、删除、遍历等。这些操作要求应聘者对指针(或引用)有深刻的理解,并能正确操作和管理内存。
  3. 边界条件处理 链表操作中常常涉及复杂的边界条件,如空链表、链表头和尾的处理、单节点链表等。这些题目可以考察应聘者对边界条件的考虑和处理能力。
  4. 递归与迭代 链表问题通常可以用迭代和递归两种方式解决。例如反转链表、合并两个有序链表等。通过这些题目,面试官可以考察应聘者对递归的理解和应用能力

综上所述,链表相关的题目不仅可以全面考察应聘者的基础数据结构知识、指针操作能力、递归与迭代的理解,还可以评估应聘者在面对复杂问题时的思考过程、算法设计能力和代码优化能力。这使得链表成为程序员面试中的经典题型。

# 链表基础知识

  1. 在面试过程中,一般来说题目都会帮我们实现或者定义链表节点。

    其中需要 val 来存储当前节点的数据,next 来指向下一个节点的引用。

    class ListNode {
        int val;
        ListNode next;
        ListNode(int x) {
            val = x;
            next = null;
        }
    }
    
    class LinkedList {
        ListNode head; // 链表的头节点
        // 构造函数,初始化一个空链表
        LinkedList() { 
            this.head = null;
        }
     }
    
  2. 链表的基本操作

    • 插入 将插入位置的上一个节点的 Next 修改成当前插入节点,并且将插入的节点的 Next 指针指向下一个节点。如图所示:

      image.png

    • 删除将目标节点的上一个节点的 Next 指向目标节点的下一个节点,将删除节点的 Next 指针置空。

      image.png

    • 链表操作时间复杂度

      操作时间复杂度
      搜索O(n)
      插入O(1)
      删除O(1)
      末尾插入O(1)
      头部插入O(1)

# 实战环节

  1. 206. 反转链表

    给你单链表的头节点 head ,请你反转链表,并返回反转后的链表

    示例 1:

    image.png

    输入:head = [1,2,3,4,5]
    输出:[5,4,3,2,1]

    示例 2:

    image.png

    输入:head = [1,2]
    输出:[2,1]

    示例 3:

    输入:head = []
    输出:[]

    提示: 链表中节点的数目范围是 [0, 5000] -5000 <= Node.val <= 5000

    进阶:链表可以选用迭代或递归方式完成反转。你能否用两种方法解决这道题?

    解题思路:
    反转链表是一道很基础基础并且常被考到的链表题,而且很多题目都是基于这个题的变种,希望大家可以充分理解,做到触类旁通,举一反三。如果一开始不是很能理解,也可以先硬记下来。

    示例代码:

    /**
     * Definition for singly-linked list.
     * public class ListNode {
     *     int val;
     *     ListNode next;
     *     ListNode() {}
     *     ListNode(int val) { this.val = val; }
     *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
     * }
     */
    class Solution {
        public ListNode reverseList(ListNode head) {
            ListNode curNode = head;
            ListNode prev = null;
    
            while(curNode != null) {
                ListNode tempNext = curNode.next;
                curNode.next = prev;
                prev = curNode;
                curNode = tempNext;
            }
    
            return prev;
        }
    }
    
  2. 25. K 个一组翻转链表

    给你链表的头节点 head ,每 k 个节点一组进行翻转,请你返回修改后的链表。

    k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

    你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。

    示例 1 image.png

    输入:head = [1,2,3,4,5], k = 2
    输出:[2,1,4,3,5]

    示例 2 image.png 输入:head = [1,2,3,4,5], k = 3
    输出:[3,2,1,4,5]

    提示:

    • 链表中的节点数目为 n
    • 1 <= k <= n <= 5000
    • 0 <= Node.val <= 1000

    进阶:你可以设计一个只用 O(1) 额外内存空间的算法解决此问题吗?

    解题思路:
    这个题目被归类到了 困难 难度分类,但是也是面试中的常客。这个题目是我们刚才讨论的 206 反转链表的进阶版本。

    解题的关键在于意识到这个题目是把一个完整的链表切割成多个子链表,并且分开进行处理的过程。我们需要 preend 两个指针分别来标记子链表的起始和终点。遍历的过程中更新 preend 两个指针,来更新不同的子链表的起始和终止位置,再调用反转链表的算法来完成每组子链表的反转。

    示例代码:

    /**
     * Definition for singly-linked list.
     * public class ListNode {
     *     int val;
     *     ListNode next;
     *     ListNode() {}
     *     ListNode(int val) { this.val = val; }
     *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
     * }
     */
    class Solution {
        public ListNode reverseKGroup(ListNode head, int k) {
            ListNode dummyNode = new ListNode();
            dummyNode.next = head;
    
            ListNode pre = dummyNode;
            ListNode end = dummyNode;
    
            while(end.next != null) {
                for(int i = 0; i < k && end != null; i++) {
                    end = end.next;
                }
    
                if(end == null) {
                    break;
                }
    
                ListNode start = pre.next;
                ListNode next = end.next;
    
                end.next = null;
                pre.next = reverse(start);
                start.next = next;
    
                pre = start;
                end = start;
            }
    
            return dummyNode.next;
        }
    
        private ListNode reverse(ListNode node) {
            ListNode pre = null;
    
            while(node != null) {
                ListNode tempNode = node.next;
                node.next = pre;
                pre = node;
                node = tempNode;
            }
    
            return pre;
        }
    }
    
  3. 141. 环形链表

    给你一个链表的头节点 head ,判断链表中是否有环。

    如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

    如果链表中存在环 ,则返回 true 。 否则,返回 false 。

    示例 1: image.png

    输入:head = [3,2,0,-4], pos = 1
    输出:true
    解释:链表中有一个环,其尾部连接到第二个节点。

    示例2: image.png

    输入:head = [1,2], pos = 0
    输出:true
    解释:链表中有一个环,其尾部连接到第一个节点。

    示例3:

    image.png

    输入: head = [1], pos = -1
    输出: false
    解释: 链表中没有环。

    提示:
    链表中节点的数目范围是 [0, 104]
    -105 <= Node.val <= 105
    pos 为 -1 或者链表中的一个 有效索引 。

    进阶:你能用 O(1)(即,常量)内存解决此问题吗?

    解题思路:
    这个题目类似于我们小学时候经常做的追击问题,或者更好的比喻是龟兔赛跑 🤔。 我们可以创建两个指针,一个每次移动一个节点(慢指针),一个每次移动两个节点(快指针),两个指针同时从头节点开始遍历,如果链表有环的话,会一直在循环体内重复,并且快的指针指向的节点一定会追击到慢的指针。我们也不要忘记判断快指针是不是遍历完成了,不然会进入到 dead loop。

    示例代码:

    public class Solution {
        public boolean hasCycle(ListNode head) {
            if(head == null || head.next == null) {
                return false;
            }
    
            ListNode fast = head.next, slow = head;
    
            while(fast != slow) {
                if(fast == null || fast.next == null) {
                    return false;
                }
                fast = fast.next.next;
                slow = slow.next;
            }
    
            return true;
        }
    }
    
  4. 142. 环形链表 II
    给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

    如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

    不允许修改链表。

    示例 1:

    image.png

    输入:head = [3,2,0,-4], pos = 1
    输出:返回索引为 1 的链表节点
    解释:链表中有一个环,其尾部连接到第二个节点。

    示例 2: image.png 输入:head = [1,2], pos = 0
    输出:返回索引为 0 的链表节点
    解释:链表中有一个环,其尾部连接到第一个节点。

    示例 3:

    image.png

    输入:head = [1], pos = -1
    输出:返回 null
    解释:链表中没有环。

    提示:

    链表中节点的数目范围在范围 [0, 104] 内 -105 <= Node.val <= 105 pos 的值为 -1 或者链表中的一个有效索引

    进阶:你是否可以使用 O(1) 空间解决此题?

    解题思路:
    这个题目是上面 141环形链表 进阶版本,虽然只是找出入环节点就把这个题目提高了一个难度档次。第一次看这个题目,大家可能无从下手,如何可以找到这个循环体的入口呢?

    其实这里需要运用公式推倒了。

    2(l+m)=l+m+krl=krpl=(k1)r+(rp)2(l + m) = l + m + k*r \\ l = k*r - p \\ l = (k - 1) * r + (r - p)

    其中 l 表示起始节点到环起点的距离,m 表示环起点到相遇的距离,r 表示完整的一圈, k 表示套的圈数。

    表示的含义是从 head 走向环起点,等于从 meet 到 环起点,再绕几圈儿。

    如下图所示:

    IMG_0031.jpg

    所以我们在141环形链表的基础上找到相遇节点,再重新创建一个从头节点出发的节点,和之前的慢节点同时遍历,注意,这个时候新建节点和满节点的速度要保持一致,最后他俩相遇的节点就是我们要找的环的交界点。

    示例代码:

    /**
     * Definition for singly-linked list.
     * class ListNode {
     *     int val;
     *     ListNode next;
     *     ListNode(int x) {
     *         val = x;
     *         next = null;
     *     }
     * }
     */
    public class Solution {
        public ListNode detectCycle(ListNode head) {
            ListNode fastP = head;
            ListNode slowP = head;
    
            while(fastP != null && fastP.next != null) {
                slowP = slowP.next;
                fastP = fastP.next.next;
    
                if(slowP == fastP) {
                    break;
                }
            }
    
            if(fastP == null || fastP.next == null) return null;
    
            fastP = head;
            while(fastP != slowP) {
                fastP = fastP.next;
                slowP = slowP.next;
            }
    
            return fastP;
        }
    }
    

# 总结

链表的题目是我们面试过程中的常客,很多时候解题思路很巧妙,需要我们有一定的做题经验,才会有思路。

我们在解题的过程中,也要细心再细心,处理好边界条件,管理好指针,避免溢出和死循环。

希望大家阅读完这个章节后可以快速上手链表,开启愉快的刷题之路。