算法学习之链表

101 阅读6分钟

前置节点法

代码:

class Solution {
    public ListNode removeElements(ListNode head, int val) {
        if (head == null) {
            return head;
        }
        // 因为删除可能涉及到头节点,所以设置dummy节点,统一操作
        ListNode dummy = new ListNode(-1, head);
        ListNode pre = dummy;
        ListNode cur = head;
        while (cur != null) {
            if (cur.val == val) {
                pre.next = cur.next;
            } else {
                pre = cur;
            }
            cur = cur.next;
        }
        return dummy.next;
    }
}

我们这里设置了虚拟节点,让程序本身方便了不少,这里要重点要注意的点有两个,一是设置虚拟节点,二是保证虚拟节点和遍历节点的前进规则,保证一次循环之内就要将所有的目标元素连接起来。这里的逻辑的虚拟节点在前头,然后遍历结点一直前进, 每当遍历结点到目标值时,虚拟节点就会连接遍历结点的下一个结点,直到遍历结束

前置结点法是许多链表里都可以设置的方法,我们看到链表的题目,就应该想到利用前置节点。与前置节点相对的,我们甚至可以设置一个后置节点,当然后置节点用得到的地方比较少,但也不失为一种思路

前置结点法的进一步理解

注意这里我们的题目的addAtHead()方法,这里我们如果使用前置结点法,能有效降低本题的难度,避免特殊的判断。但是虚拟节点的使用也仍然有说法,因为虚拟节点的设计方法并不是只有一种,而不同的设计所产生的效果是不同的,下面我们就来看看下面的两种设计虚拟节点的情况

虚拟节点设计在方法中:

import java.util.HashSet;
import java.util.Set;
​
class Node{
    int val;
    Node next;
​
    public Node() {
    }
​
    public Node(int val) {
        this.val = val;
    }
​
    public Node(Node next) {
        this.next = next;
    }
​
    public Node(int val, Node next) {
        this.val = val;
        this.next = next;
    }
}
​
class MyLinkedList {
    int size;
    Node head;
​
    public MyLinkedList() {
        size = 0;
        head = null;
    }
​
    public int get(int index) {
        if(index<0 || index>=size){
            return -1;
        }
        Node prev = new Node(-1,head);
        for (int i = 0; i < index; i++) {
            prev=prev.next;
        }
        return prev.next.val;
    }
​
    public void addAtHead(int val) {
        addAtIndex(0,val);
    }
​
    public void addAtTail(int val) {
        addAtIndex(size,val);
    }
​
    public void addAtIndex(int index, int val) {
        if(index>size){
            return;
        }
        Node prev = new Node(-1,head);
        Node node = new Node(val);
        for (int i = 0; i < index; i++) {
            prev=prev.next;
        }
        Node next = prev.next;
        prev.next=node;
        node.next=next;
        size++;
    }
​
    public void deleteAtIndex(int index) {
        if(index<0 || index>=size){
            return;
        }
        Node prev = new Node(-1,head);
        for (int i = 0; i < index; i++) {
            prev=prev.next;
        }
        Node node = prev.next.next;
        prev.next=node;
        size--;
    }
}

这是虚拟节点设计在方法中的情况,这样设计的确可以避免特殊处理头结点的情况,但是这还不够完美,因为我们可以看到我们在方法中需要维护我们的head,而且在每一个方法里我们都要重复进行创建前置结点的动作,还是有些麻烦的,不够完美

虚拟节点设计在头部中的情况

import java.util.HashSet;
import java.util.Set;
​
//单链表
class ListNode {
    int val;
    ListNode next;
    ListNode(){}
    ListNode(int val) {
        this.val=val;
    }
}
class MyLinkedList {
    //size存储链表元素的个数
    int size;
    //虚拟头结点
    ListNode head;
​
    //初始化链表
    public MyLinkedList() {
        size = 0;
        head = new ListNode(0);
    }
​
    //获取第index个节点的数值
    public int get(int index) {
        //如果index非法,返回-1
        if (index < 0 || index >= size) {
            return -1;
        }
        ListNode currentNode = head;
        //包含一个虚拟头节点,所以查找第 index+1 个节点
        for (int i = 0; i <= index; i++) {
            currentNode = currentNode.next;
        }
        return currentNode.val;
    }
​
    //在链表最前面插入一个节点
    public void addAtHead(int val) {
        addAtIndex(0, val);
    }
​
    //在链表的最后插入一个节点
    public void addAtTail(int val) {
        addAtIndex(size, val);
    }
​
    // 在第 index 个节点之前插入一个新节点,例如index为0,那么新插入的节点为链表的新头节点。
    // 如果 index 等于链表的长度,则说明是新插入的节点为链表的尾结点
    // 如果 index 大于链表的长度,则返回空
    public void addAtIndex(int index, int val) {
        if (index > size) {
            return;
        }
        if (index < 0) {
            index = 0;
        }
        size++;
        //找到要插入节点的前驱
        ListNode pred = head;
        for (int i = 0; i < index; i++) {
            pred = pred.next;
        }
        ListNode toAdd = new ListNode(val);
        toAdd.next = pred.next;
        pred.next = toAdd;
    }
​
    //删除第index个节点
    public void deleteAtIndex(int index) {
        if (index < 0 || index >= size) {
            return;
        }
        size--;
        ListNode pred = head;
        for (int i = 0; i < index; i++) {
            pred = pred.next;
        }
        pred.next = pred.next.next;
    }
}

