一文讲透链表操作,看完你也能轻松写出正确的链表代码

前言

链表和数组一样,是一种线性的数据结构,算法中的链表操作一般都针对单向链表,因为单向链表比较简单但是又比较能考研编程者的思维能力。虽然单向链表比较简单,但是要写好链表的代码也不是一件容易的事,掌握好链表有几个关键点:首先就是要防止指针丢失,然后就是我们可以引入哨兵来简化链表的操作,最后巧妙的利用双指针也可以写出更高效简洁的链表算法。

什么是链表

链表是一种物理存储单元上非连续、非顺序的存储结构,但是其在逻辑上是连续的。链表中每一个数据元素的逻辑顺序是通过链表中的指针来实现的。

链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。链表的每个结点应至少包括两个部分:一个是存储节点的元素信息,另一个是存储下一个结点地址的 next 指针。

链表相比较于数组操作会更复杂一点,而且链表和数组也有一些本质区别。

链表和数组的区别

链表和数组一样,都是一种线性存储的数据结构。数组需要内存空间是连续的,所以对内存的要求比较高,因为即使内存中的总剩余内存足够,但是假如内存不是连续的,或者说连续的内存空间不够当前数组的初始化,数组也是无法初始化成功的;而链表对内存的要求相对就较低,因为链表并不需要内存空间是连续的。除此之外,链表和数组在以下的操作中也有本质区别:

  • 插入元素

数组中插入元素时,插入位置之后的所有元素都需要往后移动一位,所以数组中插入一个元素最坏时间复杂度是 O(n),而链表却可以达到 O(1) 的时间复杂度,因为其只需要修改插入位置相关指针的指向即可完成元素的插入。

  • 删除元素

删除元素也一样,数组需要将删除位置之后的元素全部往前移动一位,所以最坏时间复杂度也是 O(n),而链表却不需要,在链表中删除一个元素也仅仅需要修改相关指针指向即可,时间复杂度为 O(1)

  • 随机获取元素

数组因为空间连续,所以随机获取一个元素可以达到 O(1) 的时间复杂度,而链表却不行,链表随机获取一个元素需要从头节点开始遍历,所以最坏时间复杂度为 O(n)

  • 获取长度

数组中获取长度的时间复杂度是 O(1),而链表只能从头节点开始遍历,一直链表结尾,所以获取链表长度的时间复杂度也是 O(n)

常见链表类型

在链表中,我们通常有三种常见类型:单向链表双向链表循环链表

单向链表

单向链表指的是每个节点除了维护自身的节点信息,还会维护一个指向下一个节点的 next 指针,链表最后一个节点的 next 指针指向 null,如下图所示就是一个单向链表:

链表中第一个节点也被称之为“头节点”,头节点用来记录链表的基地址,一般我们都是通过头节点去遍历整条链表,直到某一个节点的 next=null,则表示链表已经结束,通过遍历我们就可以得到整条链表的长度以及链表中的每一个节点。

单向链表在算法题中非常受欢迎,因为其操作简单,但是却又能考察思维,尤其是链表反转,有序链表合并等操作,非常重要,这其中又以链表反转为重中之重,后面我们会专门来分析这两个问题。

双向链表

相比较于单向链表,双向链表多了一个 prev 指针来指向上一个节点,如下图所示就是一个双向链表:

双向链表在算法中比较少,但是在实际工程中却应用更加广泛,比如 JavaJUC 当中就大量运用了双向链表,双向链表相比较于单向链表也有优势,比如给定一个节点 Node,要将其从链表删除,那么这时候如果是单向链表只能从头开始遍历找到这个节点的前一个节点才能执行删除操作,而双向链表就不需要,直接通过 prev 指针就能找到当前节点的前一个节点。

循环链表

循环链表就是首尾相接,比如单向链表中,将最后一个节点的 next 指针指向头节点就形成了一个循环链表;在双向链表中将头节点的 prev 指针指向尾节点,将尾节点的 next 指针指向头部节点,也形成了一个循环链表。

链表的基本操作

链表的基础操作同样的是增删改查,接下来我们就分别看一下如何对链表进行增删改查操作。

在进行增删改查之前,我们先初始化一个链表:

//定义链表节点
public class ListNode {
    public int val;//节点中的值
    public ListNode next;//指向下一个节点的指针

    public int getVal(){
        return this.val;
    }

    ListNode(int x) {
        val = x;
        next = null;
    }
}
//根据数组初始化一个链表
package com.lonely.wolf.note.list;
public class ListNodeInit {
    public static ListNode initLinkedList(int[] array) {
        ListNode head = null, cur = null;
        for (int i = 0; i < array.length; i++) {
            ListNode newNode = new ListNode(array[i]);
            newNode.next = null;
            if (i == 0) {//初始化头节点
                head = newNode;
                cur = head;
            } else {//继续一个个往后插入节点
                cur.next = newNode;
                cur = newNode;
            }
        }
        return head;
    }
}

获取链表长度

在单向链表中,获取一个链表的长度需要从头节点开始往后遍历,直到链表尾部。所以获取一个链表的长度时间复杂度也是 O(n)

public static int getListLength(ListNode head){
    int length = 0;
    while (null != head){
        length++;
        head = head.next;
    }
    return length;
}

查找节点

假如说需要查找一个指定节点,或者说查找第 N 个节点,查找方式也是和获取链表长度一样,从 head 节点开始遍历,然后进行对比或者计数,直到找到需要的节点,所以查找一个节点的最坏时间复杂度也是 O(n)

新增节点

假如说我们需要在指定链表的指定位置插入一个节点,那么这时候我们需要考虑以下几点:

  • 当前插入的位置是否超出了链表的长度。
  • 当前指定的链表是否为空。
  • 当前插入链表的位置是链表的头部,尾部,还是中间位置。

下面就是一个往指定链表中指定位置插入一个元素的示例:

public static ListNode insertNode(ListNode head, ListNode insertNode, int position) {
        if (null == head){//需要注意当前链表为空的情况
            return insertNode;
        }
        int size = ListNodeInit.getListLength(head);//获取链表长度
        if (position < 0 || position > size + 1){//处理越界
            return head;
        }
        if (position == 1){//插入头部(对链表一般从 1 开始)
            insertNode.next = head;
            head = insertNode;
            return head;
        }

        //假如不是插入链表头部,那么这时候我们需要找到插入位置的前一个节点
        ListNode prevNode = head;
        int i = 1;
        while (i < position-1) {//遍历到position的前一个节点
            prevNode = prevNode.next;
            i++;
        }
        //注意这里的插入操作,要有器注意指针丢失,如果后面这两句话反一下,那么就会造成链表断裂
        insertNode.next = prevNode.next;//1
        prevNode.next = insertNode;//2
        return head;
    }

我们以文中开头的单向链表为例子,假如要在第 4 个 位置插入一个 val=5 的节点,这时候我们需要先找到节点 3,也就是上面示例中的 preNode 节点,这时候假如我们先执行上面注释为 2 的代码:preNode.next = insertNode,那么就会出现下图中情况(虚线的指针表示已经被被改变):

这时候我们发现原链表被断开,也就是节点 4 已经找不到了,所以插入节点的时候,一定要将新节点和原链表先接上才能防止指针丢失。

这里要划个重点,请注意链表操作一定要谨防指针丢失

删除节点

删除一个节点和插入一个节点类似,也需要考虑各种边界情况,大家不要以为这些边界判断都是小事,事实在面试中写一个算法,最为重要的就是边界的判断,边界判断错误,可能运行就报错,运行报错可能面试就 over 了。

还是以文中开始的单向链表为例,假如要删除节点 3,那么这时候 pNode 就是 2deleteNode 就是 3,要把节点 3 删除的第一步就是把图中指针 p1 先指向 4,然后再将指针 p2 断开,即可完成节点的删除。

下面就是删除一个节点的示例:

public static ListNode deleteNode(ListNode head,int position) {
        if (null == head){//链表为空
            return null;
        }
        int length = ListNodeInit.getListLength(head);//获取链表长度
        if (position <= 0 || position > length){//删除位置无效
            return head;
        }

        //删除头部节点
        ListNode pNode = head;
        if (position == 1){
            head.next = null;//断开原head,不断也行,断开可以防止内存泄露
            return pNode.next;//设置新head
        }

        int count = 1;
        while (count < position - 1){//找到删除节点的前一个节点
            pNode = pNode.next;
            count++;
        }

        //断开流程见下图
        ListNode deleteNode = pNode.next;//找到需要删除的节点
        pNode.next = deleteNode.next;
        deleteNode.next = null;//断开删除节点和链表的联系,不断也行,断开可以防止内存泄露
        return head;
    }

