数据结构:程序世界的基石与艺术: https://blog.csdn.net/R_Feynman_/article/details/148284180?spm=1001.2014.3001.5501
线性表是数据结构中最基本、最常用的一种结构,用于存储具有线性关系的数据元素集合。其特点是数据元素之间存在“一对一”的顺序关系,即除了第一个和最后一个元素外,每个元素都有唯一的前驱和后继。
有限性:元素个数有限(区别于无限数列)。
有序性:元素按线性顺序排列,有明确的先后关系。
同类型:通常要求所有元素属于同一数据类型(某些语言支持泛型时可放宽)。
线性表有两种主要存储方式:
顺序存储(顺序表)
实现方式:用数组实现,元素在内存中连续存放。
特点:
支持随机访问(通过下标直接访问,时间复杂度 O(1))。
插入/删除可能需要移动大量元素(平均时间复杂度 O(n))。
需预先分配固定空间,可能浪费内存或溢出。
链式存储(链表)
实现方式:通过结点(数据域 + 指针域)动态分配内存,元素物理上非连续。
特点:
插入/删除效率高(时间复杂度 O(1),若已知位置)。
不支持随机访问,需从头遍历(时间复杂度 O(n))。
无需预先分配空间,内存利用率高。
线性表是零个或多个数据元素的有限序列,相邻元素存在序偶关系。线性表ADT定义了其逻辑特性和基本操作,与实现方式无关(顺序存储/链式存储)。
ADT List {
// 数据对象
Data: 具有相同类型的数据元素集合 {a₁, a₂, ..., aₙ}, n≥0
// 关系定义
Relation: 元素间存在线性序偶关系 <aᵢ, aᵢ₊₁>
// 基本操作
InitList(&L) // 初始化空表
DestroyList(&L) // 销毁表
ListEmpty(L) // 判空 → 返回布尔值
ListLength(L) // 返回元素个数n
GetElem(L, i, &e) // 用e返回位置i的元素值
LocateElem(L, e) // 返回元素e的位置(无则返回0)
ListInsert(&L, i, e) // 在位置i插入e(原i及后续元素后移)
ListDelete(&L, i, &e) // 删除位置i的元素 → 用e返回其值
PriorElem(L, e, &pre_e) // 获取e的直接前驱 → pre_e
NextElem(L, e, &next_e) // 获取e的直接后继 → next_e
TraverseList(L) // 按序访问所有元素(如打印)
}
操作名称 | 输入 | 输出/效果 | 前置条件 | 后置条件 |
---|---|---|---|---|
InitList(&L) |
无 |
创建一个空线性表 L | 无 | L 被初始化为空表 |
DestroyList(&L) |
线性表 L | 释放线性表 L 的所有存储空间 | L 已存在且已被初始化 | L 被销毁,空间被释放 |
ListEmpty(L) |
线性表 L | 返回布尔值(True: 空表, False: 非空) | L 已存在且已被初始化 | 线性表 L 保持不变 |
ListLength(L) |
线性表 L | 返回线性表的元素个数 | L 已存在且已被初始化 | 线性表 L 保持不变 |
GetElem(L, i, &e) |
L, 位置 i (1≤i≤n) | 将位置 i 的元素值赋给 e | L 存在且 1≤i≤ListLength(L) | 线性表 L 保持不变 |
LocateElem(L, e) |
L, 元素 e | 返回 e 首次出现的位置(0表示不存在) | L 已存在且已被初始化 | 线性表 L 保持不变 |
ListInsert(&L, i, e) |
L, 位置 i (1≤i≤n+1), 元素 e | 在位置 i 插入新元素 e | L 存在且 1≤i≤ListLength(L)+1 | 表长度+1,原 i 及后续元素后移 |
ListDelete(&L, i, &e) |
L, 位置 i (1≤i≤n) | 删除位置 i 的元素,值赋给 e | L 存在且 1≤i≤ListLength(L) | 表长度-1,原 i+1 及后续元素前移 |
PriorElem(L, e, &pre_e) |
L, 元素 e | 获取 e 的直接前驱元素赋给 pre_e | e 存在且不是首元素 | 线性表 L 保持不变 |
NextElem(L, e, &next_e) |
L, 元素 e | 获取 e 的直接后继元素赋给 next_e | e 存在且不是尾元素 | 线性表 L 保持不变 |
TraverseList(L) |
L | 按顺序访问线性表的所有元素 | L 已存在且已被初始化 | 线性表 L 保持不变 |
#define MAX_SIZE 100
int array[MAX_SIZE];
int length = 0; // 当前数组的实际长度
初始化时,将数组长度设置为0。
void InitArray() {
length = 0;
}
在数组的指定位置插入一个元素。
插入操作需要从后向前移动元素,避免覆盖。
插入后更新数组长度。
int Insert(int index, int value) {
// 检查是否超出容量
if (length >= MAX_SIZE) {
return -1; // 数组已满,插入失败
}
// 检查插入位置是否合法
if (index < 0 || index > length) {
return -1; // 位置不合法
}
// 从后向前移动元素,为新元素腾出空间
for (int i = length; i > index; i--) {
array[i] = array[i - 1];
}
// 插入新元素
array[index] = value;
length++;
return 0; // 插入成功
}
删除数组中指定位置的元素。
从删除位置开始,将后续元素向前移动一位。
减少数组长度。
int Delete(int index) {
// 检查数组是否为空
if (length == 0) {
return -1; // 数组为空,删除失败
}
// 检查删除位置是否合法
if (index < 0 || index >= length) {
return -1; // 位置不合法
}
// 从删除位置开始,将后续元素向前移动一位
for (int i = index; i < length - 1; i++) {
array[i] = array[i + 1];
}
length--;
return 0; // 删除成功
}
修改数组中指定位置的元素。
无需移动其他元素。
int Update(int index, int value) {
// 检查数组是否为空
if (length == 0) {
return -1; // 数组为空,修改失败
}
// 检查修改位置是否合法
if (index < 0 || index >= length) {
return -1; // 位置不合法
}
// 修改元素
array[index] = value;
return 0; // 修改成功
}
查询数组中指定位置的元素。
int GetElement(int index, int *value) {
// 检查数组是否为空
if (length == 0) {
return -1; // 数组为空,查询失败
}
// 检查查询位置是否合法
if (index < 0 || index >= length) {
return -1; // 位置不合法
}
// 获取元素
*value = array[index];
return 0; // 查询成功
}
#include
#define MAX_SIZE 100
int array[MAX_SIZE];
int length = 0;
void InitArray() {
length = 0;
}
int Insert(int index, int value) {
if (length >= MAX_SIZE) {
return -1;
}
if (index < 0 || index > length) {
return -1;
}
for (int i = length; i > index; i--) {
array[i] = array[i - 1];
}
array[index] = value;
length++;
return 0;
}
int Delete(int index) {
if (length == 0) {
return -1;
}
if (index < 0 || index >= length) {
return -1;
}
for (int i = index; i < length - 1; i++) {
array[i] = array[i + 1];
}
length--;
return 0;
}
int Update(int index, int value) {
if (length == 0) {
return -1;
}
if (index < 0 || index >= length) {
return -1;
}
array[index] = value;
return 0;
}
int GetElement(int index, int *value) {
if (length == 0) {
return -1;
}
if (index < 0 || index >= length) {
return -1;
}
*value = array[index];
return 0;
}
void PrintArray() {
printf("Array: ");
for (int i = 0; i < length; i++) {
printf("%d ", array[i]);
}
printf("\n");
}
int main() {
InitArray();
Insert(0, 10);
Insert(1, 20);
Insert(2, 30);
PrintArray(); // 输出:Array: 10 20 30
Delete(1);
PrintArray(); // 输出:Array: 10 30
Update(1, 50);
PrintArray(); // 输出:Array: 10 50
int value;
GetElement(1, &value);
printf("Element at index 1: %d\n", value); // 输出:Element at index 1: 50
return 0;
}
运行上述代码后,输出如下:
Array: 10 20 30
Array: 10 30
Array: 10 50
Element at index 1: 50
单链表是一种常见的线性数据结构,它由一系列节点组成,每个节点包含两部分:数据域和指针域。
数据域:用于存储数据元素,可以是任意类型的数据,如整数、浮点数、字符串等。
指针域:用于存储指向下一个节点的指针(或引用)。最后一个节点的指针域通常指向空(null 或 None),表示链表的结束。
单链表的特点:
① 动态存储:链表的节点可以根据需要动态分配和释放内存,因此其大小可以灵活变化,不受数组等静态存储结构的大小限制。
② 非连续存储:链表的节点在内存中可以分散存储,不需要像数组那样连续存储,这使得它在插入和删除操作时效率较高,因为不需要移动大量元素。
③ 单向性:由于每个节点只包含指向下一个节点的指针,因此单链表只能从头节点开始顺序访问每个节点,无法直接访问中间节点。
单链表通常需要一个头指针(或头节点)来标识链表的起始位置。头指针指向头结点,如果链表为空,则头指针指向空。
1. 结点(Node)
定义:链表的基本组成单元,包含两部分:
数据域(Data Field):存储实际数据。
指针域(Next Field):存储指向下一个结点的地址。
2. 首元结点(First Element Node)
定义:链表中第一个存储实际数据的结点(非头结点)。
特点:
是数据域的起点,存储链表的第一个有效数据。
在不带头结点的链表中,首元结点直接由头指针指向。
在带头结点的链表中,首元结点是头结点的下一个结点。
3. 头指针(Head Pointer)
定义:指向链表第一个结点的指针(无论第一个结点是头结点还是首元结点)。
特点:
必须存在,是访问链表的唯一入口。
若链表为空:头指针指向 NULL。
若链表带头结点:头指针指向头结点(头结点不存储实际数据)。
若链表不带头结点:头指针直接指向首元结点(存储实际数据)。
4. 头结点(Header Node)
定义:附加在链表首元结点之前的辅助结点,不存储实际数据。
特点:
数据域无意义:通常为空或存储链表长度等元信息。
指针域指向首元结点:若链表为空,则指针域为 NULL。
非必需:链表可以没有头结点(由设计需求决定)。
作用:
统一操作:简化插入/删除首元结点的逻辑(无需修改头指针)。
避免空指针:空链表时头指针仍指向头结点(而非 NULL)。
情况1: 头指针指向头结点(带头结点的空链表)
┌──────────┐ ┌──────────┐
│ 头指针 │───▶│ 头结点 │───▶ NULL
└──────────┘ └──────────┘
(空链表但头指针不为NULL)
情况2: 头指针为NULL(不带头结点的空链表)
┌──────────┐
│ 头指针 │───▶ NULL
└──────────┘
(真正的空链表)
① 头指针指向头结点
状态:空链表但带头结点
注意:
增加头节点后,无论链表是否为空,头指针都是指向头结点的非空指针。如下图所示的非空单链表,头指针指向头结点。
头指针 head
│
▼
┌────────────┐
│ 头结点 │
│ (数据域空) │
├────────────┤
│ next │───┐
└────────────┘ │
▼
┌────────────┐ ┌────────────┐
│ 元素1 │ │ 元素2 │
├────────────┤ ├────────────┤
│ 数据: A │ │ 数据: B │
├────────────┤ ├────────────┤
│ next │───▶│ next │───▶ NULL
└────────────┘ └────────────┘
若为空表,则头节点的指针域为空(判定空表的条件可记为:L -> Next == NULL)
┌──────────┐ ┌──────────┐
│ 头指针 │───▶│ 头结点 │───▶ NULL
└──────────┘ └──────────┘
(空链表但头指针不为NULL)
内存结构:
头指针 → [头结点] → NULL
└─数据域可能为空/0/长度等
特点:
头指针非空:存在一个头结点作为占位符
空链表标识:头结点的指针域为NULL
操作特性:
插入第一个数据时:头结点的指针域指向新结点
删除最后一个数据时:头结点指针域恢复为NULL
头指针永远不需要改变
② 头指针为NULL
状态:完全空链表(不带头结点)
内存结构:
头指针 → NULL
特点:
绝对空链表:没有任何结点存在
操作特性:
插入第一个数据时:头指针必须修改为指向新结点
删除最后一个数据时:头指针必须重置为NULL
边界操作需额外检查空指针
特征 | 头指针指向头结点 | 头指针为NULL |
---|---|---|
链表类型 | 带头结点的链表 | 不带头结点的链表 |
空链表表示 | 头结点的指针域为NULL | 头指针直接为NULL |
头指针稳定性 | 永远不会改变 | 插入/删除首元素时需改变 |
空链表内存占用 | ≥1个结点(头结点) | 0个结点 |
首元结点插入 | 无需特判空链表 | 必须单独处理空链表情况 |
代码一致性 | 所有位置插入/删除操作逻辑统一 | 首元素操作需特殊处理 |
#include
#include
// 定义链表节点结构
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域
} Node;
返回值:指向头节点的指针
Node* initList() {
Node *head = (Node*)malloc(sizeof(Node)); // 创建头节点
head->next = NULL; // 头节点指针域置空
return head;
}
head: 链表头指针
pos: 位置(从1开始计数)
e: 用来存放取到的元素
返回值:成功返回1,失败返回0
int getElem(Node *head, int pos, int *e) {
Node *p = head->next; // 指向第一个实际节点
int count = 1; // 计数器
// 遍历链表直到找到位置或到达末尾
while (p && count < pos) {
p = p->next;
count++;
}
// 若位置无效或链表为空
if (!p || count > pos)
return 0;
*e = p->data; // 通过指针返回元素值
return 1;
}
head: 链表头指针
e: 要查找的元素
返回值:元素位置(从1开始),未找到返回0
int locateElem(Node *head, int e) {
Node *p = head->next; // 指向第一个实际节点
int pos = 1; // 位置计数器
// 遍历链表查找元素
while (p) {
if (p->data == e)
return pos;
p = p->next;
pos++;
}
return 0; // 未找到
}
head: 链表头指针
e: 要插入的元素
void insertAtHead(Node *head, int e) {
Node *newNode = (Node*)malloc(sizeof(Node)); // 创建新节点
newNode->data = e; // 设置新节点数据
newNode->next = head->next; // 新节点指向原第一个节点
head->next = newNode; // 头节点指向新节点
}
head: 链表头指针
e: 要插入的元素
void insertAtTail(Node *head, int e) {
Node *newNode = (Node*)malloc(sizeof(Node)); // 创建新节点
newNode->data = e;
newNode->next = NULL;
Node *p = head;
// 遍历到最后一个节点
while (p->next) {
p = p->next;
}
p->next = newNode; // 尾节点指向新节点
}
head: 链表头指针
pos: 插入位置(从1开始)
e: 要插入的元素
返回值:成功返回1,失败返回0
int insertAtPos(Node *head, int pos, int e) {
Node *p = head;
int count = 0;
// 寻找第pos-1个节点
while (p && count < pos - 1) {
p = p->next;
count++;
}
// 若位置无效
if (!p || count > pos - 1)
return 0;
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = e;
newNode->next = p->next; // 新节点指向原位置节点
p->next = newNode; // 前一节点指向新节点
return 1;
}
head: 链表头指针
pos: 删除位置(从1开始)
e: 保存删除的元素值
返回值:成功返回1,失败返回0
int deleteAtPos(Node *head, int pos, int *e) {
Node *p = head;
int count = 0;
// 寻找第pos-1个节点
while (p->next && count < pos - 1) {
p = p->next;
count++;
}
// 若位置无效
if (!(p->next) || count > pos - 1)
return 0;
Node *delNode = p->next; // 要删除的节点
*e = delNode->data; // 保存删除的值
p->next = delNode->next; // 跳过删除节点
free(delNode); // 释放内存
return 1;
}
void printList(Node *head) {
Node *p = head->next; // 跳过头节点
while (p) {
printf("%d -> ", p->data);
p = p->next;
}
printf("NULL\n");
}
顺序表: 核心是连续内存块。像一排座位,元素必须挨着坐。这使得它内存访问效率高(直接计算地址),但也有致命弱点:插入/删除元素中间时需要大量移动其它元素(O(n))。容量固定或需扩容(申请新块 → 搬数据 → 释放旧块,开销大)。
链表: 核心是节点(包含数据和指针)和指针链接。像一群人手拉手(或拉前后两人),站哪不重要,记住拉谁就行。插入/删除(尤其在已知位置后)只需修改少量指针(O(1))。空间天然动态,按需分配节点。缺点是无法直接定位第N个元素(O(n)遍历)。
随机访问 (Access by Index):
顺序表完胜(O(1) vs O(n))。
插入/删除 (Insertion/Deletion):
在链表已知位置插入/删除节点时(尤其是头、尾或已知节点前后),链表优势巨大(O(1))。
在顺序表中间插入/删除,需要移动后续元素(O(n))。
在顺序表尾端插入/删除,若无扩容/缩容,也是O(1);但在链表尾操作,若无尾指针也需要先遍历到尾(O(n)),有尾指针则为O(1)。
查找 (Search by Value):
两者都需要遍历(O(n))。如果顺序表有序,可用二分查找达到O(log n),链表则不行。
顺序表: 空间密集(元素本身),但有“容量”限制。扩容时可能申请过大的预留空间造成浪费(内部碎片),碎片化问题通常较少。元素本身存储开销小。
链表: 空间稀疏(元素 + 指针开销,对于小对象尤为不利)。没有预留空间浪费,按需分配,碎片化问题主要集中在内存分配器层面而非数据结构本身。无“容量”概念。
顺序表简单直接(维护数组和大小)。
链表稍复杂(创建节点、维护指针:头指针、尾指针(可选)、双向链表的前驱指针。需处理边界条件(空表、头尾操作)和指针异常,特别是双向链表。
特性 | 顺序表(数组) | 链表 | 总结对比 |
---|---|---|---|
存储方式 | 连续内存空间 | 分散内存空间(通过指针链接) | 顺序表物理连续,链表逻辑连续但物理分散 |
容量管理 | 固定大小或需扩容 | 动态按需分配 | 链表天然支持动态扩容 |
随机访问(索引访问) | ✅ O(1) (直接寻址) | ❌ O(n) (需遍历) | 顺序表完胜 |
头插/删效率 | ❌ O(n) (需移动后续元素) | ✅ O(1) (仅修改指针) | 链表完胜 |
尾插/删效率 | ✅ O(1) (预空间充足时) | ✅ O(1) (有尾指针时)/O(n) (无尾指针) | 顺序表更稳定 |
中插/删效率 | ❌ O(n) (需移动元素) | ⚡ O(1) (定位后修改指针) | 链表更优 |
按值查找 | ⚡ O(n) (有序时可O(log n)) | ❌ O(n) (必须遍历) | 顺序表稍优(可二分) |
内存开销 | 仅元素空间 | 元素空间+指针空间(每个节点1-2个指针) | 链表空间开销更大 |
内存碎片 | 内部碎片(预留空间) | 外部碎片(多次分配) | 顺序表内存利用率更低 |
缓存友好性 | ✅ 连续内存预读取高效 | ❌ 分散内存缓存命中率低 | 顺序表访问性能更高 |
实现复杂度 | 简单直观 | 指针操作需谨慎(空指针/断链等) | 链表更易出错 |
扩容代价 | 高(O(n)):申请新空间+数据迁移 | 低(O(1)):只分配单个节点 | 链表扩容零迁移 |
迭代器安全性 | ❌ 扩容后迭代器失效 | ✅ 节点删除仅影响当前节点 | 链表迭代器更安全 |
适用场景 | ✔️ 高频随机访问 ✔️ 数据量稳定 ✔️ 注重访问性能 |
✔️ 频繁插入删除 ✔️ 数据量变化大 ✔️ 内存受限 |
根据核心操作选择 |