顺序表和链表

目录

线性表

顺序表

概念及结构

接口

[1. 顺序表初始化](#1. 顺序表初始化)

[2. 检查容量(内部辅助函数)](#2. 检查容量(内部辅助函数))

[3. 顺序表尾插](#3. 顺序表尾插)

[4. 顺序表尾删](#4. 顺序表尾删)

[5. 顺序表头插](#5. 顺序表头插)

[6. 顺序表头删](#6. 顺序表头删)

[7. 顺序表查找](#7. 顺序表查找)

[8. 顺序表在 pos 位置插入 x](#8. 顺序表在 pos 位置插入 x)

[9. 顺序表删除 pos 位置的值](#9. 顺序表删除 pos 位置的值)

[10. 顺序表销毁](#10. 顺序表销毁)

[11. 顺序表打印](#11. 顺序表打印)

总代码

顺序表的问题

链表

链表的概念及结构

链表的分类

链表的实现

接口

链表结构定义

[1. 动态申请一个结点](#1. 动态申请一个结点)

[2. 单链表打印](#2. 单链表打印)

[3. 单链表尾插](#3. 单链表尾插)

[4. 单链表头插](#4. 单链表头插)

[5. 单链表尾删](#5. 单链表尾删)

[6. 单链表头删](#6. 单链表头删)

[7. 单链表查找](#7. 单链表查找)

[8. 单链表在 pos 位置之后插入 x](#8. 单链表在 pos 位置之后插入 x)

[9. 单链表删除 pos 位置之后的值](#9. 单链表删除 pos 位置之后的值)

[10. 单链表销毁(释放整个链表)](#10. 单链表销毁(释放整个链表))

总代码

链表面试题

双向链表的实现

接口

结点结构定义

[1. 创建返回链表的头结点](#1. 创建返回链表的头结点)

[2. 双向链表销毁](#2. 双向链表销毁)

[3. 双向链表打印](#3. 双向链表打印)

[4. 双向链表尾插](#4. 双向链表尾插)

[5. 双向链表尾删](#5. 双向链表尾删)

[6. 双向链表头插](#6. 双向链表头插)

[7. 双向链表头删](#7. 双向链表头删)

[8. 双向链表查找](#8. 双向链表查找)

[9. 双向链表在 pos 的前面进行插入](#9. 双向链表在 pos 的前面进行插入)

[10. 双向链表删除pos位置的结点](#10. 双向链表删除pos位置的结点)

代码总

顺序表和链表的区别

[1. 存储空间上](#1. 存储空间上)

[2. 随机访问](#2. 随机访问)

[3. 任意位置插入或删除](#3. 任意位置插入或删除)

[4. 插入时的容量问题](#4. 插入时的容量问题)

[5. 应用场景](#5. 应用场景)

[6. 缓存利用率](#6. 缓存利用率)


线性表

线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使 用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串...

线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的, 线性表在物理上存储时,通常以数组和链式结构的形式存储。

顺序表

概念及结构

顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存 储。在数组上完成数据的增删查改。

顺序表一般可以分为:

  1. 静态顺序表:使用定长数组存储元素。
  1. 动态顺序表:使用动态开辟的数组存储。
接口

静态顺序表只适用于确定知道需要存多少数据的场景。静态顺序表的定长数组导致N定大了,空 间开多了浪费,开少了不够用。所以现实中基本都是使用动态顺序表,根据需要动态的分配空间 大小,所以下面我们实现动态顺序表。

cpp 复制代码
typedef int SLDataType;

// 顺序表的动态存储
typedef struct SeqList
{
  SLDataType* array;  // 指向动态开辟的数组
  size_t size ;       // 有效数据个数
  size_t capicity ;   // 容量空间的大小
}SeqList;

// 基本增删查改接口
// 顺序表初始化
void SeqListInit(SeqList* psl);
// 检查空间,如果满了,进行增容
void CheckCapacity(SeqList* psl);
// 顺序表尾插
void SeqListPushBack(SeqList* psl, SLDataType x);
// 顺序表尾删
void SeqListPopBack(SeqList* psl);
// 顺序表头插
void SeqListPushFront(SeqList* psl, SLDataType x);
// 顺序表头删
void SeqListPopFront(SeqList* psl);
// 顺序表查找
int SeqListFind(SeqList* psl, SLDataType x); 
// 顺序表在pos位置插入x
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x);
// 顺序表删除pos位置的值
void SeqListErase(SeqList* psl, size_t pos);
// 顺序表销毁
void SeqListDestory(SeqList* psl);
// 顺序表打印
void SeqListPrint(SeqList* psl);

1. 顺序表初始化

解释

初始化顺序表结构体,将内部指针置为 NULL,有效数据个数和容量都设为 0。调用前需确保传入的 SeqList* 指针有效。初始化后可以安全地调用其他接口。

cpp 复制代码
// 顺序表初始化
void SeqListInit(SeqList* psl)
{
    assert(psl != NULL);   // 保证指针不为空
    psl->array = NULL;     // 初始不分配内存
    psl->size = 0;         // 没有有效数据
    psl->capacity = 0;     // 容量为0
}

2. 检查容量(内部辅助函数)

解释

在插入数据前调用,如果当前有效数据个数等于容量(即已满),则进行扩容。扩容策略:若原容量为 0,则分配 4 个元素的空间;否则扩容为原来的 2 倍。使用 realloc 动态调整内存,并用临时变量接收返回值,防止扩容失败导致原指针丢失。

cpp 复制代码
// 检查空间,如果满了,进行增容
void CheckCapacity(SeqList* psl)
{
    assert(psl != NULL);
    if (psl->size == psl->capacity)          // 容量已满
    {
        size_t newCapacity = (psl->capacity == 0) ? 4 : psl->capacity * 2;
        SLDataType* tmp = (SLDataType*)realloc(psl->array, newCapacity * sizeof(SLDataType));
        if (tmp == NULL)                     // 内存分配失败处理
        {
            perror("realloc fail");
            return;
        }
        psl->array = tmp;
        psl->capacity = newCapacity;
    }
}

3. 顺序表尾插

解释

在顺序表末尾添加一个新元素。先调用 CheckCapacity 确保空间充足,然后在 array[size] 位置放入数据,最后 size 自增。时间复杂度 O(1)(均摊意义上)。

cpp 复制代码
// 顺序表尾插
void SeqListPushBack(SeqList* psl, SLDataType x)
{
    assert(psl != NULL);
    CheckCapacity(psl);                    // 保证有空间
    psl->array[psl->size] = x;            // 尾部插入
    psl->size++;                          // 有效数据个数加1
}

4. 顺序表尾删

解释

删除顺序表最后一个元素。只需要将 size 减 1 即可,逻辑上废弃了最后一个元素。物理内存不立即释放,下次插入时会直接覆盖。要求顺序表非空(size > 0)。时间复杂度 O(1)。

cpp 复制代码
// 顺序表尾删
void SeqListPopBack(SeqList* psl)
{
    assert(psl != NULL);
    assert(psl->size > 0);                // 确保有元素可删
    psl->size--;                          // 逻辑删除,原数据不再被访问
}

5. 顺序表头插

解释

在顺序表头部插入新元素。需要先将现有所有元素向后移动一位(从后往前移动,避免覆盖),然后在下标 0 处放入新值,最后 size++。时间复杂度 O(N),因为需要移动所有元素。

cpp 复制代码
// 顺序表头插
void SeqListPushFront(SeqList* psl, SLDataType x)
{
    assert(psl != NULL);
    CheckCapacity(psl);
    // 将现有元素从后向前依次后移一位,空出下标0的位置
    for (size_t i = psl->size; i > 0; i--)
    {
        psl->array[i] = psl->array[i - 1];
    }
    psl->array[0] = x;
    psl->size++;
}

6. 顺序表头删

解释

删除顺序表第一个元素。将下标 1 及之后的元素依次前移,覆盖下标 0 的位置,最后 size--。时间复杂度 O(N)。调用前需确保顺序表非空。

cpp 复制代码
// 顺序表头删
void SeqListPopFront(SeqList* psl)
{
    assert(psl != NULL);
    assert(psl->size > 0);
    // 从第二个元素开始依次前移
    for (size_t i = 0; i < psl->size - 1; i++)
    {
        psl->array[i] = psl->array[i + 1];
    }
    psl->size--;
}

7. 顺序表查找

解释

在顺序表中查找第一个值为 x 的元素,返回其下标。如果找到返回下标(int 类型),未找到返回 -1。时间复杂度 O(N)。

cpp 复制代码
// 顺序表查找(返回下标,未找到返回 -1)
int SeqListFind(SeqList* psl, SLDataType x)
{
    assert(psl != NULL);
    for (size_t i = 0; i < psl->size; i++)
    {
        if (psl->array[i] == x)
            return (int)i;            // 找到返回下标
    }
    return -1;                        // 未找到
}

8. 顺序表在 pos 位置插入 x

解释

在指定下标 pos 处插入元素 x。有效 pos 范围是 [0, size](当 pos == size 时相当于尾插)。插入点之后的元素需要依次后移,然后放入新值,最后 size++。时间复杂度 O(N)。调用前需确保空间充足。

cpp 复制代码
// 顺序表在pos位置插入x
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x)
{
    assert(psl != NULL);
    assert(pos >= 0 && pos <= psl->size);      // 允许在尾部插入
    CheckCapacity(psl);
    // 将pos及之后的元素后移
    for (size_t i = psl->size; i > pos; i--)
    {
        psl->array[i] = psl->array[i - 1];
    }
    psl->array[pos] = x;
    psl->size++;
}

9. 顺序表删除 pos 位置的值

解释

删除下标 pos 处的元素。有效 pos 必须小于 size(指向一个存在的元素)。删除后,将 pos 之后的元素依次前移覆盖被删元素,然后 size--。时间复杂度 O(N)。

cpp 复制代码
// 顺序表删除pos位置的值
void SeqListErase(SeqList* psl, size_t pos)
{
    assert(psl != NULL);
    assert(pos >= 0 && pos < psl->size);       // 必须指向有效元素
    // 将pos之后的元素前移
    for (size_t i = pos; i < psl->size - 1; i++)
    {
        psl->array[i] = psl->array[i + 1];
    }
    psl->size--;
}

10. 顺序表销毁

解释

释放动态申请的数组内存,并将指针置为 NULL,同时重置 sizecapacity 为 0。防止内存泄漏和野指针。调用后顺序表可以重新初始化再使用。

cpp 复制代码
// 顺序表销毁
void SeqListDestory(SeqList* psl)
{
    assert(psl != NULL);
    if (psl->array != NULL)
    {
        free(psl->array);
        psl->array = NULL;
    }
    psl->size = 0;
    psl->capacity = 0;
}

11. 顺序表打印

解释

遍历顺序表,将所有有效元素打印出来,便于调试和观察内容。时间复杂度 O(N)。打印格式通常为 [1, 2, 3, ...]

cpp 复制代码
// 顺序表打印
void SeqListPrint(SeqList* psl)
{
    assert(psl != NULL);
    printf("[");
    for (size_t i = 0; i < psl->size; i++)
    {
        printf("%d", psl->array[i]);
        if (i != psl->size - 1)
            printf(", ");
    }
    printf("]\n");
}
总代码
cpp 复制代码
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>

// 类型重定义,方便修改存储的数据类型
typedef int SLDataType;

// 顺序表的动态存储结构
typedef struct SeqList
{
    SLDataType* array;   // 指向动态开辟的数组
    size_t size;         // 有效数据个数
    size_t capacity;     // 当前已分配的容量(可容纳元素个数)
} SeqList;

//接口声明
void SeqListInit(SeqList* psl);
void CheckCapacity(SeqList* psl);
void SeqListPushBack(SeqList* psl, SLDataType x);
void SeqListPopBack(SeqList* psl);
void SeqListPushFront(SeqList* psl, SLDataType x);
void SeqListPopFront(SeqList* psl);
int SeqListFind(SeqList* psl, SLDataType x);
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x);
void SeqListErase(SeqList* psl, size_t pos);
void SeqListDestory(SeqList* psl);
void SeqListPrint(SeqList* psl);

//接口实现

// 1. 顺序表初始化
void SeqListInit(SeqList* psl)
{
    assert(psl != NULL);   // 保证指针不为空
    psl->array = NULL;     // 初始不分配内存
    psl->size = 0;         // 没有有效数据
    psl->capacity = 0;     // 容量为0
}

// 2. 检查容量,如果满了则扩容(内部辅助函数)
void CheckCapacity(SeqList* psl)
{
    assert(psl != NULL);
    if (psl->size == psl->capacity)          // 容量已满,需要扩容
    {
        size_t newCapacity = (psl->capacity == 0) ? 4 : psl->capacity * 2;
        SLDataType* tmp = (SLDataType*)realloc(psl->array, newCapacity * sizeof(SLDataType));
        if (tmp == NULL)                     // 内存分配失败处理
        {
            perror("realloc fail");
            return;
        }
        psl->array = tmp;
        psl->capacity = newCapacity;
    }
}

// 3. 顺序表尾插
void SeqListPushBack(SeqList* psl, SLDataType x)
{
    assert(psl != NULL);
    CheckCapacity(psl);                    // 保证有空间
    psl->array[psl->size] = x;            // 尾部插入
    psl->size++;                          // 有效数据个数加1
}

// 4. 顺序表尾删
void SeqListPopBack(SeqList* psl)
{
    assert(psl != NULL);
    assert(psl->size > 0);                // 确保有元素可删
    psl->size--;                          // 逻辑删除,原数据不再被访问
}

// 5. 顺序表头插
void SeqListPushFront(SeqList* psl, SLDataType x)
{
    assert(psl != NULL);
    CheckCapacity(psl);
    // 将现有元素从后向前依次后移一位,空出下标0的位置
    for (size_t i = psl->size; i > 0; i--)
    {
        psl->array[i] = psl->array[i - 1];
    }
    psl->array[0] = x;
    psl->size++;
}

// 6. 顺序表头删
void SeqListPopFront(SeqList* psl)
{
    assert(psl != NULL);
    assert(psl->size > 0);
    // 从第二个元素开始依次前移覆盖第一个元素
    for (size_t i = 0; i < psl->size - 1; i++)
    {
        psl->array[i] = psl->array[i + 1];
    }
    psl->size--;
}

// 7. 顺序表查找(返回下标,未找到返回-1)
int SeqListFind(SeqList* psl, SLDataType x)
{
    assert(psl != NULL);
    for (size_t i = 0; i < psl->size; i++)
    {
        if (psl->array[i] == x)
            return (int)i;            // 找到返回下标
    }
    return -1;                        // 未找到
}

// 8. 顺序表在pos位置插入x
void SeqListInsert(SeqList* psl, size_t pos, SLDataType x)
{
    assert(psl != NULL);
    assert(pos >= 0 && pos <= psl->size);      // 允许在尾部插入(pos == size)
    CheckCapacity(psl);
    // 将pos及之后的元素后移
    for (size_t i = psl->size; i > pos; i--)
    {
        psl->array[i] = psl->array[i - 1];
    }
    psl->array[pos] = x;
    psl->size++;
}

// 9. 顺序表删除pos位置的值
void SeqListErase(SeqList* psl, size_t pos)
{
    assert(psl != NULL);
    assert(pos >= 0 && pos < psl->size);       // 必须指向有效元素
    // 将pos之后的元素前移覆盖被删元素
    for (size_t i = pos; i < psl->size - 1; i++)
    {
        psl->array[i] = psl->array[i + 1];
    }
    psl->size--;
}

// 10. 顺序表销毁
void SeqListDestory(SeqList* psl)
{
    assert(psl != NULL);
    if (psl->array != NULL)
    {
        free(psl->array);
        psl->array = NULL;
    }
    psl->size = 0;
    psl->capacity = 0;
}

// 11. 顺序表打印(用于调试)
void SeqListPrint(SeqList* psl)
{
    assert(psl != NULL);
    printf("[");
    for (size_t i = 0; i < psl->size; i++)
    {
        printf("%d", psl->array[i]);
        if (i != psl->size - 1)
            printf(", ");
    }
    printf("]\n");
}

//测试用例
int main()
{
    SeqList sl;
    SeqListInit(&sl);

    printf("===== 尾插 1, 2, 3 =====\n");
    SeqListPushBack(&sl, 1);
    SeqListPushBack(&sl, 2);
    SeqListPushBack(&sl, 3);
    SeqListPrint(&sl);   //[1, 2, 3]

    printf("\n===== 头插 0 =====\n");
    SeqListPushFront(&sl, 0);
    SeqListPrint(&sl);   //[0, 1, 2, 3]

    printf("\n===== 尾删 =====\n");
    SeqListPopBack(&sl);
    SeqListPrint(&sl);   //[0, 1, 2]

    printf("\n===== 头删 =====\n");
    SeqListPopFront(&sl);
    SeqListPrint(&sl);   //[1, 2]

    printf("\n===== 在下标1处插入 99 =====\n");
    SeqListInsert(&sl, 1, 99);
    SeqListPrint(&sl);   //[1, 99, 2]

    printf("\n===== 删除下标0的元素 =====\n");
    SeqListErase(&sl, 0);
    SeqListPrint(&sl);   //[99, 2]

    printf("\n===== 查找元素 2 的位置 =====\n");
    int pos = SeqListFind(&sl, 2);
    printf("元素2的下标: %d\n", pos);   //1

    printf("\n===== 销毁顺序表 =====\n");
    SeqListDestory(&sl);
    // 销毁后再次打印应无错误(但最好不要再访问)
    printf("顺序表已销毁\n");

    return 0;
}

顺序表的问题

问题:

  1. 中间/头部的插入删除,时间复杂度为O(N)

  2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。

  3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到 200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。

如何解决以上问题呢-----链表

链表

链表的概念及结构

概念:链表是一种物理存储结构上非连续、非顺序 的存储结构,数据元素的逻辑顺序 是通过链表 中的指针链接次序实现的 。

链表的分类

实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:

  1. 单向或者双向
  1. 带头或者不带头
  1. 循环或者非循环

虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:

  1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结 构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。

  2. 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都 是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带 来很多优势,实现反而简单了

链表的实现

接口
cpp 复制代码
// 1、无头+单向+非循环链表增删查改实现
typedef int SLTDateType;
typedef struct SListNode
{
 SLTDateType data;
 struct SListNode* next;
}SListNode;

// 动态申请一个结点
SListNode* BuySListNode(SLTDateType x);
// 单链表打印
void SListPrint(SListNode* plist);
// 单链表尾插
void SListPushBack(SListNode** pplist, SLTDateType x);
// 单链表的头插
void SListPushFront(SListNode** pplist, SLTDateType x);
// 单链表的尾删
void SListPopBack(SListNode** pplist);
// 单链表头删
void SListPopFront(SListNode** pplist);
// 单链表查找
SListNode* SListFind(SListNode* plist, SLTDateType x);
// 单链表在pos位置之后插入x
// 分析思考为什么不在pos位置之前插入?
void SListInsertAfter(SListNode* pos, SLTDateType x);
// 单链表删除pos位置之后的值
// 分析思考为什么不删除pos位置?
void SListEraseAfter(SListNode* pos);
// 单链表销毁(释放所有结点)
void SListDestroy(SListNode** pplist);
链表结构定义

无头单向非循环链表:

  • 无头:没有专门的"头结点",第一个结点就是存数据的结点

  • 单向:每个结点只指向下一个结点

  • 非循环:最后一个结点的 next 指向 NULL

cpp 复制代码
typedef int SLTDateType;
typedef struct SListNode
{
    SLTDateType data;          // 结点中保存的数据
    struct SListNode* next;    // 指向下一个结点的指针
} SListNode;
1. 动态申请一个结点

解释

在堆上申请一个新的结点,将数据 x 存入 data,将 next 初始化为 NULL,并返回该结点的指针。

这是所有插入操作的基础辅助函数。时间复杂度 O(1)。

cpp 复制代码
// 动态申请一个结点
SListNode* BuySListNode(SLTDateType x)
{
    SListNode* newNode = (SListNode*)malloc(sizeof(SListNode));
    if (newNode == NULL)          // 内存分配失败检查
    {
        perror("malloc fail");
        exit(-1);
    }
    newNode->data = x;
    newNode->next = NULL;
    return newNode;
}
2. 单链表打印

解释

遍历链表,从头结点开始依次访问每个结点,打印 data 的值,直到遇到 NULL。

时间复杂度 O(N)(N 为链表结点数)。

cpp 复制代码
// 单链表打印
void SListPrint(SListNode* plist)
{
    SListNode* cur = plist;          // 从头结点开始遍历
    while (cur != NULL)
    {
        printf("%d -> ", cur->data);  // 打印当前结点数据
        cur = cur->next;              // 移动到下一个结点
    }
    printf("NULL\n");                 // 链表结束标志
}
3. 单链表尾插

解释

在链表末尾插入新结点。

需要先找到最后一个结点(next == NULL),然后将其 next 指向新结点。

特殊情况 :空链表(*pplist == NULL)时,直接让头指针指向新结点。

由于需要修改头指针(空链表时)或尾结点的 next,因此参数使用二级指针 SListNode** pplist,这样才能修改实参(外部指针变量)的值。

时间复杂度 O(N)(需要遍历找到尾结点)。

cpp 复制代码
// 单链表尾插
void SListPushBack(SListNode** pplist, SLTDateType x)
{
    assert(pplist != NULL);                // 保证二级指针有效
    SListNode* newNode = BuySListNode(x);  // 创建新结点

    if (*pplist == NULL)                   // 空链表:新结点成为头结点
    {
        *pplist = newNode;
    }
    else
    {
        // 找到尾结点(最后一个结点)
        SListNode* tail = *pplist;
        while (tail->next != NULL)
        {
            tail = tail->next;
        }
        tail->next = newNode;              // 尾结点指向新结点
    }
}
4. 单链表头插

解释

在链表头部插入新结点。

新结点的 next 指向原来的头结点,然后让头指针指向新结点。

无需遍历 ,时间复杂度 O(1)。

同样因为要修改头指针(使指向新结点),所以需要二级指针。

cpp 复制代码
// 单链表的头插
void SListPushFront(SListNode** pplist, SLTDateType x)
{
    assert(pplist != NULL);
    SListNode* newNode = BuySListNode(x);
    newNode->next = *pplist;      // 新结点指向原头结点
    *pplist = newNode;            // 头指针指向新结点
}
5. 单链表尾删

解释

删除最后一个结点。

需要找到倒数第二个结点 ,将其 next 置为 NULL,然后释放最后一个结点。

特殊情况

  • 空链表:无需操作(可以断言或直接返回)

  • 只有一个结点:释放该结点后头指针置空

    同样需要二级指针,因为头指针可能变为 NULL。

    时间复杂度 O(N)(需要遍历找到倒数第二个结点)。

cpp 复制代码
// 单链表的尾删
void SListPopBack(SListNode** pplist)
{
    assert(pplist != NULL);
    // 空链表
    if (*pplist == NULL)
        return;

    // 只有一个结点
    if ((*pplist)->next == NULL)
    {
        free(*pplist);
        *pplist = NULL;
        return;
    }

    // 找到倒数第二个结点
    SListNode* prev = *pplist;
    while (prev->next->next != NULL)
    {
        prev = prev->next;
    }
    // 释放尾结点
    free(prev->next);
    prev->next = NULL;
}
6. 单链表头删

解释

删除第一个结点。

让头指针指向第二个结点,然后释放原头结点。

时间复杂度 O(1)。

需要二级指针,因为头指针会改变。

cpp 复制代码
// 单链表头删
void SListPopFront(SListNode** pplist)
{
    assert(pplist != NULL);
    if (*pplist == NULL)          // 空链表
        return;

    SListNode* del = *pplist;     // 要删除的结点
    *pplist = (*pplist)->next;    // 头指针指向下一个结点
    free(del);                    // 释放原头结点
}
7. 单链表查找

解释

在链表中查找第一个 data == x 的结点,返回该结点的指针;若没找到,返回 NULL。

由于不修改链表头指针,所以只需一级指针。

时间复杂度 O(N)。

cpp 复制代码
// 单链表查找
SListNode* SListFind(SListNode* plist, SLTDateType x)
{
    SListNode* cur = plist;
    while (cur != NULL)
    {
        if (cur->data == x)
            return cur;          // 找到,返回结点地址
        cur = cur->next;
    }
    return NULL;                 // 未找到
}
8. 单链表在 pos 位置之后插入 x

解释

在已知结点 pos后面 插入一个新结点。

操作简单:新结点的 next 指向 pos->next,然后 pos->next 指向新结点。

时间复杂度 O(1)。

为什么不支持在 pos 之前插入?

因为单链表无法直接找到 pos 的前驱结点(除非从头遍历),而 pos 之后插入可以 O(1) 完成。如果非要支持"之前插入",则需要重新设计接口(传入头指针或使用二级指针,且必须遍历找前驱,时间复杂度 O(N))。

cpp 复制代码
// 单链表在pos位置之后插入x
void SListInsertAfter(SListNode* pos, SLTDateType x)
{
    assert(pos != NULL);                         // 保证pos有效
    SListNode* newNode = BuySListNode(x);
    newNode->next = pos->next;
    pos->next = newNode;
}
9. 单链表删除 pos 位置之后的值

解释

删除结点 pos 后面紧邻的那个结点。

需要判断 pos->next 不为空,然后保存待删除结点,让 pos->next 指向待删除结点的 next,最后释放。

时间复杂度 O(1)。

为什么不直接删除 pos 本身?

因为删除 pos 需要知道它的前驱,单链表无法直接获得前驱。如果非要删除 pos 本身,可以修改数据(把后一个结点的数据拷贝过来再删除后一个),但那样会破坏地址语义。通常这类接口设计成"删除给定位置之后"以避免遍历。更通用的删除任意位置需要接收头指针并在函数内查找前驱。

cpp 复制代码
// 单链表删除pos位置之后的值
void SListEraseAfter(SListNode* pos)
{
    assert(pos != NULL);
    if (pos->next == NULL)          // 后面没有结点可删
        return;

    SListNode* del = pos->next;     // 要删除的结点
    pos->next = del->next;          // 跳过del
    free(del);
}
10. 单链表销毁(释放整个链表)

解释

链表使用完毕后,必须手动释放所有动态申请的结点,否则会造成内存泄漏。

本函数遍历整个链表,依次 free 每个结点,最后将头指针置为 NULL

因为需要修改外部的头指针(使其指向 NULL),所以参数使用二级指针 SListNode** pplist

时间复杂度 O(N)(N 为结点个数)。

非常重要:在程序结束前或不再使用链表时务必调用此函数。

cpp 复制代码
// 单链表销毁(释放所有结点)
void SListDestroy(SListNode** pplist)
{
    assert(pplist != NULL);          // 保证二级指针有效
    SListNode* cur = *pplist;        // 从头结点开始
    while (cur != NULL)
    {
        SListNode* next = cur->next; // 先保存下一个结点的地址
        free(cur);                   // 释放当前结点
        cur = next;                  // 移动到下一个结点
    }
    *pplist = NULL;                  // 头指针置空,防止野指针
}
总代码
cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int SLTDateType;

typedef struct SListNode
{
    SLTDateType data;
    struct SListNode* next;
} SListNode;

// 接口声明
SListNode* BuySListNode(SLTDateType x);
void SListPrint(SListNode* plist);
void SListPushBack(SListNode** pplist, SLTDateType x);
void SListPushFront(SListNode** pplist, SLTDateType x);
void SListPopBack(SListNode** pplist);
void SListPopFront(SListNode** pplist);
SListNode* SListFind(SListNode* plist, SLTDateType x);
void SListInsertAfter(SListNode* pos, SLTDateType x);
void SListEraseAfter(SListNode* pos);
void SListDestroy(SListNode** pplist);

// 1. 动态申请一个结点
SListNode* BuySListNode(SLTDateType x)
{
    SListNode* newNode = (SListNode*)malloc(sizeof(SListNode));
    if (newNode == NULL)
    {
        perror("malloc fail");
        exit(-1);
    }
    newNode->data = x;
    newNode->next = NULL;
    return newNode;
}

// 2. 单链表打印
void SListPrint(SListNode* plist)
{
    SListNode* cur = plist;
    while (cur != NULL)
    {
        printf("%d -> ", cur->data);
        cur = cur->next;
    }
    printf("NULL\n");
}

// 3. 单链表尾插
void SListPushBack(SListNode** pplist, SLTDateType x)
{
    assert(pplist != NULL);
    SListNode* newNode = BuySListNode(x);
    if (*pplist == NULL)
    {
        *pplist = newNode;
    }
    else
    {
        SListNode* tail = *pplist;
        while (tail->next != NULL)
            tail = tail->next;
        tail->next = newNode;
    }
}

// 4. 单链表头插
void SListPushFront(SListNode** pplist, SLTDateType x)
{
    assert(pplist != NULL);
    SListNode* newNode = BuySListNode(x);
    newNode->next = *pplist;
    *pplist = newNode;
}

// 5. 单链表尾删
void SListPopBack(SListNode** pplist)
{
    assert(pplist != NULL);
    if (*pplist == NULL)
        return;
    if ((*pplist)->next == NULL)
    {
        free(*pplist);
        *pplist = NULL;
        return;
    }
    SListNode* prev = *pplist;
    while (prev->next->next != NULL)
        prev = prev->next;
    free(prev->next);
    prev->next = NULL;
}

// 6. 单链表头删
void SListPopFront(SListNode** pplist)
{
    assert(pplist != NULL);
    if (*pplist == NULL)
        return;
    SListNode* del = *pplist;
    *pplist = (*pplist)->next;
    free(del);
}

// 7. 单链表查找
SListNode* SListFind(SListNode* plist, SLTDateType x)
{
    SListNode* cur = plist;
    while (cur != NULL)
    {
        if (cur->data == x)
            return cur;
        cur = cur->next;
    }
    return NULL;
}

// 8. 单链表在pos位置之后插入x
void SListInsertAfter(SListNode* pos, SLTDateType x)
{
    assert(pos != NULL);
    SListNode* newNode = BuySListNode(x);
    newNode->next = pos->next;
    pos->next = newNode;
}

// 9. 单链表删除pos位置之后的值
void SListEraseAfter(SListNode* pos)
{
    assert(pos != NULL);
    if (pos->next == NULL)
        return;
    SListNode* del = pos->next;
    pos->next = del->next;
    free(del);
}

// 10. 单链表销毁
void SListDestroy(SListNode** pplist)
{
    assert(pplist != NULL);
    SListNode* cur = *pplist;
    while (cur != NULL)
    {
        SListNode* next = cur->next;
        free(cur);
        cur = next;
    }
    *pplist = NULL;
}

// 测试
int main()
{
    SListNode* plist = NULL;

    printf("尾插 1, 2, 3\n");
    SListPushBack(&plist, 1);
    SListPushBack(&plist, 2);
    SListPushBack(&plist, 3);
    SListPrint(plist);   // 1 -> 2 -> 3 -> NULL

    printf("\n头插 0\n");
    SListPushFront(&plist, 0);
    SListPrint(plist);   // 0 -> 1 -> 2 -> 3 -> NULL

    printf("\n尾删\n");
    SListPopBack(&plist);
    SListPrint(plist);   // 0 -> 1 -> 2 -> NULL

    printf("\n头删\n");
    SListPopFront(&plist);
    SListPrint(plist);   // 1 -> 2 -> NULL

    printf("\n查找元素 2\n");
    SListNode* pos = SListFind(plist, 2);
    if (pos)
        printf("找到了结点,地址:%p,数据:%d\n", (void*)pos, pos->data);
    else
        printf("未找到\n");

    printf("\n在找到的结点之后插入 99\n");
    SListInsertAfter(pos, 99);
    SListPrint(plist);   // 1 -> 2 -> 99 -> NULL

    printf("\n删除找到结点之后的结点(即删除 99)\n");
    SListEraseAfter(pos);
    SListPrint(plist);   // 1 -> 2 -> NULL

    printf("\n销毁链表\n");
    SListDestroy(&plist);
    printf("链表已销毁,plist = %p\n", (void*)plist);  // NULL

    return 0;
}

链表面试题

  1. 删除链表中等于给定值 val 的所有结点。203. 移除链表元素 - 力扣(LeetCode)

  2. 反转一个单链表。 206. 反转链表 - 力扣(LeetCode)

  3. 给定一个带有头结点 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则 返回第二个中间结点。876. 链表的中间结点 - 力扣(LeetCode)

  4. 输入一个链表,输出该链表中倒数第k个结点。 链表中倒数第k个结点_牛客题霸_牛客网

  5. 将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有 结点组成的。21. 合并两个有序链表 - 力扣(LeetCode)

  6. 编写代码,以给定值x为基准将链表分割成两部分,所有小于x的结点排在大于或等于x的结 点之前 。链表分割_牛客题霸_牛客网

  7. 链表的回文结构。链表的回文结构_牛客题霸_牛客网

  8. 输入两个链表,找出它们的第一个公共结点。160. 相交链表 - 力扣(LeetCode)

  1. 给定一个链表,判断链表中是否有环。 OJ链接

思路:

快慢指针,即慢指针一次走一步,快指针一次走两步,两个指针从链表其实位置开始运行, 如果链表 带环则一定会在环中相遇,否则快指针率先走到链表的末尾。

扩展: 为什么快指针每次走两步,慢指针走一步可以?

假设链表带环,两个指针最后都会进入环,快指针先进环,慢指针后进环。当慢指针刚 进环时,可能就和快指针相遇了,最差情况下两个指针之间的距离刚好就是环的长度。 此时,两个指针每移动一次,之间的距离就缩小一步,不会出现每次刚好是套圈的情 况,因此:在满指针走到一圈之前,快指针肯定是可以追上慢指针的,即相遇。

快指针一次走3步,走4步,...n步行吗?

  1. 给定一个链表,返回链表开始入环的第一个结点。 如果链表无环,则返回 NULL 142. 环形链表 II - 力扣(LeetCode)

结论

让一个指针从链表起始位置开始遍历链表,同时让一个指针从判环时相遇点的位置开始绕环 运行,两个指针都是每次均走一步,最终肯定会在入口点的位置相遇。

证明

  1. 给定一个链表,每个结点包含一个额外增加的随机指针,该指针可以指向链表中的任何结点 或空结点。 要求返回这个链表的深度拷贝。138. 随机链表的复制 - 力扣(LeetCode)

  2. 其他 。ps:链表的题 题单 - 力扣(LeetCode)全球极客挚爱的技术成长平台 + 牛客 牛客网在线编程_算法笔面试篇_面试TOP101

双向链表的实现

为什么带头双向循环链表不需要二级指针?

因为头结点固定存在且地址不变。所有操作都是通过头结点指针来修改结点之间的链接,不需要改变外部头指针本身。因此参数只需一级指针 ListNode* plist

带头双向循环链表的特点

  • 带头 :有一个固定的头结点(不存数据),它的 next 指向第一个实际结点,prev 指向最后一个实际结点。

  • 双向 :每个结点有 prevnext 指针,可以向前或向后遍历。

  • 循环 :最后一个结点的 next 指向头结点,头结点的 prev 指向最后一个结点。

优点:插入删除(已知位置)时间复杂度 O(1),无需考虑头指针修改(因为头结点固定)。

接口
cpp 复制代码
// 2、带头+双向+循环链表增删查改实现
typedef int LTDataType;
typedef struct ListNode
{
 LTDataType _data;
 struct ListNode* next;
 struct ListNode* prev;
}ListNode;

// 创建返回链表的头结点.
ListNode* ListCreate();
// 双向链表销毁
void ListDestory(ListNode* plist);
// 双向链表打印
void ListPrint(ListNode* plist);
// 双向链表尾插
void ListPushBack(ListNode* plist, LTDataType x);
// 双向链表尾删
void ListPopBack(ListNode* plist);
// 双向链表头插
void ListPushFront(ListNode* plist, LTDataType x);
// 双向链表头删
void ListPopFront(ListNode* plist);
// 双向链表查找
ListNode* ListFind(ListNode* plist, LTDataType x);
// 双向链表在pos的前面进行插入
void ListInsert(ListNode* pos, LTDataType x);
// 双向链表删除pos位置的结点
void ListErase(ListNode* pos);

结点结构定义

cpp 复制代码
typedef int LTDataType;
typedef struct ListNode
{
    LTDataType _data;          // 结点数据
    struct ListNode* next;     // 指向下一个结点
    struct ListNode* prev;     // 指向前一个结点
} ListNode;

1. 创建返回链表的头结点

解释

创建一个头结点(哨兵位),它的 nextprev 都指向自己(空循环状态)。

该头结点不存储有效数据,之后所有操作都基于这个头结点。

时间复杂度 O(1)。

cpp 复制代码
// 创建返回链表的头结点
ListNode* ListCreate()
{
    ListNode* phead = (ListNode*)malloc(sizeof(ListNode));
    if (phead == NULL)
    {
        perror("malloc fail");
        exit(-1);
    }
    phead->next = phead;   // 初始时自己指向自己
    phead->prev = phead;
    // 头结点的 _data 可以不用初始化(不使用)
    return phead;
}

2. 双向链表销毁

解释

释放所有实际结点(除了头结点),最后释放头结点并置空。

注意:头结点也需要释放,否则内存泄漏。

需要遍历链表,依次释放。由于链表是循环的,需要判断何时回到头结点。

时间复杂度 O(N)。

cpp 复制代码
// 双向链表销毁
void ListDestory(ListNode* plist)
{
    assert(plist != NULL);
    ListNode* cur = plist->next;   // 从第一个实际结点开始
    while (cur != plist)           // 未回到头结点
    {
        ListNode* next = cur->next;
        free(cur);
        cur = next;
    }
    free(plist);                   // 释放头结点
    plist = NULL;                  // 调用方需将外部指针置空
}

3. 双向链表打印

解释

遍历链表,打印每个实际结点的数据(不打印头结点)。

从头结点的下一个开始,直到回到头结点为止。

时间复杂度 O(N)。

cpp 复制代码
// 双向链表打印
void ListPrint(ListNode* plist)
{
    assert(plist != NULL);
    printf("head<->");
    ListNode* cur = plist->next;
    while (cur != plist)
    {
        printf("%d<->", cur->_data);
        cur = cur->next;
    }
    printf("head\n");   // 表示循环回到头结点
}

4. 双向链表尾插

解释

在链表的尾部(头结点的 prev 位置)插入新结点。

因为循环双向,尾结点就是 plist->prev

新结点与原来的尾结点和头结点建立双向链接。

时间复杂度 O(1)(不需要遍历)。

cpp 复制代码
// 双向链表尾插
void ListPushBack(ListNode* plist, LTDataType x)
{
    assert(plist != NULL);
    ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
    if (newNode == NULL)
    {
        perror("malloc fail");
        return;
    }
    newNode->_data = x;

    ListNode* tail = plist->prev;   // 原来的尾结点
    // 链接顺序:tail <-> newNode <-> plist
    tail->next = newNode;
    newNode->prev = tail;
    newNode->next = plist;
    plist->prev = newNode;
}

5. 双向链表尾删

解释

删除最后一个实际结点。

先判断链表是否为空(plist->next == plist 表示只有头结点)。

找到尾结点(plist->prev),将其前驱结点的 next 指向头结点,头结点的 prev 指向该前驱,然后释放尾结点。

时间复杂度 O(1)。

cpp 复制代码
// 双向链表尾删
void ListPopBack(ListNode* plist)
{
    assert(plist != NULL);
    assert(plist->next != plist);   // 链表非空(至少有一个实际结点)

    ListNode* tail = plist->prev;    // 尾结点
    ListNode* tailPrev = tail->prev; // 尾结点的前一个结点
    tailPrev->next = plist;
    plist->prev = tailPrev;
    free(tail);
}

6. 双向链表头插

解释

在链表头部(头结点之后)插入新结点。

新结点插入在 plistplist->next 之间。

时间复杂度 O(1)。

cpp 复制代码
// 双向链表头插
void ListPushFront(ListNode* plist, LTDataType x)
{
    assert(plist != NULL);
    ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
    if (newNode == NULL)
    {
        perror("malloc fail");
        return;
    }
    newNode->_data = x;

    ListNode* first = plist->next;   // 原来的第一个结点
    // 链接顺序:plist <-> newNode <-> first
    plist->next = newNode;
    newNode->prev = plist;
    newNode->next = first;
    first->prev = newNode;
}

7. 双向链表头删

解释

删除第一个实际结点(即 plist->next)。

先检查链表非空。然后让头结点的 next 指向第二个结点,第二个结点的 prev 指向头结点,释放第一个结点。

时间复杂度 O(1)。

cpp 复制代码
// 双向链表头删
void ListPopFront(ListNode* plist)
{
    assert(plist != NULL);
    assert(plist->next != plist);   // 非空

    ListNode* del = plist->next;     // 要删除的第一个结点
    ListNode* second = del->next;    // 第二个结点
    plist->next = second;
    second->prev = plist;
    free(del);
}

8. 双向链表查找

解释

在链表中查找第一个数据等于 x 的结点,返回其指针;若未找到返回 NULL

时间复杂度 O(N)。

cpp 复制代码
// 双向链表查找
ListNode* ListFind(ListNode* plist, LTDataType x)
{
    assert(plist != NULL);
    ListNode* cur = plist->next;
    while (cur != plist)
    {
        if (cur->_data == x)
            return cur;
        cur = cur->next;
    }
    return NULL;
}

9. 双向链表在 pos 的前面进行插入

解释

在已知结点 pos 的前面插入新结点。由于双向链表可以方便地找到前驱,所以直接操作。

插入位置在 pos->prevpos 之间。

时间复杂度 O(1)。

注意pos 通常是 ListFind 返回的结点,pos 可以是头结点吗?理论上头结点不存数据,但允许在头结点前插入相当于尾插?通常约定 pos 为实际结点,但若用户误传头结点,也可处理(头结点前插入即头插)。严谨实现可以允许 pos == plist,此时相当于尾插(因为头结点的前面就是尾结点)。但一般要求 pos 是链表中有效结点(包括头结点也可,按需求定)。这里按常规:pos 是实际结点或头结点均可,函数能正确处理。

cpp 复制代码
// 双向链表在pos的前面进行插入
void ListInsert(ListNode* pos, LTDataType x)
{
    assert(pos != NULL);
    ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
    if (newNode == NULL)
    {
        perror("malloc fail");
        return;
    }
    newNode->_data = x;

    ListNode* prev = pos->prev;
    // 链接:prev <-> newNode <-> pos
    prev->next = newNode;
    newNode->prev = prev;
    newNode->next = pos;
    pos->prev = newNode;
}

10. 双向链表删除pos位置的结点

解释

删除已知结点 pos。需要保证 pos 不是头结点(因为头结点不存数据,一般不删除头结点)。

pos 的前驱和后继直接链接,然后释放 pos

时间复杂度 O(1)。

注意 :调用前应确保 pos != plist(不删除头结点)。若删除头结点会导致链表结构破坏,通常约定不允许删除头结点。

cpp 复制代码
// 双向链表删除pos位置的结点
void ListErase(ListNode* pos)
{
    assert(pos != NULL);
    // 通常不允许删除头结点,可以加断言
    // 若允许删除头结点需要特殊处理,但一般不会这样做
    assert(pos->next != pos && pos->prev != pos); // 简单检查,避免删除头结点

    ListNode* prev = pos->prev;
    ListNode* next = pos->next;
    prev->next = next;
    next->prev = prev;
    free(pos);
}

代码总

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

typedef int LTDataType;
typedef struct ListNode
{
    LTDataType _data;
    struct ListNode* next;
    struct ListNode* prev;
} ListNode;

// 接口声明
ListNode* ListCreate();
void ListDestory(ListNode* plist);
void ListPrint(ListNode* plist);
void ListPushBack(ListNode* plist, LTDataType x);
void ListPopBack(ListNode* plist);
void ListPushFront(ListNode* plist, LTDataType x);
void ListPopFront(ListNode* plist);
ListNode* ListFind(ListNode* plist, LTDataType x);
void ListInsert(ListNode* pos, LTDataType x);
void ListErase(ListNode* pos);

// 1. 创建头结点
ListNode* ListCreate()
{
    ListNode* phead = (ListNode*)malloc(sizeof(ListNode));
    if (phead == NULL)
    {
        perror("malloc fail");
        exit(-1);
    }
    phead->next = phead;
    phead->prev = phead;
    return phead;
}

// 2. 销毁链表
void ListDestory(ListNode* plist)
{
    assert(plist != NULL);
    ListNode* cur = plist->next;
    while (cur != plist)
    {
        ListNode* next = cur->next;
        free(cur);
        cur = next;
    }
    free(plist);
}

// 3. 打印链表
void ListPrint(ListNode* plist)
{
    assert(plist != NULL);
    printf("head<->");
    ListNode* cur = plist->next;
    while (cur != plist)
    {
        printf("%d<->", cur->_data);
        cur = cur->next;
    }
    printf("head\n");
}

// 4. 尾插
void ListPushBack(ListNode* plist, LTDataType x)
{
    assert(plist != NULL);
    ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
    if (newNode == NULL)
    {
        perror("malloc fail");
        return;
    }
    newNode->_data = x;

    ListNode* tail = plist->prev;
    tail->next = newNode;
    newNode->prev = tail;
    newNode->next = plist;
    plist->prev = newNode;
}

// 5. 尾删
void ListPopBack(ListNode* plist)
{
    assert(plist != NULL);
    assert(plist->next != plist);

    ListNode* tail = plist->prev;
    ListNode* tailPrev = tail->prev;
    tailPrev->next = plist;
    plist->prev = tailPrev;
    free(tail);
}

// 6. 头插
void ListPushFront(ListNode* plist, LTDataType x)
{
    assert(plist != NULL);
    ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
    if (newNode == NULL)
    {
        perror("malloc fail");
        return;
    }
    newNode->_data = x;

    ListNode* first = plist->next;
    plist->next = newNode;
    newNode->prev = plist;
    newNode->next = first;
    first->prev = newNode;
}

// 7. 头删
void ListPopFront(ListNode* plist)
{
    assert(plist != NULL);
    assert(plist->next != plist);

    ListNode* del = plist->next;
    ListNode* second = del->next;
    plist->next = second;
    second->prev = plist;
    free(del);
}

// 8. 查找
ListNode* ListFind(ListNode* plist, LTDataType x)
{
    assert(plist != NULL);
    ListNode* cur = plist->next;
    while (cur != plist)
    {
        if (cur->_data == x)
            return cur;
        cur = cur->next;
    }
    return NULL;
}

// 9. 在pos前面插入
void ListInsert(ListNode* pos, LTDataType x)
{
    assert(pos != NULL);
    ListNode* newNode = (ListNode*)malloc(sizeof(ListNode));
    if (newNode == NULL)
    {
        perror("malloc fail");
        return;
    }
    newNode->_data = x;

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

// 10. 删除pos结点
void ListErase(ListNode* pos)
{
    assert(pos != NULL);
    // 防止删除头结点(通常不允许)
    assert(pos->next != pos && pos->prev != pos);
    ListNode* prev = pos->prev;
    ListNode* next = pos->next;
    prev->next = next;
    next->prev = prev;
    free(pos);
}

// 测试
int main()
{
    ListNode* plist = ListCreate();

    printf("尾插 1, 2, 3\n");
    ListPushBack(plist, 1);
    ListPushBack(plist, 2);
    ListPushBack(plist, 3);
    ListPrint(plist);   // head<->1<->2<->3<->head

    printf("\n头插 0\n");
    ListPushFront(plist, 0);
    ListPrint(plist);   // head<->0<->1<->2<->3<->head

    printf("\n尾删\n");
    ListPopBack(plist);
    ListPrint(plist);   // head<->0<->1<->2<->head

    printf("\n头删\n");
    ListPopFront(plist);
    ListPrint(plist);   // head<->1<->2<->head

    printf("\n查找元素 2\n");
    ListNode* pos = ListFind(plist, 2);
    if (pos)
        printf("找到了结点,地址:%p,数据:%d\n", (void*)pos, pos->_data);
    else
        printf("未找到\n");

    printf("\n在找到的结点之前插入 99\n");
    ListInsert(pos, 99);
    ListPrint(plist);   // head<->1<->99<->2<->head

    printf("\n删除找到的结点(即删除 2)\n");
    ListErase(pos);
    ListPrint(plist);   // head<->1<->99<->head

    printf("\n销毁链表\n");
    ListDestory(plist);
    printf("链表已销毁\n");

    return 0;
}

顺序表和链表的区别

不同点 顺序表 链表
存储空间 物理连续 逻辑连续,物理随机
随机访问 O(1) O(N)
插入/删除(已知位置) O(N) 移动元素 O(1) 改指针
容量 需预分配/扩容 无容量概念,动态申请
应用场景 频繁访问、较少插入删除 频繁插入删除、数据量动态
缓存利用率 高(空间局部性好) 低(结点分散,易 cache miss)

1. 存储空间上

顺序表 :物理上一定连续。

即它在内存中占据一块连续的空间(如数组)。可以通过下标直接计算地址:address = 起始地址 + i * 元素大小

优点:支持随机访问,CPU 缓存友好。

缺点:扩容时需要重新分配一块更大的连续空间并拷贝数据,可能产生内存碎片。

链表 :逻辑上连续,但物理上不一定连续。

每个结点单独在堆上分配,地址随机。结点通过指针链接成序列。

优点:没有容量限制(只要有内存),插入删除不需要移动数据。

缺点:无法随机访问,只能顺序遍历;每个结点额外存储指针,有空间开销。

2. 随机访问

顺序表 :支持 O(1) 随机访问。

给定下标 i,直接用 array[i] 访问,效率极高。

适用于频繁按位置访问的场景(如二分查找、排序等)。

链表 :不支持随机访问,访问第 i 个元素需要从头遍历 O(N)。

因此不适用于需要频繁按位置查找的场景。

3. 任意位置插入或删除

顺序表 :需要搬移元素,时间复杂度 O(N)。

例如在中间插入一个元素,需要将该位置及后面的所有元素后移;删除时需要前移。

动态顺序表在尾部插入是 O(1)(均摊),但中间或头部的操作代价较高。

链表 :只需修改指针指向,时间复杂度 O(1)(前提是已经知道插入/删除位置的前驱)。

但查找该位置仍需 O(N)。所以"任意位置"的实际总开销可能仍是 O(N),但操作本身(指针修改)是常数时间。

4. 插入时的容量问题

顺序表 :动态顺序表在插入前需要检查容量,若 size == capacity 则必须扩容。

扩容通常申请原来 2 倍的新空间,将原数据拷贝过去,再释放旧空间。

扩容操作可能耗费较多时间(尤其是大数据量时),且可能产生内存碎片。

链表 :没有"容量"概念,每次插入新结点时动态申请内存。

只要系统还有可用堆内存,就能继续插入。

因此链表更适合数据量不确定、频繁动态增长的场景。

5. 应用场景

顺序表

适合 元素高效存储 + 频繁随机访问 的场景。

例如:静态数据存储、排序后的数据、需要频繁按下标访问的数组。

链表

适合 任意位置频繁插入和删除 的场景。

例如:实现队列(头删尾插)、LRU 缓存淘汰算法、需要动态拼接拆分的场景。

6. 缓存利用率

什么是缓存利用率?

CPU 访问内存的速度远低于 CPU 本身的速度,因此现代 CPU 有多级缓存(L1/L2/L3)。缓存会一次性加载一块连续内存(一个缓存行,通常 64 字节)。如果程序访问的数据在内存中是连续的,那么一次缓存加载可以满足后续多次访问,命中率高,程序快。反之,如果数据分散,每次访问都可能触发缓存未命中,需要从慢速内存读取,效率低。

顺序表

物理连续 → 遍历或随机访问时,相邻元素大概率在同一缓存行。

例如访问 array[0] 后,紧接着的 array[1] 已经在缓存中,访问极快。

缓存利用率高

链表

结点在内存中随机分布 → 即使逻辑上相邻的结点,物理地址可能相差很远。

遍历链表时,每次 cur = cur->next 都可能跳到一个新的、不在缓存中的地址,导致缓存未命中。

缓存利用率低,链表遍历速度远慢于数组遍历(相同数据量下)。

局部性原理

时间局部性:刚访问过的数据可能很快再次访问。

空间局部性 :访问某个地址后,其附近的地址也可能被访问。

顺序表天然具有很好的空间局部性;链表则几乎不具备空间局部性(结点地址随机)。

结论:顺序表的缓存友好性远优于链表。因此虽然链表某些操作的理论复杂度更优(O(1) 插入删除),但在实际现代计算机中,因为缓存的作用,顺序表在多数情况下整体性能可能更好,尤其是遍历和随机访问为主的场景。

相关推荐
牛油果子哥q1 小时前
二叉树(Binary Tree)零基础精讲,树基础概念、树形分类、核心性质、递归/层序遍历、完整代码与面试考点全解
c++·面试·数据挖掘
小糯米6011 小时前
C语言文件操作
c语言·开发语言·数据结构
一切皆是因缘际会1 小时前
神经符号融合智能体
大数据·数据结构·人工智能·ai
玖玥拾2 小时前
C/C++ 数据结构(四)链表与STL容器
c语言·数据结构·c++·链表·stl库
不吃土豆的马铃薯2 小时前
C++ 正则表达式入门详解
linux·服务器·网络·数据库·c++·正则表达式
满怀冰雪2 小时前
第15篇-链表基础-反转链表-合并链表与快慢指针
java·算法·链表
玖玥拾2 小时前
C/C++ 数据结构(一)基础概念、线性表链表
c语言·数据结构·c++·链表
星恒随风2 小时前
C++ 模板初阶:从泛型编程、函数模板到类模板,一篇打通基础概念
开发语言·c++·笔记·学习
芋只因2 小时前
力扣100题解(Java版)
数据结构