一览:本文从零介绍链式存储结构的线性表——单链表。包括如下内容:node
- 什么是链式存储存储结构?
- 单链表的结构
- 辨析头结点、头指针等易混淆概念
- 基本的增删改查操做(不带头结点和带头结点)
- 单链表与顺序表的对比
在【顺序表详解】一文中咱们介绍了一种“用曲线链接”的线性表,“曲线”是一种形象化的语言,实际上并不会存在所谓“曲线”的这种东西。git
所谓“曲线链接”即链式存储,那什么是链式存储呢?github
在【顺序表详解】一文中介绍顺序存储结构时举了一个例子:算法
好比,一群孩子肩并肩地站成一排,占据必定的连续土地。这群孩子的队伍很是整齐划一。数组
这个例子反映在内存中,即为顺序存储结构。网络
如今孩子们以为肩并肩站队太过于约束,因而便散开了,想站在哪里就站在哪里,可是队伍不能中断啊。因此他们便手拉着手来保持队伍不断开。数据结构
如今孩子们占据的不是连续的土地了,而是任意的某块土地。数据结构和算法
这个例子反映在内存中,就是数据元素任意出现,占据某块内存。函数
上面两张图放在一块对比,就能够看出问题了:学习
在【顺序表详解】一文中提到过,线性表的特色之一是元素之间有顺序。顺序存储结构靠连续的内存空间来实现这种“顺序”,但链式存储结构的元素是“任意的”,如何实现“顺序”呢?
“任意”和“顺序”乍一看很像反义词,但并不矛盾。小孩子们为了避免让队伍中断便“手拉手”,咱们要实现“顺序”,就靠那根“像链条同样的曲线”来把元素连接起来,这样就有了顺序。
这就是链式存储。
在【顺序表详解】一文中提到过“顺序存储结构是使用一段连续的内存单元分别存储线性表中的数据的数据结构”。咱们照猫画虎,就能够获得“链式存储结构是使用一组任意的、不连续的内存单元来分别存储线性表中的数据的数据结构”的结论。
那我偏就使用连续的内存单元来存数据,可不能够?固然能够。
下图直观地画出了线性表的元素以链式存储的方式存储在内存中。
为了方便画图,咱们将表示顺序的曲线人为地拉直,将任意存储的元素人为地排列整齐,造成下图中的逻辑关系:
这种链式存储结构的线性表,简称为链表。
上图的链表之间用一个单向箭头相连,从一个指向另外一个,这样整个链表就有了一个方向,咱们称之为单链表。
总结一下特色:
由于单链表是任意的内存位置,因此数组确定不能用了。
由于要存储一个值,因此直接用变量就行。
由于单链表在物理内存上是任意存储的,因此表示顺序的箭头(小孩子的手臂)必需要用代码实现出来。
而箭头的含义就是,经过 1 能找到 2。
又由于咱们使用变量来存储值,因此,换句话说,咱们要经过一个变量找到另外一个变量。
变量找变量?怎么作到?
C 语言恰好有这个机制——指针。若是你对指针还不清楚,能够移步到文章【如何掌握 C 语言的一大利器——指针?】。
如今万事俱备,只差结合。
一个变量用来存值,一个指针用来存其直接后继的地址,把变量和指针绑在一块构成一个数据元素,啪的一下,就成了。
因为指针中存了其直接后继的地址,这就至关于用一个有向箭头指向了其直接后继。
先明确几个名词:
总结一下,一个单链表 (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;
带头结点的单链表的特色是多了一个不存储数据的头结点,因此咱们初始化链表时,要将其建立出来。
可是在建立以前,咱们先来搞清楚三个问题,分别是:
链表的头指针
指向【指向结点的指针】的指针
函数参数的值传递和地址传递
简单解释:
若是以上内容还不清楚,说明对指针的掌握还不够熟练,请移步至文章【如何掌握 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;
这两句就是插入操做的核心代码,也是各类状况下插入操做的不变之处,搞明白这两句,就能够以不变应万变了。
其中 previous
、 current
和 new
是三个指向结点的指针, new
指向要插入的结点, previous
、 current
一前一后,在进行插入操做以前的关系为:
current = previous->next;
事实上, current
指针不是必需的,只有一个 previous
也能够作到插入操做,缘由就是 current = previous->next
,这种状况下的核心代码变为:
new->next = previous->next; previous->next = new;
但请注意,在这种状况下两句代码是有顺序的,你不能写成:
// 错误代码 previous->next = new; new->next = previous->next;
咱们能够从两个角度来理解为何这两句会有顺序:
【角度一】
由于 current
指针的做用就是用来保存 previous
的直接后继结点的地址的,因此在咱们断开 previous
和 current
联系后,咱们仍能找到 current
及其之后的结点。“链子”就算暂时断开了,因为断开处两侧都有标记,咱们也能接上去。。
可是如今没了 current
以后,一旦断开, current
及其之后的结点就消失在茫茫内存中,这就关键时刻掉链子了。
因此咱们要先把 new
和 previous->next
( previous
的直接后继结点)连起来,这样一来,指针 new
就保存了它所指向的及其之后的结点,
【角度二】
直接看代码,previous->next = new
执行完后 new->next = previous->next
就至关于 new->next = new
,本身指本身,这显然不正确。
总之,把核心代码理解到位,剩下的就在于如何准确的找到 previous
和 current
的位置。
咱们须要考虑两种状况:
/** * 指定插入位置 * 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; }
删除操做是将要删除的结点从链表中剔除,和插入操做相似。
previous
和 current
为指向结点的指针,如今咱们要删除结点 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); }
经过 insert
和 delete
函数,咱们就能体会到不带头结点和带头结点的差异了,对于插入和删除操做,不带头结点须要额外考虑在第一个元素前插入和删除第一个元素的特殊状况,而带头结点的链表则将对全部元素的操做统一了。
还有特殊的删头法和删尾法,这里再也不给出代码了
查找本质就是遍历链表,使用一个辅助指针,将该指针正确的遍历到指定位置,就能够获取该结点了。
修改则是在查找到目标结点的基础上修改其值。
代码很简单,这里再也不列出。详细代码文末获取。
经过以上代码,能够体会到:
优势:
缺点:
若是插入和删除操做频繁,就选择单链表;若是查找和修改操做频繁,就选择顺序表;若是元素个数变化大、难以估计,则可使用单链表;若是元素个数变化不大、能够预估,则可使用顺序表。
总之,单链表和线性表各有其优缺点,不必踩一捧一。根据实际状况灵活地选择数据结构,从而更优地解决问题才是咱们学习数据结构和算法的最终目的。
【推荐阅读】
若有错误,还请指正。
若是以为写的不错能够关注一下我。