视频封面

视频元信息

  • 时长:约1小时(推断)

内容摘要(TL;DR)

本讲是C语言部分的最后一课,聚焦数据结构。介绍抽象数据类型(队列、栈、字典),深入讲解数组动态扩容(malloc/realloc)、链表、二叉搜索树、哈希表和Trie,分析各种结构的运行时间与空间权衡,并通过实际案例说明数据结构在真实世界中的应用,为下周Python和问题集5(拼写检查器)做铺垫。


一、引言:本周主题与过渡

[47.3s] 这是CS50第五周,主题是数据结构。本周是C语言的最后一周,下周将转向Python——一种高级编程语言,能让我们更快地解决问题,但代码运行速度可能不如C。

  • 核心主题:权衡(trade-offs)。在接下来的课程中,我们会反复看到“用空间换时间”或“用时间换空间”的取舍。
  • 理解:Python等高级语言隐藏了底层细节,但底层仍然在解决同样的问题,只是由语言设计者替我们做了。

二、抽象数据类型:队列(Queue)与栈(Stack)

[99.0s] 通过动画(Jack和Blue的故事)引入队列和栈的概念。

队列(Queue)

[242.6s] 队列是现实世界常见的“排队”结构,具有FIFO(先进先出) 属性:先来的人先服务。

  • 两个基本操作:
    • enqueue(入队):进入队列末尾。
    • dequeue(出队):从队列头部离开。
  • 实现示例:在C中,可以用结构体包含一个固定大小的数组(如容量50)和当前大小(size)。
    • 易错点:固定容量限制。如果第51个人到达,无法存储。修改容量需要重新编译,且存在“分配过多浪费内存”或“分配不足”的风险。
  • 思考:理想的队列应能动态增长和收缩,就像向操作系统请求更多内存一样,但静态数组做不到。
  • 附加难点:需要跟踪队头位置,因为出队后要更新队头。

栈(Stack)

[446.2s] 栈具有LIFO(后进先出) 属性,就像一叠盘子,总是取最上面的。

  • 两个基本操作:
    • push(压栈):把元素放到栈顶。
    • pop(弹栈):从栈顶移除元素。
  • 现实例子:食堂的餐盘堆、Gmail收件箱默认新邮件在顶部(因此容易忽略旧邮件)。
  • 实现:与队列类似,可以用数组+大小实现,但栈更容易——始终在数组末尾插入/删除,第一个元素永远在位置0。
  • 同样存在固定容量限制,但栈和队列是抽象类型,可以通过动态内存管理改进。

考点:FIFO vs LIFO——队列先进先出,栈后进先出。务必区分两者操作名称:enqueue/dequeue vs push/pop。


三、字典(Dictionary):键值对

[644.1s] 字典是另一种抽象数据类型,本质是键值对(key-value pairs) 集合。

  • 现实世界:英语词典(单词→定义)、电话簿(名字→号码)。
  • 计算机科学中,左侧叫键(key),右侧叫值(value)。
  • 实现方式多种多样:可以用数组(固定大小、可能慢),也可以用更动态的结构(如哈希表)。
  • 理解:手机通讯录如果只允许50个联系人会很不合理,所以实际应用一定采用了更动态的实现。

四、数组与动态内存管理

[746.1s] 回顾数组:连续内存块,值一个接一个。数组的优点是访问速度快(支持二分查找),缺点是大小需预先确定。

静态分配的问题

[856.1s] 如果数组大小不够,想增加一个元素,不能直接“放在旁边”,因为相邻内存可能已被其他数据占用(例如字符串"hello, world"恰好就在旁边)。

  • 解决方案:申请一块更大的新内存,把旧数据复制过去,然后释放旧内存。
  • 这需要用到 malloc 和 free。

动态分配示例:list.c

[970.1s] 使用 malloc 分配3个整数的空间,并利用指针与数组的等价性:

int *list = malloc(3 * sizeof(int));
list[0] = 1;
list[1] = 2;
list[2] = 3;
  • 易错点:内存泄漏。如果直接 list = malloc(4 * sizeof(int)),会丢失原内存地址,无法释放,导致内存泄漏(可用Valgrind检测)。
  • 正确做法:使用临时变量 tmp 保存新内存地址,复制旧数据,释放旧内存,最后更新 list。
  • 必须检查 malloc 返回值是否为NULL,否则可能发生段错误。

