【数据结构篇C++实现】- 线性表 - 顺序表和链表

友情链接:C/C++系列系统学习目录



🚀线性表

🚢一、线性表的定义

故事导入:

幼儿园放学时,一个班级的小朋友,一个跟着一个排队出校,有一个打头,有一个收尾,当中的小朋友每一个都知道他前面和后面的一个是谁,就如同一根线把他们串联了起来。如此具有像线一样的性质的表就叫线性表

线性表(List):零个或多个数据元素的有限序列。

在这里插入图片描述

  • 首先它是一个序列,也就是说,元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后驱,其他每个元素都有且只有一个前驱和后继
  • 强调线性表是有限的,元素个数必须要是有限的
  • 每个元素的数据类型强调要是相同的
  • 在较复杂的线性表中,一个数据元素可以由若干个数据项组成。

 

🚀线性表的顺序存储结构

 

🚢一、顺序表

🛴(一)顺序表的原理精讲

顺序表:用一段地址连续的存储单元依次存储线性表的数据元素,这种存储结构的线性表称为顺序表

在内存中找了块地,占用了这块内存空间,然后把相同数据类型的数据元素依次存放在这块空地中。既然线性表的每个数据元素的类型都相同,所以可以用C语言(其他语言也相同)的一维数组来实现顺序表,即把第一个数据元素存到数组下标为0的位置中,接着把线性表相邻的元素存储在数组中相邻的位置

  • 逻辑上相邻的数据在计算机内的存储位置也是相邻的,可以快速定位到第几个元素,中间不允许有空值
  • 只要确定好了存储线性表的起始位置,线性表中任一数据元素都可以随机存取,与数组一样具有随机存取的特性
  • 优点:无须为表中元素之间的逻辑关系而增加额外的存储空间;可以快速的存取表中任一位置的元素。
  • 缺点:插入和删除操作需要移动大量元素;当线性表长度较大时,难以确定存储空间的容量;造成存储空间的“碎片”。

在这里插入图片描述

顺序表的三个关键:

  1. 存储空间的起始地址:用elems记录存储位置的基地址
  2. 顺序表的最大存储容量:分配一段连续的存储空间size
  3. 顺序表当前长度:用length记录实际的元素个数,即顺序表的长度

显然,我们在定义顺序表结构的时候需要包含这三个关键,不过需要注意的是,数据结构并不是死的,我们只是建立一个标准,从而按照这个标准书写能方便每个程序员代码之间的互通。所以实际上我们能在一些细节上改动,比如有些人定义顺序表时并没有size这个成员,

区别说明:地址计算方法

在C语言数组中,是从0开始第一个下标的,但我们数数都是从1开始数的,在大多数书记讲解中,线性表以及其它许多数据结构起始也是1,于是线性表的第i个元素是要存储在数组下标为i-1的位置(i>=1),如果我们从0开始数,则第i个元素就存储在数组下标为i的位置(i>=0)。为了习惯C语言,作为使用C/C++语言的我来说,我还是经常使用后者,两者代码实现上需要注意细节即可,在具体位置我都会提到

🛴(二)顺序表的相关代码实现

1.顺序表的结构体定义
#define MAX_SIZE 100 //定义最大长度

typedef int ElemType;   //ElemType的类型根据实际情况而定,这里假定为int

typedef struct{
	ElemType *elems; // 顺序表的基地址  用动态分配的一维数组表示。
	int length; // 顺序表的长度
	int size; // 顺序表的空间
}SqList;

解释:

  1. 顺序表的基地址,也可以如此定义:int elems[MAX_SIZE]; 这些都是最基本的知识,不过后面的初始化等都要相应做出改变
  2. 大部分对于顺序表的定义实际上没有size这个成员,不过我习惯加上
2.顺序表初始化
 //构造一个空的顺序表 L
bool initList(SqList &L) {
    L.elems=new int[MAX_SIZE];  //为顺序表分配 Maxsize 个空间
    if(!L.elems) return false; //存储分配失败
    L.length=0; //空表长度为 0
    L.size = MAX_SIZE;
    return true;
}

解释:

  1. 有些地方将此函数返回值设置为void,不过很明显存在存储分配失败情况,设置为bool类型比较合适

  2. 采用动态分配内存的方法分配一段连续的存储空间,如果按int elems[MAX_SIZE];方式给出基地址,那么初始化就可以直接通过循环全部赋0即可。建议采用动态分配内存的方法

    void initList(SqList &L){
        for (int i = 0; i < MaxSize; i++)
        {
            L.elems[i] = 0;      //将所有数据元素初始化为0
        }
        L.length = 0;
        L.size = MAX_SIZE;
    }
    
  3. c语言的动态内存分配方法

    L.elems = (int *)malloc(MAXSIZE * sizeof(int));
    
  4. 函数参数使用引用,有时在其他地方能看到使用指针,不过实际上没什么太大区别,用指针到时候就传递地址,用引用到时候就直接传递即可,推荐使用引用,基本知识不过多讲解

