数据结构 · 2027 考研计算机 408

第 2 章 线性表(顺序表与链表)

目标院校:四川大学 / 电子科技大学 | 建议用时:概念 3 小时 + 例题练习 5 小时

本章地位:线性表是 408 数据结构大题的第一主战场:2009(找链表倒数第 \(k\) 个结点)、2010(数组循环左移)、2012(两链表公共后缀起点)等真题大题全部以本章为背景,选择题更是密集轰炸头结点辨析、插入删除移动次数、随机存取与顺序存取、双指针技巧。学习目标:顺序表 / 单链表 / 双链表 / 循环链表 / 静态链表的概念全过关,基本操作能默写 C 代码并分析复杂度,形成「双指针」「对折」「归并」三大算法设计套路。

2.1 线性表的定义与基本操作

2.1.1 定义:有限序列与位序

定义线性表是具有相同数据类型的 \(n\ (n\ge 0)\) 个数据元素的有限序列,记作 \[ L=(a_1,\ a_2,\ \dots,\ a_n) \] 其中 \(n\) 为表长,\(n=0\) 时为空表;\(a_i\) 是第 \(i\) 个元素,称其位序为 \(i\)。表头元素 \(a_1\) 没有前驱,表尾元素 \(a_n\) 没有后继;其余每个元素有且仅有一个直接前驱和一个直接后继。
一句话记忆线性表 = 「一对一关系 + 元素个数有限 + 类型相同」。三个关键词各对应一个考点:「一对一」区别于树和图(第 1 章逻辑结构四分类);「有限」区别于无限序列;「相同类型」保证每个元素占用等长存储单元——这正是 2.2 节地址能「算」出来的前提。

2.1.2 基本操作与「逻辑 vs 实现」

基本操作清单按访问方式分两类(教材签名沿用 & 传引用的伪码风格):按位序(已知位置)——\(\mathrm{GetElem}(L,\,i)\) 取第 \(i\) 个元素,\(\mathrm{ListInsert}(\&L,\,i,\,e)\) 在位序 \(i\) 上插入 \(e\),\(\mathrm{ListDelete}(\&L,\,i,\,\&e)\) 删除位序 \(i\) 的元素并由 \(e\) 带回;按值(已知内容)——\(\mathrm{LocateElem}(L,\,e)\) 返回值等于 \(e\) 的元素的位序。另有 \(\mathrm{InitList}\)、\(\mathrm{Length}\)、\(\mathrm{Empty}\)、\(\mathrm{PrintList}\)、\(\mathrm{DestroyList}\) 等配套操作。
易错呼应第 1 章:线性表是逻辑结构,顺序表与链表是它的两种存储实现——「顺序表是不是一种逻辑结构?」答:不是,它是「线性表 + 顺序存储」。同理,栈、队列、串是逻辑结构(运算受限或元素受限的线性表),而「循环单链表」属于存储实现层面的名词。运算的定义(插入删除查什么)由逻辑结构给出,实现(移动元素还是改指针)由存储结构决定。
练习 1 高频考点

判断正误:(1) 线性表中每个元素都有且仅有一个直接前驱和一个直接后继;(2) 线性表的长度是固定不变的;(3) 同一种逻辑结构可以用不同的存储结构实现;(4) 链表一定比顺序表节省空间。

查看答案

(1) 错:端点例外——表头无前驱、表尾无后继,须说「除端点外」。(2) 错:表长随插入删除动态变化;顺序表静态分配时固定的是「容量」不是「长度」。

(3) 对:线性表既可顺序存储(顺序表)也可链式存储(链表)。(4) 错:链表每个结点要额外存指针(存储密度小于 1),存同样多元素并不省空间;它省的是「不需要一整块连续空间、容量可按需增长」(见 2.4 对比表)。

2.2 顺序表 高频考点

2.2.1 地址计算与随机存取

定义用一组地址连续的存储单元依次存放线性表的元素,使逻辑相邻的元素物理位置也相邻,即为顺序表。位序 \(i\) 的元素存放在下标 \(i-1\) 处。

// 顺序表类型定义:静态分配(动态分配则用 int *data 指针 + malloc,容量可扩)

#define MaxSize 50              // 静态分配:容量在编译期确定
typedef struct {
    int data[MaxSize];           // 存元素的数组
    int length;                  // 当前表长
} SqList;
地址公式设每个元素占 \(c=\mathrm{sizeof}(\mathrm{ElemType})\) 个单元、首地址(下标 0 的元素)为 \(\mathrm{LOC}(A)\),则下标 \(j\) 的元素地址为 \[ \mathrm{LOC}(a_{j+1}) = \mathrm{LOC}(A) + j\cdot c \] 知道下标一步算出地址、一步取到元素,与表长无关——这就是随机存取,\(O(1)\)。
数值例:\(\mathrm{int}\) 型 \(c=4\),\(\mathrm{LOC}(A)=2000\):下标 4(位序 5)的地址 \(=2000+4\times4=2016\);地址 2024 处的下标 \(=(2024-2000)/4=6\) ✓。
易错随机存取 ≠ 顺序存取,这是一对「存取方式」名词:① 顺序表是随机存取的(按位 O(1));链表是顺序存取的(找第 \(i\) 个必须从头走 \(i\) 步);② 别被名字骗:「顺序存储结构」里的「顺序」指物理相邻,与「顺序存取」是两码事;③ 存取方式由存储结构决定,与逻辑结构无关——逻辑上都是线性表,存取方式却完全不同。

