Appearance
2019·41 链表重排:从数组辅助到三步原地
本文是「从暴力解到最优解」专题的第二篇。这道题和 2018·41 的分差来源相反:那道卡的是时间,这道卡的是空间——题面直接把 O(1) 空间写成了硬性约束。
题目
设线性表 L = (a₁, a₂, a₃, …, aₙ₋₂, aₙ₋₁, aₙ) 采用带头结点的单链表保存,链表中的结点定义如下:
c
typedef struct node {
int data;
struct node *next;
} NODE;请设计一个空间复杂度为 O(1) 且时间上尽可能高效的算法,重新排列 L 中的各结点,得到线性表 L' = (a₁, aₙ, a₂, aₙ₋₁, a₃, aₙ₋₂, …)。要求:
(1) 给出算法的基本设计思想。 (2) 根据设计思想,采用 C 或 C++ 语言描述算法,关键之处给出注释。 (3) 说明你所设计算法的时间复杂度。
总分 10 分。注意题面同时给了两个约束:空间 O(1) 是硬性写死的,时间「尽可能高效」。
第一步:最自然的想法——数组辅助
L' 的形态是「从两端向中间交替取」:先 a₁、再 aₙ、再 a₂、再 aₙ₋₁……如果这是数组题,两端下标 i、j 交替向中间靠拢就完事了。麻烦在于它是单链表——aₙ 在链表里恰恰是最难够到的。
最自然的补救:把链表结点指针全存进一个数组,用数组换来随机访问能力,然后按 L' 的顺序重新接 next 指针:
c
void rearrange(NODE *L) {
// 数一遍结点个数
int n = 0;
for (NODE *p = L->next; p != NULL; p = p->next) n++;
if (n < 3) return; // 0/1/2 个结点无需重排
// 把所有结点指针存进数组,换来随机访问
NODE **nodes = (NODE **)malloc(n * sizeof(NODE *));
int k = 0;
for (NODE *p = L->next; p != NULL; p = p->next) nodes[k++] = p;
// 两端下标交替向中间靠拢,按 L' 顺序重接 next
int i = 0, j = n - 1;
while (i < j) {
nodes[i]->next = nodes[j]; // a(i+1) -> a(j+1)
nodes[j]->next = nodes[i + 1]; // a(j+1) -> 下一个左端元素
i++;
j--;
}
nodes[i]->next = NULL; // 中间结点收尾
free(nodes);
}拿 n=6 走一遍:i=0, j=5 接出 a₁→a₆→a₂;i=1, j=4 接出 a₂→a₅→a₃;i=2, j=3 接出 a₃→a₄;退出循环后 a₄ 收尾置 NULL。结果 a₁,a₆,a₂,a₅,a₃,a₄ ✓。奇数长度 n=5 时退出循环的是 i=j=2,a₃ 收尾 ✓。
复杂度:数结点 + 存数组 + 重接,三段线性,时间
时间已经达标了,卡在空间上。
它值多少分
按正确性/最优性两个维度(详见总纲):
- 正确性:这份解设计思想清楚(两端交替取)、代码能正确得到 L'、奇偶长度都处理对了——正确性维度的主体分拿得到。
- 最优性:题面把 O(1) 空间写成了硬性要求,
的指针数组直接违反它,空间相关的分拿不到。
还有一条路线也值得知道:不开数组、每轮从当前位置向后扫到尾把尾结点摘下来接到前面——空间
对这道 10 分的题来说,暴力解保底、分差有限——但这道题的最优解三步全是链表基本功,值得练到能拿满。
差距在哪:数组买来的能力,链表自己就有
数组辅助解花
- 左半边 a₁, a₂, a₃, …——从头顺序访问。链表本来就会。
- 右半边 aₙ, aₙ₋₁, aₙ₋₂, …——从尾逆序访问。链表不会,这才是花钱买数组的真实原因。
所以浪费点找到了:为了「逆序访问后一半」这一个需求,把整条链表都搬进了数组。而「让链表的后一半支持逆序访问」有零成本的做法——就地逆置。后半截反转之后,「逆序访问原后半」变成了「顺序访问新链」,随机访问的需求彻底消失。
智慧化改造:三步原地
于是最优解自然浮现,三步全是改指针,一个新结点都不分配:
- 快慢指针找中点——slow 每步走 1、fast 每步走 2,fast 到尾时 slow 正好在前半最后一个结点,把链表切成前后两半;
- 后半就地逆置——三指针反转(prev/cur/nxt),aₙ 成为后半新头;
- 交叉合并——前半指针 p、逆置后的后半指针 r 交替编织。
c
void rearrange(NODE *L) {
if (L == NULL || L->next == NULL || L->next->next == NULL) return;
// ① 快慢指针找中点:slow 终止在前半最后一个结点
NODE *slow = L, *fast = L;
while (fast->next != NULL && fast->next->next != NULL) {
slow = slow->next;
fast = fast->next->next;
}
// ② 截下后半并就地逆置
NODE *q = slow->next;
slow->next = NULL; // 先切断,再逆置
NODE *prev = NULL, *cur = q, *nxt;
while (cur != NULL) {
nxt = cur->next;
cur->next = prev;
prev = cur;
cur = nxt;
}
NODE *r = prev; // 逆置后的新头(即原 an)
// ③ 交叉合并:r 的结点逐个插到前半的间隙里
NODE *p = L->next;
while (p != NULL && r != NULL) {
NODE *p_nxt = p->next;
NODE *r_nxt = r->next;
p->next = r;
r->next = p_nxt;
p = p_nxt;
r = r_nxt;
}
}复杂度:找中点、逆置、合并各扫半条链,时间
从数组辅助解升级过来,四个细节决定这 10 分能拿多满:
- 快慢指针的循环条件必须是
fast->next != NULL && fast->next->next != NULL。写成fast != NULL && fast->next != NULL会让 slow 多走一步,奇数长度时切分点偏右、前后半颠倒。 - 先切断再逆置:
slow->next = NULL必须发生在反转之前,否则前后两半在反转过程中纠缠不清。 - 合并时先存后继再改指针:
p_nxt、r_nxt必须先保存——p->next = r一执行,原来的后继就找不回来了。这和逆置的三指针是同一个肌肉记忆。 - 前半 ≥ 后半是隐含不变量:偶数长度两半相等,奇数长度前半多一个。合并循环里 r 先耗尽,剩下的前半尾结点连接已经就位,无需额外收尾。
两版对照跑一遍
同一条链 1 → 2 → 3 → 4 → 5 → 6,目标 1 → 6 → 2 → 5 → 3 → 4。先看数组辅助版(演示为不带头结点的等价 JavaScript 版本):
再看三步原地版——注意全程只有指针在动,没有任何新容器出现:
演示为不带头结点的 JavaScript 等价版本;考卷上请按题面用带头结点写法(上文 C 代码)。
下一步
- 在 OJ 上把两版代码都提交跑一遍:p2313 链表重排
- 回到题库看这道题的完整解析与错因:真题库 · 数据结构
- 三步里的每一步都有独立的真题和讲解:找中点/倒数定位见 链表算法题三板斧,逆置是 2009·42 之后反复出现的基本功
- 上一篇:2018·41 未出现的最小正整数