作为数据结构的开篇核心知识点,顺序表和单链表是线性表的两种最基础实现形式,二者相辅相成、优缺点互补,是所有后端开发、算法学习的核心地基,也是校招、初阶面试的高频考点。
一、顺序表(线性结构的顺序存储结构)
1. 核心定义(小白必懂)
顺序表是线性表的顺序存储结构 ,它在计算机内存中开辟一段连续的、固定大小的地址空间 ,按照线性表中元素的逻辑顺序,依次将数据元素存储在这段连续地址中。简单来说,顺序表的本质就是数组,但和普通数组不同的是:顺序表会严格维护元素的 "线性逻辑关系"(即元素之间的前后顺序),且通常会配套实现增删改查等完整操作,是一种 "封装好的数组应用"。
2. 核心优势(结合实际场景)
- 有序后查找效率极高,支持随机访问 顺序表的最大优势的是支持 "随机访问"------ 只要知道元素的下标(索引),就能通过公式
地址 = 起始地址 + 下标 × 元素大小直接定位到元素,时间复杂度为 O(1) 。比如:在一个有序的顺序表中查找某个元素,配合二分查找算法,时间复杂度可降至 O(log₂n),这也是顺序表在 "查找频繁" 场景中(如学生成绩查询、字典检索)的核心价值。 - 结构简单,易于实现和调试底层依托数组实现,无需复杂的指针操作,代码逻辑简洁,上手成本极低。无论是 C 语言、Java 还是 Python,都能快速实现顺序表的基础功能,适合新手入门练手。
- 内存开销小,访问速度快顺序表的元素存储连续,CPU 缓存命中率更高(缓存会预加载连续内存的数据),相比链表的分散存储,访问速度更有优势。
3. 核心劣势(深度剖析,直击痛点)
- 插入、删除元素效率极低,数据量越大越明显 顺序表的核心特性是 "内存地址连续",而这也是它插入、删除低效的根源。为了保证插入 / 删除后,元素依然保持连续的地址和线性逻辑,必须批量移动后续所有元素:
- 头部插入 / 删除:需要移动全部元素(比如在数组 [1,2,3,4] 头部插入 0,需将 1、2、3、4 依次后移一位);
- 中间插入 / 删除:需要移动从插入位置到末尾的所有元素(比如在 [1,2,3,4] 中间插入 5 到索引 2,需将 3、4 后移一位);
- 尾部插入 / 删除:无需移动元素(直接在末尾添加 / 删除),但这种情况仅为特例。总体而言,插入 / 删除的时间复杂度为 O(n),当数据量达到 10 万、100 万级时,效率会急剧下降。
- 内存空间固定,利用率低,易造成浪费或溢出 顺序表初始化时,需要提前分配固定大小的内存空间:
- 若分配的空间过大,会造成内存浪费(比如只存 10 个元素,却分配了 1000 个元素的空间);
- 若分配的空间过小,当元素数量超过空间大小时,会出现 "内存溢出",无法继续存储数据。虽然可以通过 "扩容"(重新分配更大的连续空间,将原数据拷贝过去)解决溢出问题,但扩容过程会消耗额外的时间和内存(拷贝数据的时间复杂度为 O (n))。
- 延伸思考(面试常考) 问:如果地址不连续,是不是就能规避插入删除低效的问题?答:完全可以 !这就是我们接下来要讲的链式存储结构(链表) ------ 链表放弃了 "地址连续" 的要求,用指针串联分散的元素,彻底解决了顺序表插入删除低效的痛点,但同时也牺牲了部分查找效率,这就是 "取舍" 的设计思想。
二、线性结构的链式存储结构 ------ 链表
顺序表的短板,恰好是链表的优势;链表的短板,也恰好是顺序表的优势。二者的对比学习,是理解 "数据结构设计思想" 的关键。
1. 基础概念(拆解通俗,避免晦涩)
- 链表:线性表的链式存储结构 ,数据元素(结点)在内存中地址不连续,分散存储在内存的各个角落。
- 单链表:只有一条 "后继指针链" 的链表,是最基础、最常用的链表结构(后续的双链表、循环链表,都是在单链表基础上拓展而来)。
- 什么是「链」?核心就是指针(或引用) ------ 指针就像 "绳子",将分散在内存中的各个数据结点串联起来,维系元素之间的线性逻辑关系,这也是 "链表" 名字的由来。
2. 单链表核心原理(图文级拆解)
(1)结点(Node)------ 链表的最小单元
单链表的每一个数据成员,称为一个结点,每个结点都包含两部分,缺一不可:
- 数据域(data):存储真实的数据(比如 int、char、结构体等任意类型数据);
- 指针域(next):存储下一个结点的内存地址,通过这个指针,将当前结点与下一个结点关联起来。
(2)地址不连续,如何维系线性结构?(核心难点)
链表的结点在内存中是分散的,没有连续的地址,它之所以能保持 "线性结构"(元素前后有序),核心靠指针的关联:
- 每个结点的指针域(next),都指向它的后继结点(下一个结点)的地址;
- 相对于当前结点,上一个结点称为前驱结点(单链表中,前驱结点无法直接获取,只能通过遍历查找);
- 链表的最后一个结点(尾结点),其指针域(next)指向
NULL(空指针),表示链表的结束; - 为了方便管理整个链表,我们通常会设置一个头结点(不存储真实数据),头结点的指针域指向链表的第一个数据结点(首元结点)。
(3)头结点的作用(避坑重点)
很多新手会忽略头结点,导致链表操作出现 bug,这里明确头结点的 3 个核心作用:
- 统一操作:无论链表是否为空,头结点都存在,这样插入、删除的代码逻辑可以统一(无需单独处理 "空链表" 的特殊情况);
- 避免混乱:头结点不存储数据,仅用于指向首元结点,避免将首元结点误当作 "头",导致链表操作出错;
- 存储链表信息:头结点中可以存储链表的额外信息(如本文后续实现中的 "数据元素大小"),方便管理链表。
三、单链表 C 语言 完整实现(ADT + 接口函数 + 详细注释)
我们采用抽象数据类型 (ADT) 设计,通用性极强,可存储任意类型数据(int、char、结构体等),代码包含详细注释,小白也能看懂,复制就能编译运行。
1. 头文件与数据结构定义
<stdio<stdlib<string.h>
// 定义比较函数指针类型(用户自定义数据比较规则,适配任意数据类型)// 返回值:0 - 相等,非 0 - 不相等typedef int (*cmp_t)(const void *data1, const void *data2);
// 1. 单链表数据结点结构体(存储真实数据)struct node_st {void *data; // 数据域:存储任意类型数据的地址(通用性关键)struct node_st *next; // 指针域:指向后继结点的地址};
// 2. 单链表头结点结构体(管理整个链表,不存储真实数据)typedef struct {struct node_st *head; // 指向链表第一个数据结点(首元结点)int size; // 数据元素的大小(字节数),用于内存分配} listhead_t;
cs
### 2. 核心接口函数实现(含异常处理,避免bug)
#### (1)初始化链表头结点
```c
/**
* @brief 初始化链表头结点
* @param list:指向头结点指针的指针(用于修改外部头结点指针)
* @param size:数据元素的大小(字节数)
* @return 0-初始化成功,-1-初始化失败(内存分配失败)
*/
int listhead_init(listhead_t **list, int size) {
// 1. 校验参数(避免空指针异常)
if (list ==<= 0) {
printf("初始化失败:参数错误(list为空或size非法)\n");
return -1;
}
// 2. 为头结点分配内存
*list = (listhead_t *)malloc(sizeof(listhead_t));
if (*list == NULL) {
printf("初始化失败:内存分配失败\n");
return -1;
}
// 3. 初始化头结点成员
(*list)->head = NULL; // 初始时链表为空,头结点不指向任何数据结点
(*list)->size = size; // 记录数据元素大小
printf("链表头结点初始化成功\n");
return 0;
}
(2)增:头插法(在链表头部插入结点)
cs
/**
* @brief 头插法:在链表头部插入数据结点
* @param list:已初始化的头结点指针
* @param data:要插入的数据(传入数据的地址)
* @return 0-插入成功,-1-插入失败(参数错误/内存分配失败)
*/
int list_add(listhead_t *list, const void *data) {
// 1. 校验参数
if (list == NULL || data == NULL) {
printf("头插失败:参数错误(list或data为空)\n");
return -1;
}
// 2. 为新数据结点分配内存
struct node_st *new_node = (struct node_st *)malloc(sizeof(struct node_st));
if (new_node == NULL) {
printf("头插失败:内存分配失败\n");
return -1;
}
// 3. 为新结点的数据域分配内存,并拷贝数据
new_node->data = malloc(list->size);
if (new_node->data == NULL) {
printf("头插失败:数据域内存分配失败\n");
free(new_node); // 释放已分配的结点内存,避免内存泄漏
return -1;
}
memcpy(new_node->data, data, list->size); // 拷贝数据到新结点
// 4. 插入新结点(核心:修改指针指向)
new_node->next = list->head; // 新结点的next指向原首元结点
list->head = new_node; // 头结点的head指向新结点,新结点成为新的首元结点
printf("头插数据成功\n");
return 0;
}
(3)增:尾插法(在链表尾部插入结点)
cs
/**
* @brief 尾插法:在链表尾部插入数据结点
* @param list:已初始化的头结点指针
* @param data:要插入的数据(传入数据的地址)
* @return 0-插入成功,-1-插入失败(参数错误/内存分配失败)
*/
int list_add_tail(listhead_t *list, const void *data) {
// 1. 校验参数
if (list == NULL || data == NULL) {
printf("尾插失败:参数错误(list或data为空)\n");
return -1;
}
// 2. 为新数据结点分配内存(和头插一致)
struct node_st *new_node = (struct node_st *)malloc(sizeof(struct node_st));
if (new_node == NULL) {
printf("尾插失败:内存分配失败\n");
return -1;
}
new_node->data = malloc(list->size);
if (new_node->data == NULL) {
printf("尾插失败:数据域内存分配失败\n");
free(new_node);
return -1;
}
memcpy(new_node->data, data, list->size);
new_node->next = NULL; // 尾结点的next必须指向NULL
// 3. 找到链表的尾结点(遍历链表)
struct node_st *tail = list->head; // 用于遍历的指针
if (tail == NULL) {
// 链表为空(没有数据结点),直接将新结点作为首元结点
list->head = new_node;
} else {
// 链表非空,遍历到最后一个结点(next为NULL的结点)
while (tail->next != NULL) {
tail = tail->next;
}
tail->next = new_node; // 尾结点的next指向新结点
}
printf("尾插数据成功\n");
return 0;
}
(4)删:按关键字删除结点
cs
/**
* @brief 按关键字删除结点(需用户提供比较函数)
* @param list:已初始化的头结点指针
* @param key:要删除的关键字(传入关键字地址)
* @param cmp:比较函数(用户自定义,判断数据与关键字是否相等)
* @return 0-删除成功,-1-删除失败(参数错误/链表为空/未找到关键字)
*/
int list_del(listhead_t *list, const void *key, cmp_t cmp) {
// 1. 校验参数
if (list == NULL || key == NULL || cmp == NULL) {
printf("删除失败:参数错误(list/key/cmp为空)\n");
return -1;
}
// 2. 处理链表为空的情况
if (list->head == NULL) {
printf("删除失败:链表为空,无数据可删\n");
return -1;
}
// 3. 遍历链表,查找要删除的结点(需要记录前驱结点)
struct node_st *curr = list->head; // 当前结点(要查找的结点)
struct node_st *prev = NULL; // 前驱结点(当前结点的上一个结点)
while (curr != NULL) {
// 调用比较函数,判断当前结点数据是否与关键字相等
if (cmp(curr->data, key) == 0) {
// 找到要删除的结点,修改指针指向,释放内存
if (prev == NULL) {
// 要删除的是首元结点(前驱结点为空)
list->head = curr->next;
} else {
// 要删除的是中间/尾结点,前驱结点的next指向当前结点的next
prev->next = curr->next;
}
// 释放当前结点的内存(先释放数据域,再释放结点)
free(curr->data);
free(curr);
printf("删除关键字成功\n");
return 0;
}
// 未找到,继续遍历
prev = curr;
curr = curr->next;
}
// 4. 遍历结束,未找到关键字
printf("删除失败:未找到指定关键字\n");
return -1;
}
(5)改:按关键字修改结点数据
cs
/**
* @brief 按关键字修改结点数据(需用户提供比较函数)
* @param list:已初始化的头结点指针
* @param key:要修改的关键字(传入关键字地址)
* @param cmp:比较函数(用户自定义)
* @param new_data:新的数据(传入新数据地址)
* @return 0-修改成功,-1-修改失败(参数错误/链表为空/未找到关键字)
*/
int list_update(const listhead_t *list, const void *key, cmp_t cmp, const void *new_data) {
// 1. 校验参数
if (list == NULL || key == NULL || cmp == NULL || new_data == NULL) {
printf("修改失败:参数错误(list/key/cmp/new_data为空)\n");
return -1;
}
// 2. 处理链表为空的情况
if (list->head == NULL) {
printf("修改失败:链表为空,无数据可改\n");
return -1;
}
// 3. 遍历链表,查找要修改的结点
struct node_st *curr = list->head;
while (curr != NULL) {
if (cmp(curr->data, key) == 0) {
// 找到结点,替换数据(拷贝新数据到数据域)
memcpy(curr->data, new_data, list->size);
printf("修改关键字成功\n");
return 0;
}
curr = curr->next;
}
// 4. 未找到关键字
printf("修改失败:未找到指定关键字\n");
return -1;
}
(6)查:按关键字查找结点
cs
/**
* @brief 按关键字查找结点(需用户提供比较函数)
* @param list:已初始化的头结点指针
* @param key:要查找的关键字(传入关键字地址)
* @param cmp:比较函数(用户自定义)
* @return 找到:返回结点数据域地址;未找到:返回NULL
*/
void *list_find(const listhead_t *list, const void *key, cmp_t cmp) {
// 1. 校验参数
if (list == NULL || key == NULL || cmp == NULL) {
printf("查找失败:参数错误(list/key/cmp为空)\n");
return NULL;
}
// 2. 处理链表为空的情况
if (list->head == NULL) {
printf("查找失败:链表为空,无数据可查\n");
return NULL;
}
// 3. 遍历链表,查找结点
struct node_st *curr = list->head;
while (curr != NULL) {
if (cmp(curr->data, key) == 0) {
// 找到结点,返回数据域地址(用户可通过该地址获取数据)
printf("找到指定关键字\n");
return curr->data;
}
curr = curr->next;
}
// 4. 未找到关键字
printf("查找失败:未找到指定关键字\n");
return NULL;
}
(7)销毁整个链表(释放所有内存,避免泄漏)
cs
/**
* @brief 销毁整个链表,释放所有内存(头结点+所有数据结点)
* @param list:指向头结点指针的指针(用于修改外部头结点指针为NULL)
*/
void list_destroy(listhead_t **list) {
// 1. 校验参数
if (list == NULL || *list == NULL) {
printf("销毁失败:参数错误(list为空或链表已销毁)\n");
return;
}
// 2. 遍历链表,释放所有数据结点
struct node_st *curr = (*list)->head;
struct node_st *next = NULL; // 记录下一个结点,避免释放后无法遍历
while (curr != NULL) {
next = curr->next; // 先记录下一个结点
free(curr->data); // 释放数据域内存
free(curr); // 释放结点内存
curr = next; // 移动到下一个结点
}
// 3. 释放头结点内存,并将外部指针置为NULL(避免野指针)
free(*list);
*list = NULL;
printf("链表销毁成功,所有内存已释放\n");
}
3. 测试用例(完整可运行,方便读者验证)
cs
// 测试用例:以int类型数据为例,演示链表所有接口的使用
// 1. 自定义比较函数(int类型)
int cmp_int(const void *data1, const void *data2) {
return *(int *)data1 - *(int *)data2; // 相等返回0,不相等返回差值
}
// 2. 主函数测试
int main() {
listhead_t *list = NULL;
int size = sizeof(int); // 数据元素为int类型,大小为4字节
// 1. 初始化链表
if (listhead_init(&list, size) != 0) {
return -1;
}
// 2. 头插数据(10, 20, 30)
int a = 10, b = 20, c = 30;
list_add(list, &a);
list_add(list, &b);
list_add(list, &c); // 此时链表:30→20→10→NULL
// 3. 尾插数据(40)
int d = 40;
list_add_tail(list, &d); // 此时链表:30→20→10→40→NULL
// 4. 查找数据(20)
int key1 = 20;
int *find_data = (int *)list_find(list, &key1, cmp_int);
if (find_data != NULL) {
printf("找到的数据:%d\n", *find_data);
}
// 5. 修改数据(将20改为200)
int new_data = 200;
list_update(list, &key1, cmp_int, &new_data); // 此时链表:30→200→10→40→NULL
// 6. 删除数据(10)
int key2 = 10;
list_del(list, &key2, cmp_int); // 此时链表:30→200→40→NULL
// 7. 销毁链表
list_destroy(&list);
return 0;
}
4. 编译运行说明(小白必看)
- 将上述代码复制到 C 语言编译器(如 Dev-C++、VS Code)中,保存为
.c文件(如linklist.c); - 编译命令:
gcc linklist.c -o linklist(Linux/Mac)或直接点击编译器 "运行" 按钮(Windows); - 运行结果:会依次打印各操作的执行结果,验证所有接口功能正常;
- 注意事项:若提示 "内存分配失败",可检查编译器内存设置,或减少数据量。
四、高频编程作业(必练 + 思路详解 + 代码提示)
链表的核心在于 "指针操作",而以下两道作业题,是面试高频考点,也是巩固指针操作的最佳练习,建议大家先自己动手写,再参考思路和提示。
作业 1:单链表逆序(面试必考)
核心要求
将单链表的结点顺序完全反转(如原链表:1→2→3→4→NULL,反转后:4→3→2→1→NULL),要求时间复杂度 O (n),空间复杂度 O (1)(不允许使用额外数组 / 链表存储)。
实现思路(三步法,通俗易懂)
- 定义三个指针,分别指向当前结点、前驱结点、后继结点:
prev:指向当前结点的前驱结点(初始为 NULL);curr:指向当前正在处理的结点(初始为链表首元结点);next:指向当前结点的后继结点(用于保存下一个结点,避免反转后丢失)。
- 遍历链表,逐个反转指针指向:
- 先保存当前结点的后继结点(
next = curr->next); - 将当前结点的指针域指向前驱结点(
curr->next = prev); - 移动前驱和当前指针(
prev = curr; curr = next);
- 先保存当前结点的后继结点(
- 遍历结束后,修改头结点指向:
- 遍历结束时,
curr为 NULL,prev指向原链表的尾结点(反转后的首元结点); - 将头结点的
head指向prev,完成逆序。
- 遍历结束时,
代码提示(关键部分)
cs
// 单链表逆序函数
void list_reverse(listhead_t *list) {
if (list == NULL || list->head == NULL || list->head->next == NULL) {
// 链表为空或只有一个结点,无需逆序
return;
}
struct node_st *prev = NULL;
struct node_st *curr = list->head;
struct node_st *next = NULL;
while (curr != NULL) {
next = curr->next; // 保存后继结点
curr->next = prev; // 反转当前结点指针
prev = curr; // 移动prev
curr = next; // 移动curr
}
list->head = prev; // 头结点指向反转后的首元结点
}
作业 2:实现双链表(拓展进阶)
核心要求
在单链表基础上,实现双链表(双向链表),要求支持双向遍历、双向插入 / 删除,并实现完整的接口函数(初始化、增、删、改、查、销毁)。
核心拓展点
- 双链表的结点结构:在单链表结点基础上,增加前驱指针
prev,每个结点同时指向前一个和后一个结点; - 核心优势:支持双向遍历(既能从前往后,也能从后往前),删除指定结点时,无需遍历查找前驱结点(直接通过
prev获取),效率更高; - 注意事项:插入、删除时,需要同时修改两个指针(
prev和next),避免指针混乱。
双链表结构定义与核心接口提示
cs
// 双链表结点结构体
struct dnode_st {
void *data; // 数据域
struct dnode_st *prev; // 前驱指针(指向前一个结点)
struct dnode_st *next; // 后继指针(指向后一个结点)
};
// 双链表头结点结构体
typedef struct {
struct dnode_st *head; // 指向首元结点
struct dnode_st *tail; // 指向尾结点(方便尾插/尾删)
int size; // 数据元素大小
} dlisthead_t;
// 核心接口(参考单链表,修改指针操作)
int dlisthead_init(dlisthead_t **list, int size); // 初始化
int dlist_add(dlisthead_t *list, const void *data); // 头插
int dlist_add_tail(dlisthead_t *list, const void *data); // 尾插
int dlist_del(dlisthead_t *list, const void *key, cmp_t cmp); // 删除
// 其他接口(改、查、销毁)类似,需同时处理prev和next指针
五、顺序表 VS 单链表 总结对比(面试高频考点)
| 特性 | 顺序表 | 单链表 |
|---|---|---|
| 存储地址 | 连续 | 不连续 |
| 底层结构 | 数组 | 结点 + 指针 |
| 查找速度 | 快(随机访问,O (1)) | 慢(遍历查找,O (n)) |
| 插入 / 删除 | 慢(需移动元素,O (n)) | 快(仅修改指针,O (1)) |
| 内存利用率 | 较低(固定空间,易浪费) | 较高(按需分配,无浪费) |
| 内存开销 | 小(无额外指针开销) | 大(每个结点需额外存储指针) |
| 缓存命中率 | 高(连续存储) | 低(分散存储) |
| 适用场景 | 查找多、增删少(如成绩查询、字典检索) | 增删多、查找少(如消息队列、链表编辑器) |
| 实现难度 | 简单(无需指针操作) | 复杂(需处理指针,易出现野指针、内存泄漏) |
核心总结(划重点)
- 顺序表和单链表的核心区别:是否要求内存地址连续,这一区别决定了二者的优缺点和适用场景;
- 设计思想:没有完美的数据结构,只有适合场景的数据结构 ------ 顺序表牺牲增删效率换取查找效率,链表牺牲查找效率换取增删效率;
- 学习重点:单链表是链式结构的基础,掌握 "结点、指针、头结点" 的设计,以及插入、删除时的指针操作,就能轻松拓展双链表、循环链表;
- 避坑提醒:链表操作中,一定要注意 "内存泄漏"(忘记释放结点内存)和 "野指针"(指针指向已释放的内存),这是新手最容易犯的错误。
六、常见问题与避坑指南(小白必看)
- 问题 1:单链表插入结点时,指针顺序搞反,导致链表断裂?解决:先保存后继结点,再修改当前结点指针,最后移动指针(如头插时,先
new_node->next = list->head,再list->head = new_node)。 - 问题 2:删除结点后,忘记释放内存,导致内存泄漏?解决:删除结点时,先释放数据域内存,再释放结点本身,避免只释放结点、遗漏数据域。
- 问题 3:链表销毁后,外部指针未置为 NULL,导致野指针?解决:销毁链表时,释放头结点后,一定要将外部头结点指针置为 NULL(如
*list = NULL)。 - 问题 4:顺序表扩容时,拷贝数据出错?解决:扩容时,使用
memcpy函数拷贝原数据,确保拷贝的字节数等于 "原数据个数 × 元素大小",避免拷贝不完整。 - 问题 5:单链表无法直接获取前驱结点,如何优化?解决:使用双链表,通过
prev指针直接获取前驱结点,适合需要频繁获取前驱结点的场景。