建议先阅读: 数组 — 顺序表的底层就是数组,寻址公式与动态扩容在本章会被直接引用。

本章定位:线性表是最基础、最常用的数据结构,也是 408 的第一考点。数组章讲的是”连续内存怎么用”,链表章讲的是”节点怎么串”,而本章回答一个更上位的问题:线性表这套 ADT 是什么,它的两种实现(顺序表/链表)如何取舍。读完本章再进入 链表,你会清楚链表到底在”替代”什么。


一、什么是线性表

1.1 定义

线性表(Linear List)是 个数据元素的有限序列,记作:

  • 为表长; 时称为空表
  • 是第 个元素, 是它的位序(从 1 开始,注意与数组下标从 0 开始的区别)
  • 除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继

1.2 逻辑特征:一对一

线性表的逻辑结构是典型的线性结构(回顾 基本知识 中的四类逻辑结构):

flowchart LR
    A1["a₁"] --> A2["a₂"] --> A3["a₃"] --> D["…"] --> AN["aₙ"]
位置元素直接前驱直接后继
表头
中间
表尾

注意:“线性”指的是逻辑关系的一对一,不是物理存储的连续。元素在内存里可以连续放(顺序表),也可以散落各处用指针相连(链表)——这正是本章后面要展开的两种实现。

1.3 生活中的线性表

场景元素特点
排队买票每个人有先后次序,只能在队尾加入
通讯录列表每条联系人记录按录入顺序排列,可插入、删除
播放列表每首歌有固定顺序,支持任意位置插播
学生成绩单每个学生的成绩记录按学号或录入顺序组织

二、线性表的 ADT 与基本操作

2.1 抽象数据类型视角

数据结构 = 逻辑结构 + 存储结构 + 运算。线性表的运算定义在逻辑层面,与存储方式无关——这就是 基本知识 里说的”数据抽象”:对外只暴露能做什么,对内怎么存是实现细节。

2.2 基本操作一览

操作语义典型返回
InitList(&L)初始化,构造一个空表成功/失败
DestroyList(&L)销毁表,释放空间
ListEmpty(L)判空true / false
ListLength(L)求表长元素个数
GetElem(L, i, &e)按位查找:取第 个元素元素值
LocateElem(L, e)按值查找:找第一个值为 的元素位序或地址
PriorElem(L, e, &pre) 的直接前驱前驱值
NextElem(L, e, &next) 的直接后继后继值
ListInsert(&L, i, e)在第 个位置插入 成功/失败
ListDelete(&L, i, &e)删除 个元素并返回其值成功/失败
TraverseList(L)遍历并访问每个元素

2.3 C 接口约定

本章代码统一采用教材风格的接口:用 Status 表示执行结果,用 ElemType 占位元素类型,参数传指针以便修改表。

#define OK 1
#define ERROR 0
typedef int Status;
typedef int ElemType;      /* 实际使用时替换为具体类型 */
 
/* 线性表 ADT 的接口(实现可以是顺序表,也可以是链表) */
Status InitList(void *L);
Status ListInsert(void *L, int i, ElemType e);
Status ListDelete(void *L, int i, ElemType *e);
int    ListLength(void *L);
Status GetElem(void *L, int i, ElemType *e);
int    LocateElem(void *L, ElemType e);

关键思想:同一套接口,底下换成数组就是顺序表,换成节点指针就是链表。调用者只关心”插入第 3 个位置”这个语义,不关心元素怎么搬。后面两章分别实现这套接口的两种版本。


三、两种实现:顺序表与链表

线性表只有两种基本存储方式(回顾 基本知识 的物理结构):

flowchart TD
    L["线性表 ADT<br/>一对一逻辑关系"] --> S["顺序存储<br/>顺序表 Sequential List"]
    L --> C["链式存储<br/>链表 Linked List"]
    S --> S1["用数组存放<br/>逻辑相邻 = 物理相邻"]
    C --> C1["用节点+指针<br/>逻辑相邻 ≠ 物理相邻"]
    S1 --> SA["实现见 [[A_数组_Array|数组]] 章"]
    C1 --> CA["实现见 [[F_链表_LinkedList|链表]] 章"]
维度顺序表链表
存储单元连续内存块任意位置独立节点
逻辑相邻物理也相邻靠指针建立联系
随机访问支持,不支持,
容量固定或需扩容搬移按需申请,天然动态