2.2.2 插入 / 删除的移动次数推导

移动次数表长为 \(n\),位序 \(i\) 从 1 开始:
  1. 插入到位序 \(i\)(\(1\le i\le n+1\)):\(a_i\cdots a_n\) 共 \(n-i+1\) 个元素后移一位(先挪尾部再腾位);
  2. 删除位序 \(i\)(\(1\le i\le n\)):\(a_{i+1}\cdots a_n\) 共 \(n-i\) 个元素前移一位。
设每个位置等概率。插入:\(p_i=\frac{1}{n+1}\), \[ E_{\text{插}}=\frac{1}{n+1}\sum_{i=1}^{n+1}(n-i+1)=\frac{1}{n+1}\cdot\frac{n(n+1)}{2}=\frac{n}{2} \] 删除:\(p_i=\frac{1}{n}\), \[ E_{\text{删}}=\frac{1}{n}\sum_{i=1}^{n}(n-i)=\frac{1}{n}\cdot\frac{n(n-1)}{2}=\frac{n-1}{2} \]
一句话记忆插入平均动 \(\frac{n}{2}\) 个、删除平均动 \(\frac{n-1}{2}\) 个——「删比插少动半个」。直觉:插入有 \(n+1\) 个合法位置而删除只有 \(n\) 个,删除的「便宜位置」(尾部)占比更高。两者都是 \(O(n)\)。
例 1 高频考点 移动次数计算(含 \(n=5\) 逐项验证)

表长 \(n=5\) 的顺序表:(1) 分别求位序 \(i=1,2,3,4,5,6\) 插入时移动的元素个数并验证平均 \(\frac{n}{2}\);(2) 位序 \(i=1,\dots,5\) 删除时的移动次数并验证平均 \(\frac{n-1}{2}\)。

查看解答
位序 \(i\)123456合计 / 平均
插入移动 \(n-i+1\)543210\(15\),\(\frac{15}{6}=2.5=\frac{n}{2}\) ✓
删除移动 \(n-i\)43210—\(10\),\(\frac{10}{5}=2=\frac{n-1}{2}\) ✓

快速验算技巧:插入最多动 \(n\) 个(插表头)、最少动 0 个(插表尾后);删除最多动 \(n-1\) 个(删表头)、删表尾动 0 个。选项中出现「\(\frac{n+1}{2}\)」「\(\frac{n}{2}-1\)」等都是干扰项。

2.2.3 三个基本操作的 C 实现

例 2 方法 插入 / 删除 / 按值查找的完整代码与复杂度

// 位序 i 从 1 开始,数组下标从 0 开始;bool 需要 stdbool.h

bool ListInsert(SqList *L, int i, int e) {
    if (i < 1 || i > L->length + 1) return false;   // 位序越界
    if (L->length >= MaxSize) return false;          // 表满(静态分配)
    for (int j = L->length; j >= i; j--)             // 从最后一个开始后移
        L->data[j] = L->data[j - 1];
    L->data[i - 1] = e;                               // 位序 i 落在下标 i-1
    L->length++;
    return true;
}
bool ListDelete(SqList *L, int i, int *e) {
    if (i < 1 || i > L->length) return false;
    *e = L->data[i - 1];
    for (int j = i; j < L->length; j++)              // 从前往后挪,补上空位
        L->data[j - 1] = L->data[j];
    L->length--;
    return true;
}
int LocateElem(SqList L, int e) {                    // 按值查找,返回位序
    for (int j = 0; j < L.length; j++)
        if (L.data[j] == e) return j + 1;            // 下标 j ↔ 位序 j+1
    return 0;                                        // 未找到
}
查看解答

复杂度:插入 / 删除的主要开销是移动循环,最坏 \(O(n)\)、等概率 \(O(n)\);按位取 \(\mathrm{GetElem}\) 是 \(O(1)\)(随机存取);按值查找 \(O(n)\)(无序表只能逐个比)。

数值验证(\(n=5\),插到位序 3):循环 \(j=5,4,3\) 共 3 次 \(=n-i+1=5-3+1=3\) ✓;删除位序 2:\(j\) 取 \(2,3,4\) 共 3 次 \(=n-i=5-2=3\) ✓(\(j=5\) 不满足 \(j<\mathrm{length}\),别误数成 4 次),被前移的正是 \(a_3,a_4,a_5\)——「数循环次数」必须代小 \(n\) 逐一核对,别背错。

