线性表,双向链表,静态链表,循环链表(约瑟夫环)

目录

什么是线性表(线性存储结构)

顺序存储结构和链式存储结构

前驱和后继

顺序表(顺序存储结构)及初始化详解

顺序表的初始化

双向链表及创建(C语言)详解

双向链表的创建

静态链表及其创建(C语言实现)

静态链表中的节点

备用链表

静态链表的创建

循环链表(约瑟夫环)的建立及C语言实现

循环链表实现约瑟夫环

总结


什么是线性表(线性存储结构)

通过前面的学习我们知道,具有“一对一”逻辑关系的数据,最佳的存储方式是使用线性表。那么,什么是线性表呢?

线性表,全名为线性存储结构。使用线性表存储数据的方式可以这样理解,即“把所有数据用一根线儿串起来,再存储到物理空间中”。

 

"一对一"逻辑关系的数据


图 1 "一对一"逻辑关系的数据


如图 1 所示,这是一组具有“一对一”关系的数据,我们接下来采用线性表将其储存到物理空间中。

首先,用“一根线儿”把它们按照顺序“串”起来,如图 2 所示:

 


图 2 数据的"线性"结构


图 2 中,左侧是“串”起来的数据,右侧是空闲的物理空间。把这“一串儿”数据放置到物理空间,我们可以选择以下两种方式,如图 3 所示。

 

两种线性存储结构


图 3 两种线性存储结构


图 3a) 是多数人想到的存储方式,而图 3b) 却少有人想到。我们知道,数据存储的成功与否,取决于是否能将数据完整地复原成它本来的样子。如果把图 3a) 和图 3b) 线的一头扯起,你会发现数据的位置依旧没有发生改变(和图 1 一样)。因此可以认定,这两种存储方式都是正确的。

将具有“一对一”关系的数据“线性”地存储到物理空间中,这种存储结构就称为线性存储结构(简称线性表)

使用线性表存储的数据,如同向数组中存储数据那样,要求数据类型必须一致,也就是说,线性表存储的数据,要么全部都是整形,要么全部都是字符串。一半是整形,另一半是字符串的一组数据无法使用线性表存储。

顺序存储结构链式存储结构

图 3 中我们可以看出,线性表存储数据可细分为以下 2 种:

  1. 如图 3a) 所示,将数据依次存储在连续的整块物理空间中,这种存储结构称为顺序存储结构(简称顺序表);
  2. 如图 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 项数据:

  1. 顺序表申请的存储容量;
  2. 顺序表的长度,也就是表中存储数据元素的个数;

提示:正常状态下,顺序表申请的存储容量要大于顺序表的长度。

因此,我们需要自定义顺序表,C 语言实现代码如下:

 
 
  1. typedef struct Table{
  2. int * head;//声明了一个名为head的长度不确定的数组,也叫“动态数组”
  3. int length;//记录当前顺序表的长度
  4. int size;//记录顺序表分配的存储容量
  5. }table;

注意,head 是我们声明的一个未初始化的动态数组,不要只把它看做是普通的指针。

接下来开始学习顺序表的初始化,也就是初步建立一个顺序表。建立顺序表需要做如下工作:

  • 给 head 动态数据申请足够大小的物理空间;
  • 给 size 和 length 赋初值;


因此,C 语言实现代码如下:

 
 
  1. #define Size 5 //对Size进行宏定义,表示顺序表申请空间的大小
  2. table initTable(){
  3. table t;
  4. t.head=(int*)malloc(Size*sizeof(int));//构造一个空的顺序表,动态申请存储空间
  5. if (!t.head) //如果申请失败,作出提示并直接退出程序
  6. {
  7. printf("初始化失败");
  8. exit(0);
  9. }
  10. t.length=0;//空表的长度初始化为0
  11. t.size=Size;//空表的初始存储空间为Size
  12. return t;
  13. }

我们看到,整个顺序表初始化的过程被封装到了一个函数中,此函数返回值是一个已经初始化完成的顺序表。这样做的好处是增加了代码的可用性,也更加美观。与此同时,顺序表初始化过程中,要注意对物理空间的申请进行判断,对申请失败的情况进行处理,这里只进行了“输出提示信息和强制退出”的操作,可以根据你自己的需要对代码中的 if 语句进行改进。

