Harvard CS50 Problems Set 5

Problem Set 5 概述

本周作业围绕数据结构主题展开,需要运用 Week 5 学习的链表、哈希表等知识解决实际问题。

题目 难度 核心知识点
Inheritance ⭐⭐ 递归、动态内存分配、树结构
Speller ⭐⭐⭐⭐ 哈希表、链表、文件操作、性能优化

作业页面:


1. Inheritance(遗传模拟)

题目背景

血型遗传问题

这道题涉及到的核心知识点是指针(Pointers)、动态内存分配(malloc/free)、递归(Recursion)

想象下我们小时候学习的family tree,没错,这里我们就是要构建树这种结构。

一、核心概念分析

1. 数据结构:倒置的树

在这题中,一个人(person)有一对父母。

  • 通常的树是根(root)在顶端,分叉在下面,通过指针由根节点指向子节点

  • family tree 中,孩子在最下层做为根节点,指向父母,父母再指向自己的父母。。。

    复制代码
           祖父母
         /      \
     父亲        母亲
         \      /
           孩子

    三代人的遗传关系:

    • 孩子的等位基因来自父母
    • 父母的等位基因来自祖父母
  • 每个节点 (person)包含:

  1. person[2]:指向爸爸和妈妈的指针
  2. alleles[2]:两个血型基因字符(比如 'A' 和 'B')。

2. 递归构建(Recursion)

  • Create:要创建一个person,必须先创建其父母,要创建"父母",必须先创建"祖父母",这是一个典型的递归过程。(涉及到树结构,基本上就是使用递归)。
  • Basic Case(终止条件) :当我们达到 generations(祖父母) 代,他们没有父母,模拟时指针设置为NULL,基因也是随机生成的。

3. 孟德尔遗传定律

  • 每个人的两个基因 (alleles),一个来自父亲,一个来自母亲。
  • 我们要随机决定从父亲的两个基因里选哪一个,再从母亲的两个基因里选哪一个。

逐步解决

我们的任务是完成 create_familyfree_family 中的 TODO。

1. create_family(构建 family tree)

思考

  1. 不管这个人是谁,都要为其在内存上开辟空间,使用 malloc 动态分配内存。
  2. 判断代数
  • 如果有上一代(generations > 1)
    • 调用 create_family 把其父母创建出来,并把指针存储到当前 parents 数组中
    • 决定基因:根据刚刚创建出来的父母的基因,随机决定孩子的基因。
  • 如果是祖宗(即不再有父母)
    • 父母指针设为 NULL
    • 不再有父母,没有上一代数据, 调用 random_allele() 基因随机生成。
  1. 把创建好的这个人返回给上一层。
    实现如下:

    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)。

  1. 一本字典(dictionary):包含几万甚至十几万的正确单词。
  2. 一个文件(text):比如电影《 La La Land》的剧本。
    程序需要做的事情是:
  3. 把字典加载到内存上
  4. 遍历文件的每一个单词
  5. 判断这个单词是否存在于字典中(忽略大小写)
  6. 如果不在,这个单词是错误,打印出这个错误单词。
  7. 统计并输出加载、检查、释放的时间(Benchmark)。
    这题的难点
    速度 : 默认字典中有143,091个单词,而文章可能有十几万字,如果我们的查找算法不够快(比如每次都遍历整个字典),程序可能会跑几分钟甚至几小时。目标是在几分之一秒内完成。
    内存:不能把所有单词都写死在代码里,必须动态分配内存。而且用完必须释放,不能有内存泄漏。

二、核心数据结构(Data Structure)

为了实现"极速查找",题目强制要求使用 哈希表 (Hash Table)

  1. 结构体定义
    dictionary.c 中,定义了节点(Node):

    // Represents a node in a hash table
    typedef struct node
    {
    char word[LENGTH + 1];
    struct node *next;
    } node;

  2. 哈希表

    // Hash table
    node *table[N];

