三、链表详解

👉 欢迎阅读这篇文章 👇

目录

1、链表

我们前面介绍了顺序表是元素存储在物理上连续空间中,优势是访问任意位序元素的时间复杂度事 O ( 1 ) O(1) O(1),但是要想插入元素,需要挪动大量的数据,时间复杂度是 O ( n ) O(n) O(n),并且空间如果满了就需要扩容。

所以发明了链式结构来存储线性表。

链式存储:把逻辑上相邻的数据元素存储在任意的一组物理存储单元中,数据元素之间的逻辑关系用指针来表示。

链表的优势在于可以按需申请空间,不再需要扩容,需要存储一个数据就申请一块空间,用指针将空间与空间链接起来,存储数据和指针的这一块空间叫做结点(节点)。还有优势是在某个节点位置插入和删除数据不再需要挪动数据,直接更改结点之间的链接关系。

链表有很多种结构,先看一个单链表

2、单链表

2.1单链表的定义

单链表的结点中既要存储值,也要存储后继元素结点的指针。

结点的定义:

c 复制代码
//定义结点
typedef int LDataType;
typedef struct ListNode
{
    LDataType data;//存放数据
    struct ListNode* next;//存放后继结点的指针
}LNode,*LinkList;

单链表的特点:

  • 指向第一个结点的指针叫头指针。尾结点的指针指向空
  • 单链表分为带头结点和不带头结点两种结构。头结点也叫哨兵位,不存储有效数据。

2.2接口函数定义

以下定义是针对带头结点的单链表进行研究的。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int LDataType;
//定义结点
typedef struct ListNode
{
    LDataType data;//存放数据
    struct ListNode* next;//存放下一个结点的地址
} LNode,*LinkList;

//创建一个新结点
LNode* BuyListNode(int data);

//初始化
LNode* ListInit();

//打印链表
void Listprint(LNode* L);

//获取链表中有效元素的个数
int Listsize(LNode* L);

//按值查找
LNode* ListLocateElem(LNode*L,LDataType x);

//按下标查找
LNode* ListGetElem(LNode*L,int i);

//在下标为i的位置插入元素x
void ListInsert(LNode*L,int i,LDataType x);

//删除下标为i的结点,返回删除结点里存放的数据
LDataType ListDelete(LNode*L,int i);

//头插
void ListPushFront(LNode* L, LDataType x);
//尾插
void ListPushBack(LNode* L, LDataType x);
//头删
LDataType ListPopFront(LNode* L);
//尾删
LDataType ListPopBack(LNode* L);

//销毁
void ListDestroy(LNode* L);

2.3初始化

LNode* ListInit() 初始化函数使⽤了返回哨兵位头结点,因为我们函数内部要创建⼀个头结点,返回头结点指针。

代码演示

c 复制代码
//新建一个结点
LNode* BuyListNode(int data)
{
    //申请空间
    LNode* newNode=(LNode*)malloc(sizeof(LNode));
    if(newNode==NULL)
    {
        perror("BuyListNode malloc:");
        return NULL;
    }

    //开辟成功后,对结点中的数据域和指针域进行初始化
    newNode->data = data;
    newNode->next = NULL;

    return newNode;
}

LNode* ListInit()
{
    LNode* node=BuyListNode(-1);
    return node;
}

如果使⽤传参⽅式调⽤ListInit以获取指向哨兵位的头指针,就必须使⽤⼆级指针 LNode** 作为形参才能解决问题 void ListInit(LNode** pL)。或者使用C++引用的方式,即void ListInit(LNode& pL);

2.4遍历打印和求长度

打印的核心操作是遍历链表,遍历链表的本质:在当前结点中的next成员拿到下一个结点的地址进行迭代。

代码演示

c 复制代码
void Listprint(LNode* L)
{
    //哨兵位头指针不为空
    assert(L);

    //定义一个指针指向每一个结点
    LNode* cur=L->next;

    while(cur!=NULL)
    {
        printf("%d->",cur->data);
        cur=cur->next;
    }
    printf("NULL\n");
}

获取链表中有效元素的个数

代码演示

c 复制代码
int ListLocateElem(LNode* L)
{
    int n = 0;
    LNode* cur=L->next;
    while(cur!=NULL)
    {
        cur=cur->next;
        n++;
    }
    return n;
}

2.5查找

链表的查找分为按值查找和按下标查找

2.5.1按值查找

按值查找是遍历⼀遍链表,找到第⼀个值跟x相等的结点即返回,没有找到返回NULL

代码演示