在这里插入图片描述

3.顺序表插入元素
bool listInsert(SqList &L,int i, int e) {
    if(i<0 || i>L.length)return false; //i 值不合法
    if(L.length==MAX_SIZE) return false; //存储空间已满
    
    if (i != L.length){  //若插入位置不在表尾
        for(int j=L.length-1; j>=i; j--) {
        L.elems[j+1]=L.elems[j]; //从最后一个元素开始后移,直到第 i 个元素后移
        }
    }
    
    L.elems[i]=e; //将新元素 e 放入第 i 个位置
    L.length++; //表长增 1
    return true;
}

解释:

  1. 前面说过,我习惯上说第0个位置,与C语言数组相嵌合,就叫采用下标法吧,如果不采用下标法,从1开始数,应该如此编写:

    bool ListInsert(SeqList &L, int i, int j){
        if(i<1 || i>L.length+1){                //判断i的范围是否有效 
            return false;
        }
        if(L.length >= MaxSize){                //判断存储空间是否已经满了
            return false;
        }
        
        for(int k=L.length; K>=i; K--){         //将第i个元素及其以后的元素后移
            L.elems[k] = L.elems[k-1];
        }      
        L.elems[i-1] = e;        //在位置i处放入新增元素j
        L.length++;             //长度加1
    }
    

在这里插入图片描述
 

4.顺序表删除元素
bool listDelete(SqList &L,int i,int &e) {
    if(i<0 || i>=L.length) return false; //不合法
    if(L.length == 0)	return false;   //线性表为空
    
    e = L.elems[i];  //返回被删除元素
    
    if(i == L.length-1) { //删除最后一个元素,直接删除
        L.length--;
        return true;
    }
    
    for (int j=i; j<L.length-1; j++) {
        L.elems[j] =L.elems[j+1]; //被删除元素之后的元素前移
    }
    
    L.length--;
    return true;
}

解释:

  1. 执行删除最后一个元素时L.length–,我们将顺序表中的长度直接减去1,这样操作是很简单的,但要清楚,我们是针对着顺序表操作,实际上你分配的这段基地址的末尾元素还是存在的,我们仅仅将length减去了1而已

在这里插入图片描述

 

5.查找某个元素,并且获取值
bool GetElem(SqList L, int i, int &e) {
    if(L.length == 0 || i<0 || i>L.length-1) {
        return false;
    }
    e = L.elems[i];
    return true;
}

解释:

  1. 获取操作不涉及到对顺序表的改动,不需要传递引用或者指针

  2. i同样采用下标法,这种方法为按位查找,也可以按值查找:

    bool GetElem(SqList L, int i, int &e) {
        for(int i=0; i<L.length; i++) {
            if(L.elems[i] == e) {
                //获取查找到的元素,不过好像按值查找还获取值,这里我有点多余了,今天学懵了,不好意思
                e = L.elems[i];  	
                return true;     
            }
            return false;            //退出循环,说明没有找到元素
        }
    }
    
  3. 同样返回值应该设置为bool类型
     

6.读取、打印顺序表所有元素
//c语言:
void OutPut(SqList L){
    printf("当前顺序表的长度:%d\n", L.length);
    for(int i = 0; i < L.length; i++){
        printf("%d ",L.elems[i]);
    }
    printf("\n");
}
//c++语言:
void OutPut(SqList L){
    cout << "当前顺序表的长度:" << L.length << endl;
    for(int i = 0; i < L.length; i++) {
        cout << L.elems[i];
    }
    cout << endl;
}

解释:

  1. 打印操作就不需要在设置为bool类型,void类型即可
  2. 参数类型也不需要用指针或引用
     
7.顺序表销毁
void destroyList(SqList &L) {
    if (L.elems) delete []L.elems;  //释放存储空间
    L.length = 0;
    L.size = 0;
}

解释:

  1. 返回类型其实也可以设置为bool类型,当顺序表不存在(未初始化时),删除失败返回false

  2. c语言的释放存储空间方式:

    if (L.elems) free(L.elems);
    

     

🚀线性表的链式存储结构

 

🚢一、单链表

🛴(一)单链表的原理精讲