四、顺序表

4.1 定义与结构

顺序表是用一段地址连续的存储单元依次存放线性表元素的实现。因为逻辑相邻的元素物理也相邻,所以可以用”基地址 + 偏移”直接算出任意元素的地址——这正是 数组 章的寻址公式。

#define MAXSIZE 100        /* 静态分配的最大容量 */
 
typedef struct {
    ElemType data[MAXSIZE];  /* 连续存储空间 */
    int length;              /* 当前表长 */
} SqList;

实际工程中更常用动态分配版本:

typedef struct {
    ElemType *data;    /* 指向堆上申请的连续空间 */
    int length;        /* 当前元素个数 */
    int capacity;      /* 当前容量 */
} SeqList;

4.2 按位查找:

个元素(位序从 1 开始)存放在下标 处:

Status GetElem(SeqList *L, int i, ElemType *e) {
    if (i < 1 || i > L->length) return ERROR;   /* 位序越界检查 */
    *e = L->data[i - 1];
    return OK;
}

一次乘加即可定位,时间复杂度 。这是顺序表相对链表最大的优势。

4.3 按值查找:

从头到尾逐个比较,找到返回位序,找不到返回 0:

int LocateElem(SeqList *L, ElemType e) {
    for (int i = 0; i < L->length; i++)
        if (L->data[i] == e) return i + 1;   /* 返回位序 */
    return 0;
}
  • 最好情况:第 1 个就命中,比较 1 次
  • 最坏情况:最后一个或不存在,比较
  • 等概率下查找成功的平均比较次数:

4.4 插入操作与平均移动次数

目标:在位序 )处插入新元素

算法:先把第 到第 个元素整体后移一位(必须从后往前搬,否则会覆盖),再写入新元素,最后表长加一。

Status ListInsert(SeqList *L, int i, ElemType e) {
    if (i < 1 || i > L->length + 1) return ERROR;   /* 位置非法 */
    if (L->length == L->capacity) {                 /* 容量不足,扩容 */
        if (GrowList(L) != OK) return ERROR;
    }
    for (int j = L->length; j >= i; j--)   /* 从后往前,后移一位 */
        L->data[j] = L->data[j - 1];
    L->data[i - 1] = e;
    L->length++;
    return OK;
}

平均移动次数推导:在位置 插入,需要移动的元素个数为

