第3章 链表算法
第3章 链表算法¶
基本口径与安全改链:先认清头结点,再动 next¶
“带头结点”和“头指针”到底差在哪里?¶
何时用:所有单链表算法落笔前;题干说“带头结点”时尤其要先统一口径。
思路步骤:
- 头指针 head 指向头结点;头结点不存有效数据,首个数据结点是 head->next。
- 空表的判定是 head!=NULL 且 head->next==NULL。
- 需要修改首个数据结点时,让 pre 从 head 开始,可统一普通位置与表首位置。
- 若题目是不带头结点链表,函数往往要接收二级指针或返回新表头,不能直接照搬。
伪代码/关键模板:
复杂度:口径本身无复杂度;头结点让表首插删保持 O(1) 且少一个特判。
易错边界:不要把头结点算入表长或倒数第 k 个;head==NULL 表示连头结点都不存在,与空表不是同一状态;本章默认“带头结点”。
b_72_11;PDF 第 72 页 · b_72_13;PDF 第 72 页 · b_72_15;PDF 第 72 页 · b_72_16遍历带头结点单链表,起点和终止条件怎样写?¶
何时用:统计长度、查值、打印、求和,以及绝大多数链表算法的基础扫描。
思路步骤:
- p=head->next,从首个数据结点开始。
- 在 p!=NULL 时处理 p->data。
- 每轮末尾令 p=p->next。
- 若需要删除或插入,另设 pre,不能只保留当前 p。
伪代码/关键模板:
static int ac_list_length(const ACNode *head) {
int n = 0;
if (head == NULL) return 0;
for (const ACNode *p = head->next; p != NULL; p = p->next) ++n;
return n;
}
复杂度:时间 O(n),额外空间 O(1)。
易错边界:从 head 开始会把头结点误计;循环内漏写推进会死循环;链表若有环,普通遍历不会终止,应先判环或以长度限制。
b_75_0;PDF 第 75 页 · b_75_1;PDF 第 75 页 · b_75_6;PDF 第 75 页 · b_75_10删除当前结点时,为什么必须先保存 next?¶
何时用:扫描过程中删除满足条件的结点,尤其是一轮内可能连续删除多个结点时。
思路步骤:
- 维护 pre 指向当前 p 的前驱。
- 命中删除条件时先保存 next=p->next。
- 令 pre->next=next,再释放 p,最后 p=next;此时 pre 不动。
- 未删除时才同时推进 pre=p、p=p->next。
伪代码/关键模板:
static void ac_delete_value(ACNode *head, int value) {
if (head == NULL) return;
ACNode *pre = head;
ACNode *p = head->next;
while (p != NULL) {
if (p->data == value) {
ACNode *next = p->next;
pre->next = next;
free(p);
p = next;
} else {
pre = p;
p = p->next;
}
}
}
复杂度:时间 O(n),额外空间 O(1)。
易错边界:free(p) 后绝不能再读 p->next;连续命中时 pre 不前进。新版增强(不判旧版考试写法错误):若题目只考链表逻辑且允许省略内存回收,可按评分要求书写;本卡按完整 C 所有权口径保存后继、修链后 free。共享后缀等场景则不能擅自释放。
b_73_4;PDF 第 73 页 · b_73_7;PDF 第 73 页 · b_73_10;PDF 第 73 页 · b_73_12在 pre 后插入结点,怎样避免丢掉原后继?¶
何时用:已知插入位置前驱 pre,把现有结点 node 插到其后。
思路步骤:
- 保存原后继 old_next=pre->next。
- 令 node->next=old_next。
- 再令 pre->next=node。
- 若 node 原本属于另一条链,先从原链安全摘下;不能让同一结点同时被两条独立链拥有。
伪代码/关键模板:
static int ac_insert_after(ACNode *pre, ACNode *node) {
if (pre == NULL || node == NULL) return 0;
ACNode *old_next = pre->next;
node->next = old_next;
pre->next = node;
return 1;
}
复杂度:已知 pre 时,时间 O(1),额外空间 O(1)。
易错边界:若先写 pre->next=node,再从 pre->next 取原后继就已丢失;插入自身会成环;“在 p 前插入”需要先找前驱或采用数据交换技巧,不能假装有前向指针。
b_73_15;PDF 第 73 页 · b_73_17;PDF 第 73 页 · b_73_19;PDF 第 74 页 · b_74_9头插与尾插分别保留或逆转什么顺序?¶
何时用:用已有结点建表、把一段链表逆置,或归并时决定结果顺序。
思路步骤:
- 头插:每个新结点插在头结点后,输入顺序会反转。
- 尾插:维护 tail 指向当前尾结点,新结点接到 tail 后,输入顺序保留。
- 尾插后要更新 tail;插入已有结点时先保存其原 next。
- 最终尾结点 next 必须为 NULL。
伪代码/关键模板:
static void ac_push_front(ACNode *head, ACNode *node) {
if (head == NULL || node == NULL) return;
node->next = head->next;
head->next = node;
}
static void ac_push_back(ACNode *head, ACNode **tail, ACNode *node) {
if (head == NULL || tail == NULL || *tail == NULL || node == NULL) return;
node->next = NULL;
(*tail)->next = node;
*tail = node;
}
复杂度:有头结点/尾指针时单次插入均为 O(1);额外空间 O(1)。
易错边界:尾插若每次从头找尾会退化为 O(n²);头插天然逆序,不适合要求保序的建表;空表时 tail 初值应为 head。
b_74_1;PDF 第 74 页 · b_74_2;PDF 第 74 页 · b_74_3;PDF 第 74 页 · b_74_6;PDF 第 74 页 · b_74_7有序归并与快慢指针:利用单向结构而非随机访问¶
两条升序链表怎样原地归并并复用结点?¶
何时用:两个带头结点的升序链表合并,要求不新建数据结点。
思路步骤:
- p、q 分别指向两表首个数据结点,tail 指向结果尾部。
- 比较 p、q,把较小结点接到 tail 后并推进对应指针。
- 每接一个结点都推进 tail。
- 某一表结束后,直接接上另一表剩余链。
伪代码/关键模板:
static ACNode *ac_merge_lists(ACNode *h1, ACNode *h2) {
if (h1 == NULL || h2 == NULL) return h1;
ACNode *p = h1->next, *q = h2->next, *tail = h1;
while (p != NULL && q != NULL) {
if (p->data <= q->data) {
tail->next = p;
p = p->next;
} else {
tail->next = q;
q = q->next;
}
tail = tail->next;
}
tail->next = p != NULL ? p : q;
h2->next = NULL;
return h1;
}
复杂度:时间 O(m+n),额外空间 O(1)。
易错边界:复用结点后第二个头结点不再拥有原数据链;若两表共享结点,普通归并会破坏结构;相等元素是否保留两份由题意决定。
b_78_0;PDF 第 78 页 · b_78_4;PDF 第 78 页 · b_78_6;PDF 第 79 页 · b_79_2快慢指针判环与找中点,循环条件为何不同?¶
何时用:判环、寻找链表中点、把链表分成前后两半。
思路步骤:
- 判环:slow 每次一步、fast 每次两步;能相遇则有环。
- 找中点:链表必须无环;让 slow、fast 都从首个数据结点出发。
- 当 fast 后面不足两步时停止,slow 是前半段末结点。
- 切分时先保存 slow->next,再令 slow->next=NULL。
伪代码/关键模板:
static int ac_has_cycle(const ACNode *head) {
const ACNode *slow = head, *fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return 1;
}
return 0;
}
static ACNode *ac_first_half_end(ACNode *head) {
if (head == NULL || head->next == NULL) return head;
ACNode *slow = head->next, *fast = head->next;
while (fast->next != NULL && fast->next->next != NULL) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
复杂度:时间 O(n),额外空间 O(1)。
易错边界:求中点前必须排除环;访问 fast->next->next 前先保证 fast->next 非空;偶数长度时本模板返回前半段最后一个结点。
b_79_5;PDF 第 79 页 · b_79_6;PDF 第 79 页 · b_79_7;PDF 第 80 页 · b_80_0前后指针保持固定间隔,怎样抽象“倒数第 k 个”?¶
何时用:单链表倒数第 k 个、删除倒数第 k 个,或让两个指针之间保持固定距离。
思路步骤:
- lead、trail 都从头结点出发。
- lead 先走 k 个数据边;若不足 k 个,问题无解。
- 之后两者同步移动,保持相差 k 个结点。
- lead 到尾结点时,trail->next 就是倒数第 k 个数据结点。
伪代码/关键模板:
static const ACNode *ac_kth_from_end(const ACNode *head, int k) {
if (head == NULL || k <= 0) return NULL;
const ACNode *lead = head, *trail = head;
for (int i = 0; i < k; ++i) {
if (lead->next == NULL) return NULL;
lead = lead->next;
}
while (lead->next != NULL) {
lead = lead->next;
trail = trail->next;
}
return trail->next;
}
复杂度:一次线性扫描,时间 O(n),额外空间 O(1)。
易错边界:k 必须为正;头结点不计入名次;先行指针的起点与停止条件必须成套,否则会差一位。
b_80_3;PDF 第 80 页 · b_80_6;PDF 第 80 页 · b_80_7;PDF 第 80 页 · b_80_8逆置与结构变换:每次改链前都保存后继¶
原地逆置单链表,三指针各自负责什么?¶
何时用:整表逆置、只逆置后半段、回文判断、2019 重排等。
思路步骤:
- prev 保存已逆置部分的表头,cur 指向待处理结点。
- 先保存 next=cur->next。
- 改向 cur->next=prev,把 cur 纳入已逆置部分。
- prev=cur、cur=next;结束后把头结点 next 指向 prev。
伪代码/关键模板:
static ACNode *ac_reverse_chain(ACNode *first) {
ACNode *prev = NULL, *cur = first;
while (cur != NULL) {
ACNode *next = cur->next;
cur->next = prev;
prev = cur;
cur = next;
}
return prev;
}
static void ac_reverse_after_head(ACNode *head) {
if (head != NULL) head->next = ac_reverse_chain(head->next);
}
复杂度:时间 O(n),额外空间 O(1)。
易错边界:改 cur->next 前不保存 next 会丢失余链;空表和单结点表应自然成立;本函数反转数据链,不反转头结点。
b_81_7;PDF 第 82 页 · b_82_0;PDF 第 82 页 · b_82_1;PDF 第 82 页 · b_82_2;PDF 第 82 页 · b_82_7回文链表怎样做到 O(n)、O(1),还不破坏原表?¶
何时用:判断带头结点单链表是否回文,且要求常数额外空间。
思路步骤:
- 快慢指针找到前半段末结点。
- 原地逆置后半段。
- 从表首与逆置后半段同步比较;后半段比较完即可。
- 再逆置一次并接回,恢复输入结构。新版增强(不判旧解错误):若题设只要求判断且允许改变链表,比较后不恢复也能完成题意;本卡给出不破坏输入结构的版本。
伪代码/关键模板:
static int ac_is_palindrome_restore(ACNode *head) {
if (head == NULL) return 0;
ACNode *first_end = ac_first_half_end(head);
ACNode *second = ac_reverse_chain(first_end->next);
int same = 1;
for (ACNode *p = head->next, *q = second; q != NULL; p = p->next, q = q->next) {
if (p->data != q->data) {
same = 0;
break;
}
}
first_end->next = ac_reverse_chain(second);
return same;
}
复杂度:时间 O(n),额外空间 O(1)。
易错边界:比较失败也必须恢复链表;奇数长度时中间结点无需与自己比较;若链表可能有环,应先判环。
b_90_0;PDF 第 90 页 · b_90_1;PDF 第 91 页 · b_91_1;PDF 第 91 页 · b_91_2;PDF 第 91 页 · b_91_4;PDF 第 91 页 · b_91_5两条升序链表怎样不排序就合成降序链表?¶
何时用:两条升序链表原地合并,结果要求降序,且允许复用原结点。
思路步骤:
- 像升序归并一样,每次摘下两表当前较小结点。
- 但不尾插,而是把摘下结点头插到结果头结点之后。
- 摘取顺序从小到大,头插后整体自然从大到小。
- 一表耗尽后继续逐个摘取另一表并头插。
伪代码/关键模板:
static ACNode *ac_merge_descending(ACNode *h1, ACNode *h2) {
if (h1 == NULL || h2 == NULL) return h1;
ACNode *p = h1->next, *q = h2->next;
h1->next = NULL;
while (p != NULL || q != NULL) {
ACNode *node;
if (q == NULL || (p != NULL && p->data <= q->data)) {
node = p;
p = p->next;
} else {
node = q;
q = q->next;
}
node->next = h1->next;
h1->next = node;
}
h2->next = NULL;
return h1;
}
复杂度:时间 O(m+n),额外空间 O(1)。
易错边界:摘下 node 后先保存原后继;若要求去重,相等分支还需删除/跳过一个结点;两个原头结点的归属要说明清楚。
b_88_0;PDF 第 88 页 · b_88_1;PDF 第 89 页 · b_89_2;PDF 第 89 页 · b_89_3;PDF 第 89 页 · b_89_5;PDF 第 89 页 · b_89_6链表综合方法:比较、摘取、复用结点¶
稀疏多项式相加,怎样把“归并”扩展到同指数消项?¶
何时用:两个带头结点多项式链表按指数降序,要求结果仍降序并尽量复用原结点。
思路步骤:
- p、q 指向两表当前项,pre 指向 p 的前驱。
- p 指数更大:保留 p,推进 p/pre。
- q 指数更大:把 q 从 L2 摘下并插到 pre 与 p 之间。
- 指数相等:系数相加;若为 0,同时删除该指数项;否则保留合并项。
- L2 若有剩余,直接接到 L1 尾部。
伪代码/关键模板:
typedef struct ACPolyNode {
long long coef;
int exp;
struct ACPolyNode *next;
} ACPolyNode;
static ACPolyNode *ac_add_polynomial(ACPolyNode *h1, ACPolyNode *h2) {
if (h1 == NULL || h2 == NULL) return h1;
ACPolyNode *pre = h1, *p = h1->next, *q = h2->next;
while (p != NULL && q != NULL) {
if (p->exp > q->exp) {
pre = p;
p = p->next;
} else if (p->exp < q->exp) {
ACPolyNode *qnext = q->next;
q->next = p;
pre->next = q;
pre = q;
q = qnext;
} else {
p->coef += q->coef;
ACPolyNode *qnext = q->next;
free(q);
q = qnext;
if (p->coef == 0) {
ACPolyNode *dead = p;
p = p->next;
pre->next = p;
free(dead);
} else {
pre = p;
p = p->next;
}
}
}
if (q != NULL) pre->next = q;
h2->next = NULL;
return h1;
}
复杂度:时间 O(m+n),额外空间 O(1)。
易错边界:比较的是指数而不是系数;相同指数相加为 0 时必须同时修链和释放;摘取 q 前先保存 q->next;函数会消费 L2 的数据结点。
b_92_0;PDF 第 92 页 · b_92_1;PDF 第 93 页 · b_93_2;PDF 第 93 页 · b_93_3;PDF 第 93 页 · b_93_4;PDF 第 94 页 · b_94_1两个升序链表求交集,怎样原地保留 L1 中的公共结点?¶
何时用:两个无重复升序链表求值交集,要求结果写回 L1,额外空间 O(1)。
思路步骤:
- p、q 分别扫描 L1、L2,tail 指向 L1 中最后一个已保留结点。
- p->data
data 时,该 p 不可能再匹配,删除 p。 - p->data>q->data 时,推进 q。
- 相等时保留 p,推进 p、q 与 tail。
- 任一链结束后,删除 L1 中剩余未匹配结点。
伪代码/关键模板:
static void ac_free_chain(ACNode *p) {
while (p != NULL) {
ACNode *next = p->next;
free(p);
p = next;
}
}
static ACNode *ac_sorted_intersection(ACNode *h1, const ACNode *h2) {
if (h1 == NULL || h2 == NULL) return h1;
ACNode *p = h1->next, *tail = h1;
const ACNode *q = h2->next;
while (p != NULL && q != NULL) {
if (p->data < q->data) {
ACNode *dead = p;
p = p->next;
tail->next = p;
free(dead);
} else if (p->data > q->data) {
q = q->next;
} else {
tail = p;
p = p->next;
q = q->next;
}
}
ac_free_chain(p);
tail->next = NULL;
return h1;
}
复杂度:时间 O(m+n),额外空间 O(1)。
易错边界:这里求的是“值交集”,与共享后缀的“地址相同”不是一题;删除 L1 结点前保存后继;若结点被外部共享,不能直接 free。
b_94_2;PDF 第 94 页 · b_94_3;PDF 第 95 页 · b_95_7;PDF 第 95 页 · b_95_8;PDF 第 96 页 · b_96_0;PDF 第 96 页 · b_96_8统考真题路线:把公共模板写成完整答案¶
2009 倒数第 k 个,怎样按真题要求返回成功状态和值?¶
何时用:带头结点单链表只给头指针,不改变链表,查找倒数第 k 个并通过返回值表示成败。
思路步骤:
- 复用固定间隔模板,让 lead 先走 k 步。
- 不足 k 个数据结点立即失败。
- lead 与 trail 同步走到末端。
- 把 trail->next 的 data 写入输出参数,返回 1;失败返回 0。
伪代码/关键模板:
static int ac_kth_value(const ACNode *head, int k, int *out) {
if (out == NULL) return 0;
const ACNode *node = ac_kth_from_end(head, k);
if (node == NULL) return 0;
*out = node->data;
return 1;
}
复杂度:时间 O(n),额外空间 O(1),且链表结构不变。
易错边界:k≤0、空表、长度不足 k 都失败;不要把头结点算入 k;输出参数只有成功时才写入。
b_96_10;PDF 第 96 页 · b_96_13;PDF 第 98 页 · b_98_8;PDF 第 98 页 · b_98_9;PDF 第 98 页 · b_98_11;PDF 第 99 页 · b_99_12012 公共后缀,为什么比较地址而不是字符值?¶
何时用:两条单链表可能共享一段实际存储结点,求共同后缀的起始结点。
思路步骤:
- 分别求两链数据结点长度 len1、len2。
- 较长链先走长度差,使两个指针到表尾的剩余距离相同。
- 两指针同步后移,第一次 p==q 的结点就是公共后缀起点。
- 同值但不同地址不算共享;若始终不相等则返回 NULL。
伪代码/关键模板:
static const ACNode *ac_common_suffix_start(const ACNode *h1, const ACNode *h2) {
int n1 = ac_list_length(h1), n2 = ac_list_length(h2);
const ACNode *p = h1 == NULL ? NULL : h1->next;
const ACNode *q = h2 == NULL ? NULL : h2->next;
while (n1 > n2) {
p = p->next;
--n1;
}
while (n2 > n1) {
q = q->next;
--n2;
}
while (p != NULL && q != NULL && p != q) {
p = p->next;
q = q->next;
}
return p == q ? p : NULL;
}
复杂度:时间 O(m+n),额外空间 O(1)。
易错边界:核心判定是 p==q,不是 p->data==q->data;共享结点以后 next 链天然相同;共享结构不能由某一条链单独释放。
b_99_2;PDF 第 99 页 · b_99_3;PDF 第 99 页 · b_99_12;PDF 第 100 页 · b_100_7;PDF 第 100 页 · b_100_8;PDF 第 101 页 · b_101_0;PDF 第 101 页 · b_101_12015 按绝对值去重,散列表值域和删除动作怎样同时写对?¶
何时用:链表含 m 个整数,且 |data|≤n,只保留每种绝对值第一次出现的结点。
思路步骤:
- seen[0..n] 记录某绝对值是否出现,空间由值域 n 决定。
- pre 指向 p 的前驱;首次出现就标记并同步前进。
- 已出现时保存 p->next,修正 pre->next,释放 p,再从保存处继续。
- 用 long long 计算绝对值,避免对 INT_MIN 直接调用 abs。
伪代码/关键模板:
static int ac_abs_dedup(ACNode *head, int n) {
if (head == NULL || n < 0) return 0;
for (ACNode *p = head->next; p != NULL; p = p->next) {
long long v = p->data;
if (v < 0) v = -v;
if (v > n) return 0;
}
unsigned char *seen = calloc((size_t)n + 1U, sizeof(*seen));
if (seen == NULL) return 0;
ACNode *pre = head, *p = head->next;
while (p != NULL) {
long long v = p->data;
if (v < 0) v = -v;
if (!seen[(size_t)v]) {
seen[(size_t)v] = 1;
pre = p;
p = p->next;
} else {
ACNode *next = p->next;
pre->next = next;
free(p);
p = next;
}
}
free(seen);
return 1;
}
复杂度:时间 O(m+n)(含 seen 初始化;题目常简写 O(m+n)),空间 O(n)。若 n=O(m),也可按题目口径写线性空间。
易错边界:值域包含 0 和 n,所以数组长 n+1;删除时 pre 不移动;空间复杂度由 n 而非链长 m 决定;分配失败不能继续访问 seen。
b_101_2;PDF 第 101 页 · b_101_3;PDF 第 102 页 · b_102_2;PDF 第 103 页 · b_103_3;PDF 第 103 页 · b_103_4;PDF 第 103 页 · b_103_8;PDF 第 103 页 · b_103_92019 重排为 a1,an,a2,an-1…,三段结构变换怎样闭环?¶
何时用:带头结点单链表要求原地重排为首、尾、次首、次尾……,时间 O(n)、额外空间 O(1)。
思路步骤:
- 快慢指针找到前半段末结点 first_end。
- 从 first_end->next 切开,并把后半段原地逆置。
- p 指向前半段,q 指向逆置后的后半段。
- 每轮先保存 pnext、qnext,再接 p->q->pnext,之后同步推进。
- 后半段耗尽时结束;奇数长度的中间结点已留在前半段末尾。
伪代码/关键模板:
static void ac_reorder_2019(ACNode *head) {
if (head == NULL || head->next == NULL || head->next->next == NULL) return;
ACNode *first_end = ac_first_half_end(head);
ACNode *second = ac_reverse_chain(first_end->next);
first_end->next = NULL;
ACNode *p = head->next, *q = second;
while (q != NULL) {
ACNode *pnext = p->next;
ACNode *qnext = q->next;
p->next = q;
q->next = pnext;
p = pnext;
q = qnext;
}
}
复杂度:找中点、逆置、交叉合并各 O(n),总时间 O(n),额外空间 O(1)。
易错边界:新版补充/校订:旧版 p105 只展示到新建后半段头结点,完整实现跨页截断;本卡给出校订完成版,证据仍锚定 p104–105 的题意、步骤和部分代码。切链、逆置、交织每次都要先保存 next;无需 malloc 新头结点。
b_104_0;PDF 第 104 页 · b_104_3;PDF 第 104 页 · b_104_5;PDF 第 105 页 · b_105_4;PDF 第 105 页 · b_105_5;PDF 第 105 页 · b_105_6;PDF 第 105 页 · b_105_9;PDF 第 105 页 · b_105_10;PDF 第 105 页 · b_105_11