链表:用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。数据元素可以存在内存中未被使用的任意位置

  • 链表逻辑上相邻的数据在计算机内的存储位置不必须相邻,那么怎么表示逻辑上的相邻关系呢?我们可以给每个元素附加一个指针域,指向下一个元素的存储位置:

    每个元素包含两个域:数据域和指针域,数据域存储本身的信息,指针域中存储的信息称为指针或链,存储其后继元素的地址,因此指针指向的类型也是相同元素类型,这两部分信息组成数据元素ai的存储映像,称为结点,n个结点(ai的存储映像)链结成一个链表,即为线性表(a1, a2, …, an)的链式存储结构,

  • 线性表顺序存储的弱点是在做插入和删除操作时,需要移动大量元素。由于链式存储结构不要求逻辑上相邻的元素在物理位置上也相邻,因此它没有顺序存储结构所具有的弱点,但同时也失去了顺序表可随机存取的优势,

  • 因为链表的每个结点中只包含一个指针域,均单向指向下一个节点,形成一条单向访问的数据链,所以叫做单链表。

在这里插入图片描述

链表的两个关键:

  1. 每个结点由数据域和指针域组成
  2. 指针域指向下一个结点的内存地址

几个重要概念:

  • 头指针:指示链表中第一个结点的存储位置

  • 头结点:有时为了方便对链表进行操作,会在单链表的第一个结点前附设一个节点,称为头结点,此时头指针指向的结点就是头结点。头节点一般不存数据,单纯用来牵引整个链条

  • 空链表:头结点的直接后继为空。

  • 假设p是指向线性表第i个数据元素的指针,p->data表示第i个位置的数据域,p->next则表示第i+1个位置的元素,p->next->data则表示第i+1个位置元素的数据域:

在这里插入图片描述

🛴(二)单链表相关代码实现

1.单链表的结构体定义
typedef int ElemType;   //ElemType的类型根据实际情况而定,这里假定为int

typedef struct _LinkNode {
    ElemType data; //结点的数据域
    struct _LinkNode *next; //结点的指针域
}LinkNode, LinkList; //链表节点、链表

解释:

  1. struct _LinkNode *next:之前提到指针域中存储的信息称为指针或链,存储其后继元素的地址,因此指针指向的类型也是相同元素类型

  2. typedef:为了操作方便,起了两个别名LinkNode, LinkListLinkList理解为头节点,有了头节点就拿到了整个链表,所以LinkList直接表示链表,LinkNode表示其中一个结点,实际上都是一个结点,结构体定义是一样的。如链表中增加几个元素,例如:长度,那么两者就需要分开定义

    //构造结点
    typedef struct LinkNode {
        ElemType data;
        struct LinkNode *next;
    }LinkNode;
    
    //构造LinkList
    typedef struct {
        int lenght;
        LinkNode *next;
    }LinkList;
    

    分别定义的好处就是更加灵活,比如链表的定义中增加了length表示链表的长度,不过后面的初始化操作等都有细节上的改变,读者自行拓展,本篇还是采用相同结构体的方法

2.单链表的初始化
//构造一个空的单链表 L
bool InitList(LinkList* &L) {
    L=new LinkNode; //生成新结点作为头结点,用头指针 L 指向头结点
    if(!L)return false; //生成结点失败
    L->next=NULL; //头结点的指针域置空
    return true;
}	

解释:

  1. 在进入函数前(main函数中)就创建了一个头指针 LinkList *L = NULL 但此时仅仅创建了一个指针变量,根据语法,这是一个未指明地址指向的野指针,需对其进行初始化;此处我们选取函数对其进行初始化;
  2. 参数采用指针的引用,直接用双指针也可,到时候传递指针的地址即可,都属于基本知识,不过多讲解。
  3. 链表做参数时,其实就是定义一个临时变量LinkList *p = L代替头指针去遍历,头指针是不能变的,查找这种操作做子函数时,链表不会发生改变,就用头结点的指针做参数就可以了。但在增加,修改,删除这种操作时,链表会发生改变,这就表示头指针所指的这块内存会发生改变,也就是指针的指向可能会发生改变,这种情况下就要用头指针的引用或双指针传递头指针地址。

在这里插入图片描述

3.单链表增加元素
(1)前插法
//前插法
bool ListInsert_front(LinkList* &L, LinkNode * node){
    if(!L || !node ) return false;
    
    node->next = L->next;
    L->next = node;
    return true;
}
(2)尾插法
//尾插法
bool ListInsert_back(LinkList* &L, LinkNode *node){
    LinkNode *last = NULL;

    if(!L || !node ) return false;

    //找到最后一个节点
    last = L;
    while(last->next) last=last->next;

    //新的节点链接到最尾部
    node->next = NULL;
    last->next = node;
    return true;
}
(3)指定位置插入
//在带头结点的单链表 L 中第 i 个位置插入值为 e 的新结点
bool LinkInsert(LinkList* &L, int i, int &e) {
    int j;  //j用来计数跳过的结点数,也可理解为当前p指针的是第几个结点
    LinkList *p, *s;  //定义两个临时变量

    p=L;  //临时变量P指向头结点
    j=0;
    
    while (p && j<i-1) { //查找第 i-1 个结点,p 指向该结点
        p=p->next;
        j++;
    }

    if (!p || j>i-1){ //i>n+1 或者 i<1
        return false;
    }

    s = new LinkNode; //生成新结点
    s->data = e; //将新结点的数据域置为 e
    s->next = p->next; //将新结点的指针域指向结点 ai
    p->next = s; //将结点 p 的指针域指向结点 s
    return true;
}

解释:

  1. 对于第几个位置的说法,仍然采用下标法,不过这里头结点充当下标为0,第一个结点下标即为1,其实正好统一了不采用下标法记录结点的方法,他们对于结点1的位置就是1,而我们本来结点1应该是第0个位置,这里头结点作为0了
  2. 想要在第i个位置上插入元素,那么就要找到第i − 1个结点,然后将新结点插入其后面
  3. 指定位置插入方法其实包含了前插法,前插法即在i = 1的情况下的插入,但是并不能包含尾插方法,因为按照我们的链表定义,并不知道链表的尾部的位置,还需要代码补充,比较麻烦
  4. 可自行添加方法:指定元素的前插、指定元素的后插等,再次强调数据结构是灵活多变的

在这里插入图片描述

4.单链表删除元素
//在带头结点的单链表 L 中,删除第 i 个位置
bool LinkDelete(LinkList* &L, int i) {
    LinkList *p, *q;	//p用来代替头指针进行遍历,q等会用来存储被删结点
    int j;             //j用来计数跳过的结点数,也可理解为当前p指针的是第几个结点
    p=L;
    j=0;
    
    while((p->next)&&(j<i-1)) { //查找第 i-1 个结点,p 指向该结点
        p=p->next;
        j++;
    }
    
    if (!(p->next)||(j>i-1)) {	//当 i>n 或 i<1 时,删除位置不合理
    	return false;
    }
    
    q=p->next; 		//临时保存被删结点的地址以备释放空间
    p->next=q->next; //改变删除结点前驱结点的指针域
    delete q; 		//释放被删除结点的空间
    return true;
}

解释:

  1. 和插入方法中大体思想一样,不过对于查找第i-1个结点的循环条件需要注意,插入操作中是p && j<i-1而删除操作中是(p->next)&&(j<i-1),因为插入是能在末尾插入的,即前一个结点p->next为空是可以的,但再往后p为空就不行了,而在删除中是不能删除末尾不存在的元素的,所以查找i-1个结点必须p->next不能为空
  2. 同样,可以自己添加一个参数,返回被删除元素的数据域
  3. 这种方法是按位删除,读者可自行编写理解按值删除的操作

在这里插入图片描述

5.单链表查找、获取元素
//单链表的取值
bool Link_GetElem(LinkList* L, int i, int &e)
{
    //在带头结点的单链表 L 中查找第 i 个元素
    //用 e 记录 L 中第 i 个数据元素的值
    int j;
    LinkList* p;
    
    p=L->next;//p 指向第一个结点,
    j=1; //j 为计数器
    
    while (j<i && p) //顺链域向后扫描,直到 p 指向第 i 个元素或 p 为空
    {
        p=p->next; //p 指向下一个结点
        j++; //计数器 j 相应加 1
    }
    
    if (!p || j>i){
    	return false; //i 值不合法 i>n 或 i<=0
    }
    
    e=p->data; //取第 i 个结点的数据域
    return true;
}

//按值查找
bool Link_FindElem(LinkList *L, int e) {
    //在带头结点的单链表 L 中查找值为 e 的元素
    LinkList *p;
    p=L->next;
    
    while (p && p->data!=e) { //顺链域向后扫描,直到 p 为空或 p 所指结点的数据域等于 e
        p=p->next; 			//p 指向下一个结点
    }
    
    if(!p)	return false; //查找失败 p 为 NULL
    
    return true;
}

解释:

  1. 同样,按位查找读者可自行编写
  2. 不需要改变链表,参数传递指针即可
  3. 这里将查找和获取元素值分开写以便讲解,单链表的取值中查找就是按位查找的方法,实际上大家可以将其放到一个函数里实现
  4. 返回类型我设置的bool类型,也可以直接设置为LinkNode *类型,找到后直接返回即可
6.单链表的遍历
//单链表的输出
void LinkPrint(LinkList* L) {
    LinkNode* p;
    p=L->next;
    
    while (p) {
        cout <<p->data <<"\t";
        p=p->next;
    }
    cout<<endl;
}

解释:

  1. 这个比较简单,就不过多讲解,注意while循环条件
  2. 传参这里不需要对链表进行改动,所以传递指针即可
7.单链表的销毁
void LinkDestroy(LinkList* &L) { //单链表的销毁
    //定义临时节点 p 指向头节点
    LinkList *p = L;
    cout<<"销毁链表!"<<endl;
    
    while(p) {
        L=L->next; //L 指向下一个节点
        cout<<"删除元素: "<<p->data<<endl;
        delete p; //删除当前节点
        p=L; //p 移向下一个节点
	}
}

解释:

这里因为要删除指向的当前节点,在往下遍历的同时,要有一个指针来临时保存被删结点的地址以备释放空间,但其实不需要再多定义一个临时变量,利用好L进行遍历就好,直接移动头指针,同时也符合从头到尾一个一个结点删除后头指针的改变,p就用来临时保存要删除的结点

 

🚢二、静态链表

🛴(一)静态链表原理精讲

1.静态链表的基本概念

静态链表:用数组描述的链表,还有起名叫做游标实现法

顺序表插入和删除操作需要移动大量元素;当线性表长度较大时,难以确定存储空间的容量;造成存储空间的“碎片”。链表不要求逻辑上相邻的元素在物理位置上也相邻,因此它没有顺序存储结构所具有的弱点,但同时也失去了顺序表可随机存取的优势,

那么,是否存在一种存储结构,可以融合顺序表和链表各自的优点,从而既能快速访问元素,又能快速增加或删除数据元素,答案就是静态链表:

  • 使用静态链表存储数据,数据全部存储在数组中(和顺序表一样),
  • 但存储位置是随机的,数据之间"一对一"的逻辑关系通过一个整形变量(称为"游标",和指针功能类似)维持(和链表类似)。
  • 所以静态链表的两个核心要素为数据域:用于存储数据元素的值;游标:其实就是数组下标,表示直接后继元素所在数组中的位置;
  • 静态链表实际上就是一个结构体数组
  • 通常,静态链表会将第一个数据元素放到数组下标为 1 的位置(a[1])中,a[0]不存储数据元素,a[1]才是静态链表的第一个真正存放数据的结点。
  • 在链表中最后一个结点的next指针为null,而在静态链表中,因为a[0]不存储数据元素,我们可以将最后一个数据的cur设为0,在单链表中如果想要表示表尾结点,会让该结点的尾指针指向NULL。而在静态链表中会将游标设置为0。

在这里插入图片描述

举例:

在这里插入图片描述

图中从 a[1] 存储的数据元素 1 开始,通过存储的游标变量 3,就可以在 a[3] 中找到元素 1 的直接后继元素 2;同样,通过元素 a[3] 存储的游标变量 5,可以在 a[5] 中找到元素 2 的直接后继元素 3,这样的循环过程直到某元素的游标变量为 0 截止(因为 a[0] 默认不存储数据元素)。

区别说明:

在《大话数据结构》中采用最后一个元素为数据链表头结点,数据域不存放数据,游标域存放首结点的数组下标。本篇总结采用a[1]为数据链表的头结点方法,最后一个数组下标和普通数组下标作用一样,所以代码上有一点点的差别,我个人觉得后者比较好,关于另外一种方法的理解和代码可以自己去看看书

备用链表:

  • 其实上面显示的静态链表还不够完整,在链表中,我们不可能恰好将所有的位置都使用,那么就会出现空闲的位置,用来连接这些空闲位置的链表,我们就将其称之为备用链表,他的作用为:回收数组中未使用或之前使用过(目前未使用)的存储空间,留待后期使用。
  • 也就是说,静态链表使用数组申请的物理空间中,存有两个链表,一条连接数据,另一条连接数组中未使用的空间。
  • 一般情况下:备用链表的表头位于数组下标为 0(a[0]) 的位置,而数据链表的表头位于数组下标为 1(a[1])的位置。

举例说明:

在这里插入图片描述

图中备用链表依次是:a[0] -> a[2] -> a[4],数据链表依次是:a[1] -> a[3] -> a[5]

2.静态链表添加数据的实现过程

