☰
C语言算法源码包实战:从快速排序到KMP的工程实现与避坑指南
2026/10/31 7:14:29 网站建设 项目流程

简介:这份资源是《程序员实用算法》一书的配套源码包,面向有一定编程基础、希望把经典算法真正落到代码上的开发者与计算机专业学生,用于解决算法原理看得懂、动手实现却无从下手的问题。包内共116个文件,以68个C源文件和18个头文件为主体,覆盖链表、散列、查找、排序、树、日期时间、任意精度算术、数据压缩与校验等章节的实现代码,另附txt说明、bat批处理与mak构建脚本,便于在不同编译环境下组织编译,压缩包约163KB。已有413人学习下载,说明其在算法实践群体中具备一定参考价值。读者可对照书中目录逐章查阅对应实现,理解Boyer-Moore查找、红黑树、LZW压缩、CRC校验等算法的具体编码方式,并借助构建脚本快速跑通示例,适合作为算法课程实验与日常开发的案头参考。

1. 拿到一份算法源码包,先别急着编译

很多算法资料的通病是只给伪代码,读者看完觉得懂了,一合上就写不出来。这份「程序员实用算法」源码包反着来——它把排序、查找、字符串处理、图论、动态规划这些日常真正用得上的算法,全部落成了可编译、可断点、可改参数的工程代码。适合两类人:一类是刷题刷到麻木、想看看工业级实现长什么样的新手;另一类是接手老项目、需要把某个算法模块抠出来复用的熟手。它不教你算法思想从哪来,它解决的是「我知道原理,但写不出能跑的版本」这个具体问题。下面按「能干什么、怎么跑、怎么改」的顺序拆。

2. 工程结构与技术选型:为什么是这套目录

2.1 目录分层与模块职责

拿到源码包,第一件事是看目录,而不是找 main。这类算法集合项目,常见做法是按算法族分目录,每个目录自成一个可独立编译的小单元,避免一个巨型 Makefile 把所有东西绑死。典型结构大致是这样:

algorithms/ ├── sort/ # 排序族:插入、归并、快排、堆排 │ ├── sort.h # 统一接口声明 │ ├── quick.c # 快排实现 │ └── test_sort.c # 该模块的独立测试入口 ├── search/ # 查找族:二分、哈希、二叉搜索树 ├── string/ # 字符串:KMP、编辑距离、Trie ├── graph/ # 图论:BFS/DFS、最短路、最小生成树 ├── dp/ # 动态规划:背包、LCS、区间 DP ├── common/ # 公共设施:内存分配封装、计时、断言 └── Makefile # 顶层构建,按目录递归

这种分层的价值在于「可裁剪」。你只想用 KMP,就只把string/和common/拷进自己项目,不用拖着一堆无关代码。common/单独抽出来是关键设计——算法代码里最怕到处malloc不检查返回值,把内存封装、计时、断言集中到一处,后面改起来只动一个文件。

2.2 语言与构建选择

这类源码包绝大多数是 C 语言实现,少数配 C++ 封装。选 C 的理由很实际:算法逻辑和数据结构的边界最清晰,指针操作能让你看清每个元素到底在哪,不会被语言特性遮住。构建用 Makefile 而非 CMake,是因为模块少、依赖浅,一个递归 Makefile 足够,读者不用先装一套构建系统。

顶层 Makefile 的常见写法是遍历子目录:

# 顶层 Makefile:递归进入每个算法目录编译 SUBDIRS = sort search string graph dp all: for dir in $(SUBDIRS); do \ $(MAKE) -C $$dir || exit 1; \ done clean: for dir in $(SUBDIRS); do \ $(MAKE) -C $$dir clean; \ done

-C让 make 切换到子目录执行,|| exit 1保证任一模块编译失败就整体停下,不会出现「一半成功一半失败还继续跑」的假象。子目录里的 Makefile 各自维护自己的源文件列表和测试目标,互不干扰。

2.3 统一接口约定

跨模块复用最怕接口风格不一致。合格的做法是每个算法族提供一个头文件,把对外函数签名固定下来,实现文件只依赖这个头。以排序为例:

/* sort.h:排序模块统一接口 */ #ifndef SORT_H #define SORT_H #include <stddef.h> /* 比较函数:返回 <0 表示 a 应排在 b 前 */ typedef int (*cmp_fn)(const void *a, const void *b); /* 通用快排:对 base 起始的 n 个元素排序,每个元素 size 字节 */ void quick_sort(void *base, size_t n, size_t size, cmp_fn cmp); /* 归并排序,接口同上,便于替换对比 */ void merge_sort(void *base, size_t n, size_t size, cmp_fn cmp); #endif

cmp_fn这个函数指针是整个设计的枢纽:算法本身不关心元素是 int 还是结构体,只通过回调比较。size参数让算法能按字节偏移移动元素,从而支持任意类型。这种「泛型 + 回调」的写法是 C 里做通用算法的标准套路,后面所有模块都沿用同一套约定,你换算法时调用方代码几乎不用改。

3. 核心算法模块拆解:从快排到 KMP 的实现细节

3.1 快速排序:分区逻辑与递归边界

快排是这份源码里最值得逐行读的模块,因为它把「边界处理」这个最容易翻车的地方暴露得很清楚。核心是分区函数:

/* 分区:以末尾元素为基准,小于基准的放左边,返回基准最终位置 */ static size_t partition(void *base, size_t n, size_t size, cmp_fn cmp) { char *arr = (char *)base; char *pivot = arr + (n - 1) * size; /* 基准取最后一个元素 */ size_t i = 0; /* i 指向「小于区」的下一个空位 */ for (size_t j = 0; j < n - 1; j++) { char *cur = arr + j * size; if (cmp(cur, pivot) < 0) { if (i != j) swap(cur, arr + i * size, size); i++; } } swap(arr + i * size, pivot, size); /* 基准归位 */ return i; }

逐段看:char *强转是为了按字节做指针运算,因为元素大小未知,不能用int *自增。pivot指向末尾元素,循环只走到n-1,避免把基准自己和自己比较。i维护的是「已确认小于基准」区域的右边界,每次遇到更小的元素就和i位置交换并右移。循环结束后把基准换到i位置,此时左边全小、右边全大。

递归部分要注意边界:

void quick_sort(void *base, size_t n, size_t size, cmp_fn cmp) { if (n < 2) return; /* 0 或 1 个元素直接返回 */ size_t p = partition(base, n, size, cmp); quick_sort(base, p, size, cmp); /* 左半区,长度 p */ quick_sort((char *)base + (p + 1) * size, n - p - 1, size, cmp); /* 右半区,跳过基准 */ }

n < 2是递归出口,少了这行就会在单元素时无限递归。右半区起点是(p+1)*size,长度是n-p-1,这两个表达式错一个就会越界或漏排。我见过最常见的翻车就是把右半区长度写成n-p,结果把基准又排进去,小数据看不出,大数据直接栈溢出。

3.2 KMP:next 数组的构建与匹配

字符串模块里 KMP 是重头戏,它的价值在于把匹配复杂度从 O(nm) 压到 O(n+m)。先看 next 数组构建:

/* 构建 next 数组:next[i] 表示 pattern[0..i] 的最长相等前后缀长度 */ static void build_next(const char *pat, int m, int *next) { next[0] = 0; int len = 0; /* 当前最长相等前后缀长度 */ int i = 1; while (i < m) { if (pat[i] == pat[len]) { len++; next[i] = len; i++; } else if (len > 0) { len = next[len - 1]; /* 回退到次长前后缀 */ } else { next[i] = 0; i++; } } }

len记录的是「当前已匹配的前后缀长度」。当pat[i]和pat[len]相等,前后缀延长,len加一。不等时不能直接归零,而是回退到next[len-1]——这是 KMP 的精髓,利用已知信息跳过不可能的位置。len == 0时才真正归零并前进。

匹配阶段复用同一套回退逻辑:

/* 在 text 中查找 pat,返回首次出现的下标,未找到返回 -1 */ int kmp_search(const char *text, const char *pat) { int n = strlen(text), m = strlen(pat); if (m == 0) return 0; int *next = malloc(m * sizeof(int)); build_next(pat, m, next); int i = 0, j = 0; /* i 走 text,j 走 pat */ while (i < n) { if (text[i] == pat[j]) { i++; j++; } if (j == m) { free(next); return i - j; } /* 匹配完成 */ else if (i < n && text[i] != pat[j]) { if (j > 0) j = next[j - 1]; /* 失配,按 next 回退 */ else i++; /* j 已为 0,text 前进 */ } } free(next); return -1; }

i只前进不回退,这是 KMP 线性的保证。失配时j按next[j-1]回退,而不是把i拉回去重来。注意j == m的判断要放在失配判断之前,否则匹配成功那一刻会误判。next数组用完必须free,这类小函数最容易漏释放。

3.3 图模块:邻接表与 BFS 骨架

图论模块通常用邻接表存储,因为实际场景里稀疏图远多于稠密图。核心结构:

/* 邻接表节点 */ typedef struct Edge { int to; /* 目标顶点 */ int weight; /* 边权,无权图可忽略 */ struct Edge *next; /* 下一条边 */ } Edge; typedef struct { Edge **adj; /* adj[i] 是顶点 i 的边链表头 */ int n; /* 顶点数 */ } Graph;

adj是二级指针,每个顶点挂一条链表。BFS 骨架:

/* 从 src 出发做 BFS,dist 输出最短跳数,-1 表示不可达 */ void bfs(Graph *g, int src, int *dist) { for (int i = 0; i < g->n; i++) dist[i] = -1; int *queue = malloc(g->n * sizeof(int)); int head = 0, tail = 0; dist[src] = 0; queue[tail++] = src; /* 入队 */ while (head < tail) { int u = queue[head++]; /* 出队 */ for (Edge *e = g->adj[u]; e; e = e->next) { if (dist[e->to] == -1) { /* 未访问 */ dist[e->to] = dist[u] + 1; queue[tail++] = e->to; } } } free(queue); }

用数组模拟队列,head和tail两个下标,避免链表队列的额外开销。dist == -1同时承担「未访问」和「不可达」两个语义,初始化时全设 -1,访问过才赋值。这个技巧在无权图最短路里很常用,省一个 visited 数组。

4. 编译运行与测试:把第一个模块跑起来

4.1 环境准备与编译命令

这类 C 源码包对环境要求很低,Linux 或 macOS 自带 gcc/clang 即可,Windows 用 MinGW 或 WSL。进入根目录直接:

# 编译全部模块 make # 只编译排序模块 make -C sort # 清理产物 make clean

如果只想跑单个测试,进对应目录看 Makefile 里的目标名,通常是make test或直接./test_sort。编译报错先看是不是缺common/里的头文件路径,子目录 Makefile 一般用-I../common指定。

4.2 测试入口与断言

每个模块的test_*.c是独立可执行文件,里面用断言校验结果:

/* test_sort.c:排序模块自测 */ #include <assert.h> #include <string.h> #include "sort.h" static int int_cmp(const void *a, const void *b) { return *(const int *)a - *(const int *)b; } int main(void) { int arr[] = {5, 2, 9, 1, 5, 6}; int expect[] = {1, 2, 5, 5, 6, 9}; size_t n = sizeof(arr) / sizeof(arr[0]); quick_sort(arr, n, sizeof(int), int_cmp); assert(memcmp(arr, expect, sizeof(arr)) == 0); /* 逐字节比对 */ merge_sort(arr, n, sizeof(int), int_cmp); assert(memcmp(arr, expect, sizeof(arr)) == 0); return 0; }

int_cmp用减法返回差值,注意大数相减可能溢出,生产代码里应改成比较后返回 -1/0/1。memcmp逐字节比对要求两个数组内存布局完全一致,对 int 数组成立。断言失败会直接 abort 并打印行号,比打印一堆日志再肉眼比对高效得多。

4.3 用计时器验证复杂度

光跑通不够,还得验证复杂度对不对。common/里通常有个计时封装:

/* 计时:返回毫秒级耗时 */ #include <time.h> double now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6; }

拿它包住排序调用,分别跑 1 万、10 万、100 万元素,看耗时是否近似按 n log n 增长。如果 10 万到 100 万耗时涨了 20 倍以上,说明实现退化成了 O(n²),多半是分区选点有问题或递归没收敛。这种实测比看代码猜靠谱。

5. 避坑与常见问题排查

5.1 编译通过但运行段错误

现象:make全绿,一跑测试就 Segmentation fault。原因八成是指针运算的字节偏移算错,比如arr + i而不是arr + i * size,元素大小不是 1 时就会踩到相邻内存。解决:把所有char *上的偏移都检查一遍,确认乘了size;用gcc -fsanitize=address重编译,ASan 会直接指出越界的那一行。

5.2 排序结果部分有序

现象:小数组对,大数组错几个位置。原因通常是分区边界写错,右半区长度多算或少算一个,导致基准元素被重复处理或漏掉。解决:在partition返回后打印p和两段长度,确认p + (n-p-1) + 1 == n恒成立;用随机数组跑一千次对比标准库qsort结果。

5.3 KMP 匹配位置偏移一位

现象:能找到但返回下标差 1。原因多是j == m的判断时机不对,或者返回i - j时i已经多走了一步。解决:把匹配成功判断紧跟在i++; j++;之后,返回前确认i - j是模式串起点;用「模式串在文本开头」和「在结尾」两个边界用例各测一次。

5.4 内存泄漏累积

现象:长时间跑或循环调用后内存持续上涨。原因:build_next、BFS 队列这类临时分配没在所有返回路径上释放,尤其是提前 return 的分支。解决:统一「谁分配谁释放」,把free放在函数唯一出口前;用valgrind --leak-check=full跑一遍测试,它会列出每处未释放的分配点。

5.5 递归深度过大导致栈溢出

现象:百万元素快排直接崩。原因:基准选末尾元素,遇到已有序数组时每次分区极不均衡,递归深度退化成 n。解决:基准改成三数取中(首、中、尾取中位数),或对小分区改用插入排序、对大分区继续递归,把递归深度压到 log n 量级。

6. 二次开发:把算法模块抠进自己项目

真正用起来,很少是整包编译,而是「抠一个模块走」。我的习惯是先看目标模块依赖了common/里哪些东西,只把那几个文件一起拷走,避免拖家带口。以把 KMP 接进一个日志过滤工具为例,改造点集中在三处:接口适配、内存策略、错误处理。

接口适配方面,原实现用int下标,如果你的文本超过 2GB 就得换成size_t,改动虽小但要全链路一致:

/* 适配版:下标改为 size_t,支持大文本 */ size_t kmp_search_big(const char *text, size_t n, const char *pat, size_t m) { if (m == 0) return 0; size_t *next = malloc(m * sizeof(size_t)); if (!next) return (size_t)-1; /* 分配失败显式返回 */ build_next_big(pat, m, next); /* ... 匹配逻辑同前,i/j 均为 size_t ... */ free(next); return found ? pos : (size_t)-1; }

内存策略上,如果调用频繁,别每次mallocnext 数组,改成调用方传入缓冲区,把分配开销挪到循环外。错误处理上,原实现失败返回 -1,接进大项目时最好定义独立错误码,和业务返回值区分开,否则 -1 到底是「没找到」还是「出错」说不清。

验证方法很简单:拿一份真实日志,用改造版和系统strstr各跑一遍,比对命中位置和耗时;再构造「模式串比文本长」「空模式串」「全相同字符」三个边界用例。我早年图省事跳过边界测试,结果上线后遇到空模式串直接死循环,从那以后每次抠算法模块出来,都强制把边界用例先写满再谈集成。希望这份拆解能帮你少走那段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询