当前位置 博文首页 > 英雄哪里出来:《画解数据结构》七个动画 “画“ 解链表

    英雄哪里出来:《画解数据结构》七个动画 “画“ 解链表

    作者:[db:作者] 时间:2021-08-14 11:53

    本文已收录于专栏
    🌳《画解数据结构》🌳

    零、前言

    ??「 数据结构 」「 算法 」 是密不可分的,两者往往是「 相辅相成 」的存在,所以,在学习 「 数据结构 」 的过程中,不免会遇到各种「 算法 」
    ??到底是先学 数据结构 ,还是先学 算法,我认为不必纠结这个问题,一定是一起学的。
    ??数据结构 常用的操作一般为:「 增 」「 删 」「 改 」「 查 」。基本上所有的数据结构都是围绕这几个操作进行展开的。
    ??那么这篇文章,作者将用 「 七张动图 」 来阐述一种最基础的链式结构

    「 单向链表 」

    在这里插入图片描述

    🙉饭不食,水不饮,题必须刷🙉

    C语言免费动漫教程,和我一起打卡!
    🌞《光天化日学C语言》🌞

    LeetCode 太难?先看简单题!
    🧡《C语言入门100例》🧡

    数据结构难?不存在的!
    🌳《画解数据结构》🌳

    LeetCode 太简单?算法学起来!
    🌌《夜深人静写算法》🌌

    ??今天要讲的内容,浓缩一下就是下面这张图:


    ??看不懂没有关系,我会把它拆开来一个一个讲,首先来看一些简单的概念。

    一、概念

    • 对于顺序存储的结构,如数组,最大的缺点就是:插入删除 的时候需要移动大量的元素。所以,基于前人的智慧,他们发明了链表。

    1、链表定义

    ??链表 是由一个个 结点 组成,每个 结点 之间通过 链接关系 串联起来,每个 结点 都有一个 后继节点,最后一个 结点后继结点空结点。如下图所示:

    • 由链接关系A -> B组织起来的两个结点,B被称为A的后继结点,A被称为B的前驱结点。
    • 链表 分为 单向链表双向链表循环链表 等等,本文要介绍的链表是 单向链表
    • 由于链表是由一个个 结点 组成,所以我们先来看下 结点 的实现。

    2、结点结构体定义

    typedef int DataType;
    struct ListNode {
        DataType data;  // (1)
        ListNode *next; // (2)
    };
    
    • ( 1 ) (1) (1) 数据域:可以是任意类型,由编码的人自行指定;这段代码中,利用typedef将它和int同名,本文的 数据域 也会全部采用int类型进行讲解;
    • ( 2 ) (2) (2) 指针域:指向 后继结点 的地址;
    • 一个结点包含的两部分如下图所示:
      在这里插入图片描述

    3、结点的创建

    • 我们通过 C语言 中的库函数malloc来创建一个 链表结点,然后对 数据域指针域 进行赋值,代码实现如下:
    ListNode *ListCreateNode(DataType data) {
        ListNode *node = (ListNode *) malloc ( sizeof(ListNode) ); // (1)
        node->data = data;                                         // (2)
        node->next = NULL;                                         // (3)
        return node;                                               // (4)
    }
    
    • ( 1 ) (1) (1) 利用系统库函数malloc分配一块内存空间,用来存放ListNode即链表结点对象;
    • ( 2 ) (2) (2)数据域 置为函数传参data
    • ( 3 ) (3) (3)指针域 置空,代表这是一个孤立的 链表结点
    • ( 4 ) (4) (4) 返回这个结点的指针。
    • 创建完毕以后,这个孤立结点如下所示:

    二、链表的创建 - 尾插法

    • 那么接下来,让我们看下如何通过一个 数组中的数据 来创建一个链表。

    1、算法描述

    ??首先介绍 尾插法 ,顾名思义,即 从链表尾部插入 的意思,就是记录一个 链表尾结点,然后遍历给定数组,将数组元素一个一个插到链表的尾部,每插入一个结点,则将它更新为新的 链表尾结点。注意初始情况下,链表尾结点 为空。

    2、动画演示

    上图演示的是 尾插法 的整个过程,其中:
    ??head 代表链表头结点,创建完一个结点以后,它就保持不变了;
    ??tail 代表链表尾结点,即动图中的 绿色结点
    ??vtx 代表正在插入链表尾部的结点,即动图中的 橙色结点,插入完毕以后,vtx 变成 tail

    • 看完这个动图,你应该已经大致理解了 链表的创建过程。那么接下来,我们用程序语言来描述一下整个过程,这里采用的是 C语言 的形式,如果你是 Java、C#、Python 技术栈的,也可以试着写出自己的版本。
    • 语言并不是关键,思维才是关键。

    3、源码详解

    • C语言 实现如下:
    ListNode *ListCreateListByTail(int n, int a[]) {
        ListNode *head, *tail, *vtx;         // (1) 
        int idx;                              
        if(n <= 0)
            return NULL;                     // (2) 
        idx = 0;
        vtx = ListCreateNode(a[0]);          // (3) 
        head = tail = vtx;                   // (4)  
        while(++idx < n) {                   // (5) 
            vtx = ListCreateNode(a[idx]);    // (6) 
            tail->next = vtx;                // (7) 
            tail = vtx;                      // (8)  
        } 
        return head;                         // (9) 
    } 
    

    对应的注释如下:
    ?? ( 1 ) (1) (1) head存储头结点的地址,tail存储尾结点的地址,vtx存储当前正在插入结点的地址;
    ?? ( 2 ) (2) (2) 当需要创建的元素个数为 0 时,直接返回空链表;
    ?? ( 3 ) (3) (3) 创建一个 数据域a[0]的链表结点;
    ?? ( 4 ) (4) (4) 由于初始情况下只有一个结点,所以将链表头结点head和链表尾结点tail都置为vtx
    ?? ( 5 ) (5) (5) 从数组第 1 个元素 (0 - based) 开始,循环遍历数组;
    ?? ( 6 ) (6) (6) 由于数组中第 0 个元素已经创建过了,所以这里只需要对除了第 0 个元素以外的数据创建链表结点;
    ?? ( 7 ) (7) (7) 结点创建出来后,将当前链表尾结点tail后继结点 置为vtx
    ?? ( 8 ) (8) (8) 将最近创建的结点vtx作为新的 链表尾结点
    ?? ( 9 ) (9) (9) 返回链表头结点;


    • 尾插法 比较符合直观的思维逻辑,但是就代码量来说还是有点长(注意:在实现相同功能的情况下,代码应该是越简洁,越简单越好的)。
    • 于是,我们引入了另一种创建链表的方式 —— 头插法。

    三、链表的创建 - 头插法

    1、算法描述

    ??头插法,顾名思义,就是每次从头结点前面进行插入,但是这样一来,就会导致插入的数据元素是 逆序 的,所以我们需要 逆序访问数组 执行插入,此所谓 负负得正 的思想。

    • 它的特点是代码量短,且 常数时间复杂度 低。虽然没有 尾插法 那么直观,但是代码简洁,更加容易阅读。

    2、动画演示

    上图所示的是 头插法 的整个插入过程,其中:
    ??head 代表链表头结点,即动图中的 绿色结点,每新加一个结点,头结点就变成了新加入的结点;
    ??tail 代表链表尾结点,创建完一个结点以后,它就保持不变了;
    ??vtx 代表正在插入链表头部的结点,即动图中的 橙色结点,插入完毕以后,vtx 变成 head

    3、源码详解

    ListNode *ListCreateListByHead(int n, int *a) {
        ListNode *head = NULL, *vtx;       // (1) 
        while(n--) {                       // (2) 
            vtx = ListCreateNode(a[n]);    // (3) 
            vtx->next = head;              // (4) 
            head = vtx;                    // (5) 
        } 
        return head;                       // (6) 
    } 
    

    对应的注释如下:
    ?? ( 1 ) (1) (1) head存储头结点的地址,初始为空链表, vtx存储当前正在插入结点的地址;
    ?? ( 2 ) (2) (2) 总共需要插入 n n n 个结点,所以采用逆序的 n n n 次循环;
    ?? ( 3 ) (3) (3) 创建一个元素值为a[i]的链表结点,注意,由于逆序,所以这里 i i i 的取值为 n ? 1 → 0 n-1 \to 0 n?10
    ?? ( 4 ) (4) (4) 将当前创建的结点的 后继结点 置为 链表的头结点head
    ?? ( 5 ) (5) (5) 将链表头结点head置为vtx
    ?? ( 6 ) (6) (6) 返回链表头结点;


    • 头插法 的代码量比 尾插法 少了三分之一,而且将 创建结点的逻辑 统一起来了。这句话什么意思呢?仔细观察可以发现,尾插法 在实现过程中,ListCreateNode在代码里出现了两次,而 头插法 只出现了一次,将流程简化了,所以还是推荐使用 头插法

    四、链表的打印

    1、打印的作用

    • 可视化 能够帮助我们更好的理解数据结构。所以,对于一种数据结构,如何通过 输出函数 将它 打印到控制台 上,就成了我们接下来要做的事情。
    • 我会用 C语言 来实现,但是只要你掌握了这套自己验证的方法,那么就算用其他语言,一样可以验证自己代码的正确性。

    那么,如何打印一个链表呢?我们可以这么思考:
    ??链表的每个结点都有一个 后继结点 ,我们可以用A -> B代表结点B是结点A后继结点,而对于最后一个结点而言,它的后继可以用NULL表示。所以,我们可以循环输出所有结点并且带上->,然后在最后加上NULL

    2、源码详解

    • C语言实现如下:
    void ListPrint(ListNode *head) {
        ListNode *vtx = head;
        while(vtx) {