CC 咖啡猫的工作空间 Coding Space

数据结构与算法之美 — 学习笔记

来源:极客时间专栏《数据结构与算法之美》— 王争(前 Google 工程师) 专栏共 81 讲,本文涵盖数据结构核心章节(约 25 讲),按知识点分类整理。


📖 专栏章节速查

章节 标题 类别
03 复杂度分析(上) ⚡ 基础
04 复杂度分析(下) ⚡ 基础
05 数组:为什么很多编程语言中数组都从 0 开始编号? 🧱 线性表
06 链表(上):如何实现 LRU 缓存淘汰算法? 🧱 线性表
07 链表(下):如何轻松写出正确的链表代码? 🧱 线性表
08 栈:如何实现浏览器的前进和后退功能? 🧱 线性表
09 队列:队列在线程池等有限资源池中的应用 🧱 线性表
17 跳表:为什么 Redis 一定要用跳表来实现有序集合? 🌲 跳表
18 散列表(上):Word 单词拼写检查功能是如何实现的? 🔑 哈希
19 散列表(中):如何打造一个工业级水平的散列表? 🔑 哈希
20 散列表(下):为什么散列表和链表经常会一起使用? 🔑 哈希
23 二叉树基础(上):什么样的二叉树适合用数组来存储? 🌳 树
24 二叉树基础(下):有了散列表,为什么还需要二叉树? 🌳 树
25 红黑树(上):为什么工程中都用红黑树这种二叉树? 🌳 树
26 红黑树(下):掌握这些技巧,你也可以实现一个红黑树 🌳 树
28 堆和堆排序:为什么说堆排序没有快速排序快? 📦 堆
29 堆的应用:如何快速获取到 Top 10 最热门的搜索关键词? 📦 堆
30 图的表示:如何存储微博、微信等社交网络中的好友关系? 🕸️ 图
31 深度和广度优先搜索:如何找出社交网络中的三度好友关系? 🕸️ 图
35 Trie 树:如何实现搜索引擎的搜索关键词提示功能? 🔤 字符串
45 位图:如何实现网页爬虫中的 URL 去重功能? 💾 高级
48 B+ 树:MySQL 数据库索引是如何实现的? 🌳 树

⚡ 复杂度分析(第 03-04 讲)

"数据结构和算法的半壁江山,是学习的精髓。" —— 王争

为什么需要复杂度分析?

把代码跑一遍统计执行时间的方法叫事后统计法,有致命缺陷:

  • 依赖测试环境(不同机器性能差异大)
  • 依赖数据规模(数据量小测不出问题)
  • 不同数据可能导致不同结果

因此需要不依赖测试数据、粗略估算执行效率的方法。

大 O 复杂度表示法

T(n) = O(f(n))
  • T(n):代码执行时间
  • n:数据规模的大小
  • f(n):每行代码执行次数总和
  • O 表示执行时间与 f(n) 成正比

大 O 不表示具体执行时间,而是代码执行时间随数据规模增长的变化趋势(渐进时间复杂度)。

时间复杂度分析三法则

法则 说明
1. 只关注循环执行次数最多的一段代码 常量级、低阶和系数在大 O 中忽略
2. 加法法则:总复杂度 = 量级最大的那段代码的复杂度 O(n²) + O(n) = O(n²)
3. 乘法法则:嵌套代码的复杂度 = 内外复杂度的乘积 O(n) × O(n) = O(n²)

常见复杂度量级

复杂度 名称 典型场景
O(1) 常数级 数组下标访问、散列表查找
O(logn) 对数级 二分查找、平衡二叉树
O(n) 线性级 遍历数组、链表
O(nlogn) 线性对数级 快排、归并排序
O(n²) 平方级 冒泡排序、双重循环
O(n³) 立方级 三重循环、Floyd 算法
O(2ⁿ) 指数级 递归求斐波那契(无记忆化)
O(n!) 阶乘级 全排列

效率排序O(1) > O(logn) > O(n) > O(nlogn) > O(n²) > O(n³) > O(2ⁿ) > O(n!)

空间复杂度

表示存储空间与数据规模之间的增长关系。常见的有 O(1)、O(n)、O(n²)。

四个复杂度分析维度(第 04 讲)

维度 含义 举例
最好情况 最理想下的复杂度 要查找的元素正好是数组第一个元素 → O(1)
最坏情况 最糟糕下的复杂度 要查找的元素在数组最后或不存在 → O(n)
平均情况 加权平均下的复杂度 假设元素在每个位置概率相同 → O(n)
均摊时间复杂度 特殊的平均,一次耗时操作后紧接着多次耗时少的操作 数组动态扩容:大部分 O(1),偶尔 O(n) → 均摊 O(1)

