Bug Free 单链表(反转、合并、环、k 节点)操作

160 阅读9分钟

1、为什么单链表操作是基础

虽说单链表操作对计算机专业的学生或者 LeetCode 练习者来说是最基础的知识,但是依然不妨碍作为面试内容经常出现。对于现代编程语言而言,链表基本都是内置的数据结构,手写链表可以考察 coding 能力,是否理解链表的数据结构特性,是否有调理的分析链表操作逻辑,是否能保证代码的鲁棒性。从市场长期反馈来看,能写出一个完整的单链表反转是不到 10% 的。只要从事一线开发,这就是一项基本技能。这里使用 Java 语言实现一些 bug free 单链表操作,更进一步,保证每个大标题下的操作能在 10 分钟内完成,这是对一个软件工程师的基本要求,也可以作为语言训练健身操。

再说一下,链表操作容易出现的问题。最重要的是指针的使用,虽然 Java 没有指针概念,但是引用类似于指针,变量的地址赋值给指针,都是指向的内存变量。同时,需要注意指针丢失和内存泄露,虽然 Java 不需要主动回收内存空间,但在操作指针过程中容易发生指针丢失造成链表断裂等问题。

接着就是边界条件处理,在链表为空、只有一个节点、只有两个节点、只处理头或尾节点等情况下是否能保证链表操作正常进行,这也是代码鲁棒性的考验。

最后注意是否使用哨兵,没有哨兵在指针反转等操作留意头节点处理,使用哨兵可以无差别操作表头与非表头节点。

既然是基础技能,无论备面还是训练都应该是信手拈来的,单链表中常用的双指针和递归无外乎是一些操作技巧。无他,唯手熟尔。

2、创建单链表

使用最简单的链表创建方法:

  • 每个节点只有数值与指向下一个节点的引用
  • 使用容器包裹节点,只保留头节点,不增加额外容器属性
  • 链表容器使用哨兵,也就是头节点在链表创建后便始终存在,不包含节点值

(1)链表容器

创建一个链表对象,属性只有哨兵指向表头,提供一个构造方法在链表初始化时初始化哨兵。

public class LinkedList {

    Node head;

    public LinkedList() {
        head = new Node(-1);
    }
}

(2)链表节点

链表提供内部类节点对象,节点对象只有节点值(简化为整型)和指向下一个节点的引用,并提供一个构造方法在节点初始化对节点赋值。

public static class Node {

    int value;
    Node next;

    public Node(int value) {
        this.value = value;
    }
}

(3)链表头插

在链表对象内提供链表插入方法,头插就是每次在链表中新增节点都是插入在链表头部,因为使用了哨兵不需要对头节点做判空操作,直接更改哨兵和新增节点的引用关系即可。

public void firstInsert(Node node) {
    Node nextNode = head.next;
    head.next = node;
    node.next = nextNode;
}

(4)链表尾插

类似的,再提供一个尾插方法。这种 while 循环会经常在链表操作中出现,因为无法像数组或者哈希表一样快速定位,都需要每次从头开始遍历直到找到尾节点。当然,这里只是作为思维练习,链表是可以增加一个引用指向尾节点的。

public void lastInsert(Node node) {
    Node indexNode = head;
    while (indexNode.next != null) {
        indexNode = indexNode.next;
    }
    indexNode.next = node;
}

(5)链表打印

打印操作更多是为了验证链表操作的准确性,这里就直接输出到屏幕终端,从头遍历各节点数值并通过连接符连接。

public void print() {
    if (head.next == null) {
        System.out.println("List is Empty");
        return;
    }
    Node printNode = head.next;
    while (printNode.next != null) {
        System.out.print(printNode.value + "->");
        printNode = printNode.next;
    }
    System.out.println(printNode.value);
}

3、单链表反转

不熟练就画图,单链表就是操作引用,保证引用不丢失,因为使用了哨兵也要避免反转后哨兵与尾节点成环。

