剑指offer算法课(四)链表

98 阅读8分钟

1.png

链表的基础知识

链表是一种常见的数据结构,创建链表时无需知道链表长度不需要提前分配内存,而是每增加一个节点时分配一次内存。因此,链表能够实现灵活的内存动态管理

链表的插入操作时间复杂度为O(1),查找为O(n)

单链表节点实现如下:

public class ListNode {
    public int val;
    public ListNode next;
    public ListNode(int val) {
        this.val = val;
}

哨兵节点

为了简化链表处理过程而引入,位于链表首位,值没有任何意义,只是拿来占位用,减少对链表头的判断。增加哨兵节点后,原链表所有节点都是非头节点。

哨兵节点常起名dummy

双指针

  • 前后双指针:指针移速相同之间相差k步,用来查找倒数第k个节点
  • 快慢双指针:移速差一倍,用来寻找链表的环&链表中点

反转链表

有些面试题需要从链表尾开始扫描更合适,此时需要反转链表。


面试题21:删除倒数的第k个节点

leetcode.cn/problems/SL… 给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。
如下例,输入n=2,应当删除“4”节点。

image.png

ANSWER

求倒数第n个节点,使用前后双指针法。只需要遍历一次,时间复杂度O(n)

  1. 声明前后双指针,间隔n
  2. 先后行动,找到倒数第n+1
  3. 删除倒数第n个,删除的时候注意防止指针失效
public ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode front = head, back = dummy;
    for (int i=0; i<n; i++) {
        front = front.next;
    }
    while (front != null) {
        front = front.next;
        back = back.next;
    }
    back.next = back.next.next; // 删除back.next节点
    return dummy.next; // 返回头节点
}

面试题22:链表中环的入口节点

leetcode.cn/problems/c3…

给定一个链表,返回链表开始入环的第一个节点。 从链表的头节点开始沿着 next 指针进入环的第一个节点为环的入口节点。如果链表无环,则返回 null。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。

说明:不允许修改给定的链表。
如下例,返回pos=1。

image.png

ANSWER

题目是问链表中的环,采用快慢两节点二倍速,当它们出发后首次相遇时一定处于环中节点。然后用单节步进确定环的长度。最后用类似上一题求解倒数第n个节点的方法得到环的入口。

上述解法依赖于计算环的长度,接下来介绍一种无需计算长度的方法。同样采用2倍速,当两个节点在环中相遇时,假设相差k步,则快指针比慢指针多走了k步,k是环长度的整数倍。接下来让孙子节点从head出发,慢指针处节点同步前进,则两者相差k当两者相遇时,孙子节点正处于环的入口处

利用的知识点是,若快慢两指针在链表中相遇,则它们的步数之差一定是环长的整数倍。

public ListNode detectCycle(ListNode head) {
    ListNode inLoop = getNodeInLoop(head); // 运用2倍速找到环中的节点
    if (inLoop == null) return null;
    ListNode node = head;
    while (node != inLoop) {
        node = node.next;
        inLoop = inLoop.next;
    }
    return node;
}

面试题23:两个链表的第一个重合节点

leetcode.cn/problems/3u…

给定两个单链表的头节点 headA 和 headB ,请找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回 null 。

图示两个链表在节点 c1 开始相交:

image.png

ANSWER

暴力算法是二层嵌套循环,时间复杂度O(mn)

正确的解法是

  1. 依次遍历A、B链表,分别得到其长度m、n,假设m<n,如图例
  2. 指针b先走(n-m)步,然后a出发
  3. 两者速度相同,相遇时就是第一个相交节点

代码略。


面试题24:反转链表

leetcode.cn/problems/UH…

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

image.png

ANSWER

扫描原链表,在处理每个节点时保存其前后节点,谨防链表断裂