均摊分析要点

  • 大部分情况下时间复杂度很低(如 O(1)),个别情况较高(如 O(n))
  • 操作之间存在连贯的时序关系
  • 可将高复杂度操作均摊到低复杂度操作上
  • 一般均摊时间复杂度等于最好情况时间复杂度(相当于最佳实践情况)

复杂度分析在面试中的重要性

"只掌握数据结构和算法的特点用法,而没学会复杂度分析,相当于只知道操作口诀,没掌握心法。心法了然于胸,才能无招胜有招。"


🧱 一、数组(第 05 讲)

定义

数组(Array)是一种线性表数据结构,用一组连续的内存空间,存储一组具有相同类型的数据。

三个关键字:

关键词 含义
线性表 数据排成一条线,每个数据最多只有前后两个方向
连续内存 支持随机访问,但也导致插入/删除低效
相同类型 每个元素占用相同大小的内存空间

随机访问

寻址公式:

a[i]_address = base_address + i * data_type_size
  • 通过下标随机访问:O(1)
  • ⚠️ 纠错:数组的查找操作并不是 O(1)!排好序的数组用二分查找也是 O(logn)

多维数组寻址

// 二维 m×n
a[i][j] = base_address + (i * n + j) * type_size

为什么数组从 0 开始编号?

效率原因(本质):从 1 开始编号,寻址公式变为 base_address + (i - 1) * type_size,每次随机访问多一次减法指令。数组是最基础的数据结构,效率优化要做到极致。

历史原因:C 语言用 0 开始,Java、JavaScript 效仿以降低学习成本。并非所有语言都从 0 开始(Matlab 从 1 开始,Python 支持负数下标)。

本质理解:"下标"最确切的定义是偏移(Offset)——a[0] = 偏移为 0 = 首地址。

插入和删除的低效性

操作 最好 最坏 平均
插入 O(1)(末尾) O(n)(开头) O(n)
删除 O(1)(末尾) O(n)(开头) O(n)

插入优化技巧:如果数组仅作为存储集合(不关心顺序),可将第 k 位原数据移到末尾,新元素放入第 k 位 → O(1)。这也是快排中用到的思想。

删除优化技巧:先标记已删除,不立即搬移数据,等空间不足时再统一清理。这正是 JVM 标记-清除 GC 的核心思想!

容器 vs 数组

场景 推荐 原因
业务开发、大小动态变化 ArrayList(容器) 封装操作细节,自动扩容
性能敏感、基本类型 数组 避免装箱/拆箱,减少一层封装
大小已知、操作简单 数组 不需要容器的丰富 API
多维数组 数组 int[][]ArrayList<ArrayList<Integer>> 直观
底层开发(网络框架等) 数组 性能做到极致

🧱 二、链表(第 06-07 讲)

数组 vs 链表核心对比

特性 数组 链表
内存 需要连续的内存空间 通过指针串联零散的内存块
随机访问 O(1) O(n)
插入/删除 O(n)(需要搬移数据) O(1)(修改指针即可)
CPU 缓存 友好(连续存储可预读) 不友好(非连续,无法预读)
扩容 耗时,需搬移数据 天然支持动态扩容
内存消耗 紧凑 每个节点额外存储指针

链表类型

1. 单链表(Singly Linked List)

  • 结点(Node):存储数据 + 后继指针 next
  • 头结点(Head):第一个节点,记录链表基地址
  • 尾结点(Tail):最后一个节点,next 指向 NULL

2. 循环链表(Circular Linked List)

尾结点的 next 指向头结点,形成环。适合处理具有环形结构特点的数据(如约瑟夫问题)。

3. 双向链表(Doubly Linked List)

每个节点有两个指针:next(后继)和 prev(前驱)。

删除场景 单链表 双向链表
删除"值等于某个给定值"的节点 O(n)(遍历查找) O(n)(遍历查找)
删除给定指针指向的节点 O(n)(需要找到前驱) O(1)(有 prev 指针)

空间换时间的经典应用。Java LinkedHashMap 底层用双向链表实现。

4. 双向循环链表

双向链表 + 循环链表的结合体。

链表实现 LRU 缓存淘汰算法

三种常见缓存淘汰策略

策略 全称 含义
FIFO First In, First Out 先进先出
LFU Least Frequently Used 最少使用
LRU Least Recently Used 最近最少使用(最常用)

LRU 实现思路(有序单链表):

维护一个有序单链表,越靠近尾部的节点是越早被访问的:

1. 如果数据之前已在链表中:
   → 找到对应节点,从原位置删除
   → 插入到链表头部

2. 如果数据不在链表中:
   - 缓存未满 → 直接插入链表头部
   - 缓存已满 → 删除尾部节点,新数据插入头部

复杂度:O(n)(纯链表)。优化:引入散列表 → LinkedHashMap → 全部 O(1)。