c 复制代码
LNode* ListLocateElem(LNode*L,LDataType x)
{
    assert(L);
    LNode* cur=L->next;
    while(cur!=NULL)
    {
        if(cur->data==x)
        {
            return cur;
        }
        cur = cur->next;
    }
    return NULL;
}

2.5.2按下标查找

按下标查找相对更复杂一些,需要构造一个计数器j ,根据j的大小去遍历当j=i的时候就到了下标为i的这个结点。需要j<i&&iNode!=NULL 作为循环条件,因为链表的第i个结点不⼀定存在。(当i大于目前链表中结点个数的时候就不存在)。

代码演示

c 复制代码
LNode* ListGetElem(LNode*L,int i)
{
    assert(L);
    int j = 0;
    LNode* iNode = L->next;
    while(j<i&&iNode!=NULL)
    {
        iNode = iNode->next;
        j++;
    }
    return iNode;
}

2.6插入

链表的插入不再需要像顺序表那样挪动数据,只需要改动结点间的链接关系,但是要在第i个结点之前插入,所以就要能够找到第i-1个结点i_1Node

核心操作

  • 找到i_1Node,方法是利用计数器j和下标i的关系进行控制遍历次数,找到i_1Node
  • 计数器j应该从-1开始,i_1Node最初应该是头指针,以保证头插的时候的正确性。
  • 找到i_1Node后,需要开辟一个新的结点newNode,需要让i_1Node->next指向newNode,而newNode->next需要指向i_Node->next(就是原iNode)。

注意 :为了保证正确,我们需要先操作newNode->next=i_Node->next再操作i_1Node->next=newNode。因为我们需要i_Node->next来代表第i个结点,如果先执行i_1Node->next=newNode,那么这时i_Node->next就是新插入的那个结点,无法找到第i个结点。

或者创建一个临时指针temp存储i_Node->next,后面再newNode->next=temp即可,这样顺序就不影响了。

代码演示

c 复制代码
void ListInsert(LNode*L,int i,LDataType x)
{
    assert(L);
    assert(i>=0);

    //寻找i_1Node结点
    int j = -1;
    LNode* i_1Node = L;
    while(j<i-1&&i_1Node!=NULL)
    {
        i_1Node = i_1Node->next;
        j++;
    }

    //没有第i-1个结点,说明i非法
    assert(i_1Node!=NULL);

    //找到了第i-1个结点
    LNode* newNode = BuyListNode(x);//创建新结点,放入要插入的元素x

    //改变结点间的链接关系
    newNode->next = i_1Node->next;//让新插入的结点的next指向原来下标为i的结点
    i_1Node->next = newNode;//让原来的下标i-1的位置的结点指向新插入的结点
}

2.7删除

链表的删除不再需要向顺序表那样挪动数据,只需要改动结点间的链接关系,要删除第i个结点,就需要找到第i-1个结点,叫i_1Node

核心操作:

  • 找到i_1Node,方法是利用计数器j和下标i的关系进行控制遍历次数,找到i_1Node
  • 计数器j应该从-1开始,i_1Node最初应该是头指针,以保证头删的时候的正确性。
  • 具体操作的时候可以将删除的那个结点的数据返回
  • 要先创建一个临时指针变量存储第i个结点的地址,防止后续找不到这个结点的位置。
  • 执行完寻找i_1Node的功能后,要检查前驱结点后的那个结点是否存在,检查要删除的结点是否存在。

注意:

如果没有头结点的链表删除,则形参 LNode** pL 必须⽤⼆级指针,因为如果i=0 头删时需要让实参头指针LT指向第i+1个结点(第2个结点),也就是 *pL = iNode->next;

所以不带头结点删除更复杂⼀些,因为要对头删单独判断处理,且要⽤⼆级指针处理。

代码演示:

c 复制代码
//删除下标为i的结点,返回删除结点里存放的数据
LDataType ListDelete(LNode*L,int i)
{
    assert(L);
    assert(i>=0);

    //查找下标为i-1的元素
    int j = -1;
    LNode* i_1Node = L;
    while(j<i-1&&i_1Node!=NULL)
    {
        i_1Node = i_1Node->next;
        j++;
    }

    //断言i不合法的情况和检查要删除的结点是否存在
    assert(i_1Node!=NULL&&i_1Node->next!=NULL);

    LNode* iNode = i_1Node->next;

    //修改结点间链接地址
    i_1Node->next = iNode->next;
    LDataType x = iNode->data;
    //释放删除的结点
    free(iNode);
    return x;
}

2.8头尾删除插入

