Problem Set 5 概述
本周作业围绕数据结构主题展开,需要运用 Week 5 学习的链表、哈希表等知识解决实际问题。
| 题目 | 难度 | 核心知识点 |
|---|---|---|
| Inheritance | ⭐⭐ | 递归、动态内存分配、树结构 |
| Speller | ⭐⭐⭐⭐ | 哈希表、链表、文件操作、性能优化 |
作业页面:
1. Inheritance(遗传模拟)
题目背景
血型遗传问题
这道题涉及到的核心知识点是指针(Pointers)、动态内存分配(malloc/free)、递归(Recursion) 。
想象下我们小时候学习的family tree,没错,这里我们就是要构建树这种结构。
一、核心概念分析
1. 数据结构:倒置的树
在这题中,一个人(person)有一对父母。
-
通常的树是根(root)在顶端,分叉在下面,通过指针由根节点指向子节点
-
family tree 中,孩子在最下层做为根节点,指向父母,父母再指向自己的父母。。。
祖父母 / \ 父亲 母亲 \ / 孩子三代人的遗传关系:
- 孩子的等位基因来自父母
- 父母的等位基因来自祖父母
-
每个节点 (
person)包含:
person[2]:指向爸爸和妈妈的指针alleles[2]:两个血型基因字符(比如 'A' 和 'B')。
2. 递归构建(Recursion)
- Create:要创建一个person,必须先创建其父母,要创建"父母",必须先创建"祖父母",这是一个典型的递归过程。(涉及到树结构,基本上就是使用递归)。
- Basic Case(终止条件) :当我们达到
generations(祖父母) 代,他们没有父母,模拟时指针设置为NULL,基因也是随机生成的。
3. 孟德尔遗传定律
- 每个人的两个基因 (alleles),一个来自父亲,一个来自母亲。
- 我们要随机决定从父亲的两个基因里选哪一个,再从母亲的两个基因里选哪一个。
逐步解决
我们的任务是完成 create_family 和 free_family 中的 TODO。
1. create_family(构建 family tree)
思考:
- 不管这个人是谁,都要为其在内存上开辟空间,使用
malloc动态分配内存。 - 判断代数
- 如果有上一代(generations > 1)
- 调用
create_family把其父母创建出来,并把指针存储到当前parents数组中 - 决定基因:根据刚刚创建出来的父母的基因,随机决定孩子的基因。
- 调用
- 如果是祖宗(即不再有父母)
- 父母指针设为
NULL - 不再有父母,没有上一代数据, 调用
random_allele()基因随机生成。
- 父母指针设为
-
把创建好的这个人返回给上一层。
实现如下:person *create_family(int generations)
{
// TODO: Allocate memory for new person
person *p = malloc(sizeof(person));
// If there are still generations left to create
if (generations > 1)
{
// Create two new parents for current person by recursively calling create_family
person *parent0 = create_family(generations - 1);
person *parent1 = create_family(generations - 1);// TODO: Set parent pointers for current person p->parents[0] = parent0; p->parents[1] = parent1; // TODO: Randomly assign current person's alleles based on the alleles of their parents p->alleles[0] = parent0->alleles[random() % 2]; p->alleles[1] = parent1->alleles[random() % 2]; } // If there are no generations left to create else { // TODO: Set parent pointers to NULL p->parents[0] = NULL; p->parents[1] = NULL; // TODO: Randomly assign alleles p->alleles[0] = random_allele(); p->alleles[1] = random_allele(); } // TODO: Return newly created person return p; // return NULL;}
2. free_family()(释放内存)
原则:你必须先释放"引用对象",才能释放"引用者"
在递归中,通常是后序遍历 (Post-order Traversal):先释放每一个人的父母(子树),最后释放这个人自己(根)。如果你先释放了自己,你就找不到父母的指针了,那些内存就泄漏了。
-
Base Case:如果指针是 NULL,说明没东西可释放,直接返回。
-
递归释放父母:先去把 parents[0] 和 parents[1] 这两条线上的所有人清理干净。
-
释放自己:最后 free(p)。
代码实现:void free_family(person *p)
{
// TODO: Handle base case
if (p == NULL)
{
return;
}// TODO: Free parents recursively free_family(p->parents[0]); free_family(p->parents[1]); // TODO: Free child free(p);}
2. Speller
这题文件有点多
speller/ $ tree -L 2
.
├── dictionaries
│ ├── large
│ └── small
├── dictionary.c
├── dictionary.h
├── keys
│ ├── aca.txt
│ ├── austen.txt
│ ├── birdman.txt
│ ├── burnett.txt
│ ├── carroll.txt
│ ├── cat.txt
│ ├── constitution.txt
│ ├── federalist.txt
│ ├── frankenstein.txt
│ ├── grimm.txt
│ ├── her.txt
│ ├── holmes.txt
│ ├── homer.txt
│ ├── lalaland.txt
│ ├── mansfield.txt
│ ├── pneumonoultramicroscopicsilicovolcanoconiosis.txt
│ ├── revenant.txt
│ ├── rinehart.txt
│ ├── shakespeare.txt
│ ├── stein.txt
│ ├── stoker.txt
│ ├── surgery.txt
│ ├── tolstoy.txt
│ ├── wells.txt
│ ├── whittier.txt
│ ├── wordsworth.txt
│ ├── xueqin1.txt
│ └── xueqin2.txt
├── Makefile
├── speller50
├── speller.c
└── texts
├── aca.txt
├── austen.txt
├── birdman.txt
├── burnett.txt
├── carroll.txt
├── cat.txt
├── constitution.txt
├── federalist.txt
├── frankenstein.txt
├── grimm.txt
├── her.txt
├── holmes.txt
├── homer.txt
├── lalaland.txt
├── mansfield.txt
├── pneumonoultramicroscopicsilicovolcanoconiosis.txt
├── revenant.txt
├── rinehart.txt
├── shakespeare.txt
├── stein.txt
├── stoker.txt
├── surgery.txt
├── tolstoy.txt
├── wells.txt
├── whittier.txt
├── wordsworth.txt
├── xueqin1.txt
└── xueqin2.txt
一、问题背景
我们要解决什么问题 :写一个拼写检查器 (Spell Checker)。
- 一本字典(dictionary):包含几万甚至十几万的正确单词。
- 一个文件(text):比如电影《 La La Land》的剧本。
程序需要做的事情是: - 把字典加载到内存上
- 遍历文件的每一个单词
- 判断这个单词是否存在于字典中(忽略大小写)
- 如果不在,这个单词是错误,打印出这个错误单词。
- 统计并输出加载、检查、释放的时间(Benchmark)。
这题的难点
速度 : 默认字典中有143,091个单词,而文章可能有十几万字,如果我们的查找算法不够快(比如每次都遍历整个字典),程序可能会跑几分钟甚至几小时。目标是在几分之一秒内完成。
内存:不能把所有单词都写死在代码里,必须动态分配内存。而且用完必须释放,不能有内存泄漏。
二、核心数据结构(Data Structure)
为了实现"极速查找",题目强制要求使用 哈希表 (Hash Table)。
-
结构体定义
在dictionary.c中,定义了节点(Node):// Represents a node in a hash table
typedef struct node
{
char word[LENGTH + 1];
struct node *next;
} node; -
哈希表
// Hash table
node *table[N];
这是一个指针数组,
N是桶(Buckets)的数量table[i]是第 i 个链表的头指针(Head Pointer)- 链表存储哈希到同一索引的所有单词
- 初始,所有头指针
table[i]的值都是NULL
-
类比
为了便于理解,做个类比
想象哈希表是一个巨大的文件柜,有 N 个抽屉。
回忆下课程notes中的
最终哈希表存储的结果可能如下所示
table[0] → [apple] → [ant] → [aardvark] → NULL
table[1] → [banana] → [bear] → NULL
table[2] → [cat] → [car] → [cat] → NULL
...
table[25] → [zebra] → [zoo] → NULL
三、核心概念 :哈希函数 (Hash Function)
哈希函数是整个系统的引擎。它的输入是 const char *word,输出是 unsigned int(也就是数组下标 index)。
输入: "Apple"
处理: 必须不区分大小写。'A' 和 'a' 算出来的结果必须一样。
输出: 0 到 N-1 之间的整数。
确定性: 每次给它 "Apple",它必须返回同一个数字。
四、逐步分析和实现
我们不需要写 main 函数,只需要填充 dictionary.c 里的 5 个函数。建议按以下顺序实现:
1. 准备工作:全局变量与定义
首先,我们需要确定桶的数量 N。
// 可以先用小一点的数测试,最后改大比如 10000 甚至 150000
const unsigned int N = 26;
需要一个变量来记录字典里到底有多少个词,方便 size() 函数使用。
unsigned int word_count = 0;
2. 实现 hash (最简单的版本)
先别管效率,先让程序能跑起来。最简单的哈希就是取首字母。
unsigned int hash(const char *word)
{
return toupper(word[0]) - 'A';
}
问题分析:
这个简单版的哈希方法有哪些问题呢?
-
分布不均匀
- 以 'A' 开头的单词很多 → table[0] 很长
- 以 'X' 开头的单词很少 → table[23] 很短
示例:
table[0]: [apple, ant, aardvark, able, ...] (几千个)
table[23]: [xylophone, x-ray] (几十个)
结果:
-
查找 'apple':需要遍历很长的链表
-
性能退化到接近 O(n)
改进的哈希函数
目标:将单词均匀分布到更多的桶中 -
策略1:增加桶的数量
比如将桶的数量定义为 26*26const unsigned int N = 26 * 26; // 676 个桶(AA-ZZ)
哈希函数必须与桶的数量是匹配的,因此,我们需要修改上述哈希函数如下:
unsigned int hash(const char *word)
{
unsigned int index = 0;
// 获取第一个字母的数值 (0-25)
unsigned int first = toupper(word[0]) - 'A';
// 检查是否有第二个字母
// word[1] 是字符串的第二个字符。如果是 '\0',说明单词结束了,长度只有1
if (word[1] == '\0')
{
// 如果只有一个字母,就直接用它
// 注意:为了不浪费空间,我们还是得把它放到某个位置
// 比如 "A" 可以放在 "AA" 的位置,或者单独处理
// 最简单的策略:假设第二个字母是 'A' (即值0)
index = first * 26 + 0;
}
else
{
// 获取第二个字母的数值
// 注意:这里需要处理撇号 ',或者假设输入都是字母
// 健壮性写法:如果是撇号,把它当成 0 或者其他处理,这里假设是字母
unsigned int second = 0;
if (isalpha(word[1]))
{
second = toupper(word[1]) - 'A';
}
// 如果是撇号,second 保持为 0,或者你可以自定义
// 套用公式
index = first * 26 + second;
}
// 双重保险:防止计算出的值越界 (虽然理论上不会)
return index % N;
}
为什么对应的hash 函数是这样的。
哈希函数的逻辑必须与桶的数量(N)相匹配。如果 N变了,但哈希函数没变,增加的桶就浪费了。
前面设定 N= 26,简单版 Hash:return toupper(word[0]) - 'A';
背景回顾
- 此前设定:N=26N=26
- 简单版 Hash :
return toupper(word[0]) - 'A';- 这个函数返回值的范围是 00 到 2525。
- 正好覆盖了 table[0]table[0] 到 table[25]table[25]。
场景分析:N=26×26=676N=26×26=676
如果把 NN 改大了,但哈希函数还是只看第一个字母,那么:
- 哈希函数依然只返回 0−250−25。
- 数据只会填充在前 26 个桶里。
- 后面的 table[26]table[26] 到 table[675]table[675] 全部是空的(NULL),完全浪费了空间,且没有解决冲突拥挤的问题。
解决方案:利用前两个字母
为了充分利用 676676 个桶,我们需要让哈希函数的返回值范围扩大到 00 到 675675。一个很自然的想法是利用单词的前两个字母。
数学逻辑(Base-26 进制)
想象这是一个 26 进制的系统:
- 第 1 个字母代表"高位"(就像十进制里的十位)。
- 第 2 个字母代表"低位"(就像十进制里的个位)。
公式设计:
Index=(第一个字母的值×26)+第二个字母的值Index=(第一个字母的值×26)+第二个字母的值
举例:
- "AA" :
- 'A' -> 0, 'A' -> 0
- Index = 0×26+0=00×26+0=0
- "BA" :
- 'B' -> 1, 'A' -> 0
- Index = 1×26+0=261×26+0=26
- "ZZ" :
- 'Z' -> 25, 'Z' -> 25
- Index = 25×26+25=650+25=67525×26+25=650+25=675
这样,从 "AA" 到 "ZZ" 的所有双字母组合都能映射到 0−6750−675 之间唯一的桶。
代码实现细节
这里有几个边界情况(Edge Cases)需要处理:
- 单词长度只有 1(比如 "A", "I"):它们没有第二个字母。
- 非字母字符 :虽然题目说单词主要是字母,但要小心特殊符号(如撇号
')。不过简化起见,通常我们只处理字母。
进一步思考:为什么 26*26 还是不够好?
虽然 676 个桶比 26 个好很多,但对于 14 万个单词的字典来说:
143,000/676≈211143,000/676≈211
平均每个桶里还是有 211 个单词。查找时还是要在长链表里跑。
这就是为什么在实际工程和进阶优化中,我们不再试图用"字母的几何意义"(第1个字、第2个字)来设计哈希,而是使用数学混淆。
数学混淆法(多项式哈希) :
不关心字母的位置含义,只关心把字符串变成一串混乱的数字。
比如:
Hash=(H×33)+CHash=(H×33)+C
这种算法算出来的数可能很大(几十亿),然后我们要模上桶的数量 NN。
Index=(VeryBigNumber)%NIndex=(VeryBigNumber)%N
这时,你可以把 NN 设为 10,00010,000 甚至 100,000100,000,哈希函数完全不需要改代码,只需要改 NN 的值即可(只要哈希算出来的值足够大且分散)。
这种基于"多项式滚动哈希"的算法,最著名的实现被称为 djb2 (由 Dan Bernstein 提出)。它在 C 语言社区和许多开源项目中被广泛使用,因其在处理英文字符串时分布极其均匀且计算速度快而闻名。
总结:
如果用 N=676N=676,必须手动硬编码"前两个字母"的逻辑。但更好的做法是使用通用的数学哈希算法,这样你可以随意调整 NN 的大小,代码具有更好的扩展性。
djb2 的核心逻辑
1. 核心逻辑
这种哈希算法的思想不是把字母看作"位置"(如第1个、第2个), 而是把整个单词看作一个巨大的数字。
公式如下:
Hashnew=(Hashold×33)+CurrentCharacterHashnew=(Hashold×33)+CurrentCharacter
- 初始值 :通常设为一个较大的质数,比如 5381。
*** 乘数 33**:这是一个神奇的数字。实验表明,乘 33 能很好地将英语单词里的字母特征分散到哈希值的各个二进制位上,避免冲突。 - 累加字符:把当前字母的 ASCII 值加进去。
2. 代码实现
#include <ctype.h> // 需要用到 tolower
#include <strings.h> // 需要用到 strcasecmp
// 1. 调整 N 的大小
// 既然用了高级哈希,桶就要够多才能发挥威力。
// 建议设为大一点的数字,比如 100,000 甚至更大。
// 为了减少冲突,最好是一个质数,比如 143091 (字典大小) 附近的质数。
const unsigned int N = 100000;
unsigned int hash(const char *word)
{
// 2. 初始化哈希值为 5381 (djb2 算法的经典初始值)
// 为什么是 5381?这是 Dan Bernstein 通过实验发现的"魔数"
// 使用 unsigned long 以防溢出(虽然溢出回绕在这里其实没关系,甚至有帮助)
unsigned long hash = 5381; // ⚠️ 注意:必须是 5381,不是其他数字!
// 3. 遍历单词的每一个字符
for (int i = 0; word[i] != '\0'; i++)
{
// 4. 核心公式:hash * 33 + c
// 必须使用 tolower(),因为题目要求哈希表不区分大小写
// "Cat" 和 "cat" 必须算出相同的 hash 值
hash = ((hash << 5) + hash) + tolower(word[i]);
}
// 5. 取模运算
// 算出来的 hash 可能非常大(几十亿),必须映射到 0 到 N-1 的范围内
return hash % N;
}
3. 代码细节深度解析
- 为什么是
((hash << 5) + hash)?
这其实就是hash * 33的位运算优化写法。
hash << 5意思是把二进制位向左移 5 位,相当于乘以 2525,即 乘以 32。- 所以:
(hash * 32) + hash就等于hash * 33。 - 在旧的编译器上,位运算比乘法快。虽然现代编译器很聪明,会自动帮你优化,但这样写显得你很懂底层(Geek 范儿)。当然,直接写
hash = (hash * 33) + tolower(word[i]);也是完全正确的。
-
为什么要
tolower?题目明确要求
check函数是 case-insensitive (不区分大小写)的。这意味着如果你在
load字典时,单词 "Apple" 被存进了 5 号桶(因为 'A' 的值),而在check时,文章里的 "apple" 算出来是 30 号桶(因为 'a' 的值),那你就永远找不到它了。
原则:存入和查找时,必须统一转成小写(或大写)来计算哈希值。 -
为什么要
% N?循环结束后,
hash变量里存着一个巨大的整数(例如384918423)。你的
table数组只有N个格子。
% N操作能确保结果永远落在0到N-1之间,作为数组的合法下标。
4. 性能提升预期
如果把 N 改为 100,000 并配合这个哈希函数:
- 原本 (N=26) :平均每个桶有 5500 个单词。
check一个词要对比 5500 次字符串。 - 现在 (N=100000) :平均每个桶只有 1.4 个单词!
- 结果 :绝大多数情况下,
check函数只需要对比 1 次或 2 次就能得出结果。程序的运行速度将提升成百上千倍。
这就是哈希表的魔力:通过良好的哈希函数和足够的空间,实现接近 O(1)O(1) 的查找速度。
3. load() - 加载字典 ⭐最复杂
把字典从磁盘搬到内存。
功能需求
输入:字典文件路径(如 "dictionaries/large")
输出:成功返回 true,失败返回 false
步骤:
1. 打开字典文件
2. 逐行读取单词
3. 为每个单词创建节点
4. 计算哈希值
5. 插入到哈希表(链表头部)
6. 关闭文件
伪代码逻辑:
fopen 打开字典文件。检查是否为 NULL。
准备一个临时字符数组 buffer 存读到的词。
Loop: 使用 fscanf(file, "%s", buffer) 循环读取直到文件结束 (EOF)。
malloc 一个新节点 new_node。如果 NULL,报错并 return false。
strcpy 把 buffer 里的词复制到 new_node->word。
调用 hash(buffer) 算出 index。
插入链表 (头插法):
new_node->next 指向 table[index] (原本的头)。
table[index] 指向 new_node (新节点变成头)。
word_count 加 1。
fclose 文件。
返回 true。
完整实现
```C
// 全局变量:记录字典中的单词数
unsigned int word_count = 0;
bool load(const char *dictionary)
{
// 1. 打开字典文件
FILE *file = fopen(dictionary, "r");
if (file == NULL)
{
return false;
}
// 2. 初始化哈希表(所有指针设为 NULL)
for (int i = 0; i < N; i++)
{
table[i] = NULL;
}
// 3. 读取单词(缓冲区)
char word[LENGTH + 1];
// 4. 逐个读取单词
while (fscanf(file, "%s", word) != EOF)
{
// 5. 创建新节点
node *new_node = malloc(sizeof(node));
if (new_node == NULL)
{
// 内存分配失败,清理并返回
fclose(file);
unload();
return false;
}
// 6. 复制单词到节点
strcpy(new_node->word, word);
// 7. 计算哈希值
unsigned int index = hash(word);
// 8. 插入到链表头部(最快)
new_node->next = table[index];
table[index] = new_node;
// 9. 更新计数器
word_count++;
}
// 10. 关闭文件
fclose(file);
return true;
}
``` {data-source-line="551"}
注意,在将读到的word 复制到hash table 时使用的是`strcpy(new_node->word, word);`,而不是`new_node->word = word;`
是因为word 是局部变量,只复制会被覆盖。
### 4. `size()` 操作
这个非常简单,前面在将词典从磁盘加载到内存时定义了一个全局变量 `word_count`,每次读取一个单词时,计数器就加一,所以`size()`直接返回该全局变量即可。
```C
// Returns number of words in dictionary if loaded, else 0 if not yet loaded
unsigned int size(void)
{
// TODO
return word_count;
}
``` {data-source-line="563"}
**注意**: 如果 load 没成功,应该根据 word_count 的初始值返回 0,这也符合逻辑。
### 5. `check()`
这是在检查文章拼写时需要被疯狂调用的函数。
#### **功能需求**
输入:待检查的单词
输出:在字典中返回 true,否则 false
步骤:
1. 计算哈希值
2. 遍历对应的链表
3. 比较每个节点的单词(不区分大小写)
伪代码
1. 调用 `hash(word)` 计算这个`word` 在哪个桶 `index`
2. 创建一个指针 `cursor` 指向 `table[index]`
3. **Loop**: `while (cursor != NULL)`
- 使用 `strcmp(word, cursor->word)` 比较
- 如果值为 `0`,说明找到了,返回 `true`
- 当前节点没找到,继续下一个 `cursor = cursor->next`
4. 循环完了还没找到,说明不在字典中,拼写错误,返回 `false`.
代码实现
```C
// Returns true if word is in dictionary, else false
bool check(const char *word)
{
// TODO
unsigned index = hash(word);
node *cursor = table[index];
while (cursor != NULL)
{
// ⚠️ 关键:必须使用 strcasecmp 进行大小写不敏感比较!
// 如果使用 strcmp,"Apple" 和 "apple" 会被视为不同单词
if (strcasecmp(word, cursor->word) == 0)
{
return true;
}
cursor = cursor->next;
}
return false;
}
``` {data-source-line="602"}
> ⚠️ **常见 Bug 警告**:很多同学(包括我最初)使用 `strcmp` 而不是 `strcasecmp`,导致大量"拼写错误"。因为字典中存的是 `apple`,而文章中可能是 `Apple`,`strcmp` 会认为它们不相等!
**性能分析**:
最好情况:O(1) - 单词在链表头部
最坏情况:O(n) - 链表很长,单词在末尾或不存在
平均情况:O(n/N) - n 个单词,N 个桶
优化关键:
- N 越大,链表越短
- 哈希函数越好,分布越均匀
### 6. unload()
程序结束前必须释放内存。我们必须遍历所有桶,销毁所有链表。
**伪代码逻辑:**
**Loop**: 遍历桶,`i` 从 `0` 到 `N-1`
设置 `cursor` 指向 `table[i]`
**Inner Loop**: `while (cursor != NULL)`
`node *tmp = cursor;`
`cursor = cursor->next;`
`free(tmp);`
返回`true`
完整实现
```C
// Unloads dictionary from memory, returning true if successful, else false
bool unload(void)
{
// TODO
for (int i = 0; i < N; i++)
{
node *cursor = table[i];
while (cursor != NULL)
{
node *tmp = cursor;
cursor = cursor->next;
free(tmp);
}
}
return true; // 注意:这里返回 true,不是 tmp!
}
``` {data-source-line="642"}
完整的 `dictionary.c` 在 speller 目录下。
### 三、测试和优化
#### 1. 基础测试
```shell
# 编译 {#编译 data-source-line="647"}
make speller
# 测试小字典 + 小文本 {#测试小字典--小文本 data-source-line="650"}
./speller dictionaries/small texts/cat.txt
* 方法 1 hash
```shell {data-source-line="653"}
speller/ $ ./speller dictionaries/small texts/cat.txt
MISSPELLED WORDS
A
is
not
a
WORDS MISSPELLED: 4
WORDS IN DICTIONARY: 2
WORDS IN TEXT: 6
TIME IN load: 0.00
TIME IN check: 0.00
TIME IN size: 0.00
TIME IN unload: 0.00
TIME IN TOTAL: 0.00
这个规模比较小,即便hash() 方法 1也是瞬间完成,后面就不用这个文本测试了。
测试大字典 + 中等文本
./speller texts/holmes.txt
-
hash() 方法 1
WORDS MISSPELLED: 122102
WORDS IN DICTIONARY: 143091
WORDS IN TEXT: 1150970
TIME IN load: 0.02
TIME IN check: 33.36
TIME IN size: 0.00
TIME IN unload: 0.00
TIME IN TOTAL: 33.39 -
hash() 方法 2
WORDS MISSPELLED: 122102
WORDS IN DICTIONARY: 143091
WORDS IN TEXT: 1150970
TIME IN load: 0.02
TIME IN check: 7.13
TIME IN size: 0.00
TIME IN unload: 0.00
TIME IN TOTAL: 7.16 -
hash() 方法 3
WORDS MISSPELLED: 122102
WORDS IN DICTIONARY: 143091
WORDS IN TEXT: 1150970
TIME IN load: 0.03
TIME IN check: 1.37
TIME IN size: 0.00
TIME IN unload: 0.01
TIME IN TOTAL: 1.41
hash() 方法 3明显快了很多,但还是与官方实现的有差距,可以尝试调整 N 的大小再去优化。
与官方实现对比
./speller50 texts/holmes.txt
./speller texts/holmes.txt
-
官方测评
WORDS MISSPELLED: 955
WORDS IN DICTIONARY: 143091
WORDS IN TEXT: 17756
TIME IN load: 0.02
TIME IN check: 0.02
TIME IN size: 0.00
TIME IN unload: 0.01
TIME IN TOTAL: 0.06
2. 检查正确性
# 使用 check50
check50 cs50/problems/2024/x/speller
# 手动比较输出
./speller texts/holmes.txt > student.txt
./speller50 texts/holmes.txt > staff.txt
diff student.txt staff.txt
方法 1 与官方比较
122107c17850
< WORDS MISSPELLED: 122102
---
> WORDS MISSPELLED: 17845
122111c17854
< TIME IN check: 23.92
---
> TIME IN check: 0.98
122113,122114c17856,17857
< TIME IN unload: 0.00
< TIME IN TOTAL: 23.94
---
> TIME IN unload: 0.01
> TIME IN TOTAL: 1.02
-
hash() 方法 2 与官方比较
< WORDS MISSPELLED: 122102
WORDS MISSPELLED: 17845
122111c17854
< TIME IN check: 4.73
TIME IN check: 0.98
122113,122114c17856,17857
< TIME IN unload: 0.00
< TIME IN TOTAL: 4.75
TIME IN unload: 0.01
TIME IN TOTAL: 1.02
hash() 方法 3 与 官方比较
< WORDS MISSPELLED: 122102
---
> WORDS MISSPELLED: 17845
122110,122111c17853,17854
< TIME IN load: 0.03
< TIME IN check: 0.99
---
> TIME IN load: 0.02
> TIME IN check: 0.98
122114c17857
< TIME IN TOTAL: 1.03
---
> TIME IN TOTAL: 1.02
⚠️ Bug 分析:为什么拼写错误数差这么多?
注意到上面的测试结果中,我的程序报告 122,102 个拼写错误,而官方只有 17,845 个!
原因 :我最初在 check() 函数中使用了 strcmp() 而不是 strcasecmp()!
这里的bug 分析是我让AI 帮我 review 我的实现和笔记,AI 给出的,这里是我阅读官方 hints 部分不仔细,"To compare two strings case-insensitively, you may find strcasecmp (declared in strings.h) useful!" 非常明白的给了提示的。
// 错误写法 ❌
if (strcmp(word, cursor->word) == 0)
// 正确写法 ✅
if (strcasecmp(word, cursor->word) == 0)
后果:
- 字典中存的是小写
"apple" - 文章中可能出现
"Apple"或"APPLE" strcmp("Apple", "apple")返回非零值(不相等)- 程序错误地认为
"Apple"是拼写错误
修复后:拼写错误数与官方一致!
💡 教训:这个 bug 让我深刻理解了为什么 CS50 强调 "case-insensitive" 比较的重要性。在实际项目中,这类 bug 可能导致严重后果。
3. 内存泄漏检查
# 使用 valgrind
valgrind --leak-check=full ./speller texts/cat.txt
# 应该看到:
# All heap blocks were freed -- no leaks are possible
4 性能优化
性能目标
Staff Solution (speller50) 基准:
- TIME IN load: 0.02-0.05s
- TIME IN check: 0.00-0.01s (重点优化)
- TIME IN unload: 0.00-0.02s
你的目标:
- 接近或超过官方实现
- check() 时间最关键(调用次数最多)
优化策略
- 调整哈希表大小 N
- 改进哈希函数
这两点是对应的,前面我们在实现hash()函数时已经讲到。
总结
知识点对照表
| 题目 | 核心数据结构 | 关键算法 | 课程对应内容 |
|---|---|---|---|
| Inheritance | 树(指针数组) | 递归构建、递归释放 | Week 5: Trees, Recursion |
| Speller | 哈希表(链表数组) | 哈希函数、链表操作 | Week 5: Hash Tables, Linked Lists |
常见错误清单
| 错误类型 | 具体错误 | 正确做法 |
|---|---|---|
| 字符串比较 | 使用 strcmp |
使用 strcasecmp(大小写不敏感) |
| 返回值 | unload() 返回 tmp |
返回 true |
| 头文件 | 忘记包含 <strings.h> |
使用 strcasecmp 需要此头文件 |
| 哈希初值 | 使用随意数字 | djb2 算法使用 5381 |
| 内存泄漏 | 忘记 free() |
使用 valgrind 检查 |
性能优化要点
哈希表性能公式:平均链表长度 = 单词总数 / 桶数量
N = 26: 143,091 / 26 ≈ 5,500 个节点/桶 → 很慢
N = 676: 143,091 / 676 ≈ 212 个节点/桶 → 一般
N = 100,000: 143,091 / 100,000 ≈ 1.4 个节点/桶 → 很快!
调试技巧
-
使用 valgrind 检查内存泄漏:
valgrind --leak-check=full ./speller texts/cat.txt -
使用 diff 对比输出:
./speller texts/lalaland.txt > student.txt ./speller50 texts/lalaland.txt > staff.txt diff -y student.txt staff.txt -
使用 check50 官方测试:
check50 cs50/problems/2025/x/speller
延伸学习
- 哈希函数设计:了解更多哈希算法如 FNV-1a、MurmurHash
- 数据结构优化:学习开放寻址法、布隆过滤器等
- 算法复杂度:深入理解 O(1) vs O(n) 的实际影响
参考资料:
下次见! 🚀