易错:插入的合法范围是 \(1\le i\le n+1\)(可以插成新表尾),删除是 \(1\le i\le n\);移动方向相反——插入「从尾往前」挪,删除「从头往后」挪。

易错位序从 1、下标从 0:题目说「第 \(i\) 个元素」,代码里写下标 \(i-1\);反之 \(\mathrm{LocateElem}\) 在下标 \(j\) 命中时要返回 \(j+1\)。读题先标一句「位序 or 下标」,大题算法因这个差一写错边界白扣一半分。
练习 2

顺序表首地址 \(\mathrm{LOC}(A)=2000\),每个元素占 4 个单元:(1) 位序 1 与位序 7 的元素地址各是多少?(2) 地址 2040 处存的是位序几的元素?(3) 若元素改为每个占 8 单位,位序 6 的地址是多少?

查看答案

(1) 位序 1 即下标 0:\(2000\);位序 7 即下标 6:\(2000+6\times4=2024\)。

(2) 下标 \(=(2040-2000)/4=10\),位序 11。注意 2040 对应的是「第 11 个元素」,别答 10。

(3) 下标 5:\(2000+5\times8=2040\)。同一公式换 \(c\) 即可——单位换成 8 后「位序 6」与 (2) 中「位序 11」地址恰相同,说明地址由「下标 × 单元素大小」唯一决定。

2.3 链表 高频考点

2.3.1 单链表:结点与头结点

定义单链表通过一组任意存储单元存放元素,每个结点 = 数据域 + 指针域(存后继结点地址)。结点类型:

// 单链表结点类型:LNode 是结点,LinkList 是指向结点的指针(整个链表用头指针代表)

typedef struct LNode {
    int data;                   // 数据域
    struct LNode *next;         // 指针域:指向后继结点,表尾结点为 NULL
} LNode, *LinkList;
头结点的两大作用头指针指向链表第一个结点;若在首元结点前附设一个数据域一般不用的头结点,则:① 统一空表与非空表——带头结点时空表判据是 L->next == NULL(头指针 L 永不为 NULL),不带头结点则是 L == NULL;② 统一首位与中间位置的操作——在位序 1 插入 / 删除时改的也是头结点的指针域,语句与其他位置完全一样,不必特判修改头指针。这正是大题代码默认带头结点的原因。

查找:按序号取第 \(i\) 个元素只能从首元结点出发沿 next 走 \(i\) 步,按值查找同样逐个比较,两者都是 \(O(n)\)——单链表是顺序存取结构,不支持随机存取(呼应 2.2 的辨析)。但插入 / 删除在已拿到前驱指针 \(p\) 时只改两条指针,\(O(1)\)。

2.3.2 插入、删除与建表

后插(在 \(p\) 之后插入 \(s\)):s->next = p->next; p->next = s;——先接管后继、再改前驱,顺序不可颠倒。删除 \(p\) 的后继 \(q\):q = p->next; p->next = q->next; free(q);。指针变化见图 2-1。

① 后插:在 p 之后插入 s(原 p→q 边作废) p aᵢ₋₁ s(新) x q aᵢ ① s->next = p->next ② p->next = s 先 ① 后 ②:若先 ②, 原后继 q 失联(s 自环) ② 删除:摘掉 p 的后继 q p aᵢ₋₁ q(被删) aᵢ r aᵢ₊₁ ① p->next = q->next ② free(q):先摘链、后 释放,free 后不再读 q
图 2-1 单链表插入与删除的指针变化:后插两步(①②顺序不可颠倒)、删除两步(摘链 + 释放)
例 3 易错 指针操作的顺序

设 p 指向单链表中某结点,q 是 p 的原后继(p->next == q),在 p 之后插入 s,能正确完成插入的是( )
A. p->next = s; s->next = p->next;  B. s->next = p->next; p->next = s;  C. s->next = p; p->next = s;  D. p->next = s; s->next = q;

查看解答

B:s 先接管 q(此时 p->next 仍是 q),再让 p 指向 s,两步互不破坏。

A 是经典陷阱:先执行 p->next = s 后,s->next = p->next 等价于 s->next = s——s 自环、q 从此失联(内存泄漏 + 死循环);C 中 s->next 指向前驱方向,遍历陷入 p↔s 互指环。

D 依赖题目额外给出的 q 才成立,q 未保存时不可用——标准答案永远是 B。口诀「先接新、再改旧:凡用到 p->next 旧值的语句都放在改写它之前」。

建表有两种标准写法。头插法:新结点总插在表头,读入 \(a_1,\dots,a_n\) 得到的是逆序链 \(a_n\to\cdots\to a_1\);尾插法:附设尾指针 \(r\)(始终指向表尾),得到顺序链。两者都是 \(O(n)\)。

// 头插法与尾插法建表:数组 a[0..n-1] 依次读入,均带头结点

LinkList HeadCreate(int a[], int n) {   // 头插法:结果 a[n-1] → … → a[0](逆序)
    LinkList L = (LinkList)malloc(sizeof(LNode));
    L->next = NULL;                      // 空表:这一句不能省
    for (int i = 0; i < n; i++) {
        LNode *s = (LNode *)malloc(sizeof(LNode));
        s->data = a[i];  s->next = L->next;  L->next = s;   // 头插两步
    }
    return L;
}

