详解|写完这篇文章我终于搞懂链表了

一览:本文从零介绍链式存储结构的线性表——单链表。包括如下内容:node

  • 什么是链式存储存储结构?
  • 单链表的结构
  • 辨析头结点、头指针等易混淆概念
  • 基本的增删改查操做(不带头结点和带头结点)
  • 单链表与顺序表的对比

线性表的链式存储结构

【顺序表详解】一文中咱们介绍了一种“用曲线链接”的线性表,“曲线”是一种形象化的语言,实际上并不会存在所谓“曲线”的这种东西。git

曲线链接元素

所谓“曲线链接”即链式存储,那什么是链式存储呢?github

【顺序表详解】一文中介绍顺序存储结构时举了一个例子:算法

好比,一群孩子肩并肩地站成一排,占据必定的连续土地。这群孩子的队伍很是整齐划一。数组

这个例子反映在内存中,即为顺序存储结构。网络

顺序存储类比图

如今孩子们以为肩并肩站队太过于约束,因而便散开了,想站在哪里就站在哪里,可是队伍不能中断啊。因此他们便手拉着手来保持队伍不断开。数据结构

如今孩子们占据的不是连续的土地了,而是任意的某块土地。数据结构和算法

这个例子反映在内存中,就是数据元素任意出现,占据某块内存。函数

链式存储类比图

上面两张图放在一块对比,就能够看出问题了:学习

  • 直线 VS 曲线
  • 整齐划一 VS 杂乱无章
  • 连续内存空间 VS 任意内存空间
  • 顺序存储 VS 链式存储

【顺序表详解】一文中提到过,线性表的特色之一是元素之间有顺序。顺序存储结构靠连续的内存空间来实现这种“顺序”,但链式存储结构的元素是“任意的”,如何实现“顺序”呢?

“任意”和“顺序”乍一看很像反义词,但并不矛盾。小孩子们为了避免让队伍中断便“手拉手”,咱们要实现“顺序”,就靠那根“像链条同样的曲线”来把元素连接起来,这样就有了顺序。

这就是链式存储。

【顺序表详解】一文中提到过“顺序存储结构是使用一段连续的内存单元分别存储线性表中的数据的数据结构”。咱们照猫画虎,就能够获得“链式存储结构是使用一组任意的、不连续的内存单元来分别存储线性表中的数据的数据结构”的结论。

那我偏就使用连续的内存单元来存数据,可不能够?固然能够。

下图直观地画出了线性表的元素以链式存储的方式存储在内存中。

链式存储在物理内存上的关系

为了方便画图,咱们将表示顺序的曲线人为地拉直,将任意存储的元素人为地排列整齐,造成下图中的逻辑关系:

链式存储在逻辑上关系

这种链式存储结构的线性表,简称为链表

上图的链表之间用一个单向箭头相连,从一个指向另外一个,这样整个链表就有了一个方向,咱们称之为单链表

总结一下特色:

  1. 用一组任意的存储单元来存储线性表的数据元素,这组存储单元能够是连续的(1和3),也能够是不连续的;
  2. 这组任意的存储单元用“一根链子”串起来,因此虽然在内存上是乱序的,可是在逻辑上却仍然是线性的;
  3. 单链表的方向是单向的,只能从前日后,不能从后往前;

单链表的实现思路

由于单链表是任意的内存位置,因此数组确定不能用了。

由于要存储一个值,因此直接用变量就行。

由于单链表在物理内存上是任意存储的,因此表示顺序的箭头(小孩子的手臂)必需要用代码实现出来。

而箭头的含义就是,经过 1 能找到 2。

又由于咱们使用变量来存储值,因此,换句话说,咱们要经过一个变量找到另外一个变量。

变量找变量?怎么作到?

C 语言恰好有这个机制——指针。若是你对指针还不清楚,能够移步到文章【如何掌握 C 语言的一大利器——指针?】

如今万事俱备,只差结合。

一个变量用来存值,一个指针用来存其直接后继的地址,把变量和指针绑在一块构成一个数据元素,啪的一下,就成了。