反转思路是遍历链表,使用三个引用分别指向待反转引用的前一节点、当前节点与后一节点。前一节点与当前节点引用用于记录反转前后节点关系,后一节点可避免当前待反转引用节点在反转后引用丢失,同时推进链表遍历记录下一待反转节点位置。

反转需要处理的边界清况是头节点将成为尾节点,使用哨兵需要避免和哨兵成环,因此需要将引用置空,反转结束后将哨兵重新指向新的头节点。

public void reverse() {
    Node preNode = null;
    Node curNode = head.next;
    while (curNode != null) {
        Node nextNode = curNode.next;
        curNode.next = preNode;
        preNode = curNode;
        curNode = nextNode;
    }
    head.next = preNode;
}

4、检查环

(1)检查是否存在环

检查单链表是否存在环需要分析其数据结构特性,每个节点都有一个引用可以指向另外一个节点,每个节点可以被多个节点引用。那么单链表如果存在环就一定是尾节点指向了链表中的某个节点(包括尾节点自己),换句话说,使用上面尾插法 while 判空来判定链表尾节点的方法是无效的,会出现无限循环。

检查环的思路是使用双指针,就像操场跑圈一样,一快一慢两个匀速引用只要是在环上就一定会相遇。这里就使用步幅为 1 的慢引用和步幅为 2 的快引用同时从表头遍历,如果有环一定会在环上某点相遇,否则遍历结束。

检查环的边界条件就是结束条件,一是快引用每次查找下一节点的下一节点,需要保证下一节点是存在的;二是一旦快慢引用相遇及时退出循环,否则会无限循环。

public Node checkLoop() {
    Node slowIndex = head;
    Node fastIndex = head;
    while (fastIndex != null && fastIndex.next != null) {
        slowIndex = slowIndex.next;
        fastIndex = fastIndex.next.next;
        if (slowIndex == fastIndex) {
            return slowIndex;
        }
    }
    return null;
}

(2)查找环起点

查找环起点的分析思路是找到快慢引用的相对关系,快引用是慢引用步幅的两倍,相遇时他们走过的绝对路径是一样的。

假设存在环,沿着环引用方向,表头到环起点距离是 a,环起点到相遇点距离是 b,相遇点再到环起点距离是 c,那么快引用绕环 m 圈与慢引用绕环 n 圈后相遇时绝对路径是一样的。a+m(b+c)+b=2(a+n(b+c)+b)可以得到a=(m-2n-1)(b+c)+c,表头到相遇点的距离是整数倍的环长度再加上相遇点到环入口的距离,也就是慢引用从相遇位置继续前进,快引用从头开始他们可以相遇在环起点。

查找环起点的边界条件就是判断是否存在环并拿到相遇点,如果存在就将快引用指向表头判断快慢引用是否再次相遇,相遇位置即为环起点。

public Node findLoopBegin() {
    Node meetNode = checkLoop();
    Node fastIndex = head;
    while (meetNode != fastIndex) {
        meetNode = meetNode.next;
        fastIndex = fastIndex.next;
    }
    return fastIndex;
}

5、单链表中间节点

(1)查找中间节点

在不清楚单链表长度的情况下获取链表长度需要先遍历一次链表,然后再次获取中间节点,如果参照上面双指针的思路,可以设置快慢两个引用,快引用步幅是慢引用的两倍,快引用到达链表尾部的是否慢引用就在链表中间位置了。同样的,查找三分之一、四分之一的位置就调整快引用步幅。

查找中间节点的边界条件也就类似于检查环一样,快引用每次查找下一节点的下一节点,需要保证下一点存在。

public Node findMiddleNode() {
    Node slowIndex = head.next;
    Node fastIndex = head.next;
    while (fastIndex != null && fastIndex.next != null) {
        slowIndex = slowIndex.next;
        fastIndex = fastIndex.next.next;
    }
    return slowIndex;
}

(2)检查是否存在回文结构