LinkList TailCreate(int a[], int n) {   // 尾插法:结果 a[0] → … → a[n-1](顺序)
    LinkList L = (LinkList)malloc(sizeof(LNode));
    LNode *r = L;                       // r 始终指向表尾结点
    for (int i = 0; i < n; i++) {
        LNode *s = (LNode *)malloc(sizeof(LNode));
        s->data = a[i];  s->next = NULL;  r->next = s;  r = s;
    }
    return L;
}
读入 1、2 后 L 2 1 ∧ 新结点 2 抢当表头 读入 3 后 L 3 2 1 ∧ 链:3 → 2 → 1(逆序)
图 2-2 头插法建表(依次读入 1、2、3):新结点(绿)总插在头结点之后,最终链为 3→2→1——与读入顺序相反,也正是「单链表逆置」的原型

2.3.3 双指针技巧与单链表逆置

例 4 真题风格 找倒数第 \(k\) 个结点(2009 真题原型)

设计一个尽可能高效的算法,输出带头结点单链表中倒数第 \(k\) 个结点的值(\(k\) 为正整数;若 \(k\) 大于表长则报告失败)。要求只扫描一遍。

// 双指针「间隔 k」:q 先走 k 步,再与 p 同步前进

int FindKthFromEnd(LinkList L, int k) {
    LNode *p = L->next, *q = L->next;
    for (int i = 0; i < k; i++) {          // q 先走 k 步
        if (q == NULL) return -1;          // k > 表长:失败
        q = q->next;
    }
    while (q != NULL) {                    // q 到 NULL 时,p 恰在倒数第 k
        p = p->next;
        q = q->next;
    }
    return p->data;
}
查看解答

正确性:当 q 走到 NULL(越过表尾)时它共走了 \(n\) 步;p 晚出发 \(k\) 步,走了 \(n-k\) 步,停在第 \(n-k+1\) 个结点——从尾部数正好第 \(k\) 个。时间 \(O(n)\)(一遍扫描),空间 \(O(1)\)。

数值验证(\(n=5\),链 1→2→3→4→5,\(k=2\)):q 先走 2 步到结点 3;同步推进 \((p{=}1,q{=}3)\to(2,4)\to(3,5)\to(4,\mathrm{NULL})\) 停,\(p\) 指向结点 4 = 倒数第 2 ✓。再验 \(k=5\):q 先走到 NULL 恰需 5 步,同步循环 0 次,p 停在首元 1 = 倒数第 5 ✓;\(k=6\) 时 q 在第 6 步前已为 NULL,返回 −1 ✓。

套路总结:单链表「只能向前走」,凡涉及「倒数 / 距表尾」的量,就用两个指针把「距离差」先造出来——这一招在例 8(公共后缀)中再次登场。

例 5 方法 单链表原地逆置(头插法)

将带头结点单链表就地逆置(辅助空间 \(O(1)\))。

// 思路:把原表结点逐个“摘下来头插”,头插天生逆序(见图 2-2)

void ReverseList(LinkList L) {
    LNode *p = L->next, *q;
    L->next = NULL;                    // 先把原表摘空,L 成为空表
    while (p != NULL) {
        q = p->next;                   // 暂存 p 的后继,防断链
        p->next = L->next;             // 头插两步(先接新、再改旧,同例 3)
        L->next = p;
        p = q;                         // 处理下一个
    }
}
查看解答

数值验证(\(n=3\),链 1→2→3):第 1 轮摘 1 插空表 → L→1;第 2 轮摘 2 头插 → L→2→1;第 3 轮摘 3 头插 → L→3→2→1 ✓。时间 \(O(n)\)(每个结点摘、插各一次),空间 \(O(1)\)(只用 p、q 两个指针)。

易错:q = p->next 必须在头插之前保存——一旦执行 p->next = L->next,p 原来的后继就丢了。

快慢指针找中间结点:slow 每次走 1 步、fast 每次走 2 步,fast 到尾时 slow 在中点(\(n=5\) 停在第 3 个,\(n=6\) 停在第 4 个,即 \(\lfloor n/2\rfloor+1\))。判环:若 fast 能与 slow 相遇则存在环(fast 每轮追近 1 步,环内必追上)。循环链表的判空条件辨析见练习 3,找中点的完整代码留作练习 6。

2.3.4 双链表 / 循环链表 / 静态链表

双链表单链表找后继 \(O(1)\)、找前驱 \(O(n)\)。双链表每个结点增加 prior 指针域,双向都可 \(O(1)\):

// 双链表结点类型

typedef struct DNode {
    int data;
    struct DNode *prior, *next;   // 分别指向前驱与后继
} DNode, *DLinkList;