public ListNode reverseList(ListNode head) {
    ListNode prev = null;
    ListNode cur = head;
    while (cur != null) {
        ListNode next = cur.next; // 下个待处理节点,临时保存下
        cur.next = prev;
        prev = cur;
        cur = next;
    }
return prev;

面试题25:链表中的数字相加

leetcode.cn/problems/lM… 给定两个 非空链表 l1和 l2 来代表两个非负整数。数字最高位位于链表开始位置。它们的每个节点只存储一位数字。将这两数相加会返回一个新的链表。

可以假设除了数字 0 之外,这两个数字都不会以零开头。示例如下图:

image.png

ANSWER

反转,再逐位相加,注意进位

public ListNode addTwoNumbers(ListNode head1, ListNode head2) {
    head1 = reverseList(head1);
    head2 = reverseList(head2);
    ListNode reverseHead = addReversed(head1, head2);
    return reverseList(reversedHead);
}

private ListNode addReversed(ListNode head1, ListNode head2) {
    ListNode dummy = new ListNode(0);
    ListNode sumNode = dummy;
    int carry = 0;
    while (head1 != null || head2 != null) {
        int sum = (head1 == null ? 0 : head1.val) + (head2 == null ? 0 : head2.val) + carry;
        carry = sum >= 10 ? 1 : 0;
        sum = sum >= 10 ? sum - 10 : sum;
        ListNode newNode = new ListNode(sum);
        sumNode.next = newNode;
        sumNode = sumNode.next;
        head1 = head1 == null ? null : head1.next;
        head2 = head2 == null ? null : head2.next;
    }
    if (carry > 0) {
        sumNode.next = new ListNode(carry);
    }
    return dummy.next;
}

面试题26:重排链表

leetcode.cn/problems/LG…

给定一个单链表 L 的头节点 head ,单链表 L 表示为:L0 → L1 → … → Ln-1 → Ln

请将其重新排列后变为:L0 → Ln → L1 → Ln-1 → L2 → Ln-2 → …

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

image.png

ANSWER

原链表按照“首-尾-首-尾……”方式重排,解题思路如下:

  1. 采用二倍速快慢指针法得到原链表中点奇数长度则为正中节点,偶数长度则为左侧中点
  2. 从中点处截断,得到2个子链表A、B
  3. 将B翻转得到B'
  4. 把A、B'合并(注意边界条件,奇数长度时合并到最后A会多出一个节点

时间复杂度为O(m) ,代码实现略。


面试题27:回文链表

leetcode.cn/problems/aM…

给定一个链表的 头节点 head ,请判断其是否为回文链表。

如果一个链表是回文,那么链表节点序列从前往后看和从后往前看是相同的。

image.png

ANSWER

思路同上一题:

  1. 平分
  2. 反转
  3. 对比

面试题28:展平多级双向链表

leetcode.cn/problems/Qv…

多级双向链表中,除了指向下一个节点和前一个节点指针之外,它还有一个子链表指针,可能指向单独的双向链表。这些子列表也可能会有一个或多个自己的子项,依此类推,生成多级数据结构,如下面的示例所示。

给定位于列表第一级的头节点,请扁平化列表,即将这样的多级双向链表展平成普通的双向链表,使所有结点出现在单级双链表中。

示例输入:

image.png

ANSWER

本题发明了“多级链表”的概念,难点在于理解概念,首先要理解何为“铺平”——当节点有子节点时,把子节点塞入原来节点的位置,子节点的尾巴衔接原来节点的next。

由于子链表中的节点也可能存在子链表,因此这道题存在递归过程。

每个节点遍历一次,时间复杂度O(n) ,递归调用次数取决于链表嵌套次数,如果有k层,空间复杂度O(k)

public Node flattenGetTain(Node head) {
    flattenGetTail(head);
    return head;
}

private Node flattenGetTail(Node head) {
    Node node = head;
    Node tail = null;
    while (node != null) {
        Node next = node.next; // 先保存next,因为后面会替换原来的next指针
        if (node.child != null) { // 有子节点,进入递归
            Node child = node.child;
            Node childTail = flattenGetTail(node.child); // 展平child
            // 将展平后的链表插入原链表
            node.child = null;
            node.next = child;
            child.prev = node;
            childTail.next = next;
            if (next != null) {
                next.prev = childTail;
            }
            tail = childTail;
        } else {
            tail = node; // node没有子节点,不展开了
        }
        node = next; // 同层的下一个
    }
    return tail;
}

面试题29:排序的循环链表

leetcode.cn/problems/4u…

给定循环单调非递减列表中的一个点,写一个函数向这个列表中插入一个新元素 insertVal ,使这个列表仍然是循环升序的。

给定的可以是这个列表中任意一个顶点的指针,并不一定是这个列表中最小元素的指针。

如果有多个满足条件的插入位置,可以选择任意一个位置插入新的值,插入后整个列表仍然保持有序。

如果列表为空(给定的节点是 null),需要创建一个循环有序列表并返回这个节点。否则。请返回原先给定的节点。
示例链表,新增“2”:

image.png

增加2后:

image.png

ANSWER

笨方法求解,注意边界条件

  1. 假设新增节点N

  2. 当原链表为null时,N作为新链表,且指向它自己

  3. 原链表只有1个节点,则它与N互相指

  4. N的值比原链表全部值都大/小,则N插入原链表最大值&最小值之间

  5. N就是普通的N,则扫描一遍原链表找到N的位置

时间复杂度O (n)