目录
[2.1 存储方式与寻址](#2.1 存储方式与寻址)
[2.2 基本操作的实现](#2.2 基本操作的实现)
[2.3 性能特征](#2.3 性能特征)
[3.1 单链表](#3.1 单链表)
[3.2 双向链表与循环链表](#3.2 双向链表与循环链表)
[3.3 基本操作的实现](#3.3 基本操作的实现)
[3.4 性能特征](#3.4 性能特征)
[5.1 单链表的反转](#5.1 单链表的反转)
[5.2 检测链表中的环](#5.2 检测链表中的环)
[5.3 合并两个有序链表](#5.3 合并两个有序链表)
一、线性表的定义与基本概念
线性表(Linear List)是由 n(n ≥ 0)个类型相同的数据元素构成的有限序列。当 n = 0 时称为空表。除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。这种"一对一"的逻辑关系是线性结构的基本特征。
线性表支持以下核心操作:
初始化(InitList)
销毁(DestroyList)
插入(ListInsert)
删除(ListDelete)
按位置取值(GetElem)
按值查找(LocateElem)
求长度(ListLength)
判空(IsEmpty)
根据在计算机中存储方式的不同,线性表有两种基本的物理实现:顺序存储结构 和链式存储结构。下文分别讨论。
二、线性表的顺序存储结构(顺序表)
2.1 存储方式与寻址
顺序表采用一组地址连续的存储单元依次存放线性表中的数据元素,逻辑上相邻的元素在物理位置上也相邻。通常用高级语言中的数组来实现。
设每个元素占用 L 个存储单元,则第 i 个元素(0-based)的存储地址为:
text
Loc(a_i) = Loc(a_0) + i × L
该公式使得顺序表能够实现随机访问,即存取任意位置元素的时间复杂度为 O(1)。
2.2 基本操作的实现
以下给出动态顺序表的 C 语言实现。采用动态数组,支持容量扩展。
cpp
#include <stdio.h>
#include <stdlib.h>
#define INIT_SIZE 10
#define INCREMENT 5
typedef int ElemType;
typedef struct {
ElemType *data; // 动态数组指针
int length; // 当前元素个数
int capacity; // 当前分配容量
} SeqList;
// 初始化
void InitList(SeqList *L) {
L->data = (ElemType *)malloc(INIT_SIZE * sizeof(ElemType));
if (!L->data) exit(1);
L->length = 0;
L->capacity = INIT_SIZE;
}
// 销毁
void DestroyList(SeqList *L) {
if (L->data) {
free(L->data);
L->data = NULL;
L->length = 0;
L->capacity = 0;
}
}
// 插入:在位置 i(0-based)插入元素 e
int ListInsert(SeqList *L, int i, ElemType e) {
if (i < 0 || i > L->length) return 0; // 位置非法
if (L->length >= L->capacity) {
ElemType *newData = (ElemType *)realloc(L->data,
(L->capacity + INCREMENT) * sizeof(ElemType));
if (!newData) return 0;
L->data = newData;
L->capacity += INCREMENT;
}
// 从后往前移动元素,空出位置 i
for (int j = L->length; j > i; j--) {
L->data[j] = L->data[j - 1];
}
L->data[i] = e;
L->length++;
return 1;
}
// 删除:删除位置 i 的元素,并用 e 返回其值
int ListDelete(SeqList *L, int i, ElemType *e) {
if (i < 0 || i >= L->length) return 0;
*e = L->data[i];
// 从前往后移动元素,填补空位
for (int j = i; j < L->length - 1; j++) {
L->data[j] = L->data[j + 1];
}
L->length--;
return 1;
}
// 按位置取值
int GetElem(SeqList *L, int i, ElemType *e) {
if (i < 0 || i >= L->length) return 0;
*e = L->data[i];
return 1;
}
// 按值查找,返回首次出现的位置,未找到返回 -1
int LocateElem(SeqList *L, ElemType e) {
for (int i = 0; i < L->length; i++) {
if (L->data[i] == e) return i;
}
return -1;
}
// 求长度
int ListLength(SeqList *L) {
return L->length;
}
// 判空
int IsEmpty(SeqList *L) {
return L->length == 0;
}
2.3 性能特征
插入/删除 :平均需要移动约 n/2 个元素,时间复杂度为 O(n)。在表尾操作时为 O(1)。
按位置访问 :通过地址直接计算,时间复杂度为 O(1)。
按值查找 :平均比较 n/2 次,时间复杂度为 O(n)。
空间利用率:除数据本身外无额外开销,存储密度高。
空间分配:需要预先分配连续空间,扩容时需整体搬移。
三、线性表的链式存储结构(链表)
链表用一组任意的存储单元存放线性表的元素,这些单元在物理上可以不连续。元素间的逻辑关系通过指针来链接。
3.1 单链表
每个节点包含数据域和指针域,最后一个节点的指针域为空。通常引入头结点,其作用是统一空表与非空表的操作,使首元节点的插入删除与其他节点一致。
节点定义:
cpptypedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList;
3.2 双向链表与循环链表
双向链表 :节点增加指向前驱的指针
prior,使得从任一节点均可向前或向后遍历。循环链表:尾节点的 next 指向头结点(或首元节点),形成环。双向循环链表则将头结点的 prior 指向尾节点。
这些变体在某些场景下具有优势。
3.3 基本操作的实现
以下为带头结点的单链表核心操作。
cpp#include <stdio.h> #include <stdlib.h> typedef int ElemType; typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; // 初始化 void InitList(LinkList *L) { *L = (LNode *)malloc(sizeof(LNode)); if (!(*L)) exit(1); (*L)->next = NULL; } // 销毁(释放所有节点) void DestroyList(LinkList L) { LNode *p = L; while (p) { LNode *q = p->next; free(p); p = q; } } // 插入:在位置 i(0-based)插入元素 e int ListInsert(LinkList L, int i, ElemType e) { if (i < 0) return 0; LNode *p = L; int j = 0; while (p && j < i) { // 找到第 i 个节点的前驱 p = p->next; j++; } if (!p) return 0; // i 超出范围 LNode *s = (LNode *)malloc(sizeof(LNode)); if (!s) return 0; s->data = e; s->next = p->next; p->next = s; return 1; } // 删除:删除位置 i 的元素,用 e 返回 int ListDelete(LinkList L, int i, ElemType *e) { if (i < 0) return 0; LNode *p = L; int j = 0; while (p && j < i) { p = p->next; j++; } if (!p || !p->next) return 0; LNode *q = p->next; *e = q->data; p->next = q->next; free(q); return 1; } // 按位置取值 int GetElem(LinkList L, int i, ElemType *e) { if (i < 0) return 0; LNode *p = L->next; int j = 0; while (p && j < i) { p = p->next; j++; } if (!p) return 0; *e = p->data; return 1; } // 按值查找,返回位置(0-based),未找到返回 -1 int LocateElem(LinkList L, ElemType e) { LNode *p = L->next; int i = 0; while (p) { if (p->data == e) return i; p = p->next; i++; } return -1; } // 求长度 int ListLength(LinkList L) { int len = 0; LNode *p = L->next; while (p) { len++; p = p->next; } return len; } // 判空 int IsEmpty(LinkList L) { return L->next == NULL; }
3.4 性能特征
插入/删除:若已知插入位置的前驱,则操作时间为 O(1);若需按位置查找,则定位过程为 O(n)。
按位置访问 :必须从表头遍历,时间复杂度为 O(n)。
按值查找 :平均遍历约 n/2 个节点,时间复杂度为 O(n)。
空间利用率:每个节点额外存储指针(单链表 1 个,双向 2 个),存储密度较低。
空间分配:节点动态申请,无需连续空间,扩展灵活,无内存碎片。
四、两种存储结构的特点与应用场景
顺序表和链表各自具有不同的适应性,选择时需根据具体应用的操作特征来决定。
顺序表适用情形:
主要操作为按位置访问,如数组索引、缓存系统。
数据量较固定或变化幅度较小,可预先分配空间。
插入删除操作较少,或主要在表尾进行。
链表适用情形:
数据量动态变化,无法预估最大值。
主要操作为插入和删除,且位置分散。
对随机访问需求很低,不需要频繁按位置取值。
在实际系统中,顺序表因其内存连续、缓存友好,在数据量较小时往往表现出更好的实际性能;链表则更适合构造更复杂的数据结构,如邻接表、LRU 缓存等。两者均为线性表的重要实现方式,不应简单评判优劣,而应根据场景合理选用。
五、经典问题与算法示例
线性表的链式存储由于其指针操作具有较高技巧性,是算法考察的重点。以下列举三个典型问题及其标准解法。
5.1 单链表的反转
将单链表逆序,使每个节点的 next 指向前驱。
cppLNode* ReverseList(LNode *head) { LNode *prev = NULL; LNode *curr = head; LNode *next = NULL; while (curr != NULL) { next = curr->next; // 保存后继 curr->next = prev; // 反转指针 prev = curr; curr = next; } return prev; // 新头结点 }该方法仅使用常数个辅助指针,空间复杂度 O(1),时间复杂度 O(n)。
5.2 检测链表中的环
利用快慢指针,若存在环则二者必定相遇。
cppint HasCycle(LNode *head) { LNode *slow = head; LNode *fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; if (slow == fast) return 1; } return 0; }
5.3 合并两个有序链表
将两个递增有序的单链表合并为一个递增有序链表,可递归实现。
cppLNode* MergeTwoLists(LNode *l1, LNode *l2) { if (l1 == NULL) return l2; if (l2 == NULL) return l1; if (l1->data < l2->data) { l1->next = MergeTwoLists(l1->next, l2); return l1; } else { l2->next = MergeTwoLists(l1, l2->next); return l2; } }递归方法简洁易读,但若链表过长可能导致栈溢出,可改用迭代实现。
六、总结
本文系统介绍了线性表的定义、两种主流物理存储结构及其核心操作的实现方法,并分析了各自的时间与空间性能特征。线性表是所有线性数据结构(的基础,深刻理解其存储原理和操作细节,对后续学习更复杂的数据结构具有重要的奠基作用。
掌握顺序表和链表的实现,不仅有助于通过理论考核,更是编写高效程序的基本功。希望读者能够亲自动手实践以上代码,并进一步思考其在现实问题中的运用。