mirror of
https://github.com/krahets/hello-algo.git
synced 2025-11-02 21:24:53 +08:00
Update the book based on the revised second edition (#1014)
* Revised the book * Update the book with the second revised edition * Revise base on the manuscript of the first edition
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
# 数组
|
||||
|
||||
「数组 array」是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要术语和概念。
|
||||
「数组 array」是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的「索引 index」。下图展示了数组的主要概念和存储方式。
|
||||
|
||||

|
||||
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
观察上图,链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。
|
||||
|
||||
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
|
||||
- 尾节点指向的是“空”,它在 Java、C++ 和 Python 中分别被记为 $\text{null}$、$\text{nullptr}$ 和 $\text{None}$ 。
|
||||
- 在 C、C++、Go 和 Rust 等支持指针的语言中,上述的“引用”应被替换为“指针”。
|
||||
- 尾节点指向的是“空”,它在 Java、C++ 和 Python 中分别被记为 `null`、`nullptr` 和 `None` 。
|
||||
- 在 C、C++、Go 和 Rust 等支持指针的语言中,上述“引用”应被替换为“指针”。
|
||||
|
||||
如以下代码所示,链表节点 `ListNode` 除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,**链表比数组占用更多的内存空间**。
|
||||
|
||||
@ -451,7 +451,7 @@
|
||||
|
||||
如下图所示,常见的链表类型包括三种。
|
||||
|
||||
- **单向链表**:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 $\text{None}$ 。
|
||||
- **单向链表**:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 `None` 。
|
||||
- **环形链表**:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
|
||||
- **双向链表**:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
|
||||
|
||||
|
||||
@ -4,9 +4,8 @@
|
||||
|
||||
- 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和分散空间存储。两者的特点呈现出互补的特性。
|
||||
- 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
|
||||
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。
|
||||
- 常见的链表类型包括单向链表、环形链表、双向链表,它们分别具有各自的应用场景。
|
||||
- 列表是一种支持增删查改的元素有序集合,通常基于动态数组实现,其保留了数组的优势,同时可以灵活调整长度。
|
||||
- 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、环形链表、双向链表。
|
||||
- 列表是一种支持增删查改的元素有序集合,通常基于动态数组实现,它保留了数组的优势,同时可以灵活调整长度。
|
||||
- 列表的出现大幅地提高了数组的实用性,但可能导致部分内存空间浪费。
|
||||
- 程序运行时,数据主要存储在内存中。数组可提供更高的内存空间效率,而链表则在内存使用上更加灵活。
|
||||
- 缓存通过缓存行、预取机制以及空间局部性和时间局部性等数据加载机制,为 CPU 提供快速数据访问,显著提升程序的执行效率。
|
||||
@ -24,15 +23,15 @@
|
||||
|
||||
!!! question "为什么数组要求相同类型的元素,而在链表中却没有强调同类型呢?"
|
||||
|
||||
链表由节点组成,节点之间通过引用(指针)连接,各个节点可以存储不同类型的数据,例如 int、double、string、object 等。
|
||||
链表由节点组成,节点之间通过引用(指针)连接,各个节点可以存储不同类型的数据,例如 `int`、`double`、`string`、`object` 等。
|
||||
|
||||
相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,数组同时包含 int 和 long 两种类型,单个元素分别占用 4 bytes 和 8 bytes ,此时就不能用以下公式计算偏移量了,因为数组中包含了两种长度的元素。
|
||||
相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,数组同时包含 `int` 和 `long` 两种类型,单个元素分别占用 4 字节 和 8 字节 ,此时就不能用以下公式计算偏移量了,因为数组中包含了两种“元素长度”。
|
||||
|
||||
```shell
|
||||
# 元素内存地址 = 数组内存地址 + 元素长度 * 元素索引
|
||||
```
|
||||
|
||||
!!! question "删除节点后,是否需要把 `P.next` 设为 $\text{None}$ 呢?"
|
||||
!!! question "删除节点后,是否需要把 `P.next` 设为 `None` 呢?"
|
||||
|
||||
不修改 `P.next` 也可以。从该链表的角度看,从头节点遍历到尾节点已经不会遇到 `P` 了。这意味着节点 `P` 已经从链表中删除了,此时节点 `P` 指向哪里都不会对该链表产生影响。
|
||||
|
||||
@ -46,7 +45,7 @@
|
||||
|
||||
该示意图只是定性表示,定量表示需要根据具体情况进行分析。
|
||||
|
||||
- 不同类型的节点值占用的空间是不同的,比如 int、long、double 和实例对象等。
|
||||
- 不同类型的节点值占用的空间是不同的,比如 `int`、`long`、`double` 和实例对象等。
|
||||
- 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。
|
||||
|
||||
!!! question "在列表末尾添加元素是否时时刻刻都为 $O(1)$ ?"
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||

|
||||
|
||||
下图展示了本题的三个约束条件:**多个皇后不能在同一行、同一列、同一对角线**。值得注意的是,对角线分为主对角线 `\` 和次对角线 `/` 两种。
|
||||
下图展示了本题的三个约束条件:**多个皇后不能在同一行、同一列、同一条对角线上**。值得注意的是,对角线分为主对角线 `\` 和次对角线 `/` 两种。
|
||||
|
||||

|
||||
|
||||
|
||||
@ -87,8 +87,8 @@
|
||||
|
||||
请注意,虽然 `selected` 和 `duplicated` 都用于剪枝,但两者的目标不同。
|
||||
|
||||
- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,其作用是防止 `choices` 中的任一元素在 `state` 中重复出现。
|
||||
- **相等元素剪枝**:每轮选择(每个调用的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在本轮遍历(`for` 循环)中哪些元素已被选择过,其作用是保证相等的元素只被选择一次。
|
||||
- **重复选择剪枝**:整个搜索过程中只有一个 `selected` 。它记录的是当前状态中包含哪些元素,其作用是避免某个元素在 `state` 中重复出现。
|
||||
- **相等元素剪枝**:每轮选择(每个调用的 `backtrack` 函数)都包含一个 `duplicated` 。它记录的是在本轮遍历(`for` 循环)中哪些元素已被选择过,其作用是保证相等元素只被选择一次。
|
||||
|
||||
下图展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。
|
||||
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
- 在全排列问题中,如果集合中存在重复元素,则最终结果会出现重复排列。我们需要约束相等元素在每轮中只能被选择一次,这通常借助一个哈希表来实现。
|
||||
- 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起始点,从而将生成重复子集的搜索分支进行剪枝。
|
||||
- 对于子集和问题,数组中的相等元素会产生重复集合。我们利用数组已排序的前置条件,通过判断相邻元素是否相等实现剪枝,从而确保相等元素在每轮中只能被选中一次。
|
||||
- $n$ 皇后问题旨在寻找将 $n$ 个皇后放置到 $n \times n$ 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。
|
||||
- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线上是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。
|
||||
- $n$ 皇后问题旨在寻找将 $n$ 个皇后放置到 $n \times n$ 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和次对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。
|
||||
- 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、次对角线上是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。
|
||||
|
||||
### Q & A
|
||||
|
||||
|
||||
@ -471,12 +471,12 @@
|
||||
return 0
|
||||
|
||||
def loop(n: int):
|
||||
"""循环 O(1)"""
|
||||
"""循环的空间复杂度为 O(1)"""
|
||||
for _ in range(n):
|
||||
function()
|
||||
|
||||
def recur(n: int) -> int:
|
||||
"""递归 O(n)"""
|
||||
"""递归的空间复杂度为 O(n)"""
|
||||
if n == 1: return
|
||||
return recur(n - 1)
|
||||
```
|
||||
|
||||
@ -988,7 +988,7 @@ $$
|
||||
|
||||
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 $1$ 个细胞,分裂一轮后变为 $2$ 个,分裂两轮后变为 $4$ 个,以此类推,分裂 $n$ 轮后有 $2^n$ 个细胞。
|
||||
|
||||
下图和以下代码模拟了细胞分裂的过程,时间复杂度为 $O(2^n)$ 。
|
||||
下图和以下代码模拟了细胞分裂的过程,时间复杂度为 $O(2^n)$ :
|
||||
|
||||
```src
|
||||
[file]{time_complexity}-[class]{}-[func]{exponential}
|
||||
|
||||
@ -13,8 +13,8 @@
|
||||
|
||||
基本数据类型的取值范围取决于其占用的空间大小。下面以 Java 为例。
|
||||
|
||||
- 整数类型 `byte` 占用 $1$ byte = $8$ bits ,可以表示 $2^{8}$ 个数字。
|
||||
- 整数类型 `int` 占用 $4$ bytes = $32$ bits ,可以表示 $2^{32}$ 个数字。
|
||||
- 整数类型 `byte` 占用 $1$ 字节 = $8$ 比特 ,可以表示 $2^{8}$ 个数字。
|
||||
- 整数类型 `int` 占用 $4$ 字节 = $32$ 比特 ,可以表示 $2^{32}$ 个数字。
|
||||
|
||||
下表列举了 Java 中各种基本数据类型的占用空间、取值范围和默认值。此表格无须死记硬背,大致理解即可,需要时可以通过查表来回忆。
|
||||
|
||||
@ -22,25 +22,25 @@
|
||||
|
||||
| 类型 | 符号 | 占用空间 | 最小值 | 最大值 | 默认值 |
|
||||
| ------ | -------- | -------- | ------------------------ | ----------------------- | -------------- |
|
||||
| 整数 | `byte` | 1 byte | $-2^7$ ($-128$) | $2^7 - 1$ ($127$) | $0$ |
|
||||
| | `short` | 2 bytes | $-2^{15}$ | $2^{15} - 1$ | $0$ |
|
||||
| | `int` | 4 bytes | $-2^{31}$ | $2^{31} - 1$ | $0$ |
|
||||
| | `long` | 8 bytes | $-2^{63}$ | $2^{63} - 1$ | $0$ |
|
||||
| 浮点数 | `float` | 4 bytes | $1.175 \times 10^{-38}$ | $3.403 \times 10^{38}$ | $0.0\text{f}$ |
|
||||
| | `double` | 8 bytes | $2.225 \times 10^{-308}$ | $1.798 \times 10^{308}$ | $0.0$ |
|
||||
| 字符 | `char` | 2 bytes | $0$ | $2^{16} - 1$ | $0$ |
|
||||
| 布尔 | `bool` | 1 byte | $\text{false}$ | $\text{true}$ | $\text{false}$ |
|
||||
| 整数 | `byte` | 1 字节 | $-2^7$ ($-128$) | $2^7 - 1$ ($127$) | $0$ |
|
||||
| | `short` | 2 字节 | $-2^{15}$ | $2^{15} - 1$ | $0$ |
|
||||
| | `int` | 4 字节 | $-2^{31}$ | $2^{31} - 1$ | $0$ |
|
||||
| | `long` | 8 字节 | $-2^{63}$ | $2^{63} - 1$ | $0$ |
|
||||
| 浮点数 | `float` | 4 字节 | $1.175 \times 10^{-38}$ | $3.403 \times 10^{38}$ | $0.0\text{f}$ |
|
||||
| | `double` | 8 字节 | $2.225 \times 10^{-308}$ | $1.798 \times 10^{308}$ | $0.0$ |
|
||||
| 字符 | `char` | 2 字节 | $0$ | $2^{16} - 1$ | $0$ |
|
||||
| 布尔 | `bool` | 1 字节 | $\text{false}$ | $\text{true}$ | $\text{false}$ |
|
||||
|
||||
请注意,上表针对的是 Java 的基本数据类型的情况。每种编程语言都有各自的数据类型定义,它们的占用空间、取值范围和默认值可能会有所不同。
|
||||
|
||||
- 在 Python 中,整数类型 `int` 可以是任意大小,只受限于可用内存;浮点数 `float` 是双精度 64 位;没有 `char` 类型,单个字符实际上是长度为 1 的字符串 `str` 。
|
||||
- C 和 C++ 未明确规定基本数据类型大小,而因实现和平台各异。上表遵循 LP64 [数据模型](https://en.cppreference.com/w/cpp/language/types#Properties),其用于包括 Linux 和 macOS 在内的 Unix 64 位操作系统。
|
||||
- C 和 C++ 未明确规定基本数据类型的大小,而因实现和平台各异。上表遵循 LP64 [数据模型](https://en.cppreference.com/w/cpp/language/types#Properties),其用于包括 Linux 和 macOS 在内的 Unix 64 位操作系统。
|
||||
- 字符 `char` 的大小在 C 和 C++ 中为 1 字节,在大多数编程语言中取决于特定的字符编码方法,详见“字符编码”章节。
|
||||
- 即使表示布尔量仅需 1 位($0$ 或 $1$),它在内存中通常存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。
|
||||
- 即使表示布尔量仅需 1 位($0$ 或 $1$),它在内存中通常也存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。
|
||||
|
||||
那么,基本数据类型与数据结构之间有什么联系呢?我们知道,数据结构是在计算机中组织与存储数据的方式。这句话的主语是“结构”而非“数据”。
|
||||
|
||||
如果想表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数 `int`、小数 `float` 或是字符 `char` ,则与“数据结构”无关。
|
||||
如果想表示“一排数字”,我们自然会想到使用数组。这是因为数组的线性结构可以表示数字的相邻关系和顺序关系,但至于存储的内容是整数 `int`、小数 `float` 还是字符 `char` ,则与“数据结构”无关。
|
||||
|
||||
换句话说,**基本数据类型提供了数据的“内容类型”,而数据结构提供了数据的“组织方式”**。例如以下代码,我们用相同的数据结构(数组)来存储与表示不同的基本数据类型,包括 `int`、`float`、`char`、`bool` 等。
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||

|
||||
|
||||
然而,**ASCII 码仅能够表示英文**。随着计算机的全球化,诞生了一种能够表示更多语言的字符集「EASCII」。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。
|
||||
然而,**ASCII 码仅能够表示英文**。随着计算机的全球化,诞生了一种能够表示更多语言的「EASCII」字符集。它在 ASCII 的 7 位基础上扩展到 8 位,能够表示 256 个不同的字符。
|
||||
|
||||
在世界范围内,陆续出现了一批适用于不同地区的 EASCII 字符集。这些字符集的前 128 个字符统一为 ASCII 码,后 128 个字符定义不同,以适应不同语言的需求。
|
||||
|
||||
@ -64,7 +64,7 @@ UTF-8 的编码规则并不复杂,分为以下两种情况。
|
||||
|
||||
## 编程语言的字符编码
|
||||
|
||||
对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长的编码。在等长编码下,我们可以将字符串看作数组来处理,这种做法具有以下优点。
|
||||
对于以往的大多数编程语言,程序运行中的字符串都采用 UTF-16 或 UTF-32 这类等长编码。在等长编码下,我们可以将字符串看作数组来处理,这种做法具有以下优点。
|
||||
|
||||
- **随机访问**:UTF-16 编码的字符串可以很容易地进行随机访问。UTF-8 是一种变长编码,要想找到第 $i$ 个字符,我们需要从字符串的开始处遍历到第 $i$ 个字符,这需要 $O(n)$ 的时间。
|
||||
- **字符计数**:与随机访问类似,计算 UTF-16 编码的字符串的长度也是 $O(1)$ 的操作。但是,计算 UTF-8 编码的字符串的长度需要遍历整个字符串。
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
- **基于数组可实现**:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 $\geq 3$ 的数组)等。
|
||||
- **基于链表可实现**:栈、队列、哈希表、树、堆、图等。
|
||||
|
||||
基于数组实现的数据结构也称“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构称“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。
|
||||
基于数组实现的数据结构也称“静态数据结构”,这意味着此类数据结构在初始化后长度不可变。相对应地,基于链表实现的数据结构也称“动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整。
|
||||
|
||||
!!! tip
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
在本书中,标题带有 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。
|
||||
|
||||
## 整数编码
|
||||
## 原码、反码和补码
|
||||
|
||||
在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 `byte` 的取值范围是 $[-128, 127]$ 。这个现象比较反直觉,它的内在原因涉及原码、反码、补码的相关知识。
|
||||
|
||||
@ -88,9 +88,9 @@ $$
|
||||
|
||||
## 浮点数编码
|
||||
|
||||
细心的你可能会发现:`int` 和 `float` 长度相同,都是 4 bytes ,但为什么 `float` 的取值范围远大于 `int` ?这非常反直觉,因为按理说 `float` 需要表示小数,取值范围应该变小才对。
|
||||
细心的你可能会发现:`int` 和 `float` 长度相同,都是 4 字节 ,但为什么 `float` 的取值范围远大于 `int` ?这非常反直觉,因为按理说 `float` 需要表示小数,取值范围应该变小才对。
|
||||
|
||||
实际上,**这是因为浮点数 `float` 采用了不同的表示方式**。记一个 32-bit 长度的二进制数为:
|
||||
实际上,**这是因为浮点数 `float` 采用了不同的表示方式**。记一个 32 位长度的二进制数为:
|
||||
|
||||
$$
|
||||
b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0
|
||||
@ -98,9 +98,9 @@ $$
|
||||
|
||||
根据 IEEE 754 标准,32-bit 长度的 `float` 由以下三个部分构成。
|
||||
|
||||
- 符号位 $\mathrm{S}$ :占 1 bit ,对应 $b_{31}$ 。
|
||||
- 指数位 $\mathrm{E}$ :占 8 bits ,对应 $b_{30} b_{29} \ldots b_{23}$ 。
|
||||
- 分数位 $\mathrm{N}$ :占 23 bits ,对应 $b_{22} b_{21} \ldots b_0$ 。
|
||||
- 符号位 $\mathrm{S}$ :占 1 位 ,对应 $b_{31}$ 。
|
||||
- 指数位 $\mathrm{E}$ :占 8 位 ,对应 $b_{30} b_{29} \ldots b_{23}$ 。
|
||||
- 分数位 $\mathrm{N}$ :占 23 位 ,对应 $b_{22} b_{21} \ldots b_0$ 。
|
||||
|
||||
二进制数 `float` 对应值的计算方法为:
|
||||
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
- 原码、反码和补码是在计算机中编码数字的三种方法,它们之间可以相互转换。整数的原码的最高位是符号位,其余位是数字的值。
|
||||
- 整数在计算机中是以补码的形式存储的。在补码表示下,计算机可以对正数和负数的加法一视同仁,不需要为减法操作单独设计特殊的硬件电路,并且不存在正负零歧义的问题。
|
||||
- 浮点数的编码由 1 位符号位、8 位指数位和 23 位分数位构成。由于存在指数位,因此浮点数的取值范围远大于整数,代价是牺牲了精度。
|
||||
- ASCII 码是最早出现的英文字符集,长度为 1 字节,共收录 127 个字符。GBK 字符集是常用的中文字符集,共收录两万多个汉字。Unicode 致力于提供一个完整的字符集标准,收录世界内各种语言的字符,从而解决由于字符编码方法不一致而导致的乱码问题。
|
||||
- UTF-8 是最受欢迎的 Unicode 编码方法,通用性非常好。它是一种变长的编码方法,具有很好的扩展性,有效提升了存储空间的使用效率。UTF-16 和 UTF-32 是等长的编码方法。在编码中文时,UTF-16 比 UTF-8 的占用空间更小。Java 和 C# 等编程语言默认使用 UTF-16 编码。
|
||||
- ASCII 码是最早出现的英文字符集,长度为 1 字节,共收录 127 个字符。GBK 字符集是常用的中文字符集,共收录两万多个汉字。Unicode 致力于提供一个完整的字符集标准,收录世界上各种语言的字符,从而解决由于字符编码方法不一致而导致的乱码问题。
|
||||
- UTF-8 是最受欢迎的 Unicode 编码方法,通用性非常好。它是一种变长的编码方法,具有很好的扩展性,有效提升了存储空间的使用效率。UTF-16 和 UTF-32 是等长的编码方法。在编码中文时,UTF-16 占用的空间比 UTF-8 更小。Java 和 C# 等编程语言默认使用 UTF-16 编码。
|
||||
|
||||
### Q & A
|
||||
|
||||
@ -20,14 +20,14 @@
|
||||
哈希表底层是数组,而为了解决哈希冲突,我们可能会使用“链式地址”(后续“哈希冲突”章节会讲):数组中每个桶指向一个链表,当链表长度超过一定阈值时,又可能被转化为树(通常为红黑树)。
|
||||
从存储的角度来看,哈希表的底层是数组,其中每一个桶槽位可能包含一个值,也可能包含一个链表或一棵树。因此,哈希表可能同时包含线性数据结构(数组、链表)和非线性数据结构(树)。
|
||||
|
||||
!!! question "`char` 类型的长度是 1 byte 吗?"
|
||||
!!! question "`char` 类型的长度是 1 字节吗?"
|
||||
|
||||
`char` 类型的长度由编程语言采用的编码方法决定。例如,Java、JavaScript、TypeScript、C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 char 类型的长度为 2 bytes。
|
||||
`char` 类型的长度由编程语言采用的编码方法决定。例如,Java、JavaScript、TypeScript、C# 都采用 UTF-16 编码(保存 Unicode 码点),因此 `char` 类型的长度为 2 字节。
|
||||
|
||||
!!! question "基于数组实现的数据结构也称“静态数据结构” 是否有歧义?因为栈也可以进行出栈和入栈等操作,这些操作都是“动态”的。"
|
||||
!!! question "基于数组实现的数据结构也称“静态数据结构” 是否有歧义?栈也可以进行出栈和入栈等操作,这些操作都是“动态”的。"
|
||||
|
||||
栈确实可以实现动态的数据操作,但数据结构仍然是“静态”(长度不可变)的。尽管基于数组的数据结构可以动态地添加或删除元素,但它们的容量是固定的。如果数据量超出了预分配的大小,就需要创建一个新的更大的数组,并将旧数组的内容复制到新数组中。
|
||||
|
||||
!!! question "在构建栈(队列)的时候,未指定它的大小,为什么它们是“静态数据结构”呢?"
|
||||
|
||||
在高级编程语言中,我们无须人工指定栈(队列)的初始容量,这个工作由类内部自动完成。例如,Java 的 ArrayList 的初始容量通常为 10。另外,扩容操作也是自动实现的。详见后续的“列表”章节。
|
||||
在高级编程语言中,我们无须人工指定栈(队列)的初始容量,这个工作由类内部自动完成。例如,Java 的 `ArrayList` 的初始容量通常为 10。另外,扩容操作也是自动实现的。详见后续的“列表”章节。
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
1. 前序遍历的首元素 3 是根节点的值。
|
||||
2. 查找根节点 3 在 `inorder` 中的索引,利用该索引可将 `inorder` 划分为 `[ 9 | 3 | 1 2 7 ]` 。
|
||||
3. 根据 `inorder` 划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 `preorder` 划分为 `[ 3 | 9 | 2 1 7 ]` 。
|
||||
3. 根据 `inorder` 的划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 `preorder` 划分为 `[ 3 | 9 | 2 1 7 ]` 。
|
||||
|
||||

|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
| 左子树 | $i + 1$ | $[l, m-1]$ |
|
||||
| 右子树 | $i + 1 + (m - l)$ | $[m+1, r]$ |
|
||||
|
||||
请注意,右子树根节点索引中的 $(m-l)$ 的含义是“左子树的节点数量”,建议配合下图理解。
|
||||
请注意,右子树根节点索引中的 $(m-l)$ 的含义是“左子树的节点数量”,建议结合下图理解。
|
||||
|
||||

|
||||
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
2. **子问题是独立的**:子问题之间没有重叠,互不依赖,可以独立解决。
|
||||
3. **子问题的解可以合并**:原问题的解通过合并子问题的解得来。
|
||||
|
||||
显然,归并排序满足以上三条判断依据。
|
||||
显然,归并排序满足以上三个判断依据。
|
||||
|
||||
1. **问题可以分解**:递归地将数组(原问题)划分为两个子数组(子问题)。
|
||||
2. **子问题是独立的**:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
|
||||
@ -78,7 +78,7 @@ $$
|
||||
- **汉诺塔问题**:汉诺塔问题可以通过递归解决,这是典型的分治策略应用。
|
||||
- **求解逆序对**:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以利用分治的思想,借助归并排序进行求解。
|
||||
|
||||
另一方面,分治在算法和数据结构的设计中应用非常广泛。
|
||||
另一方面,分治在算法和数据结构的设计中应用得非常广泛。
|
||||
|
||||
- **二分查找**:二分查找是将有序数组从中点索引处分为两部分,然后根据目标值与中间元素值比较结果,决定排除哪一半区间,并在剩余区间执行相同的二分操作。
|
||||
- **归并排序**:本节开头已介绍,不再赘述。
|
||||
@ -86,6 +86,6 @@ $$
|
||||
- **桶排序**:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的元素依次取出,从而得到一个有序数组。
|
||||
- **树**:例如二叉搜索树、AVL 树、红黑树、B 树、B+ 树等,它们的查找、插入和删除等操作都可以视为分治策略的应用。
|
||||
- **堆**:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
|
||||
- **哈希表**:虽然哈希表来并不直接应用分治,但某些哈希冲突解决方案间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
|
||||
- **哈希表**:虽然哈希表并不直接应用分治,但某些哈希冲突解决方案间接应用了分治策略,例如,链式地址中的长链表会被转化为红黑树,以提升查询效率。
|
||||
|
||||
可以看出,**分治是一种“润物细无声”的算法思想**,隐含在各种算法与数据结构之中。
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
如下图所示,对于问题 $f(1)$ ,即当只有一个圆盘时,我们将它直接从 `A` 移动至 `C` 即可。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@ -31,7 +31,7 @@
|
||||
3. 最后将小圆盘从 `B` 移至 `C` 。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@ -55,7 +55,7 @@
|
||||
3. 令 `C` 为目标柱、`A` 为缓冲柱,将两个圆盘从 `B` 移至 `C` 。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@ -66,7 +66,7 @@
|
||||
=== "<4>"
|
||||

|
||||
|
||||
从本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并。
|
||||
从本质上看,**我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和一个子问题 $f(1)$** 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并。
|
||||
|
||||
至此,我们可总结出下图所示的解决汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ ,并按照以下顺序解决这三个子问题。
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@ $$
|
||||
|
||||

|
||||
|
||||
在该问题中,如果上一轮是跳 $1$ 阶上来的,那么下一轮就必须跳 $2$ 阶。这意味着,**下一步选择不能由当前状态(当前所在楼梯阶数)独立决定,还和前一个状态(上轮所在楼梯阶数)有关**。
|
||||
在该问题中,如果上一轮是跳 $1$ 阶上来的,那么下一轮就必须跳 $2$ 阶。这意味着,**下一步选择不能由当前状态(当前所在楼梯阶数)独立决定,还和前一个状态(上一轮所在楼梯阶数)有关**。
|
||||
|
||||
不难发现,此问题已不满足无后效性,状态转移方程 $dp[i] = dp[i-1] + dp[i-2]$ 也失效了,因为 $dp[i-1]$ 代表本轮跳 $1$ 阶,但其中包含了许多“上一轮是跳 $1$ 阶上来的”方案,而为了满足约束,我们就不能将 $dp[i-1]$ 直接计入 $dp[i]$ 中。
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
- 问题的目标是找出所有可能的解决方案,而不是找出最优解。
|
||||
- 问题描述中有明显的排列组合的特征,需要返回具体的多个方案。
|
||||
|
||||
如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。
|
||||
如果一个问题满足决策树模型,并具有较为明显的“加分项”,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。
|
||||
|
||||
## 问题求解步骤
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ $$
|
||||
|
||||
观察上图,**指数阶的时间复杂度是“重叠子问题”导致的**。例如 $dp[9]$ 被分解为 $dp[8]$ 和 $dp[7]$ ,$dp[8]$ 被分解为 $dp[7]$ 和 $dp[6]$ ,两者都包含子问题 $dp[7]$ 。
|
||||
|
||||
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。
|
||||
以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的子问题上。
|
||||
|
||||
## 方法二:记忆化搜索
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# 小结
|
||||
|
||||
- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,提高 计算效率。
|
||||
- 动态规划对问题进行分解,并通过存储子问题的解来规避重复计算,提高计算效率。
|
||||
- 不考虑时间的前提下,所有动态规划问题都可以用回溯(暴力搜索)进行求解,但递归树中存在大量的重叠子问题,效率极低。通过引入记忆化列表,可以存储所有计算过的子问题的解,从而保证重叠子问题只被计算一次。
|
||||
- 记忆化递归是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,其如同“填写表格”一样。由于当前状态仅依赖某些局部状态,因此我们可以消除 $dp$ 表的一个维度,从而降低空间复杂度。
|
||||
- 记忆化搜索是一种从顶至底的递归式解法,而与之对应的动态规划是一种从底至顶的递推式解法,其如同“填写表格”一样。由于当前状态仅依赖某些局部状态,因此我们可以消除 $dp$ 表的一个维度,从而降低空间复杂度。
|
||||
- 子问题分解是一种通用的算法思路,在分治、动态规划、回溯中具有不同的性质。
|
||||
- 动态规划问题有三大特性:重叠子问题、最优子结构、无后效性。
|
||||
- 如果原问题的最优解可以从子问题的最优解构建得来,则它就具有最优子结构。
|
||||
|
||||
@ -30,7 +30,7 @@ $$
|
||||
|
||||

|
||||
|
||||
我们还可以为边添加“权重”变量,从而得到如下图所示的「有权图 weighted graph」。例如在“王者荣耀”等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
|
||||
我们还可以为边添加“权重”变量,从而得到如下图所示的「有权图 weighted graph」。例如在《王者荣耀》等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。
|
||||
|
||||

|
||||
|
||||
@ -70,7 +70,7 @@ $$
|
||||
|
||||
观察上图,**邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率**。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ;还可以把链表转换为哈希表,从而将时间复杂度降至 $O(1)$ 。
|
||||
|
||||
## 图常见应用
|
||||
## 图的常见应用
|
||||
|
||||
如下表所示,许多现实系统可以用图来建模,相应的问题也可以约化为图计算问题。
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以把树看作图的一种特例。显然,**树的遍历操作也是图的遍历操作的一种特例**。
|
||||
|
||||
图和树都需要应用搜索算法来实现遍历操作。图的遍历方式可分为两种:「广度优先遍历 breadth-first traversal」和「深度优先遍历 depth-first traversal」。它们也常被称为「广度优先搜索 breadth-first search」和「深度优先搜索 depth-first search」,简称 BFS 和 DFS 。
|
||||
图和树都需要应用搜索算法来实现遍历操作。图的遍历方式也可分为两种:「广度优先遍历」和「深度优先遍历」。
|
||||
|
||||
## 广度优先遍历
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
[file]{fractional_knapsack}-[class]{}-[func]{fractional_knapsack}
|
||||
```
|
||||
|
||||
在最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。
|
||||
除排序之外,在最差情况下,需要遍历整个物品列表,**因此时间复杂度为 $O(n)$** ,其中 $n$ 为物品数量。
|
||||
|
||||
由于初始化了一个 `Item` 对象列表,**因此空间复杂度为 $O(n)$** 。
|
||||
|
||||
@ -45,6 +45,6 @@
|
||||
|
||||
对于该解中的其他物品,我们也可以构建出上述矛盾。总而言之,**单位价值更大的物品总是更优选择**,这说明贪心策略是有效的。
|
||||
|
||||
如下图所示,如果将物品重量和物品单位价值分别看作一张二维图表的横轴和纵轴,则分数背包问题可转化为“求在有限横轴区间下的最大围成面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。
|
||||
如下图所示,如果将物品重量和物品单位价值分别看作一张二维图表的横轴和纵轴,则分数背包问题可转化为“求在有限横轴区间下围成的最大面积”。这个类比可以帮助我们从几何角度理解贪心策略的有效性。
|
||||
|
||||

|
||||
|
||||
@ -11,19 +11,21 @@
|
||||
|
||||
!!! question
|
||||
|
||||
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额则返回 $-1$ 。
|
||||
给定 $n$ 种硬币,第 $i$ 种硬币的面值为 $coins[i - 1]$ ,目标金额为 $amt$ ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 $-1$ 。
|
||||
|
||||
本题采取的贪心策略如下图所示。给定目标金额,**我们贪心地选择不大于且最接近它的硬币**,不断循环该步骤,直至凑出目标金额为止。
|
||||
|
||||

|
||||
|
||||
实现代码如下所示。你可能会不由地发出感叹:So clean !贪心算法仅用约十行代码就解决了零钱兑换问题:
|
||||
实现代码如下所示:
|
||||
|
||||
```src
|
||||
[file]{coin_change_greedy}-[class]{}-[func]{coin_change_greedy}
|
||||
```
|
||||
|
||||
## 贪心的优点与局限性
|
||||
你可能会不由地发出感叹:So clean !贪心算法仅用约十行代码就解决了零钱兑换问题。
|
||||
|
||||
## 贪心算法的优点与局限性
|
||||
|
||||
**贪心算法不仅操作直接、实现简单,而且通常效率也很高**。在以上代码中,记硬币最小面值为 $\min(coins)$ ,则贪心选择最多循环 $amt / \min(coins)$ 次,时间复杂度为 $O(amt / \min(coins))$ 。这比动态规划解法的时间复杂度 $O(n \times amt)$ 提升了一个数量级。
|
||||
|
||||
@ -33,7 +35,7 @@
|
||||
- **反例 $coins = [1, 20, 50]$**:假设 $amt = 60$ ,贪心算法只能找到 $50 + 1 \times 10$ 的兑换组合,共计 $11$ 枚硬币,但动态规划可以找到最优解 $20 + 20 + 20$ ,仅需 $3$ 枚硬币。
|
||||
- **反例 $coins = [1, 49, 50]$**:假设 $amt = 98$ ,贪心算法只能找到 $50 + 1 \times 48$ 的兑换组合,共计 $49$ 枚硬币,但动态规划可以找到最优解 $49 + 49$ ,仅需 $2$ 枚硬币。
|
||||
|
||||

|
||||

|
||||
|
||||
也就是说,对于零钱兑换问题,贪心算法无法保证找到全局最优解,并且有可能找到非常差的解。它更适合用动态规划解决。
|
||||
|
||||
@ -61,9 +63,9 @@
|
||||
|
||||
有一篇论文给出了一个 $O(n^3)$ 时间复杂度的算法,用于判断一个硬币组合能否使用贪心算法找出任意金额的最优解。
|
||||
|
||||
Pearson, David. A polynomial-time algorithm for the change-making problem. Operations Research Letters 33.3 (2005): 231-234.
|
||||
Pearson, D. A polynomial-time algorithm for the change-making problem[J]. Operations Research Letters, 2005, 33(3): 231-234.
|
||||
|
||||
## 贪心解题步骤
|
||||
## 贪心算法解题步骤
|
||||
|
||||
贪心问题的解决流程大体可分为以下三步。
|
||||
|
||||
@ -80,7 +82,7 @@
|
||||
|
||||
然而,正确性证明也很可能不是一件易事。如若没有头绪,我们通常会选择面向测试用例进行代码调试,一步步修改与验证贪心策略。
|
||||
|
||||
## 贪心典型例题
|
||||
## 贪心算法典型例题
|
||||
|
||||
贪心算法常常应用在满足贪心选择性质和最优子结构的优化问题中,以下列举了一些典型的贪心算法问题。
|
||||
|
||||
|
||||
@ -36,11 +36,11 @@ $$
|
||||
|
||||

|
||||
|
||||
由此便可推出本题的贪心策略:初始化两指针分列容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。
|
||||
由此便可推出本题的贪心策略:初始化两指针,使其分列容器两端,每轮向内收缩短板对应的指针,直至两指针相遇。
|
||||
|
||||
下图展示了贪心策略的执行过程。
|
||||
|
||||
1. 初始状态下,指针 $i$ 和 $j$ 分列与数组两端。
|
||||
1. 初始状态下,指针 $i$ 和 $j$ 分列数组两端。
|
||||
2. 计算当前状态的容量 $cap[i, j]$ ,并更新最大容量。
|
||||
3. 比较板 $i$ 和 板 $j$ 的高度,并将短板向内移动一格。
|
||||
4. 循环执行第 `2.` 步和第 `3.` 步,直至 $i$ 和 $j$ 相遇时结束。
|
||||
|
||||
@ -50,7 +50,7 @@ $$
|
||||
|
||||
1. 输入整数 $n$ ,从其不断地切分出因子 $3$ ,直至余数为 $0$、$1$、$2$ 。
|
||||
2. 当余数为 $0$ 时,代表 $n$ 是 $3$ 的倍数,因此不做任何处理。
|
||||
3. 当余数为 $2$ 时,不继续划分,保留之。
|
||||
3. 当余数为 $2$ 时,不继续划分,保留。
|
||||
4. 当余数为 $1$ 时,由于 $2 \times 2 > 1 \times 3$ ,因此应将最后一个 $3$ 替换为 $2$ 。
|
||||
|
||||
### 代码实现
|
||||
|
||||
@ -9,4 +9,4 @@
|
||||
- 求解贪心问题主要分为三步:问题分析、确定贪心策略、正确性证明。其中,确定贪心策略是核心步骤,正确性证明往往是难点。
|
||||
- 分数背包问题在 0-1 背包的基础上,允许选择物品的一部分,因此可使用贪心算法求解。贪心策略的正确性可以使用反证法来证明。
|
||||
- 最大容量问题可使用穷举法求解,时间复杂度为 $O(n^2)$ 。通过设计贪心策略,每轮向内移动短板,可将时间复杂度优化至 $O(n)$ 。
|
||||
- 在最大切分乘积问题中,我们先后推理出两个贪心策略:$\geq 4$ 的整数都应该继续切分、最优切分因子为 $3$ 。代码中包含幂运算,时间复杂度取决于幂运算实现方法,通常为 $O(1)$ 或 $O(\log n)$ 。
|
||||
- 在最大切分乘积问题中,我们先后推理出两个贪心策略:$\geq 4$ 的整数都应该继续切分,最优切分因子为 $3$ 。代码中包含幂运算,时间复杂度取决于幂运算实现方法,通常为 $O(1)$ 或 $O(\log n)$ 。
|
||||
|
||||
@ -82,7 +82,7 @@ $$
|
||||
|
||||
不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。
|
||||
|
||||
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
|
||||
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2 和 SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
|
||||
|
||||
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。下表展示了在实际应用中常见的哈希算法。
|
||||
|
||||
@ -92,13 +92,13 @@ $$
|
||||
|
||||
<p align="center"> 表 <id> 常见的哈希算法 </p>
|
||||
|
||||
| | MD5 | SHA-1 | SHA-2 | SHA-3 |
|
||||
| -------- | ------------------------------ | ---------------- | ---------------------------- | -------------------- |
|
||||
| 推出时间 | 1992 | 1995 | 2002 | 2008 |
|
||||
| 输出长度 | 128 bits | 160 bits | 256/512 bits | 224/256/384/512 bits |
|
||||
| 哈希冲突 | 较多 | 较多 | 很少 | 很少 |
|
||||
| 安全等级 | 低,已被成功攻击 | 低,已被成功攻击 | 高 | 高 |
|
||||
| 应用 | 已被弃用,仍用于数据完整性检查 | 已被弃用 | 加密货币交易验证、数字签名等 | 可用于替代 SHA-2 |
|
||||
| | MD5 | SHA-1 | SHA-2 | SHA-3 |
|
||||
| -------- | ------------------------------ | ---------------- | ---------------------------- | ------------------- |
|
||||
| 推出时间 | 1992 | 1995 | 2002 | 2008 |
|
||||
| 输出长度 | 128 bit | 160 bit | 256/512 bit | 224/256/384/512 bit |
|
||||
| 哈希冲突 | 较多 | 较多 | 很少 | 很少 |
|
||||
| 安全等级 | 低,已被成功攻击 | 低,已被成功攻击 | 高 | 高 |
|
||||
| 应用 | 已被弃用,仍用于数据完整性检查 | 已被弃用 | 加密货币交易验证、数字签名等 | 可用于替代 SHA-2 |
|
||||
|
||||
## 数据结构的哈希值
|
||||
|
||||
@ -354,4 +354,4 @@ $$
|
||||
|
||||
虽然自定义对象(比如链表节点)的成员变量是可变的,但它是可哈希的。**这是因为对象的哈希值通常是基于内存地址生成的**,即使对象的内容发生了变化,但它的内存地址不变,哈希值仍然是不变的。
|
||||
|
||||
细心的你可能发现在不同控制台中运行程序时,输出的哈希值是不同的。**这是因为 Python 解释器在每次启动时,都会为字符串哈希函数加入一个随机的盐(Salt)值**。这种做法可以有效防止 HashDoS 攻击,提升哈希算法的安全性。
|
||||
细心的你可能发现在不同控制台中运行程序时,输出的哈希值是不同的。**这是因为 Python 解释器在每次启动时,都会为字符串哈希函数加入一个随机的盐(salt)值**。这种做法可以有效防止 HashDoS 攻击,提升哈希算法的安全性。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
上一节提到,**通常情况下哈希函数的输入空间远大于输出空间**,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
|
||||
|
||||
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
|
||||
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
|
||||
|
||||
1. 改良哈希表数据结构,**使得哈希表可以在出现哈希冲突时正常工作**。
|
||||
2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
|
||||
@ -23,8 +23,8 @@
|
||||
|
||||
链式地址存在以下局限性。
|
||||
|
||||
- **占用空间增大**,链表包含节点指针,它相比数组更加耗费内存空间。
|
||||
- **查询效率降低**,因为需要线性遍历链表来查找对应元素。
|
||||
- **占用空间增大**:链表包含节点指针,它相比数组更加耗费内存空间。
|
||||
- **查询效率降低**:因为需要线性遍历链表来查找对应元素。
|
||||
|
||||
以下代码给出了链式地址哈希表的简单实现,需要注意两点。
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
|
||||
## 开放寻址
|
||||
|
||||
「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等。
|
||||
「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测和多次哈希等。
|
||||
|
||||
下面以线性探测为例,介绍开放寻址哈希表的工作机制。
|
||||
|
||||
@ -48,19 +48,19 @@
|
||||
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
|
||||
|
||||
- **插入元素**:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 $1$ ),直至找到空桶,将元素插入其中。
|
||||
- **查找元素**:若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 `value` 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 $\text{None}$ 。
|
||||
- **查找元素**:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回 `value` 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 `None` 。
|
||||
|
||||
下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 `key` 都会被映射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。
|
||||
|
||||