在 \(p\) 之后插入 \(s\)(四步,顺序敏感):s->next = p->next; p->next->prior = s; s->prior = p; p->next = s;——前两句都用到旧的 p->next,必须放在改写它的第四句之前。删除 \(p\) 的后继 \(q\):p->next = q->next; q->next->prior = p; free(q);。

循环链表① 循环单链表:尾结点的 next 指回头结点(不是首元)。判空(带头结点):L->next == L;从任一结点出发都能遍历全表。设尾指针 r 时找头(r->next)与找尾(r)都是 \(O(1)\),表尾操作频繁时首选;两个循环单链表首尾衔接只需 \(O(1)\)。
② 循环双链表:头结点的 prior 也指表尾。判空:L->next == L(且 L->prior == L,两者等价)。
易错循环链表的终止条件变了:遍历不再写 while (p != NULL),而写 while (p != L)(或 p->next != L,按需要停在哪一步选)。检查循环链表代码就盯一件事:还有没有把 NULL 当表尾。
静态链表用数组模拟链表:每个元素配一个游标 cursor,存「下一元素的下标」(相当于把指针换成下标)。

// 静态链表:cursor = -1 表示表尾,0 号下标常作备用链表头

#define MaxSize 50
typedef struct {
    int data;                 // 数据域
    int cursor;               // 游标:下一元素的下标(-1 表尾)
} SLinkList[MaxSize];
适用场景优点:插入删除只改游标、不移动元素(对比顺序表)。缺点:不能随机存取(仍要顺着游标找);容量固定;失去指针灵活性。适用:不支持指针的低级语言(如早期 FORTRAN / 汇编环境),或需要「链表结构 + 静态分配」的场合。
练习 3 易错

写出下列链表的判空条件:(1) 带头结点的单链表;(2) 带头结点的循环单链表;(3) 带头结点的循环双链表;(4) 不带头结点的单链表。

查看答案

(1) L->next == NULL;(2) L->next == L(头结点指向自己);(3) L->next == L(此时必有 L->prior == L);(4) L == NULL(头指针本身为空——不带头结点时头指针就是首元指针)。

辨析关键:先问「有没有头结点」,再问「循环到谁」。把 (1) 的答案用到 (2) 上是最高频的错误。

练习 4 易错

判断正误:(1) 静态链表的插入删除不需要移动元素;(2) 静态链表能随机存取第 \(i\) 个元素;(3) 静态链表必须在不支持指针的语言中才能使用。

查看答案

(1) 对:只改前后两个元素的游标(逻辑下一跳),元素物理位置不动。(2) 错:仍要顺游标逐个走,顺序存取。(3) 错:是「常用于 / 适合」不支持指针的语言,不是「必须」——支持指针的语言同样能实现。

2.4 顺序表与链表:对比与算法设计实战 真题风格

2.4.1 顺序表 vs 链表选型

对比维度顺序表链表(单链表)
逻辑结构都是线性表——完全相同(区别只在存储)
存取方式随机存取,按位 \(O(1)\)顺序存取,按位 \(O(n)\)
空间存储密度 1(无额外开销);静态分配容量固定,可能溢出或闲置;需一整块连续空间存储密度 < 1(指针域开销);动态按需分配结点,不需要连续大块空间
插入 / 删除需移动元素,平均 \(\frac{n}{2}\) / \(\frac{n-1}{2}\) 个,\(O(n)\)找到位置后改指针 \(O(1)\),但找位置本身 \(O(n)\)
适用场景表长可预估、按位访问频繁、插删少(如查表、随机取样)表长变化剧烈、频繁插删、只顺序扫描(如不确定长度的流式处理)
一句话记忆「查多改少用顺序,插删频繁用链表」。选择题若问「哪种实现更好」——没有前提就是错项,必须看场景。

2.4.2 顺序表算法实战

例 6 真题风格 两个有序顺序表合并

设 A、B 是元素递增有序的顺序表,写算法合并为递增有序表 C(值相同的元素都保留)。

// 归并:谁小谁先进 C——这是第 8 章归并排序的“原子操作”,现在就要掌握

bool Merge(SqList A, SqList B, SqList *C) {
    if (A.length + B.length > MaxSize) return false;   // 放不下
    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 true;
}
查看解答

每个元素恰好进 C 一次,时间 \(O(m+n)\)、空间 \(O(1)\)(C 是输出,不算辅助)。<= 保证相等时先取 A 的元素,合并稳定(A 中元素的相对次序不被 B 打乱)。

数值验证:A=\(\{2,5\}\),B=\(\{1,3,5\}\):比 \(2|1\) 取 1;比 \(2|3\) 取 2;比 \(5|3\) 取 3;比 \(5|5\) 取 A 的 5(相等取前路);A 尽,扫尾 B 的 5 → C=\(\{1,2,3,5,5\}\) ✓,共比较 4 次恰好每对元素最多比一次。

易错:忘记扫尾循环是最常见丢分点——一路取完后另一路的剩余段必须整体搬过去。

例 7 方法 原地删除有序表中的重复元素(双指针)