例如:假设使用静态链表(数组长度为 7)存储 {1,2,3},

(1)在数据未存储之前,数据链表当前是不存在的,数组中所有位置都处于空闲状态,因此都应被链接在备用链表上

在这里插入图片描述

(2)添加元素1

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

在这里插入图片描述

(3)添加元素2

在这里插入图片描述

(4)添加元素3

在这里插入图片描述

🛴(二)静态链表相关代码实现

1.静态链表的结构体定义
#define MAXSIZE 1000    //假设链表的最大长度是1000

typedef struct _Component {
    int data;	//数据域
    int cur;	//游标(Cursor),为0时表示无指向
}Component,StaticLinkList[MAXSIZE];

解释:

  1. 为了方便插入数据,我们通常会把数组建立得大一些,以便有一些空闲空间可以便于插入时不至于溢出。

  2. Component:每个结构体(元素、组成部分),StaticLinkList[MAXSIZE]:结构体数组,这样写可以极大的简化代码

    其等价于

    #define MaxSize 1000
    typedef struct _Component {
        int data;
        int cur;
    }Component;
    
    //这里相当于SLinkList可用来定义为一个数组,数组长度为Maxsize,类型为Component
    typedef struct _Component SLinkList[MaxSize]; 
    
    void testSLinkList(){
        SLinkList a;  //等价于struct _component a[Maxsize]
    }
    
2.静态链表分配空间
//申请下一个分量的资源,返回下标
int Malloc_SLL(StaticLinkList space){
    int i = space[0].cur;   //当前数组第一个元素的cur存的值,就是要返回的第一个备用空间的下标
    if(space[0].cur){
        space[0].cur = space[i].cur;    //把下一个分量用来做备用
    }
    return i;
}


//将下标为k的空闲节点收回到备用链表
void Free_SSL(Component *space, int k){
    space[k].cur = space[0].cur;    //把第一个元素cur值赋值给要删除的分量cur
    space[0].cur = k;   //把要删除的分量下标赋值给第一个元素的cur
}

解释:

  1. 在前面的动态链表中,节点的申请和释放分别借用new和delete两个函数来实现,不需要预先申请内存空间,而是在需要的时候才向内存申请,当链表中添加或删除数据元素时,只需要通过 malloc、new 或free、delete 函数来申请或释放空间即可,实现起来比较简单。
  2. 但在静态链表中,我们是从静态链表中的备用链表中一个一个拿出结点到我们的数据链表中,所以逻辑上需要我们自己实现这两个函数。
  3. 为了辨明数组中哪些分量未被使用,解决的办法是将所有未被使用过的及已被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取得第一个结点作为待插入的新节点。
  4. 参数为结构体数组,按照我们的定义,直接传递StaticLinkList即可,也可传递Component *,同样表示结构体数组

在这里插入图片描述

3.静态链表的初始化
bool InitList(Component *space){
    for(int i=0; i<MAXSIZE; i++){
        space[i].cur = i+1;
    }
    
    space[MAXSIZE-1].cur = 0; //目前静态链表为空,最后一个元素的cur为0
    return OK;
}

在这里插入图片描述

4.静态链表插入操作
//得到静态列表的长度,初始条件:静态列表L已存在。操作结果:返回L中数据元素的个数
int ListLength(StaticLinkList L){
    int j = 1;  //要算上数据链表表头本身
    int i = L[1].cur;
    while(i){
        i = L[i].cur;
        j++;
    }
    return j;
}

//在静态链表L中第i个元素之前插入新的元素e
bool ListInsert(Component *L, int i, int e){
    int insertlc,k;
    
    k = 1;   //k表示数据链表头结点的位置
    if(i<1 || i>ListLength(L) + 1){
        return false;
    }
    
    insertlc = Malloc_SLL(L);  //申请空间(从备用链表中拿出一个空间),准备插入
    
    if(insertlc){
        L[insertlc].data = e;  //将数据赋值给此分量的data
        
        for(int t=1; t< i-1; t++){ 
            k = L[k].cur;       //找到要插入位置的上一个结点在数组中的位置
        }
        L[insertlc].cur = L[k].cur;    //新插入结点的游标等于其直接前驱结点的游标
        L[k].cur = insertlc;   	   //直接前驱结点的游标等于新插入结点所在数组中的下标
        return true;
    }
    return false;
}

解释:

对于位置的说法,同样是下标法,下标法从a[0]开始,而不用下标法的方法从数据链表表头开始数1,所以又一次统一了

此时的数据链表为:甲 -> 乙 -> 丙 -> 戊 -> 丁