插入位置移动个数
表头(
表尾(
一般位置

假设 个插入位置等概率,平均移动次数为:

插入平均要移动一半元素,时间复杂度

4.5 删除操作与平均移动次数

目标:删除位序 )处的元素,并用 返回其值。

算法:保存被删元素,把第 到第 个元素整体前移一位(从前往后搬),表长减一。

Status ListDelete(SeqList *L, int i, ElemType *e) {
    if (i < 1 || i > L->length) return ERROR;
    *e = L->data[i - 1];
    for (int j = i; j < L->length; j++)   /* 从前往后,前移一位 */
        L->data[j - 1] = L->data[j];
    L->length--;
    return OK;
}

平均移动次数推导:删除位置 需要移动 个元素。

删除位置移动个数
表头(
表尾(
一般位置

个删除位置等概率,平均移动次数:

两个平均移动次数是 408 选择题的高频考点:插入平均 次,删除平均 次。记忆技巧:插入可插 个位置,删除只有 个位置。

4.6 扩容:顺序表的动态化

静态顺序表容量写死,动态版本在容量不足时申请一块更大的连续空间(通常翻倍),把旧数据整体搬过去,再释放旧空间——这就是 数组 章讲过的均摊分析:虽然单次扩容是 ,但均摊到每次插入只有

Status GrowList(SeqList *L) {
    int newCap = L->capacity ? L->capacity * 2 : 8;
    ElemType *p = realloc(L->data, newCap * sizeof(ElemType));
    if (p == NULL) return ERROR;
    L->data = p;
    L->capacity = newCap;
    return OK;
}

扩容的代价是搬移全部元素要求内存有足够大的连续空闲区,这是顺序表在工程上的主要痛点;链表用节点分散分配绕开了它,但付出了指针开销与缓存不友好的代价。


五、顺序表 vs 链表

5.1 八维度对比

维度顺序表链表
存储方式连续内存离散节点 + 指针
随机访问(必须从头遍历)
按值查找,平均 次比较,比较次数相同但常数更大
插入/删除(已知位置),平均移动 / ,只改指针
插入/删除(需先查找位置) 查找 + 移动 查找 + 改指针
空间分配预先分配或扩容搬移,可能浪费按需分配,无碎片浪费(但指针占空间)
存储密度(全部空间存数据)(每个节点含指针开销)
缓存友好度好(连续内存,空间局部性)差(节点分散,pointer chasing)

存储密度定义为:

顺序表节点只有数据,密度为 1;单链表每个节点还要存一个指针,密度明显小于 1(32 位指针 + int 数据时仅 50%)。

5.2 选型口诀

场景选择原因
频繁按位访问、很少插删顺序表随机访问 ,缓存友好
频繁在已知位置插删链表改指针 ,无需搬移
数据量无法预估、波动大链表按需分配,不浪费也不溢出
对内存连续性/缓存敏感顺序表链表 pointer chasing 代价高
需要二分查找顺序表链表无法随机访问,二分退化为

现实中”数组 + 双指针 + 原地操作”往往比链表更快,因为缓存的收益远大于搬移的代价——这也是 数组 章反复强调的底层逻辑。


六、408 高频算法设计题

顺序表算法题是 408 综合应用题(13 分)的常客。以下六题覆盖主流题型,全部要求手写代码 + 复杂度分析

6.1 合并两个有序顺序表

题目:将两个有序顺序表 合并为新的有序顺序表 ,要求不破坏

思路:双指针分别扫描 ,每次取较小的放入 ,最后把剩余部分直接接上。

Status MergeSq(SeqList *A, SeqList *B, SeqList *C) {
    if (A->length + B->length > C->capacity) return ERROR;  /* 空间不足 */
    int i = 0, j = 0, k = 0;
    while (i < A->length && j < B->length)
        C->data[k++] = (A->data[i] <= B->data[j]) ? A->data[i++] : B->data[j++];
    while (i < A->length) C->data[k++] = A->data[i++];      /* A 剩余 */
    while (j < B->length) C->data[k++] = B->data[j++];      /* B 剩余 */
    C->length = k;
    return OK;
}

时间复杂度 ,空间复杂度 (不算结果表)。

6.2 就地逆置

题目:设计算法将顺序表就地逆置,空间复杂度

思路:首尾双指针相向而行,交换对应元素。

void ReverseSq(SeqList *L) {
    for (int i = 0, j = L->length - 1; i < j; i++, j--) {
        ElemType t = L->data[i];
        L->data[i] = L->data[j];
        L->data[j] = t;
    }
}

时间 ,空间 。这道题是所有”原地修改”题型的母题。

6.3 删除所有值为 x 的元素

题目:删除顺序表中所有值为 的元素,要求时间 、空间

思路:用 记录不等于 的元素个数,一次遍历边扫描边覆盖,避免每删一个就搬一次。

void DeleteAllX(SeqList *L, ElemType x) {
    int k = 0;
    for (int i = 0; i < L->length; i++)
        if (L->data[i] != x) L->data[k++] = L->data[i];
    L->length = k;
}

这是顺序表删除的标准写法:把”删除”转化为”保留元素的重新写入”,全程只搬一次。

6.4 删除有序表中的重复元素

题目:有序顺序表中可能有重复值,删除重复元素使每个值只出现一次。

思路:因为有序,重复元素必然相邻。记录最后一个保留元素的下标,后续元素与之不同则保留。

void DeleteDuplicates(SeqList *L) {
    if (L->length == 0) return;
    int k = 0;                              /* 已保留的最后一个位置 */
    for (int i = 1; i < L->length; i++)
        if (L->data[i] != L->data[k]) L->data[++k] = L->data[i];
    L->length = k + 1;
}

时间 ,空间

6.5 两个等长有序序列的中位数

题目:两个长度均为 的有序序列 ,求它们合并后的中位数。

思路一(:归并式扫描,数到第 个元素即为中位数。
思路二(,408 加分项):分别取两序列的中位数 比较——若 直接返回;若 ,则中位数必在 的右半段与 的左半段中,每次砍掉一半。

ElemType MedianOfTwo(SeqList *A, SeqList *B) {   /* O(n) 版本 */
    int i = 0, j = 0, n = A->length, cnt = 0;
    ElemType mid = 0;
    while (cnt < n) {                              /* 数到第 n 个 */
        if (A->data[i] <= B->data[j]) mid = A->data[i++];
        else                          mid = B->data[j++];
        cnt++;
    }
    return mid;
}

408 更常考 版本的正确性, 版本考分治思想的表述。

6.6 循环左移 p 位

题目:将顺序表循环左移 位(),要求空间

思路(三次逆置法):左移 位 = 把前 个逆置 + 后 个逆置 + 整体逆置。

void LeftRotate(SeqList *L, int p) {
    ReverseRange(L, 0, p - 1);            /* 逆置前 p 个 */
    ReverseRange(L, p, L->length - 1);    /* 逆置剩余部分 */
    ReverseRange(L, 0, L->length - 1);    /* 整体逆置 */
}

时间 ,空间 。这道题展示了”多次局部逆置合成置换”的经典技巧。


七、各语言中的线性表

语言顺序实现链式实现说明
C手写数组/动态数组手写节点标准库不提供,最能看清底层
C++std::vectorstd::list / std::forward_listvector 是默认选择,list 极少用
Pythonlist无内置list 实为动态数组,链表需自己写或用 collections.deque
JavaArrayListLinkedListLinkedList 同时实现 Deque,实际性能常不如 ArrayList
Goslice手写节点slice 是动态数组视图
RustVec<T>LinkedList<T>标准库文档明确不推荐 LinkedList

工程结论一致:顺序表(动态数组)是绝大多数场景的默认选择,链表只在特定插删模式或内存分配约束下才有优势。


八、应用场景

  • 动态数组容器:C++ vector、Java ArrayList、Python list 的底层都是顺序表
  • 查找表与缓冲区:需要按下标随机访问的场合(详见 数组 章)
  • LRU 与链式结构:需要频繁在任意位置插删时用链表(详见 链表 章)
  • 操作系统内核:进程就绪队列、空闲链表等大量使用链式线性表
  • 哈希表冲突链:链地址法用单链表串联同桶元素
  • 大整数与多项式:用顺序表或链表存储系数序列

练习

题号题目难度知识点
344反转字符串入门首尾双指针就地逆置
1089复写零入门顺序表插入与元素移动
448找到所有数组中消失的数字入门原地标记、下标映射
80删除有序数组中的重复项 II中等保留 k 个的通用删除写法
189轮转数组中等三次逆置实现循环移位
4寻找两个正序数组的中位数困难归并扫描 / 二分分治

核心推演清单

以下为 408 风格概念题——先自己算,再对照解析。

题 1(插入平均移动次数):长度为 的顺序表,在任意合法位置等概率插入一个元素,平均需要移动多少个元素?若只在表头插入呢?

插入位置共 个,位置 需移动 个,平均为 。只在表头插入则固定移动 个——“平均”的前提是等概率,这是最常见的偷换概念陷阱。

题 2(删除平均移动次数):长度为 的顺序表,等概率删除任意元素,平均移动多少个?删除表尾呢?

删除位置共 个,位置 需移动 个,平均为 。删除表尾移动 0 个。

题 3(空间对比) 个元素,顺序表与单链表分别占用多少空间?(设元素占 字节,指针占 字节,顺序表按满容量计算)

顺序表: 字节(存储密度 1);单链表: 字节(存储密度 )。指针开销是链表固有的空间税。

题 4(结构选型):某系统需要频繁地在表中任意位置插入、删除,且元素个数变化剧烈,应选哪种存储结构?为什么?

选链表:插入删除只改指针(,不计查找),且按需分配不受容量限制。若选顺序表,每次插删平均要搬移一半元素,且容量难以预估。


动手实验

编号题目说明
E1插入/删除平均移动次数实测实现动态顺序表,分别在随机位置执行 10 万次插入与删除,用计数器统计实际移动的元素总数,与理论值 对比
E2顺序表 vs 链表遍历性能对比分别构建 1000 万元素的顺序表与单链表,各遍历 10 次并计时。用 perf stat -e cache-misses 观察两者缓存缺失数量级差异,解释 pointer chasing 的代价
E3扩容策略对性能的影响实现两种扩容策略(每次 +1 与每次 ×2),各插入 100 万个数并计时。验证均摊分析与实测差异,画出耗时曲线
E4合并两个有序表用 6.1 的算法合并两个各 100 万元素的有序表,计时并与”合并后排序”的方案对比,验证 的差距