使用 realloc 简化

[1170.1s] realloc 可以智能地扩展内存块:如果原位置后面有空间,直接原地扩展;否则分配新内存并自动复制旧数据。

int *tmp = realloc(list, 4 * sizeof(int));
if (tmp == NULL) {
    free(list);
    return 1;
}
list = tmp;
list[3] = 4;
  • 易错点:仍然要检查NULL,且要使用临时变量,避免 realloc 失败后丢失原指针。
  • 理解:realloc 知道要复制多少数据,因为标准库内部记录了之前分配的大小。
  • 思考:在Python中,这些问题由语言自动处理,所以我们不需要写这么多行代码。

五、链表(Linked List)

[1290.1s] 链表是数组的替代品,通过指针把分散在内存各处的节点“串”起来,从而避免连续内存的限制。

节点定义

typedef struct node {
    int number;
    struct node *next;
} node;
  • 注意:结构体内部必须写 struct node *next,因为 typedef 名要到结尾才生效,编译器需要先看到 struct node。
  • 每个节点包含数据(int)和指向下一个节点的指针。

构建链表

[1460.1s] 初始链表为空:node *list = NULL;

每次插入新节点:

  1. node *n = malloc(sizeof(node));
  2. 检查 n != NULL
  3. 设置 n->number = 用户输入
  4. 设置 n->next = NULL
  5. 前插(prepend):n->next = list; list = n;
  • 箭头运算符 -> 是 (*n).number 的简写,相当于“解引用指针并访问字段”。
  • 理解:前插操作是常数时间 O(1),因为不需要遍历列表。
  • 易错点:顺序很重要。如果先执行 list = n 再执行 n->next = list,就会丢失原链表,造成内存泄漏。

遍历链表

[1800.1s] 打印链表所有值:

for (node *ptr = list; ptr != NULL; ptr = ptr->next) {
    printf("%i\n", ptr->number);
}
  • 或者用 while 循环:node *ptr = list; while (ptr != NULL) { ... ptr = ptr->next; }

运行时间分析

  • 插入(前插):O(1)
  • 搜索:O(n)(最坏情况在末尾)
  • 删除:O(n)(需要先找到节点)
  • 遍历:O(n)

  • 思考:链表牺牲了二分查找的能力,因为无法直接跳到中间,必须从头遍历。

有序插入的复杂性

[1930.1s] 如果希望链表始终有序,插入时需要判断四种情况:

  1. 列表为空 → 直接插入
  2. 新节点应插到开头 → 前插
  3. 新节点应插到末尾 → 遍历到末尾后追加
  4. 新节点应插到中间 → 需要两个指针,仔细调整链接
  • 易错点:中间插入时指针操作顺序错误会导致节点“孤儿化”,即丢失部分链表。
  • 释放链表时也必须逐个节点 free,不能只 free(list),因为 free 不知道链表结构。

考点:链表 vs 数组——链表支持动态增长,但搜索/删除为O(n);数组支持二分查找O(log n),但扩容代价高。实际使用需根据场景权衡。


六、二叉搜索树(Binary Search Tree)

[2210.1s] 二叉搜索树(BST)是链表和数组的“混合体”,每个节点有两个指针(左孩子、右孩子),形成二维结构。

性质

  • 每个节点的值 大于其左子树的所有值,小于其右子树的所有值。
  • 该性质是递归的:每个子树也满足BST性质。

搜索运行时间

[2340.1s] 从根开始,每次比较后丢弃一半子树,类似二分查找,因此搜索、插入、删除均为 O(log n),前提是树保持平衡。

  • 示例:在包含7个元素的树中查找5,只需2步。
  • 代价:每个节点需要存储两个指针,内存使用约为数组的3倍。

搜索代码(递归)

bool search(int number, node *tree) {
    if (tree == NULL) {
        return false;
    }
    else if (number < tree->number) {
        return search(number, tree->left);
    }
    else if (number > tree->number) {
        return search(number, tree->right);
    }
    else {
        return true;
    }
}
  • 理解:递归完美匹配BST的递归结构——左子树和右子树本身就是更小的树。

