自己算法上的知识一直都很薄弱,好些题目都是靠之前的死记硬背下来的,过段时间就忘记解法了。所以最近在看极客时间上的一个专栏《数据结构与算法之美》,打算就着这个专栏来复习一些基本算法知识。

ps:看一本书或一篇文章简单,写下来并总结才是最难的

  这次看链表的章节,对我来说收货最大的在于去理解““哨兵"节点的作用,以前看清华大学邓俊辉那本《数据结构》书的时候,知道他总是在链表前面加一个head节点,作为哨兵使用,这个head节点本身不存储任何数据, 它的next指针指向链表的第一个节点。这样做的好处就在于,即使一个链表是空的,它也有一个head节点;而不是常规意义上的空链表(一个节点都没有),有助于我们统一插入和删除逻辑,而不用判断当前节点p是否是 最后一个节点or链表为空的情况。当然,我们自己在写链表相关的代码的时候,必须要考虑你的代码当链表为空,只有一个元素,只有两个元素的时候分别是否work。

  下面是专栏里面的一些链表相关的题目的具体实现,为了方便,有些题目我并没有使用哨兵节点。下面是公共的节点类:

static class Node {
    private int data;
    private Node next;

    public Node() {
    }

    public Node(int data) {
        this.data = data;
        this.next = null;
    }
}

一、单链表翻转

  本质上,翻转一个链表需要三个节点,分别是当前节点curr,当前节点的上一个节点pre,当前节点的下一个节点next。其实就是curr不停的往前走,然后依次将curr的next指针指向pre,pre也依次推移即可。 代码如下:

public static Node reverse(Node node) {
    Node curr = node;
    Node pre = null;
    while (curr != null) {
        Node next = curr.next;   //找到当前节点的下一个节点,保存起来
        curr.next = pre;         //将当前节点的next指针指向上一个节点pre
        pre = curr;              //pre节点后移
        curr = next;             //curr节点后移
    }
    return pre;     //最后pre就指向原始链表中的最后一个节点
}

  上面是我们的常规做法,其实还有一种递归的方式来翻转链表,代码更简洁,但是并不是特别容易理解。参考自 如何递归翻转链表。这里面你需要学会的是,关于链表类的最容易理解的方式就是画图,把整个流程画出来就会比较清晰了,代码如下

  不过这里还引申出了一些其他变种,比如:翻转链表的前n个节点,翻转链表中某个区间的节点。可以尝试锻炼一下递归思维。

public static Node reverseRecursion(Node node) {
    if (node == null || node.next == null) return node;
    Node last = reverseRecursion(node.next);
    node.next.next = node;   //明白这一步的含义
    node.next = null;
    return last;
}

二、检测链表中是否有环

  这道题也是上学时候常见的一道题目,快慢指针法,快指针每次走两步,慢指针每次走一步,如果最后快指针能够追上慢指针,则说明有环,否则无环,比较简单。代码如下:

public static boolean checkCircle(Node node) {
    if (node == null || node.next == null) {
        return false;
    }
    Node fast = node;
    Node slow = node;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (fast == slow) return true;
    }
    return false;
}

  同样用到快慢指针法的还有一道比较简单的题目,找单链表的中间节点,代码如下,不过这样也分两种情况,节点个数为奇数没什么问题,如果是偶数的话,找到的是中间偏右的那个节点,即中间节点的位置为n/2 (位置从0开始)。n为节点个数。

  可以通过更改while里面的条件,比如改为fast.next != null && fast.next.next != null,这样最终的slow指向 的是中间偏左的节点(对于偶数个节点数)。

public static Node findMiddleNode(Node node) {
    Node fast = node;
    Node slow = node;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}

三、合并两个有序链表

  这个题目也可以自己独立写出来。用到了哨兵的技巧,即new一个新的节点出来,在这个节点后面不断的增加两个有序链表中的节点。代码如下:

public static Node mergeSortedList(Node node1, Node node2) {
    if (node1 == null) {
        return node2;
    }
    if (node2 == null) {
        return node1;
    }

    Node head = new Node(-999);
    Node curr = head;
    while (node1 != null && node2 != null) {
        if (node1.data < node2.data) {
            curr.next = node1;
            node1 = node1.next;
            curr = curr.next;

        } else {
            curr.next = node2;
            node2 = node2.next;
            curr = curr.next;
        }
    }
    if (node1 == null) {
        curr.next = node2;
    }
    if (node2 == null) {
        curr.next = node1;
    }
    return head.next;
}

四、删除链表倒数第n个节点

  我开始已经忘记这个题目的trick解法了,只能先遍历一次链表,求出length;然后知道倒数第k个就是顺数的第length - k个(从0开始),那只要找到待删除节点的pre即可,代码如下,还需要考虑一些异常情况。 比如待删除的就是头节点,那它其实没有pre的(当然也可以造一个哨兵节点出来),本质逻辑都是一样的。代码如下:

public static Node deleteKthNode(Node node, int k) throws Exception {
    Node head = node;
    int length = 0;
    while (head != null) {
        length++;
        head = head.next;
    }
    if (k > length) throw new Exception("k is bigger than length");
    k = length - k;
    if (k == 0) {
        return node.next;
    }
    Node pre = node;
    for (int i = 0; i < k - 1; i++) {
        pre = pre.next;
    }
    pre.next = pre.next.next;
    return node;
}

  看了专栏作者的代码,才发现有一种trick解法,省去了第一次遍历求链表长度的操作。思路:用两个指针,都指向head,第一个指针先走k - 1步,然后从现在开始一起往后走。当第一个指针到达链表末尾时,第二个 指针指向的节点就是待删除的节点。当然,你需要求的永远都应该是待删除节点的pre。

  attention:所以这里有一个trick,你让第一个指针走k步,这样最后第二个指针指向的就会是待删除节点的pre了。 如果按照走k步的方式,那么你再后面那次遍历的时候,就需要记录下第二个指针的pre节点,最后根据pre节点的情况来判断是否删除的是头节点。代码如下:

public static Node deleteKthNodeTrick(Node node, int k) {
    Node first = node;
    Node pre = node;

    //默认k会大于0且不会大于链表长度,实际情况需要询问清楚
    for (int i = 0; i < k; i++) {
        first = first.next;
    }

    if (first == null) {    //此处需要增加一个判断,否则在下面可能会抛空指针
        return node.next;
    }

    while (first.next != null) {
        first = first.next;
        pre = pre.next;
    }

    if (pre == node) {
        return node.next;
    }
    pre.next = pre.next.next;
    return node;
}

五、判断一个链表是否为回文

  这道题比前面几道都稍微复杂一点,但只是一道LeetCode的简单题目而已。可以看一下基于链表的方式判断Palindrome的 策略,需要同时结合翻转链表和找中间节点。思路就是:使用快慢指针找中间节点,我们知道中间节点最后返回的是慢指针, 在慢指针前进的过程中,同时修改其next指针,让链表的前半部分逆序。最后比较中间节点两侧的链表是否相等即可。代码如下:

public static boolean isPalindrome(Node node) {
    if (node == null || node.next == null) return true;

    Node fast = node;
    Node slow = node;
    Node pre = null;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        Node next = slow.next;
        slow.next = pre;
        pre = slow;
        slow = next;
    }

    //这一步特别关键,即判断奇偶性,也就是上面while循环终止的条件,如果fast == null,则证明
    节点个数为偶数;如果fast.next == null,则证明节点个数为奇数。
    if (fast != null) {
        slow = slow.next;
    }

    while (slow != null) {
        if (slow.data != pre.data) {
            return false;
        }
        slow = slow.next;
        pre = pre.next;
    }
    return true;
}

  总结:先画图,再理解每一步你需要做哪些事情。而且在实际情况,需要判断各种异常情况,每一次代码写完后,需要用各种非正常情况来测试你的代码,测试所有的边界情况。

六、反转链表的前N个节点