通过在主函数中调用 initTable 语句,就可以成功创建一个空的顺序表,与此同时我们还可以试着向顺序表中添加一些元素,C 语言实现代码如下:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #define Size 5
  4. typedef struct Table{
  5. int * head;
  6. int length;
  7. int size;
  8. }table;
  9. table initTable(){
  10. table t;
  11. t.head=(int*)malloc(Size*sizeof(int));
  12. if (!t.head)
  13. {
  14. printf("初始化失败");
  15. exit(0);
  16. }
  17. t.length=0;
  18. t.size=Size;
  19. return t;
  20. }
  21. //输出顺序表中元素的函数
  22. void displayTable(table t){
  23. for (int i=0;i<t.length;i++) {
  24. printf("%d ",t.head[i]);
  25. }
  26. printf("\n");
  27. }
  28. int main(){
  29. table t=initTable();
  30. //向顺序表中添加元素
  31. for (int i=1; i<=Size; i++) {
  32. t.head[i-1]=i;
  33. t.length++;
  34. }
  35. printf("顺序表中存储的元素分别是:\n");
  36. displayTable(t);
  37. return 0;
  38. }

程序运行结果如下:

顺序表中存储的元素分别是:
1 2 3 4 5

可以看到,顺序表初始化成功。

双向链表及创建(C语言)详解

目前我们所学到的链表,无论是动态链表还是静态链表,表中各节点中都只包含一个指针(游标),且都统一指向直接后继节点,通常称这类链表为单向链表(或单链表)。

虽然使用单链表能 100% 解决逻辑关系为 "一对一" 数据的存储问题,但在解决某些特殊问题时,单链表并不是效率最优的存储结构。比如说,如果算法中需要大量地找某指定结点的前趋结点,使用单链表无疑是灾难性的,因为单链表更适合 "从前往后" 找,而 "从后往前" 找并不是它的强项。

为了能够高效率解决类似的问题,本节来学习双向链表(简称双链表)。

从名字上理解双向链表,即链表是 "双向" 的,如图 1 所示:


 

双向链表结构示意图


图 1 双向链表结构示意图

双向,指的是各节点之间的逻辑关系是双向的,但通常头指针只设置一个,除非实际情况需要

从图 1 中可以看到,双向链表中各节点包含以下 3 部分信息(如图 2 所示):

  1. 指针域:用于指向当前节点的直接前驱节点;
  2. 数据域:用于存储数据元素。
  3. 指针域:用于指向当前节点的直接后继节点;


 

双向链表的节点构成


图 2 双向链表的节点构成


因此,双链表的节点结构用 C 语言实现为:

  1. typedef struct line{
  2. struct line * prior; //指向直接前趋
  3. int data;
  4. struct line * next; //指向直接后继
  5. }line;

双向链表的创建

同单链表相比,双链表仅是各节点多了一个用于指向直接前驱的指针域。因此,我们可以在单链表的基础轻松实现对双链表的创建。

需要注意的是,与单链表不同,双链表创建过程中,每创建一个新节点,都要与其前驱节点建立两次联系,分别是:

  • 将新节点的 prior 指针指向直接前驱节点;
  • 将直接前驱节点的 next 指针指向新节点;


这里给出创建双向链表的 C 语言实现代码:

  1. line* initLine(line * head){
  2. head=(line*)malloc(sizeof(line));//创建链表第一个结点(首元结点)
  3. head->prior=NULL;
  4. head->next=NULL;
  5. head->data=1;
  6. line * list=head;
  7. for (int i=2; i<=3; i++) {
  8. //创建并初始化一个新结点
  9. line * body=(line*)malloc(sizeof(line));
  10. body->prior=NULL;
  11. body->next=NULL;
  12. body->data=i;
  13. list->next=body;//直接前趋结点的next指针指向新结点
  14. body->prior=list;//新结点指向直接前趋结点
  15. list=list->next;
  16. }
  17. return head;
  18. }