链表的结点

因为指针中存了其直接后继的地址,这就至关于用一个有向箭头指向了其直接后继。

先明确几个名词:

  • 用来存数据的变量叫作数据域
  • 用来存“直接后继元素的地址”的 指针 叫作指针域
  • 数据域和指针域构成的数据元素叫作 结点 (node)

总结一下,一个单链表 (SinglyLinkedList) 的结点 (Node) 由如下几部分组成:

  • 用来存数据的数据域——data
  • 用来存直接后继结点的的地址的指针域——next

结点的具体实现

可使用 C 语言的结构体来实现结点:

为了说明问题简单,咱们这里的结点只存储整数。

//结点
typedef struct Node {
    int data; //数据域:存储数据
    struct Node *next; //指针域:存储直接后继结点的地址
} Node;

这样的一个结构体就能完美地表示一个结点了,许多结点连在一块就能构成链表了。

一个结点

单链表的结构

单链表由若干的结点单向连接组成,一个单链表必需要有头有尾,由于计算机很“笨”,不像人看一眼就知道头在哪尾在哪。因此咱们要用代码清晰地表示出一个单链表的全部结构。

头指针

请先注意 “头” 这个概念,咱们在平常生活中拿绳子的时候,总喜欢找“绳子头”,此“头”即彼“头”。

而后再理解 “指针” 这个概念(如不清楚指针,请移步至请移步至文章【如何掌握 C 语言的一大利器——指针?】),指针里面存储的是地址。

那么,头指针即存储链表的第一个结点的内存地址的指针,也即头指针指向链表的第一个结点。

下图是一个由三个结点构成的单链表:

(为了方便理解链表的结构,我会给出每一个结点的地址以及指针域的值)

头指针

指针 head 中保存了第一个结点 1 的地址,head 即为头指针。有了头指针,咱们就能够找到第一个结点的位置,就能够找到整个链表。一般,头指针的名字就是链表的名字。

即,head(头指针)在手,链表我有

值为 3 的结点是该链表的“尾”,因此它的指针域中保存的值为 NULL用来表示整个链表到此为止

咱们用头指针表示链表的开始,用 NULL 表示链表的结束

头结点

在上面的链表中,咱们能够在值为 1 的结点前再加一个结点,称其为头结点。见下图:

头结点

头结点的数据域中通常不存放数据(能够存放如结点数等可有可无的数据),从这点看,头结点是不一样于其余结点的“假结点”。

此时头指针指向头结点,由于如今头结点才是第一个结点

为何要设立头结点呢?这能够方便咱们对链表的操做,后面你将会体会到这一点。

固然,头结点不是链表的必要结构之一,他无关紧要,仅凭你的喜爱

有无头结点的单链表

既然头结点不是链表的必要结构,这就意味着能够有两种链表:

  • 带头结点的单链表
  • 不带头结点的单链表

再加上头指针,初学链表时,“咱们的头”很容易被“链表的头”搞晕。

别晕,看下面两幅图:

不带头结点的单链表

带头结点的单链表

记住如下几条:

  • 头指针很重要,没它不行
  • 虽然头结点能够方便咱们的一些操做,可是有没有都行
  • 不管带头结点与否,头指针都指向链表的第一个结点

(后面的操做咱们将讨论不带头结点和带头结点两种状况)

空链表

不带头结点

下图是一个不带头结点的空链表:

即头指针中存储的地址为 NULL

带头结点

下图是一个带头结点的空链表:

此时头指针中存储的是第一个结点——头结点的地址,头结点的指针域中存储的是 NULL

(后面的图示将再也不给出内存地址)

创造结点

至此,关于单链表的基本概念、实现思路、结点的具体实现咱们都已经了解了,但这些还都停留咱们的脑子里。下面要作的就是把咱们脑子里的东西,之内存喜闻乐见的形式搬到内存中去。

由于链表是由结点组成的,因此咱们先来创造结点。

