顺序表和链表_精华知识点_CSDN博客

【数据结构入门】顺序表和链表,一篇讲透(超详细图解 + 完整代码)

适合人群:刚学数据结构、被"顺序表/链表"绕晕的初学者。全文用大白话 + 生活类比讲解,代码可直接编译运行。

目录

  • 一、先搞懂:什么是线性表
  • [二、线性表的两种实现:顺序存储 vs 链式存储](#二、线性表的两种实现:顺序存储 vs 链式存储)
  • [三、先补一个小知识:C++ 的"引用"](#三、先补一个小知识:C++ 的"引用")
  • [四、顺序表(Sequential List)](#四、顺序表(Sequential List))
  • [五、链表(Linked List)](#五、链表(Linked List))
  • [六、顺序表 vs 链表:到底该用哪个?](#六、顺序表 vs 链表:到底该用哪个?)
  • [七、面试/408 高频考点速查表](#七、面试/408 高频考点速查表)
  • 八、写在最后

一、先搞懂:什么是线性表

线性表(linear list)就是:n 个"同类型"数据元素排成的一条"队"。

形式化一点写:

复制代码
L = (a1, a2, a3, ..., an)     n >= 0

拆开看这几个点,考试特别爱考:

概念 含义
n 表的长度。n = 0 时叫空表
a1 表头元素,它没有前驱
an 表尾元素,它没有后继
其他元素 都有唯一 的前驱和唯一的后继
i 元素 ai 的位序

⚠️ 初学最容易踩的坑:位序 ≠ 数组下标

  • 位序 :从 1 开始数(第 1 个、第 2 个......)------ 教材、408 真题都用位序
  • 数组下标 :从 0 开始数 ------ C/C++ 代码里用

所以"第 i 个位置"在代码里通常是 arr[i-1]。做题时一定要看清题目问的是位序还是下标,这是丢分重灾区。

线性表在"数据结构三要素"里的位置

数据结构三要素 = 逻辑结构 + 物理结构(存储结构)+ 数据运算。

  • 线性表的定义 → 讲的是逻辑结构
  • 增删改查这些运算的定义 → 针对逻辑结构
  • 运算的具体实现 → 必须落到某种存储结构上

于是就有了一句话总结:

用顺序存储实现线性表 = 顺序表;用链式存储实现线性表 = 链表。


二、线性表的两种实现:顺序存储 vs 链式存储

顺序存储

把逻辑上相邻的元素,存放在一段连续的物理存储单元里。逻辑关系由物理位置关系直接体现。

大白话:像电影院的连号座位,第 3 个和第 4 个人就是挨着坐的,找人也方便(座位号一算就到)。

链式存储

逻辑上相邻的元素,存放在任意的一组物理存储单元 中,元素之间的逻辑关系用指针表示。

大白话:像寻宝游戏,每张纸条上写着"下一个线索在哪儿",纸条本身可以散落在任何地方。

用一张图理解

假设线性表 L = ("张三", "李四", "王五", "赵六"):

复制代码
顺序存储(挤在一起):
┌────────┬────────┬────────┬────────┐
│  张三  │  李四  │  王五  │  赵六  │     地址连续
└────────┴────────┴────────┴────────┘
  1000     1004     1008     1012

链式存储(散落各处,靠指针牵着):
┌────────┬─────┐     ┌────────┬─────┐     ┌────────┬─────┐     ┌────────┬──────┐
│  张三  │  ●──┼────▶│  李四  │  ●──┼────▶│  王五  │  ●──┼────▶│  赵六  │ NULL │
└────────┴─────┘     └────────┴─────┘     └────────┴─────┘     └────────┴──────┘
  地址 2000            地址 512             地址 9000            地址 300

一句话记住区别:

顺序表:逻辑上相邻,物理上也相邻。

链表:逻辑上相邻,物理上不一定相邻。


三、先补一个小知识:C++ 的"引用"

严蔚敏《数据结构(C 语言版)》里掺了一些 C++ 的引用语法(&),学校教材也爱这么写。不懂它,看书上的函数原型就会懵。

引用是什么?

引用不是新定义一个变量,而是给已存在的变量取了个"别名"。 编译器不会为引用单独开内存,它和被引用的变量共用同一块内存。

就像《水浒传》里:李逵,宋江叫他"铁牛",江湖上人称"黑旋风"------三个名字,同一个人。

cpp 复制代码
int a = 10;
int& ra = a;      // ra 是 a 的别名
ra = 20;          // 改 ra 就是改 a
printf("%d %d", a, ra);   // 输出 20 20

引用的三条铁律

  1. 定义时必须初始化
  2. 一个变量可以有多个引用
  3. 引用一旦引用了某个实体,就不能再改去引用别人
cpp 复制代码
int& r;        // ❌ 编译报错:引用必须初始化
int a = 1, b = 2;
int& r = a;
r = b;         // ⚠️ 这不是让 r 改指 b,而是把 b 的值赋给 a!

传值 / 传指针 / 传引用 对比

传参方式 形参是什么 能否改变实参 大对象有额外拷贝开销吗
传值 实参的拷贝 ❌ 不能 有
传指针 实参的地址 ✅ 能 无
传引用 实参的别名 ✅ 能 无
cpp 复制代码
void Swap1(int x, int y)   { int t = x; x = y; y = t; }   // ❌ 改不了实参
void Swap2(int* x, int* y) { int t = *x; *x = *y; *y = t; } // ✅ 可以
void Swap3(int& x, int& y) { int t = x; x = y; y = t; }     // ✅ 可以,写法更清爽

为什么要讲这个? 因为后面链表初始化时要"修改调用方的头指针"。教材里常见两种写法:

  • 用 void ListInit(SLNode** pphead) ------ 二级指针(纯 C 做法,本教程用这个)
  • 用 void ListInit(SLNode*& phead) ------ 指针的引用(C++ 做法,更省事)

小贴士:想用引用语法,把 .c 文件改名成 .cpp 就行。C++ 兼容 C,但反过来不行。


四、顺序表(Sequential List)

4.1 静态顺序表 vs 动态顺序表

静态顺序表:用固定大小的数组存数据。

c 复制代码
#define N 100          // 写死容量,方便改类型/容量
typedef int SLDataType; // 方便以后把 int 换成别的类型

typedef struct SeqList {
    SLDataType a[N];   // 固定大小的静态数组
    int size;          // 已经存了多少个有效数据
} SL;
  • ✅ 优点:实现简单
  • ❌ 缺点:容量写死了。开小了不够用,开大了浪费

动态顺序表 :用一个堆上动态申请的数组,不够了就扩容。

c 复制代码
typedef struct SeqList {
    SLDataType* a;     // 指向动态申请的数组
    int size;          // 有效数据个数
    int capacity;      // 当前容量(能装多少个)
} SL;
  • ✅ 优点:适用场景广,不确定存多少数据也不怕
  • ❌ 缺点:实现稍复杂

实际开发一律用动态顺序表。 静态的会了,动态的就是"手拿把掐"。

4.2 完整接口实现(可直接编译运行)

SeqList.h:

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

typedef int SLDataType;

typedef struct SeqList {
    SLDataType* a;
    int size;
    int capacity;
} SL;

void SLInit(SL* ps);                                        // 初始化
void SLDestroy(SL* ps);                                     // 销毁
void SLPrint(SL* ps);                                       // 打印
void SLCheckCapacity(SL* ps);                               // 检查并扩容
void SLPushBack(SL* ps, SLDataType x);                      // 尾插
void SLPushFront(SL* ps, SLDataType x);                     // 头插
void SLPopBack(SL* ps);                                     // 尾删
void SLPopFront(SL* ps);                                    // 头删
void SLInsert(SL* ps, int pos, SLDataType x);               // 在 pos 位置插入
void SLErase(SL* ps, int pos);                              // 删除 pos 位置
int  SLFind(SL* ps, SLDataType x);                          // 按值查找,返回下标
SLDataType SLAt(SL* ps, int pos);                           // 按位序查找

SeqList.c:

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

/* ---------- 初始化 ---------- */
void SLInit(SL* ps)
{
    assert(ps);
    ps->a = (SLDataType*)malloc(sizeof(SLDataType) * 4); // 默认给 4 个,别开太大
    if (ps->a == NULL) {
        perror("malloc fail");   // 申请失败
        exit(-1);
    }
    ps->size = 0;
    ps->capacity = 4;
}

/* ---------- 销毁 ---------- */
void SLDestroy(SL* ps)
{
    assert(ps);
    if (ps->a != NULL) {          // 防止重复销毁
        free(ps->a);              // 堆上的空间必须还回去,否则内存泄漏
        ps->a = NULL;
        ps->size = 0;
        ps->capacity = 0;
    }
}

/* ---------- 扩容 ---------- */
void SLCheckCapacity(SL* ps)
{
    assert(ps);
    if (ps->size == ps->capacity) {          // 满了才扩
        int newCapacity = ps->capacity * 2;  // 一般是 2 倍扩容
        SLDataType* tmp = (SLDataType*)realloc(ps->a, sizeof(SLDataType) * newCapacity);
        if (tmp == NULL) {
            perror("realloc fail");
            exit(-1);
        }
        ps->a = tmp;                          // ⚠️ 必须用临时指针接,直接覆盖会丢原地址
        ps->capacity = newCapacity;
    }
}

/* ---------- 插入(核心) ---------- */
void SLInsert(SL* ps, int pos, SLDataType x)
{
    assert(ps);
    assert(pos >= 0 && pos <= ps->size);   // pos 是下标,合法范围 [0, size]

    SLCheckCapacity(ps);                   // 满了先扩容

    // 把 [pos, size-1] 的元素整体往后挪一格
    // 从后往前挪!从前往后会覆盖掉还没搬走的数据
    for (int i = ps->size; i > pos; i--) {
        ps->a[i] = ps->a[i - 1];
    }
    ps->a[pos] = x;
    ps->size++;
}

/* ---------- 删除(核心) ---------- */
void SLErase(SL* ps, int pos)
{
    assert(ps);
    assert(pos >= 0 && pos < ps->size);

    // 把 [pos+1, size-1] 的元素整体往前挪一格
    // 从前往后挪!从后往前会覆盖
    for (int i = pos; i < ps->size - 1; i++) {
        ps->a[i] = ps->a[i + 1];
    }
    ps->size--;
}

/* ---------- 查找 ---------- */
int SLFind(SL* ps, SLDataType x)      // 按值查找:O(n)
{
    assert(ps);
    for (int i = 0; i < ps->size; i++) {
        if (ps->a[i] == x) return i;  // 返回下标,找不到返回 -1
    }
    return -1;
}

SLDataType SLAt(SL* ps, int pos)      // 按位序查找:O(1),直接算地址
{
    assert(ps);
    assert(pos >= 0 && pos < ps->size);
    return ps->a[pos];
}

void SLPrint(SL* ps)
{
    assert(ps);
    for (int i = 0; i < ps->size; i++) printf("%d ", ps->a[i]);
    printf("\n");
}

/* ---------- 复用上面的接口实现头尾操作 ---------- */
void SLPushBack(SL* ps, SLDataType x)  { SLInsert(ps, ps->size, x); }
void SLPushFront(SL* ps, SLDataType x) { SLInsert(ps, 0, x); }
void SLPopBack(SL* ps)                 { SLErase(ps, ps->size - 1); }
void SLPopFront(SL* ps)                { SLErase(ps, 0); }

测试一下:

c 复制代码
int main()
{
    SL s;
    SLInit(&s);
    SLPushBack(&s, 1);      // 1
    SLPushBack(&s, 2);      // 1 2
    SLPushFront(&s, 0);     // 0 1 2
    SLInsert(&s, 1, 9);     // 0 9 1 2
    SLPrint(&s);            // 输出:0 9 1 2
    SLErase(&s, 0);         // 9 1 2
    SLPrint(&s);            // 输出:9 1 2
    printf("find 1 -> %d\n", SLFind(&s, 1));  // 输出 1
    SLDestroy(&s);
    return 0;
}

4.3 关键细节:扩容到底发生了什么?

realloc(ptr, size) 是扩容的核心,它有两种结局:

复制代码
① 原地扩容:数组后面刚好还有空地
   原地址 ──▶ [■■■■][空 空 空 空]
                返回的地址和 ptr 一样

② 异地扩容:后面被别的数据占了
   原地址 ──▶ [■■■■][别人的数据]
   新地址 ──▶ [        ■■■■■■■■        ]   把老数据拷过来,再 free 掉老空间
                返回新地址(和 ptr 不同)

两个必须记住的点:

  1. realloc 失败会返回 NULL。千万别写 ps->a = realloc(ps->a, ...) ,一旦失败,老地址就丢了,内存泄漏。必须用临时指针接(见上面的 tmp)。
  2. 扩容是有代价的(可能要搬 n 个元素),所以采用2 倍扩容 这种"前瞻式"策略,把多次小扩容合并成少数几次大扩容,均摊下来每次插入的代价仍是 O(1)。

free 的真相:free 并不是把空间"销毁"了 ,而是把这段空间的使用权还给操作系统 ,操作系统之后可能把它分给别人。所以 free 之后一定要把指针置 NULL,防止"野指针"。

4.4 复杂度分析(考试必背)

操作 最好 最坏 平均
按位序查找(随机访问) O(1) O(1) O(1)
按值查找 O(1)(在表头) O(n)(在表尾/不存在) O(n)
插入 O(1)(尾插,无需扩容) O(n)(头插,要挪 n 个 + 可能异地扩容搬 n 个) O(n)
删除 O(1)(尾删) O(n)(头删,要挪 n-1 个) O(n)

平均情况怎么来的? 假设在任意位置插入/删除的概率相等(都是 1/(n+1) 或 1/n),那么移动次数的期望是一个等差数列的平均值:

  • 插入平均移动 n/2 次 → O(n)
  • 删除平均移动 (n-1)/2 次 → O(n)
  • 按值查找平均比较 (n+1)/2 次 → O(n)

一句话总结顺序表:访问快(O(1)),插入删除慢(O(n)),空间要连续。

4.5 真题演练(2023 年 408 选择题)

题目 :下列对顺序存储的有序表(长度为 n)实现给定操作的算法中,平均时间复杂度为 O(1) 的是____

A. 查找包含指定值元素的算法

B. 插入包含指定值元素的算法

C. 删除第 i(1≤i≤n)个元素的算法

D. 获取第 i(1≤i≤n)个元素的算法

答案:D

解析:

  • A/B/C 都需要按值查找或挪动元素。挪动和比较次数基本是 1~n 的等差数列,平均在 n/2 左右 → 都是 O(n)
  • D 是位序查找(随机访问) 。顺序表物理上连续,通过首地址 + i * sizeof(元素) 常数次运算就能算出任意位置的地址 → O(1) ✅

4.6 两道经典 LeetCode 题(顺序表思想)

① 27. 移除元素

给你一个数组 nums 和一个值 val,原地移除所有等于 val 的元素,返回新长度。

思路 1(暴力) :从前往后遍历,遇到等于 val 的就把后面元素整体往前挪。最坏情况(大部分元素都等于 val)时间复杂度 O(n²)。❌

思路 2(空间换时间) :新建一个 tmp 数组,把不等于 val 的元素依次放进去,再拷回原数组。时间 O(n),但空间 O(n)。

思路 3(双指针,最优) :两个指针 src 和 dst 都从 0 开始。src 负责遍历,只要 src 指向的元素不等于 val,就把它覆盖到 dst 位置,然后 dst++。时间 O(n),空间 O(1)。✅

c 复制代码
int removeElement(int* nums, int numsSize, int val)
{
    int src = 0, dst = 0;
    while (src < numsSize) {
        if (nums[src] != val) {
            nums[dst] = nums[src];   // 把"要保留的"往前放
            dst++;
        }
        src++;
    }
    return dst;                      // dst 就是新的有效长度
}
② 26. 删除有序数组中的重复项

原地删除有序数组中的重复元素,使每个元素只出现一次,返回新长度。

同样是双指针。因为数组有序,所以重复元素一定挨着:

c 复制代码
int removeDuplicates(int* nums, int numsSize)
{
    if (numsSize == 0) return 0;
    int dst = 1;                      // 第一个元素一定保留
    for (int src = 1; src < numsSize; src++) {
        if (nums[src] != nums[dst - 1]) {   // 和"已保留的最后一个"比较
            nums[dst] = nums[src];
            dst++;
        }
    }
    return dst;
}

五、链表(Linked List)

5.1 为什么需要链表?

先看顺序表的痛点:

  • 插入/删除平均要挪 O(n) 个元素
  • 空间必须连续,满了要扩容(扩容有代价,还可能浪费空间)

于是就有了链式结构。

链表的本质:需要存一个数据,就去申请一块空间,然后用指针把它们串起来。 存数据和指针的这块空间叫结点(Node)。

生活类比:链表就像高铁车厢。节假日人多,可以在任意位置加挂车厢;人少就摘掉,不用把整列车重造。

5.2 单链表

5.2.1 结点定义
c 复制代码
typedef int SLTDataType;

typedef struct SListNode {
    SLTDataType data;              // 数据域:存值
    struct SListNode* next;        // 指针域:存下一个结点的地址
} SLTNode;

⚠️ 语法重点 :结构体里面为什么写 struct SListNode* next 而不是 SLTNode* next?

因为在结构体定义内部 ,typedef 的别名 SLTNode 还没生效(typedef 要在整个声明结束后才可用)。所以必须写完整的 struct SListNode*。

5.2.2 头指针、头结点、尾结点
  • 头指针:指向第一个结点的指针

  • 尾结点 :它的 next 指向 NULL

  • 头结点(哨兵位) :一个不存有效数据的额外结点,纯粹为了让代码好写

    带头结点的单链表:
    head ──▶ ┌────┬───┐ ┌────┬───┐ ┌────┬──────┐
    │ 哨 │ ●─┼──▶│ a1 │ ●─┼──▶│ a2 │ NULL │
    └────┴───┘ └────┴───┘ └────┴──────┘
    头结点(不存数据) 第1个有效结点 尾结点

    不带头结点的单链表:
    head ──▶ ┌────┬───┐ ┌────┬──────┐
    │ a1 │ ●─┼──▶│ a2 │ NULL │
    └────┴───┘ └────┴──────┘

带头结点的好处(这是本节的灵魂):

插入和删除第一个结点时,不用单独判断 。因为第一个有效结点前面永远有个头结点,prev 永远不为空。

⚠️ 一个常见误区 :有些书把链表长度存在头结点的数据域里。这不太合理 ------如果链表存 char,长度一长就溢出了。头结点就老老实实当哨兵。

听说经验 :OJ(力扣)和校招给的链表基本都不带头结点 ;学校考试和 408 题目基本都带头结点 。题目一般会说明,如果没说,答题时最好主动注明你的实现带不带头结点,方便阅卷老师理解。

5.2.3 完整接口实现

SList.h:

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

typedef int SLTDataType;

typedef struct SListNode {
    SLTDataType data;
    struct SListNode* next;
} SLTNode;

SLTNode* BuyNode(SLTDataType x);                        // 申请一个新结点
SLTNode* SListInit(void);                               // 初始化(返回哨兵位)
void SListDestroy(SLTNode* phead);                      // 销毁
void SListPrint(SLTNode* phead);                        // 打印
int  SListSize(SLTNode* phead);                         // 有效元素个数
SLTNode* SListFind(SLTNode* phead, SLTDataType x);      // 按值查找
SLTNode* SListAt(SLTNode* phead, int pos);              // 按下标查找
void SListInsert(SLTNode* phead, int pos, SLTDataType x);// 下标 pos 处插入
void SListErase(SLTNode* phead, int pos, SLTDataType* px);// 删除下标 pos 并带出值
void SListPushFront(SLTNode* phead, SLTDataType x);     // 头插
void SListPushBack(SLTNode* phead, SLTDataType x);      // 尾插
void SListPopFront(SLTNode* phead);                     // 头删
void SListPopBack(SLTNode* phead);                      // 尾删

SList.c:

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

/* ---------- 申请新结点 ---------- */
SLTNode* BuyNode(SLTDataType x)
{
    SLTNode* newNode = (SLTNode*)malloc(sizeof(SLTNode));
    if (newNode == NULL) {
        perror("malloc fail");
        exit(-1);
    }
    newNode->data = x;
    newNode->next = NULL;
    return newNode;
}

/* ---------- 初始化:返回哨兵位头结点 ---------- */
SLTNode* SListInit(void)
{
    // ⚠️ 不能定义成局部变量 SLTNode head; 出了函数就销毁了
    //    所以必须从堆上申请
    return BuyNode(-1);   // 头结点的 data 不用管
}

/* ---------- 打印 / 求长度:核心是遍历 ---------- */
void SListPrint(SLTNode* phead)
{
    assert(phead);
    SLTNode* cur = phead->next;        // ⚠️ 跳过头结点,它不存有效数据
    while (cur != NULL) {
        printf("%d -> ", cur->data);
        cur = cur->next;               // 从当前结点的 next 拿到下一个结点
    }
    printf("NULL\n");
}

int SListSize(SLTNode* phead)
{
    assert(phead);
    int n = 0;
    SLTNode* cur = phead->next;
    while (cur) { n++; cur = cur->next; }
    return n;                          // ⚠️ 头结点不算在内
}

/* ---------- 查找 ---------- */
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
    assert(phead);
    SLTNode* cur = phead->next;
    while (cur) {
        if (cur->data == x) return cur;   // 找到第一个就返回
        cur = cur->next;
    }
    return NULL;
}

SLTNode* SListAt(SLTNode* phead, int pos)
{
    assert(phead);
    SLTNode* cur = phead->next;
    int i = 0;
    while (cur && i < pos) { cur = cur->next; i++; }  // 计数 + 非空 一起判断
    return cur;   // 可能为 NULL,说明第 pos 个结点不存在
}

/* ---------- 插入(本节最重点!) ---------- */
void SListInsert(SLTNode* phead, int pos, SLTDataType x)
{
    assert(phead);
    // 找到第 pos 个结点的【前一个】结点,才能改链接关系
    SLTNode* prev = SListAt(phead, pos - 1);
    if (prev == NULL) { printf("插入位置非法\n"); return; }

    SLTNode* newNode = BuyNode(x);
    // ⚠️⚠️ 这两句顺序绝对不能交换!
    newNode->next = prev->next;   // ① 新结点先接管后面的链表
    prev->next = newNode;         // ② 前一个结点再指向新结点
}

为什么这两句不能交换? 画个图就懂了:

复制代码
交换前:prev ──▶ [ B ] ──▶ [ C ] ──▶ ...

✅ 正确顺序:
   ① newNode->next = prev->next;   newNode ──▶ [B]
   prev ──▶ [B],newNode ──▶ [B]

   ② prev->next = newNode;
   prev ──▶ [newNode] ──▶ [B] ──▶ [C]   ✔ 成功

❌ 错误顺序:
   ① prev->next = newNode;
   prev ──▶ [newNode],而 [B] 没人指向它了!链表断了

   ② newNode->next = prev->next;   此时 prev->next 已经是 newNode 自己
   newNode ──▶ newNode  ← 自己指自己,完蛋

头插为什么不用特殊处理?

c 复制代码
// 头插:在 pos = 0 处插入
// SListAt(phead, -1) 返回什么?循环 i < -1 一次都不执行,返回 phead->next...

等等,这里要小心。上面的实现里 SListAt(phead, -1) 会返回第一个有效结点,这不是我们要的。更稳妥的写法 是让 SListAt 支持 pos = -1 返回头结点:

c 复制代码
SLTNode* SListAt(SLTNode* phead, int pos)
{
    assert(phead);
    SLTNode* cur = phead;              // 从【头结点】开始!
    int i = -1;
    while (cur && i < pos) { cur = cur->next; i++; }
    return cur;
}

这样 SListAt(phead, -1) 返回头结点,SListAt(phead, 0) 返回第一个有效结点。于是头插时 prev 就是头结点,代码和中间插入完全一样,不需要单独判断 ------ 这就是哨兵位的价值!

5.2.4 删除(顺序同样要命)
c 复制代码
void SListErase(SLTNode* phead, int pos, SLTDataType* px)
{
    assert(phead);
    SLTNode* prev = SListAt(phead, pos - 1);
    // 第 pos 个结点不存在 → 非法删除
    if (prev == NULL || prev->next == NULL) {
        printf("删除位置非法\n");
        return;
    }
    SLTNode* del = prev->next;      // ⚠️ 先保存要删的结点
    *px = del->data;                // 用输出参数把删掉的值带出去
    prev->next = del->next;
    free(del);
    del = NULL;
}

同样有顺序陷阱 :必须先保存被删结点 del,再让 prev->next 跨过它。否则一旦改了 prev->next,就再也找不到 del 了,free 不掉 → 内存泄漏。

c 复制代码
void SListDestroy(SLTNode* phead)
{
    SLTNode* cur = phead;
    while (cur) {
        SLTNode* next = cur->next;   // ⚠️ 先存下一个,否则 free 完就找不到了
        free(cur);
        cur = next;
    }
}

规律总结 :链表里凡是"先断开再找"的操作,都容易翻车。操作指针前,先把要用的地址存进临时变量。

5.2.5 四道单链表高频题

① 876. 链表的中间结点 ------ 快慢指针

  • 思路 1:遍历一遍求长度 n,再遍历一遍找第 n/2 个。O(n),需要两趟
  • 思路 2(更优) :slow 一次走 1 步,fast 一次走 2 步。fast 走到尾时,slow 刚好在中间。O(n),一趟搞定 ✅
c 复制代码
struct ListNode* middleNode(struct ListNode* head)
{
    struct ListNode *slow = head, *fast = head;
    // 偶数个结点:fast 走到 NULL 结束
    // 奇数个结点:fast 走到尾结点结束
    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;
}

② 面试题 02.02. 返回倒数第 k 个结点 ------ 快慢指针

fast 先走 k 步,然后 slow 和 fast 同时走。当 fast 走到空时,slow 就在倒数第 k 个。(slow 和 fast 之间始终隔着 k 个结点)

c 复制代码
int kthToLast(struct ListNode* head, int k)
{
    struct ListNode *fast = head, *slow = head;
    while (k--) fast = fast->next;      // fast 先走 k 步
    while (fast) {                      // 同时走
        slow = slow->next;
        fast = fast->next;
    }
    return slow->val;
}

③ 203. 移除链表元素 ------ 哨兵位的经典应用

准备 prev 和 cur 两个指针遍历。当 cur->val == val 时,让 prev->next = cur->next,然后 free(cur)。

麻烦在哪? 如果第一个结点 就是要删的,prev 是空,得单独处理并更新头指针。加一个哨兵位就完美解决 :prev 永远不为空。

c 复制代码
struct ListNode* removeElements(struct ListNode* head, int val)
{
    struct ListNode* dummy = (struct ListNode*)malloc(sizeof(struct ListNode));
    dummy->next = head;                 // 哨兵位,保证 prev 永不为空
    struct ListNode* prev = dummy, *cur = head;
    while (cur) {
        if (cur->val == val) {
            prev->next = cur->next;     // 跨过 cur
            free(cur);
            cur = prev->next;
        } else {
            prev = cur;
            cur = cur->next;
        }
    }
    head = dummy->next;
    free(dummy);
    return head;
}

④ 206. 反转链表 ------ 头插法

思路极简:cur 遍历原链表,每取下一个结点头插到新链表,走完就反转了。

c 复制代码
struct ListNode* reverseList(struct ListNode* head)
{
    struct ListNode* newHead = NULL;
    struct ListNode* cur = head;
    while (cur) {
        struct ListNode* next = cur->next;  // ① 先存下一个
        cur->next = newHead;                // ② 头插到新链表
        newHead = cur;                      // ③ 更新新链表头
        cur = next;                         // ④ 继续下一个
    }
    return newHead;
}

⑤ 21. 合并两个有序链表 ------ 归并 + 哨兵位

和归并两个有序数组思路一样:依次比较,取小的尾插到新链表。不同点:链表可以把结点"摘下来"直接挂过去,数组只能拷贝数据。

用哨兵位后,插入第一个结点时不用单独判断(因为 tail 始终不为空)。

c 复制代码
struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2)
{
    if (l1 == NULL) return l2;
    if (l2 == NULL) return l1;

    struct ListNode* dummy = (struct ListNode*)malloc(sizeof(struct ListNode));
    struct ListNode* tail = dummy;
    while (l1 && l2) {
        if (l1->val <= l2->val) { tail->next = l1; l1 = l1->next; }
        else                    { tail->next = l2; l2 = l2->next; }
        tail = tail->next;
    }
    tail->next = l1 ? l1 : l2;    // 没走完的直接挂上
    struct ListNode* head = dummy->next;
    free(dummy);
    return head;
}

⑥ 160. 相交链表

  1. 分别遍历两个链表找到尾结点,同时求出两个长度
  2. 尾结点相同 → 相交;不同 → 不相交(因为相交后必然共享同一段尾巴)
  3. 算出长度差,让长的先走差距步 ,然后两个一起走,第一个相等的结点就是交点

⑦ 141/142. 环形链表 ------ 快慢指针判环

fast 一次 2 步,slow 一次 1 步。不带环,fast 先到尾;带环,两人都进环后开始"追赶",fast 一定会追上 slow。

找环入口的两种方法:

  • 方法 1(转成交叉链表) :找到相遇点后,把相遇点的 next 置空作为"尾",问题就变成"求两个链表的交点"
  • 方法 2(更简单) :一个指针从表头 出发,另一个从相遇点 出发,都每次走 1 步,它们一定在环入口相遇

拓展思考(校招可能深挖):

  • slow 走 1 步、fast 走 2 步,一定能相遇吗? → 能。因为进入环后,两者的距离每次缩小 1,一定能缩到 0
  • slow 走 1 步、fast 走 3 步呢? → 距离每次缩小 2,如果环长是偶数、初始距离是奇数,就可能永远跳过,不一定相遇

⑧ 138. 随机链表的复制(较难)

每个结点多一个 random 指针,指向链表中任意结点或 NULL。

  • 思路 1 :先把链表复制一份,random 先空着。然后对每个结点,遍历原链表找出它的 random 是第几个,再去拷贝链表对应位置。每个 random 都是 O(n) → 整体 O(n²)
  • 思路 2(O(n),很巧妙) :
    1. 把拷贝结点插到原结点后面 (A → A' → B → B' → ...)
    2. 这样原结点的 random->next 就是拷贝结点的 random
    3. 把拷贝结点取下来尾插成新链表,最后恢复原链表

5.3 链表的分类

按三个维度组合,一共 8 种链表:

维度 选项
方向 单向 / 双向
头结点 带头结点 / 不带头结点
循环 循环 / 非循环

只需要重点掌握 3 种(考试和实践出现的基本就是这三种):

  1. 带头结点单链表
  2. 不带头结点单链表
  3. 带头双向循环链表

其他组合都能触类旁通。

5.3.1 双向链表

单链表的痛点:找前驱很麻烦 ,只能从头遍历,O(n)。所以只有当前结点的指针 pos(没有头指针)时,想在 pos 之前插入、或删除 pos 本身,都做不到。

双向链表每个结点多一个前驱指针:

c 复制代码
typedef struct DListNode {
    LTDataType data;
    struct DListNode* prev;   // 指向前驱
    struct DListNode* next;   // 指向后继
} DLTNode;

适用场景:倒着遍历、删除当前结点、在当前结点前插入。

5.3.2 循环链表

单链表适合头插头删 (O(1)),但尾插尾删要 O(n)(得先找尾)。而实践中头尾操作比"第 i 个位置"的操作更常见。

循环链表 就是让尾结点的 next 指回头结点:

复制代码
双向循环链表:
        ┌─────────────────────────────────┐
        ▼                                 │
   ┌────────┐    ┌────┐    ┌────┐    ┌────┐
   │ 头结点 │◀──▶│ a1 │◀──▶│ a2 │◀──▶│ a3 │
   └────────┘    └────┘    └────┘    └────┘
        ▲                                 │
        └─────────────────────────────────┘
  • 单循环链表 :尾结点 next 指向头结点
  • 双向循环链表 :尾结点 next 指向头结点,头结点 prev 指向尾结点

为什么双向循环链表最实用? 因为可以通过头结点的 prev 直接找到尾结点 ,于是头插、尾插、头删、尾删全都是 O(1) !C++ STL 里的 list 就是这么实现的。

5.3.3 单链表 vs 循环单链表(两处不同)

① 初始化不同 :循环单链表要让头结点的 next 指向自己。

c 复制代码
// 单链表
phead->next = NULL;

// 循环单链表
phead->next = phead;

② 遍历结束条件不同 :循环链表不能用"指针为空"作为结束条件(永远不为空),而是"走一圈回到头结点"。

c 复制代码
// 单链表
while (cur != NULL) { ... cur = cur->next; }

// 循环单链表
while (cur != phead) { ... cur = cur->next; }
5.3.4 带头双向循环链表的核心操作

在 pos 之后插入 newNode (要改 4 个指针):

c 复制代码
void DCListInsert(DLTNode* pos, LTDataType x)
{
    assert(pos);
    DLTNode* newNode = BuyNode(x);
    // pos 可以是任意结点(包括头结点),不用考虑前后为空
    newNode->next = pos->next;
    pos->next->prev = newNode;   // 原后继的 prev 指向新结点
    newNode->prev = pos;
    pos->next = newNode;
}

顺序建议 :先处理 newNode 和自己的前后,把 pos->next = newNode 放到最后 ,就不会因为 pos->next 被提前改动而找不到原后继。

删除 pos 结点(改 2 个指针,无先后顺序):

c 复制代码
void DCListErase(DLTNode* pos)
{
    assert(pos && pos->next != pos);   // 不能删头结点
    pos->prev->next = pos->next;
    pos->next->prev = pos->prev;
    free(pos);
}

这两句没有先后关系 ,因为从头到尾 pos 自己的 prev 和 next 都没被修改。

对比一下 :如果是非循环 的双向链表,pos 是尾结点时还要处理 pos->next == NULL,而且 pos 不能是头结点。循环结构把这些边界情况全消掉了 ------ 这就是它好用的原因。

5.3.5 真题演练

① 2023 年 408 选择题 02(双向链表插入)

非空双向链表 L,结点结构 | prev | data | next |。在指针 p 所指结点(非尾结点 )之后插入指针 s 所指新结点。执行完 s->next = p->next; p->next->prev = s; 后,还需要执行的是( )

答案:C (s->prev = p; p->next = s;)

解析 :非尾结点 → 不用考虑 p 的后继为空。这题的技巧就是画图,而且题目故意先把容易让人晕的那两句给了出来,不画图真的会晕菜。

② 2016 年 408 选择题 02(双向循环链表删除)

带头结点的双向循环链表 L,删除指针 p 所指结点,正确的语句序列是( )

答案:D (p->prev->next = p->next; p->next->prev = p->prev;)

解析 :双向循环 → 不用考虑 p 的后继为空。同样靠画图。这两句没有先后关系。

③ 2021 年 408 选择题 01(单循环链表删除第一个元素)

头指针 h 指向带头结点的非空单循环链表,p 是尾指针,q 是临时指针。删除链表第一个元素,正确的是( )

答案:D

解析 :先让 q 指向第一个结点,再让 h 指向第二个结点 。h 一旦先动,就找不到第一个结点了。

这题的精髓在"特殊情况" :p 是尾指针,看起来像个路人。但当链表只有一个结点 时,被删的第一个结点同时也是尾结点 !这时 p == q,删完后尾结点就是头结点本身。这道题完美体现了 408 为什么要求你考虑边界情况。


六、顺序表 vs 链表:到底该用哪个?

顺序表和链表逻辑结构都是线性结构 ,最大的区别在存储结构(物理结构)。

优缺点对照表

顺序表 链表
随机访问(按下标) ✅ O(1) ❌ O(n)
插入/删除(除头尾) ❌ O(n),要挪数据 ✅ O(1)(双向循环链表确定位置后)
头插/头删 ❌ O(n) ✅ O(1)
尾插/尾删 ✅ O(1)(均摊) 单链表 O(n);双向循环链表 O(1)
空间管理 需扩容,可能有浪费 ✅ 按需申请释放,不需扩容
CPU 缓存命中率 ✅ 高 ❌ 低
内存碎片 ✅ 不产生 ❌ 容易产生

详细说明

顺序表的优点:

  1. 支持下标随机访问(O(1))。实践中非常有用:排序、排序后二分查找、优先级队列,都适合在顺序表上做
  2. CPU 缓存命中率高,且不产生内存碎片

顺序表的缺点:

  1. 插入删除要挪数据(除了尾插/尾删),O(n)。只适合大量尾插尾删的场景
  2. 空间不够要扩容(有代价),且可能有空间浪费

链表的优点:

  1. 按需申请释放空间,不需要扩容
  2. 双向循环链表在确定位置的情况下,任意位置插入删除都是 O(1),不挪数据

链表的缺点:

  1. 不支持下标随机访问
  2. CPU 缓存命中率低,容易产生内存碎片

核心结论 :顺序表和链表相辅相成 ------ 顺序表的优点正好弥补链表的缺点,反之亦然。实践中要根据使用场景来选择,没有绝对的好坏。

延伸:为什么顺序表"缓存友好"?(CPU 缓存)

现代 CPU 有多级缓存:L1(分指令缓存 + 数据缓存)、L2、L3。

复制代码
        ┌─────────┐
        │   CPU   │
        └────┬────┘
        ┌────▼────┐  L1:每个核独享,最小最快
        ┌────▼────┐  L2:每个核独享
        ┌────▼────┐  L3:所有核心共享
        ┌────▼────┐
        │  内存   │  最大最慢
        └─────────┘
  • 越靠近 CPU 越小、越快;越远越大、越慢
  • CPU 不会一个字节一个字节地加载 ,而是一块一块地加载 ,这个块叫 Cache Line ,主流是 64 Bytes

CPU 的读取流程 :先在 Cache 里找 → 找到就直接用(命中);没找到就去内存取,同时把这一整块调入 Cache,方便后续访问。

为什么这么设计?------ 局部性原理

  • 时间局部性 :一个数据被访问了,它很可能在不久的将来又被访问
  • 空间局部性 :一个数据被访问了,它相邻的数据也很可能马上被访问

于是差距就出来了:

c 复制代码
// 顺序表:连续内存
for (int i = 0; i < n; i++) sum += a[i];
// 访问 a[0] 时,a[1]~a[15](64字节/4字节)一并被拉进 Cache
// 后面 15 次访问全部命中!✅

// 链表:结点散落各处
for (SLTNode* cur = head; cur; cur = cur->next) sum += cur->data;
// 每个结点的地址都是"跳"过去的,Cache Line 里装的是别人
// 基本每次都 Cache Miss,要访问内存 ❌

我们平时说"数组遍历比链表快",不只是因为复杂度 。即使都是 O(n),顺序表也常常快好几倍,因为缓存。

延伸:内存碎片

内存碎片 :内存中总空闲容量足够,但这些空闲空间被分割成许多不连续的小块,无法分配给需要连续内存的程序使用。

就像停车场有很多零散空位,但停不下一辆需要连续多个车位的大巴车。

  • 顺序表 :插入删除过程中始终保持一块连续 空间,不产生内存碎片
  • 链表 :不断申请和释放小块空间,会导致严重的内存碎片。比如链表结构里空闲部分明明还有 6 块空间,却申请不出超过 3 块的连续空间

七、面试/408 高频考点速查表

考点 结论
位序 vs 下标 位序从 1 开始,下标从 0 开始
顺序表按位序查找 O(1),靠首地址 + 偏移量直接算
顺序表插入/删除 平均 O(n),要挪元素
顺序表扩容 一般 2 倍扩容,用 realloc,必须用临时指针接返回值
循环单链表初始化 头结点 next 指向自己
循环单链表遍历结束条件 cur == phead,不是 cur == NULL
单链表插入/删除的前提 先找到前一个结点
单链表插入的顺序 newNode->next = prev->next; 在前,prev->next = newNode; 在后
单链表删除的顺序 先保存被删结点,再改链接关系
头结点(哨兵位)的作用 插入/删除第一个结点时不用单独判断
双向循环链表插入 改 4 个指针,pos->next = newNode 建议放最后
双向循环链表删除 改 2 个指针,无先后顺序
OJ 链表 vs 408 链表 OJ/校招基本不带头结点;408/学校考试基本带头结点
顺序表 vs 链表 顺序表访问快、缓存友好、无碎片;链表按需申请、插入删除快、但缓存不友好、有碎片

八、写在最后

把这篇的核心思想浓缩成三句话:

  1. 顺序表 = 数组 + 一个 size。它的强大在于 O(1) 随机访问,它的软肋在于插入删除要挪数据。
  2. 链表 = 结点 + 指针。它的强大在于按需申请、改指针就能插删,它的软肋在于找不到下标、缓存不友好。
  3. 头结点(哨兵位)是链表代码优雅的关键。加上它,头插头删的边界判断全部消失。

给初学者的学习建议:

  • 链表的题一定要画图。408 里双向链表那几道题,答案都写着"本题的技巧就是画图"------不画图谁都会晕
  • 指针操作先存地址再断开,这条规律能帮你躲掉 90% 的链表 bug
  • 调试链表时,先手动构造一个只有 3~4 个结点的链表,比在 OJ 上瞎猜快得多

如果这篇对你有帮助,欢迎点赞 + 收藏,后续会继续更新栈、队列、二叉树等内容~

本文基于个人学习笔记整理,如有错误欢迎评论区指正。

相关推荐
All for pursuit.1 小时前
【回溯-7】494.目标和
数据结构·c++·算法·leetcode
50万马克的面包1 小时前
数据结构01-数据结构概念与复杂度-博客稿
数据结构
Logic1011 小时前
C语言/数据结构贪心算法题解:字符串趋同——逐位统计最高频率求最小修改代价
c语言·数据结构·贪心算法·字符串·时间复杂度·频率统计·算法题
红豆诗人3 小时前
C++二叉搜索树:查找、插入和删除为什么这样写
数据结构·c++·算法
H.莓飛3 小时前
【数据结构】堆_OJ题
linux·数据结构·算法
7yewh12 小时前
SLAM 三维空间刚体运动(2)
数据结构·人工智能·机器人·嵌入式·slam
leikooo12 小时前
用餐厅和盘子理解栈和堆
java·数据结构·链表·rust
lvwangshu16 小时前
登录日志 题解
数据结构·线段树·题解·技巧·性质题·思维好题·细节题
CQU_JIAKE16 小时前
10.2【A】
数据结构