非递减有序顺序表中值相同的元素只保留一个,其余删除,要求 \(O(n)\) 时间、\(O(1)\) 空间。

// 快慢指针:i 守“已保留段”末尾,j 向前探查

int Dedup(SqList *L) {
    if (L->length == 0) return 0;
    int i = 0;                                  // 慢指针:已保留段最后一个下标
    for (int j = 1; j < L->length; j++)         // 快指针:逐个检查
        if (L->data[j] != L->data[i])          // 遇到新值
            L->data[++i] = L->data[j];         // 就地覆盖到保留段之后
    L->length = i + 1;
    return L->length;
}
查看解答

数值验证(\(n=6\),\(\{1,2,2,3,3,3\}\)):

\(j\)比较 data[j] 与 data[i]动作保留段
12 ≠ 1data[1]=2,i=1\(\{1,2\}\)
22 = 2跳过\(\{1,2\}\)
33 ≠ 2data[2]=3,i=2\(\{1,2,3\}\)
4、53 = 3跳过\(\{1,2,3\}\)

最终 \(\mathrm{length}=i+1=3\),表为 \(\{1,2,3\}\) ✓。赋值只发生 2 次(每个「新值」一次)——有序表去重从不整体搬移,这是它优于无序表的原因。时间 \(O(n)\)、空间 \(O(1)\)。

练习 5 方法

将顺序表 \(L\) 中元素原地逆置(只允许 \(O(1)\) 辅助空间),写出核心代码并说明复杂度。

查看答案

对折交换(第 1 章例 7 已分析过同类问题):

void Reverse(SqList *L) {
    for (int i = 0; i < L->length / 2; i++) {
        int t = L->data[i];
        L->data[i] = L->data[L->length - 1 - i];
        L->data[L->length - 1 - i] = t;
    }
}

\(n=5\) 时交换 \((0,4)\)、\((1,3)\) 两轮,中间 data[2] 不动 \(\lfloor 5/2\rfloor=2\) ✓。时间 \(O(n)\),空间 \(O(1)\)——它也是自测 10(循环左移)的一个零件。

2.4.3 链表算法实战

例 8 真题风格 找两个链表的公共后缀起点(2012 真题原型)

两个带头结点单链表共享同一段后缀(形如 Y)。设计算法找出公共后缀的起始结点,要求不破坏两链、尽量高效。

// 思路:先各测表长,长链先走 |len1-len2| 步(例 4 的“造距离差”),再同步比较指针

int ListLen(LinkList L) {
    int n = 0;
    LNode *p = L->next;
    while (p != NULL) { n++; p = p->next; }
    return n;
}

LNode *FindFirstCommon(LinkList s1, LinkList s2) {
    int m = ListLen(s1), n = ListLen(s2);
    LNode *p = s1->next, *q = s2->next;
    for (; m > n; m--) p = p->next;           // s1 长则先走
    for (; n > m; n--) q = q->next;           // s2 长则先走
    while (p != NULL && p != q) {             // 同步推进,指针相等即命中
        p = p->next;
        q = q->next;
    }
    return p;                                  // 无公共结点时返回 NULL
}
查看解答

正确性:两链一旦汇合就不再分开(每个结点只有一个 next),所以从公共起点到表尾的长度相同;把长的链先走 \(|\,\mathrm{len}_1-\mathrm{len}_2\,|\) 步后,两指针距表尾等远,同步前进时第一次「指针相等」的结点就是答案。时间 \(O(\mathrm{len}_1+\mathrm{len}_2)\)、空间 \(O(1)\)。

s1 s2 a₁ a₂ b₁ b₂ b₃ c₁ c₂ c₃ ∧ p q len₁ = 5、len₂ = 6:q 先走 |6−5| = 1 步(到 b₂),再与 p(a₁)同步推进 (p=a₁,q=b₂) → (a₂,b₃) → (c₁,c₁):指针相等,返回 c₁ ✓
图 2-3 两个链表的公共后缀:从 c₁ 起两链共用结点(Y 形)。比较的是指针是否相同,不是数据域的值

易错:公共后缀比较的是结点地址(p == q),逐个比 data 会误判——两链前段完全可能出现相同值而不相交。

套路总结:本题 = 例 4「先造距离差」的直接推广;结合 tip-box 的快慢指针,链表设计题的两大双指针模型(间隔 k、速度 2:1)就齐了。

练习 6 方法

用快慢指针写算法:返回带头结点单链表的中间结点(表长偶数时返回后半段起点),并分别对 \(n=5\)、\(n=6\) 各走一遍验证。

查看答案
LNode *FindMid(LinkList L) {
    LNode *slow = L->next, *fast = L->next;
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;          // 慢指针 1 步
        fast = fast->next->next;    // 快指针 2 步
    }
    return slow;
}