这里就是将前置结点设计在构造方法中的情况,这里总是头结点为前置节点,后面的结点都是放在这个节点后面的,这样不但能避免特殊处理头结点,而且还能避免重复定义前置节点,只定义一次就够了

双指针法

在链表中我们同样有双指针法用于解决问题,先来看下面的题目

这一题我们就要用到双指针结合前置结点,我们将第一个指针指向链表的前置节点,第二个指针指向链表的要翻转的结点,然后进行翻转,翻转之后将这个两个指针都往下移动一位,最后我们返回符合题目要求的尾结点。本题有迭代和递归两种解题方式,其本质都是一样的,都是使用双指针法

看看gif的演示图

迭代:

class Solution {
    public ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode node = head;
        while (node!=null){
            ListNode next = node.next;
            node.next=prev;
            prev=node;
            node=next;
        }
        return prev;
    }
}

可以看到我们这里不但使用了虚拟节点,而且每次循环都保存其下一个引用的内存地址用于后续的前两个指针的往后移动,循环结束时第一个指针必然指向最后一个结点,我们只要返回第一个指针所指向的结点就可以了

递归:

class Solution {
    public ListNode reverseList(ListNode head) {
        return dfs(null,head);
    }
​
    private ListNode dfs(ListNode prev, ListNode head) {
        if(head==null){
            return prev;
        }
        ListNode next = head.next;
        head.next=prev;
        return dfs(head,next);
    }
}

递归的本质也是双指针法,不过这里要做一些算法上的修改,比如我们这里实际调用的递归方法是我们自己设计的递归方法,这样做相当于是在创造双指针,然后每次递归我们翻转对应结点的指向,接着再次进入递归,传入其下一个结点和当前结点,这一步相当于是令两个指针前进一位。这里我们要注意的最后一点是我们返回的prev的结点,这个节点就是我们的目标结点,这个节点是通过dfs()方法返回的,我们将这个节点返回给题目,最终就可以成功解题了

双指针法的进一步的理解

进一步加深理解双指针法,我们继续做下面这一题。我们对下一题要用上双指针法,前置节点法

在本题中很重要的一点是对这个翻转过程的模拟,这里的翻转并不是简单的改变两个结点的指向就可以了,实际涉及到了三个结点,每次循环不但要改变对应结点的指向,而且要将我们的指针结点都往前前进一位

模拟法很重要的一点是对过程本身的模拟,这里最好用到图纸进行模拟,因为光靠脑子想十之八九就寄了,没事还是要多动笔。这里我们的步骤是可以互换的,相应的我们也要稍微对代码进行一些改造就可以了

先来看看这一份代码:

class Solution {
    public ListNode swapPairs(ListNode head) {
        if(head!=null && head.next==null){
            return head;
        }
        ListNode prev = new ListNode(-1,head);
        ListNode node = head;
        ListNode ansnode = head == null ? null : head.next;
        while (node!=null && node.next!=null){
            ListNode next = node.next;
            prev.next = next;
            node.next=next.next;
            next.next=node;
            prev = node;
            node = node.next;
        }
        return ansnode;
    }
}

可以看到这一份代码已经成功使用了前置节点法和双指针法了,但是这份代码却有对特例进行判断的部分(这些特例分别是链表为空和链表只有一个元素时的情况),之所以会存在这种特例判断,这是因为其循环条件里,仍然是以头结点来进行判断而不是前置节点导致的,如果我们想要前置节点发挥百分百的功效,让其能够将特殊情况普通化,那么就应当注意前置节点在后续代码里的使用,不要只加入前置节点然后就放着不管了,这样是不行的

来看看完美的题解代码:

// 虚拟头结点
class Solution {
    public ListNode swapPairs(ListNode head) {
​
        ListNode dummyNode = new ListNode(0);
        dummyNode.next = head;
        ListNode prev = dummyNode;
​
        while (prev.next != null && prev.next.next != null) {
            ListNode temp = head.next.next; // 缓存 next
            prev.next = head.next;          // 将 prev 的 next 改为 head 的 next
            head.next.next = head;          // 将 head.next(prev.next) 的next,指向 head
            head.next = temp;               // 将head 的 next 接上缓存的temp
            prev = head;                    // 步进1位
            head = head.next;               // 步进1位
        }
        return dummyNode.next;
    }
}

这份代码同样也可以用递归实现,这里就作为一个思考题留给大家思考了

