目录
什么是线性表(线性存储结构)
顺序存储结构和链式存储结构
前驱和后继
顺序表(顺序存储结构)及初始化详解
顺序表的初始化
双向链表及创建(C语言)详解
双向链表的创建
静态链表及其创建(C语言实现)
静态链表中的节点
备用链表
静态链表的创建
循环链表(约瑟夫环)的建立及C语言实现
循环链表实现约瑟夫环
总结
什么是线性表(线性存储结构)
通过前面的学习我们知道,具有“一对一”逻辑关系的数据,最佳的存储方式是使用线性表。那么,什么是线性表呢?
线性表,全名为线性存储结构。使用线性表存储数据的方式可以这样理解,即“把所有数据用一根线儿串起来,再存储到物理空间中”。
图 1 "一对一"逻辑关系的数据
如图 1 所示,这是一组具有“一对一”关系的数据,我们接下来采用线性表将其储存到物理空间中。
首先,用“一根线儿”把它们按照顺序“串”起来,如图 2 所示:
图 2 数据的"线性"结构
图 2 中,左侧是“串”起来的数据,右侧是空闲的物理空间。把这“一串儿”数据放置到物理空间,我们可以选择以下两种方式,如图 3 所示。
图 3 两种线性存储结构
图 3a) 是多数人想到的存储方式,而图 3b) 却少有人想到。我们知道,数据存储的成功与否,取决于是否能将数据完整地复原成它本来的样子。如果把图 3a) 和图 3b) 线的一头扯起,你会发现数据的位置依旧没有发生改变(和图 1 一样)。因此可以认定,这两种存储方式都是正确的。
将具有“一对一”关系的数据“线性”地存储到物理空间中,这种存储结构就称为线性存储结构(简称线性表)。
使用线性表存储的数据,如同向数组中存储数据那样,要求数据类型必须一致,也就是说,线性表存储的数据,要么全部都是整形,要么全部都是字符串。一半是整形,另一半是字符串的一组数据无法使用线性表存储。
顺序存储结构和链式存储结构
图 3 中我们可以看出,线性表存储数据可细分为以下 2 种:
- 如图 3a) 所示,将数据依次存储在连续的整块物理空间中,这种存储结构称为顺序存储结构(简称顺序表);
- 如图 3b) 所示,数据分散的存储在物理空间中,通过一根线保存着它们之间的逻辑关系,这种存储结构称为链式存储结构(简称链表);
也就是说,线性表存储结构可细分为顺序存储结构和链式存储结构。
前驱和后继
数据结构中,一组数据中的每个个体被称为“数据元素”(简称“元素”)。例如,图 1 显示的这组数据,其中 1、2、3、4 和 5 都是这组数据中的一个元素。
另外,对于具有“一对一”逻辑关系的数据,我们一直在用“某一元素的左侧(前边)或右侧(后边)”这样不专业的词,其实线性表中有更准确的术语:
- 某一元素的左侧相邻元素称为“直接前驱”,位于此元素左侧的所有元素都统称为“前驱元素”;
- 某一元素的右侧相邻元素称为“直接后继”,位于此元素右侧的所有元素都统称为“后继元素”;
以图 1 数据中的元素 3 来说,它的直接前驱是 2 ,此元素的前驱元素有 2 个,分别是 1 和 2;同理,此元素的直接后继是 4 ,后继元素也有 2 个,分别是 4 和 5。如图 4 所示:
图 4 前驱和后继
顺序表(顺序存储结构)及初始化详解
顺序表,全名顺序存储结构,是线性表的一种。通过《什么是线性表》一节的学习我们知道,线性表用于存储逻辑关系为“一对一”的数据,顺序表自然也不例外。
不仅如此,顺序表对数据的物理存储结构也有要求。顺序表存储数据时,会提前申请一整块足够大小的物理空间,然后将数据依次存储起来,存储时做到数据元素之间不留一丝缝隙。
例如,使用顺序表存储集合 {1,2,3,4,5}
,数据最终的存储状态如图 1 所示:
图 1 顺序存储结构示意图
由此我们可以得出,将“具有 '一对一' 逻辑关系的数据按照次序连续存储到一整块物理空间上”的存储结构就是顺序存储结构。
通过观察图 1 中数据的存储状态,我们可以发现,顺序表存储数据同数组非常接近。其实,顺序表存储数据使用的就是数组。
顺序表的初始化
使用顺序表存储数据之前,除了要申请足够大小的物理空间之外,为了方便后期使用表中的数据,顺序表还需要实时记录以下 2 项数据:
- 顺序表申请的存储容量;
- 顺序表的长度,也就是表中存储数据元素的个数;
提示:正常状态下,顺序表申请的存储容量要大于顺序表的长度。
因此,我们需要自定义顺序表,C 语言实现代码如下:
- typedef struct Table{
- int * head;//声明了一个名为head的长度不确定的数组,也叫“动态数组”
- int length;//记录当前顺序表的长度
- int size;//记录顺序表分配的存储容量
- }table;
注意,head 是我们声明的一个未初始化的动态数组,不要只把它看做是普通的指针。
接下来开始学习顺序表的初始化,也就是初步建立一个顺序表。建立顺序表需要做如下工作:
- 给 head 动态数据申请足够大小的物理空间;
- 给 size 和 length 赋初值;
因此,C 语言实现代码如下:
- #define Size 5 //对Size进行宏定义,表示顺序表申请空间的大小
- table initTable(){
- table t;
- t.head=(int*)malloc(Size*sizeof(int));//构造一个空的顺序表,动态申请存储空间
- if (!t.head) //如果申请失败,作出提示并直接退出程序
- {
- printf("初始化失败");
- exit(0);
- }
- t.length=0;//空表的长度初始化为0
- t.size=Size;//空表的初始存储空间为Size
- return t;
- }
我们看到,整个顺序表初始化的过程被封装到了一个函数中,此函数返回值是一个已经初始化完成的顺序表。这样做的好处是增加了代码的可用性,也更加美观。与此同时,顺序表初始化过程中,要注意对物理空间的申请进行判断,对申请失败的情况进行处理,这里只进行了“输出提示信息和强制退出”的操作,可以根据你自己的需要对代码中的 if 语句进行改进。
通过在主函数中调用 initTable 语句,就可以成功创建一个空的顺序表,与此同时我们还可以试着向顺序表中添加一些元素,C 语言实现代码如下:
- #include <stdio.h>
- #include <stdlib.h>
- #define Size 5
- typedef struct Table{
- int * head;
- int length;
- int size;
- }table;
- table initTable(){
- table t;
- t.head=(int*)malloc(Size*sizeof(int));
- if (!t.head)
- {
- printf("初始化失败");
- exit(0);
- }
- t.length=0;
- t.size=Size;
- return t;
- }
- //输出顺序表中元素的函数
- void displayTable(table t){
- for (int i=0;i<t.length;i++) {
- printf("%d ",t.head[i]);
- }
- printf("\n");
- }
- int main(){
- table t=initTable();
- //向顺序表中添加元素
- for (int i=1; i<=Size; i++) {
- t.head[i-1]=i;
- t.length++;
- }
- printf("顺序表中存储的元素分别是:\n");
- displayTable(t);
- return 0;
- }
程序运行结果如下:
顺序表中存储的元素分别是:
1 2 3 4 5
可以看到,顺序表初始化成功。
双向链表及创建(C语言)详解
目前我们所学到的链表,无论是动态链表还是静态链表,表中各节点中都只包含一个指针(游标),且都统一指向直接后继节点,通常称这类链表为单向链表(或单链表)。
虽然使用单链表能 100% 解决逻辑关系为 "一对一" 数据的存储问题,但在解决某些特殊问题时,单链表并不是效率最优的存储结构。比如说,如果算法中需要大量地找某指定结点的前趋结点,使用单链表无疑是灾难性的,因为单链表更适合 "从前往后" 找,而 "从后往前" 找并不是它的强项。
为了能够高效率解决类似的问题,本节来学习双向链表(简称双链表)。
从名字上理解双向链表,即链表是 "双向" 的,如图 1 所示:
图 1 双向链表结构示意图
双向,指的是各节点之间的逻辑关系是双向的,但通常头指针只设置一个,除非实际情况需要。
从图 1 中可以看到,双向链表中各节点包含以下 3 部分信息(如图 2 所示):
- 指针域:用于指向当前节点的直接前驱节点;
- 数据域:用于存储数据元素。
- 指针域:用于指向当前节点的直接后继节点;
图 2 双向链表的节点构成
因此,双链表的节点结构用 C 语言实现为:
- typedef struct line{
- struct line * prior; //指向直接前趋
- int data;
- struct line * next; //指向直接后继
- }line;
双向链表的创建
同单链表相比,双链表仅是各节点多了一个用于指向直接前驱的指针域。因此,我们可以在单链表的基础轻松实现对双链表的创建。
需要注意的是,与单链表不同,双链表创建过程中,每创建一个新节点,都要与其前驱节点建立两次联系,分别是:
- 将新节点的 prior 指针指向直接前驱节点;
- 将直接前驱节点的 next 指针指向新节点;
这里给出创建双向链表的 C 语言实现代码:
- line* initLine(line * head){
- head=(line*)malloc(sizeof(line));//创建链表第一个结点(首元结点)
- head->prior=NULL;
- head->next=NULL;
- head->data=1;
- line * list=head;
- for (int i=2; i<=3; i++) {
- //创建并初始化一个新结点
- line * body=(line*)malloc(sizeof(line));
- body->prior=NULL;
- body->next=NULL;
- body->data=i;
- list->next=body;//直接前趋结点的next指针指向新结点
- body->prior=list;//新结点指向直接前趋结点
- list=list->next;
- }
- return head;
- }
我们可以尝试着在 main 函数中输出创建的双链表,C 语言代码如下:
- #include <stdio.h>
- #include <stdlib.h>
- //节点结构
- typedef struct line{
- struct line * prior;
- int data;
- struct line * next;
- }line;
- //双链表的创建函数
- line* initLine(line * head);
- //输出双链表的函数
- void display(line * head);
- int main() {
- //创建一个头指针
- line * head=NULL;
- //调用链表创建函数
- head=initLine(head);
- //输出创建好的链表
- display(head);
- //显示双链表的优点
- printf("链表中第 4 个节点的直接前驱是:%d",head->next->next->next->prior->data);
- return 0;
- }
- line* initLine(line * head){
- //创建一个首元节点,链表的头指针为head
- head=(line*)malloc(sizeof(line));
- //对节点进行初始化
- head->prior=NULL;
- head->next=NULL;
- head->data=1;
- //声明一个指向首元节点的指针,方便后期向链表中添加新创建的节点
- line * list=head;
- for (int i=2; i<=5; i++) {
- //创建新的节点并初始化
- line * body=(line*)malloc(sizeof(line));
- body->prior=NULL;
- body->next=NULL;
- body->data=i;
- //新节点与链表最后一个节点建立关系
- list->next=body;
- body->prior=list;
- //list永远指向链表中最后一个节点
- list=list->next;
- }
- //返回新创建的链表
- return head;
- }
- void display(line * head){
- line * temp=head;
- while (temp) {
- //如果该节点无后继节点,说明此节点是链表的最后一个节点
- if (temp->next==NULL) {
- printf("%d\n",temp->data);
- }else{
- printf("%d <-> ",temp->data);
- }
- temp=temp->next;
- }
- }
程序运行结果:
1 <-> 2 <-> 3 <-> 4 <-> 5
链表中第 4 个节点的直接前驱是:3
静态链表及其创建(C语言实现)
《顺序表和链表优缺点》一节,我们了解了两种存储结构各自的特点,那么,是否存在一种存储结构,可以融合顺序表和链表各自的优点,从而既能快速访问元素,又能快速增加或删除数据元素。
静态链表,也是线性存储结构的一种,它兼顾了顺序表和链表的优点于一身,可以看做是顺序表和链表的升级版。
使用静态链表存储数据,数据全部存储在数组中(和顺序表一样),但存储位置是随机的,数据之间"一对一"的逻辑关系通过一个整形变量(称为"游标",和指针功能类似)维持(和链表类似)。
例如,使用静态链表存储 {1,2,3}
的过程如下:
创建一个足够大的数组,假设大小为 6,如图 1 所示:
图 1 空数组
接着,在将数据存放到数组中时,给各个数据元素配备一个整形变量,此变量用于指明各个元素的直接后继元素所在数组中的位置下标,如图 2 所示:
图 2 静态链表存储数据
通常,静态链表会将第一个数据元素放到数组下标为 1 的位置(a[1])中。
图 2 中,
从 a[1] 存储的数据元素 1 开始,通过存储的游标变量 3,就可以在 a[3] 中找到元素 1 的直接后继元素 2;
通过元素 a[3] 存储的游标变量 5,可以在 a[5] 中找到元素 2 的直接后继元素 3,这样的循环过程直到某元素的游标变量为 0 截止(因为 a[0] 默认不存储数据元素)。
类似图 2 这样,通过 "数组+游标" 的方式存储具有线性关系数据的存储结构就是静态链表。
静态链表中的节点
通过上面的学习我们知道,静态链表存储数据元素也需要自定义数据类型,至少需要包含以下 2 部分信息:
- 数据域:用于存储数据元素的值;
- 游标:其实就是数组下标,表示直接后继元素所在数组中的位置;
因此,静态链表中节点的构成用 C 语言实现为:
- typedef struct {
- int data;//数据域
- int cur;//游标
- }component;
备用链表
图 2 显示的静态链表还不够完整,静态链表中,除了数据本身通过游标组成的链表外,还需要有一条连接各个空闲位置的链表,称为备用链表。
备用链表的作用是回收数组中未使用或之前使用过(目前未使用)的存储空间,留待后期使用。也就是说,静态链表使用数组申请的物理空间中,存有两个链表,一条连接数据,另一条连接数组中未使用的空间。
通常,备用链表的表头位于数组下标为 0(a[0]) 的位置,而数据链表的表头位于数组下标为 1(a[1])的位置。
静态链表中设置备用链表的好处是,可以清楚地知道数组中是否有空闲位置,以便数据链表添加新数据时使用。比如,若静态链表中数组下标为 0 的位置上存有数据,则证明数组已满。
例如,使用静态链表存储 {1,2,3}
,假设使用长度为 6 的数组 a,则存储状态可能如图 3 所示:
图 3 备用链表和数据链表
图 3 中,备用链表上连接的依次是 a[0]、a[2] 和 a[4],而数据链表上连接的依次是 a[1]、a[3] 和 a[5]。
静态链表的创建
假设使用静态链表(数组长度为 6)存储 {1,2,3}
,则需经历以下几个阶段:
- 在数据链表未初始化之前,数组中所有位置都处于空闲状态,因此都应被链接在备用链表上,如图 4 所示:
图 4 未存储数据之前静态链表的状态
当向静态链表中添加数据时,需提前从备用链表中摘除节点,以供新数据使用。备用链表摘除节点最简单的方法是摘除 a[0] 的直接后继节点;同样,向备用链表中添加空闲节点也是添加作为 a[0] 新的直接后继节点。因为 a[0] 是备用链表的第一个节点,我们知道它的位置,操作它的直接后继节点相对容易,无需遍历备用链表,耗费的时间复杂度为
O(1)
。 - 在图 4 的基础上,向静态链表中添加元素 1 的过程如图 5 所示:
图 5 静态链表中添加元素 1 - 在图 5 的基础上,添加元素 2 的过程如图 6 所示:
图 6 静态链表中继续添加元素 2 - 在图 6 的基础上,继续添加元素 3 ,过程如图 7 所示:
图 7 静态链表中继续添加元素 3
由此,静态链表就创建完成了。
下面给出了创建静态链表的 C 语言实现代码:
- #include <stdio.h>
- #define maxSize 6
- typedef struct {
- int data;
- int cur;
- }component;
- //将结构体数组中所有分量链接到备用链表中
- void reserveArr(component *array);
- //初始化静态链表
- int initArr(component *array);
- //输出函数
- void displayArr(component * array,int body);
- //从备用链表上摘下空闲节点的函数
- int mallocArr(component * array);
- int main() {
- component array[maxSize];
- int body=initArr(array);
- printf("静态链表为:\n");
- displayArr(array, body);
- return 0;
- }
- //创建备用链表
- void reserveArr(component *array){
- for (int i=0; i<maxSize; i++) {
- array[i].cur=i+1;//将每个数组分量链接到一起
- array[i].data=-1;
- }
- array[maxSize-1].cur=0;//链表最后一个结点的游标值为0
- }
- //提取分配空间
- int mallocArr(component * array){
- //若备用链表非空,则返回分配的结点下标,否则返回 0(当分配最后一个结点时,该结点的游标值为 0)
- int i=array[0].cur;
- if (array[0].cur) {
- array[0].cur=array[i].cur;
- }
- return i;
- }
- //初始化静态链表
- int initArr(component *array){
- reserveArr(array);
- int body=mallocArr(array);
- //声明一个变量,把它当指针使,指向链表的最后的一个结点,因为链表为空,所以和头结点重合
- int tempBody=body;
- for (int i=1; i<4; i++) {
- int j=mallocArr(array);//从备用链表中拿出空闲的分量
- array[tempBody].cur=j;//将申请的空闲分量链接在链表的最后一个结点后面
- array[j].data=i;//给新申请的分量的数据域初始化
- tempBody=j;//将指向链表最后一个结点的指针后移
- }
- array[tempBody].cur=0;//新的链表最后一个结点的指针设置为0
- return body;
- }
- void displayArr(component * array,int body){
- int tempBody=body;//tempBody准备做遍历使用
- while (array[tempBody].cur) {
- printf("%d,%d ",array[tempBody].data,array[tempBody].cur);
- tempBody=array[tempBody].cur;
- }
- printf("%d,%d\n",array[tempBody].data,array[tempBody].cur);
- }
代码输出结果为:
静态链表为:
-1,2 1,3 2,4 3,0
提示,此代码创建了一个带有头节点的静态链表,因此最先输出的 "-1,2" 表示的是头节点(-1表示此处未存储数据),其首元节点(存储元素 1 的节点)在数组 array[2] 中。
循环链表(约瑟夫环)的建立及C语言实现
无论是静态链表还是动态链表,有时在解决具体问题时,需要我们对其结构进行稍微地调整。比如,可以把链表的两头连接,使其成为了一个环状链表,通常称为循环链表。
和它名字的表意一样,只需要将表中最后一个结点的指针指向头结点,链表就能成环儿,如图 1 所示。
图1 循环链表
需要注意的是,虽然循环链表成环状,但本质上还是链表,因此在循环链表中,依然能够找到头指针和首元节点等。循环链表和普通链表相比,唯一的不同就是循环链表首尾相连,其他都完全一样。
循环链表实现约瑟夫环
约瑟夫环问题,是一个经典的循环链表问题
题意是:已知 n 个人(分别用编号 1,2,3,…,n 表示)围坐在一张圆桌周围,从编号为 k 的人开始顺时针报数,数到 m 的那个人出列;他的下一个人又从 1 开始,还是顺时针开始报数,数到 m 的那个人又出列;依次重复下去,直到圆桌上剩余一个人。
如图 2 所示,假设此时圆周周围有 5 个人,要求从编号为 3 的人开始顺时针数数,数到 2 的那个人出列:
图 2 循环链表实现约瑟夫环
出列顺序依次为:
- 编号为 3 的人开始数 1,然后 4 数 2,所以 4 先出列;
- 4 出列后,从 5 开始数 1,1 数 2,所以 1 出列;
- 1 出列后,从 2 开始数 1,3 数 2,所以 3 出列;
- 3 出列后,从 5 开始数 1,2 数 2,所以 2 出列;
- 最后只剩下 5 自己,所以 5 胜出。
约瑟夫环问题有多种变形,比如顺时针转改为逆时针等,虽然问题的细节有多种变数,但解决问题的中心思想是一样的,即使用循环链表。
通过以上的分析,我们可以尝试编写 C 语言代码,完整代码如下所示:
- #include <stdio.h>
- #include <stdlib.h>
- typedef struct node{
- int number;
- struct node * next;
- }person;
- person * initLink(int n){
- person * head=(person*)malloc(sizeof(person));
- head->number=1;
- head->next=NULL;
- person * cyclic=head;
- for (int i=2; i<=n; i++) {
- person * body=(person*)malloc(sizeof(person));
- body->number=i;
- body->next=NULL;
- cyclic->next=body;
- cyclic=cyclic->next;
- }
- cyclic->next=head;//首尾相连
- return head;
- }
- void findAndKillK(person * head,int k,int m){
- person * tail=head;
- //找到链表第一个结点的上一个结点,为删除操作做准备
- while (tail->next!=head) {
- tail=tail->next;
- }
- person * p=head;
- //找到编号为k的人
- while (p->number!=k) {
- tail=p;
- p=p->next;
- }
- //从编号为k的人开始,只有符合p->next==p时,说明链表中除了p结点,所有编号都出列了,
- while (p->next!=p) {
- //找到从p报数1开始,报m的人,并且还要知道数m-1de人的位置tail,方便做删除操作。
- for (int i=1; i<m; i++) {
- tail=p;
- p=p->next;
- }
- tail->next=p->next;//从链表上将p结点摘下来
- printf("出列人的编号为:%d\n",p->number);
- free(p);
- p=tail->next;//继续使用p指针指向出列编号的下一个编号,游戏继续
- }
- printf("出列人的编号为:%d\n",p->number);
- free(p);
- }
- int main() {
- printf("输入圆桌上的人数n:");
- int n;
- scanf("%d",&n);
- person * head=initLink(n);
- printf("从第k人开始报数(k>1且k<%d):",n);
- int k;
- scanf("%d",&k);
- printf("数到m的人出列:");
- int m;
- scanf("%d",&m);
- findAndKillK(head, k, m);
- return 0;
- }
输出结果:
输入圆桌上的人数n:5
从第k人开始报数(k>1且k<5):3
数到m的人出列:2
出列人的编号为:4
出列人的编号为:1
出列人的编号为:3
出列人的编号为:2
出列人的编号为:5
最后出列的人,即为胜利者。当然,你也可以改进程序,令查找出最后一个人时,输出此人胜利的信息。
总结
循环链表和动态链表唯一不同在于它的首尾连接,这也注定了在使用循环链表时,附带最多的操作就是遍历链表。
在遍历的过程中,尤其要注意循环链表虽然首尾相连,但并不表示该链表没有第一个节点和最后一个结点。所以,不要随意改变头指针的指向。