写好链表代码的六大技巧(第 07 讲)

  1. 理解指针/引用的含义:指针存储的是所指对象的内存地址
  2. 警惕指针丢失:插入操作先让新节点指向后续节点,再修改前驱指针
  3. 利用哨兵简化实现:哨兵(Sentinel)是解决边界问题的虚拟节点,消除头尾特殊处理
  4. 重点留意边界条件:链表为空、只有一个节点、只有两个节点、处理头尾节点
  5. 举例画图辅助思考:可视化指针操作
  6. 多写多练,没有捷径

5 个必练的经典链表操作:单链表反转、链表中环的检测、两个有序链表合并、删除链表倒数第 n 个节点、求链表的中间节点。


🧱 三、栈(第 08 讲)

定义

栈是一种操作受限的线性表,只允许在一端插入和删除数据。后进先出(LIFO)

操作 说明 时间复杂度
push() 入栈 O(1)
pop() 出栈 O(1)
  • 顺序栈:用数组实现
  • 链式栈:用链表实现

核心应用:浏览器前进后退

使用两个栈 X 和 Y

操作 栈 X(后退栈) 栈 Y(前进栈)
首次浏览 a → b → c 压入 a, b, c
点击后退 弹出 c 压入 c
再次后退 弹出 b 压入 b
点击前进 从 Y 弹出 b,压入 X b 出栈
中途打开新页面 d 压入 d 清空 Y(无法前进!)

当栈 X 为空 → 无页面可后退;栈 Y 为空 → 无页面可前进。中途打开新页面清空 Y,这正是浏览器的精妙之处。

其他经典应用

场景 说明
函数调用栈 进入函数时压入栈帧(临时变量等),返回时弹出
表达式求值 编译器用两个栈(操作数栈 + 运算符栈)计算四则运算
括号匹配 左括号压栈,右括号与栈顶匹配弹出,栈为空则合法

为什么要用栈而不是数组/链表?

数组或链表暴露了太多操作接口,使用灵活但也更不可控。栈是对特定场景的抽象——限制操作让逻辑更清晰安全。


🧱 四、队列(第 09 讲)

定义

队列是一种操作受限的线性表,只允许在队尾插入、在队头删除。先进先出(FIFO)

操作 说明
enqueue() 入队(放尾部)
dequeue() 出队(从头部取)

顺序队列的关键问题:数据搬移

用数组实现时,headtail 指针不断后移。当 tail 到末尾时,即使前面有空位也无法插入。

优化:不每次出队搬移,而是 tail==n && head!=0 时,入队时集中搬移一次。出队 O(1),入队均摊 O(1)。

循环队列(重点)

将数组首尾相连成环,避免数据搬移。

队空判断:head == tail
队满判断:(tail + 1) % n == head  // 会浪费一个位置
// 入队
items[tail] = item;
tail = (tail + 1) % n;

// 出队
ret = items[head];
head = (head + 1) % n;

高级队列类型

类型 特点 本质
阻塞队列 (Blocking Queue) 队空取阻塞、队满插阻塞 生产者-消费者模型
并发队列 (Concurrent Queue) 线程安全的队列 简单方式加锁;高效方式用 CAS + 循环队列

核心应用:线程池中的请求排队

当线程池没有空闲线程时,两种处理策略:

策略 实现 适用场景
非阻塞 直接拒绝请求 需要快速失败
阻塞排队 队列存储请求,FIFO 希望公平处理

链式 vs 顺序队列的选择:链表实现(无界队列)支持无限排队但响应时间不可控;数组实现(有界队列)大小有限但响应时间敏感系统适合。

核心思想:队列可应用在任何有限资源池中用于请求排队——数据库连接池、线程池、消息队列等。


🌲 五、跳表(第 17 讲)

核心思想

跳表 = 链表 + 多级索引,是"空间换时间"的经典应用。

二分查找依赖数组随机访问,无法直接用于链表。如果对有序链表建立多级索引,就能实现类似"二分"的查找效率——这就是跳表。

做法:每两个节点提取一个到上一级形成索引层,索引节点有 down 指针指向下一级对应节点。多层索引叠加 = 跳表。

时间复杂度:O(logn)

推导过程(每 2 个节点抽 1 个):

索引层级 节点个数
原始链表 n
第 1 级索引 n/2
第 2 级索引 n/4
第 k 级索引 n/(2ᵏ)

设最高级有 2 个节点:n/(2ʰ) = 2h = log₂n - 1,跳表高度 = log₂n

为什么每层最多遍历 3 个节点? 在第 k 级找到 y < x < z,下降到第 k-1 级后,y 和 z 之间最多只有 3 个节点。

总时间复杂度 = O(3 × logn) = O(logn),与二分查找一致。

空间复杂度:O(n)

索引节点总和:n/2 + n/4 + n/8 + ... + 8 + 4 + 2 = n - 2O(n)

优化:每 3 个或 5 个节点抽 1 个做索引,空间降到约 n/2,仍为 O(n)。