既然可以查找到中间节点,引申出来的就是判断链表是否存在回文结构。

实现思路是查找中间节点并将链表后半段反转逐一比较。这里使用类似与上文的单链表反转方法,返回反转后链表头节点,因为没有修改中间节点前一个节点的引用关系,所以会存在两个链表共用同一个尾节点,但不影响结束条件判断,因为反转已经将尾节点的后继引用置空。

边界处理还需要增加空链表的判断。

public boolean findPail() {
    Node middleNode = findMiddleNode();
    if (middleNode == null) {
        return true;
    }
    Node reversedNode = reverse(middleNode);
    Node headNode = head.next;
    while (reversedNode != null) {
        if (reversedNode.value != headNode.value) {
            return false;
        }
        reversedNode = reversedNode.next;
        headNode = headNode.next;
    }
    return true;
}

(3)查找倒数第 k 个节点

使用双指针不仅可以用于查找成比例的节点,也可以用于查找固定距离的节点,比如查找倒数第 k 个节点就可以使用相距 k 始终相同步幅的快慢引用完成。

查找倒数第 k 个节点的边界条件是 k 是否超过链表长度,验证空链表以及倒数第一个和最后一个节点等边界值。

public Node getLastKNode(int k) {
    Node fastIndex = head;
    int index = 0;
    while (fastIndex.next != null) {
        if (index == k) {
            break;
        }
        ++index;
        fastIndex = fastIndex.next;
    }
    if (index < k) {
        return null;
    }
    Node slowIndex = head;
    while (fastIndex != null) {
        slowIndex = slowIndex.next;
        fastIndex = fastIndex.next;
    }
    return slowIndex;
}

6、两个递增单链表合并

两个链表都是有序构建的,因此合并链表可以通过移动各节点的引用,减少额外空间的占用。

又因为使用了哨兵,不需要对两个链表进行判空,将链表合并交给递归或者非递归方法即可。

public void mergeSortedList(LinkedList otherList) {
    Node otherHead = otherList.head;
    // 递归合并
	head.next = recursiveMerge(head.next, otherHead.next);
    // 非递归合并,二选一
    // head.next = nonRecursiveMerge(head.next, otherHead.next);
}

(1)递归合并

使用两个引用分别从头遍历两个链表,每次移动引用后面的操作又是相同的,也就是问题拆分成子问题,子问题解决方法相同就可以考虑递归实现。

实现思路是选取一个最小的头节点作为合并后链表头节点,判断选取哪个链表推进节点合并分别执行递归调用,递归结束条件就是该链表遍历结束。

public Node recursiveMerge(Node node, Node otherNode) {
    if (node == null) {
        return otherNode;
    }
    if (otherNode == null) {
        return node;
    }
    Node newNode = null;
    if (node.value < otherNode.value) {
        newNode = node;
        newNode.next = recursiveMerge(node.next, otherNode);
    } else {
        newNode = otherNode;
        newNode.next = recursiveMerge(node, otherNode.next);
    }
    return newNode;
}

(2)非递归合并

非递归合并分别遍历两个链表,同样先取最小头节点作为合并后链表头节点,然后逐一比较两个链表中的值大小并修改引用关系,直到有一个链表先遍历结束,因为是有序的直接将另一个链表接进来。

public Node nonRecursiveMerge(Node node, Node otherNode) {
    Node newHead = null;
    if (node.value < otherNode.value) {
        newHead = node;
        node = node.next;
    } else {
        newHead = otherNode;
        otherNode = otherNode.next;
    }

    Node curNode = newHead;
    while (node != null && otherNode != null) {
        if (node.value < otherNode.value) {
            curNode.next = node;
            curNode = curNode.next;
            node = node.next;
        } else {
            curNode.next = otherNode;
            curNode = curNode.next;
            otherNode = otherNode.next;
        }
    }
    if (node == null) {
        curNode.next = otherNode;
    } else if (otherNode == null) {
        curNode.next = node;
    }

    return newHead;
}