验证 \(n=5\)(1…5):轮次后 (s2,f3)→(s3,f5),f5 的 next 为 NULL 停 → 第 3 个 = 中点 ✓。\(n=6\)(1…6):(s2,f3)→(s3,f5)→(s4,f=NULL) 停 → 第 4 个 = \(\lfloor 6/2\rfloor+1\),即后半段起点 ✓。注意 fast != NULL 与 fast->next != NULL 的先后顺序——写反会在偶数表长时解引用 NULL。

2.5 章末自测 真题风格

限时 50 分钟,先做后看答案。难度:★★ 基础 / ★★★ 强化 / ★★★★ 冲刺。所有移动次数、指针推进类结论自检:代入小 \(n\)(3~6)逐步数一遍。

自测 1(选择 · ★★)

表长 \(n=8\) 的顺序表,在位序 3 处插入一个元素,需移动的元素个数是( )
A. 5  B. 6  C. 7  D. 8

查看答案

B。\(n-i+1=8-3+1=6\)(\(a_3\cdots a_8\) 依次后移)✓。顺手验极端:位序 1 插入动 8 个、位序 9(表尾后)动 0 个,均与公式吻合。

自测 2(选择 · ★★ 易错)

关于存取方式,下列说法正确的是( )
A. 链表支持随机存取 B. 顺序表是一种顺序存取的存储结构 C. 顺序表可以随机存取任意一个元素 D. 存取方式由线性表的逻辑结构决定

查看答案

C。A:链表只能从头顺序存取;B:「顺序表」的「顺序」指存储结构(物理相邻),它恰恰是随机存取的——名字与存取方式正好相反,是本考点的经典陷阱;D:存取方式由存储结构决定(对照 2.4 表:同为线性表,两实现存取方式不同)。

自测 3(选择 · ★★★ 高频考点)

带表头结点 t 的单链表为空的判定条件是( )
A. t == NULL  B. t->next == NULL  C. t->next == t  D. t->next->next == NULL

查看答案

B。带头结点时头指针 t 永远指向头结点,判空看首元:t->next 为 NULL。A 是不带头结点时的判据;C 是带头结点循环单链表的判据;D 表示表中恰剩一个首元结点(非空)。

自测 4(选择 · ★★★)

双链表中 p 指向某结点,s 是新结点,在 p 之后插入 s。下列操作序列正确的是( )
① s->next = p->next ② p->next->prior = s ③ s->prior = p ④ p->next = s
A. ①②③④  B. ①③④②  C. ④①②③  D. ②①③④

查看答案

A(另有多个合法变体,如 ③①②④)。硬约束:①②都读取旧的 p->next(取后继、访问其 prior 域),必须发生在改写它的④之前;B 的错误在于④执行后②访问的是 s->prior 而非「原后继的 prior」,原后继结点的 prior 域没被改,反向遍历在 p→s 处断链;C 首句就毁掉后继地址,非法。口诀:凡「用旧值」的语句一律放在「改旧值」之前。

自测 5(选择 · ★★★)

与顺序表相比,静态链表的优点是( )
A. 可以随机存取任一元素 B. 插入删除时无需移动元素 C. 存储密度更高 D. 所需空间随表长动态增长

查看答案

B。插入删除只改游标(下标),元素物理位置不动。A:仍要顺游标逐个找,不能随机存取;C:游标域占额外空间,存储密度低于顺序表;D:容量由数组大小固定,不能动态增长。

自测 6(填空 · ★★)

顺序表首地址 \(\mathrm{LOC}(A)=1000\),每个元素占 6 个单元,则位序 10 的元素地址是 \(\underline{\hspace{1.5cm}}\)。

查看答案

位序 10 ↔ 下标 9:\(1000+9\times6=1054\) ✓(自检:下标 0 存 1000,下标 9 是第 10 个 6 单位格,跨过 \(9\times6=54\) 个单元)。

自测 7(填空 · ★★★)

表长 \(n=101\) 的顺序表,等概率下:插入一个元素平均移动 \(\underline{\hspace{1.5cm}}\) 个;删除一个元素平均移动 \(\underline{\hspace{1.5cm}}\) 个。

查看答案

插入 \(\frac{n}{2}=50.5\) 个;删除 \(\frac{n-1}{2}=50\) 个。代入小 \(n\) 复核:\(n=3\) 时插入按位序 \(1\sim4\) 的移动数为 \(3,2,1,0\),均值 \(\frac{6}{4}=1.5=\frac{3}{2}\) ✓;删除按位序 \(1\sim3\) 的移动数为 \(2,1,0\),均值 \(1=\frac{3-1}{2}\) ✓。

自测 8(填空 · ★★★ 高频考点)

带头结点的循环单链表判空条件是 \(\underline{\hspace{1.5cm}}\);带头结点的循环双链表判空条件是 \(\underline{\hspace{1.5cm}}\)。

查看答案

循环单链表:L->next == L;循环双链表:L->next == L(等价地 L->prior == L,空表时头结点前后指针都指回自己)。辨析口诀:单链表问「下一个是不是空」,循环链表问「下一个是不是自己」。

自测 9(解答 · ★★★)

两个元素递增的单链表 A、B(均带头结点),写算法归并为一个递增单链表 C,要求利用原有结点空间(不再 malloc)。

查看解答

// 尾插归并:r 始终指向 C 的表尾,直接摘原结点接上

LinkList MergeList(LinkList A, LinkList B) {
    LinkList C = A;                          // 借用 A 的头结点
    LNode *p = A->next, *q = B->next, *r = C;
    while (p != NULL && q != NULL) {
        if (p->data <= q->data) { r->next = p; r = p; p = p->next; }
        else                   { r->next = q; r = q; q = q->next; }
    }
    r->next = (p != NULL) ? p : q;           // 扫尾:接上剩余段
    free(B);                                 // B 的头结点不再需要
    return C;
}

每结点只接一次,\(O(m+n)\)、\(O(1)\)。与例 6 顺序表版对照:数组用下标 i/j/k,链表用指针 p/q/r,「比较—摘小—挂尾」三步完全同构;扫尾只需改一个指针(剩余段本来就连着)。易错点同样是忘扫尾。

自测 10(解答 · ★★★★ 冲刺)

(2010 真题原型)将 \(n\) 个整数存于数组 R,设计算法把 R 循环左移 \(p\ (0\lt p\lt n)\) 个位置(如 1 2 3 4 5 左移 2 位得 3 4 5 1 2),要求时间 \(O(n)\)、空间 \(O(1)\)。

查看解答

思路(三次逆置,练习 5 是零件):先把前 \(p\) 个逆置、再把后 \(n-p\) 个逆置、最后整体逆置。

// ab → (a^r b^r),再整体逆置得 ba,即左移 p 位

void Reverse(int R[], int left, int right) {
    while (left < right) {
        int t = R[left]; R[left] = R[right]; R[right] = t;
        left++; right--;
    }
}

void LeftShift(int R[], int n, int p) {
    Reverse(R, 0, p - 1);      // 前段逆置
    Reverse(R, p, n - 1);      // 后段逆置
    Reverse(R, 0, n - 1);      // 整体逆置
}

数值验证(\(n=5\),\(p=2\),R=1 2 3 4 5):① 逆置前 2 个 → 2 1 3 4 5;② 逆置后 3 个 → 2 1 5 4 3;③ 整体逆置 → 3 4 5 1 2,恰为左移 2 位 ✓(每个元素被交换 \(\le 2\) 次,总交换次数 \(\lfloor p/2\rfloor+\lfloor(n-p)/2\rfloor+\lfloor n/2\rfloor\lt\frac{3n}{2}\),时间 \(O(n)\)、空间 \(O(1)\))。

易错:三段逆置的区间端点(0..p−1 / p..n−1 / 0..n−1);若要右移 \(p\) 位等价于左移 \(n-p\) 位,直接换参数即可。

2.6 本章考点总结

考点常考题型热度核心方法
头结点辨析(作用 / 判空)选择题★★★★★ 高频带头结点统一空表与首位操作;判空 L->next == NULL(单)、== L(循环);不带头结点看 L == NULL
随机存取 vs 顺序存取选择题★★★★ 高频顺序表 = 顺序存储但随机存取;链表 = 链式存储且顺序存取;名字与结论正好相反
插入 / 删除移动次数选择 / 填空★★★★★ 高频插 \(n-i+1\)、删 \(n-i\) 个;平均 \(\frac{n}{2}\)、\(\frac{n-1}{2}\);插合法 \(1\le i\le n+1\)、删 \(1\le i\le n\);代小 \(n\) 验证
顺序表地址计算填空★★★\(\mathrm{LOC}(a_i)=\mathrm{LOC}(A)+(i-1)\cdot c\)(位序);下标 \(j\) 则无 \(-1\);先换算位序↔下标再算
单链表插入删除指针顺序选择 / 大题★★★★「先接新、再改旧」:用旧 p->next 的语句放在改写它之前;删除先摘链后 free
建表与逆置大题★★★★头插得逆序(建表/逆置两用)、尾插得顺序(r 指尾);逆置 = 摘空 + 逐个头插
双指针模型大题★★★★★ 真题高发间隔 k(倒数第 k、公共后缀:长链先走差值步);快慢 2:1(找中点、判环);快慢同步覆盖(有序去重)
归并与循环左移大题★★★★「比较—取小—挂尾」+ 别忘扫尾;左移 p 位 = 三段逆置(\(O(n)\)/\(O(1)\))
双链表 / 循环链表 / 静态链表选择 / 判断★★★双链表四步插入(旧 p->next 先用后改);循环表遍历看 p != L;静态链表游标免移动、不能随机存取
下一步本章过关标准:例题全部独立重做(尤其例 2、5、6、7、8 的代码能白纸默写);自测 10 题至少 8 题正确;移动次数与地址公式能现场推导而非背诵;任给链表操作题能先想「前驱指针是谁、双指针能不能造距离差」。然后进入 第 3 章 栈、队列和数组——它们是「运算受限的线性表」,栈队列的顺序实现正是本章顺序表与循环技巧的直接延伸。