数据结构学习之路——顺序表与单向链表
一、前置知识:什么是线性结构与抽象数据类型
在正式学习两种结构前,我们先理清基础概念。
早期编程使用机器语言,直接操作二进制和内存地址,存储、运算数据既低效又容易出错。于是高级语言引入数据类型,屏蔽了底层内存细节,还能校验运算合法性。
在此基础上延伸出抽象数据类型(ADT):它是一个数学模型 + 一组配套操作,只定义数据范围和可执行行为,不限制具体实现。就像游戏角色马里奥,定义了前进、跳跃、射击等动作,至于代码如何实现并不关心。
而线性结构是一类特殊的数据组织形式,满足三条规则:
- 除首尾元素外,每个元素仅有一个前驱、一个后继;
- 第一个元素没有前驱;
- 最后一个元素没有后继。
简单理解:数据像一条笔直的队伍,前后顺序固定,顺序表和单向链表,都是线性结构的落地实现。
二、顺序表:连续内存的线性表
2.1 基本概念
顺序表是利用一组地址连续的存储单元,依次存放线性表数据元素的结构,在 C 语言中底层由数组实现。 它的逻辑顺序和物理内存顺序完全一致,所有数据紧密排列,中间没有空闲空间。
内存布局示例:
下标: [0] [1] [2] [3] [4]
数据: 10 20 30 40 50
地址: 0x100 0x104 0x108 0x10C 0x110
顺序表支持增、删、改、查、插入五大基础操作。
2.2 核心操作原理
1. 插入元素
插入分为三种场景:表头插入、中间插入、尾部插入,核心逻辑一致:
- 将插入位置及后续所有元素整体向后移动一位,腾出空位;
- 把新元素放入空位,表长度加一。
示例:在 {1,2,4,5,6,7} 第三个位置插入 3 原序列:1 2 4 5 6 7 元素后移:1 2 4 5 6 7 插入完成:1 2 3 4 5 6 7
2. 删除元素
找到目标元素后,将该元素后续所有数据整体向前移动一位,间接覆盖目标元素,最后表长度减一。
2.3 时间复杂度与优缺点
时间复杂度
- 随机访问(按下标查找):\(O(1)\)。依靠下标直接定位,效率极高;
- 按值查找、中间 / 头部增删:\(O(n)\)。需要遍历或移动大量元素;
- 尾部增删:\(O(1)\)。无需移动元素,仅修改长度即可。
优点
- 支持随机访问,查询速度快;
- 内存连续,无额外冗余数据,空间利用率高。
缺点
- 长度固定,初始化时必须指定最大容量,扩容需要重新开辟内存并拷贝所有数据,灵活性差;
- 提前分配内存,容易出现空间不足或空间浪费的问题;
- 在头部、中间位置插入 / 删除数据时,需要大量移动元素,效率低下。
2.4 适用场景
适合查询操作频繁、增删操作少、数据量相对固定的业务场景,例如静态配置表、固定人员名单等。
2.5 C 语言完整实现
#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 100 // 顺序表最大容量
// 定义顺序表结构体
typedef struct {
int data[MAX_SIZE];
int length; // 当前元素个数
} SeqList;
// 初始化顺序表
void init(SeqList *L) {
L->length = 0;
}
// 按下标插入元素
int insert(SeqList *L, int i, int x) {
if (i < 0 || i > L->length || L->length >= MAX_SIZE) {
return 0; // 插入失败
}
// 元素后移
for (int j = L->length; j > i; j--) {
L->data[j] = L->data[j - 1];
}
L->data[i] = x;
L->length++;
return 1;
}
// 按下标删除元素
int delete(SeqList *L, int i) {
if (i < 0 || i >= L->length) {
return 0; // 删除失败
}
// 元素前移
for (int j = i; j < L->length - 1; j++) {
L->data[j] = L->data[j + 1];
}
L->length--;
return 1;
}
// 按值查找,返回下标,未找到返回-1
int find(SeqList *L, int x) {
for (int i = 0; i < L->length; i++) {
if (L->data[i] == x)
return i;
}
return -1;
}
// 打印顺序表
void print(SeqList *L) {
for (int i = 0; i < L->length; i++) {
printf("%d ", L->data[i]);
}
printf("\n");
}
三、单向链表:指针串联的动态线性表
为了解决顺序表长度固定、增删低效的问题,链表应运而生。链表是使用场景第二广的通用数据结构,而单向链表是链表中最简单的形态。
3.1 基本概念
单向链表的特点:物理内存不连续,逻辑顺序连续。它依靠节点和指针串联所有数据。 每个节点分为两部分:
- 数据域:存储业务数据;
- 指针域:存储下一个节点的内存地址,用来连接节点。
逻辑结构:[数据|指针] → [数据|指针] → [数据|指针] → NULL
重要概念:头指针 & 头结点
- 头指针:必备元素,指向链表第一个节点,是访问整个链表的入口;
- 头结点:可选的虚拟节点,一般不存储有效数据。
带头结点是开发主流写法:空链表、非空链表、表头插入删除,都可以使用同一套代码逻辑,无需额外特殊判断,大幅降低代码复杂度。而不带头结点的链表,操作首元素时需要单独处理,实用性较差。
3.2 核心操作原理
单向链表所有操作本质都是修改指针指向,无需移动数据。
- 插入节点:找到目标前驱节点,修改指针指向即可完成插入;
- 删除节点:必须先找到被删节点的前驱节点,让前驱节点的指针跳过目标节点,最后释放内存。
3.3 时间复杂度与优缺点
时间复杂度
- 已知位置的增删操作:\(O(1)\),仅修改指针,效率极高;
- 查找、定位节点:\(O(n)\),不支持随机访问,必须从表头逐个遍历。
优点
- 动态内存分配,无需提前指定容量,长度不受限制,也不会造成空间浪费;
- 任意位置增删节点效率高,无需移动大量数据;
- 内存无需连续,对内存碎片化场景友好。
缺点
- 无法随机访问,查找元素只能顺序遍历;
- 每个节点都附带指针域,存在额外内存开销;
- 指针操作复杂,容易出现断链、野指针等问题,调试难度高。
3.4 适用场景
适合数据量不确定、频繁执行插入 / 删除操作的场景,例如消息队列、动态列表等。
3.5 C 语言完整实现
1. 节点定义与初始化
#include <stdio.h>
#include <stdlib.h>
// 定义链表节点
typedef struct ListNode {
int data;
struct ListNode* next;
} Node, *LinkList;
// 初始化带头结点的空链表
LinkList InitList() {
Node* h = (Node*)malloc(sizeof(Node));
if (h == NULL) {
printf("内存分配失败\n");
return NULL;
}
h->next = NULL;
return h;
}
2. 基础功能:头插、尾插、查找、插入、删除、求长度
// 头插法
LinkList Head_insert(LinkList l, int k) {
Node* s = (Node*)malloc(sizeof(Node));
s->data = k;
s->next = l->next;
l->next = s;
return l;
}
// 尾插法
LinkList Rear_insert(LinkList l, int k) {
Node* p = l;
while (p->next != NULL)
p = p->next;
Node* s = (Node*)malloc(sizeof(Node));
s->data = k;
s->next = NULL;
p->next = s;
return l;
}
// 按值查找节点
Node* find(LinkList l, int x) {
Node* p = l->next;
while (p != NULL && p->data != x)
p = p->next;
return p;
}
// 在指定值之后插入元素
LinkList Insert(LinkList l, int x, int k) {
Node* p = find(l, x);
if (p == NULL) {
printf("目标数据不存在\n");
return l;
}
Node* s = (Node*)malloc(sizeof(Node));
s->data = k;
s->next = p->next;
p->next = s;
return l;
}
// 按值删除节点
LinkList Delet(LinkList l, int k) {
if (l->next == NULL) {
printf("链表为空\n");
return l;
}
Node* p = l;
Node* q = l->next;
while (q != NULL && q->data != k) {
p = q;
q = q->next;
}
if (q == NULL) {
printf("待删除数据不存在\n");
return l;
}
p->next = q->next;
free(q);
q = NULL;
return l;
}
// 获取链表长度
int get_size(LinkList l) {
int sum = 0;
Node* p = l->next;
while (p != NULL) {
sum++;
p = p->next;
sum++;
p = p->next;
}
return sum;
}
3. 主函数测试
int main() {
LinkList l = InitList();
// 头插
l = Head_insert(l, 3);
l = Head_insert(l, 2);
l = Head_insert(l, 1);
// 尾插
l = Rear_insert(l, 4);
l = Rear_insert(l, 5);
// 指定位置插入
l = Insert(l, 4, 9);
// 删除元素
l = Delet(l, 5);
// 输出长度
printf("链表长度:%d\n", get_size(l));
return 0;
}
四、顺序表 VS 单向链表 全面对比
| 对比维度 | 顺序表 | 单向链表 |
|---|---|---|
| 内存存储 | 物理地址连续 | 物理地址分散,逻辑连续 |
| 访问方式 | 支持随机访问,\(O(1)\) | 仅顺序遍历,\(O(n)\) |
| 增删效率 | 头尾增删\(O(1)\),中间 / 头部\(O(n)\) | 已知位置增删\(O(1)\) |
| 空间特性 | 预分配内存,易浪费 / 不足,无额外开销 | 动态分配,有指针内存开销 |
| 容量 | 固定,扩容复杂 | 动态可变,灵活 |
| 适用场景 | 查询多、增删少、数据固定 | 频繁增删、数据量不确定 |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)