实际中不必太在意——索引节点只存 key 和指针,数据节点可能存大对象,额外空间可忽略。

动态更新:随机函数维护平衡

不断插入而不更新索引 → 某两节点间数据越来越多 → 退化到 O(n)

解决方案:通过随机函数生成值 K,将新节点插入到第 1 级到第 K 级的 K 级索引中。

为什么 Redis 用跳表实现有序集合而不用红黑树?

操作 红黑树 跳表
查找/插入/删除 O(logn) O(logn)
有序迭代输出 O(logn) O(logn)
按区间查找 ❌ 效率低(需中序遍历) ✅ O(logn) 定位起点,顺序遍历

三大原因

  1. 区间查找效率高:O(logn) 定位起点,在原始链表顺序遍历
  2. 实现更简单:比红黑树的旋转、变色好懂好写
  3. 更加灵活:可通过改变索引构建策略,灵活平衡效率和内存消耗

🔑 六、散列表 / 哈希表(第 18-20 讲)

定义

散列表 = 数组 + 散列函数,本质是数组的扩展。

利用数组按下标随机访问 O(1) 的特性,通过 hash(key) 将 key 映射为数组下标,存储键值对(key-value)

散列函数设计要求

要求 说明
非负整数 散列值必须是 ≥0 的整数
一致性 key1 = key2 → hash(key1) = hash(key2)
均匀性 key1 ≠ key2 → 尽量 hash(key1) ≠ hash(key2)(无法完美实现)

⚠️ 第三点几乎无法完美实现,MD5、SHA、CRC 也存在冲突。散列冲突不可避免。

散列冲突的两种解决方法

方法一:开放寻址法(Open Addressing)

冲突了就去找下一个空闲位置

探测方式 探测序列
线性探测 hash(key)+0, +1, +2, …
二次探测 hash(key)+0, +1², +2², …
双重散列 依次使用 hash1(key), hash2(key), …

删除时不能直接置空,要标记为 deleted,否则查找时会误判"不存在"。

方法二:链表法(Chaining)

每个数组槽位(桶/bucket)指向一条链表。冲突元素追加到对应链表中。插入 O(1),查找/删除 O(k),k = n/m。

装载因子(Load Factor)

装载因子 = 填入表中的元素个数 / 散列表的长度
  • 越大 → 空闲越少 → 冲突越多 → 性能越差
  • 一般经验:装载因子 > 0.7 时考虑扩容

工业级散列表(第 19 讲)

核心要求:快速查询插入删除 + 内存占用合理 + 性能稳定(极端不退化到 O(n))。

动态扩容

方式 做法 问题
低效 一次性全量迁移 某次插入 O(n)
高效(分批迁移) 每次插入新数据时顺便搬一个老数据 均摊 O(1)

查询时:先查新表,再查老表

HashMap 参数(JDK)

参数
默认初始大小 16
装载因子阈值 0.75
扩容倍数 2 倍
链表转红黑树阈值 ≥ 8
红黑树退回链表阈值 ≤ 6
// HashMap 散列函数
int hash(Object key) {
    int h = key.hashCode();
    return (h ^ (h >>> 16)) & (capacity - 1);
}

开放寻址法 vs 链表法