|
||||

|
||||
|
||||
然而,**线性探测容易产生“聚集现象”**。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
|
||||
|
||||
值得注意的是,**我们不能在开放寻址哈希表中直接删除元素**。这是因为删除元素会在数组内产生一个空桶 $\text{None}$ ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
|
||||
值得注意的是,**我们不能在开放寻址哈希表中直接删除元素**。这是因为删除元素会在数组内产生一个空桶 `None` ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
|
||||
|
||||

|
||||
|
||||
为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,**而是利用一个常量 `TOMBSTONE` 来标记这个桶**。在该机制下,$\text{None}$ 和 `TOMBSTONE` 都代表空桶,都可以放置键值对。但不同的是,线性探测到 `TOMBSTONE` 时应该继续遍历,因为其之下可能还存在键值对。
|
||||
为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,**而是利用一个常量 `TOMBSTONE` 来标记这个桶**。在该机制下,`None` 和 `TOMBSTONE` 都代表空桶,都可以放置键值对。但不同的是,线性探测到 `TOMBSTONE` 时应该继续遍历,因为其之下可能还存在键值对。
|
||||
|
||||
然而,**懒删除可能会加速哈希表的性能退化**。这是因为每次删除操作都会产生一个删除标记,随着 `TOMBSTONE` 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 `TOMBSTONE` 才能找到目标元素。
|
||||
|
||||
@ -90,8 +90,8 @@
|
||||
|
||||
顾名思义,多次哈希方法使用多个哈希函数 $f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$ 进行探测。
|
||||
|
||||
- **插入元素**:若哈希函数 $f_1(x)$ 出现冲突,则尝试 $f_2(x)$ ,以此类推,直到找到空桶后插入元素。
|
||||
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 $\text{None}$ 。
|
||||
- **插入元素**:若哈希函数 $f_1(x)$ 出现冲突,则尝试 $f_2(x)$ ,以此类推,直到找到空位后插入元素。
|
||||
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 `None` 。
|
||||
|
||||
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。
|
||||
|
||||
@ -103,6 +103,6 @@
|
||||
|
||||
各种编程语言采取了不同的哈希表实现策略,下面举几个例子。
|
||||
|
||||
- Python 采用开放寻址。字典 dict 使用伪随机数进行探测。
|
||||
- Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
|
||||
- Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。
|
||||
- Python 采用开放寻址。字典 `dict` 使用伪随机数进行探测。
|
||||
- Java 采用链式地址。自 JDK 1.8 以来,当 `HashMap` 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
|
||||
- Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶;当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 哈希表
|
||||
|
||||
「哈希表 hash table」,又称「散列表」,其通过建立键 `key` 与值 `value` 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 `key` ,则可以在 $O(1)$ 时间内获取对应的值 `value` 。
|
||||
「哈希表 hash table」,又称「散列表」,它通过建立键 `key` 与值 `value` 之间的映射,实现高效的元素查询。具体而言,我们向哈希表中输入一个键 `key` ,则可以在 $O(1)$ 时间内获取对应的值 `value` 。
|
||||
|
||||
如下图所示,给定 $n$ 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用下图所示的哈希表来实现。
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
hmap[10583] = "小鸭"
|
||||
|
||||
# 查询操作
|
||||
# 向哈希表输入键 key ,得到值 value
|
||||
# 向哈希表中输入键 key ,得到值 value
|
||||
name: str = hmap[15937]
|
||||
|
||||
# 删除操作
|
||||
@ -64,7 +64,7 @@
|
||||
map[10583] = "小鸭";
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
string name = map[15937];
|
||||
|
||||
/* 删除操作 */
|
||||
@ -87,7 +87,7 @@
|
||||
map.put(10583, "小鸭");
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
String name = map.get(15937);
|
||||
|
||||
/* 删除操作 */
|
||||
@ -110,7 +110,7 @@
|
||||
};
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
string name = map[15937];
|
||||
|
||||
/* 删除操作 */
|
||||
@ -133,7 +133,7 @@
|
||||
hmap[10583] = "小鸭"
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
name := hmap[15937]
|
||||
|
||||
/* 删除操作 */
|
||||
@ -156,7 +156,7 @@
|
||||
map[10583] = "小鸭"
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
let name = map[15937]!
|
||||
|
||||
/* 删除操作 */
|
||||
@ -178,7 +178,7 @@
|
||||
map.set(10583, '小鸭');
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
let name = map.get(15937);
|
||||
|
||||
/* 删除操作 */
|
||||
@ -202,7 +202,7 @@
|
||||
console.info(map);
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
let name = map.get(15937);
|
||||
console.info('\n输入学号 15937 ,查询到姓名 ' + name);
|
||||
|
||||
@ -228,7 +228,7 @@
|
||||
map[10583] = "小鸭";
|
||||
|
||||
/* 查询操作 */
|
||||
// 向哈希表输入键 key ,得到值 value
|
||||
// 向哈希表中输入键 key ,得到值 value
|
||||
String name = map[15937];
|
||||
|
||||
/* 删除操作 */
|
||||
@ -512,6 +512,6 @@ index = hash(key) % capacity
|
||||
|
||||

|
||||
|
||||
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
|
||||
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步增加了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
|
||||
|
||||
「负载因子 load factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,**也常作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表扩容至原先的 $2$ 倍。
|
||||
|
||||
@ -44,4 +44,4 @@
|
||||
|
||||
!!! question "为什么哈希表扩容能够缓解哈希冲突?"
|
||||
|
||||
哈希函数的最后一步往往是对数组长度 $n$ 取余,让输出值落在数组索引范围内;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。
|
||||
哈希函数的最后一步往往是对数组长度 $n$ 取模(取余),让输出值落在数组索引范围内;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。
|
||||
|
||||
@ -46,7 +46,7 @@ $$
|
||||
T(h) = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{(h-1)}\times1
|
||||
$$
|
||||
|
||||
化简上式需要借助中学的数列知识,先对 $T(h)$ 乘以 $2$ ,得到:
|
||||
化简上式需要借助中学的数列知识,将对 $T(h)$ 乘以 $2$ ,得到:
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@ -71,4 +71,4 @@ T(h) & = 2 \frac{1 - 2^h}{1 - 2} - h \newline
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
进一步地,高度为 $h$ 的完美二叉树的节点数量为 $n = 2^{h+1} - 1$ ,易得复杂度为 $O(2^h) = O(n)$ 。以上推算表明,**输入列表并建堆的时间复杂度为 $O(n)$ ,非常高效**。
|
||||
进一步,高度为 $h$ 的完美二叉树的节点数量为 $n = 2^{h+1} - 1$ ,易得复杂度为 $O(2^h) = O(n)$ 。以上推算表明,**输入列表并建堆的时间复杂度为 $O(n)$ ,非常高效**。
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
「堆 heap」是一种满足特定条件的完全二叉树,主要可分为两种类型,如下图所示。
|
||||
|
||||
- 「大顶堆 max heap」:任意节点的值 $\geq$ 其子节点的值。
|
||||
- 「小顶堆 min heap」:任意节点的值 $\leq$ 其子节点的值。
|
||||
- 「大顶堆 max heap」:任意节点的值 $\geq$ 其子节点的值。
|
||||
|
||||

|
||||
|
||||
@ -11,11 +11,11 @@
|
||||
|
||||
- 最底层节点靠左填充,其他层的节点都被填满。
|
||||
- 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
|
||||
- 对于大顶堆(小顶堆),堆顶元素(根节点)的值分别是最大(最小)的。
|
||||
- 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。
|
||||
|
||||
## 堆常用操作
|
||||
## 堆的常用操作
|
||||
|
||||
需要指出的是,许多编程语言提供的是「优先队列 priority queue」,这是一种抽象数据结构,定义为具有优先级排序的队列。
|
||||
需要指出的是,许多编程语言提供的是「优先队列 priority queue」,这是一种抽象的数据结构,定义为具有优先级排序的队列。
|
||||
|
||||
实际上,**堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列**。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一称作“堆”。
|
||||
|
||||
@ -23,13 +23,13 @@
|
||||
|
||||
<p align="center"> 表 <id> 堆的操作效率 </p>
|
||||
|
||||
| 方法名 | 描述 | 时间复杂度 |
|
||||
| --------- | -------------------------------------------- | ----------- |
|
||||
| push() | 元素入堆 | $O(\log n)$ |
|
||||
| pop() | 堆顶元素出堆 | $O(\log n)$ |
|
||||
| peek() | 访问堆顶元素(大 / 小顶堆分别为最大 / 小值) | $O(1)$ |
|
||||
| size() | 获取堆的元素数量 | $O(1)$ |
|
||||
| isEmpty() | 判断堆是否为空 | $O(1)$ |
|
||||
| 方法名 | 描述 | 时间复杂度 |
|
||||
| ----------- | ------------------------------------------------ | ----------- |
|
||||
| `push()` | 元素入堆 | $O(\log n)$ |
|
||||
| `pop()` | 堆顶元素出堆 | $O(\log n)$ |
|
||||
| `peek()` | 访问堆顶元素(对于大 / 小顶堆分别为最大 / 小值) | $O(1)$ |
|
||||
| `size()` | 获取堆的元素数量 | $O(1)$ |
|
||||
| `isEmpty()` | 判断堆是否为空 | $O(1)$ |
|
||||
|
||||
在实际应用中,我们可以直接使用编程语言提供的堆类(或优先队列类)。
|
||||
|
||||
@ -353,7 +353,7 @@
|
||||
|
||||
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。**节点指针通过索引映射公式来实现**。
|
||||
|
||||
如下图所示,给定索引 $i$ ,其左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$ ,父节点索引为 $(i - 1) / 2$(向下整除)。当索引越界时,表示空节点或节点不存在。
|
||||
如下图所示,给定索引 $i$ ,其左子节点的索引为 $2i + 1$ ,右子节点的索引为 $2i + 2$ ,父节点的索引为 $(i - 1) / 2$(向下整除)。当索引越界时,表示空节点或节点不存在。
|
||||
|
||||

|
||||
|
||||
@ -373,7 +373,7 @@
|
||||
|
||||
### 元素入堆
|
||||
|
||||
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏,**因此需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 heapify」。
|
||||
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 `val` 可能大于堆中其他元素,堆的成立条件可能已被破坏,**因此需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 heapify」。
|
||||
|
||||
考虑从入堆节点开始,**从底至顶执行堆化**。如下图所示,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。
|
||||
|
||||
@ -456,7 +456,7 @@
|
||||
[file]{my_heap}-[class]{max_heap}-[func]{sift_down}
|
||||
```
|
||||
|
||||
## 堆常见应用
|
||||
## 堆的常见应用
|
||||
|
||||
- **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。
|
||||
- **堆排序**:给定一组数据,我们可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。然而,我们通常会使用一种更优雅的方式实现堆排序,详见“堆排序”章节。
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
- 完全二叉树非常适合用数组表示,因此我们通常使用数组来存储堆。
|
||||
- 堆化操作用于维护堆的性质,在入堆和出堆操作中都会用到。
|
||||
- 输入 $n$ 个元素并建堆的时间复杂度可以优化至 $O(n)$ ,非常高效。
|
||||
- Top-K 是一个经典算法问题,可以使用堆数据结构高效解决,时间复杂度为 $O(n \log k)$ 。
|
||||
- Top-k 是一个经典算法问题,可以使用堆数据结构高效解决,时间复杂度为 $O(n \log k)$ 。
|
||||
|
||||
### Q & A
|
||||
|
||||
!!! question "数据结构的“堆”与内存管理的“堆”是同一个概念吗?"
|
||||
|
||||
两者不是同一个概念,只是碰巧都叫堆。计算机系统内存中的堆是动态内存分配的一部分,程序在运行时可以使用它来存储数据。程序可以请求一定量的堆内存,用于存储如对象和数组等复杂结构。当这些数据不再需要时,程序需要释放这些内存,以防止内存泄漏。相较于栈内存,堆内存的管理和使用需要更谨慎,使用不当可能会导致内存泄漏和野指针等问题。
|
||||
两者不是同一个概念,只是碰巧都叫“堆”。计算机系统内存中的堆是动态内存分配的一部分,程序在运行时可以使用它来存储数据。程序可以请求一定量的堆内存,用于存储如对象和数组等复杂结构。当这些数据不再需要时,程序需要释放这些内存,以防止内存泄漏。相较于栈内存,堆内存的管理和使用需要更谨慎,使用不当可能会导致内存泄漏和野指针等问题。
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# Top-K 问题
|
||||
# Top-k 问题
|
||||
|
||||
!!! question
|
||||
|
||||
给定一个长度为 $n$ 的无序数组 `nums` ,请返回数组中前 $k$ 大的元素。
|
||||
给定一个长度为 $n$ 的无序数组 `nums` ,请返回数组中最大的 $k$ 个元素。
|
||||
|
||||
对于该问题,我们先介绍两种思路比较直接的解法,再介绍效率更高的堆解法。
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
|
||||
## 方法三:堆
|
||||
|
||||
我们可以基于堆更加高效地解决 Top-K 问题,流程如下图所示。
|
||||
我们可以基于堆更加高效地解决 Top-k 问题,流程如下图所示。
|
||||
|
||||
1. 初始化一个小顶堆,其堆顶元素最小。
|
||||
2. 先将数组的前 $k$ 个元素依次入堆。
|
||||
@ -70,4 +70,4 @@
|
||||
|
||||
总共执行了 $n$ 轮入堆和出堆,堆的最大长度为 $k$ ,因此时间复杂度为 $O(n \log k)$ 。该方法的效率很高,当 $k$ 较小时,时间复杂度趋向 $O(n)$ ;当 $k$ 较大时,时间复杂度不会超过 $O(n \log n)$ 。
|
||||
|
||||
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大 $k$ 个元素的动态更新。
|
||||
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大的 $k$ 个元素的动态更新。
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
在正式探讨算法之前,有一个有趣的事实值得分享:**你已经在不知不觉中学会了许多算法,并习惯将它们应用到日常生活中了**。下面我将举几个具体的例子来证实这一点。
|
||||
|
||||
**例一:查阅字典**。在字典里,每个汉字都对应一个拼音,而字典是按照拼音字母顺序排列的。假设我们需要查找一个拼音首字母为 $r$ 的字,通常会按照下图所示的方式实现。
|
||||
**例一:查字典**。在字典里,每个汉字都对应一个拼音,而字典是按照拼音字母顺序排列的。假设我们需要查找一个拼音首字母为 $r$ 的字,通常会按照下图所示的方式实现。
|
||||
|
||||
1. 翻开字典约一半的页数,查看该页的首字母是什么,假设首字母为 $m$ 。
|
||||
2. 由于在拼音字母表中 $r$ 位于 $m$ 之后,所以排除字典前半部分,查找范围缩小到后半部分。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# 小结
|
||||
|
||||
- 算法在日常生活中无处不在,并不是遥不可及的高深知识。实际上,我们已经在不知不觉中学会了许多算法,用以解决生活中的大小问题。
|
||||
- 查阅字典的原理与二分查找算法相一致。二分查找算法体现了分而治之的重要算法思想。
|
||||
- 查字典的原理与二分查找算法相一致。二分查找算法体现了分而治之的重要算法思想。
|
||||
- 整理扑克的过程与插入排序算法非常类似。插入排序算法适合排序小型数据集。
|
||||
- 货币找零的步骤本质上是贪心算法,每一步都采取当前看来最好的选择。
|
||||
- 算法是在有限时间内解决特定问题的一组指令或操作步骤,而数据结构是计算机中组织和存储数据的方式。
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
# 关于本书
|
||||
|
||||
本项目旨在创建一本开源、免费、新手友好的数据结构与算法入门教程。
|
||||
本项目旨在创建一本开源、免费、对新手友好的数据结构与算法入门教程。
|
||||
|
||||
- 全书采用动画图解,结构化地讲解数据结构与算法知识,内容清晰易懂、学习曲线平滑。
|
||||
- 算法源代码皆可一键运行,支持 Python、C++、Java、C#、Go、Swift、JavaScript、TypeScript、Dart、Rust、C、Zig 等语言。
|
||||
- 鼓励读者在章节讨论区互帮互助、共同进步,提问与评论通常可在两日内得到回复。
|
||||
- 全书采用动画图解,结构化地讲解数据结构与算法知识,内容清晰易懂,学习曲线平滑。
|
||||
- 算法源代码皆可一键运行,支持 Python、C++、Java、C#、Go、Swift、JavaScript、TypeScript、Dart、Rust、C 和 Zig 等语言。
|
||||
- 鼓励读者在线上章节评论区互帮互助、共同进步,提问与评论通常可在两日内得到回复。
|
||||
|
||||
## 读者对象
|
||||
|
||||
若你是算法初学者,从未接触过算法,或者已经有一些刷题经验,对数据结构与算法有模糊的认识,在会与不会之间反复横跳,那么本书正是为你量身定制的!
|
||||
|
||||
如果你已经积累一定刷题量,熟悉大部分题型,那么本书可助你回顾与梳理算法知识体系,仓库源代码可以当作“刷题工具库”或“算法字典”来使用。
|
||||
如果你已经积累一定的刷题量,熟悉大部分题型,那么本书可助你回顾与梳理算法知识体系,仓库源代码可以当作“刷题工具库”或“算法字典”来使用。
|
||||
|
||||
若你是算法大神,我们期待收到你的宝贵建议,或者[一起参与创作](https://www.hello-algo.com/chapter_appendix/contribution/)。
|
||||
若你是算法“大神”,我们期待收到你的宝贵建议,或者[一起参与创作](https://www.hello-algo.com/chapter_appendix/contribution/)。
|
||||
|
||||
!!! success "前置条件"
|
||||
|
||||
@ -22,25 +22,25 @@
|
||||
|
||||
本书的主要内容如下图所示。
|
||||
|
||||
- **复杂度分析**:数据结构和算法的评价维度与方法。时间复杂度、空间复杂度的推算方法、常见类型、示例等。
|
||||
- **数据结构**:基本数据类型,数据结构的分类方法。数组、链表、栈、队列、哈希表、树、堆、图等数据结构的定义、优缺点、常用操作、常见类型、典型应用、实现方法等。
|
||||
- **复杂度分析**:数据结构和算法的评价维度与方法。时间复杂度和空间复杂度的推算方法、常见类型、示例等。
|
||||
- **数据结构**:基本数据类型和数据结构的分类方法。数组、链表、栈、队列、哈希表、树、堆、图等数据结构的定义、优缺点、常用操作、常见类型、典型应用、实现方法等。
|
||||
- **算法**:搜索、排序、分治、回溯、动态规划、贪心等算法的定义、优缺点、效率、应用场景、解题步骤和示例问题等。
|
||||
|
||||

|
||||
|
||||
## 致谢
|
||||
|
||||
在本书的创作过程中,我得到了许多人的帮助,包括但不限于:
|
||||
在本书的创作过程中,我得到了许多人的帮助。
|
||||
|
||||
- 感谢我在公司的导师李汐博士,在一次畅谈中你鼓励我“快行动起来”,坚定了我写这本书的决心;
|
||||
- 感谢我的女朋友泡泡作为本书的首位读者,从算法小白的角度提出许多宝贵建议,使得本书更适合新手阅读;
|
||||
- 感谢腾宝、琦宝、飞宝为本书起了一个富有创意的名字,唤起大家写下第一行代码 "Hello World!" 的美好回忆;
|
||||
- 感谢校铨在知识产权方面提供的专业帮助,这对本开源书的发展起到了重要的作用;
|
||||
- 感谢苏潼为本书设计了精美的封面和 logo ,并在我的强迫症下多次耐心修改;
|
||||
- 感谢 @squidfunk 提供的写作排版建议,以及他开发的开源文档主题 [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material/tree/master) 。
|
||||
- 感谢校铨在知识产权方面提供的专业帮助,这对本开源书的完善起到了重要作用;
|
||||
- 感谢苏潼为本书设计了精美的封面和 logo ,并在我的强迫症的驱使下多次耐心修改;
|
||||
- 感谢 @squidfunk 提供的排版建议,以及他开发的开源文档主题 [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material/tree/master) 。
|
||||
|
||||
在写作过程中,我阅读了许多关于数据结构与算法的教材和文章。这些作品为本书提供了优秀的范本,确保了本书内容的准确性与品质。在此感谢所有老师和前辈们的杰出贡献!
|
||||
|
||||
本书倡导手脑并用的学习方式,在这一点上深受[《动手学深度学习》](https://github.com/d2l-ai/d2l-zh)的启发。在此向各位读者强烈推荐这本优秀的著作。
|
||||
本书倡导手脑并用的学习方式,在这一点上我深受[《动手学深度学习》](https://github.com/d2l-ai/d2l-zh)的启发。在此向各位读者强烈推荐这本优秀的著作。
|
||||
|
||||
**衷心感谢我的父母,正是你们一直以来的支持与鼓励,让我有机会做这件富有趣味的事**。
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
- 标题后标注 `*` 的是选读章节,内容相对困难。如果你的时间有限,可以先跳过。
|
||||
- 重要专有名词及其英文翻译会用 `「 」` 括号标注,例如 `「数组 array」` 。建议记住它们,以便阅读文献。
|
||||
- 专有名词和有特指含义的词句会使用 `“引号”` 标注,以避免歧义。
|
||||
- 重要名词、重点内容和总结性语句会被 **加粗** ,这类文字值得特别关注。
|
||||
- 当涉及编程语言之间不一致的名词时,本书均以 Python 为准,例如使用 $\text{None}$ 来表示“空”。
|
||||
- 重要名词、重点内容和总结性语句会 **加粗** ,这类文字值得特别关注。
|
||||
- 当涉及编程语言之间不一致的名词时,本书均以 Python 为准,例如使用 `None` 来表示“空”。
|
||||
- 本书部分放弃了编程语言的注释规范,以换取更加紧凑的内容排版。注释主要分为三种类型:标题注释、内容注释、多行注释。
|
||||
|
||||
=== "Python"
|
||||
@ -169,7 +169,7 @@
|
||||
|
||||
## 在动画图解中高效学习
|
||||
|
||||
相较于文字,视频和图片具有更高的信息密度和结构化程度,更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。
|
||||
相较于文字,视频和图片具有更高的信息密度和结构化程度,更易于理解。在本书中,**重点和难点知识将主要通过动画以图解形式展示**,而文字则作为解释与补充。
|
||||
|
||||
如果你在阅读本书时,发现某段内容提供了如下图所示的动画或图解,**请以图为主、以文字为辅**,综合两者来理解内容。
|
||||
|
||||
@ -209,7 +209,7 @@ git clone https://github.com/krahets/hello-algo.git
|
||||
|
||||
在阅读本书时,请不要轻易跳过那些没学明白的知识点。**欢迎在评论区提出你的问题**,我和小伙伴们将竭诚为你解答,一般情况下可在两天内回复。
|
||||
|
||||
如下图所示,每个章节的底部都配有评论区。希望你能多关注评论区的内容。一方面,你可以了解大家遇到的问题,从而查漏补缺,激发更深入的思考。另一方面,期待你能慷慨地回答其他小伙伴的问题,分享你的见解,帮助他人进步。
|
||||
如下图所示,网页版每个章节的底部都配有评论区。希望你能多关注评论区的内容。一方面,你可以了解大家遇到的问题,从而查漏补缺,激发更深入的思考。另一方面,期待你能慷慨地回答其他小伙伴的问题,分享你的见解,帮助他人进步。
|
||||
|
||||

|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# 小结
|
||||
|
||||
- 本书的主要受众是算法初学者。如果已有一定基础,本书能帮助你系统回顾算法知识,书中源代码也可作为“刷题工具库”使用。
|
||||
- 书中内容主要包括复杂度分析、数据结构、算法三部分,涵盖了该领域的大部分主题。
|
||||
- 本书的主要受众是算法初学者。如果你已有一定基础,本书能帮助你系统回顾算法知识,书中源代码也可作为“刷题工具库”使用。
|
||||
- 书中内容主要包括复杂度分析、数据结构和算法三部分,涵盖了该领域的大部分主题。
|
||||
- 对于算法新手,在初学阶段阅读一本入门书至关重要,可以少走许多弯路。
|
||||
- 书中的动画和图解通常用于介绍重点和难点知识。阅读本书时,应给予这些内容更多关注。
|
||||
- 书中的动画图解通常用于介绍重点和难点知识。阅读本书时,应给予这些内容更多关注。
|
||||
- 实践乃学习编程之最佳途径。强烈建议运行源代码并亲自敲代码。
|
||||
- 本书网页版的每个章节都设有讨论区,欢迎随时分享你的疑惑与见解。
|
||||
- 本书网页版的每个章节都设有评论区,欢迎随时分享你的疑惑与见解。
|
||||
|
||||
@ -49,9 +49,9 @@
|
||||
[file]{binary_search}-[class]{}-[func]{binary_search}
|
||||
```
|
||||
|
||||
**时间复杂度 $O(\log n)$** :在二分循环中,区间每轮缩小一半,循环次数为 $\log_2 n$ 。
|
||||
**时间复杂度为 $O(\log n)$** :在二分循环中,区间每轮缩小一半,循环次数为 $\log_2 n$ 。
|
||||
|
||||
**空间复杂度 $O(1)$** :指针 $i$ 和 $j$ 使用常数大小空间。
|
||||
**空间复杂度为 $O(1)$** :指针 $i$ 和 $j$ 使用常数大小空间。
|
||||
|
||||
## 区间表示方法
|
||||
|
||||
|
||||
@ -53,4 +53,4 @@
|
||||
代码在此省略,以下两点值得注意。
|
||||
|
||||
- 给定数组不包含小数,这意味着我们无须关心如何处理相等的情况。
|
||||
- 因为该方法引入了小数,所以需要将函数中的变量 `target` 改为浮点数类型。
|
||||
- 因为该方法引入了小数,所以需要将函数中的变量 `target` 改为浮点数类型(Python 无须改动)。
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
!!! question
|
||||
|
||||
给定一个长度为 $n$ 的有序数组 `nums` 和一个元素 `target` ,数组不存在重复元素。现将 `target` 插入数组 `nums` 中,并保持其有序性。若数组中已存在元素 `target` ,则插入到其左方。请返回插入后 `target` 在数组中的索引。
|
||||
给定一个长度为 $n$ 的有序数组 `nums` 和一个元素 `target` ,数组不存在重复元素。现将 `target` 插入数组 `nums` 中,并保持其有序性。若数组中已存在元素 `target` ,则插入到其左方。请返回插入后 `target` 在数组中的索引。示例如下图所示。
|
||||
|
||||

|
||||
|
||||
|
||||
@ -81,4 +81,4 @@
|
||||
- 适用于海量数据,因为树节点在内存中是分散存储的。
|
||||
- 适合需要维护有序数据或范围查找的场景。
|
||||
- 在持续增删节点的过程中,二叉搜索树可能产生倾斜,时间复杂度劣化至 $O(n)$ 。
|
||||
- 若使用 AVL 树或红黑树,则各项操作可在 $O(\log n)$ 效率下稳定运行,但维护树平衡的操作会增加额外开销。
|
||||
- 若使用 AVL 树或红黑树,则各项操作可在 $O(\log n)$ 效率下稳定运行,但维护树平衡的操作会增加额外的开销。
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
|
||||
设数组的长度为 $n$ ,冒泡排序的步骤如下图所示。
|
||||
|
||||
1. 首先,对 $n$ 个元素执行“冒泡”,**将数组的最大元素交换至正确位置**,
|
||||
1. 首先,对 $n$ 个元素执行“冒泡”,**将数组的最大元素交换至正确位置**。
|
||||
2. 接下来,对剩余 $n - 1$ 个元素执行“冒泡”,**将第二大元素交换至正确位置**。
|
||||
3. 以此类推,经过 $n - 1$ 轮“冒泡”后,**前 $n - 1$ 大的元素都被交换至正确位置**。
|
||||
4. 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
|
||||
|
||||
@ -24,9 +24,9 @@
|
||||
|
||||
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并。
|
||||
|
||||
- **时间复杂度 $O(n + k)$** :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间。**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。合并结果时需要遍历所有桶和元素,花费 $O(n + k)$ 时间。
|
||||
- **时间复杂度为 $O(n + k)$** :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间。**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。合并结果时需要遍历所有桶和元素,花费 $O(n + k)$ 时间。
|
||||
- **自适应排序**:在最差情况下,所有数据被分配到一个桶中,且排序该桶使用 $O(n^2)$ 时间。
|
||||
- **空间复杂度 $O(n + k)$、非原地排序**:需要借助 $k$ 个桶和总共 $n$ 个元素的额外空间。
|
||||
- **空间复杂度为 $O(n + k)$、非原地排序**:需要借助 $k$ 个桶和总共 $n$ 个元素的额外空间。
|
||||
- 桶排序是否稳定取决于排序桶内元素的算法是否稳定。
|
||||
|
||||
## 如何实现平均分配
|
||||
|
||||
@ -71,8 +71,8 @@ $$
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,时间复杂度趋于 $O(n)$ 。
|
||||
- **空间复杂度 $O(n + m)$、非原地排序**:借助了长度分别为 $n$ 和 $m$ 的数组 `res` 和 `counter` 。
|
||||
- **时间复杂度为 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ ,时间复杂度趋于 $O(n)$ 。
|
||||
- **空间复杂度为 $O(n + m)$、非原地排序**:借助了长度分别为 $n$ 和 $m$ 的数组 `res` 和 `counter` 。
|
||||
- **稳定排序**:由于向 `res` 中填充元素的顺序是“从右向左”的,因此倒序遍历 `nums` 可以避免改变相等元素之间的相对位置,从而实现稳定排序。实际上,正序遍历 `nums` 也可以得到正确的排序结果,但结果是非稳定的。
|
||||
|
||||
## 局限性
|
||||
|
||||
@ -68,6 +68,6 @@
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n \log n)$、非自适应排序**:建堆操作使用 $O(n)$ 时间。从堆中提取最大元素的时间复杂度为 $O(\log n)$ ,共循环 $n - 1$ 轮。
|
||||
- **空间复杂度 $O(1)$、原地排序**:几个指针变量使用 $O(1)$ 空间。元素交换和堆化操作都是在原数组上进行的。
|
||||
- **时间复杂度为 $O(n \log n)$、非自适应排序**:建堆操作使用 $O(n)$ 时间。从堆中提取最大元素的时间复杂度为 $O(\log n)$ ,共循环 $n - 1$ 轮。
|
||||
- **空间复杂度为 $O(1)$、原地排序**:几个指针变量使用 $O(1)$ 空间。元素交换和堆化操作都是在原数组上进行的。
|
||||
- **非稳定排序**:在交换堆顶元素和堆底元素时,相等元素的相对位置可能发生变化。
|
||||
|
||||
@ -27,11 +27,11 @@
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n^2)$、自适应排序**:在最差情况下,每次插入操作分别需要循环 $n - 1$、$n-2$、$\dots$、$2$、$1$ 次,求和得到 $(n - 1) n / 2$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。
|
||||
- **空间复杂度 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
|
||||
- **时间复杂度为 $O(n^2)$、自适应排序**:在最差情况下,每次插入操作分别需要循环 $n - 1$、$n-2$、$\dots$、$2$、$1$ 次,求和得到 $(n - 1) n / 2$ ,因此时间复杂度为 $O(n^2)$ 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ 。
|
||||
- **空间复杂度为 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
|
||||
- **稳定排序**:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。
|
||||
|
||||
## 插入排序优势
|
||||
## 插入排序的优势
|
||||
|
||||
插入排序的时间复杂度为 $O(n^2)$ ,而我们即将学习的快速排序的时间复杂度为 $O(n \log n)$ 。尽管插入排序的时间复杂度更高,**但在数据量较小的情况下,插入排序通常更快**。
|
||||
|
||||
|
||||
@ -59,8 +59,8 @@
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n \log n)$、非自适应排序**:划分产生高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,因此总体时间复杂度为 $O(n \log n)$ 。
|
||||
- **空间复杂度 $O(n)$、非原地排序**:递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间。合并操作需要借助辅助数组实现,使用 $O(n)$ 大小的额外空间。
|
||||
- **时间复杂度为 $O(n \log n)$、非自适应排序**:划分产生高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,因此总体时间复杂度为 $O(n \log n)$ 。
|
||||
- **空间复杂度为 $O(n)$、非原地排序**:递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间。合并操作需要借助辅助数组实现,使用 $O(n)$ 大小的额外空间。
|
||||
- **稳定排序**:在合并过程中,相等元素的次序保持不变。
|
||||
|
||||
## 链表排序
|
||||
|
||||
@ -61,8 +61,8 @@
|
||||
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度 $O(n \log n)$、自适应排序**:在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ ,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
|
||||
- **空间复杂度 $O(n)$、原地排序**:在输入数组完全倒序的情况下,达到最差递归深度 $n$ ,使用 $O(n)$ 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
|
||||
- **时间复杂度为 $O(n \log n)$、自适应排序**:在平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。在最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ ,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间。
|
||||
- **空间复杂度为 $O(n)$、原地排序**:在输入数组完全倒序的情况下,达到最差递归深度 $n$ ,使用 $O(n)$ 栈帧空间。排序操作是在原数组上进行的,未借助额外数组。
|
||||
- **非稳定排序**:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。
|
||||
|
||||
## 快速排序为什么快
|
||||
|
||||
@ -20,7 +20,7 @@ $$
|
||||
x_k = \lfloor\frac{x}{d^{k-1}}\rfloor \bmod d
|
||||
$$
|
||||
|
||||
其中 $\lfloor a \rfloor$ 表示对浮点数 $a$ 向下取整,而 $\bmod \: d$ 表示对 $d$ 取余。对于学号数据,$d = 10$ 且 $k \in [1, 8]$ 。
|
||||
其中 $\lfloor a \rfloor$ 表示对浮点数 $a$ 向下取整,而 $\bmod \: d$ 表示对 $d$ 取模(取余)。对于学号数据,$d = 10$ 且 $k \in [1, 8]$ 。
|
||||
|
||||
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 $k$ 位进行排序:
|
||||
|
||||
@ -36,6 +36,6 @@ $$
|
||||
|
||||
相较于计数排序,基数排序适用于数值范围较大的情况,**但前提是数据必须可以表示为固定位数的格式,且位数不能过大**。例如,浮点数不适合使用基数排序,因为其位数 $k$ 过大,可能导致时间复杂度 $O(nk) \gg O(n^2)$ 。
|
||||
|
||||
- **时间复杂度 $O(nk)$**:设数据量为 $n$、数据为 $d$ 进制、最大位数为 $k$ ,则对某一位执行计数排序使用 $O(n + d)$ 时间,排序所有 $k$ 位使用 $O((n + d)k)$ 时间。通常情况下,$d$ 和 $k$ 都相对较小,时间复杂度趋向 $O(n)$ 。
|
||||
- **空间复杂度 $O(n + d)$、非原地排序**:与计数排序相同,基数排序需要借助长度为 $n$ 和 $d$ 的数组 `res` 和 `counter` 。
|
||||
- **时间复杂度为 $O(nk)$**:设数据量为 $n$、数据为 $d$ 进制、最大位数为 $k$ ,则对某一位执行计数排序使用 $O(n + d)$ 时间,排序所有 $k$ 位使用 $O((n + d)k)$ 时间。通常情况下,$d$ 和 $k$ 都相对较小,时间复杂度趋向 $O(n)$ 。
|
||||
- **空间复杂度为 $O(n + d)$、非原地排序**:与计数排序相同,基数排序需要借助长度为 $n$ 和 $d$ 的数组 `res` 和 `counter` 。
|
||||
- **稳定排序**:当计数排序稳定时,基数排序也稳定;当计数排序不稳定时,基数排序无法保证得到正确的排序结果。
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
## 算法特性
|
||||
|
||||
- **时间复杂度为 $O(n^2)$、非自适应排序**:外循环共 $n - 1$ 轮,第一轮的未排序区间长度为 $n$ ,最后一轮的未排序区间长度为 $2$ ,即各轮外循环分别包含 $n$、$n - 1$、$\dots$、$3$、$2$ 轮内循环,求和为 $\frac{(n - 1)(n + 2)}{2}$ 。
|
||||
- **空间复杂度 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
|
||||
- **空间复杂度为 $O(1)$、原地排序**:指针 $i$ 和 $j$ 使用常数大小的额外空间。
|
||||
- **非稳定排序**:如下图所示,元素 `nums[i]` 有可能被交换至与其相等的元素的右边,导致两者的相对顺序发生改变。
|
||||
|
||||

|
||||
|
||||
@ -18,9 +18,7 @@
|
||||
|
||||
!!! question "排序算法稳定性在什么情况下是必需的?"
|
||||
|
||||
在现实中,我们有可能是基于对象的某个属性进行排序。例如,学生有姓名和身高两个属性,我们希望实现一个多级排序:
|
||||
|
||||
先按照姓名进行排序,得到 `(A, 180) (B, 185) (C, 170) (D, 170)` ;再对身高进行排序。由于排序算法不稳定,因此可能得到 `(D, 170) (C, 170) (A, 180) (B, 185)` 。
|
||||
在现实中,我们有可能基于对象的某个属性进行排序。例如,学生有姓名和身高两个属性,我们希望实现一个多级排序:先按照姓名进行排序,得到 `(A, 180) (B, 185) (C, 170) (D, 170)` ;再对身高进行排序。由于排序算法不稳定,因此可能得到 `(D, 170) (C, 170) (A, 180) (B, 185)` 。
|
||||
|
||||
可以发现,学生 D 和 C 的位置发生了交换,姓名的有序性被破坏了,而这是我们不希望看到的。
|
||||
|
||||
|
||||
@ -10,14 +10,14 @@
|
||||
|
||||
<p align="center"> 表 <id> 双向队列操作效率 </p>
|
||||
|
||||
| 方法名 | 描述 | 时间复杂度 |
|
||||
| ----------- | ---------------- | ---------- |
|
||||
| pushFirst() | 将元素添加至队首 | $O(1)$ |
|
||||
| pushLast() | 将元素添加至队尾 | $O(1)$ |
|
||||
| popFirst() | 删除队首元素 | $O(1)$ |
|
||||
| popLast() | 删除队尾元素 | $O(1)$ |
|
||||
| peekFirst() | 访问队首元素 | $O(1)$ |
|
||||
| peekLast() | 访问队尾元素 | $O(1)$ |
|
||||
| 方法名 | 描述 | 时间复杂度 |
|
||||
| ------------- | ---------------- | ---------- |
|
||||
| `pushFirst()` | 将元素添加至队首 | $O(1)$ |
|
||||
| `pushLast()` | 将元素添加至队尾 | $O(1)$ |
|
||||
| `popFirst()` | 删除队首元素 | $O(1)$ |
|
||||
| `popLast()` | 删除队尾元素 | $O(1)$ |
|
||||
| `peekFirst()` | 访问队首元素 | $O(1)$ |
|
||||
| `peekLast()` | 访问队尾元素 | $O(1)$ |
|
||||
|
||||
同样地,我们可以直接使用编程语言中已实现的双向队列类:
|
||||
|
||||
|
||||
@ -12,11 +12,11 @@
|
||||
|
||||
<p align="center"> 表 <id> 队列操作效率 </p>
|
||||
|
||||
| 方法名 | 描述 | 时间复杂度 |
|
||||
| ------ | ---------------------------- | ---------- |
|
||||
| push() | 元素入队,即将元素添加至队尾 | $O(1)$ |
|
||||
| pop() | 队首元素出队 | $O(1)$ |
|
||||
| peek() | 访问队首元素 | $O(1)$ |
|
||||
| 方法名 | 描述 | 时间复杂度 |
|
||||
| -------- | ---------------------------- | ---------- |
|
||||
| `push()` | 元素入队,即将元素添加至队尾 | $O(1)$ |
|
||||
| `pop()` | 队首元素出队 | $O(1)$ |
|
||||
| `peek()` | 访问队首元素 | $O(1)$ |
|
||||
|
||||
我们可以直接使用编程语言中现成的队列类:
|
||||
|
||||
@ -310,7 +310,7 @@
|
||||
|
||||
## 队列实现
|
||||
|
||||
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。链表和数组都符合要求。
|
||||
为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素,链表和数组都符合要求。
|
||||
|
||||
### 基于链表的实现
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 栈
|
||||
|
||||
「栈 stack」是一种遵循先入后出的逻辑的线性数据结构。
|
||||
「栈 stack」是一种遵循先入后出逻辑的线性数据结构。
|
||||
|
||||
我们可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。
|
||||
|
||||
@ -8,17 +8,17 @@
|
||||
|
||||

|
||||
|
||||
## 栈常用操作
|
||||
## 栈的常用操作
|
||||
|
||||
栈的常用操作如下表所示,具体的方法名需要根据所使用的编程语言来确定。在此,我们以常见的 `push()`、`pop()`、`peek()` 命名为例。
|
||||
|
||||
<p align="center"> 表 <id> 栈的操作效率 </p>
|
||||
|
||||
| 方法 | 描述 | 时间复杂度 |
|
||||
| ------ | ---------------------- | ---------- |
|
||||
| push() | 元素入栈(添加至栈顶) | $O(1)$ |
|
||||
| pop() | 栈顶元素出栈 | $O(1)$ |
|
||||
| peek() | 访问栈顶元素 | $O(1)$ |
|
||||
| 方法 | 描述 | 时间复杂度 |
|
||||
| -------- | ---------------------- | ---------- |
|
||||
| `push()` | 元素入栈(添加至栈顶) | $O(1)$ |
|
||||
| `pop()` | 栈顶元素出栈 | $O(1)$ |
|
||||
| `peek()` | 访问栈顶元素 | $O(1)$ |
|
||||
|
||||
通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”当作栈来使用,并在程序逻辑上忽略与栈无关的操作。
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
|
||||
```python title="stack.py"
|
||||
# 初始化栈
|
||||
# Python 没有内置的栈类,可以把 List 当作栈来使用
|
||||
# Python 没有内置的栈类,可以把 list 当作栈来使用
|
||||
stack: list[int] = []
|
||||
|
||||
# 元素入栈
|
||||
|
||||
@ -16,13 +16,13 @@
|
||||
|
||||
## 表示任意二叉树
|
||||
|
||||
完美二叉树是一个特例,在二叉树的中间层通常存在许多 $\text{None}$ 。由于层序遍历序列并不包含这些 $\text{None}$ ,因此我们无法仅凭该序列来推测 $\text{None}$ 的数量和分布位置。**这意味着存在多种二叉树结构都符合该层序遍历序列**。
|
||||
完美二叉树是一个特例,在二叉树的中间层通常存在许多 `None` 。由于层序遍历序列并不包含这些 `None` ,因此我们无法仅凭该序列来推测 `None` 的数量和分布位置。**这意味着存在多种二叉树结构都符合该层序遍历序列**。
|
||||
|
||||
如下图所示,给定一棵非完美二叉树,上述数组表示方法已经失效。
|
||||
|
||||

|
||||
|
||||
为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 $\text{None}$** 。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。示例代码如下:
|
||||
为了解决此问题,**我们可以考虑在层序遍历序列中显式地写出所有 `None`** 。如下图所示,这样处理后,层序遍历序列就可以唯一表示二叉树了。示例代码如下:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@ -120,9 +120,9 @@
|
||||
|
||||

|
||||
|
||||
值得说明的是,**完全二叉树非常适合使用数组来表示**。回顾完全二叉树的定义,$\text{None}$ 只出现在最底层且靠右的位置,**因此所有 $\text{None}$ 一定出现在层序遍历序列的末尾**。
|
||||
值得说明的是,**完全二叉树非常适合使用数组来表示**。回顾完全二叉树的定义,`None` 只出现在最底层且靠右的位置,**因此所有 `None` 一定出现在层序遍历序列的末尾**。
|
||||
|
||||
这意味着使用数组表示完全二叉树时,可以省略存储所有 $\text{None}$ ,非常方便。下图给出了一个例子。
|
||||
这意味着使用数组表示完全二叉树时,可以省略存储所有 `None` ,非常方便。下图给出了一个例子。
|
||||
|
||||

|
||||
|
||||
@ -147,4 +147,4 @@
|
||||
|
||||
- 数组存储需要连续内存空间,因此不适合存储数据量过大的树。
|
||||
- 增删节点需要通过数组插入与删除操作实现,效率较低。
|
||||
- 当二叉树中存在大量 $\text{None}$ 时,数组中包含的节点数据比重较低,空间利用率较低。
|
||||
- 当二叉树中存在大量 `None` 时,数组中包含的节点数据比重较低,空间利用率较低。
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
# AVL 树 *
|
||||
|
||||
在“二叉搜索树”章节中,我们提到,在多次插入和删除操作后,二叉搜索树可能退化为链表。在这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$ 。
|
||||
在“二叉搜索树”章节中我们提到,在多次插入和删除操作后,二叉搜索树可能退化为链表。在这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 劣化为 $O(n)$ 。
|
||||
|
||||
如下图所示,经过两次删除节点操作,这棵二叉搜索树便会退化为链表。
|
||||
|
||||

|
||||
|
||||
再例如,在下图所示的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之恶化。
|
||||
再例如,在下图所示的完美二叉树中插入两个节点后,树将严重向左倾斜,查找操作的时间复杂度也随之劣化。
|
||||
|
||||

|
||||
|
||||
1962 年 G. M. Adelson-Velsky 和 E. M. Landis 在论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
|
||||
1962 年 G. M. Adelson-Velsky 和 E. M. Landis 在论文“An algorithm for the organization of information”中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
|
||||
|
||||
## AVL 树常见术语
|
||||
|
||||
@ -206,7 +206,7 @@ AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉
|
||||
|
||||
```
|
||||
|
||||
“节点高度”是指从该节点到其最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 $0$ ,而空节点的高度为 $-1$ 。我们将创建两个工具函数,分别用于获取和更新节点的高度:
|
||||
“节点高度”是指从该节点到它的最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 $0$ ,而空节点的高度为 $-1$ 。我们将创建两个工具函数,分别用于获取和更新节点的高度:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{a_v_l_tree}-[func]{update_height}
|
||||
@ -246,9 +246,9 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
|
||||
=== "<4>"
|
||||

|
||||
|
||||
如下图所示,当节点 `child` 有右子节点(记为 `grandChild` )时,需要在右旋中添加一步:将 `grandChild` 作为 `node` 的左子节点。
|
||||
如下图所示,当节点 `child` 有右子节点(记为 `grand_child` )时,需要在右旋中添加一步:将 `grand_child` 作为 `node` 的左子节点。
|
||||
|
||||

|
||||

|
||||
|
||||
“向右旋转”是一种形象化的说法,实际上需要通过修改节点指针来实现,代码如下所示:
|
||||
|
||||
@ -262,9 +262,9 @@ AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中
|
||||
|
||||

|
||||
|
||||
同理,如下图所示,当节点 `child` 有左子节点(记为 `grandChild` )时,需要在左旋中添加一步:将 `grandChild` 作为 `node` 的右子节点。
|
||||
同理,如下图所示,当节点 `child` 有左子节点(记为 `grand_child` )时,需要在左旋中添加一步:将 `grand_child` 作为 `node` 的右子节点。
|
||||
|
||||

|
||||

|
||||
|
||||
可以观察到,**右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的**。基于对称性,我们只需将右旋的实现代码中的所有的 `left` 替换为 `right` ,将所有的 `right` 替换为 `left` ,即可得到左旋的实现代码:
|
||||
|
||||
|
||||
@ -41,15 +41,15 @@
|
||||
|
||||
给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作流程如下图所示。
|
||||
|
||||
1. **查找插入位置**:与查找操作相似,从根节点出发,根据当前节点值和 `num` 的大小关系循环向下搜索,直到越过叶节点(遍历至 $\text{None}$ )时跳出循环。
|
||||
2. **在该位置插入节点**:初始化节点 `num` ,将该节点置于 $\text{None}$ 的位置。
|
||||
1. **查找插入位置**:与查找操作相似,从根节点出发,根据当前节点值和 `num` 的大小关系循环向下搜索,直到越过叶节点(遍历至 `None` )时跳出循环。
|
||||
2. **在该位置插入节点**:初始化节点 `num` ,将该节点置于 `None` 的位置。
|
||||
|
||||

|
||||
|
||||
在代码实现中,需要注意以下两点。
|
||||
|
||||
- 二叉搜索树不允许存在重复节点,否则将违反其定义。因此,若待插入节点在树中已存在,则不执行插入,直接返回。
|
||||
- 为了实现插入节点,我们需要借助节点 `pre` 保存上一轮循环的节点。这样在遍历至 $\text{None}$ 时,我们可以获取到其父节点,从而完成节点插入操作。
|
||||
- 为了实现插入节点,我们需要借助节点 `pre` 保存上一轮循环的节点。这样在遍历至 `None` 时,我们可以获取到其父节点,从而完成节点插入操作。
|
||||
|
||||
```src
|
||||
[file]{binary_search_tree}-[class]{binary_search_tree}-[func]{insert}
|
||||
@ -59,11 +59,7 @@
|
||||
|
||||
### 删除节点
|
||||
|
||||
先在二叉树中查找到目标节点,再将其删除。
|
||||
|
||||
与插入节点类似,我们需要保证在删除操作完成后,二叉搜索树的“左子树 < 根节点 < 右子树”的性质仍然满足。
|
||||
|
||||
因此,我们根据目标节点的子节点数量,分 0、1 和 2 三种情况,执行对应的删除节点操作。
|
||||
先在二叉树中查找到目标节点,再将其删除。与插入节点类似,我们需要保证在删除操作完成后,二叉搜索树的“左子树 < 根节点 < 右子树”的性质仍然满足。因此,我们根据目标节点的子节点数量,分 0、1 和 2 三种情况,执行对应的删除节点操作。
|
||||
|
||||
如下图所示,当待删除节点的度为 $0$ 时,表示该节点是叶节点,可以直接删除。
|
||||
|
||||
|
||||
@ -193,7 +193,7 @@
|
||||
二叉树的常用术语如下图所示。
|
||||
|
||||
- 「根节点 root node」:位于二叉树顶层的节点,没有父节点。
|
||||
- 「叶节点 leaf node」:没有子节点的节点,其两个指针均指向 $\text{None}$ 。
|
||||
- 「叶节点 leaf node」:没有子节点的节点,其两个指针均指向 `None` 。
|
||||
- 「边 edge」:连接两个节点的线段,即节点引用(指针)。
|
||||
- 节点所在的「层 level」:从顶至底递增,根节点所在层为 1 。
|
||||
- 节点的「度 degree」:节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
如下图所示,「层序遍历 level-order traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
|
||||
|
||||
层序遍历本质上属于「广度优先遍历 breadth-first traversal, BFS」,它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
|
||||
层序遍历本质上属于「广度优先遍历 breadth-first traversal」,也称「广度优先搜索 breadth-first search, BFS」,它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
|
||||
|
||||

|
||||
|
||||
@ -22,12 +22,12 @@
|
||||
|
||||
### 复杂度分析
|
||||
|
||||
- **时间复杂度 $O(n)$** :所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
||||
- **空间复杂度 $O(n)$** :在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 $(n + 1) / 2$ 个节点,占用 $O(n)$ 空间。
|
||||
- **时间复杂度为 $O(n)$** :所有节点被访问一次,使用 $O(n)$ 时间,其中 $n$ 为节点数量。
|
||||
- **空间复杂度为 $O(n)$** :在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 $(n + 1) / 2$ 个节点,占用 $O(n)$ 空间。
|
||||
|
||||
## 前序、中序、后序遍历
|
||||
|
||||
相应地,前序、中序和后序遍历都属于「深度优先遍历 depth-first traversal, DFS」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。
|
||||
相应地,前序、中序和后序遍历都属于「深度优先遍历 depth-first traversal」,也称「深度优先搜索 depth-first search, DFS」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。
|
||||
|
||||
下图展示了对二叉树进行深度优先遍历的工作原理。**深度优先遍历就像是绕着整棵二叉树的外围“走”一圈**,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
|
||||
|
||||
@ -85,5 +85,5 @@
|
||||
|
||||
### 复杂度分析
|
||||
|
||||
- **时间复杂度 $O(n)$** :所有节点被访问一次,使用 $O(n)$ 时间。
|
||||
- **空间复杂度 $O(n)$** :在最差情况下,即树退化为链表时,递归深度达到 $n$ ,系统占用 $O(n)$ 栈帧空间。
|
||||
- **时间复杂度为 $O(n)$** :所有节点被访问一次,使用 $O(n)$ 时间。
|
||||
- **空间复杂度为 $O(n)$** :在最差情况下,即树退化为链表时,递归深度达到 $n$ ,系统占用 $O(n)$ 栈帧空间。
|
||||
|
||||
Reference in New Issue
Block a user