双指针法的再一步加深理解

双指针的进阶用法里很重要的一步是先定位出两个指针,然后利用这两个指针解题

这里我们的思路是先定义出两个指针,第一个指针指向前置结点,另一个指针指向第n的结点,n为其要删除的倒数结点,然后我们将这个指针一起前进,这样当第二个指针到末位的时候,第一个指针就正好指向要删除的结点的前一位,此时进行链表结点的删除就可以了

题解代码:

class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode node = new ListNode(-1,head);
        ListNode listNode = node;
        ListNode ans = node;
        while (n-->=0){
            listNode = listNode.next;
        }
        while (listNode!=null){
            listNode=listNode.next;
            node=node.next;
        }
        node.next=node.next.next;
        return ans.next;
    }
}

注意这里我们的代码使用了前置结点指针我们的两个指针最初的位置都是从前置结点开始的,我们的循环也因为前置结点多增加的一位,添加了等于号,这些都是符合规范的,同时也将前置节点发挥到了最大作用

双指针法的又一步理解

双指针法的指针甚至可以不在同一个链表里,我们通过下面这个例题来说明这种情况

我们这里的思路是先求出两个链表的长度,然后得到其差,然后让长链表指向第一个指针,接着指针前进两链表长度之差个结点,此时我们再定义一个指针指向短链表,此时我们的两个指针就同时指向了两个链表的同一个位置,我们让两个链表一起前进,当两个链表指向同一个结点的时候就到了我们的第一个共同结点

题解代码:

class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        int lenA = 0,lenB = 0;
        ListNode nodeA = headA;
        ListNode nodeB = headB;
        while (nodeA!=null){
            nodeA= nodeA.next;
            lenA++;
        }
        while (nodeB!=null){
            nodeB=nodeB.next;
            lenB++;
        }
        if(lenB>lenA){
            ListNode node = headA;
            headA = headB;
            headB = node;
            int tmp = 0;
            tmp = lenA;
            lenA=lenB;
            lenB=tmp;
        }
        int result = lenA-lenB;
        nodeA = headA;
        nodeB = headB;
        for (int i = 0; i < result; i++) {
            nodeA = nodeA.next;
        }
        while (nodeA!=null){
            if(nodeA==nodeB){
                return nodeA;
            }
            nodeB= nodeB.next;
            nodeA=nodeA.next;
        }
        return null;
    }
}

注意我们这里并没有使用前置结点,这是因为这里前置的结点根本就不需要,因此不需要用,当然,我们嗯加上去也是可以的,不过完全是多此一举。其次是这里我们对长度进行了一个修正,总是让headA代表的链表为最长链表,这样就只需要处理一种情况就可以了

双指针的最终进阶——快慢指针

这是链表双指针的最后一个内容,快慢指针!先来看例题

我们这里判断其是否有环的方式是定义两个指针,一个指针每次循环前进一位,另一个指针每次循环前进两位,如果该链表有环,则最终其一定会相遇。接下来的问题在于如果判断环的入口,一个简单的想法是通过Set集合从头遍历链表,当加入的链表内存地址相同时就返回那个链表,这个方法很容易想到,但是其效率较低,因为只要用了哈希表效率一般都不咋地。我们有一个方法就是当我们的快慢指针相遇时,我们立刻在链表头部定义一个新指针,然后新指针和相遇的指针每次循环都前进一步,最终他们相遇的第一个结点就是链表的环入口。这里面的证明涉及到数学过程,这里就不给了

题解代码:

class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode prev = new ListNode(-1);
        prev.next = head;
        ListNode slow = prev;
        ListNode fast = prev;
        while (fast!=null && fast.next!=null){
            slow = slow.next;
            fast = fast.next.next;
            if(slow == fast){
                ListNode node = prev;
                while (true){
                    if(node==slow){
                        return node;
                    }
                    slow = slow.next;
                    node = node.next;
                }
            }
        }
        return null;
    }
}

这里我们使用了虚拟节点,其实用不用都一样,但是为了规范我们还是用,因为我们此前说过只要链表,我们都要先想到虚拟节点

链表知识点的总结

最后我们来总结下本章的知识点,首先是前置节点的使用,我们看到任何链表,都应该首先想到要使用前置节点,其次是前置节点不能只是单纯的创建一个就完了,在循环中也应当使用到,以求发挥出其功效,最后一点是某些情况下前置节点定义到最开头当做虚拟节点使用,以后的头结点都是该结点的后一位,这样定义比每次需要时再前置节点要好得多,效率也更高

其次是双指针法,双指针法的关键在于定义双指针的起始位置,两个指针运动的规律,以及指针结束的条件,无论是何种双指针法,都遵从这三个原则。在链表里有时使用双指针法时,我们需要在每一次的循环里先获得第二个指针的下一个结点

最后是模拟法,对于模拟法,其关键在于我们要动笔画,画出模拟图然后在代码上实现,不要靠脑子想,这样只会寄