From 44a856835642d99e07f9c54d726ea55667df0567 Mon Sep 17 00:00:00 2001 From: krahets Date: Sun, 20 Aug 2023 13:37:08 +0800 Subject: [PATCH] build --- chapter_array_and_linkedlist/array.md | 14 ++++++------- chapter_array_and_linkedlist/linked_list.md | 10 +++++----- chapter_array_and_linkedlist/list.md | 12 +++++------ chapter_backtracking/n_queens_problem.md | 6 +++--- chapter_backtracking/permutations_problem.md | 10 +++++----- chapter_backtracking/subset_sum_problem.md | 10 +++++----- .../performance_evaluation.md | 6 +++--- .../space_complexity.md | 10 +++++----- .../time_complexity.md | 18 ++++++++--------- .../binary_search_recur.md | 2 +- .../build_binary_tree_problem.md | 8 ++++---- .../divide_and_conquer.md | 4 ++-- chapter_divide_and_conquer/hanota_problem.md | 6 +++--- .../dp_solution_pipeline.md | 8 ++++---- .../edit_distance_problem.md | 6 ++++-- .../knapsack_problem.md | 8 ++++---- .../unbounded_knapsack_problem.md | 18 +++++++++++------ chapter_graph/graph.md | 4 ++-- chapter_graph/graph_traversal.md | 8 ++++---- chapter_greedy/fractional_knapsack_problem.md | 6 +++--- chapter_greedy/max_capacity_problem.md | 6 +++--- chapter_greedy/max_product_cutting_problem.md | 6 +++--- chapter_hashing/hash_collision.md | 4 ++-- chapter_heap/build_heap.md | 14 +++++++------ chapter_heap/heap.md | 8 ++++---- chapter_searching/binary_search_edge.md | 4 ++-- chapter_stack_and_queue/deque.md | 4 ++-- chapter_stack_and_queue/queue.md | 4 ++-- chapter_stack_and_queue/stack.md | 10 +++++----- chapter_tree/avl_tree.md | 20 +++++++++---------- chapter_tree/binary_search_tree.md | 8 ++++---- chapter_tree/binary_tree.md | 8 ++++---- 32 files changed, 140 insertions(+), 130 deletions(-) diff --git a/chapter_array_and_linkedlist/array.md b/chapter_array_and_linkedlist/array.md index ca7605eff..9df64a3df 100755 --- a/chapter_array_and_linkedlist/array.md +++ b/chapter_array_and_linkedlist/array.md @@ -12,7 +12,7 @@ comments: true ## 4.1.1   数组常用操作 -### 初始化数组 +### 1.   初始化数组 我们可以根据需求选用数组的两种初始化方式:无初始值、给定初始值。在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 $0$ 。 @@ -118,7 +118,7 @@ comments: true let nums: Vec = vec![1, 3, 2, 5, 4]; ``` -### 访问元素 +### 2.   访问元素 数组元素被存储在连续的内存空间中,这意味着计算数组元素的内存地址非常容易。给定数组内存地址(即首元素内存地址)和某个元素的索引,我们可以使用以下公式计算得到该元素的内存地址,从而直接访问此元素。 @@ -291,7 +291,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex } ``` -### 插入元素 +### 3.   插入元素 数组元素在内存中是“紧挨着的”,它们之间没有空间再存放任何数据。这意味着如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。 @@ -468,7 +468,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex } ``` -### 删除元素 +### 4.   删除元素 同理,如果我们想要删除索引 $i$ 处的元素,则需要把索引 $i$ 之后的元素都向前移动一位。 @@ -629,7 +629,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex - **丢失元素**:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。 - **内存浪费**:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做也会造成部分内存空间的浪费。 -### 遍历数组 +### 5.   遍历数组 在大多数编程语言中,我们既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素。 @@ -836,7 +836,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex } ``` -### 查找元素 +### 6.   查找元素 在数组中查找指定元素需要遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。 @@ -999,7 +999,7 @@ elementAddr = firtstElementAddr + elementLength * elementIndex } ``` -### 扩容数组 +### 7.   扩容数组 在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在大多数编程语言中,**数组的长度是不可变的**。 diff --git a/chapter_array_and_linkedlist/linked_list.md b/chapter_array_and_linkedlist/linked_list.md index b947325b0..6a7c1f5a1 100755 --- a/chapter_array_and_linkedlist/linked_list.md +++ b/chapter_array_and_linkedlist/linked_list.md @@ -190,7 +190,7 @@ comments: true ## 4.2.1   链表常用操作 -### 初始化链表 +### 1.   初始化链表 建立链表分为两步,第一步是初始化各个节点对象,第二步是构建引用指向关系。初始化完成后,我们就可以从链表的头节点出发,通过引用指向 `next` 依次访问所有节点。 @@ -401,7 +401,7 @@ comments: true 数组整体是一个变量,比如数组 `nums` 包含元素 `nums[0]` , `nums[1]` 等,而链表是由多个独立的节点对象组成的。**我们通常将头节点当作链表的代称**,比如以上代码中的链表可被记做链表 `n0` 。 -### 插入节点 +### 2.   插入节点 **在链表中插入节点非常容易**。假设我们想在相邻的两个节点 `n0` , `n1` 之间插入一个新节点 `P` ,则只需要改变两个节点引用(指针)即可,时间复杂度为 $O(1)$ 。 @@ -543,7 +543,7 @@ comments: true } ``` -### 删除节点 +### 3.   删除节点 在链表中删除节点也非常简便,只需改变一个节点的引用(指针)即可。 @@ -728,7 +728,7 @@ comments: true } ``` -### 访问节点 +### 4.   访问节点 **在链表访问节点的效率较低**。如上节所述,我们可以在 $O(1)$ 时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 $i$ 个节点需要循环 $i - 1$ 轮,时间复杂度为 $O(n)$ 。 @@ -901,7 +901,7 @@ comments: true } ``` -### 查找节点 +### 5.   查找节点 遍历链表,查找链表内值为 `target` 的节点,输出节点在链表中的索引。此过程也属于「线性查找」。 diff --git a/chapter_array_and_linkedlist/list.md b/chapter_array_and_linkedlist/list.md index cab351b0f..d157ab6e0 100755 --- a/chapter_array_and_linkedlist/list.md +++ b/chapter_array_and_linkedlist/list.md @@ -10,7 +10,7 @@ comments: true ## 4.3.1   列表常用操作 -### 初始化列表 +### 1.   初始化列表 我们通常使用“无初始值”和“有初始值”这两种初始化方法。 @@ -132,7 +132,7 @@ comments: true let list2: Vec = vec![1, 3, 2, 5, 4]; ``` -### 访问元素 +### 2.   访问元素 列表本质上是数组,因此可以在 $O(1)$ 时间内访问和更新元素,效率很高。 @@ -251,7 +251,7 @@ comments: true list[1] = 0; // 将索引 1 处的元素更新为 0 ``` -### 插入与删除元素 +### 3.   插入与删除元素 相较于数组,列表可以自由地添加与删除元素。在列表尾部添加元素的时间复杂度为 $O(1)$ ,但插入和删除元素的效率仍与数组相同,时间复杂度为 $O(n)$ 。 @@ -481,7 +481,7 @@ comments: true list.remove(3); // 删除索引 3 处的元素 ``` -### 遍历列表 +### 4.   遍历列表 与数组一样,列表可以根据索引遍历,也可以直接遍历各元素。 @@ -666,7 +666,7 @@ comments: true } ``` -### 拼接列表 +### 5.   拼接列表 给定一个新列表 `list1` ,我们可以将该列表拼接到原列表的尾部。 @@ -767,7 +767,7 @@ comments: true list.extend(list1); ``` -### 排序列表 +### 6.   排序列表 完成列表排序后,我们便可以使用在数组类算法题中经常考察的“二分查找”和“双指针”算法。 diff --git a/chapter_backtracking/n_queens_problem.md b/chapter_backtracking/n_queens_problem.md index cb2805ace..5dbaa7a59 100644 --- a/chapter_backtracking/n_queens_problem.md +++ b/chapter_backtracking/n_queens_problem.md @@ -20,7 +20,7 @@ comments: true

图:n 皇后问题的约束条件

-### 逐行放置策略 +### 1.   逐行放置策略 皇后的数量和棋盘的行数都为 $n$ ,因此我们容易得到一个推论:**棋盘每行都允许且只允许放置一个皇后**。 @@ -34,7 +34,7 @@ comments: true 本质上看,**逐行放置策略起到了剪枝的作用**,它避免了同一行出现多个皇后的所有搜索分支。 -### 列与对角线剪枝 +### 2.   列与对角线剪枝 为了满足列约束,我们可以利用一个长度为 $n$ 的布尔型数组 `cols` 记录每一列是否有皇后。在每次决定放置前,我们通过 `cols` 将已有皇后的列进行剪枝,并在回溯中动态更新 `cols` 的状态。 @@ -48,7 +48,7 @@ comments: true

图:处理列约束和对角线约束

-### 代码实现 +### 3.   代码实现 请注意,$n$ 维方阵中 $row - col$ 的范围是 $[-n + 1, n - 1]$ ,$row + col$ 的范围是 $[0, 2n - 2]$ ,所以主对角线和次对角线的数量都为 $2n - 1$ ,即数组 `diag1` 和 `diag2` 的长度都为 $2n - 1$ 。 diff --git a/chapter_backtracking/permutations_problem.md b/chapter_backtracking/permutations_problem.md index 52ed0519c..f2f07c0e2 100644 --- a/chapter_backtracking/permutations_problem.md +++ b/chapter_backtracking/permutations_problem.md @@ -36,7 +36,7 @@ comments: true

图:全排列的递归树

-### 重复选择剪枝 +### 1.   重复选择剪枝 为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 `selected` ,其中 `selected[i]` 表示 `choices[i]` 是否已被选择。剪枝的实现原理为: @@ -51,7 +51,7 @@ comments: true 观察上图发现,该剪枝操作将搜索空间大小从 $O(n^n)$ 降低至 $O(n!)$ 。 -### 代码实现 +### 2.   代码实现 想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短代码行数,我们不单独实现框架代码中的各个函数,而是将他们展开在 `backtrack()` 函数中。 @@ -489,7 +489,7 @@ comments: true 那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,**因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝**,这样可以进一步提升算法效率。 -### 相等元素剪枝 +### 1.   相等元素剪枝 观察发现,在第一轮中,选择 $1$ 或选择 $\hat{1}$ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 $\hat{1}$ 剪枝掉。 @@ -501,7 +501,7 @@ comments: true

图:重复排列剪枝

-### 代码实现 +### 2.   代码实现 在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 `duplicated` ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝。 @@ -905,7 +905,7 @@ comments: true 最大递归深度为 $n$ ,使用 $O(n)$ 栈帧空间。`selected` 使用 $O(n)$ 空间。同一时刻最多共有 $n$ 个 `duplicated` ,使用 $O(n^2)$ 空间。**因此空间复杂度为 $O(n^2)$** 。 -### 两种剪枝对比 +### 3.   两种剪枝对比 请注意,虽然 `selected` 和 `duplicated` 都用作剪枝,但两者的目标不同: diff --git a/chapter_backtracking/subset_sum_problem.md b/chapter_backtracking/subset_sum_problem.md index 57c1b6d70..20f989839 100644 --- a/chapter_backtracking/subset_sum_problem.md +++ b/chapter_backtracking/subset_sum_problem.md @@ -15,7 +15,7 @@ comments: true - 输入集合中的元素可以被无限次重复选取。 - 子集是不区分元素顺序的,比如 $\{4, 5\}$ 和 $\{5, 4\}$ 是同一个子集。 -### 参考全排列解法 +### 1.   参考全排列解法 类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 `target` 时,就将子集记录至结果列表。 @@ -445,7 +445,7 @@ comments: true - 当数组元素较多,尤其是当 `target` 较大时,搜索过程会产生大量的重复子集。 - 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。 -### 重复子集剪枝 +### 2.   重复子集剪枝 **我们考虑在搜索过程中通过剪枝进行去重**。观察下图,重复子集是在以不同顺序选择数组元素时产生的,具体来看: @@ -464,7 +464,7 @@ comments: true 总结来看,给定输入数组 $[x_1, x_2, \cdots, x_n]$ ,设搜索过程中的选择序列为 $[x_{i_1}, x_{i_2}, \cdots , x_{i_m}]$ ,则该选择序列需要满足 $i_1 \leq i_2 \leq \cdots \leq i_m$ ,**不满足该条件的选择序列都会造成重复,应当剪枝**。 -### 代码实现 +### 3.   代码实现 为实现该剪枝,我们初始化变量 `start` ,用于指示遍历起点。**当做出选择 $x_{i}$ 后,设定下一轮从索引 $i$ 开始遍历**。这样做就可以让选择序列满足 $i_1 \leq i_2 \leq \cdots \leq i_m$ ,从而保证子集唯一。 @@ -932,13 +932,13 @@ comments: true

图:相等元素导致的重复子集

-### 相等元素剪枝 +### 1.   相等元素剪枝 为解决此问题,**我们需要限制相等元素在每一轮中只被选择一次**。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。 与此同时,**本题规定中的每个数组元素只能被选择一次**。幸运的是,我们也可以利用变量 `start` 来满足该约束:当做出选择 $x_{i}$ 后,设定下一轮从索引 $i + 1$ 开始向后遍历。这样即能去除重复子集,也能避免重复选择元素。 -### 代码实现 +### 2.   代码实现 === "Java" diff --git a/chapter_computational_complexity/performance_evaluation.md b/chapter_computational_complexity/performance_evaluation.md index 58ab418ba..a02a96888 100644 --- a/chapter_computational_complexity/performance_evaluation.md +++ b/chapter_computational_complexity/performance_evaluation.md @@ -30,10 +30,10 @@ comments: true 由于实际测试具有较大的局限性,我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 Asymptotic Complexity Analysis」,简称为「复杂度分析」。 -**复杂度分析评估的是算法运行效率随着输入数据量增多时的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解: +复杂度分析评估的是算法执行所需的时间和空间资源。**它被表示为一个函数,描述了随着输入数据大小的增加,算法所需时间(空间)的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解: -1. “算法运行效率”可分为运行时间和占用空间两部分,与之对应地,复杂度可分为「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」。 -2. “随着输入数据量增多时”意味着复杂度反映了算法运行效率与输入数据量之间的关系。 +1. “时间(空间)”分别对应「时间复杂度 Time Complexity」和「空间复杂度 Space Complexity」。 +2. “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。 3. “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间。 **复杂度分析克服了实际测试方法的弊端**。首先,它独立于测试环境,分析结果适用于所有运行平台。其次,它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。 diff --git a/chapter_computational_complexity/space_complexity.md b/chapter_computational_complexity/space_complexity.md index c8496a2d5..d51f57337 100755 --- a/chapter_computational_complexity/space_complexity.md +++ b/chapter_computational_complexity/space_complexity.md @@ -675,7 +675,7 @@ $$ 部分示例代码需要一些前置知识,包括数组、链表、二叉树、递归算法等。如果你遇到看不懂的地方,可以在学习完后面章节后再来复习。 -### 常数阶 $O(1)$ +### 1.   常数阶 $O(1)$ 常数阶常见于数量与输入数据大小 $n$ 无关的常量、变量、对象。 @@ -1008,7 +1008,7 @@ $$ } ``` -### 线性阶 $O(n)$ +### 2.   线性阶 $O(n)$ 线性阶常见于元素数量与 $n$ 成正比的数组、链表、栈、队列等。 @@ -1419,7 +1419,7 @@ $$

图:递归函数产生的线性阶空间复杂度

-### 平方阶 $O(n^2)$ +### 3.   平方阶 $O(n^2)$ 平方阶常见于矩阵和图,元素数量与 $n$ 成平方关系。 @@ -1798,7 +1798,7 @@ $$

图:递归函数产生的平方阶空间复杂度

-### 指数阶 $O(2^n)$ +### 4.   指数阶 $O(2^n)$ 指数阶常见于二叉树。高度为 $n$ 的「满二叉树」的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间。 @@ -1970,7 +1970,7 @@ $$

图:满二叉树产生的指数阶空间复杂度

-### 对数阶 $O(\log n)$ +### 5.   对数阶 $O(\log n)$ 对数阶常见于分治算法和数据类型转换等。 diff --git a/chapter_computational_complexity/time_complexity.md b/chapter_computational_complexity/time_complexity.md index 268660102..538282385 100755 --- a/chapter_computational_complexity/time_complexity.md +++ b/chapter_computational_complexity/time_complexity.md @@ -644,7 +644,7 @@ $T(n)$ 是一次函数,说明时间的增长趋势是线性的,因此其时 根据定义,确定 $f(n)$ 之后,我们便可得到时间复杂度 $O(f(n))$ 。那么如何确定渐近上界 $f(n)$ 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。 -### 第一步:统计操作数量 +### 1.   第一步:统计操作数量 针对代码,逐行从上到下计算即可。然而,由于上述 $c \cdot f(n)$ 中的常数项 $c$ 可以取任意大小,**因此操作数量 $T(n)$ 中的各种系数、常数项都可以被忽略**。根据此原则,可以总结出以下计数简化技巧: @@ -875,7 +875,7 @@ $$ } ``` -### 第二步:判断渐近上界 +### 2.   第二步:判断渐近上界 **时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。 @@ -913,7 +913,7 @@ $$ 部分示例代码需要一些预备知识,包括数组、递归等。如果你遇到不理解的部分,可以在学习完后面章节后再回顾。现阶段,请先专注于理解时间复杂度的含义和推算方法。 -### 常数阶 $O(1)$ +### 1.   常数阶 $O(1)$ 常数阶的操作数量与输入数据大小 $n$ 无关,即不随着 $n$ 的变化而变化。 @@ -1082,7 +1082,7 @@ $$ } ``` -### 线性阶 $O(n)$ +### 2.   线性阶 $O(n)$ 线性阶的操作数量相对于输入数据大小以线性级别增长。线性阶通常出现在单层循环中。 @@ -1404,7 +1404,7 @@ $$ 值得注意的是,**数据大小 $n$ 需根据输入数据的类型来具体确定**。比如在第一个示例中,变量 $n$ 为输入数据大小;在第二个示例中,数组长度 $n$ 为数据大小。 -### 平方阶 $O(n^2)$ +### 3.   平方阶 $O(n^2)$ 平方阶的操作数量相对于输入数据大小以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$ 。 @@ -1881,7 +1881,7 @@ $$ } ``` -### 指数阶 $O(2^n)$ +### 4.   指数阶 $O(2^n)$ 生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 $1$ 个细胞,分裂一轮后变为 $2$ 个,分裂两轮后变为 $4$ 个,以此类推,分裂 $n$ 轮后有 $2^n$ 个细胞。 @@ -2246,7 +2246,7 @@ $$ 指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用「动态规划」或「贪心」等算法来解决。 -### 对数阶 $O(\log n)$ +### 5.   对数阶 $O(\log n)$ 与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。 @@ -2556,7 +2556,7 @@ $$ 对数阶常出现于基于「分治」的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是理想的时间复杂度,仅次于常数阶。 -### 线性对数阶 $O(n \log n)$ +### 6.   线性对数阶 $O(n \log n)$ 线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 $O(\log n)$ 和 $O(n)$ 。 @@ -2748,7 +2748,7 @@ $$

图:线性对数阶的时间复杂度

-### 阶乘阶 $O(n!)$ +### 7.   阶乘阶 $O(n!)$ 阶乘阶对应数学上的“全排列”问题。给定 $n$ 个互不重复的元素,求其所有可能的排列方案,方案数量为: diff --git a/chapter_divide_and_conquer/binary_search_recur.md b/chapter_divide_and_conquer/binary_search_recur.md index 41bd9c443..624bb4148 100644 --- a/chapter_divide_and_conquer/binary_search_recur.md +++ b/chapter_divide_and_conquer/binary_search_recur.md @@ -23,7 +23,7 @@ status: new 分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,**而分治搜索每轮可以排除一半选项**。 -### 基于分治实现二分 +### 1.   基于分治实现二分 在之前的章节中,二分查找是基于递推(迭代)实现的。现在我们基于分治(递归)来实现它。 diff --git a/chapter_divide_and_conquer/build_binary_tree_problem.md b/chapter_divide_and_conquer/build_binary_tree_problem.md index b582e9acb..1c4884950 100644 --- a/chapter_divide_and_conquer/build_binary_tree_problem.md +++ b/chapter_divide_and_conquer/build_binary_tree_problem.md @@ -13,7 +13,7 @@ status: new

图:构建二叉树的示例数据

-### 判断是否为分治问题 +### 1.   判断是否为分治问题 原问题定义为从 `preorder` 和 `inorder` 构建二叉树。我们首先从分治的角度分析这道题: @@ -21,7 +21,7 @@ status: new - **子问题是独立的**:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。 - **子问题的解可以合并**:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,得到原问题的解。 -### 如何划分子树 +### 2.   如何划分子树 根据以上分析,这道题是可以使用分治来求解的,但问题是:**如何通过前序遍历 `preorder` 和中序遍历 `inorder` 来划分左子树和右子树呢**? @@ -40,7 +40,7 @@ status: new

图:在前序和中序遍历中划分子树

-### 基于变量描述子树区间 +### 3.   基于变量描述子树区间 根据以上划分方法,**我们已经得到根节点、左子树、右子树在 `preorder` 和 `inorder` 中的索引区间**。而为了描述这些索引区间,我们需要借助几个指针变量: @@ -67,7 +67,7 @@ status: new

图:根节点和左右子树的索引区间表示

-### 代码实现 +### 4.   代码实现 为了提升查询 $m$ 的效率,我们借助一个哈希表 `hmap` 来存储数组 `inorder` 中元素到索引的映射。 diff --git a/chapter_divide_and_conquer/divide_and_conquer.md b/chapter_divide_and_conquer/divide_and_conquer.md index c317c3cb7..0111dccb1 100644 --- a/chapter_divide_and_conquer/divide_and_conquer.md +++ b/chapter_divide_and_conquer/divide_and_conquer.md @@ -39,7 +39,7 @@ status: new 那么,我们不禁发问:**为什么分治可以提升算法效率,其底层逻辑是什么**?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高?这个问题可以从操作数量和并行计算两方面来讨论。 -### 操作数量优化 +### 1.   操作数量优化 以「冒泡排序」为例,其处理一个长度为 $n$ 的数组需要 $O(n^2)$ 时间。假设我们把数组从中点分为两个子数组,则划分需要 $O(n)$ 时间,排序每个子数组需要 $O((\frac{n}{2})^2)$ 时间,合并两个子数组需要 $O(n)$ 时间,总体时间复杂度为: @@ -67,7 +67,7 @@ $$ 再思考,**如果我们多设置几个划分点**,将原数组平均划分为 $k$ 个子数组呢?这种情况与「桶排序」非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 $O(n + k)$ 。 -### 并行计算优化 +### 2.   并行计算优化 我们知道,分治生成的子问题是相互独立的,**因此通常可以并行解决**。也就是说,分治不仅可以降低算法的时间复杂度,**还有利于操作系统的并行优化**。 diff --git a/chapter_divide_and_conquer/hanota_problem.md b/chapter_divide_and_conquer/hanota_problem.md index 5c1844ecc..046af88ce 100644 --- a/chapter_divide_and_conquer/hanota_problem.md +++ b/chapter_divide_and_conquer/hanota_problem.md @@ -21,7 +21,7 @@ status: new **我们将规模为 $i$ 的汉诺塔问题记做 $f(i)$** 。例如 $f(3)$ 代表将 $3$ 个圆盘从 `A` 移动至 `C` 的汉诺塔问题。 -### 考虑基本情况 +### 1.   考虑基本情况 对于问题 $f(1)$ ,即当只有一个圆盘时,则将它直接从 `A` 移动至 `C` 即可。 @@ -55,7 +55,7 @@ status: new

图:规模为 2 问题的解

-### 子问题分解 +### 2.   子问题分解 对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。由于已知 $f(1)$ 和 $f(2)$ 的解,因此可从分治角度思考,**将 `A` 顶部的两个圆盘看做一个整体**,执行以下步骤: @@ -93,7 +93,7 @@ status: new

图:汉诺塔问题的分治策略

-### 代码实现 +### 3.   代码实现 在代码中,我们声明一个递归函数 `dfs(i, src, buf, tar)` ,它的作用是将柱 `src` 顶部的 $i$ 个圆盘借助缓冲柱 `buf` 移动至目标柱 `tar` 。 diff --git a/chapter_dynamic_programming/dp_solution_pipeline.md b/chapter_dynamic_programming/dp_solution_pipeline.md index d8f61e1f5..135f5f03b 100644 --- a/chapter_dynamic_programming/dp_solution_pipeline.md +++ b/chapter_dynamic_programming/dp_solution_pipeline.md @@ -102,7 +102,7 @@ $$ 根据以上分析,我们已经可以直接写出动态规划代码。然而子问题分解是一种从顶至底的思想,因此按照“暴力搜索 $\rightarrow$ 记忆化搜索 $\rightarrow$ 动态规划”的顺序实现更加符合思维习惯。 -### 方法一:暴力搜索 +### 1.   方法一:暴力搜索 从状态 $[i, j]$ 开始搜索,不断分解为更小的状态 $[i-1, j]$ 和 $[i, j-1]$ ,包括以下递归要素: @@ -326,7 +326,7 @@ $$ 每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 $m + n - 2$ 步,所以最差时间复杂度为 $O(2^{m + n})$ 。请注意,这种计算方式未考虑临近网格边界的情况,当到达网络边界时只剩下一种选择。因此实际的路径数量会少一些。 -### 方法二:记忆化搜索 +### 2.   方法二:记忆化搜索 我们引入一个和网格 `grid` 相同尺寸的记忆列表 `mem` ,用于记录各个子问题的解,并将重叠子问题进行剪枝。 @@ -588,7 +588,7 @@ $$

图:记忆化搜索递归树

-### 方法三:动态规划 +### 3.   方法三:动态规划 基于迭代实现动态规划解法。 @@ -895,7 +895,7 @@ $$

图:最小路径和的动态规划过程

-### 状态压缩 +### 4.   状态压缩 由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 $dp$ 表。 diff --git a/chapter_dynamic_programming/edit_distance_problem.md b/chapter_dynamic_programming/edit_distance_problem.md index dd4d8da27..f8008ba50 100644 --- a/chapter_dynamic_programming/edit_distance_problem.md +++ b/chapter_dynamic_programming/edit_distance_problem.md @@ -29,6 +29,8 @@ status: new

图:基于决策树模型表示编辑距离问题

+### 1.   动态规划思路 + **第一步:思考每轮的决策,定义状态,从而得到 $dp$ 表** 每一轮的决策是对字符串 $s$ 进行一次编辑操作。 @@ -74,7 +76,7 @@ $$ 观察状态转移方程,解 $dp[i, j]$ 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 $dp$ 表即可。 -### 代码实现 +### 2.   代码实现 === "Java" @@ -413,7 +415,7 @@ $$

图:编辑距离的动态规划过程

-### 状态压缩 +### 3.   状态压缩 由于 $dp[i,j]$ 是由上方 $dp[i-1, j]$ 、左方 $dp[i, j-1]$ 、左上方状态 $dp[i-1, j-1]$ 转移而来,而正序遍历会丢失左上方 $dp[i-1, j-1]$ ,倒序遍历无法提前构建 $dp[i, j-1]$ ,因此两种遍历顺序都不可取。 diff --git a/chapter_dynamic_programming/knapsack_problem.md b/chapter_dynamic_programming/knapsack_problem.md index 4c3f05eed..8ebe4b385 100644 --- a/chapter_dynamic_programming/knapsack_problem.md +++ b/chapter_dynamic_programming/knapsack_problem.md @@ -54,7 +54,7 @@ $$ 根据以上分析,我们接下来按顺序实现暴力搜索、记忆化搜索、动态规划解法。 -### 方法一:暴力搜索 +### 1.   方法一:暴力搜索 搜索代码包含以下要素: @@ -275,7 +275,7 @@ $$

图:0-1 背包的暴力搜索递归树

-### 方法二:记忆化搜索 +### 2.   方法二:记忆化搜索 为了保证重叠子问题只被计算一次,我们借助记忆列表 `mem` 来记录子问题的解,其中 `mem[i][c]` 对应 $dp[i, c]$ 。 @@ -541,7 +541,7 @@ $$

图:0-1 背包的记忆化搜索递归树

-### 方法三:动态规划 +### 3.   方法三:动态规划 动态规划实质上就是在状态转移中填充 $dp$ 表的过程,代码如下所示。 @@ -824,7 +824,7 @@ $$

图:0-1 背包的动态规划过程

-### 状态压缩 +### 4.   状态压缩 由于每个状态都只与其上一行的状态有关,因此我们可以使用两个数组滚动前进,将空间复杂度从 $O(n^2)$ 将低至 $O(n)$ 。 diff --git a/chapter_dynamic_programming/unbounded_knapsack_problem.md b/chapter_dynamic_programming/unbounded_knapsack_problem.md index f218b656f..6763654db 100644 --- a/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -17,6 +17,8 @@ status: new

图:完全背包问题的示例数据

+### 1.   动态规划思路 + 完全背包和 0-1 背包问题非常相似,**区别仅在于不限制物品的选择次数**。 - 在 0-1 背包中,每个物品只有一个,因此将物品 $i$ 放入背包后,只能从前 $i-1$ 个物品中选择。 @@ -33,7 +35,7 @@ $$ dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1]) $$ -### 代码实现 +### 2.   代码实现 对比两道题目的代码,状态转移中有一处从 $i-1$ 变为 $i$ ,其余完全一致。 @@ -270,7 +272,7 @@ $$ } ``` -### 状态压缩 +### 3.   状态压缩 由于当前状态是从左边和上边的状态转移而来,**因此状态压缩后应该对 $dp$ 表中的每一行采取正序遍历**。 @@ -541,6 +543,8 @@ $$

图:零钱兑换问题的示例数据

+### 1.   动态规划思路 + **零钱兑换可以看作是完全背包的一种特殊情况**,两者具有以下联系与不同点: - 两道题可以相互转换,“物品”对应于“硬币”、“物品重量”对应于“硬币面值”、“背包容量”对应于“目标金额”。 @@ -570,7 +574,7 @@ $$ 当无硬币时,**无法凑出任意 $> 0$ 的目标金额**,即是无效解。为使状态转移方程中的 $\min()$ 函数能够识别并过滤无效解,我们考虑使用 $+ \infty$ 来表示它们,即令首行所有 $dp[0, a]$ 都等于 $+ \infty$ 。 -### 代码实现 +### 2.   代码实现 大多数编程语言并未提供 $+ \infty$ 变量,只能使用整型 `int` 的最大值来代替。而这又会导致大数越界:状态转移方程中的 $+ 1$ 操作可能发生溢出。 @@ -911,7 +915,7 @@ $$

图:零钱兑换问题的动态规划过程

-### 状态压缩 +### 3.   状态压缩 零钱兑换的状态压缩的处理方式和完全背包一致。 @@ -1188,6 +1192,8 @@ $$

图:零钱兑换问题 II 的示例数据

+### 1.   动态规划思路 + 相比于上一题,本题目标是组合数量,因此子问题变为:**前 $i$ 种硬币能够凑出金额 $a$ 的组合数量**。而 $dp$ 表仍然是尺寸为 $(n+1) \times (amt + 1)$ 的二维矩阵。 当前状态的组合数量等于不选当前硬币与选当前硬币这两种决策的组合数量之和。状态转移方程为: @@ -1198,7 +1204,7 @@ $$ 当目标金额为 $0$ 时,无需选择任何硬币即可凑出目标金额,因此应将首列所有 $dp[i, 0]$ 都初始化为 $1$ 。当无硬币时,无法凑出任何 $>0$ 的目标金额,因此首行所有 $dp[0, a]$ 都等于 $0$ 。 -### 代码实现 +### 2.   代码实现 === "Java" @@ -1468,7 +1474,7 @@ $$ } ``` -### 状态压缩 +### 3.   状态压缩 状态压缩处理方式相同,删除硬币维度即可。 diff --git a/chapter_graph/graph.md b/chapter_graph/graph.md index a66b8ce23..4672471f6 100644 --- a/chapter_graph/graph.md +++ b/chapter_graph/graph.md @@ -56,7 +56,7 @@ $$ 图的常用表示方法包括「邻接矩阵」和「邻接表」。以下使用无向图进行举例。 -### 邻接矩阵 +### 1.   邻接矩阵 设图的顶点数量为 $n$ ,「邻接矩阵 Adjacency Matrix」使用一个 $n \times n$ 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 $1$ 或 $0$ 表示两个顶点之间是否存在边。 @@ -74,7 +74,7 @@ $$ 使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 $O(1)$ 。然而,矩阵的空间复杂度为 $O(n^2)$ ,内存占用较多。 -### 邻接表 +### 2.   邻接表 「邻接表 Adjacency List」使用 $n$ 个链表来表示图,链表节点表示顶点。第 $i$ 条链表对应顶点 $i$ ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。 diff --git a/chapter_graph/graph_traversal.md b/chapter_graph/graph_traversal.md index e2d48d439..81a762eb7 100644 --- a/chapter_graph/graph_traversal.md +++ b/chapter_graph/graph_traversal.md @@ -20,7 +20,7 @@ comments: true

图:图的广度优先遍历

-### 算法实现 +### 1.   算法实现 BFS 通常借助「队列」来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。 @@ -433,7 +433,7 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质 不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,**而多个相同距离的顶点的遍历顺序是允许被任意打乱的**。以上图为例,顶点 $1$ , $3$ 的访问顺序可以交换、顶点 $2$ , $4$ , $6$ 的访问顺序也可以任意交换。 -### 复杂度分析 +### 2.   复杂度分析 **时间复杂度:** 所有顶点都会入队并出队一次,使用 $O(|V|)$ 时间;在遍历邻接顶点的过程中,由于是无向图,因此所有边都会被访问 $2$ 次,使用 $O(2|E|)$ 时间;总体使用 $O(|V| + |E|)$ 时间。 @@ -447,7 +447,7 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质

图:图的深度优先遍历

-### 算法实现 +### 1.   算法实现 这种“走到尽头 + 回溯”的算法形式通常基于递归来实现。与 BFS 类似,在 DFS 中我们也需要借助一个哈希表 `visited` 来记录已被访问的顶点,以避免重复访问顶点。 @@ -845,7 +845,7 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质 以树的遍历为例,“根 $\rightarrow$ 左 $\rightarrow$ 右”、“左 $\rightarrow$ 根 $\rightarrow$ 右”、“左 $\rightarrow$ 右 $\rightarrow$ 根”分别对应前序、中序、后序遍历,它们展示了三种不同的遍历优先级,然而这三者都属于深度优先遍历。 -### 复杂度分析 +### 2.   复杂度分析 **时间复杂度:** 所有顶点都会被访问 $1$ 次,使用 $O(|V|)$ 时间;所有边都会被访问 $2$ 次,使用 $O(2|E|)$ 时间;总体使用 $O(|V| + |E|)$ 时间。 diff --git a/chapter_greedy/fractional_knapsack_problem.md b/chapter_greedy/fractional_knapsack_problem.md index 9112d7d76..0bd82f5b0 100644 --- a/chapter_greedy/fractional_knapsack_problem.md +++ b/chapter_greedy/fractional_knapsack_problem.md @@ -26,7 +26,7 @@ status: new

图:物品在单位重量下的价值

-### 贪心策略确定 +### 1.   贪心策略确定 最大化背包内物品总价值,**本质上是要最大化单位重量下的物品价值**。由此便可推出本题的贪心策略: @@ -38,7 +38,7 @@ status: new

图:分数背包的贪心策略

-### 代码实现 +### 2.   代码实现 我们建立了一个物品类 `Item` ,以便将物品按照单位价值进行排序。循环进行贪心选择,当背包已满时跳出并返回解。 @@ -359,7 +359,7 @@ status: new 由于初始化了一个 `Item` 对象列表,**因此空间复杂度为 $O(n)$** 。 -### 正确性证明 +### 3.   正确性证明 采用反证法。假设物品 $x$ 是单位价值最高的物品,使用某算法求得最大价值为 `res` ,但该解中不包含物品 $x$ 。 diff --git a/chapter_greedy/max_capacity_problem.md b/chapter_greedy/max_capacity_problem.md index 308ca3a6e..05c8bb916 100644 --- a/chapter_greedy/max_capacity_problem.md +++ b/chapter_greedy/max_capacity_problem.md @@ -27,7 +27,7 @@ $$ 设数组长度为 $n$ ,两个隔板的组合数量(即状态总数)为 $C_n^2 = \frac{n(n - 1)}{2}$ 个。最直接地,**我们可以穷举所有状态**,从而求得最大容量,时间复杂度为 $O(n^2)$ 。 -### 贪心策略确定 +### 1.   贪心策略确定 这道题还有更高效率的解法。如下图所示,现选取一个状态 $[i, j]$ ,其满足索引 $i < j$ 且高度 $ht[i] < ht[j]$ ,即 $i$ 为短板、 $j$ 为长板。 @@ -86,7 +86,7 @@ $$

图:最大容量问题的贪心过程

-### 代码实现 +### 2.   代码实现 代码循环最多 $n$ 轮,**因此时间复杂度为 $O(n)$** 。 @@ -295,7 +295,7 @@ $$ } ``` -### 正确性证明 +### 3.   正确性证明 之所以贪心比穷举更快,是因为每轮的贪心选择都会“跳过”一些状态。 diff --git a/chapter_greedy/max_product_cutting_problem.md b/chapter_greedy/max_product_cutting_problem.md index 382fccc1b..e1441d3cd 100644 --- a/chapter_greedy/max_product_cutting_problem.md +++ b/chapter_greedy/max_product_cutting_problem.md @@ -27,7 +27,7 @@ $$ 我们需要思考的是:切分数量 $m$ 应该多大,每个 $n_i$ 应该是多少? -### 贪心策略确定 +### 1.   贪心策略确定 根据经验,两个整数的乘积往往比它们的加和更大。假设从 $n$ 中分出一个因子 $2$ ,则它们的乘积为 $2(n-2)$ 。我们将该乘积与 $n$ 作比较: @@ -64,7 +64,7 @@ $$ 3. 当余数为 $2$ 时,不继续划分,保留之。 4. 当余数为 $1$ 时,由于 $2 \times 2 > 1 \times 3$ ,因此应将最后一个 $3$ 替换为 $2$ 。 -### 代码实现 +### 2.   代码实现 在代码中,我们无需通过循环来切分整数,而可以利用向下整除运算得到 $3$ 的个数 $a$ ,用取模运算得到余数 $b$ ,此时有: @@ -285,7 +285,7 @@ $$ 变量 $a$ , $b$ 使用常数大小的额外空间,**因此空间复杂度为 $O(1)$** 。 -### 正确性证明 +### 3.   正确性证明 使用反证法,只分析 $n \geq 3$ 的情况。 diff --git a/chapter_hashing/hash_collision.md b/chapter_hashing/hash_collision.md index 29a976173..0804ef9e1 100644 --- a/chapter_hashing/hash_collision.md +++ b/chapter_hashing/hash_collision.md @@ -1156,7 +1156,7 @@ comments: true 「开放寻址 Open Addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等。 -### 线性探测 +### 1.   线性探测 线性探测采用固定步长的线性查找来进行探测,对应的哈希表操作方法为: @@ -2439,7 +2439,7 @@ comments: true } ``` -### 多次哈希 +### 2.   多次哈希 顾名思义,多次哈希方法是使用多个哈希函数 $f_1(x)$ , $f_2(x)$ , $f_3(x)$ , $\cdots$ 进行探测。 diff --git a/chapter_heap/build_heap.md b/chapter_heap/build_heap.md index 4a432625b..79b063947 100644 --- a/chapter_heap/build_heap.md +++ b/chapter_heap/build_heap.md @@ -4,17 +4,19 @@ comments: true # 8.2   建堆操作 -如果我们想要根据输入列表生成一个堆,这个过程被称为「建堆」。 +在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为「建堆」。 ## 8.2.1   借助入堆方法实现 -最直接的方法是借助“元素入堆操作”实现,首先创建一个空堆,然后将列表元素依次添加到堆中。 +最直接的方法是借助“元素入堆操作”实现。我们首先创建一个空堆,然后将列表元素依次执行“入堆”。 -设元素数量为 $n$ ,则最后一个元素入堆的时间复杂度为 $O(\log n)$ 。在依次添加元素时,堆的平均长度为 $\frac{n}{2}$ ,因此该方法的总体时间复杂度为 $O(n \log n)$ 。 +设元素数量为 $n$ ,入堆操作使用 $O(\log{n})$ 时间,因此将所有元素入堆的时间复杂度为 $O(n \log n)$ 。 ## 8.2.2   基于堆化操作实现 -有趣的是,存在一种更高效的建堆方法,其时间复杂度仅为 $O(n)$ 。我们先将列表所有元素原封不动添加到堆中,**然后迭代地对各个节点执行“从顶至底堆化”**。当然,**我们不需要对叶节点执行堆化操作**,因为它们没有子节点。 +有趣的是,存在一种更高效的建堆方法,其时间复杂度可以达到 $O(n)$ 。我们先将列表所有元素原封不动添加到堆中,然后倒序遍历该堆,依次对每个节点执行“从顶至底堆化”。 + +请注意,因为叶节点没有子节点,所以无需堆化。在代码实现中,我们从最后一个节点的父节点开始进行堆化。 === "Java" @@ -195,7 +197,7 @@ comments: true 为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。 -- 完全二叉树中,设节点总数为 $n$ ,则叶节点数量为 $(n + 1) / 2$ ,其中 $/$ 为向下整除。因此,在排除叶节点后,需要堆化的节点数量为 $(n - 1)/2$ ,复杂度为 $O(n)$ 。 +- 在完全二叉树中,设节点总数为 $n$ ,则叶节点数量为 $(n + 1) / 2$ ,其中 $/$ 为向下整除。因此,在排除叶节点后,需要堆化的节点数量为 $(n - 1)/2$ ,复杂度为 $O(n)$ 。 - 在从顶至底堆化的过程中,每个节点最多堆化到叶节点,因此最大迭代次数为二叉树高度 $O(\log n)$ 。 将上述两者相乘,可得到建堆过程的时间复杂度为 $O(n \log n)$ 。**然而,这个估算结果并不准确,因为我们没有考虑到二叉树底层节点数量远多于顶层节点的特性**。 @@ -221,7 +223,7 @@ T(h) & = 2^0h + 2^1(h-1) + 2^2(h-2) + \cdots + 2^{h-1}\times1 \newline \end{aligned} $$ -**使用错位相减法**,令下式 $2 T(h)$ 减去上式 $T(h)$ ,可得 +使用错位相减法,用下式 $2 T(h)$ 减去上式 $T(h)$ ,可得 $$ 2T(h) - T(h) = T(h) = -2^0h + 2^1 + 2^2 + \cdots + 2^{h-1} + 2^h diff --git a/chapter_heap/heap.md b/chapter_heap/heap.md index 4d1527395..6487c2f85 100644 --- a/chapter_heap/heap.md +++ b/chapter_heap/heap.md @@ -324,7 +324,7 @@ comments: true 下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 $\geq$ 替换为 $\leq$ )。感兴趣的读者可以自行实现。 -### 堆的存储与表示 +### 1.   堆的存储与表示 我们在二叉树章节中学习到,完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,**我们将采用数组来存储堆**。 @@ -565,7 +565,7 @@ comments: true } ``` -### 访问堆顶元素 +### 2.   访问堆顶元素 堆顶元素即为二叉树的根节点,也就是列表的首个元素。 @@ -676,7 +676,7 @@ comments: true } ``` -### 元素入堆 +### 3.   元素入堆 给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 Heapify」。 @@ -1049,7 +1049,7 @@ comments: true } ``` -### 堆顶元素出堆 +### 4.   堆顶元素出堆 堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤: diff --git a/chapter_searching/binary_search_edge.md b/chapter_searching/binary_search_edge.md index 459eca5b3..ff4948a86 100644 --- a/chapter_searching/binary_search_edge.md +++ b/chapter_searching/binary_search_edge.md @@ -156,7 +156,7 @@ status: new 下面我们介绍两种更加取巧的方法。 -### 复用查找左边界 +### 1.   复用查找左边界 实际上,我们可以利用查找最左元素的函数来查找最右元素,具体方法为:**将查找最右一个 `target` 转化为查找最左一个 `target + 1`**。 @@ -310,7 +310,7 @@ status: new [class]{}-[func]{binary_search_right_edge} ``` -### 转化为查找元素 +### 2.   转化为查找元素 我们知道,当数组不包含 `target` 时,最后 $i$ , $j$ 会分别指向首个大于、小于 `target` 的元素。 diff --git a/chapter_stack_and_queue/deque.md b/chapter_stack_and_queue/deque.md index 83c644107..e9f258ee8 100644 --- a/chapter_stack_and_queue/deque.md +++ b/chapter_stack_and_queue/deque.md @@ -329,7 +329,7 @@ comments: true 双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。 -### 基于双向链表的实现 +### 1.   基于双向链表的实现 回顾上一节内容,我们使用普通单向链表来实现队列,因为它可以方便地删除头节点(对应出队操作)和在尾节点后添加新节点(对应入队操作)。 @@ -1972,7 +1972,7 @@ comments: true } ``` -### 基于数组的实现 +### 2.   基于数组的实现 与基于数组实现队列类似,我们也可以使用环形数组来实现双向队列。在队列的实现基础上,仅需增加“队首入队”和“队尾出队”的方法。 diff --git a/chapter_stack_and_queue/queue.md b/chapter_stack_and_queue/queue.md index 32db628a2..7e06faf62 100755 --- a/chapter_stack_and_queue/queue.md +++ b/chapter_stack_and_queue/queue.md @@ -296,7 +296,7 @@ comments: true 为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。 -### 基于链表的实现 +### 1.   基于链表的实现 对于链表实现,我们可以将链表的「头节点」和「尾节点」分别视为队首和队尾,规定队尾仅可添加节点,而队首仅可删除节点。 @@ -1181,7 +1181,7 @@ comments: true } ``` -### 基于数组的实现 +### 2.   基于数组的实现 由于数组删除首元素的时间复杂度为 $O(n)$ ,这会导致出队操作效率较低。然而,我们可以采用以下巧妙方法来避免这个问题。 diff --git a/chapter_stack_and_queue/stack.md b/chapter_stack_and_queue/stack.md index e0222f45b..00169876a 100755 --- a/chapter_stack_and_queue/stack.md +++ b/chapter_stack_and_queue/stack.md @@ -296,7 +296,7 @@ comments: true 栈遵循先入后出的原则,因此我们只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,**因此栈可以被视为一种受限制的数组或链表**。换句话说,我们可以“屏蔽”数组或链表的部分无关操作,使其对外表现的逻辑符合栈的特性。 -### 基于链表的实现 +### 1.   基于链表的实现 使用链表来实现栈时,我们可以将链表的头节点视为栈顶,尾节点视为栈底。 @@ -1067,7 +1067,7 @@ comments: true } ``` -### 基于数组的实现 +### 2.   基于数组的实现 在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 $O(1)$ 。 @@ -1674,11 +1674,11 @@ comments: true ## 5.1.3   两种实现对比 -### 支持操作 +### 1.   支持操作 两种实现都支持栈定义中的各项操作。数组实现额外支持随机访问,但这已超出了栈的定义范畴,因此一般不会用到。 -### 时间效率 +### 2.   时间效率 在基于数组的实现中,入栈和出栈操作都是在预先分配好的连续内存中进行,具有很好的缓存本地性,因此效率较高。然而,如果入栈时超出数组容量,会触发扩容机制,导致该次入栈操作的时间复杂度变为 $O(n)$ 。 @@ -1689,7 +1689,7 @@ comments: true - 基于数组实现的栈在触发扩容时效率会降低,但由于扩容是低频操作,因此平均效率更高。 - 基于链表实现的栈可以提供更加稳定的效率表现。 -### 空间效率 +### 3.   空间效率 在初始化列表时,系统会为列表分配“初始容量”,该容量可能超过实际需求。并且,扩容机制通常是按照特定倍率(例如 2 倍)进行扩容,扩容后的容量也可能超出实际需求。因此,**基于数组实现的栈可能造成一定的空间浪费**。 diff --git a/chapter_tree/avl_tree.md b/chapter_tree/avl_tree.md index dcd37963e..ab6790426 100644 --- a/chapter_tree/avl_tree.md +++ b/chapter_tree/avl_tree.md @@ -24,7 +24,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit 「AVL 树」既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为「平衡二叉搜索树」。 -### 节点高度 +### 1.   节点高度 在操作 AVL 树时,我们需要获取节点的高度,因此需要为 AVL 树的节点类添加 `height` 变量。 @@ -418,7 +418,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit } ``` -### 节点平衡因子 +### 2.   节点平衡因子 节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。 @@ -586,7 +586,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉 我们将平衡因子绝对值 $> 1$ 的节点称为「失衡节点」。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。 -### 右旋 +### 1.   右旋 如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 `node` ,其左子节点记为 `child` ,执行「右旋」操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。 @@ -833,7 +833,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉 } ``` -### 左旋 +### 2.   左旋 相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。 @@ -1070,7 +1070,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉 } ``` -### 先左旋后右旋 +### 3.   先左旋后右旋 对于下图中的失衡节点 3,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋,即先对 `child` 执行「左旋」,再对 `node` 执行「右旋」。 @@ -1078,7 +1078,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉

图:先左旋后右旋

-### 先右旋后左旋 +### 4.   先右旋后左旋 同理,对于上述失衡二叉树的镜像情况,需要先右旋后左旋,即先对 `child` 执行「右旋」,然后对 `node` 执行「左旋」。 @@ -1086,7 +1086,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉

图:先右旋后左旋

-### 旋转的选择 +### 5.   旋转的选择 下图展示的四种失衡情况与上述案例逐个对应,分别需要采用右旋、左旋、先右后左、先左后右的旋转操作。 @@ -1521,7 +1521,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉 ## 7.5.3   AVL 树常用操作 -### 插入节点 +### 1.   插入节点 「AVL 树」的节点插入操作与「二叉搜索树」在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,**我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡**。 @@ -1873,7 +1873,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉 } ``` -### 删除节点 +### 2.   删除节点 类似地,在二叉搜索树的删除节点方法的基础上,需要从底至顶地执行旋转操作,使所有失衡节点恢复平衡。 @@ -2432,7 +2432,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉 } ``` -### 查找节点 +### 3.   查找节点 AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。 diff --git a/chapter_tree/binary_search_tree.md b/chapter_tree/binary_search_tree.md index 6d879bba5..c4ab66627 100755 --- a/chapter_tree/binary_search_tree.md +++ b/chapter_tree/binary_search_tree.md @@ -17,7 +17,7 @@ comments: true 我们将二叉搜索树封装为一个类 `ArrayBinaryTree` ,并声明一个成员变量 `root` ,指向树的根节点。 -### 查找节点 +### 1.   查找节点 给定目标节点值 `num` ,可以根据二叉搜索树的性质来查找。我们声明一个节点 `cur` ,从二叉树的根节点 `root` 出发,循环比较节点值 `cur.val` 和 `num` 之间的大小关系 @@ -319,7 +319,7 @@ comments: true } ``` -### 插入节点 +### 2.   插入节点 给定一个待插入元素 `num` ,为了保持二叉搜索树“左子树 < 根节点 < 右子树”的性质,插入操作分为两步: @@ -728,7 +728,7 @@ comments: true 与查找节点相同,插入节点使用 $O(\log n)$ 时间。 -### 删除节点 +### 3.   删除节点 与插入节点类似,我们需要在删除操作后维持二叉搜索树的“左子树 < 根节点 < 右子树”的性质。首先,我们需要在二叉树中执行查找操作,获取待删除节点。接下来,根据待删除节点的子节点数量,删除操作需分为三种情况: @@ -1474,7 +1474,7 @@ comments: true } ``` -### 排序 +### 4.   排序 我们知道,二叉树的中序遍历遵循“左 $\rightarrow$ 根 $\rightarrow$ 右”的遍历顺序,而二叉搜索树满足“左子节点 $<$ 根节点 $<$ 右子节点”的大小关系。因此,在二叉搜索树中进行中序遍历时,总是会优先遍历下一个最小节点,从而得出一个重要性质:**二叉搜索树的中序遍历序列是升序的**。 diff --git a/chapter_tree/binary_tree.md b/chapter_tree/binary_tree.md index ac19b2a79..6bf123f28 100644 --- a/chapter_tree/binary_tree.md +++ b/chapter_tree/binary_tree.md @@ -520,7 +520,7 @@ comments: true ## 7.1.3   常见二叉树类型 -### 完美二叉树 +### 1.   完美二叉树 「完美二叉树 Perfect Binary Tree」除了最底层外,其余所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 $0$ ,其余所有节点的度都为 $2$ ;若树高度为 $h$ ,则节点总数为 $2^{h+1} - 1$ ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。 @@ -532,7 +532,7 @@ comments: true

图:完美二叉树

-### 完全二叉树 +### 2.   完全二叉树 「完全二叉树 Complete Binary Tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。 @@ -540,7 +540,7 @@ comments: true

图:完全二叉树

-### 完满二叉树 +### 3.   完满二叉树 「完满二叉树 Full Binary Tree」除了叶节点之外,其余所有节点都有两个子节点。 @@ -548,7 +548,7 @@ comments: true

图:完满二叉树

-### 平衡二叉树 +### 4.   平衡二叉树 「平衡二叉树 Balanced Binary Tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。