我们可以尝试着在 main 函数中输出创建的双链表,C 语言代码如下:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. //节点结构
  4. typedef struct line{
  5. struct line * prior;
  6. int data;
  7. struct line * next;
  8. }line;
  9. //双链表的创建函数
  10. line* initLine(line * head);
  11. //输出双链表的函数
  12. void display(line * head);
  13. int main() {
  14. //创建一个头指针
  15. line * head=NULL;
  16. //调用链表创建函数
  17. head=initLine(head);
  18. //输出创建好的链表
  19. display(head);
  20. //显示双链表的优点
  21. printf("链表中第 4 个节点的直接前驱是:%d",head->next->next->next->prior->data);
  22. return 0;
  23. }
  24. line* initLine(line * head){
  25. //创建一个首元节点,链表的头指针为head
  26. head=(line*)malloc(sizeof(line));
  27. //对节点进行初始化
  28. head->prior=NULL;
  29. head->next=NULL;
  30. head->data=1;
  31. //声明一个指向首元节点的指针,方便后期向链表中添加新创建的节点
  32. line * list=head;
  33. for (int i=2; i<=5; i++) {
  34. //创建新的节点并初始化
  35. line * body=(line*)malloc(sizeof(line));
  36. body->prior=NULL;
  37. body->next=NULL;
  38. body->data=i;
  39. //新节点与链表最后一个节点建立关系
  40. list->next=body;
  41. body->prior=list;
  42. //list永远指向链表中最后一个节点
  43. list=list->next;
  44. }
  45. //返回新创建的链表
  46. return head;
  47. }
  48. void display(line * head){
  49. line * temp=head;
  50. while (temp) {
  51. //如果该节点无后继节点,说明此节点是链表的最后一个节点
  52. if (temp->next==NULL) {
  53. printf("%d\n",temp->data);
  54. }else{
  55. printf("%d <-> ",temp->data);
  56. }
  57. temp=temp->next;
  58. }
  59. }

程序运行结果:

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 语言实现为:

 
 
  1. typedef struct {
  2. int data;//数据域
  3. int cur;//游标
  4. }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},则需经历以下几个阶段:

  1. 在数据链表未初始化之前,数组中所有位置都处于空闲状态,因此都应被链接在备用链表上,如图 4 所示:


     

    未存储数据之前静态链表的状态


    图 4 未存储数据之前静态链表的状态


    当向静态链表中添加数据时,需提前从备用链表中摘除节点,以供新数据使用。

    备用链表摘除节点最简单的方法是摘除 a[0] 的直接后继节点;同样,向备用链表中添加空闲节点也是添加作为 a[0] 新的直接后继节点。因为 a[0] 是备用链表的第一个节点,我们知道它的位置,操作它的直接后继节点相对容易,无需遍历备用链表,耗费的时间复杂度为 O(1)

  2. 在图 4 的基础上,向静态链表中添加元素 1 的过程如图 5 所示:


     

    静态链表中添加元素 1


    图 5 静态链表中添加元素 1

  3. 在图 5 的基础上,添加元素 2 的过程如图 6 所示:


     

    静态链表中继续添加元素 2


    图 6 静态链表中继续添加元素 2

  4. 在图 6 的基础上,继续添加元素 3 ,过程如图 7 所示:


     

    静态链表中继续添加元素 3


    图 7 静态链表中继续添加元素 3

由此,静态链表就创建完成了。

下面给出了创建静态链表的 C 语言实现代码:

  1. #include <stdio.h>
  2. #define maxSize 6
  3. typedef struct {
  4. int data;
  5. int cur;
  6. }component;
  7. //将结构体数组中所有分量链接到备用链表中
  8. void reserveArr(component *array);
  9. //初始化静态链表
  10. int initArr(component *array);
  11. //输出函数
  12. void displayArr(component * array,int body);
  13. //从备用链表上摘下空闲节点的函数
  14. int mallocArr(component * array);
  15. int main() {
  16. component array[maxSize];
  17. int body=initArr(array);
  18. printf("静态链表为:\n");
  19. displayArr(array, body);
  20. return 0;
  21. }
  22. //创建备用链表
  23. void reserveArr(component *array){
  24. for (int i=0; i<maxSize; i++) {
  25. array[i].cur=i+1;//将每个数组分量链接到一起
  26. array[i].data=-1;
  27. }
  28. array[maxSize-1].cur=0;//链表最后一个结点的游标值为0
  29. }
  30. //提取分配空间
  31. int mallocArr(component * array){
  32. //若备用链表非空,则返回分配的结点下标,否则返回 0(当分配最后一个结点时,该结点的游标值为 0)
  33. int i=array[0].cur;
  34. if (array[0].cur) {
  35. array[0].cur=array[i].cur;
  36. }
  37. return i;
  38. }
  39. //初始化静态链表
  40. int initArr(component *array){
  41. reserveArr(array);
  42. int body=mallocArr(array);
  43. //声明一个变量,把它当指针使,指向链表的最后的一个结点,因为链表为空,所以和头结点重合
  44. int tempBody=body;
  45. for (int i=1; i<4; i++) {
  46. int j=mallocArr(array);//从备用链表中拿出空闲的分量
  47. array[tempBody].cur=j;//将申请的空闲分量链接在链表的最后一个结点后面
  48. array[j].data=i;//给新申请的分量的数据域初始化
  49. tempBody=j;//将指向链表最后一个结点的指针后移
  50. }
  51. array[tempBody].cur=0;//新的链表最后一个结点的指针设置为0
  52. return body;
  53. }
  54. void displayArr(component * array,int body){
  55. int tempBody=body;//tempBody准备做遍历使用
  56. while (array[tempBody].cur) {
  57. printf("%d,%d ",array[tempBody].data,array[tempBody].cur);
  58. tempBody=array[tempBody].cur;
  59. }
  60. printf("%d,%d\n",array[tempBody].data,array[tempBody].cur);
  61. }