这是一个指针数组,

  • N 是桶(Buckets)的数量
  • table[i] 是第 i 个链表的头指针(Head Pointer)
  • 链表存储哈希到同一索引的所有单词
  • 初始,所有头指针table[i]的值都是NULL
  1. 类比
    为了便于理解,做个类比
    想象哈希表是一个巨大的文件柜,有 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*26

    const 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
  • 简单版 Hashreturn 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. 单词长度只有 1(比如 "A", "I"):它们没有第二个字母。
  2. 非字母字符 :虽然题目说单词主要是字母,但要小心特殊符号(如撇号 ')。不过简化起见,通常我们只处理字母。

进一步思考:为什么 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. 代码细节深度解析
  1. 为什么是 ((hash << 5) + hash)
    这其实就是 hash * 33 的位运算优化写法。
  • hash << 5 意思是把二进制位向左移 5 位,相当于乘以 2525,即 乘以 32
  • 所以:(hash * 32) + hash 就等于 hash * 33
  • 在旧的编译器上,位运算比乘法快。虽然现代编译器很聪明,会自动帮你优化,但这样写显得你很懂底层(Geek 范儿)。当然,直接写 hash = (hash * 33) + tolower(word[i]); 也是完全正确的。
  1. 为什么要 tolower

    题目明确要求 check 函数是 case-insensitive (不区分大小写)的。

    这意味着如果你在 load 字典时,单词 "Apple" 被存进了 5 号桶(因为 'A' 的值),而在 check 时,文章里的 "apple" 算出来是 30 号桶(因为 'a' 的值),那你就永远找不到它了。
    原则:存入和查找时,必须统一转成小写(或大写)来计算哈希值。

  2. 为什么要 % N

    循环结束后,hash 变量里存着一个巨大的整数(例如 384918423)。

    你的 table 数组只有 N 个格子。
    % N 操作能确保结果永远落在 0N-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() 时间最关键(调用次数最多)

优化策略

  1. 调整哈希表大小 N
  2. 改进哈希函数
    这两点是对应的,前面我们在实现 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 个节点/桶 → 很快!

调试技巧

  1. 使用 valgrind 检查内存泄漏

    复制代码
    valgrind --leak-check=full ./speller texts/cat.txt
  2. 使用 diff 对比输出

    复制代码
    ./speller texts/lalaland.txt > student.txt
    ./speller50 texts/lalaland.txt > staff.txt
    diff -y student.txt staff.txt
  3. 使用 check50 官方测试

    复制代码
    check50 cs50/problems/2025/x/speller

延伸学习

  • 哈希函数设计:了解更多哈希算法如 FNV-1a、MurmurHash
  • 数据结构优化:学习开放寻址法、布隆过滤器等
  • 算法复杂度:深入理解 O(1) vs O(n) 的实际影响

参考资料

下次见! 🚀

相关推荐
mjhcsp2 小时前
P14795 [JOI 2026 二次预选] 分班 / Class Division
数据结构·c++·算法
girl-07262 小时前
2025.12.29实验题目分析总结
数据结构·算法
sin_hielo2 小时前
leetcode 840
数据结构·算法·leetcode
吴声子夜歌4 小时前
数据结构——通用树(N叉树)
数据结构
surtr14 小时前
常见排序模板(冒泡排序,希尔排序,堆排序,归并排序,快速排序)
数据结构·算法·贪心算法·排序算法
一路往蓝-Anbo5 小时前
C语言从句柄到对象 (三) —— 抛弃 Malloc:静态对象池与索引句柄的终极形态
c语言·开发语言·数据结构·stm32·单片机·算法
集芯微电科技有限公司5 小时前
PC1001超高频率(50HMZ)单通单低侧GaN FET驱动器支持正负相位配置
数据结构·人工智能·单片机·嵌入式硬件·神经网络·生成对抗网络·fpga开发
一路往蓝-Anbo6 小时前
C语言从句柄到对象 (二) —— 极致的封装:不透明指针与 SDK 级设计
c语言·开发语言·数据结构·stm32·单片机·嵌入式硬件
上天_去_做颗惺星 EVE_BLUE6 小时前
C++学习:学生成绩管理系统
c语言·开发语言·数据结构·c++·学习