这些接口的实现可以直接复用上方的插入和删除函数

2.8.1头插

代码演示

c 复制代码
void ListPushFront(LNode* L, LDataType x)
{
    ListInsert(L, 0, x);
}

2.8.2尾插

代码演示

c 复制代码
void ListPushBack(LNode* L, LDataType x)
{
    ListInsert(L, Listsize(L), x);
}

2.8.3头删

代码演示

c 复制代码
LDataType ListPopFront(LNode* L)
{
    return ListDelete(L, 0);
}

2.8.4尾删

代码演示

c 复制代码
LDataType ListPopBack(LNode* L)
{
    return ListDelete(L, Listsize(L) - 1);
}

2.9销毁

链表的销毁就是不断遍历释放链表结点,不过需要先保存下⼀个结点,否则free了当前结点就找不到下⼀个结点了。

代码演示

c 复制代码
void ListDestroy(LNode* L)
{
    assert(L);
    //构造遍历指针
    LNode* cur=L->next;

    //遍历释放
    while(cur!=NULL)
    {
        //保存当前结点的下一个结点的指针
        LNode* next = cur->next;
        //释放当前结点
        free(cur);
        //把刚刚保存的下一个结点的指针赋给遍历指针
        cur = next;
    }

3、链表的分类

3.1 分类介绍

根据不同的需要,实践应用中出现了多种不同的链表结构,分为单向链表和双向链表、带头结点的链表和不带头结点的链表、循环链表和非循环链表。

将以上几种链表进行组合可以组合出8种链表结构

重点掌握带头结点单链表 /不带头结点单链表 /双向循环链表即可。

3.2双向链表的结构

通过前面的了解我们知道单链表中保存了指向后继结点的地址,所以在单链表中找当前结点的后继结点很容易,但要获取当前结点的前驱结点就很麻烦,只能从头开始遍历,时间复杂度为 O ( n ) O(n) O(n);

双向链表相比单链表的最大的一个特征是多了一个前驱指针,一些场景需要获取当前结点的前驱结点时就需要用到双向链表。

双向链表的⼀些不⾜是找尾结点依旧不是很⽅便,另外呢,头尾插⼊删除考虑的边界依旧⽐较多。后面的带头双向循环链表就可以很好地解决这些问题。

c 复制代码
typedef int DLDataType;
struct DListNode
{
    DLDataType data;       //存放数据元素
    struct DListNode* per; //指向前驱元素
    struct DListNode* next;//指向后继元素
};

3.3循环链表的结构

实践应用中最常见的不是在第i个位序处插入删除元素,而是在头尾插入删除数据。

单链表头插头删效率很高,可以做到时间复杂度为 O ( 1 ) O(1) O(1),但是对于尾插尾删需要找到尾结点,时间复杂度为 O ( n ) O(n) O(n)。

双向链表头插头删效率⾼,确定某个结点位置以后插⼊删除效率也很⾼,均可以做到时间复杂度 O ( 1 ) O(1) O(1)

同样尾插尾删,需要增加⼀个尾指针,相对⿇烦;

所以这⾥我们引入循环链表可以解决这⾥的问题。

循环链表又可分为单向循环链表和双向循环链表

单向循环链表就是让尾结点的next指向头结点,双向循环链表就是尾结点的next指向头结点同时头结点的prev指向尾结点。

实践中双向循环链表⾮常实⽤,C++标准库(STL)中list就是使⽤的这个结构实现,因为他可以通过头结点的prev指针找到尾结点,轻松实现尾插尾删。也就是说这个结构头尾插⼊删除效率都是 O ( 1 ) O(1) O(1) ,确定某个结点位置以后得插⼊删除也是 O ( 1 ) O(1) O(1) 。

3.4单链表和循环单链表的比较

循环单链表和单链表在结构体定义和操作中有些不一样的地方

  • 初始化不同,循环单链表初始化时要让头结点的next指向⾃⼰。
c 复制代码
//单链表
void ListInit(struct ListNode* L)
{
    L = BuyListNode(-1);
    L->next = NULL;
}
//循环单链表
void ListCInit(struct CListNode* L)
{
    L = BuyListNode(-1);
    assert(L);
    L->next = L;
}
  • 遍历时判断结束的逻辑不同,循环单链表遍历不能让迭代指针指向空作为结束条件,⽽是⾛⼀圈等于头结点时结束。
c 复制代码
//单链表
int ListSize(struct ListNode* L)
{
    int size = 0;
    struct ListNode* cur = L->next;
    while(cur!=NULL)
    {
        cur = cur->next;
        size++;
    }
    return size;
}
//循环单链表
int CListSize(struct CListNode* L)
{
    int size = 0;
    struct CListNode* cur = L->next;
    while(cur!=L)
    {
        cur = cur->next;
        size++;
    }
    return size;
}

3.5双向链表和双向循环链表比较

带头双向循环链表相比于双向链表的优势主要体现在两⽅⾯

第⼀:可以通过头结点的prev快速找到尾结点,⾼效实现尾插尾删;

第⼆:pos结点位置插⼊删除时可以更简单,因为不需要考虑尾结点的的后继结点为空的情况。

4、带头双向循环链表的实现

4.1接口函数定义

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int DCListDataType;

typedef struct DCListNode
{
    DCListDataType data;
    struct DCListNode* prev;
    struct DCListNode* next;
}DCListNode;

//链表的头结点初始化
DCListNode* DCListInit();

//销毁链表
void DCListDestroy(DCListNode* L);

//打印链表
void DCListPrint(DCListNode* L);

//获取链表中下标为i的结点
DCListNode* DCListGetElem(DCListNode*L,int i);

//在结点pos后插入一个元素为x的结点
void DCListInsert(DCListNode*pos,DCListDataType x);

//删除pos结点
void DCListDelete(DCListNode*pos);

4.2初始化/销毁/打印/查找

c 复制代码
#include "DCList.h"

//创建新结点
DCListNode* BuyDCListNode(DCListDataType data)
{
    DCListNode* L = (DCListNode*)malloc(sizeof(DCListNode));
    if(L==NULL)
    {
        perror("BuyDCListNode");
        return NULL;
    }
    // 初始化时要让⾃⼰指向⾃⼰,否则就会出问题
    L->data = data;
    L->next = L;
    L->prev = L;

    return L;
}

//链表头结点初始化
DCListNode* DCListInit()
{
    DCListNode* L = BuyDCListNode(-1);
    assert(L);
    return L;
}

//销毁
void DestoryDCList(DCListNode* L)
{
    assert(L);
    //先销毁有效结点
    DCListNode* cur = L->next;
    while(cur!=L)
    {
        L->next = cur->next;
        free(cur);
        cur = L->next;
    }
    //再销毁头结点
    free(L);
}

//打印链表
void DCListPrint(DCListNode* L)
{
    assert(L);
    DCListNode* cur = L->next;
    while(cur!=L)
    {
        printf("%d->",cur->data);
        cur = cur->next;
    }
    printf("\n");
    // //从后往前打印
    // cur = L->prev;
    // while(cur!=L)
    // {
    //     printf("%d->",cur->data);
    //     cur = cur->prev;
    // }
    // printf("\n");
}

//获取链表中下标为i的结点
DCListNode* DCListGetElem(DCListNode*L,int i)
{
    assert(L);
    int j = 0;
    DCListNode* cur = L->next;
    while(cur->next!=L&&j<i)
    {
        cur = cur->next;
        j++;
    }
    //如果循环结束后j不等于i,说明i不合法
    assert(j==i);
    return cur;
}

4.3插入

pos结点之后插⼊⼀个新结点newNode

pos可以指向的任意结点(包括头结点),不需要考虑pos前⼀个或者后⼀个为空的情况。

如果是双向链表(⾮循环),要注意的是pos为尾结点时,需要考虑pos->next为空的情况。

注意: 在修改结点间的链接关系的时候,要先修改newNode ->next = pospos->next->prev = newNode,再修改newNode->prev = pospos->next = newNode。因为需要通过pos->next记录原链表的pos后面的结点。

代码演示

c 复制代码
void DCListInsert(DCListNode*pos,DCListDataType x)
{
    assert(pos);

    //创建一个新结点
    DCListNode*newNode = BuyDCListNode(x);

    //改变链接关系
    newNode->next = pos->next;
    pos->next->prev = newNode;

    pos->next = newNode;
    newNode->prev = pos;
}

4.4删除

改动两个指针链接关系即可。

pos可以指向除了头结点以外的任意结点,不需要考虑pos前⼀个或者后⼀个为空的情况。如果是双向链表(⾮循环),要注意的是pos为尾结点时,需要考虑pos->next为空的情况。

代码演示

c 复制代码
void DCListDelete(DCListNode*pos)
{
    assert(pos);
    pos->prev->next = pos->next;
    pos->next->prev = pos->prev;
    free(pos);
}

4.5头尾插入删除

对于头插尾插和头删尾删可以直接复用上面的插入和删除函数。