方法 优点 缺点 适用场景
开放寻址法 纯数组存储,CPU 缓存友好,易序列化 删除麻烦,装载因子不能大 数据量小(如 Java ThreadLocalMap
链表法 内存利用率高,可转红黑树 CPU 缓存不友好,指针占额外内存 大数据量(如 Java HashMap

散列表 + 链表组合(第 20 讲)

散列表的天然缺陷:数据被散列函数打乱后无规律存储,无法快速有序遍历。

经典组合

场景 数据结构组合 实现效果
LRU 缓存 散列表 + 双向链表 查找/插入/删除 全部 O(1)
Redis 有序集合 散列表 + 跳表 key 查询 O(1) + score 排序/区间查询 O(logn)
Java LinkedHashMap HashMap + 双向链表 支持按插入顺序/访问顺序遍历

🌳 七、二叉树(第 23-24 讲)

基本概念

概念 定义
根节点 没有父节点的节点
叶子节点 没有子节点的节点
高度(Height) 节点到叶子节点的最长路径(边数),计数起点为 0
深度(Depth) 根节点到该节点的边数,计数起点为 0
层(Level) 与深度类似,但计数起点为 1(根节点在第 1 层)
树的高度 根节点的高度

特殊二叉树

满二叉树(Full Binary Tree):叶子节点全在最底层,除叶子外每个节点都有左右两个子节点。

完全二叉树(Complete Binary Tree)

  • 叶子节点在最底下两层
  • 最后一层的叶子节点都靠左排列
  • 除最后一层,其他层节点数达到最大
  • ⚠️ 满二叉树是完全二叉树的特殊情况

存储方式

链式存储(最常用)

class Node {
    int data;
    Node left;   // 左子节点
    Node right;  // 右子节点
}

数组顺序存储(适合完全二叉树)

假设根节点下标 i = 1

查找对象 公式
节点 i 的左子节点 2 * i
节点 i 的右子节点 2 * i + 1
节点 i 的父节点 i / 2(向下取整)
  • 完全二叉树用数组存储最省内存(无需额外指针)
  • ❌ 非完全二叉树会浪费大量数组空间
  • 💡 堆(Heap)就是完全二叉树,最常用的存储方式就是数组

二叉树的遍历

前、中、后序表示的是「节点本身」与「左右子树」之间的打印顺序:

遍历方式 顺序 递推公式
前序 根 → 左 → 右 print r → pre(r.left) → pre(r.right)
中序 左 → 根 → 右 in(r.left) → print r → in(r.right)
后序 左 → 右 → 根 post(r.left) → post(r.right) → print r

时间复杂度:每个节点最多访问两次,O(n)

二叉查找树(BST, Binary Search Tree)

定义:树中任意一个节点,左子树中每个节点的值 < 该节点的值 < 右子树中每个节点的值

核心操作

操作 复杂度 说明
查找 O(height) 从根开始,小于去左、大于去右
插入 O(height) 新数据一般插入到叶子节点
删除 O(height) 分三种情况(见下)
中序遍历 O(n) 输出有序序列

删除操作的三种情况

情况 处理方法
无子节点 直接将父节点指针置为 null
只有一个子节点 更新父节点指针,指向该子节点
有两个子节点 找到右子树最小节点替换,再删除该最小节点

💡 取巧方法:不真正删除,标记为"已删除"(Lazy Deletion)。省事但浪费内存。

支持重复数据

方法 做法
方法一 每个节点存储链表/数组,相同值放同一节点
方法二 相等值插入右子树(当作大于处理),查找时遇到相等值继续向右

时间复杂度

BST 的插入、删除、查找时间复杂度与树的高度成正比 = O(height)

  • 完全二叉树高度 ≤ log₂n → 最佳 O(logn)
  • 退化为链表 → 最差 O(n)
  • 因此需要平衡二叉查找树来避免退化

BST vs 散列表

对比维度 平衡 BST 散列表
查找/插入/删除 O(logn) 稳定 O(1) 但不稳定
数据有序性 ✅ 中序遍历有序 O(n) ❌ 无序,需额外排序
性能稳定性 ✅ 平衡后很稳定 ❌ 冲突时性能下降
扩容代价 (需全量迁移)
实现复杂度 平衡性一个核心问题 散列函数、冲突解决、扩容缩容等
实际速度 O(logn) 常数因子小 O(1) 常数因子较大(哈希计算耗时)

两者各有优势,互补而非替代。


🌳 八、红黑树(第 25-26 讲)

什么是平衡二叉查找树?

严格定义:任意一个节点的左右子树高度相差不能大于 1。AVL 树是严格符合该定义的平衡二叉查找树。

红黑树不严格符合平衡定义的平衡二叉查找树,只需要满足 5 条规则。

红黑树的 5 条规则

规则 内容
每个节点不是红色就是黑色
根节点是黑色
每个叶子节点(NIL)都是黑色的空节点
红色节点不能相邻(红色节点子节点必须是黑色)
每个节点到所有可达叶子节点路径上,黑色节点数量相同

为什么红黑树是近似平衡的?

  • 抹掉所有红色节点 → "黑树"高度 ≈ log₂n
  • 红色节点不连续 → 每层黑色间最多夹一层红色
  • 红黑树高度 ≈ 2log₂n,仅比 AVL(log₂n)高一倍

为什么工程中更爱用红黑树而不用 AVL?

  • AVL 高度平衡,查询效率略高
  • 插入/删除时 AVL 需要频繁调整,维护成本高
  • 红黑树是性能和维护成本的折中 → 工业首选
  • Treap、Splay Tree 可能极端退化,不适合对单次操作敏感的场景

两个核心操作:左旋与右旋

操作 说明
左旋 围绕某节点,将其右子节点"吊起来"变成父节点,自己变左子节点
右旋 围绕某节点,将其左子节点"吊起来"变成父节点,自己变右子节点

旋转前后 BST 性质不变。

插入操作的平衡调整

规定:新插入的节点必须是红色,且放在叶子位置。

特殊情况 处理
插入的是根节点 直接变黑色
父节点是黑色 什么都不用做

其他情况分为 CASE 1~3:

情况 条件 操作
CASE 1 叔叔节点是红色 父+叔变黑,祖父变红;关注节点上移到祖父
CASE 2 叔叔黑色 + 关注节点是父的右子节点 关注节点变为父节点,围绕新关注节点左旋,跳到 CASE 3
CASE 3 叔叔黑色 + 关注节点是父的左子节点 围绕祖父右旋,父和兄弟交换颜色,调整结束

学习建议

"对于绝大部分开发工程师来说,这辈子你可能都用不着亲手写一个红黑树。" —— 王争

三点学习建议:

  1. 像玩魔方一样:记住固定步骤,不必深究正确性证明
  2. 盯住关注节点:调整过程中关注节点不断变化
  3. 插入简单删除复杂:删除分两次调整(先保证黑节点数,再消除相邻红节点)

📦 九、堆(第 28-29 讲)

定义

堆是一种特殊的树,满足两个条件:

  1. 堆是一个完全二叉树
  2. 堆中每个节点的值都必须 ≥ 或 ≤ 其子树中每个节点的值
类型 条件 堆顶
大顶堆 每个节点 ≥ 子树中每个节点 最大值
小顶堆 每个节点 ≤ 子树中每个节点 最小值

⚠️ 与 BST 的区别:BST 左<根<右,用于查找;堆左右无大小之分,主要用于排序和优先级。

堆的存储

用数组存储(完全二叉树的优势):

假设数组从下标 1 开始:

左子节点 = i * 2
右子节点 = i * 2 + 1
父节点   = i / 2

堆的核心操作(O(logn))

插入元素 — 自下往上堆化

新元素放数组末尾 → 与父节点比较 → 不满足堆特性则交换 → 重复直到满足。

删除堆顶 — 自上往下堆化

最后一个节点放到堆顶 → 从堆顶开始与较大(或较小)的子节点交换 → 重复直到满足。

堆排序 O(nlogn)

分两步:

第一步:建堆(O(n))

n/21 的非叶子节点,依次自上往下堆化。

第二步:排序(O(nlogn))

public static void sort(int[] a, int n) {
    buildHeap(a, n);       // 建堆 O(n)
    int k = n;
    while (k > 1) {
        swap(a, 1, k);     // 堆顶与末尾交换
        --k;
        heapify(a, k, 1);  // 重新堆化 O(logn)
    }
}

特点:原地排序(O(1) 额外空间)、不稳定排序、时间复杂度很稳定。

为什么快排比堆排序更受欢迎?

对比 快速排序 堆排序
数据访问方式 顺序访问,CPU 缓存友好 跳着访问(1→2→4→8),缓存不友好
数据交换次数 较少(不超过逆序度) 较多(建堆打乱原有有序度)

堆的三大应用(第 29 讲)

1. 优先级队列

堆是优先级队列的天然实现。出队顺序按优先级而非 FIFO。

场景:合并有序小文件(100 个有序文件合并为 1 个,用小顶堆每次取最小);高性能定时器(小顶堆存任务,堆顶即最早执行的任务,无需轮询)。

2. 求 Top K

静态数据:维护大小为 K 的小顶堆,遍历数组,元素 > 堆顶则替换堆顶并堆化。O(nlogK)。

动态数据(实时):始终维护大小为 K 的小顶堆,新数据到即与堆顶比较决定是否入堆。

3. 求中位数 / 百分位数

维护两个堆

存储 作用
大顶堆 前半部分数据(较小的一半) 堆顶 = 中位数
小顶堆 后半部分数据(较大的一半) 与大顶堆配合

中位数:大顶堆堆顶即为中位数。 99% 响应时间:大顶堆存 99% 数据,堆顶即为 99 百分位值。

面试题:10 亿搜索关键词求 Top 10(单机 1GB 内存)

1. 哈希分片:10亿关键词通过哈希分到10个文件(每个~1亿)
2. 统计频率:每个文件用散列表统计词频
3. Top10堆:每个文件用大小为10的小顶堆求Top10
4. 合并:10个Top10(共100个词)再求一次Top10

🕸️ 十、图(第 30-31 讲)

基本概念

图是一种非线性数据结构,由顶点(Vertex)边(Edge) 组成。

分类 说明 示例
无向图 边无方向,连接是对称的 微信好友关系
有向图 边有方向,分入度/出度 微博关注关系
带权图 边上带有权重值 QQ 好友亲密度

:与该顶点相连的边的条数(无向图)。 入度/出度:指向该点的边数 / 从该点出发的边数(有向图)。

图的存储方式

邻接矩阵(Adjacency Matrix)

底层是二维数组 A[i][j]

  • 无向图:有边则 A[i][j] = A[j][i] = 1
  • 有向图:i→j 有边则 A[i][j] = 1
  • 加权图:存储权重值
优点 缺点
存储简单,下标访问 O(1) 浪费空间
方便矩阵运算(如 Floyd) 稀疏图大部分空间浪费
快速判断两顶点是否有边 内存占用 = V²

邻接表(Adjacency Table)

每个顶点对应一个链表,存储与该顶点相连的顶点。

优点 缺点
节省存储空间 查找效率低
适合稀疏图 判断两顶点关系需遍历链表

优化:链表替换为红黑树、跳表、散列表等高效动态数据结构。

逆邻接表

用于有向图,存储"谁关注了我",常用于社交网络双向查询。

广度优先搜索(BFS)

思路:地毯式层层推进,先查找离起点最近的,然后是次近的。

  • 辅助数据结构:队列
  • BFS 找到的路径是 s 到 t 的最短路径(边数最少)
  • 时间复杂度(邻接表):O(V + E),对于连通图简化为 O(E)
  • 空间复杂度:O(V)(visited + queue + prev)

深度优先搜索(DFS)

思路:沿一个方向走到底,走不通则回溯到上一个分岔口继续尝试。

  • 辅助数据结构:递归 + 回溯(递归调用栈)
  • DFS 找到的路径不一定是最短路径
  • 时间复杂度(邻接表):O(E)(每条边最多被访问两次)
  • 空间复杂度:O(V)(visited + prev + 递归栈)

BFS vs DFS

维度 BFS DFS
数据结构 队列(Queue) 递归栈(Stack)
遍历方式 层序遍历,层层扩散 先序遍历,一条路走到底
找到的路径 最短路径 不一定最短
时间复杂度 O(E) O(E)
空间复杂度 O(V) O(V)
典型应用 最短路径、三度好友 连通性检测、走迷宫、拓扑排序

🔤 十一、Trie 树(第 35 讲)

定义

Trie 树(字典树) 是专门处理字符串匹配的树形结构,利用字符串之间的公共前缀来合并重复部分。

别名字典树(Dictionary Tree)前缀树(Prefix Tree)

核心思想

将 6 个字符串 how, hi, her, hello, so, see 构建成 Trie 树:

          root
         /    \
        h      s
       / \      \
      o   i      o
     /     \      \
    w       r      e
    |       |      |
   (how)   e→(her) e→(see)
            |
            l
            |
            l
            |
            o→(hello)
  • 根节点不包含信息
  • 每个节点表示字符串中的一个字符
  • 红色节点isEndingChar = true)表示存在完整字符串

时间复杂度

操作 复杂度 说明
构建 O(n) n = 所有字符串长度之和
查找 O(k) k = 查找的字符串长度(与集合大小无关!)

内存消耗与优化

Trie 树非常耗内存。每个节点存储 26 长度的数组,每个元素 8 字节指针:

  • 每个节点额外开销:26 × 8 = 208 字节
  • 如果包含大写字母、数字、中文,需求更大

优化方案:有序数组(二分查找)、跳表、散列表、红黑树取代子节点数组;缩点优化(单子节点合并)。

Trie 树 vs 散列表/红黑树

维度 Trie 树 散列表/红黑树
字符集要求 不能太大 无限制
前缀重合要求 必须较多 无要求
工程实现 需自己实现 现成类库
最佳场景 前缀匹配查找 精确匹配查找

搜索引擎关键词提示原理

  1. 将热门搜索关键词库构建成 Trie 树
  2. 用户输入字符时,将该字符作为前缀子串在 Trie 树中匹配
  3. 返回以该前缀开头的所有关键词展示在提示框中

实际工程挑战:中文编码、关键词排序(热门度)、拼写纠错。


🌳 十二、B+ 树(第 48 讲)

问题背景

数据库索引需要同时满足:单值查找 + 区间查找 + 高效 + 省内存

数据结构 单值查找 区间查找 结论
散列表 O(1) ✅ ❌ 不支持 不满足
平衡 BST O(logn) ✅ ❌ 不支持(需中序遍历) 不满足
跳表 O(logn) ✅ ✅ 支持 理论可行
B+ 树 O(logn) ✅ ✅ 支持(链表串联叶子) 最优解

从二叉树到 B+ 树的演化

  1. 改造 BST 支持区间查找:节点不存数据只做索引,叶子节点用链表串联
  2. 解决内存占用:索引存磁盘 → 但磁盘 IO 极慢(毫秒 vs 纳秒)→ 树的高度 = IO 次数 → 优化核心:降低高度
  3. 多叉树降低高度:二叉树存 16 个数据 → 高度 4 → 4 次 IO。五叉树 → 高度 2 → 2 次 IO。100 叉树存 1 亿数据 → 高度仅 3 → 最多 3 次 IO!
  4. m 的选择:让每个节点大小正好 = 一页(4KB),读取一个节点只需一次 IO

B+ 树的核心特点

  1. 每个节点子节点数:m/2 ≤ n ≤ m(根节点例外)
  2. 非叶子节点只存索引,不存数据(类似跳表)
  3. 叶子节点用双向链表串联 → 支持区间查找和升降序
  4. 根节点常驻内存,其他存磁盘
  5. 叶子节点存的是键值 + 数据地址(或完整数据)

插入与删除

  • 插入 → 节点分裂:子节点超 m 个→一分为二→可能级联分裂至根
  • 删除 → 节点合并:子节点< m/2 →与相邻兄弟合并→合并后超 m 再分裂

B 树 vs B+ 树

维度 B 树 B+ 树
节点存储 非叶子节点也存数据 非叶子只存索引
叶子节点 无链表串联 链表串联
区间查找 ❌ 不支持 ✅ 支持
查询稳定性 不稳定(可能在内部命中) 稳定(必须到叶子节点)
存储效率 较低 更高

💾 十三、位图 & 布隆过滤器(第 45 讲)

问题场景

10 亿个 URL 需要判重,散列表方案至少需 100GB+ 内存(不可接受)。需要更省内存的方案。

位图(BitMap)

原理:用1 个二进制位(bit) 表示一个数字是否存在。数字本身作为数组下标。

public class BitMap {
    private char[] bytes;  // char占2字节=16bit

    public void set(int k) {
        int byteIndex = k / 16;
        int bitIndex = k % 16;
        bytes[byteIndex] |= (1 << bitIndex);  // 置1
    }

    public boolean get(int k) {
        int byteIndex = k / 16;
        int bitIndex = k % 16;
        return (bytes[byteIndex] & (1 << bitIndex)) != 0;
    }
}
  • ✅ 访问效率极高,内存节省(1 亿个数约 12MB)
  • ❌ 数字范围很大时内存反增(1~10 亿需 120MB)

布隆过滤器(Bloom Filter)

核心思想

  • 固定大小位图
  • K 个哈希函数将每个数据映射到 K 个位图位置
  • 插入:K 个位置全部置 1
  • 查询:K 个位置全为 1 → 可能存在;任意为 0 → 一定不存在

False is always false. True is maybe true.

为什么比散列表快? 布隆过滤器是 CPU 密集型(多次哈希计算),散列表方案是内存密集型(大量字符串匹配),CPU 计算通常比内存访问更快。

空间对比(10 亿 URL)

方案 所需内存
散列表 100GB+
布隆过滤器(10 倍位图) ~1.2GB

实际应用

  1. 爬虫 URL 去重(稍许误判可容忍)
  2. Redis 缓存穿透防护:先过滤不存在的数据
  3. 大型网站 UV 统计
  4. 垃圾邮件过滤
  5. 短网址冲突检测

相关实现

  • Java:BitSet(位图)、Google Guava BloomFilter
  • Redis:BitMap 位图类
  • 自动扩容:数据量超阈值 → 新建位图(查询需查多个位图)

🎯 核心总结:数据结构全景图

数据结构全景
├── 线性表
│   ├── 数组 ──── 连续内存,随机访问O(1),插入/删除O(n)
│   ├── 链表 ──── 离散内存,插入/删除O(1),随机访问O(n)
│   ├── 栈 ──── LIFO,浏览器前进后退,函数调用栈,括号匹配
│   └── 队列 ──── FIFO,线程池排队,消息队列,资源池
│
├── 哈希结构
│   └── 散列表 ──── O(1)查找,空间换时间,装载因子+冲突解决
│       ├── 拉链法 → HashMap (Java)
│       └── 开放寻址 → ThreadLocalMap (Java)
│
├── 树结构
│   ├── 二叉树 ──── 遍历 O(n),三种方式(前中后序)
│   ├── 二叉查找树 ──── 查找 O(logn)~O(n),支持有序输出
│   ├── 红黑树 ──── 工程首选平衡BST,高度≈2log₂n
│   ├── 堆 ──── 完全二叉树+数组存储,TopK O(nlogK),中位数
│   ├── B+树 ──── 多叉树,MySQL索引核心,≤3次IO查1亿数据
│   └── Trie树 ──── 前缀树,搜索引擎补全 O(k),空间换时间
│
├── 概率/索引结构
│   └── 跳表 ──── 链表+多级索引,O(logn)查找,Redis ZSet核心
│
├── 图结构
│   └── 图 ──── 邻接矩阵/邻接表存储,BFS(最短路径) + DFS(回溯)
│
└── 大数据结构
    ├── 位图 ──── 1bit标记存在,1亿数≈12MB
    └── 布隆过滤器 ──── K个哈希+位图,判定不存在100%准确

🧠 设计思想总结

思想 典型案例
空间换时间 散列表、跳表、Trie树、双向链表
时间换空间 单链表(内存紧张时替代双向链表)
升维(加索引) 跳表(链表→多级索引)
降维(减少高度) B+树(二叉树→100+叉树,减少IO)
分治哈希 TopK大数据(哈希分片+散列表+堆)
概率判重 布隆过滤器(牺牲精确性换空间)
操作限制(封装抽象) 栈/队列(限制操作=更安全清晰)
均摊分析 数组动态扩容、循环队列数据搬移
平衡维护 红黑树(旋转+变色)、跳表(随机函数)