/**
 * 创造结点,返回指向该结点的指针
 * elem : 结点的数据域的值
 * return : 指向该结点的指针(该结点的地址)
 */
Node *create_node(int elem)
{
    Node *node = (Node *) malloc(sizeof(Node));
    node->data = elem;
    node->next = NULL;
    return node;
}

注意:咱们要使用 malloc 函数给结点申请一块内存,而后才能对该结点的数据域进行赋值,而因为该结点此时是一个独立的结点,没有直接后继结点,因此其指针域为 NULL

初始化链表

初始化链表即初始化一个空链表,详见本文【空链表】一节中的两幅图。

不带头结点

要初始化一个不带头节点的链表,咱们直接建立一个能够指向结点的空指针便可,该空指针即为头指针

Node *head = NULL;

带头结点

带头结点的单链表的特色是多了一个不存储数据的头结点,因此咱们初始化链表时,要将其建立出来。

可是在建立以前,咱们先来搞清楚三个问题,分别是:

  1. 链表的头指针

  2. 指向【指向结点的指针】的指针

  3. 函数参数的值传递和地址传递

简单解释:

  1. 头指针是链表必定要有的,找到头指针才能找到整个链表,不然整个链表就消失在“茫茫内存”之中了。因此不管进行何种操做,头指针必定要像咱们攥紧绳子头同样“被攥在咱们手中”。
  2. 指针中保存了别人的地址,它也有本身地址。若是一个指针中保存了别的指针的地址,该指针就是“指向指针的指针”。由于头指针是指向链表第一个结点的指针,因此咱们找到头指针也就找到了整个链表(这句话啰嗦太多遍了)。而为了能找到头指针,咱们就须要知道头指针的地址,也即将头指针的地址保存下来,换句话说,用一个指针来指向头指针
  3. 函数的值传递改变的是形参(实参的一份拷贝),影响不了实参。因此在一些状况下,咱们须要传给函数的是地址,函数使用指针来直接操做该指针指向的内存。

若是以上内容还不清楚,说明对指针的掌握还不够熟练,请移步至文章【如何掌握 C 语言的一大利器——指针?】

下面画一张比较形象的图:

上图中头指针和链表像不像一根带手柄的鞭子?

好比下面这个我小时候常常玩的游戏

打陀螺游戏 图源来自网络

/**
 * 初始化链表
 * p_head: 指向头指针的指针
 */
void init(Node **p_head)
{
    //建立头结点
    Node *node = (Node *) malloc(sizeof(Node));
    node->next = NULL;
    //头指针指向头结点
    *p_head = node;
}

遍历操做

所谓遍历,就是从链表头开始,向链表尾一个一个结点进行遍历,咱们一般借助一个辅助指针来进行遍历。

不带头结点

不带头结点的链表从头指针开始遍历,因此辅助指针的初始位置就是头指针,这里咱们以获取链表的长度为例:

int get_length(Node *head)
{
    int length = 0;
    Node *p = head;
    while (p != NULL) {
        length++;
        p = p->next;
    }
    return length;
}

使用 for 循环能使代码看起来更精简些。

带头结点

带头结点的链表须要从头结点开始遍历,因此辅助指针的初始位置是头结点的后继结点:

int get_length(Node *head)
{
    int length = 0;
    Node *p = head->next;
    while (p != NULL) {
        length++;
        p = p->next;
    }
    return length;
}

插入操做

基本思想

咱们在前面举了“小孩子手拉手”这个例子来描述单链表。

小孩子手拉手

孩子 A 和 B 手拉手连接在一块儿,如今有个孩子 C 想要插到他们之间,怎么作?

C 拉上 B 的手 => A 松开 B 的手(虚线表示松开) => A 拉上 C 的手

A 松开 B 的手 => A 拉上 C 的手 => C 拉上 B 的手

一样地,在链表中,咱们也是相似的操做:

写成代码就是:

new->next = current;
previous->next = new;

或这换一下顺序:

previous->next = new;
new->next = current;

