19、算法与数据结构的现代化:从手写链表到标准库、引入uthash/utlist、算法复杂度优化
说实话,我见过太多C语言项目里,还在用二十年前那套手写链表、手搓哈希表的做法。不是说手写不好,而是很多时候,我们明明有更好的选择。今天我们就聊聊,怎么让C语言里的算法和数据结构,跟上现代开发的节奏。
19.1 手写链表:情怀与现实的博弈
我刚入行那会儿,写链表是基本功。面试必考,项目必用。每个节点都得自己malloc,每个指针都得自己维护。你想想看,一个简单的双向链表,增删改查、遍历、排序,少说也得几百行代码。
// 传统手写链表节点
struct list_node {
int data;
struct list_node *prev;
struct list_node *next;
};
// 插入操作
void list_insert(struct list_node **head, int data) {
struct list_node *node = malloc(sizeof(*node));
node->data = data;
node->prev = NULL;
node->next = *head;
if (*head) (*head)->prev = node;
*head = node;
}
这段代码看着简单,对吧?但我在项目中遇到过一个问题:一个同事写的链表,插入时忘了更新尾指针,结果遍历时直接死循环。排查了整整一个下午。嗯,这种坑,手写链表里比比皆是。
19.2 标准库的救赎:<search.h> 与 <stdlib.h>
C语言标准库其实提供了不少好东西,只是很多人不知道。比如 qsort() 和 bsearch(),这两个函数就能解决大部分排序和查找问题。
#include <stdlib.h>
#include <search.h>
int compare(const void *a, const void *b) {
return *(int*)a - *(int*)b;
}
int main() {
int arr[] = {5, 3, 8, 1, 9, 2};
size_t n = sizeof(arr) / sizeof(arr[0]);
// 排序
qsort(arr, n, sizeof(int), compare);
// 二分查找
int key = 8;
int *found = bsearch(&key, arr, n, sizeof(int), compare);
if (found) {
printf("找到了: %d\n", *found);
}
return 0;
}
我个人习惯是,只要项目里需要排序或查找,优先用标准库。为什么?因为标准库的实现经过了几十年的优化,比你手写的快得多。而且,你写的代码越少,bug就越少。
19.3 引入uthash:哈希表从未如此简单
哈希表是C语言里的老大难问题。手写一个?你得考虑哈希函数、冲突解决、动态扩容……我见过一个项目,哈希表代码占了整整800行,结果性能还不如人意。
后来我发现了uthash这个头文件库。它只有一个头文件,包含就能用。你不需要自己管理内存,不需要操心扩容,甚至不需要写哈希函数。
#include "uthash.h"
struct my_hash {
int id; // key
char name[32];
UT_hash_handle hh; // 哈希句柄
};
struct my_hash *users = NULL;
void add_user(int id, const char *name) {
struct my_hash *s = malloc(sizeof(*s));
s->id = id;
strcpy(s->name, name);
HASH_ADD_INT(users, id, s);
}
struct my_hash *find_user(int id) {
struct my_hash *s;
HASH_FIND_INT(users, &id, s);
return s;
}
你看,就这么几行,一个完整的哈希表就搞定了。我在项目中用uthash重构过一个配置管理系统,原来手写的哈希表有300多行,换成uthash后只剩50行,性能还提升了20%。
HASH_ADD_STR 和 HASH_FIND_STR,它们会自动处理字符串比较。
19.4 utlist:链表操作的终极简化
uthash的作者还写了utlist,同样是头文件库,专门处理链表。它支持单向链表、双向链表、循环链表,所有操作都是宏,性能极高。
#include "utlist.h"
struct list_node {
int data;
struct list_node *prev;
struct list_node *next;
};
struct list_node *head = NULL;
void demo() {
struct list_node *node, *tmp;
// 插入
node = malloc(sizeof(*node));
node->data = 42;
DL_APPEND(head, node); // 双向链表尾部追加
node = malloc(sizeof(*node));
node->data = 13;
DL_PREPEND(head, node); // 头部插入
// 遍历
DL_FOREACH(head, node) {
printf("%d\n", node->data);
}
// 删除
DL_FOREACH_SAFE(head, node, tmp) {
if (node->data == 13) {
DL_DELETE(head, node);
free(node);
}
}
}
你想想看,以前手写链表,光是遍历时安全删除就得写好几行。现在一个 DL_FOREACH_SAFE 就搞定了。我个人建议,新项目里直接用utlist,别自己造轮子了。
19.5 算法复杂度优化:从O(n²)到O(n log n)
聊完了数据结构,我们说说算法复杂度。我见过太多C语言项目,明明可以用更好的算法,却因为「习惯」而用了低效的实现。
| 场景 | 低效做法 | 复杂度 | 优化做法 | 复杂度 |
|---|---|---|---|---|
| 查找 | 线性查找 | O(n) | 二分查找(需排序) | O(log n) |
| 排序 | 冒泡排序 | O(n²) | 快速排序(qsort) | O(n log n) |
| 去重 | 双重循环 | O(n²) | 哈希表(uthash) | O(n) |
| 缓存 | 每次都计算 | O(n) | 记忆化(查表) | O(1) |
为什么会这样?很多时候是因为开发者没有意识到性能瓶颈在哪里。我记得有一次优化一个网络协议解析模块,原来用线性链表存储会话,每次查找都是O(n)。换成uthash后,查找变成O(1),整个模块的吞吐量提升了3倍。
19.6 知识体系总览
下面这张图,是我对本章知识体系的总结。你可以把它当作一个决策树,帮助你在实际项目中快速选择合适的数据结构和算法。
这张图的核心逻辑很简单:先判断你需要什么数据结构,再根据场景选择合适的工具。能用标准库就用标准库,能用uthash/utlist就用它们,实在不行再手写。这样既能保证性能,又能降低维护成本。
好了,这一章的内容就到这里。记住,现代化的C语言开发,不是让你抛弃底层能力,而是让你在合适的时候使用合适的工具。手写链表是基本功,但用utlist是智慧。
公众号:蓝海资料掘金营,微信deep3321