代码输出结果为:

静态链表为:
-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 语言代码,完整代码如下所示:

 
 
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. typedef struct node{
  4. int number;
  5. struct node * next;
  6. }person;
  7. person * initLink(int n){
  8. person * head=(person*)malloc(sizeof(person));
  9. head->number=1;
  10. head->next=NULL;
  11. person * cyclic=head;
  12. for (int i=2; i<=n; i++) {
  13. person * body=(person*)malloc(sizeof(person));
  14. body->number=i;
  15. body->next=NULL;
  16. cyclic->next=body;
  17. cyclic=cyclic->next;
  18. }
  19. cyclic->next=head;//首尾相连
  20. return head;
  21. }
  22. void findAndKillK(person * head,int k,int m){
  23. person * tail=head;
  24. //找到链表第一个结点的上一个结点,为删除操作做准备
  25. while (tail->next!=head) {
  26. tail=tail->next;
  27. }
  28. person * p=head;
  29. //找到编号为k的人
  30. while (p->number!=k) {
  31. tail=p;
  32. p=p->next;
  33. }
  34. //从编号为k的人开始,只有符合p->next==p时,说明链表中除了p结点,所有编号都出列了,
  35. while (p->next!=p) {
  36. //找到从p报数1开始,报m的人,并且还要知道数m-1de人的位置tail,方便做删除操作。
  37. for (int i=1; i<m; i++) {
  38. tail=p;
  39. p=p->next;
  40. }
  41. tail->next=p->next;//从链表上将p结点摘下来
  42. printf("出列人的编号为:%d\n",p->number);
  43. free(p);
  44. p=tail->next;//继续使用p指针指向出列编号的下一个编号,游戏继续
  45. }
  46. printf("出列人的编号为:%d\n",p->number);
  47. free(p);
  48. }
  49. int main() {
  50. printf("输入圆桌上的人数n:");
  51. int n;
  52. scanf("%d",&n);
  53. person * head=initLink(n);
  54. printf("从第k人开始报数(k>1且k<%d):",n);
  55. int k;
  56. scanf("%d",&k);
  57. printf("数到m的人出列:");
  58. int m;
  59. scanf("%d",&m);
  60. findAndKillK(head, k, m);
  61. return 0;
  62. }

输出结果:

输入圆桌上的人数n:5
从第k人开始报数(k>1且k<5):3
数到m的人出列:2
出列人的编号为:4
出列人的编号为:1
出列人的编号为:3
出列人的编号为:2
出列人的编号为:5

最后出列的人,即为胜利者。当然,你也可以改进程序,令查找出最后一个人时,输出此人胜利的信息。

总结

循环链表和动态链表唯一不同在于它的首尾连接,这也注定了在使用循环链表时,附带最多的操作就是遍历链表。

在遍历的过程中,尤其要注意循环链表虽然首尾相连,但并不表示该链表没有第一个节点和最后一个结点。所以,不要随意改变头指针的指向。

猜你喜欢

转载自blog.csdn.net/qq_38998213/article/details/132306491