这两句就是插入操做的核心代码,也是各类状况下插入操做的不变之处,搞明白这两句,就能够以不变应万变了。

其中 previouscurrentnew 是三个指向结点的指针, new 指向要插入的结点, previouscurrent 一前一后,在进行插入操做以前的关系为:

current = previous->next;

事实上, current 指针不是必需的,只有一个 previous 也能够作到插入操做,缘由就是 current = previous->next,这种状况下的核心代码变为:

new->next = previous->next;
previous->next = new;

但请注意,在这种状况下两句代码是有顺序的,你不能写成:

// 错误代码
previous->next = new;
new->next = previous->next;

咱们能够从两个角度来理解为何这两句会有顺序:

【角度一】

由于 current 指针的做用就是用来保存 previous 的直接后继结点的地址的,因此在咱们断开 previouscurrent 联系后,咱们仍能找到 current 及其之后的结点。“链子”就算暂时断开了,因为断开处两侧都有标记,咱们也能接上去。。

可是如今没了 current 以后,一旦断开, current 及其之后的结点就消失在茫茫内存中,这就关键时刻掉链子了。

因此咱们要先把 newprevious->nextprevious 的直接后继结点)连起来,这样一来,指针 new 就保存了它所指向的及其之后的结点,

【角度二】

直接看代码,previous->next = new 执行完后 new->next = previous->next 就至关于 new->next = new ,本身指本身,这显然不正确。

总之,把核心代码理解到位,剩下的就在于如何准确的找到 previouscurrent 的位置。

指定位置插入

不带头结点

咱们须要考虑两种状况:

  1. 在第一个元素前插入
  2. 在其余元素前插入
/**
 * 指定插入位置
 * p_head: 指向头指针的指针
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 新结点的数据
 */ 