在这里插入图片描述

5.静态链表的删除
//删除在L中第i个数据元素e
bool ListDelete(Component *L, int i){
    int j,k;
    if(i<1 || i>ListLength(L)+1){
        return false;
    }
    k = 1;
    for(j=1; j<i-1; j++){
        k = L[k].cur;   //找到第i个元素之前的位置
    }
    j = L[k].cur;   
    L[k].cur = L[j].cur;  //改变前一个元素的cur为删除元素的cur
    OUTPUT(L);   
    Free_SSL(&L, j);
    return OK;
}

解释:

此时的数据链表为:甲 -> 乙 -> 戊 -> 丁

在这里插入图片描述

6.静态链表的遍历
void SLLinkPrint(Component *L) 
{
	int i = 1;
	while (i) //通过循环遍历
	{
		printf("%c ", space[i].data);//输出链表中的数据
		i = sapce[i].next;
	}
	printf("\n");
}

还能延伸出许多其他操作,比如修改某个元素值,查找某个元素值,等等,这里就不多花篇章介绍了,实际上静态链表用的不是特别多
 

🚢三、循环链表

单链表最后一个结点的next指针指向null,表示单向链表结束,循环链表,顾名思义,将单链表首尾相连,最后一个结点的next指针重新指向头结点从而形成一个循环

  • 从表中任一结点触发均可找到表中的其他结点
  • 循环链表的操作和线性表基本一致,差别仅在于算法中的循环条件不是P—>next是否为空,而是是否等于头指针

在这里插入图片描述

空的循环链表:

在这里插入图片描述

仅设尾指针的循环链表

上面我们讨论的仅设置头指针的链表,这样的循环链表有一个弊端,我们可以用O(1)的时间访问第一个节点,但对于最后一个节点,却需要O(n)的时间,于是就有了仅设尾指针的循环链表。

  • 终端节点用尾指针rear指示,则查找终端节点是O(1),
  • 开始节点,其实就是rear->next->next,其时间复杂度也是O(1)。

在这里插入图片描述

两个循环链表合成一个表:

向上面这样子设置循环链表有一个好处,当我们要将两个循环链表合成一个表时,有了尾指针就非常简单了,例如:我们只需要将A链表的尾指针指向结点的next指针指向B链表的第一个结点,即B -> next -> next,把B链表尾指针指向结点的next指针再指向A的头结点即可,即rearA -> next

//第一步:保存A的头结点
p = rearA->next;	
//第二步:将本是指向B表的第一个节点(不是头结点)赋值给rearA->next
rearA->next = rearB->next->next;
//第三步:将原A表的头结点赋值给rearB->next
rearB->next=p;
//释放p
free(p);

在这里插入图片描述

 

🚢四、双向链表

🛴(一)双向链表原理精讲

前面我们提到的单链表,每个结点除了存储自身数据之后,还存储了下一个结点的地址,因此可以轻松访问下一个结点,以及后面的后继结点,但是如果想访问前面的结点就不行了,再也回不去了。由此,从某结点出发只能顺指针往后查询其他结点,若要寻查结点的直接前驱,则需要从头指针出发。双向链表则克服了单链表这种单向性的缺点:

  • 可以在单链表的基础上再给每个元素附加一个指针域prev,prev存储前一个元素的地址,next存储下一个元素的地址。这种链表称为双向链表.
  • 头结点的prev为空,尾结点的next为空

在这里插入图片描述

🛴(二)双向链表相关代码实现

1.双向链表的结构体定义
typedef struct _DoubleLinkNode {
    int data; //结点的数据域
    struct _DoubleLinkNode *next; //下一个节点的指针域
    struct _DoubleLinkNode *prev; //上一个结点的指针域
}DbLinkNode, DbLinkList; //LinkList 为指向结构体 LNode 的指针类型

解释:

  1. 定义为两个名字,跟单链表一样,为了概念上更加容易区分
  2. 跟单链表的操作其实也差别不大,增加了一个prev指针而已
2.双向链表的初始化
//构造一个空的双向链表 L
bool DbInit_List(DbLinkList* &L) {
    L=new DbLinkNode; //生成新结点作为头结点,用头指针 L 指向头结点
    
    if(!L)return false; //生成结点失败
    
    L->next=NULL; //头结点的 next 指针域置空
    L->prev=NULL; //头结点的指针域置空
    L->data = -1;
    
    return true;
}

在这里插入图片描述