更新节点

如果掌握了前面的查询,插入,删除操作,那么更新就简单了,如果只是更新节点的 val,那么遍历找到节点直接替换即可,如果说要改变某一个节点的位置,那么可以先删除原节点,再插入即可完成。

如何写出 Bug Free 的链表代码

操作链表的时候如果想写出一段 BugFree 的代码,那么每次都要至少要反复考虑下面四种场景下代码能否正常工作:

  1. 链表为空时,能否达到预期效果。
  2. 链表只有一个 head 节点,能否达到预期效果。
  3. 链表只包含两个节点,能否达到预期效果。
  4. 如果逻辑处理在头节点或者尾结点(比如插入和删除操作),能否达到预期效果。

如果上面四种场景都没有问题,那么这段代码基本上就没有什么问题了。

链表的重要算法

链表的操作非常考验编程者的思维,一不小心可能就会造成了指针丢失,或者边界处理错误的情况。在掌握了链表基本的增删改查之后,我们继续来看一下一些稍微复杂点的链表操作。

合并有序链表

leetcode 中的第 21 题:将两个升序链表合并为一个新的升序链表并返回,新链表是通过拼接给定的两个链表的所有节点组成的。

这道题不难,在 leetcode 中也是定义为简单级别,但是这道题的重点是有序列表,而且合并后仍然要保持有序,所以这时候新链表的头节点是谁我们也不知道,如果不使用任何技巧,直接写也能完成这道题目,但是却需要做各种判断,所以这里我们要介绍另一种技巧,那就是引入哨兵节点。

所谓的哨兵节点,就是定义一个虚拟节点,在这里我们可以定义一个哨兵节点来作为头节点,这样我们只需要在判断两个链表中元素的大小之后,将元素更小的节点插入哨兵节点的 next 节点即可,然后不断比较依次插入即可完成,最终返回哨兵节点的 next 节点就是合并后有序链表的头节点。

public static ListNode mergeTwoList(ListNode head1,ListNode head2){
        ListNode sentryNode = new ListNode(-1);//哨兵节点
        ListNode curr = sentryNode;
        while (null != head1 && null != head2){
            if (head1.val < head2.val){//如果链表1的节点更小,那么链表1往后走
                curr.next = head1;
                head1 = head1.next;
            }else {//如果链表2的节点更小,那么链表2往后走
                curr.next = head2;
                head2 = head2.next;
            }
            curr = curr.next;
        }
        //到这里最多有一个链表剩余节点,也可能都结束了,链表的好处就是不需要继续循环,直接接上剩余链表就行
        curr.next = null == head1 ? head2 : head1;//如果两个都为空,那么接任意一个都没关系,都是Null
        return sentryNode.next;//返回哨兵节点的下一个节点就是新链表的头节点
    }

链表反转

链表反转可以说是操作单向链表的精髓,链表反转也是学习链表必须要学习的一个功能。

leetcode 中,关于链表反转的题目很多,当然,不管题目再多,只要掌握了基础,那么其他无非是多绕几步,比如在指定区间反转等等。

leetcode 中的第 206 题:给你单链表的头节点 head,请你反转链表,并返回反转后的链表。

这道题的要求很简单,其中最关键的依然是防止指针丢失,我们仍然以文中开始的单向链表为例,我们从头部开始反转链表,那么首先就需要将下图中的指针 p1 指向前一个节点,因为当前为头节点,所以其前一个节点为 null。如果这时候我们上来就直接将指针 p1 指向 null,链表就会断裂,因为链表的第二个节点及之后的节点都找不到了,所以在反转一个节点前,我们必须要记录好这个节点的下一个节点

反转完第一个节点时,这时候开始处理节点 2,那么依然我们需要先将节点 2 的下一个节点,也就是节点 3 记录下来:

通过上图我们又发现问题了,指针 p2 该指向谁呢?很明显应该是指向节点 1 的,但是这时候我们却找不到节点 1 了,所以为了能找到节点 1,上面在第一步反转的时候,我们还需要记录一个 pre 节点,也就是在每次循环的时候,需要先将当前节点(即下一个反转节点的 pre 节点)保存下来,这样才能保证不会断了联系。

做到这两点之后,我们就可以继续往后反转,直到完成整个链表的反转。相关实现代码如下:

public static ListNode reverseByDirect(ListNode head){
        if (null == head || null == head.next){//空或者只有一个节点无需反转
            return head;
        }
        ListNode pre = null;//记录前一个节点
        ListNode curr = head;//记录当前反转的节点
        while (null != curr){
            ListNode next = curr.next;//记录下一个节点
            curr.next = pre;//将当前节点 next 指向已经反转的节点
            pre = curr;
            curr = next;
        }
        return pre;//最后 pre 就是原链表的最后一个节点,也就是新链表的头节点
    }

我们在前面实现有序链表的合并时提到了哨兵节点的妙用,那么其实在链表反转中,也可以利用哨兵节点来实现,利用哨兵实现链表反转主要步骤为:

  1. 定义一个哨兵节点。
  2. 开始遍历原链表,依次将每个节点插入哨兵之后。注意,每次都是插入哨兵的后面,这样等全部插入完成之后,哨兵的下一个节点就是原链表的最后一个节点。

通过哨兵节点的引入,链表的反转就简化成了链表的插入。相关代码示例如下:

public static ListNode reverseBySentryNode(ListNode head){
        if (null == head || null == head.next){//空或者只有一个节点无需反转
            return head;
        }

        ListNode sentry = new ListNode(-1);//哨兵节点
        ListNode curr = head;
        while (null != curr){
            ListNode next = curr.next;//记录下一个节点,防止失联
            curr.next = sentry.next;
            sentry.next = curr;//当前节点要接入哨兵之后
            curr = next;
        }
        return sentry.next;
    }

寻找倒数第 k 个节点

在剑指 offer 中的第 22 题是寻找链表倒数第 k 个节点(链表从 1 开始计数),这道题是比较有意思的一道题,正常简单粗暴的思路就是先遍历一边找到链表的长度 n,然后第 n-k+1 个节点就是倒数第 k 个节点,这种解法虽然可行,时间复杂度也是 O(n),但是却需要遍历两次链表,那么能不能通过遍历一次链表实现呢?

在数组当中我们提到了双指针思想非常重要,其实在链表中也一样,这道题也可以通过快慢指针来快速实现。

我们思考一下,从倒数第 k 个节点走到链表的末尾,注意这个末尾指的不是倒数第 1 个节点,而是 null,这时候很明显需要走 k 步。

知道了这个,那么就可以利用这个相差 k 步来做文章,我们定义两个指针,一个 fast,一个 slow。我们首先让 fast 指针走 k+1 步,然后让 slow 指针指向 head 节点,这时候 slow 指针和 fast 指针之间是不是也是刚好相差 k 步,那么这时候再让 slow 指针和 fast 指针同时走,当 fast 指向链表结尾也就是 null 的时候,slow 指针就刚好是倒数第 k 个元素。

相关代码的实现如下:

public static ListNode findKElementFromEnd(ListNode listNode,int k){
        if (k <= 0){
            return null;
        }
        ListNode fast = listNode;
        ListNode slow = listNode;

        //fast !=null 是为了防止 k 大于链表长度的情况
        while (fast != null && k > 0) {//fast 指针先走 k 步
            fast = fast.next;
            k--;
        }
        while (null != fast){//快慢指针一起走,直到 fast=null
            fast = fast.next;
            slow = slow.next;
        }
        return slow;
    }

注意这道题里面,如果 k 大于链表长度,那么会返回头节点,这是个细节问题,如果面试中碰到,大家最好问清楚如果 k 大于链表长度应该如何处理。

总结

本文主要讲述了链表的基本增删改查操作,并通过简单的增删改查操作,引入了更高级的链表合并以及链表反转等算法,最终我们可以得到掌握好链表有 3 个关键点:首先就是要防止指针丢失,然后就是我们可以引入哨兵来简化链表的操作,最后巧妙的利用双指针也可以写出更高效简洁的链表算法。

Tags: