ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

C数据结构习题解答落地:ADT、复杂度实测与差分对拍

C数据结构习题解答落地:ADT、复杂度实测与差分对拍 简介这份资源是《数据结构与算法分析C语言描述》第二版的配套习题解答手册面向正在学习数据结构与算法分析课程、准备考试或希望对照课后练习查漏补缺的读者。压缩包内仅含1个PDF文件大小约233KB集中呈现原书多数练习的参考答案便于按章节检索。内容覆盖算法分析、表栈与队列、树、哈希、优先队列、排序、不相交集合、图算法、算法设计技巧及摊还分析等主题解答以伪C代码和思路说明为主细节多留给读者自行补全。已有142人学习下载。它适合在完成课后题后核对结论、理解复杂度推导与经典结构实现思路也能帮助读者发现理解偏差并深化对数据结构与算法设计的掌握。1. 一份习题解答手册真正的用法先跑通再对拍最后改参数很多人拿到 C 语言数据结构与算法分析的教材和配套习题解答手册第一反应是当小说翻看完 AVL 旋转觉得自己懂了合上书写不出三行。真正有用的用法是把解答手册当成一份“规范文档 测试用例库”而不当成阅读材料。手册里出现的每一种结构都要落成能编译、能被 sanitizer 检查、能被随机数据反复锤打的一份 C 代码每一个复杂度结论都要在自己的机器上换成可测的数字。这条路径适合两类人正在用 C 语言补数据结构底子的开发者以及面试或工程里需要手写链表、堆、散列的熟手。后面从伪代码落地讲到差分对拍把手册里的答案变成你仓库里能回归的资产。2. 从伪代码到能编译的 CADT 骨架、指针与内存的三条线解答手册里的代码片段多半是“示意性”的函数签名省略了错误返回节点定义省略了 typedef内存分配干脆写成new或者一句话带过。这些片段作为思路无误直接贴进编辑器就会连锁翻车。这一章解决的是最底层的一步把手册里的抽象数据类型ADT描述还原成一份结构清晰、内存可控、能开告警编译的 C 工程骨架。2.1 为什么解答里的伪代码不能直接抄成 CC 语言版教材的行文习惯是先用 ADT 定义接口再给实现思路最后给一段接近 C 但不等同于 C 的代码。最容易出问题的是三处。第一处是节点定义。手册里常写“设节点包含一个元素和两个指针”实际落地要考虑 key 的类型、是否需要父指针、是否需要维护 size 和 height 这类附加字段。这些字段一旦写进结构体所有旋转、删除、重建逻辑都要跟着改所以结构体定义必须在动手前一次定清楚中途加字段的代价很高。第二处是错误处理。手册里的Insert往往只关心“插入成功”这一条路径没有说明分配失败怎么办、重复键怎么办、删除不存在的键怎么办。真实代码里这三条路径都必须有明确返回值否则调用方只能靠全局变量猜状态。第三处是所有权。手册讲链表删除节点时写“释放该节点”但没讲清楚被删除节点里的数据指针由谁释放。这在单向链表上不致命在多级结构散列表的桶、图的邻接表上就会变成连锁泄漏。我一般的做法是先写头文件把接口和错误返回定死再写实现。接口定下来之后实现怎么写都不会跑偏。2.2 一个可复用的 ADT 头文件骨架下面这份骨架是从手册里最常见的“带哨兵的单向链表”抽象出来的可以直接当模板用/* list.h —— 接口先行所有权、返回码、不变量写清楚 */ #ifndef LIST_H #define LIST_H #include stddef.h typedef int ElementType; /* 元素类型集中在这里改实现文件不用动 */ typedef enum { LIST_OK 0, LIST_ERR_ALLOC -1, /* 内存分配失败 */ LIST_ERR_NOT_FOUND -2, /* 删除/查找目标不存在 */ LIST_ERR_INVALID -3 /* 空指针、越界等调用方错误 */ } ListStatus; typedef struct ListNode { ElementType value; struct ListNode *next; } ListNode; typedef struct { ListNode *head; /* 哨兵头节点不存有效数据 */ size_t size; /* 维护长度避免每次都遍历 */ } List; ListStatus list_init(List *l); ListStatus list_push_front(List *l, ElementType v); ListStatus list_remove(List *l, ElementType v); void list_destroy(List *l); /* 释放全部节点调用后 size 归零 */ #endif逻辑说明ElementType单独 typedef 是手册里 ADT 思想最值钱的一条——换元素类型时只改一行。哨兵头节点让插入和删除都不需要特判空链表这是教材里反复强调的“哑节点”技巧在 C 里收益尤其明显因为省掉的是最容易写错的指针分支。参数说明size用size_t而不是int避免长度比较时的隐式符号转换ListStatus用枚举而不是返回布尔值调用方能区分“分配失败”和“目标不存在”排错时少走很多弯路。list_destroy不返回状态因为释放失败在 C 里没有可恢复的语义直接交给 sanitizer 检查更实际。2.3 把 malloc 和 free 收拢到两个函数手册里每次插入都是一句malloc从不检查返回值。工程里更糟的做法是到处if (p NULL) return散落各处的判断没人维护。我习惯把分配和释放收拢成两个带诊断的函数/* mem.c —— 所有节点分配都走这里泄漏和重复释放能一眼看到 */ #include stdio.h #include stdlib.h #include list.h void *xmalloc(size_t n) { void *p malloc(n); if (p NULL) { fprintf(stderr, xmalloc: out of memory (%zu bytes)\n, n); abort(); /* 教学与实验阶段直接终止便于定位 */ } return p; } void xfree(void *p) { if (p NULL) return; /* free(NULL) 合法但显式返回更易读 */ free(p); } ListStatus list_push_front(List *l, ElementType v) { if (l NULL || l-head NULL) return LIST_ERR_INVALID; ListNode *n xmalloc(sizeof *n); /* sizeof *n 比 sizeof(ListNode) 更抗改类型 */ n-value v; n-next l-head-next; /* 哨兵之后插入无需特判空表 */ l-head-next n; l-size; return LIST_OK; } void list_destroy(List *l) { if (l NULL || l-head NULL) return; ListNode *cur l-head-next; while (cur ! NULL) { ListNode *next cur-next; /* 先存后继再释放当前 */ xfree(cur); cur next; } xfree(l-head); l-head NULL; l-size 0; }逻辑说明xmalloc把“分配失败”这件事从每个调用点收拢到一个地方实验阶段直接 abort 是最省时间的选择因为你不需要为内存耗尽设计恢复路径。list_destroy里先保存next再释放cur是链表释放的标准写法顺序写反就是教科书级的 use-after-free。参数说明sizeof *n是防御性写法结构体字段变化时不用同步修改分配语句list_push_front里的l-head NULL判断是防御“未初始化就使用”因为list_init之后head一定非空这条分支正常不会触发一旦触发说明调用方的生命周期管理有问题。2.4 用固定的编译选项把问题提前暴露手册从不提编译选项但这一步决定了你后面调试要花多少时间。我固定用下面这组# Makefile —— 告警当错误sanitizer 常开 CC cc CFLAGS -stdc99 -g -O1 -Wall -Wextra -Werror \ -fsanitizeaddress,undefined -fno-omit-frame-pointer LDFLAGS -fsanitizeaddress,undefined test_list: test_list.o list.o mem.o $(CC) $(LDFLAGS) -o $ $^逻辑说明-O1配合-g是调试期的甜点-O2会把部分变量优化掉导致断点跳跃-O0又会掩盖部分未定义行为。-fsanitizeaddress抓越界和 use-after-free-fsanitizeundefined抓有符号溢出、空指针解引用、错位对齐这两类问题在数据结构代码里出现频率极高。参数说明-Werror把告警升级为错误能强制你处理“未使用变量”“隐式声明”这类问题后者在 C99 里是真正的坑——函数没声明就调用编译器会按 int 返回处理指针被截断成 32 位时只有在特定机器上才崩。-fno-omit-frame-pointer保证 sanitizer 的栈回溯可读。3. 复杂度分析落到实测把 O() 换成计数器和计时器教材里最容易被跳过的是复杂度分析这一章因为它“看起来不产出代码”。但手册里几乎所有解答都在用 O() 描述代价如果你不把抽象阶数和实测数字对上后面选结构就是凭感觉。这一章把 O() 变成可测的量怎么插桩、测什么、怎么判断你测出来的是阶数而不是噪声。3.1 上界、常数因子与“实际更快”的错觉解答手册给出的是渐进上界它丢掉了常数因子和低阶项。这在 n 很大时成立在你实际处理的规模上经常不成立。典型的例子是插入排序和归并排序前者是 O(n²)后者是 O(n log n)但在 n 小于几十、且数据基本有序时插入排序往往跑得更快因为归并排序的分配和拷贝开销摆在那里。手册不会告诉你这个交叉点在哪因为它依赖机器。我的处理方式是把“阶数结论”和“常数结论”分开验证阶数用双对数坐标的斜率验证常数用同一规模下的绝对耗时或操作计数验证。前者回答“这个算法能不能扩展”后者回答“在 n1000 时该选谁”。两个问题答案不一致是常态不是手册错了。3.2 用计数器替代 clock()统计比较与赋值次数clock()的抖动在小规模下能到 30% 以上测出来的曲线没法看。更可靠的办法是插桩计数把“比较次数”和“赋值次数”当成代价的代理指标/* count.h —— 极轻量的插桩release 时改成空宏即可 */ #ifndef COUNT_H #define COUNT_H extern unsigned long g_cmp, g_assign; #define CMP_LT(a, b) (g_cmp, (a) (b)) #define ASG(dst, val) (g_assign, (dst) (val)) #endif/* sort_measure.c —— 插入排序的插桩版本只改比较和赋值两处 */ #include count.h void insertion_sort(int *a, int n) { for (int i 1; i n; i) { int key a[i]; /* 取元素不计入交换代价 */ int j i - 1; while (j 0 CMP_LT(key, a[j])) { /* 每次成功比较都计数 */ ASG(a[j 1], a[j]); /* 每次后移都计数 */ j--; } ASG(a[j 1], key); } }逻辑说明把比较和赋值包装成宏而不是函数是为了让计数器停留在寄存器层面的开销可忽略同时保证-O1下不会被内联后优化掉宏展开后是真实的全局变量自增编译器不会删。用计数器而不是计时器是为了让“插入排序在 n50 时比归并快”这个结论可以复现——同样的输入、同样的代码计数结果永远一致。参数说明g_cmp和g_assign定义为全局变量跑完一个用例前必须清零否则数据会串。CMP_LT只覆盖“小于”这一种比较是因为手册里绝大多数排序的判定条件都能归约成小于如果你要测三路快排需要再加一个相等比较的宏否则计数会偏小。3.3 三个必须钉死的测量参数同样的代码换三个参数就能测出三条完全不同的曲线。这三个参数不控制住后面所有结论都不可信。参数取值建议不控制会怎样数据规模 n从 2^8 起每步乘 2至少 8 个点点太少无法判断斜率拟合出来的阶数随机性极大初始分布随机、升序、降序、全等值四组分别测只测随机数据会掩盖快排在有序输入上的退化重复次数每个规模至少跑 5 次取中位数单次测量受缓存和调度影响小规模下偏差可达数倍第三项容易被忽略。n256 这种规模一次运行只有微秒级进程启动和页错误的开销占比很高。我一般在外层套一个循环把同一组数据重复跑 1000 次再除以次数让单次代价落到毫秒级抖动就被摊薄了。3.4 用双对数斜率判断阶数测完数据之后不要靠眼睛看曲线“像不像抛物线”直接算斜率。若 T(n) 近似为 c·n^k则 log T 与 log n 是直线斜率为 k# slope.py —— 用相邻两点的双对数斜率判断阶数 import math def slopes(points): points: [(n, cost), ...] 按 n 升序 out [] for (n1, t1), (n2, t2) in zip(points, points[1:]): if t1 0 or t2 0: continue out.append(math.log2(t2 / t1) / math.log2(n2 / n1)) return out data [(256, 32_100), (512, 128_400), (1024, 513_800), (2048, 2_055_000)] print([round(s, 2) for s in slopes(data)]) # 期望接近 [2.0, 2.0, 2.0]逻辑说明相邻点斜率比整体拟合更能暴露“小规模常数项主导”的区间。如果前两个斜率明显小于后面的说明这一段还在常数项影响范围内不能用来判断阶数只有当斜率稳定在某个值附近才能说这段数据呈现该阶数的行为。参数说明用 log2 而不是自然对数是因为 n 每次翻倍时斜率正好是 k 本身读起来直观斜率 2.0 就是平方阶。数据里出现 cost 为 0 的点要跳过通常是规模太小、编译器把整个循环优化掉了这类点留着会污染斜率。4. 树与散列的复现AVL 旋转、散列冲突与再散列手册里平衡树和散列这两章篇幅最重也是抄错率最高的地方旋转的四种情形代码长得几乎一样散列的探测序列一旦写错表现是“大部分情况对、偶尔查不到”。这一章把这两块的落地细节钉死。4.1 AVL先写不变量再写旋转AVL 的代码之所以容易写崩是因为大多数人是照着四种情形的图去凑指针。更稳的路径是先声明不变量再让旋转去满足它。不变量只有两条任意节点的左右子树高度差绝对值不超过 1节点存储的 height 字段等于左右子树高度的较大值加 1空树高度定义为 -1。有了这两条四种情形可以归约成两个基本操作单旋转和双旋转双旋转就是两次单旋转的组合。/* avl.c —— 高度定义为 -1 起步叶子节点高度为 0 */ #include stdlib.h #include count.h typedef struct AvlNode { int key; int height; struct AvlNode *left, *right; } AvlNode; static int h(const AvlNode *t) { return t ? t-height : -1; } static int imax(int a, int b) { return a b ? a : b; } /* 左-左情形对 k2 右旋返回新的子树根 */ static AvlNode *rotate_right(AvlNode *k2) { AvlNode *k1 k2-left; k2-left k1-right; k1-right k2; k2-height imax(h(k2-left), h(k2-right)) 1; /* 先更新下层 */ k1-height imax(h(k1-left), h(k1-right)) 1; /* 再更新新根 */ return k1; } /* 右-右情形对 k1 左旋 */ static AvlNode *rotate_left(AvlNode *k1) { AvlNode *k2 k1-right; k1-right k2-left; k2-left k1; k1-height imax(h(k1-left), h(k1-right)) 1; k2-height imax(h(k2-left), h(k2-right)) 1; return k2; } /* 左右情形先对左儿子左旋再整体右旋 */ static AvlNode *double_left_right(AvlNode *k3) { k3-left rotate_left(k3-left); return rotate_right(k3); }逻辑说明旋转里最容易错的是高度更新的顺序。必须先把下沉的那个节点的高度算出来再算新根的高度因为新根的高度依赖下沉节点更新后的值。顺序反了不会立刻崩但高度会偏大 1几轮插入之后平衡判断就失效表现是“树越长越歪”。h()对空指针返回 -1 而不是 0是为了让叶子节点高度为 0 的约定自洽这也是教材里用的约定换成 0 起步会导致所有平衡因子差一位。参数说明key用 int 是为了和前面的计数器配合实际项目里换成ElementType即可。函数返回新的子树根而不是原地修改是因为插入和删除需要把新根挂回父节点用返回值写法能避免维护父指针。插入函数的关键在回溯路径上更新高度并判断平衡因子四个分支的判定条件要写清楚/* 插入后回溯平衡因子 左高 - 右高 */ AvlNode *avl_insert(AvlNode *t, int key) { if (t NULL) { AvlNode *n calloc(1, sizeof *n); /* 新叶子高度为 0calloc 后需修正 */ if (!n) return NULL; n-key key; n-height 0; return n; } if (CMP_LT(key, t-key)) { t-left avl_insert(t-left, key); if (h(t-left) - h(t-right) 2) { if (CMP_LT(key, t-left-key)) t rotate_right(t); else t double_left_right(t); } } else if (key t-key) { t-right avl_insert(t-right, key); if (h(t-right) - h(t-left) 2) { if (key t-right-key) t rotate_left(t); else t double_left_right_sym(t); } } t-height imax(h(t-left), h(t-right)) 1; return t; }逻辑说明判等分支故意留空即重复键不插入这是手册里常见的一种约定如果你的场景需要允许重复改成在相等时往右子树走即可但要注意这样会让删除逻辑复杂一档。平衡因子的判定用 2而不是 2是因为一次插入最多让高度差从 1 变成 2用等号能在断言层面尽早发现逻辑错误。参数说明calloc之后仍显式设height 0是因为调用方可能把节点高度约定改成 1 起步显式赋值比依赖零初始化安全。递归实现的好处是回溯路径天然存在坏处是深树会爆栈生产代码里一般改成带父指针的迭代版本但学习阶段先用递归把不变量吃透。4.2 散列装填因子和再散列阈值必须成对出现手册讲开放定址散列时给的是平方探测并提到“表不能太满”。这句话在落地时会被简化成“装填因子别超过 0.5”然后就没了。真正的坑是装填因子阈值和再散列逻辑必须写在一起只设阈值不写再散列插入在表满时会死循环。平方探测的探测序列是 i² 而不是 i写错这一处查找就会在“应该有元素”的位置停下并返回未找到。判定条件要写成i table_size / 2因为平方探测在表大小为质数且装填因子低于 0.5 时前一半探测序列就能覆盖到空位。/* hash.c —— 开放定址平方探测装填因子超过 0.5 触发再散列 */ #define EMPTY_KEY (-2147483647) /* 用哨兵标记空槽比额外数组省空间 */ typedef struct { int *keys; size_t capacity; /* 容量取质数且尽量是 4k3 形 */ size_t size; /* 已用槽数不包含墓碑 */ } HashTable; static size_t next_prime(size_t n) { while (1) { int ok 1; for (size_t d 2; d * d n; d) if (n % d 0) { ok 0; break; } if (ok) return n; n; } } static void rehash(HashTable *h); void hash_insert(HashTable *h, int key) { if ((double)(h-size 1) / h-capacity 0.5) rehash(h); size_t i (size_t)key % h-capacity; for (size_t k 0; k h-capacity / 2; k) { size_t pos (i k * k) % h-capacity; /* 平方探测注意取模 */ if (h-keys[pos] EMPTY_KEY) { h-keys[pos] key; h-size; return; } if (h-keys[pos] key) return; /* 重复键直接忽略 */ } }逻辑说明rehash放在插入之前判断而不是插入失败之后补救这样保证任何一次插入都有至少一半的空槽可用。k * k用size_t计算避免 n 很大时int溢出成负数导致取模结果为负这个坑在容量超过 46341 时会真实触发。参数说明容量选 4k3 形的质数是因为平方探测在质数容量下探测序列覆盖更均匀这是数论结论手册里通常只在注释里提一句。EMPTY_KEY用INT_MIN是因为题目数据都是正整数实际项目里应该用单独的occupied位图或者允许存储任意 int 时改用线性探测加墓碑标记。4.3 同一组键分别跑 AVL 与散列该看什么跑对比实验时很多人只比较“插入耗时”这个维度信息量很低散列插入是均摊常数AVL 是对数结论在写代码之前就知道了。真正值得看的三个量是观察量AVL开放定址散列说明最坏一次查找的比较次数约 1.44 log₂n探测次数不定接近满载时急剧上升最坏情况才是工程里要防的有序遍历中序 O(n) 直接输出需要额外排序散列牺牲了顺序性内存占用每节点 3 个字段加对齐槽数与装填因子成反比装填因子 0.5 意味着近 2 倍冗余第三行最容易被忽略。手册里说散列查找是常数没提这个常数是用空间换来的。装填因子压到 0.5意味着容量要是元素数的两倍压到 0.25 会让探测更快但内存直接翻四倍。如果你的场景是百万级元素、内存受限把装填因子调到 0.7 以上、同时改用线性探测提升缓存命中率实测往往比教科书参数更快代价是最坏情况的探测次数变长。这类结论靠读手册得不出来只能自己压测。5. 照抄解答最容易翻车的五个现场现象、原因与解法这一章集中处理复现过程中真实会卡住人的几类问题。写代码的时间可能只占三成剩下七成都在和这些东西耗。每条按现象、原因、解决三段说。5.1 段错误与内存类现场现象链表删除操作单独测都能过连续删 1000 个节点后在list_destroy里随机崩栈回溯指向free看不出哪一行。原因删除节点时先free(cur)再取cur-next。单次删除时free之后那块内存通常还没被复写next恰好还能读到所以看不出来连续删除时分配器把释放的块挂回空闲链表会在块头写入指针cur-next读到的就是空闲链表指针再去解释成节点就是野指针。解决读后继和释放严格分两步用临时变量保存next。这条规则在双向链表、树的删除、散列表的桶链上同样适用。另外把 sanitizer 常开这类问题会在第一次越界时就被 ASan 拦下来不用等到崩溃现场。现象程序跑完不崩但valgrind或 ASan 报告 still reachable罪魁祸首是一个只在插入路径上分配的临时数组。原因手册里的算法描述常常只讲主体回收写在别处或者干脆省略。只按主体实现临时缓冲区就漏了。解决把“谁分配谁释放”写进函数注释并在每个返回路径上核对。我一般的习惯是让malloc和free在同一屏代码内出现跨越一屏的分配就要重新审视结构划分。5.2 结果对不上类现场现象快速排序在随机数据上结果正确在“大量重复元素”的测试集上跑出非递增序列或者直接崩。原因朴素的快排按“小于 pivot 放左、大于等于放右”划分遇到全等值数据时每次划分都退化成 1 和 n-1递归深度变成 n。崩是栈溢出结果错是划分边界写错时把 pivot 覆盖了。解决划分改用双指针相向扫描遇到相等元素就交换让相等值均匀分布到两侧或者直接实现三路划分把区间分成小于、等于、大于三段。同时给递归加深度上限超过 2 log n 时切换到堆排序这也是很多标准库的兜底策略。手册里通常只给基础版本边界处理要自己补。现象图的最短路径算法在小图上正确在带负权边的图上给出错误答案但没有报错。原因Dijkstra 的贪心前提是非负权边手册在这一节通常会写一句前提条件很容易被当成废话跳过。解决加断言或者前置检查发现负权边就切换到能处理负权的算法如果只是用来验证理解至少在测试用例里显式包含一条负权边看它怎么错比只在正确输入上跑通更有价值。5.3 复杂度与性能类现场现象归并排序实现完n 取到十万时反而比插入排序慢怀疑自己写错了。原因归并的每次递归都在malloc一个临时数组分配开销盖过了 O(n log n) 的优势。手册里的伪代码默认“合并用的辅助空间已就绪”没说明这个空间要在递归外一次性分配。解决在排序入口分配一块与输入等长的缓冲区递归过程中只做拷贝不在递归内分配。这一处改动在十万级数据上通常能带来数倍差距。顺带把边界条件改成 n 小于阈值时直接插入排序交叉点实测在 16 到 32 之间。现象同一份代码在自己机器上跑 3 秒换台机器跑 12 秒开始怀疑算法选错。原因大规模顺序访问的结构数组、堆对缓存敏感同样阶数的算法在缓存命中率上能差一个数量级。编译优化等级和 CPU 频率策略也会带来数倍差异。解决跨机比较时只比操作计数不比墙上时间。要比时间就在同一台机器上、同一编译选项下、用中位数比较。这条在选结构时尤其重要手册里两个 O(n log n) 的排序实际差三倍是正常现象别急着推翻阶数结论。6. 用差分测试把解答手册变成回归用例库手册里每道题的答案本质上都是一个“预期输出”。把这层关系利用起来就能把整本手册变成回归测试集一边写自己的实现一边写一个慢但绝对正确的暴力版本用随机数据喂给两边对拍。这个做法在树、散列、图算法上都能用尤其是最大子序列和、最短路、拓扑排序这类“结论唯一、实现容易错”的题目。最典型的是最大子序列和。手册给了四种解法复杂度从立方到线性彼此可以互相验证/* difftest.c —— 暴力 O(n^3) 作为基准与分治 O(n log n) 对拍 */ #include stdio.h #include stdlib.h #include time.h #include assert.h static int brute(const int *a, int n) { int best 0; /* 空子序列和为 0与教材约定一致 */ for (int i 0; i n; i) for (int j i; j n; j) { int sum 0; for (int k i; k j; k) sum a[k]; if (sum best) best sum; } return best; } static int max3(int x, int y, int z) { int m x y ? x : y; return m z ? m : z; } static int dc(const int *a, int lo, int hi) { /* 区间 [lo, hi) */ if (lo hi) return 0; if (hi - lo 1) return a[lo] 0 ? a[lo] : 0; int mid lo (hi - lo) / 2; int left dc(a, lo, mid), right dc(a, mid, hi); int ls 0, lb 0; /* 左半含边界最大值 */ for (int i mid - 1; i lo; i--) { ls a[i]; if (ls lb) lb ls; } int rs 0, rb 0; for (int i mid; i hi; i) { rs a[i]; if (rs rb) rb rs; } return max3(left, right, lb rb); } int main(void) { srand(20240501); for (int round 0; round 20000; round) { int n 1 rand() % 40; /* 小规模多轮覆盖更多形态 */ int a[40]; for (int i 0; i n; i) a[i] rand() % 21 - 10; /* 含负数才有意义 */ assert(brute(a, n) dc(a, 0, n)); } puts(diff ok); return 0; }逻辑说明暴力版复杂度立方但逻辑直白几乎不可能写错适合当基准。分治版是全篇最容易在跨中点的循环边界上翻车的地方左半从mid-1向前累加右半从mid向后累加两边的初始值都是 0这样全负数组返回 0 与暴力版一致。断言放在循环里而不是循环外是为了在第一次不一致时就能定位到具体的随机种子和输入。参数说明rand() % 21 - 10保证正负混合全正数会让所有版本都返回总和测不出边界错误。规模限制在 40 以内是因为立方复杂度在 40 上单轮只有 6 万次运算两万轮也才 12 亿次几秒内跑完。如果被测算法是百万级才暴露问题的类型就换方法固定种子、放大规模、减少轮数但始终保留至少一个“小规模多种子”的阶段。测试维度建议取值能抓出的问题元素取值全正、全负、正负混合、全零初始值约定错误、边界返回错误规模1、2、3、2 的幂、质数附近单元素特判遗漏、递归拆分不均匀排列形态随机构造、已升序、已降序、全等值退化路径、划分不均、无限递归重复运行固定随机种子让失败可以精确复现我踩过最深的一个坑是把对拍脚本的随机种子设成time(NULL)。某次跑了两小时才发现一个不一致回头重跑再也复现不出来因为种子已经变了。从那以后所有随机测试都固定种子把种子号写进日志失败时直接改一行重放。另外就是别急着上大规模——小规模多轮次几乎总能更快地暴露逻辑错误而大规模只能告诉你性能问题。手册里的答案只是起点真正吃透一个结构靠的是你自己的对拍脚本跑绿的那一刻。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表