3.双向链表增加元素
(1)前插法
//前插法
bool DbListInsert_front(DbLinkList* &L, DbLinkNode *node){
    if(!L || !node) return false;
    
    //1.只有头节点
    if(L->next==NULL){
        node->next=NULL;
        node->prev=L; //新节点 prev 指针指向头节点
        L->next=node; //头节点 next 指针指向新节点
    } else { 
        L->next->prev=node; //第二个节点的 prev 指向新节点
        node->next = L->next; //新节点 next 指针指向第二个节点
        node->prev=L; //新节点 prev 指针指向头节点
        L->next=node; //头节点 next 指针指向新节点,完成插入
    }
    return true;
}

在这里插入图片描述

(2)尾插法
//尾插法
bool DbListInsert_back(DbLinkList* &L, DbLinkNode *node){
    DbLinkNode *last = NULL;
    
    if(!L || !node) return false;
    
    last = L;
    
    while(last->next) last = last->next;
    
    node->next = NULL;
    last->next = node;

    node->prev = last;
    return true;
}

在这里插入图片描述

(3)指定位置插入
//指定位置插入
bool DbLink_Insert(DbLinkList* &L, int i, int &e){
    if(!L||!L->next) return false;
    
    if(i<1) return false;
    
    int j =0;
    DbLinkList *p, *s;
    
    p = L;
    
    while(p && j<i){//查找位置为 i 的结点,p 指向该结点
        p = p->next;
        j++;
    }
    
    if(!p || j!=i){
        cout<<"不存在节点:"<<i<<endl;
        return false;
    }
    
    cout<<"p: "<<p<<endl;
    
    s=new DbLinkNode;//生成新节点
    s->data = e;
    
    s->next = p;
    s->prev = p->prev;
    p->prev->next = s;
    p->prev = s;
    return true;
}

解释:

  1. 当i等于1的时候就相当于头插法,但是不包含只有头结点的情况,需要完善代码
  2. 要包含进尾插法的话需要明确尾结点的位置,也需要完善代码

在这里插入图片描述

4.双向链表删除元素
//任意位置删除
bool DbLink_Delete(DbLinkList* &L, int i) //双向链表的删除
{
    DbLinkList *p;
    
    int index = 0;
    
    if(!L || !L->next) {
        cout<<"双向链表为空!"<<endl;
        return false;
    }
    
    if(i<1) return false; //不能删除头节点
    
    p=L;
    while(p && index<i){  //找到要删除的节点
        p = p->next;
        index++;
    }
    
    if(!p){ //当节点不存在时,返回失败
        return false;
    }
    
    p->prev->next=p->next; //改变删除结点前驱结点的 next 指针域
    if(p->next){
    	p->next->prev = p->prev; //改变删除节点后继节点的 prev 指针域
    }
    delete p; //释放被删除结点的空间
return true;
}

在这里插入图片描述

5.双向链表的销毁
void DbLink_Destroy(DbLinkList* &L) //双向链表的销毁
{
    //定义临时节点 p 指向头节点
    DbLinkList *p = L;
    cout<<"销毁链表!"<<endl;
    
    while(p){
        L=L->next;//L 指向下一个节点
        cout<<"删除元素: "<<p->data<<endl;
        delete p; //删除当前节点
        p = L; //p 移向下一个节点
    }
}

双向链表查找、获取元素:与单链表的操作差不多,比较简单,这里就不过多讲解了。双向链表的遍历也与单链表差距不大,双向链表可以逆向打印输出元素,还有其它可延伸操作就不一一展示了
 

🚀补充知识:Linux 内核共享双向链表

在 linux 内核中,有大量的数据结构需要用到双向链表,例如进程、文件、模块、页面等。若采用双向链表的传统实现方式,需要为这些数据结构维护各自的链表,并且为每个链表都要设计插入、删除等操作函数。因为用来维持链表的 next 和 prev 指针指向对应类型的对象,因此一种数据结构的链表操作函数不能用于操作其它数据结构的链表。

这里先简单了解一下即可:我们将链表结点中的数据域分离出来,通过结点来访问上面的数据,从而我们能把数据定义成结构体或其他类型,但同时要用到offsetof来根据链表节点在结构体中的地址来逆推出数据结构体变量的位置(节点结构体在数据结构体里面)

在这里插入图片描述


行文至此,落笔为终。文末搁笔,思绪驳杂。只道谢不道别。早晚复相逢,且祝诸君平安喜乐,万事顺意。

总结参考资料:

程杰:大话数据结构

严蔚敏:数据结构C语言版

数据结构:线性表(List)【详解】
(排版结构等都借鉴了此位前辈的博客,对我的学习总结起到了很大的帮助)

  • 23
    点赞
  • 66
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

陈七.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值