void insert(Node **p_head, int position, int elem)
{
    Node *new = create_node(elem);
    Node *current = *p_head;
    Node *previous = NULL;
    int length = get_length(*p_head);
    if (position < 1 || position > length + 1) {
        printf("插入位置不合法\n");
        return;
    }
    for (int i = 0; current != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    new->next = current;
    if (previous == NULL)
        *p_head = new;
    else
        previous->next = new;
}

带头结点

因为带了一个头结点,因此在第一个元素前插入和在其余元素前插入时的操做是相同的。

/**
 * 指定插入位置
 * p_head: 指向头指针的指针
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 新结点的数据
 */
void insert(Node **p_head, int position, int elem)
{
    Node *new = create_node(elem);
    Node *previous = *p_head;
    Node *current = previous->next;
    int length = get_length(*p_head);
    if (position < 1 || position > length + 1) {
        printf("插入位置不合法\n");
        return;
    }
    for (int i = 0; current != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    new->next = current;
    previous->next = new;
}

头插法

不带头结点

不带头结点的头插法,即新插入的节点始终被头指针所指向。

/**
 * 头插法:新插入的节点始终被头指针所指向
 * p_head: 指向头指针的指针
 * elem: 新结点的数据
 */
void insert_at_head(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    new->next = *p_head;
    *p_head = new;
}

带头结点

带头结点的头插法,即新插入的结点始终为头结点的直接后继。

/**
 * 头插法,新结点为头结点的直接后继
 * p_head: 指向头指针的指针
 * elem: 新结点的数据
 */
void insert_at_head(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    new->next = (*p_head)->next;
    (*p_head)->next = new;
}

注意:多了一个头结点,因此代码有所变化。

尾插法

尾插法要求咱们先找到链表的最后一个结点,因此重点在于如何遍历到最后一个结点。

不带头结点

/**
 * 尾插法:新插入的结点始终在链表尾
 * p_head: 指向头指针的指针
 * elem: 新结点的数据
 */
void insert_at_tail(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    Node *p = *p_head;
    while (p->next != NULL)   //从头遍历至链表尾
        p = p->next;
    p->next = new;
}

带头结点

/**
 * 尾插法:新插入的结点始终在链表尾
 * p_head: 指向头指针的指针
 * elem: 新结点的数据
 */
void insert_at_tail(Node **p_head, int elem)
{
    Node *new = create_node(elem);
    Node *p = (*p_head)->next;
    while (p->next != NULL)
        p = p->next;
    p->next = new;
}

删除操做

基本思想

删除操做是将要删除的结点从链表中剔除,和插入操做相似。

previouscurrent 为指向结点的指针,如今咱们要删除结点 current,过程以下:

核心代码为:

previous->next = current->next;
free(current);

free() 操做将要删除的结点给释放掉。

current 指针不是必需的,没有它也能够,代码写成这样:

previous->next = previous->next->next;

但此时咱们已经不能释放要删除的那个结点了,由于咱们没有一个指向它的指针,它已经消失在茫茫内存中了。

指定位置删除

知道了核心代码,剩下的工做就在于咱们如何可以正确地遍历到要删除的那个结点

如你所见,previous 指针是必需的,且必定是要删除的那个结点的直接前驱,因此要将 previous 指针遍历至其直接前驱结点。

不带头结点

/**
 * 删除指定位置的结点
 * p_head: 指向头指针的指针
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 使用该指针指向的变量接收删除的值
 */
void delete(Node **p_head, int position, int *elem)
{
    Node *previous = NULL;
    Node *current = *p_head;
    int length = get_length(*p_head);
    if (length == 0) {
        printf("空链表\n");
        return;
    }
    if (position < 1 || position > length) {
        printf("删除位置不合法\n");
        return;
    }
    for (int i = 0; current->next != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    *elem = current->data;
    if (previous == NULL)
        *p_head = (*p_head)->next;
    else
        previous->next = current->next;
    free(current);
}

带头结点

/**
 * 删除指定位置的结点
 * p_head: 指向头指针的指针
 * position: 指定位置 (1 <= position <= length + 1)
 * elem: 使用该指针指向的变量接收删除的值
 */
void delete(Node **p_head, int position, int *elem)
{
    Node *previous = *p_head;
    Node *current = previous->next;
    int length = get_length(*p_head);
    if (length == 0) {
        printf("空链表\n");
        return;
    }
    if (position < 1 || position > length) {
        printf("删除位置不合法\n");
        return;
    }
    for (int i = 0; current->next != NULL && i < position - 1; i++) {
        previous = current;
        current = current->next;
    }
    *elem = current->data;
    previous->next = current->next;
    free(current);
}

经过 insertdelete函数,咱们就能体会到不带头结点和带头结点的差异了,对于插入和删除操做,不带头结点须要额外考虑在第一个元素前插入和删除第一个元素的特殊状况,而带头结点的链表则将对全部元素的操做统一了。

还有特殊的删头法和删尾法,这里再也不给出代码了

查找和修改操做

查找本质就是遍历链表,使用一个辅助指针,将该指针正确的遍历到指定位置,就能够获取该结点了。

修改则是在查找到目标结点的基础上修改其值。

代码很简单,这里再也不列出。详细代码文末获取。

单链表的优缺点

经过以上代码,能够体会到:

优势:

  • 插入和删除某个元素时,没必要像顺序表那样移动大量元素。
  • 链表的长度不像顺序表那样是固定的,须要的时候就建立,不须要了就删除,极其方便。

缺点:

  • 单链表的查找和修改须要遍历链表,若是要查找的元素恰好是链表的最后一个,则须要遍历整个单链表,不像顺序表那样能够直接存取。

若是插入和删除操做频繁,就选择单链表;若是查找和修改操做频繁,就选择顺序表;若是元素个数变化大、难以估计,则可使用单链表;若是元素个数变化不大、能够预估,则可使用顺序表。

总之,单链表和线性表各有其优缺点,不必踩一捧一。根据实际状况灵活地选择数据结构,从而更优地解决问题才是咱们学习数据结构和算法的最终目的

【推荐阅读】

完整代码请移步至 GitHub | Gitee 获取。

若有错误,还请指正。

若是以为写的不错能够关注一下我。

相关文章
相关标签/搜索