退化问题

[2500.1s] 如果插入顺序不当(例如1,2,3,4...),BST会退化为一个“长条”链表,运行时间退化为O(n)。

  • 解决方案:平衡树(如AVL树、红黑树),在插入/删除后重新调整结构,但会增加额外时间。
  • 思考:这就是权衡——为了保持O(log n),必须付出额外维护成本。

七、哈希表(Hash Table)

[2650.1s] 哈希表是“数组+链表”的混合体,通过哈希函数将键映射到数组的某个“桶”,每个桶是一个链表,用于解决碰撞。

哈希函数

  • 把无限(或很大)的输入域映射到有限的输出范围(如0~25对应A~Z)。
  • 示例:根据名字首字母返回索引:toupper(name[0]) - 'A',返回0~25。
  • 改进:使用 const 和 unsigned int 增强安全性,防止误修改和负数索引。

哈希表结构

node *hash_table[26];  // 26个桶,每个桶是一个链表的头指针
  • 插入:计算哈希值 → 到对应桶 → 在链表中插入(前插)。
  • 查找:计算哈希值 → 到对应桶 → 在链表中线性搜索。

碰撞与运行时间

[2800.1s] 碰撞无法避免(多个名字首字母相同)。如果每个桶的链表平均长度为 n/26,则查找时间为 O(n/26) = O(n)(渐进意义下仍是线性,但实际快26倍)。

  • 改进哈希函数:使用名字前3个字母(如"lin"),减少碰撞,但需要更大数组,浪费更多内存。
  • 权衡:哈希函数越复杂,碰撞越少,但空间开销越大。

考点:哈希表本质是“数组+链表”,通过哈希函数定位桶,桶内用链表解决碰撞。理想情况下接近O(1),但最坏仍为O(n)。


八、Trie:常数时间查找

[2950.1s] Trie(检索树)是一种树形结构,每个节点是一个大小为26的数组(对应26个字母),用于存储字符串,可以实现真正的 O(1) 查找(与数据量无关)。

工作原理

  • 从根节点开始,根据单词的第一个字母选择对应的指针,走到下一个节点。
  • 每个节点代表单词的一个字符,节点中的数组存储指向下一个字符的指针。
  • 用布尔标记(或存储电话号码)表示一个单词在此结束。
  • 示例:存储 "towed" 需要 t→o→w→e→d 五个节点,每个节点中对应字母的指针非空。

优点

  • 查找时间固定为单词长度(通常有上限,如20个字符),因此视为常数时间 O(1)。
  • 支持前缀匹配、自动补全。

缺点

  • 极其浪费空间:每个节点有26个指针,但大多数为空。
  • 理解:这是“用空间换时间”的极致——为了常数时间,必须牺牲大量内存。

九、现实世界应用与总结

[3300.1s] 数据结构无处不在:

  • Sweetgreen餐厅:用哈希表按名字首字母分区存放外卖沙拉(A-E, F-J, K-N, O-Z),但也会出现“桶溢出”的问题,所以他们调整了桶的大小和数量。
  • 问题集5:实现拼写检查器,加载10万+英文单词,要求高效且省内存,可对比不同数据结构的性能。

总结

  • 数组:连续、快、支持二分查找,但扩容困难。
  • 链表:动态、易插入/删除,但搜索慢。
  • 二叉搜索树:平衡时O(log n),但可能退化且占用更多空间。
  • 哈希表:平均接近O(1),但碰撞和空间开销需权衡。
  • Trie:常数时间,但空间浪费极大。

  • 核心:没有完美的数据结构,只有最适合特定场景的结构。程序员需要在时间、空间、实现复杂度之间做出选择。


关键标签/关键词

数据结构 队列 栈 FIFO LIFO 字典 键值对 数组 malloc realloc 内存泄漏 链表 二叉搜索树 哈希表 哈希函数 Trie 运行时间 大O记号 权衡 CS50


生成时间:2026-09-02 16:16:01 生成平台:VideoNote