This commit is contained in:
krahets 2023-04-10 03:12:02 +08:00
parent 2289822dfd
commit 867ecf6d92
32 changed files with 329 additions and 445 deletions

View File

@ -23,7 +23,7 @@ comments: true
!!! note "缓存局部性"
在计算机中,数据读写速度排序是“硬盘 < 内存 < CPU 缓存当我们访问数组元素时计算机不仅会加载它还会缓存其周围的其数据从而借助高速缓存来提升后续操作的执行速度链表则不然计算机只能挨个地缓存各个节点这样的多次搬运降低了整体效率
在计算机中,数据读写速度排序是“硬盘 < 内存 < CPU 缓存当我们访问数组元素时计算机不仅会加载它还会缓存其周围的其数据从而借助高速缓存来提升后续操作的执行速度链表则不然计算机只能挨个地缓存各个节点这样的多次搬运降低了整体效率
- 下表对比了数组与链表在各种操作上的效率。

View File

@ -165,7 +165,7 @@ $$
「时间复杂度分析」采取了一种不同的方法,其统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。
“时间增长趋势”这个概念较为抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$,给定三个算法 `A``B``C`
“时间增长趋势”这个概念较为抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法 `A` , `B` , `C`
- 算法 `A` 只有 $1$ 个打印操作,算法运行时间不随着 $n$ 增大而增长。我们称此算法的时间复杂度为「常数阶」。
- 算法 `B` 中的打印操作需要循环 $n$ 次,算法运行时间随着 $n$ 增大呈线性增长。此算法的时间复杂度被称为「线性阶」。
@ -749,7 +749,7 @@ $$
### 2) 判断渐近上界
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其项的影响都可以被忽略。
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其项的影响都可以被忽略。
以下表格展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 $n$ 趋于无穷大时,这些常数变得无足轻重。

View File

@ -41,7 +41,7 @@ comments: true
3. 当所有 bits 为 0 时代表数字 $0$ ,从零开始增大,可得最大正数为 $2^{31} - 1$
4. 剩余 $2^{31}$ 个数字全部用来表示负数,因此最小负数为 $-2^{31}$ ;具体细节涉及“源码、反码、补码”的相关知识,有兴趣的同学可以查阅学习;
整数类型 byte, short, long 的取值范围的计算方法与 int 类似,在此不再赘述。
整数类型 byte, short, long 的取值范围的计算方法与 int 类似,在此不再赘述。
### 浮点数表示方式 *

View File

@ -862,7 +862,7 @@ comments: true
throw new IllegalArgumentException();
// 在邻接表中删除顶点 vet 对应的链表
adjList.remove(vet);
// 遍历其顶点的链表,删除所有包含 vet 的边
// 遍历其顶点的链表,删除所有包含 vet 的边
for (List<Vertex> list : adjList.values()) {
list.remove(vet);
}
@ -884,83 +884,7 @@ comments: true
=== "C++"
```cpp title="graph_adjacency_list.cpp"
/* 基于邻接表实现的无向图类 */
class GraphAdjList {
public:
// 邻接表key: 顶点value该顶点的所有邻接顶点
unordered_map<Vertex*, vector<Vertex*>> adjList;
/* 在 vector 中删除指定节点 */
void remove(vector<Vertex*> &vec, Vertex *vet) {
for (int i = 0; i < vec.size(); i++) {
if (vec[i] == vet) {
vec.erase(vec.begin() + i);
break;
}
}
}
/* 构造方法 */
GraphAdjList(const vector<vector<Vertex*>>& edges) {
// 添加所有顶点和边
for (const vector<Vertex*>& edge : edges) {
addVertex(edge[0]);
addVertex(edge[1]);
addEdge(edge[0], edge[1]);
}
}
/* 获取顶点数量 */
int size() { return adjList.size(); }
/* 添加边 */
void addEdge(Vertex* vet1, Vertex* vet2) {
if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2)
throw invalid_argument("不存在顶点");
// 添加边 vet1 - vet2
adjList[vet1].push_back(vet2);
adjList[vet2].push_back(vet1);
}
/* 删除边 */
void removeEdge(Vertex* vet1, Vertex* vet2) {
if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2)
throw invalid_argument("不存在顶点");
// 删除边 vet1 - vet2
remove(adjList[vet1], vet2);
remove(adjList[vet2], vet1);
}
/* 添加顶点 */
void addVertex(Vertex* vet) {
if (adjList.count(vet)) return;
// 在邻接表中添加一个新链表
adjList[vet] = vector<Vertex*>();
}
/* 删除顶点 */
void removeVertex(Vertex* vet) {
if (!adjList.count(vet))
throw invalid_argument("不存在顶点");
// 在邻接表中删除顶点 vet 对应的链表
adjList.erase(vet);
// 遍历其它顶点的链表,删除所有包含 vet 的边
for (auto& [key, vec] : adjList) {
remove(vec, vet);
}
}
/* 打印邻接表 */
void print() {
cout << "邻接表 =" << endl;
for (auto& adj : adjList) {
const auto& key= adj.first;
const auto& vec = adj.second;
cout << key->val << ": ";
PrintUtil::printVector(vetsToVals(vec));
}
}
};
[class]{GraphAdjList}-[func]{}
```
=== "Python"
@ -1012,7 +936,7 @@ comments: true
raise ValueError
# 在邻接表中删除顶点 vet 对应的链表
self.adj_list.pop(vet)
# 遍历其顶点的链表,删除所有包含 vet 的边
# 遍历其顶点的链表,删除所有包含 vet 的边
for vertex in self.adj_list:
if vet in self.adj_list[vertex]:
self.adj_list[vertex].remove(vet)
@ -1095,7 +1019,7 @@ comments: true
}
// 在邻接表中删除顶点 vet 对应的链表
delete(g.adjList, vet)
// 遍历其顶点的链表,删除所有包含 vet 的边
// 遍历其顶点的链表,删除所有包含 vet 的边
for _, list := range g.adjList {
DeleteSliceElms(list, vet)
}
@ -1174,7 +1098,7 @@ comments: true
}
// 在邻接表中删除顶点 vet 对应的链表
this.adjList.delete(vet);
// 遍历其顶点的链表,删除所有包含 vet 的边
// 遍历其顶点的链表,删除所有包含 vet 的边
for (let set of this.adjList.values()) {
const index = set.indexOf(vet);
if (index > -1) {
@ -1255,7 +1179,7 @@ comments: true
}
// 在邻接表中删除顶点 vet 对应的链表
this.adjList.delete(vet);
// 遍历其顶点的链表,删除所有包含 vet 的边
// 遍历其顶点的链表,删除所有包含 vet 的边
for (let set of this.adjList.values()) {
const index: number = set.indexOf(vet);
if (index > -1) {
@ -1348,7 +1272,7 @@ comments: true
throw new InvalidOperationException();
// 在邻接表中删除顶点 vet 对应的链表
adjList.Remove(vet);
// 遍历其顶点的链表,删除所有包含 vet 的边
// 遍历其顶点的链表,删除所有包含 vet 的边
foreach (List<Vertex> list in adjList.Values)
{
list.Remove(vet);
@ -1430,7 +1354,7 @@ comments: true
}
// 在邻接表中删除顶点 vet 对应的链表
adjList.removeValue(forKey: vet)
// 遍历其顶点的链表,删除所有包含 vet 的边
// 遍历其顶点的链表,删除所有包含 vet 的边
for key in adjList.keys {
adjList[key]?.removeAll(where: { $0 == vet })
}

View File

@ -4,7 +4,7 @@ comments: true
# 8.1. &nbsp;
「堆 Heap」是一棵限定条件下的「完全二叉树」。根据成立条件,堆主要分为两种类型:
「堆 Heap」是一种满足特定条件的完全二叉树,可分为两种类型:
- 「大顶堆 Max Heap」任意节点的值 $\geq$ 其子节点的值;
- 「小顶堆 Min Heap」任意节点的值 $\leq$ 其子节点的值;
@ -13,19 +13,19 @@ comments: true
<p align="center"> Fig. 小顶堆与大顶堆 </p>
## 8.1.1. &nbsp; 堆术语与性质
堆作为完全二叉树的一个特例,具有以下特性:
- 由于堆是完全二叉树,因此最底层节点靠左填充,其它层节点皆被填满。
- 二叉树中的根节点对应「堆顶」,底层最靠右节点对应「堆底」。
- 对于大顶堆 / 小顶堆,其堆顶元素(即根节点)的值最大 / 最小
- 最底层节点靠左填充,其他层的节点都被填满。
- 我们将二叉树的根节点称为「堆顶」,将底层最靠右的节点称为「堆底」。
- 对于大顶堆(小顶堆),堆顶元素(即根节点)的值分别是最大(最小)的
## 8.1.2. &nbsp; 堆常用操作
## 8.1.1. &nbsp; 堆常用操作
值得说明的是,多数编程语言提供的是「优先队列 Priority Queue」其是一种抽象数据结构**定义为具有出队优先级的队列**
需要指出的是,许多编程语言提供的是「优先队列 Priority Queue」这是一种抽象数据结构定义为具有优先级排序的队列
而恰好,**堆的定义与优先队列的操作逻辑完全吻合**,大顶堆就是一个元素从大到小出队的优先队列。从使用角度看,我们可以将「优先队列」和「堆」理解为等价的数据结构。因此,本文与代码对两者不做特别区分,统一使用「堆」来命名。
实际上,**堆通常用作实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列**。从使用角度来看,我们可以将「优先队列」和「堆」看作等价的数据结构。因此,本书对两者不做特别区分,统一使用「堆」来命名。
堆的常用操作见下表,方法名需根据编程语言确定。
堆的常用操作见下表,方法名需根据编程语言确定。
<div class="center-table" markdown>
@ -39,11 +39,11 @@ comments: true
</div>
我们可以直接使用编程语言提供的堆类(或优先队列类)。
在实际应用中,我们可以直接使用编程语言提供的堆类(或优先队列类)。
!!! tip
类似于排序中“从小到大排列”和“从大到小排列”,“大顶堆”和“小顶堆”可仅通过修改 Comparator 来互相转换。
类似于排序算法“从小到大排列”和“从大到小排列”,我们可以通过修改 Comparator 来实现“小顶堆”与“大顶堆”之间的转换。
=== "Java"
@ -307,23 +307,23 @@ comments: true
```
## 8.1.3. &nbsp; 堆的实现
## 8.1.2. &nbsp; 堆的实现
下文实现的是「大顶堆」,若想转换为「小顶堆」,将所有大小逻辑判断取逆(例如将 $\geq$ 替换为 $\leq$ )即可,有兴趣的同学可自行实现。
下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 $\geq$ 替换为 $\leq$ )。感兴趣的读者可以自行实现。
### 堆的存储与表示
在二叉树章节我们学过,「完全二叉树」非常适合使用「数组」来表示,而堆恰好是一棵完全二叉树,**因而我们采用「数组」来存储「堆」**。
我们在二叉树章节中学习到,完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,**我们将采用数组来存储堆**。
**二叉树指针**。使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置,**而节点指针通过索引映射公式来实现**。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。**节点指针通过索引映射公式来实现**。
具体地,给定索引 $i$ ,那么其左子节点索引为 $2i + 1$ 、右子节点索引为 $2i + 2$ 、父节点索引为 $(i - 1) / 2$ (向下整除)。当索引越界时,代表空节点或节点不存在。
具体而言,给定索引 $i$ ,其左子节点索引为 $2i + 1$ ,右子节点索引为 $2i + 2$ ,父节点索引为 $(i - 1) / 2$(向下取整)。当索引越界时,表示空节点或节点不存在。
![堆的表示与存储](heap.assets/representation_of_heap.png)
<p align="center"> Fig. 堆的表示与存储 </p>
我们将索引映射公式封装成函数,以便后续使用。
我们可以将索引映射公式封装成函数,方便后续使用。
=== "Java"
@ -510,7 +510,7 @@ comments: true
### 访问堆顶元素
堆顶元素是二叉树的根节点,即列表首元素。
堆顶元素即为二叉树的根节点,也就是列表的首个元素。
=== "Java"
@ -601,9 +601,9 @@ comments: true
### 元素入堆
给定元素 `val` ,我们先将其添加到堆底。添加后,由于 `val` 可能大于堆中其它元素,此时堆的成立条件可能已经被破坏,**因此需要修复从插入节点到根节点这条路径上的各个节点**,该操作被称为「堆化 Heapify」。
给定元素 `val` ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,**需要修复从插入节点到根节点的路径上的各个节点**,这个操作被称为「堆化 Heapify」。
考虑从入堆节点开始,**从底至顶执行堆化**。具体地,比较插入节点与其父节点的值,若插入节点更大则将它们交换;并循环以上操作,从底至顶地修复堆中的各个节点;直至越过根节点时结束,或当遇到无需交换的节点时提前结束。
考虑从入堆节点开始,**从底至顶执行堆化**。具体来说,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无需交换的节点时结束。
=== "<1>"
![元素入堆步骤](heap.assets/heap_push_step1.png)
@ -623,7 +623,7 @@ comments: true
=== "<6>"
![heap_push_step6](heap.assets/heap_push_step6.png)
设节点总数为 $n$ ,则树的高度为 $O(\log n)$ 易得堆化操作的循环轮数最多为 $O(\log n)$ **因而元素入堆操作的时间复杂度为 $O(\log n)$** 。
设节点总数为 $n$ ,则树的高度为 $O(\log n)$ 。由此可知,堆化操作的循环轮数最多为 $O(\log n)$ **元素入堆操作的时间复杂度为 $O(\log n)$** 。
=== "Java"
@ -879,13 +879,13 @@ comments: true
### 堆顶元素出堆
堆顶元素是二叉树根节点,即列表首元素,如果我们直接将首元素从列表中删除,则二叉树中所有节点都会随之发生移位(索引发生变化),这样后续使用堆化修复就很麻烦了。为了尽量减少元素索引变动,采取以下操作步骤:
堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化修复变得困难。为了尽量减少元素索引的变动,我们采取以下操作步骤:
1. 交换堆顶元素与堆底元素(即交换根节点与最右叶节点);
2. 交换完成后,将堆底从列表中删除(注意,因为已经交换,实际上删除的是原来的堆顶元素);
2. 交换完成后,将堆底从列表中删除(注意,由于已经交换,实际上删除的是原来的堆顶元素);
3. 从根节点开始,**从顶至底执行堆化**
顾名思义,**从顶至底堆化的操作方向与从底至顶堆化相反**,我们比较根节点的值与其两个子节点的值,将最大的子节点与根节点执行交换,并循环以上操作,直到越过叶节点时结束,或当遇到无需交换的节点时提前结束。
顾名思义,**从顶至底堆化的操作方向与从底至顶堆化相反**,我们将根节点的值与其两个子节点的值进行比较,将最大的子节点与根节点交换;然后循环执行此操作,直到越过叶节点或遇到无需交换的节点时结束。
=== "<1>"
![堆顶元素出堆步骤](heap.assets/heap_pop_step1.png)
@ -917,7 +917,7 @@ comments: true
=== "<10>"
![heap_pop_step10](heap.assets/heap_pop_step10.png)
与元素入堆操作类似,**堆顶元素出堆操作的时间复杂度为 $O(\log n)$**
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 $O(\log n)$
=== "Java"
@ -1275,8 +1275,8 @@ comments: true
}
```
## 8.1.4. &nbsp; 堆常见应用
## 8.1.3. &nbsp; 堆常见应用
- **优先队列**。堆常作为实现优先队列的首选数据结构,入队和出队操作时间复杂度为 $O(\log n)$ ,建队操作为 $O(n)$ ,皆非常高效。
- **堆排序**。给定一组数据,我们使用其建堆,并依次全部弹出,则可以得到有序的序列。当然,堆排序一般无需弹出元素,仅需每轮将堆顶元素交换至数组尾部并减小堆的长度即可
- **获取最大的 $k$ 个元素**。这既是一道经典算法题目,也是一种常见应用,例如选取热度前 10 的新闻作为微博热搜,选取前 10 销量的商品等。
- **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。
- **堆排序**:给定一组数据,我们可以用它们建立一个堆,然后依次将所有元素弹出,从而得到一个有序序列。当然,堆排序的实现方法并不需要弹出元素,而是每轮将堆顶元素交换至数组尾部并缩小堆的长度
- **获取最大的 $k$ 个元素**:这是一个经典的算法问题,同时也是一种典型应用,例如选择热度前 10 的新闻作为微博热搜,选取销量前 10 的商品等。

View File

@ -4,9 +4,9 @@ comments: true
# 8.3. &nbsp; 小结
- 堆是一棵限定条件下的完全二叉树,根据成立条件可分为大顶堆和小顶堆。大(小)顶堆的堆顶元素最大(小)。
- 优先队列定义为一种具有出队优先级的队列。堆是实现优先队列的最常用数据结构
- 堆的常用操作和对应时间复杂度为元素入堆 $O(\log n)$ 、堆顶元素出堆 $O(\log n)$ 、访问堆顶元素 $O(1)$ 等。
- 完全二叉树非常适合用数组来表示,因此我们一般用数组来存储堆。
- 堆化操作用于修复堆的特性,在入堆和出堆操作中都会使用到。
- 输入 $n$ 个元素并建堆的时间复杂度可以优化至 $O(n)$ ,非常高效。
- 堆是一棵完全二叉树,根据成立条件可分为大顶堆和小顶堆。大(小)顶堆的堆顶元素最大(小)
- 优先队列的定义是具有出队优先级的队列,通常使用堆来实现
- 堆的常用操作及其对应的时间复杂度包括:元素入堆 $O(\log n)$ 、堆顶元素出堆 $O(\log n)$ 和访问堆顶元素 $O(1)$ 等。
- 完全二叉树非常适合用数组表示,因此我们通常使用数组来存储堆。
- 堆化操作用于维护堆的性质,在入堆和出堆操作中都会用到。
- 输入 $n$ 个元素并建堆的时间复杂度可以优化至 $O(n)$ ,非常高效。

View File

@ -4,19 +4,19 @@ comments: true
# 1.1. &nbsp; 算法无处不在
听到“算法”这个词,我们一般会联想到数学。但实际上,大多数算法并不包含复杂的数学,而更像是在考察基本逻辑,而这些逻辑在我们日常生活中处处可见。
当我们听到“算法”这个词时,很自然地会想到数学。然而实际上,许多算法并不涉及复杂数学,而是更多地依赖于基本逻辑,这些逻辑在我们的日常生活中处处可见。
在正式介绍算法之前,我想告诉你一件有趣的事:**其实,你在过去已经学会了很多算法,并且已经习惯将它们应用到日常生活中**。接下来,我将介绍两个具体例子来佐证
在正式探讨算法之前,有一个有趣的事实值得分享:**实际上,你已经学会了许多算法,并习惯将他们应用到日常生活中了**。下面,我将举两个具体例子来证实这一点
**例一:拼积木**。一套积木,除了有许多部件之外,还会附送详细的拼装说明书。我们按照说明书上一步步操作,即可拼出复杂的积木模型。
**例一:组装积木**。一套积木,除了包含许多零件之外,还附有详细的组装说明书。我们按照说明书一步步操作,就能组装出精美的积木模型。
如果从数据结构与算法的角度看,大大小小的「积木」就是数据结构,而「拼装说明书」上的一系列步骤就是算法。
从数据结构与算法的角度来看,积木的各种形状和连接方式代表数据结构,而组装说明书上的一系列步骤则是算法。
**例二:查字典**。在字典中,每个汉字都有一个对应的拼音,而字典是按照拼音的英文字母表顺序排列的。假设需要在字典中查询任意一个拼音首字母为 $r$ 的字,一般我们会这样做
**例二:查阅字典**。在字典里,每个汉字都对应一个拼音,而字典是按照拼音的英文字母顺序排列的。假设我们需要查找一个拼音首字母为 $r$ 的字,通常会这样操作
1. 打开字典大致一半页数的位置,查看此页的首字母是什么(假设为 $m$
2. 由于在英文字母表中 $r$ 在 $m$ 的后面,因此应排除字典前半部分,查找范围仅剩后半部分;
3. 循环执行步骤 1-2 ,直到找到拼音首字母为 $r$ 的页码时终止。
1. 翻开字典约一半的页数,查看该页首字母是什么(假设为 $m$
2. 由于在英文字母表中 $r$ 位于 $m$ 之后,所以排除字典前半部分,查找范围缩小到后半部分;
3. 不断重复步骤 1-2 ,直至找到拼音首字母为 $r$ 的页码为止。
=== "<1>"
![查字典步骤](algorithms_are_everywhere.assets/look_up_dictionary_step_1.png)
@ -33,10 +33,10 @@ comments: true
=== "<5>"
![look_up_dictionary_step_5](algorithms_are_everywhere.assets/look_up_dictionary_step_5.png)
字典这个小学生的标配技能,实际上就是大名鼎鼎的「二分查找」。从数据结构角度,我们可以将字典看作是一个已排序的「数组」;而从算法角度,我们可将上述查字典的一系列指令看作是「二分查找」算法。
阅字典这个小学生必备技能,实际上就是著名的「二分查找」。从数据结构的角度,我们可以把字典视为一个已排序的「数组」;从算法的角度,我们可以将上述查字典的一系列操作看作是「二分查找」算法。
小到烹饪一道菜、大到星际航行,几乎所有问题的解决都离不开算法。计算机的出现,使我们可以通过编程将数据结构存储在内存中,也可以编写代码来调用 CPU, GPU 执行算法,从而将生活中的问题搬运到计算机中,更加高效地解决各式各样的复杂问题。
小到烹饪一道菜,大到星际航行,几乎所有问题的解决都离不开算法。计算机的出现使我们能够通过编程将数据结构存储在内存中,同时编写代码调用 CPU 和 GPU 执行算法。这样一来,我们就能把生活中的问题转移到计算机上,以更高效的方式解决各种复杂问题。
!!! tip
读到这里,如果你感到对数据结构、算法、数组、二分查找等此类概念一知半解,那么就太好了!因为这正是本书存在的价值,接下来,本书将会一步步地引导你进入数据结构与算法的知识殿堂。
阅读至此,如果你对数据结构、算法、数组和二分查找等概念仍感到一知半解,那么太好了!因为这正是本书存在的意义。接下来,这本书将一步步引导你深入数据结构与算法的知识殿堂。

View File

@ -4,8 +4,8 @@ comments: true
# 1.3. &nbsp; 小结
- 算法在生活中随处可见,并不高深莫测。我们已经不知不觉地学习到许多“算法”,用于解决生活中大大小小的问题。
- “查字典”的原理和二分查找算法一致。二分体现分而治之的重要算法思想。
- 算法是在有限时间内解决特定问题的一组指令或操作步骤,数据结构是在计算机中组织与存储数据的方式。
- 数据结构与算法两者紧密联系。数据结构是算法的底座,算法是发挥数据结构的舞台。
- 乐高积木对应数据,积木形状和连接形式对应数据结构,拼装积木的流程步骤对应算法。
- 算法在日常生活中无处不在,并不是遥不可及的高深知识。实际上,我们已经在不知不觉中学习了许多“算法”,用以解决生活中的大小问题。
- 查阅字典的原理与二分查找算法相一致。二分查找体现了分而治之的重要算法思想。
- 算法是在有限时间内解决特定问题的一组指令或操作步骤,而数据结构是计算机中组织和存储数据的方式。
- 数据结构与算法紧密相连。数据结构是算法的基石,而算法则是发挥数据结构作用的舞台。
- 乐高积木对应于数据,积木形状和连接方式代表数据结构,拼装积木的步骤则对应算法。

View File

@ -8,34 +8,33 @@ comments: true
「算法 Algorithm」是在有限时间内解决特定问题的一组指令或操作步骤。算法具有以下特性
- 问题是明确的,需要拥有明确的输入和输出定义。
- 解具有确定性,即给定相同输入时,输出一定相同。
- 具有可行性,可在有限步骤、有限时间、有限内存空间下完成。
- 独立于编程语言,即可用多种语言实现。
- 问题是明确的,具有清晰的输入和输出定义。
- 解具有确定性,即给定相同的输入时,输出始终相同。
- 具有可行性,在有限步骤、时间和内存空间下可完成。
## 1.2.2. &nbsp; 数据结构定义
「数据结构 Data Structure」是在计算机中组织与存储数据的方式。为了提高数据存储和操作性能,数据结构的设计原则有
「数据结构 Data Structure」是计算机中组织和存储数据的方式。为了提高数据存储和操作性能,数据结构的设计目标包括
- 空间占用尽可能小,节省计算机内存。
- 数据操作尽量快,包括数据访问、添加、删除、更新等。
- 提供简洁的数据表示和逻辑信息,以便算法高效运行。
- 空间占用尽量减少,节省计算机内存。
- 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等。
- 提供简洁的数据表示和逻辑信息,以利于算法高效运行。
数据结构设计是一个充满权衡的过程,这意味着如果获得某方面的优势,则往往需要在另一方面做出妥协。例如,链表相对于数组,数据添加删除操作更加方便,但牺牲了数据的访问速度;图相对于链表,提供了更多的逻辑信息,但需要占用更多的内存空间。
数据结构设计是一个充满权衡的过程,这意味着要在某方面取得优势,往往需要在另一方面作出妥协。例如,链表相较于数组,在数据添加和删除操作上更加便捷,但牺牲了数据访问速度;图相较于链表,提供了更丰富的逻辑信息,但需要占用更大的内存空间。
## 1.2.3. &nbsp; 数据结构与算法的关系
「数据结构」与「算法」是高度相关、紧密嵌合的,体现在:
「数据结构」与「算法」高度相关且紧密结合,具体表现在:
- 数据结构是算法的底座。数据结构为算法提供结构化存储的数据,以及操作数据的对应方法。
- 算法是数据结构发挥的舞台。数据结构仅存储数据信息,结合算法才可解决特定问题。
- 算法有对应最优的数据结构。给定算法,一般可基于不同的数据结构实现,而最终执行效率往往相差很大。
- 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及用于操作数据的方法。
- 算法是数据结构发挥的舞台。数据结构本身仅存储数据信息,通过结合算法才能解决特定问题。
- 特定算法通常有对应最优的数据结构。算法通常可以基于不同的数据结构进行实现,但最终执行效率可能相差很大。
![数据结构与算法的关系](what_is_dsa.assets/relationship_between_data_structure_and_algorithm.png)
<p align="center"> Fig. 数据结构与算法的关系 </p>
如果将「LEGO 乐高」类比到「数据结构与算法」,那么可以得到下表所示的对应关系
类比「LEGO 乐高」和「数据结构与算法」,则对应关系如下表所示
<div class="center-table" markdown>
@ -48,6 +47,8 @@ comments: true
</div>
值得注意的是,数据结构与算法独立于编程语言。正因如此,本书得以提供多种编程语言的实现。
!!! tip "约定俗成的简称"
在实际讨论中,我们通常会将「数据结构与算法」直接简称为「算法」。例如,我们熟称的 LeetCode 算法题目,实际上同时考察了数据结构和算法两部分知识。
在实际讨论时,我们通常会将「数据结构与算法」简称为「算法」。例如,众所周知的 LeetCode 算法题目,实际上同时考察了数据结构和算法两方面的知识。

View File

@ -4,19 +4,19 @@ comments: true
# 0.1. &nbsp; 关于本书
本项目致力于构建一本开源免费、新手友好的数据结构与算法入门书
本项目旨在创建一本开源免费、新手友好的数据结构与算法入门教程
- 全书采用动画图解,结构化地讲解数据结构与算法知识,内容清晰易懂、学习曲线平滑;
- 算法源代码皆可一键运行,支持 Java, C++, Python, Go, JS, TS, C#, Swift, Zig 等语言;
- 鼓励读者在章节讨论区互帮互助、共同进步,提问与评论一般能在两日内得到回复;
- 鼓励读者在章节讨论区互帮互助、共同进步,提问与评论通常可在两日内得到回复;
## 0.1.1. &nbsp; 读者对象
如果您是「算法初学者」,完全没有接触过算法,或者已经有少量刷题,对数据结构与算法有朦胧的理解,在会与不会之间反复横跳,那么这本书就是为你而写
若您是「算法初学者」,从未接触过算法,或者已经有一些刷题经验,对数据结构与算法有模糊的认识,在会与不会之间反复横跳,那么这本书正是为您量身定制
如果您是「算法老手」,已经积累一定刷题量,接触过大多数题型,那么本书可以帮助你回顾与梳理算法知识体系,仓库源代码可以被当作“刷题工具库”或“算法字典”来使用。
如果您是「算法老手」,已经积累一定刷题量,熟悉大部分题型,那么本书可助您回顾与梳理算法知识体系,仓库源代码可以被当作“刷题工具库”或“算法字典”来使用。
如果您是「算法大佬」,希望可以得到你的宝贵意见建议,或者[一起参与创作](https://www.hello-algo.com/chapter_appendix/contribution/)。
若您是「算法专家」,我们期待收到您的宝贵建议,或者[一起参与创作](https://www.hello-algo.com/chapter_appendix/contribution/)。
!!! success "前置条件"
@ -24,11 +24,11 @@ comments: true
## 0.1.2. &nbsp; 内容结构
本书主要内容
本书主要内容包括
- **复杂度分析**:数据结构与算法的评价维度、算法效率的评估方法。时间复杂度、空间复杂度,包括推算方法、常见类型、示例等。
- **数据结构**:常用的基本数据类型,数据在内存中的存储方式、数据结构分类方法。数组、链表、栈、队列、散列表、树、堆、图等数据结构,内容包括定义、优劣势、常用操作、常见类型、典型应用、实现方法等。
- **算法**:查找算法、排序算法、搜索与回溯、动态规划、分治算法,内容包括定义、使用场景、优劣势、时空效率、实现方法、示例题目等。
- **数据结构**:常见基本数据类型,数据在内存中的存储形式、数据结构的分类方法。涉及数组、链表、栈、队列、散列表、树、堆、图等数据结构,内容包括定义、优缺点、常用操作、常见类型、典型应用、实现方法等。
- **算法**:查找算法、排序算法、搜索与回溯、动态规划、分治算法等,内容涵盖定义、应用场景、优缺点、时空效率、实现方法、示例题目等。
![Hello 算法内容结构](about_the_book.assets/hello_algo_mindmap.png)
@ -36,16 +36,16 @@ comments: true
## 0.1.3. &nbsp; 致谢
本书的成书过程中,我获得了许多人的帮助,包括但不限于:
在本书的创作过程中,我得到了许多人的帮助,包括但不限于:
- 感谢我在公司的导师李汐博士,在一次畅谈时您告诉我“觉得应该做就去做”,坚定了我写这本书的决心。
- 感谢我的女朋友泡泡担任本书的首位读者,从算法小白的视角提出了许多建议,使这本书更加适合初学者来阅读。
- 感谢腾宝、琦宝、飞宝为本书起了个好听又有梗名字,直接唤起我最初敲下第一行代码 "Hello World!" 的回忆。
- 感谢苏潼为本书设计了封面和 LOGO ,在我的强迫症下前后多次帮忙修改,谢谢你的耐心
- 感谢 @squidfunk 给出的写作排版建议,以及优秀开源项目 [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material/tree/master) 。
- 感谢我在公司的导师李汐博士,在深入交谈中您鼓励我“行动起来”,坚定了我写这本书的决心。
- 感谢我的女朋友泡泡作为本书的首位读者,从算法小白的角度提出许多宝贵建议,使得本书更适合新手阅读。
- 感谢腾宝、琦宝、飞宝为本书起了一个富有创意的名字,唤起大家写下第一行代码 "Hello World!" 的美好回忆。
- 感谢苏潼为本书设计了精美的封面和 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-en)、[李沐老师 bilibili 主页](https://space.bilibili.com/1567748478)。
在写作过程中,我阅读了许多关于数据结构与算法的教材和文章。这些作品为本书提供了优秀的范本,确保了本书内容的准确性与品质。在此感谢所有老师和前辈们的杰出贡献!
在写作过程中,我阅读了许多数据结构与算法的教材与文章,这些著作为本书作出了很好的榜样,保证了本书内容的正确性与质量,感谢各位老师与前辈的精彩创作!
本书倡导“手脑并用”的学习方法,在此方面深受《动手学深度学习》的启发。在此向各位读者强烈推荐这本优秀著作,包括[中文版](https://github.com/d2l-ai/d2l-zh)、[英文版](https://github.com/d2l-ai/d2l-en)、[李沐老师 bilibili 主页](https://space.bilibili.com/1567748478)。
感谢父母,你们一贯的支持与鼓励给了我自由度来做这些有趣的事。
衷心感谢我的父母,正是你们一直以来的支持与鼓励,让我有机会做这些富有趣味的事。

View File

@ -4,17 +4,19 @@ comments: true
# 0.2. &nbsp; 如何使用本书
建议通读本节内容,以获取最佳阅读体验。
!!! tip
为了获得最佳的阅读体验,建议您通读本节内容。
## 0.2.1. &nbsp; 算法学习路线
总体上看,我认为可将学习数据结构与算法的过程分为三个阶段。
从总体上看,我们可以将学习数据结构与算法的过程划分为三个阶段:
1. **算法入门**熟悉各种数据结构的特点、用法,学习各种算法的原理、流程、用途、效率等
2. **刷算法题**可以先从热门题单开刷,推荐[剑指 Offer](https://leetcode.cn/problem-list/xb9nqhhg/)、[LeetCode Hot 100](https://leetcode.cn/problem-list/2cktkvj/),先积累至少 100 道题量,熟悉大多数的算法问题。刚开始刷题时,“遗忘”是最大的困扰点,但这是很正常的,请不要担心。学习中有一种概念叫“周期性回顾”,同一道题隔段时间做一次,在重复 3 轮以上后,往往就能牢记于心了
3. **搭建知识体系**。在学习方面,可以阅读算法专栏文章、解题框架、算法教材,不断地丰富知识体系。在刷题方面,可以开始采用进阶刷题方案,例如按专题分类、一题多解、一解多题等,相关刷题心得可以在各个社区找到。
1. **算法入门**我们需要熟悉各种数据结构的特点和用法,学习不同算法的原理、流程、用途和效率等方面内容
2. **刷算法题**建议从热门题目开刷,如[剑指 Offer](https://leetcode.cn/problem-list/xb9nqhhg/)和[LeetCode Hot 100](https://leetcode.cn/problem-list/2cktkvj/),先积累至少 100 道题目,熟悉主流的算法问题。初次刷题时,“知识遗忘”可能是一个挑战,但请放心,这是很正常的。我们可以按照“艾宾浩斯遗忘曲线”来复习题目,通常在进行 3-5 轮的重复后,就能将其牢记在心
3. **搭建知识体系**。在学习方面,我们可以阅读算法专栏文章、解题框架和算法教材,以不断丰富知识体系。在刷题方面,可以尝试采用进阶刷题策略,如按专题分类、一题多解、一解多题等,相关刷题心得可以在各个社区找到。
作为一本入门教程,**本书内容主要对应“第一阶段”**,致力于帮助你更高效地开展第二、三阶段的学习。
作为一本入门教程,本书内容主要涵盖“第一阶段”,旨在帮助你更高效地展开第二和第三阶段的学习。
![算法学习路线](suggestions.assets/learning_route.png)
@ -22,11 +24,11 @@ comments: true
## 0.2.2. &nbsp; 行文风格约定
标题后标注 `*` 的是选读章节,内容相对难。如果你的时间有限,建议可以先跳过。
标题后标注 `*` 的是选读章节,内容相对难。如果你的时间有限,建议可以先跳过。
文章中的重要名词会用 `「括号」` 标注,例如 `「数组 Array」` 。建议记住这些名词,包括英文翻译,以便后续阅读文献时使用。
文章中的重要名词会用 `「 」` 括号标注,例如 `「数组 Array」` 。请务必记住这些名词,包括英文翻译,以便后续阅读文献时使用。
重点内容、总起句、总结句会被 **加粗** ,此类文字值得特别关注。
**加粗的文字** 表示重点内容或总结性语句,这类文字值得特别关注。
专有名词和有特指含义的词句会使用 `“双引号”` 标注,以避免歧义。
@ -162,9 +164,9 @@ comments: true
## 0.2.3. &nbsp; 在动画图解中高效学习
视频和图片相比于文字的信息密度和结构化程度更高,更容易理解。在本书中,**知识重难点会主要以动画、图解的形式呈现**,而文字的作用则是作为动画和图的解释与补充。
相较于文字,视频和图片具有更高的信息密度和结构化程度,因此更易于理解。在本书中,**重点和难点知识将主要通过动画和图解形式展示**,而文字则作为动画和图片的解释与补充。
阅读本书时,若发现某段内容提供了动画或图解,**建议你以图为主线**,将文字内容(一般在图的上方)对齐到图中内容,综合来理解
在阅读本书时,如果发现某段内容提供了动画或图解,**建议以图为主线**,以文字(通常位于图像上方)为辅,综合两者来理解内容
![动画图解示例](suggestions.assets/animation.gif)
@ -172,30 +174,29 @@ comments: true
## 0.2.4. &nbsp; 在代码实践中加深理解
本书的配套代码托管在[GitHub 仓库](https://github.com/krahets/hello-algo)**源代码包含详细注释,配有测试样例,可以直接运行**。
本书的配套代码托管在[GitHub 仓库](https://github.com/krahets/hello-algo)**源代码包含详细注释,并附有测试样例,可直接运行**。
- 若学习时间紧张,**建议至少将所有代码通读并运行一遍**。
- 若时间允许,**强烈建议对照着代码自己敲一遍**。相比于读代码,写代码的过程往往能带来新的收获。
如果学习时间有限,建议你至少通读并运行所有代码。如果时间充裕,**建议参照代码自行敲一遍**。与仅阅读代码相比,编写代码的过程往往能带来更多收获。
![运行代码示例](suggestions.assets/running_code.gif)
<p align="center"> Fig. 运行代码示例 </p>
**第一步:安装本地编程环境**。参照[附录教程](https://www.hello-algo.com/chapter_appendix/installation/),如果已有可直接跳过
**第一步:安装本地编程环境**。参照[附录教程](https://www.hello-algo.com/chapter_appendix/installation/)进行安装,如果已安装则可跳过此步骤
**第二步:下载代码仓**。如果已经安装 [Git](https://git-scm.com/downloads) ,可以通过命令行来克隆代码仓
**第二步:下载代码仓**。如果已经安装 [Git](https://git-scm.com/downloads) ,可以通过以下命令克隆本仓库
```shell
git clone https://github.com/krahets/hello-algo.git
```
当然你也可以点击“Download ZIP”直接下载代码压缩包本地解压即可。
当然你也可以点击“Download ZIP”直接下载代码压缩包然后在本地解压即可。
![克隆仓库与下载代码](suggestions.assets/download_code.png)
<p align="center"> Fig. 克隆仓库与下载代码 </p>
**第三步:运行源代码**。若代码块的顶部标有文件名称,则可在仓库 `codes` 文件夹中找到对应的 **源代码文件**。源代码文件可以帮助你省去不必要的调试时间,将精力集中在学习内容上
**第三步:运行源代码**。如果代码块顶部标有文件名称,则可以在仓库的 `codes` 文件夹中找到相应的源代码文件。源代码文件将帮助你节省不必要的调试时间,让你能够专注于学习内容
![代码块与对应的源代码文件](suggestions.assets/code_md_to_repo.png)
@ -203,9 +204,9 @@ git clone https://github.com/krahets/hello-algo.git
## 0.2.5. &nbsp; 在提问讨论中共同成长
阅读本书时,请不要“惯着”那些弄不明白的知识点。**欢迎在评论区留下你的问题**,小伙伴们和我都会给予解答,您一般 2 日内会得到回复。
阅读本书时,请不要“惯着”那些没学明白的知识点。**欢迎在评论区提出你的问题**,我和其他小伙伴们将竭诚为你解答,一般情况下可在两天内得到回复。
同时,也希望你可以多花时间逛逛评论区。一方面,可以看看大家遇到了什么问题,反过来查漏补缺,这往往可以引起更加深度的思考。另一方面,也希望你可以慷慨地解答小伙伴们的问题、分享自己的见解,大家互相学习与进步!
同时,也希望您能在评论区多花些时间。一方面,您可以了解大家遇到的问题,从而查漏补缺,这将有助于激发更深入的思考。另一方面,希望您能慷慨地回答其他小伙伴的问题、分享您的见解,让大家共同学习和进步。
![评论区示例](suggestions.assets/comment.gif)

View File

@ -4,9 +4,9 @@ comments: true
# 0.3. &nbsp; 小结
- 本书主要面向算法初学者。对于已经有一定积累的同学,这本书可以帮助你系统回顾算法知识,源代码可被当作“刷题工具库”来使用。
- 书中内容主要分为复杂度分析、数据结构、算法三部分,覆盖了该领域的大部分主题。
- 对于算法小白,在初学阶段阅读一本入门书是非常有必要的,可以少走许多弯路。
- 书内的动画和图解往往介绍的是重点和难点知识,在阅读时应该多加关注。
- 实践是学习编程的最佳方式,强烈推荐运行源代码,动手敲代码。
- 本书提供了讨论区,遇到疑惑可以随时提问
- 本书的主要受众是算法初学者。对于已具备一定积累的同学,本书能帮助系统回顾算法知识,同时源代码可作为“刷题工具库”使用。
- 书中内容主要包括复杂度分析、数据结构、算法三部分,涵盖了该领域的绝大部分主题。
- 对于算法新手,在初学阶段阅读一本入门书籍至关重要,有助于避免走弯路。
- 书内的动画和图解通常用于介绍重点和难点知识,阅读时应给予更多关注。
- 实践乃学习编程之最佳途径,强烈建议运行源代码并亲自敲打代码。
- 本书设有讨论区,欢迎随时分享你的疑惑

View File

@ -8,7 +8,7 @@
[4] 邓俊辉. 数据结构C++ 语言版,第三版).
[5] 马克·艾伦·维斯著,陈越译. 数据结构与算法分析Java语言描述第三版.
[5] 马克 艾伦 维斯著,陈越译. 数据结构与算法分析Java语言描述第三版.
[6] 程杰. 大话数据结构.

View File

@ -4,29 +4,24 @@ comments: true
# 10.2. &nbsp; 二分查找
「二分查找 Binary Search」利用数据的有序性通过每轮缩小一半搜索区间来查找目标元素。
使用二分查找有两个前置条件:
- **要求输入数据是有序的**,这样才能通过判断大小关系来排除一半的搜索区间;
- **二分查找仅适用于数组**,而在链表中使用效率很低,因为其在循环中需要跳跃式(非连续地)访问元素。
「二分查找 Binary Search」利用数据的有序性通过每轮减少一半搜索范围来定位目标元素。
## 10.2.1. &nbsp; 算法实现
给定一个长度为 $n$ 的排序数组 `nums` ,元素从小到大排列。数组的索引取值范围为
给定一个长度为 $n$ 的有序数组 `nums` ,元素按从小到大的顺序排列。数组索引的取值范围为:
$$
0, 1, 2, \cdots, n-1
$$
使用「区间」来表示这个取值范围的方法主要有两种
我们通常使用以下两种方法来表示这个取值范围
1. **双闭区间 $[0, n-1]$** ,即两个边界都包含自身;此方法下,区间 $[0, 0]$ 仍包含一个元素;
2. **左闭右开 $[0, n)$** ,即左边界包含自身、右边界不包含自身;此方法下,区间 $[0, 0)$ 为空
1. **双闭区间 $[0, n-1]$** ,即两个边界都包含自身;在此方法下,区间 $[0, 0]$ 仍包含 $1$ 个元素;
2. **左闭右开 $[0, n)$** ,即左边界包含自身、右边界不包含自身;在此方法下,区间 $[0, 0)$ 不包含元素
### “双闭区间”实现
首先,我们采用“双闭区间”表示,在数组 `nums` 中查找目标元素 `target` 的对应索引。
首先,我们采用“双闭区间”表示,在数组 `nums` 中查找目标元素 `target` 的对应索引。
=== "<1>"
![二分查找步骤](binary_search.assets/binary_search_step1.png)
@ -49,7 +44,7 @@ $$
=== "<7>"
![binary_search_step7](binary_search.assets/binary_search_step7.png)
二分查找“双闭区间”表示下的代码如下所示。
二分查找“双闭区间”表示下的代码如下所示。
=== "Java"
@ -260,7 +255,7 @@ $$
### “左闭右开”实现
当然,我们也可以使用“左闭右开”的表示方法,写出相同功能的二分查找代码。
此外,我们也可以采用“左闭右开”的表示法,编写具有相同功能的二分查找代码。
=== "Java"
@ -472,7 +467,7 @@ $$
### 两种表示对比
对比下来,两种表示的代码写法有以下不同点:
对比这两种代码写法,我们可以发现以下不同点:
<div class="center-table" markdown>
@ -483,11 +478,11 @@ $$
</div>
观察发现,在“双闭区间”表示中,由于对左右两边界的定义是相同的,因此缩小区间的 $i$ , $j$ 处理方法也是对称的,这样更不容易出错。综上所述,**建议你采用“双闭区间”的写法。**
在“双闭区间”表示法中,由于对左右两边界的定义相同,因此缩小区间的 $i$ 和 $j$ 的处理方法也是对称的,这样更不容易出错。因此,**建议采用“双闭区间”的写法**。
### 大数越界处理
当数组长度很大时,加法 $i + j$ 的结果有可能会超出 `int` 类型的取值范围。在此情况下,我们需要换一种计算中点的写法。
当数组长度非常大时,加法 $i + j$ 的结果可能会超出 `int` 类型的取值范围。在这种情况下,我们需要采用一种更安全的计算中点的方法。
=== "Java"
@ -573,19 +568,19 @@ $$
## 10.2.2. &nbsp; 复杂度分析
**时间复杂度 $O(\log n)$** :其中 $n$ 为数组或链表长度;每轮排除一半的区间,因此循环轮数为 $\log_2 n$ ,使用 $O(\log n)$ 时间。
**时间复杂度 $O(\log n)$** :其中 $n$ 为数组长度;每轮排除一半的区间,因此循环轮数为 $\log_2 n$ ,使用 $O(\log n)$ 时间。
**空间复杂度 $O(1)$** :指针 `i` , `j` 使用常数大小空间。
## 10.2.3. &nbsp; 优点与缺点
## 10.2.3. &nbsp; 优点与局限性
二分查找效率很高,体现在:
二分查找效率很高,主要体现在:
- **二分查找时间复杂度低**。对数阶在数据量很大时具有巨大优势,例如,当数据大小 $n = 2^{20}$ 时,线性查找需要 $2^{20} = 1048576$ 轮循环,而二分查找仅需 $\log_2 2^{20} = 20$ 轮循环。
- **二分查找不需要额外空间**。相对于借助额外数据结构来实现查找的算法来说,其更加节约空间使用
- **二分查找的时间复杂度较低**。对数阶在大数据量情况下具有显著优势。例如,当数据大小 $n = 2^{20}$ 时,线性查找需要 $2^{20} = 1048576$ 轮循环,而二分查找仅需 $\log_2 2^{20} = 20$ 轮循环。
- **二分查找无需额外空间**。与哈希查找相比,二分查找更加节省空间
但并不意味着所有情况下都应使用二分查找,这是因为
然而,并非所有情况下都可使用二分查找,原因如下
- **二分查找仅适用于有序数据**如果输入数据是无序的,为了使用二分查找而专门执行数据排序,那么是得不偿失的,因为排序算法的时间复杂度一般为 $O(n \log n)$ ,比线性查找和二分查找都更差。再例如,对于频繁插入元素的场景,为了保持数组的有序性,需要将元素插入到特定位置,时间复杂度为 $O(n)$ ,也是非常昂贵的。
- **二分查找仅适用于数组**由于在二分查找中,访问索引是 “非连续” 的,因此链表或者基于链表实现的数据结构都无法使用
- **在小数据量下,线性查找的性能更好**。在线性查找中,每轮只需要 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,数据量 $n$ 较小时,线性查找反而比二分查找更快。
- **二分查找仅适用于有序数据**若输入数据无序,为了使用二分查找而专门进行排序,得不偿失。因为排序算法的时间复杂度通常为 $O(n \log n)$ ,比线性查找和二分查找都更高。对于频繁插入元素的场景,为保持数组有序性,需要将元素插入到特定位置,时间复杂度为 $O(n)$ ,也是非常昂贵的。
- **二分查找仅适用于数组**二分查找需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效率较低,因此不适合应用在链表或基于链表实现的数据结构
- **小数据量下,线性查找性能更佳**。在线性查找中,每轮只需要 1 次判断操作;而在二分查找中,需要 1 次加法、1 次除法、1 ~ 3 次判断操作、1 次加法(减法),共 4 ~ 6 个单元操作;因此,数据量 $n$ 较小时,线性查找反而比二分查找更快。

View File

@ -4,15 +4,13 @@ comments: true
# 10.3. &nbsp; 哈希查找
!!! question
「哈希查找 Hash Searching」通过使用哈希表来存储所需的键值对从而可在 $O(1)$ 时间内完成“键 $\rightarrow$ 值”的查找操作。
在数据量很大时,「线性查找」太慢;而「二分查找」要求数据必须是有序的,并且只能在数组中应用。那么是否有方法可以同时避免上述缺点呢?答案是肯定的,此方法被称为「哈希查找」。
「哈希查找 Hash Searching」借助一个哈希表来存储需要的「键值对 Key Value Pair」我们可以在 $O(1)$ 时间下实现“键 $\rightarrow$ 值”映射查找,体现着“以空间换时间”的算法思想。
与线性查找相比,哈希查找通过利用额外空间来提高效率,体现了“以空间换时间”的算法思想。
## 10.3.1. &nbsp; 算法实现
如果我们想要给定数组中的一个目标元素 `target` ,获取该元素的索引,那么可以借助一个哈希表实现查找
例如,若我们想要在给定数组中找到目标元素 `target` 的索引,则可以使用哈希查找来实现
![哈希查找数组索引](hashing_search.assets/hash_search_index.png)
@ -130,7 +128,7 @@ comments: true
}
```
再比如,如果我们想要给定一个目标节点值 `target` ,获取对应的链表节点对象,那么也可以使用哈希查找实现
同样,若要根据目标节点值 target 查找对应的链表节点对象,也可以采用哈希查找方法
![哈希查找链表节点](hashing_search.assets/hash_search_listnode.png)
@ -250,15 +248,15 @@ comments: true
**时间复杂度 $O(1)$** :哈希表的查找操作使用 $O(1)$ 时间。
**空间复杂度 $O(n)$** :其中 $n$ 为数组或链表长度。
**空间复杂度 $O(n)$** :其中 $n$ 是数组或链表的长度。
## 10.3.3. &nbsp; 优点与缺点
## 10.3.3. &nbsp; 优点与局限性
在哈希表中,**查找、插入、删除操作的平均时间复杂度都为 $O(1)$** ,这意味着无论是高频增删还是高频查找场景,哈希查找的性能表现都非常好。当然,一切的前提是保证哈希表未退化。
哈希查找的性能表现相当优秀,查找、插入、删除操作的平均时间复杂度均为 $O(1)$ 。尽管如此,哈希查找仍然存在一些问题:
即使如此,哈希查找仍存在一些问题,在实际应用中,需要根据情况灵活选择方法。
- 辅助哈希表需要占用 $O(n)$ 的额外空间,意味着需要预留更多的计算机内存;
- 构建和维护哈希表需要时间,因此哈希查找不适用于高频增删、低频查找的场景;
- 当哈希冲突严重时,哈希表可能退化为链表,导致时间复杂度劣化至 $O(n)$
- 当数据量较小时,线性查找可能比哈希查找更快。这是因为计算哈希函数可能比遍历一个小型数组更慢;
- 辅助哈希表 **需要使用 $O(n)$ 的额外空间**,意味着需要预留更多的计算机内存;
- 建立和维护哈希表需要时间,因此哈希查找 **不适合高频增删、低频查找的使用场景**
- 当哈希冲突严重时,哈希表会退化为链表,**时间复杂度劣化至 $O(n)$**
- **当数据量很小时,线性查找比哈希查找更快**。这是因为计算哈希映射函数可能比遍历一个小型数组更慢;
因此,在实际应用中,我们需要根据具体情况灵活选择解决方案。

View File

@ -4,11 +4,11 @@ comments: true
# 10.1. &nbsp; 线性查找
「线性查找 Linear Search」是一种最基础的查找方法,其从数据结构的一端开始,依次访问每个元素,直到另一端后停止。
「线性查找 Linear Search」是一种简单的查找方法,其从数据结构的一端开始,逐个访问每个元素,直至另一端为止。
## 10.1.1. &nbsp; 算法实现
线性查找实质上就是遍历数据结构 + 判断条件。比如,我们想要在数组 `nums` 中查找目标元素 `target` 的对应索引,那么可以在数组中进行线性查找
例如,若我们想要在数组 `nums` 中查找目标元素 `target` 的对应索引,可以采用线性查找方法
![在数组中线性查找元素](linear_search.assets/linear_search.png)
@ -167,7 +167,7 @@ comments: true
}
```
再比如,我们想要在给定一个目标节点值 `target` ,返回此节点对象,也可以在链表中进行线性查找。
另一个例子,若需要在链表中查找给定目标节点值 `target` 并返回该节点对象,同样可以使用线性查找。
=== "Java"
@ -332,12 +332,12 @@ comments: true
## 10.1.2. &nbsp; 复杂度分析
**时间复杂度 $O(n)$** :其中 $n$ 为数组或链表长度。
**时间复杂度 $O(n)$** :其中 $n$ 代表数组或链表的长度。
**空间复杂度 $O(1)$** :无需使用额外空间。
**空间复杂度 $O(1)$** :无需借助额外的存储空间。
## 10.1.3. &nbsp; 优点与缺点
## 10.1.3. &nbsp; 优点与局限性
**线性查找的通用性极佳**。由于线性查找是依次访问元素的,即没有跳跃访问元素,因此数组或链表皆适用
**线性查找具有极佳的通用性**。由于线性查找是逐个访问元素的,没有跳跃式访问,因此适用于数组和链表的查找
**线性查找的时间复杂度太高**。在数据量 $n$ 很大时,查找效率很低。
**线性查找的时间复杂度较高**。当数据量 $n$ 较大时,线性查找的效率较低。

View File

@ -4,19 +4,17 @@ comments: true
# 10.4. &nbsp; 小结
- 线性查找是一种最基础的查找方法,通过遍历数据结构 + 判断条件实现查找
- 二分查找利用数据的有序性,通过循环不断缩小一半搜索区间来实现查找,其要求输入数据是有序的,并且仅适用于数组或基于数组实现的数据结构。
- 哈希查找借助哈希表来实现常数阶时间复杂度的查找操作,体现以空间换时间的算法思想
- 下表总结对比了查找算法的各种特性和时间复杂度。
- 线性查找通过遍历数据结构并进行条件判断来完成查找任务
- 二分查找依赖于数据的有序性,通过循环逐步缩减一半搜索区间来实现查找。它要求输入数据有序,且仅适用于数组或基于数组实现的数据结构。
- 哈希查找利用哈希表实现常数阶时间复杂度的查找操作,体现了空间换时间的算法思维
- 下表概括并对比了三种查找算法的特性和时间复杂度。
<div class="center-table" markdown>
| | 线性查找 | 二分查找 | 哈希查找 |
| ------------------------------------- | ------------------------ | ----------------------------- | ------------------------ |
| 适用数据结构 | 数组、链表 | 数组 | 数组、链表 |
| 输入数据要求 | 无 | 有序 | 无 |
| 平均时间复杂度</br>查找 / 插入 / 删除 | $O(n)$ / $O(1)$ / $O(n)$ | $O(\log n)$ / $O(n)$ / $O(n)$ | $O(1)$ / $O(1)$ / $O(1)$ |
| 最差时间复杂度</br>查找 / 插入 / 删除 | $O(n)$ / $O(1)$ / $O(n)$ | $O(\log n)$ / $O(n)$ / $O(n)$ | $O(n)$ / $O(n)$ / $O(n)$ |
| 适用数据结构 | 数组、链表 | 有序数组 | 数组、链表 |
| 时间复杂度</br>(查找,插入,删除) | $O(n)$ , $O(1)$ , $O(n)$ | $O(\log n)$ , $O(n)$ , $O(n)$ | $O(1)$ , $O(1)$ , $O(1)$ |
| 空间复杂度 | $O(1)$ | $O(1)$ | $O(n)$ |
</div>

View File

@ -4,15 +4,11 @@ comments: true
# 11.2. &nbsp; 冒泡排序
「冒泡排序 Bubble Sort」是一种基于元素交换实现排序的算法,非常适合作为第一个学习的排序算法
「冒泡排序 Bubble Sort」的工作原理类似于泡泡在水中的浮动。在水中,较大的泡泡会最先浮到水面
!!! question "为什么叫“冒泡”"
「冒泡操作」利用元素交换操作模拟了上述过程,具体做法为:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。
在水中,越大的泡泡浮力越大,所以最大的泡泡会最先浮到水面。
「冒泡操作」则是在模拟上述过程,具体做法为:从数组最左端开始向右遍历,依次对比相邻元素大小,若“左元素 > 右元素”则将它俩交换,最终可将最大元素移动至数组最右端。
完成一次冒泡操作后,**数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素**。
**在完成一次冒泡操作后,数组的最大元素已位于正确位置,接下来只需对剩余 $n - 1$ 个元素进行排序**。
=== "<1>"
![冒泡操作步骤](bubble_sort.assets/bubble_operation_step1.png)
@ -37,11 +33,11 @@ comments: true
## 11.2.1. &nbsp; 算法流程
设输入数组长度为 $n$ 循环执行「冒泡」操作
设输入数组长度为 $n$ 整个冒泡排序的步骤为
1. 完成第一轮「冒泡」后,数组最大元素已在正确位置,接下来只需排序剩余 $n - 1$ 个元素
2. 对剩余 $n - 1$ 个元素执行「冒泡」,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个;
3. 以此类推…… **循环 $n - 1$ 轮「冒泡」,即可完成整个数组的排序**
1. 完成第一轮「冒泡」后,数组的最大元素已位于正确位置,接下来只需对剩余 $n - 1$ 个元素进行排序
2. 对剩余 $n - 1$ 个元素执行冒泡操作,可将第二大元素交换至正确位置,因而待排序元素只剩 $n - 2$ 个;
3. 如此类推,经过 $n - 1$ 轮冒泡操作,整个数组便完成排序
![冒泡排序流程](bubble_sort.assets/bubble_sort_overview.png)
@ -233,17 +229,17 @@ comments: true
## 11.2.2. &nbsp; 算法特性
**时间复杂度 $O(n^2)$** :各轮冒泡遍历的数组长度为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,因此使用 $O(n^2)$ 时间。引入下文的 `flag` 优化后,最佳时间复杂度可以达到 $O(N)$ ,因此是“自适应排序”。
**时间复杂度 $O(n^2)$** :各轮冒泡遍历的数组长度依次为 $n - 1$ , $n - 2$ , $\cdots$ , $2$ , $1$ ,总和为 $\frac{(n - 1) n}{2}$ ,因此使用 $O(n^2)$ 时间。引入下文的 `flag` 优化后,最佳时间复杂度可达到 $O(n)$ ,所以它是“自适应排序”。
**空间复杂度 $O(1)$** :指针 $i$ , $j$ 使用常数大小的额外空间,因此是“原地排序”。
冒泡操作中遇到相等元素不交换,因此是“稳定排序”。
由于冒泡操作中遇到相等元素不交换,因此冒泡排序是“稳定排序”。
## 11.2.3. &nbsp; 效率优化
我们发现,若在某轮「冒泡」中未执行任何交换操作,则说明数组已经完成排序,可直接返回结果。考虑可以增加一个标志位 `flag` 来监听该情况,若出现则直接返回。
我们发现,如果某轮冒泡操作中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 `flag` 来监测这种情况,一旦出现就立即返回。
优化,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ 而在输入数组完全有序时,达到最佳时间复杂度 $O(n)$ 。
经过优化,冒泡排序的最差和平均时间复杂度仍为 $O(n^2)$ 但当输入数组完全有序时,可达到最佳时间复杂度 $O(n)$ 。
=== "Java"

View File

@ -4,15 +4,15 @@ comments: true
# 11.6. &nbsp; 桶排序
面介绍的几种排序算法都属于 **基于比较的排序算法**,即通过比较元素之间的大小来实现排序,此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将学习几种 **非比较排序算法** ,其时间复杂度可以达到线性级别
述的几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 $O(n \log n)$ 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性水平
「桶排序 Bucket Sort」是分治思想的典型体现,其通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中,并在每个桶内部分别执行排序,最终按照桶的顺序将所有数据合并即可
「桶排序 Bucket Sort」是分治思想的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并
## 11.6.1. &nbsp; 算法流程
输入一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数,桶排序流程为
考虑一个长度为 $n$ 的数组,元素是范围 $[0, 1)$ 的浮点数。桶排序的流程如下
1. 初始化 $k$ 个桶,将 $n$ 个元素分配 $k$ 个桶中;
1. 初始化 $k$ 个桶,将 $n$ 个元素分配 $k$ 个桶中;
2. 对每个桶分别执行排序(本文采用编程语言的内置排序函数);
3. 按照桶的从小到大的顺序,合并结果;
@ -40,7 +40,7 @@ comments: true
}
// 2. 对各个桶执行排序
for (List<Float> bucket : buckets) {
// 使用内置排序函数,也可以替换成其排序算法
// 使用内置排序函数,也可以替换成其排序算法
Collections.sort(bucket);
}
// 3. 遍历桶合并结果
@ -56,31 +56,7 @@ comments: true
=== "C++"
```cpp title="bucket_sort.cpp"
/* 桶排序 */
void bucketSort(vector<float> &nums) {
// 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
int k = nums.size() / 2;
vector<vector<float>> buckets(k);
// 1. 将数组元素分配到各个桶中
for (float num : nums) {
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
int i = num * k;
// 将 num 添加进桶 bucket_idx
buckets[i].push_back(num);
}
// 2. 对各个桶执行排序
for (vector<float> &bucket : buckets) {
// 使用内置排序函数,也可以替换成其它排序算法
sort(bucket.begin(), bucket.end());
}
// 3. 遍历桶合并结果
int i = 0;
for (vector<float> &bucket : buckets) {
for (float num : bucket) {
nums[i++] = num;
}
}
}
[class]{}-[func]{bucketSort}
```
=== "Python"
@ -99,7 +75,7 @@ comments: true
buckets[i].append(num)
# 2. 对各个桶执行排序5
for bucket in buckets:
# 使用内置排序函数,也可以替换成其排序算法
# 使用内置排序函数,也可以替换成其排序算法
bucket.sort()
# 3. 遍历桶合并结果
i = 0
@ -129,7 +105,7 @@ comments: true
}
// 2. 对各个桶执行排序
for i := 0; i < k; i++ {
// 使用内置切片排序函数,也可以替换成其排序算法
// 使用内置切片排序函数,也可以替换成其排序算法
sort.Float64s(buckets[i])
}
// 3. 遍历桶合并结果
@ -163,7 +139,7 @@ comments: true
}
// 2. 对各个桶执行排序
for (const bucket of buckets) {
// 使用内置排序函数,也可以替换成其排序算法
// 使用内置排序函数,也可以替换成其排序算法
bucket.sort((a, b) => a - b);
}
// 3. 遍历桶合并结果
@ -196,7 +172,7 @@ comments: true
}
// 2. 对各个桶执行排序
for (const bucket of buckets) {
// 使用内置排序函数,也可以替换成其排序算法
// 使用内置排序函数,也可以替换成其排序算法
bucket.sort((a, b) => a - b);
}
// 3. 遍历桶合并结果
@ -238,7 +214,7 @@ comments: true
}
// 2. 对各个桶执行排序
for i in buckets.indices {
// 使用内置排序函数,也可以替换成其排序算法
// 使用内置排序函数,也可以替换成其排序算法
buckets[i].sort()
}
// 3. 遍历桶合并结果
@ -258,31 +234,31 @@ comments: true
[class]{}-[func]{bucketSort}
```
!!! question "桶排序的用场景是什么?"
!!! question "桶排序的用场景是什么?"
桶排序一般用于排序超大体量的数据。例如输入数据包含 100 万个元素,由于空间有限,系统无法一次性将所有数据加载进内存,那么可以先将数据划分到 1000 个桶里,再依次排序每个桶,最终合并结果即可
桶排序适用于处理体量很大的数据。例如,输入数据包含 100 万个元素,由于空间限制,系统内存无法一次性加载所有数据。此时,可以将数据分成 1000 个桶,然后分别对每个桶进行排序,最后将结果合并
## 11.6.2. &nbsp; 算法特性
**时间复杂度 $O(n + k)$** :假设元素平均分布在各个桶内,则每个桶内元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。最后合并结果需要遍历 $n$ 个桶,使用 $O(k)$ 时间。
**时间复杂度 $O(n + k)$** :假设元素在各个桶内平均分布,那么每个桶内的元素数量为 $\frac{n}{k}$ 。假设排序单个桶使用 $O(\frac{n}{k} \log\frac{n}{k})$ 时间,则排序所有桶使用 $O(n \log\frac{n}{k})$ 时间。**当桶数量 $k$ 比较大时,时间复杂度则趋向于 $O(n)$** 。合并结果时需要遍历 $n$ 个桶,花费 $O(k)$ 时间。
最差情况下,所有数据被分配到一个桶中,且排序算法退化至 $O(n^2)$ ,此时使用 $O(n^2)$ 时间,因此是“自适应排序”。
在最坏情况下,所有数据被分配到一个桶中,且排序该桶使用 $O(n^2)$ 时间,因此是“自适应排序”。
**空间复杂度 $O(n + k)$** :需要借助 $k$ 个桶和共 $n$ 个元素的额外空间,是“非原地排序”。
**空间复杂度 $O(n + k)$** :需要借助 $k$ 个桶和总共 $n$ 个元素的额外空间,属于“非原地排序”。
桶排序是否稳定取决于排序桶内元素的算法是否稳定。
## 11.6.3. &nbsp; 如何实现平均分配
桶排序的时间复杂度理论上可以达到 $O(n)$ **难点是需要将元素均匀分配到各个桶中**,因为现实中的数据往往都不是均匀分布的。举个例子,假设我们想要把淘宝的所有商品根据价格范围平均分配到 10 个桶中然而商品价格不是均匀分布的100 元以下非常多、1000 元以上非常少;如果我们将价格区间平均划为 10 份,那么各个桶内的商品数量差距会非常大。
桶排序的时间复杂度理论上可以达到 $O(n)$ **关键在于将元素均匀分配到各个桶中**,因为实际数据往往不是均匀分布的。例如,我们想要将淘宝上的所有商品按价格范围平均分配到 10 个桶中,但商品价格分布不均,低于 100 元的非常多,高于 1000 元的非常少。若将价格区间平均划分为 10 份,各个桶中的商品数量差距会非常大。
了实现平均分配,我们可以先大致设置一个分界线,将数据粗略分到 3 个桶,分配完后,**再把商品较多的桶继续划分为 3 个桶,直至所有桶内元素数量大致平均为止**。此方法本质上是生成一个递归树,让叶节点的值尽量平均。当然,不一定非要划分为 3 个桶,可以根据数据特点灵活选取
实现平均分配,我们可以先设定一个大致的分界线,将数据粗略地分到 3 个桶中。**分配完毕后,再将商品较多的桶继续划分为 3 个桶,直至所有桶中的元素数量大致相等**。这种方法本质上是创建一个递归树,使叶节点的值尽可能平均。当然,不一定要每轮将数据划分为 3 个桶,具体划分方式可根据数据特点灵活选择
![递归划分桶](bucket_sort.assets/scatter_in_buckets_recursively.png)
<p align="center"> Fig. 递归划分桶 </p>
如果我们提前知道商品价格的概率分布,**那么也可以根据数据概率分布来设置每个桶的价格分界线**。注意,数据分布不一定需要特意去统计,也可以根据数据特点采用某种概率模型来近似。如下图所示,我们假设商品价格服从正态分布,就可以合理设置价格区间,将商品平均分配到各个桶中。
如果我们提前知道商品价格的概率分布,**则可以根据数据概率分布设置每个桶的价格分界线**。值得注意的是,数据分布并不一定需要特意统计,也可以根据数据特点采用某种概率模型进行近似。如下图所示,我们假设商品价格服从正态分布,这样就可以合理地设定价格区间,从而将商品平均分配到各个桶中。
![根据概率分布划分桶](bucket_sort.assets/scatter_in_buckets_distribution.png)

View File

@ -4,15 +4,15 @@ comments: true
# 11.7. &nbsp; 计数排序
顾名思义,「计数排序 Counting Sort」通过统计元素数量来实现排序一般应用于整数数组。
「计数排序 Counting Sort」通过统计元素数量来实现排序通常应用于整数数组。
## 11.7.1. &nbsp; 简单实现
先看一个简单例子。给定一个长度为 $n$ 的数组 `nums` 元素皆为 **非负整数**。计数排序的整体流程为
看一个简单例子。给定一个长度为 $n$ 的数组 `nums` 其中的元素都是“非负整数”。计数排序的整体流程如下
1. 遍历记录数组中的最大数字,记为 $m$ ,并建立一个长度为 $m + 1$ 的辅助数组 `counter`
2. **借助 `counter` 统计 `nums` 中各数字的出现次数**,其中 `counter[num]` 对应数字 `num` 的出现次数。统计方法很简单,只需遍历 `nums` (设当前数字为 `num`),每轮将 `counter[num]` 增 $1$ 即可。
3. **由于 `counter` 的各个索引天然有序,因此相当于所有数字已经被排序好了**。接下来,我们遍历 `counter` ,根据各数字的出现次数,将各数字按从小到大的顺序填入 `nums` 即可。
1. 遍历数组,找出数组中的最大数字,记为 $m$ ,然后创建一个长度为 $m + 1$ 的辅助数组 `counter`
2. **借助 `counter` 统计 `nums` 中各数字的出现次数**,其中 `counter[num]` 对应数字 `num` 的出现次数。统计方法很简单,只需遍历 `nums`(设当前数字为 `num`),每轮将 `counter[num]` $1$ 即可。
3. **由于 `counter` 的各个索引天然有序,因此相当于所有数字已经被排序好了**。接下来,我们遍历 `counter` ,根据各数字的出现次数,将它们按从小到大的顺序填入 `nums` 即可。
![计数排序流程](counting_sort.assets/counting_sort_overview.png)
@ -223,24 +223,24 @@ comments: true
!!! note "计数排序与桶排序的联系"
从桶排序的角度看,我们可以把计数排序中计数数组 `counter` 的每个索引想象成一个桶,将统计数量的过程想象成把各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
从桶排序的角度看,我们可以将计数排序中的计数数组 `counter` 的每个索引视为一个桶,将统计数量的过程看作是将各个元素分配到对应的桶中。本质上,计数排序是桶排序在整型数据下的一个特例。
## 11.7.2. &nbsp; 完整实现
细心的同学可能发现,**如果输入数据是对象,上述步骤 `3.` 就失效了**。例如输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
细心的同学可能发现,**如果输入数据是对象,上述步骤 `3.` 就失效了**。例如输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter` 的「前缀和」顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和,即
那么如何才能得到原数据的排序结果呢?我们首先计算 `counter` 的「前缀和」顾名思义,索引 `i` 处的前缀和 `prefix[i]` 等于数组前 `i` 个元素之和,即
$$
\text{prefix}[i] = \sum_{j=0}^i \text{counter[j]}
$$
**前缀和具有明确意义,`prefix[num] - 1` 代表元素 `num` 在结果数组 `res` 中最后一次出现的索引**。这个信息很关键,因为其给出了各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 `nums` 的每个元素 `num` ,在每轮迭代中执行:
**前缀和具有明确意义,`prefix[num] - 1` 代表元素 `num` 在结果数组 `res` 中最后一次出现的索引**。这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 `nums` 的每个元素 `num` ,在每轮迭代中执行:
1. 将 `num` 填入数组 `res` 的索引 `prefix[num] - 1` 处;
2. 令前缀和 `prefix[num]` 减 $1$ ,从而得到下次放置 `num` 的索引;
2. 令前缀和 `prefix[num]` $1$ ,从而得到下次放置 `num` 的索引;
完成遍历后,数组 `res` 中就是排序好的结果,最后使用 `res` 覆盖原数组 `nums` 即可
遍历完成后,数组 `res` 中就是排序好的结果,最后使用 `res` 覆盖原数组 `nums` 即可
=== "<1>"
![counting_sort_step1](counting_sort.assets/counting_sort_step1.png)
@ -542,16 +542,16 @@ $$
## 11.7.3. &nbsp; 算法特性
**时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ 此时使用线性 $O(n)$ 时间
**时间复杂度 $O(n + m)$** :涉及遍历 `nums` 和遍历 `counter` ,都使用线性时间。一般情况下 $n \gg m$ 时间复杂度趋于 $O(n)$
**空间复杂度 $O(n + m)$** :借助了长度分别为 $n$ , $m$ 的数组 `res``counter` ,是“非原地排序”。
**空间复杂度 $O(n + m)$** :借助了长度分别为 $n$ $m$ 的数组 `res``counter` 因此是“非原地排序”。
**稳定排序**:由于向 `res` 中填充元素的顺序是“从右向左”的,因此倒序遍历 `nums` 可以避免改变相等元素之间的相对位置,从而实现“稳定排序”;其实正序遍历 `nums` 也可以得到正确的排序结果,但结果“非稳定”。
**稳定排序**:由于向 `res` 中填充元素的顺序是“从右向左”的,因此倒序遍历 `nums` 可以避免改变相等元素之间的相对位置,从而实现“稳定排序”。实际上,正序遍历 `nums` 也可以得到正确的排序结果,但结果“非稳定”
## 11.7.4. &nbsp; 局限性
看到这里,你也许会觉得计数排序太妙了,咔咔一通操作,时间复杂度就下来了。然而,使用计数排序的前置条件比较苛刻
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序工作。然而,使用计数排序的前置条件相对较为严格
**计数排序只适用于非负整数**。若想要用在其他类型数据上,则要求该数据必须可以被转化为非负整数,并且不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去即可。
**计数排序只适用于非负整数**。若想要将其用于其他类型的数据,需要确保这些数据可以被转换为非负整数,并且在转换过程中不能改变各个元素之间的相对大小关系。例如,对于包含负数的整数数组,可以先给所有数字加上一个常数,将全部数字转化为正数,排序完成后再转换回去即可。
**计数排序适用于数据量大但数据范围不大的情况**。比如,上述示例中 $m$ 不能太大,否则占用空间太多;而当 $n \ll m$ 时,计数排序使用 $O(m)$ 时间,可能比 $O(n \log n)$ 的排序算法还要慢。
**计数排序适用于数据量大但数据范围较小的情况**。比如,在上述示例中 $m$ 不能太大,否则会占用过多空间。而当 $n \ll m$ 时,计数排序使用 $O(m)$ 时间,可能比 $O(n \log n)$ 的排序算法还要慢。

View File

@ -4,11 +4,9 @@ comments: true
# 11.3. &nbsp; 插入排序
「插入排序 Insertion Sort」是一种基于 **数组插入操作** 的排序算法。
「插入排序 Insertion Sort」是一种基于数组插入操作的排序算法。具体来说,选择一个待排序的元素作为基准值 `base` ,将 `base` 与其左侧已排序区间的元素逐一比较大小,并将其插入到正确的位置。
「插入操作」原理:选定某个待排序元素为基准数 `base`,将 `base` 与其左侧已排序区间元素依次对比大小,并插入到正确位置。
回忆数组插入操作,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后再将 `base` 赋值给目标索引。
回顾数组插入操作,我们需要将从目标索引到 `base` 之间的所有元素向右移动一位,然后再将 `base` 赋值给目标索引。
![单次插入操作](insertion_sort.assets/insertion_operation.png)
@ -16,11 +14,11 @@ comments: true
## 11.3.1. &nbsp; 算法流程
循环执行插入操作
插入排序的整体流程如下
1. 先选取数组的 **第 2 个元素** `base` ,执行插入操作后,**数组前 2 个元素已完成排序**。
2. 选取 **第 3 个元素** `base` ,执行插入操作后,**数组前 3 个元素已完成排序**。
3. 以此类推……最后一轮选取 **数组尾元素** `base` ,执行插入操作后,**所有元素已完成排序**。
1. 首先,选取数组的第 2 个元素作`base` ,执行插入操作后,**数组前 2 个元素已排序**。
2. 接着,选取第 3 个元素作`base` ,执行插入操作后,**数组前 3 个元素已排序**。
3. 以此类推,在最后一轮中,选取数组尾元素作`base` ,执行插入操作后,**所有元素已排序**。
![插入排序流程](insertion_sort.assets/insertion_sort_overview.png)
@ -201,22 +199,22 @@ comments: true
## 11.3.2. &nbsp; 算法特性
**时间复杂度 $O(n^2)$** :最差情况下,各轮插入操作循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和为 $\frac{(n - 1) n}{2}$ ,使用 $O(n^2)$ 时间。输入数组完全有序下,达到最佳时间复杂度 $O(n)$ ,因此是“自适应排序”。
**时间复杂度 $O(n^2)$** :最差情况下,每次插入操作分别需要循环 $n - 1$ , $n-2$ , $\cdots$ , $2$ , $1$ 次,求和得到 $\frac{(n - 1) n}{2}$ ,因此时间复杂度为 $O(n^2)$ 。当输入数组完全有序时,插入排序达到最佳时间复杂度 $O(n)$ ,因此是“自适应排序”。
**空间复杂度 $O(1)$** :指针 $i$ , $j$ 使用常数大小的额外空间,因此是“原地排序”。
**空间复杂度 $O(1)$** :指针 $i$ , $j$ 使用常数大小的额外空间,所以插入排序是“原地排序”。
在插入操作中,我们会将元素插入到相等元素的右边,不会改变它们的次序,因此是“稳定排序”。
在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序,因此是“稳定排序”。
## 11.3.3. &nbsp; 插入排序优势
回顾冒泡排序插入排序的复杂度分析,两者的循环轮数都是 $\frac{(n - 1) n}{2}$ 。但不同的是
回顾冒泡排序和插入排序的复杂度分析,两者的循环轮数都是 $\frac{(n - 1) n}{2}$ 。然而,它们之间存在以下差异
- 冒泡操作基于 **元素交换** 实现,需要借助一个临时变量实现,共 3 个单元操作;
- 插入操作基于 **元素赋值** 实现,只需 1 个单元操作;
- 冒泡操作基于元素交换实现,需要借助一个临时变量,共涉及 3 个单元操作;
- 插入操作基于元素赋值实现,仅需 1 个单元操作;
粗略估计,冒泡排序的计算开销约为插入排序的 3 倍,因此插入排序更受欢迎,许多编程语言(例如 Java的内置排序函数都使用到了插入排序,大致思路为:
粗略估计下来,冒泡排序的计算开销约为插入排序的 3 倍,因此插入排序更受欢迎。实际上,许多编程语言(如 Java的内置排序函数都采用了插入排序,大致思路为:
- 对于 **长数组**,采用基于分治的排序算法,例如「快速排序」,时间复杂度为 $O(n \log n)$
- 对于 **短数组**,直接使用「插入排序」,时间复杂度为 $O(n^2)$
- 对于长数组,采用基于分治的排序算法,例如「快速排序」,时间复杂度为 $O(n \log n)$
- 对于短数组,直接使用「插入排序」,时间复杂度为 $O(n^2)$
虽然插入排序比快速排序的时间复杂度更高,**但实际上在数据量较小时插入排序更快**,这是因为复杂度中的常数项(即每轮中的单元操作数量)占主导作用。这个现象与「线性查找」和「二分查找」的情况类似。
尽管插入排序的时间复杂度高于快速排序,**但在数据量较小的情况下,插入排序实际上更快**。这是因为在数据量较小时,复杂度中的常数项(即每轮中的单元操作数量)起主导作用。这个现象与「线性查找」和「二分查找」的情况相似。

View File

@ -4,10 +4,10 @@ comments: true
# 11.1. &nbsp; 排序简介
「排序算法 Sorting Algorithm」使得列表中的所有元素按照从小到大的顺序排列。
「排序算法 Sorting Algorithm」使列表中的所有元素按照升序排列。
- 待排序列表的 **元素类型** 可以是整数、浮点数、字符或字符串;
- 排序算法可以根据需要设定 **判断规则**,例如数字大小、字符 ASCII 码顺序、自定义规则;
- 待排序列表的元素类型可以是整数、浮点数、字符或字符串
- 排序算法可根据需求设定判断规则,如数字大小、字符 ASCII 码顺序或自定义规则;
![排序中不同的元素类型和判断规则](intro_to_sort.assets/sorting_examples.png)
@ -15,11 +15,13 @@ comments: true
## 11.1.1. &nbsp; 评价维度
**运行效率**:我们希望排序算法的时间复杂度尽可能低,并且总体操作数量更少(即时间复杂度中的常数项更低)。在大数据量下,运行效率尤为重要。
**运行效率**:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(即时间复杂度中的常数项降低)。对于大数据量情况,运行效率显得尤为重要。
**就地性**:顾名思义,「原地排序」直接在原数组上操作实现排序,而不用借助额外辅助数组,节约内存;并且一般情况下,原地排序的数据搬运操作较少,运行速度也更快。
**就地性**:顾名思义,「原地排序」通过在原数组上直接操作实现排序,无需借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
**稳定性**:「稳定排序」在完成排序后,相等元素在数组中的相对顺序 **不会发生改变**。假设我们有一个存储学生信息的表格,第 1, 2 列分别是姓名和年龄。那么在以下示例中,「非稳定排序」会导致输入数据的有序性丢失。稳定性是排序算法很好的特性,**在多级排序中是必须的**。
**稳定性**:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。稳定排序是优良特性,也是多级排序场景的必要条件。
假设我们有一个存储学生信息的表格,第 1, 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失。
```shell
# 输入数据是按照姓名排序好的
@ -40,12 +42,14 @@ comments: true
('E', 23)
```
**自适应性**:「自适应排序」的时间复杂度受输入数据影响,即最佳、最差、平均时间复杂度不全相等。自适应性也要分情况对待,若最差时间复杂度差于平均时间复杂度,代表排序算法会在某些数据下发生劣化,因此是负面性质;而若最佳时间复杂度优于平均时间复杂度,则是正面性质。
**自适应性**:「自适应排序」的时间复杂度受输入数据影响,即最佳、最差、平均时间复杂度全相等。
**是否基于比较**:「比较排序」是根据比较算子($<$ , $=$ , $>$)来判断元素的相对顺序,进而排序整个数组,理论最优时间复杂度为 $O(n \log n)$ 。「非比较排序」不采用,时间复杂度可以达到 $O(n)$ ,但通用性相对较差。
自适应性需要根据具体情况来评估。如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
**是否基于比较**:「基于比较的排序」依赖于比较运算符($<$ , $=$ , $>$)来判断元素的相对顺序,从而排序整个数组,理论最优时间复杂度为 $O(n \log n)$ 。而「非比较排序」不使用比较运算符,时间复杂度可达 $O(n)$ ,但其通用性相对较差。
## 11.1.2. &nbsp; 理想排序算法
**运行快、原地、稳定、正向自适应、通用性好**。显然,**目前没有发现具备以上所有特性的排序算法**,排序算法的选型使用取决于具体的数据特点与问题特征
**运行快、原地、稳定、正向自适应、通用性好**。显然,迄今为止尚未发现兼具以上所有特性的排序算法。因此,在选择排序算法时,需要根据具体的数据特点和问题需求来决定
接下来,我们将一起学习各种排序算法,并基于以上评价维度展开分析各个排序算法的优缺点
接下来,我们将共同学习各种排序算法,并基于上述评价维度对各个排序算法的优缺点进行分析

View File

@ -4,10 +4,10 @@ comments: true
# 11.5. &nbsp; 归并排序
「归并排序 Merge Sort」是算法中“分治思想”的典型体现,其有「划分」和「合并」两个阶段:
「归并排序 Merge Sort」基于分治思想实现排序,包含“划分”和“合并”两个阶段:
1. **划分阶段**:通过递归不断 **将数组从中点位置划分开**,将长数组的排序问题转化为短数组的排序问题;
2. **合并阶段**划分到子数组长度为 1 时,开始向上合并,不断将 **左、右两个短排序数组** 合并为 **一个长排序数组**,直至合并至原数组时完成排序
1. **划分阶段**:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题;
2. **合并阶段**当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束
![归并排序的划分与合并阶段](merge_sort.assets/merge_sort_overview.png)
@ -15,14 +15,12 @@ comments: true
## 11.5.1. &nbsp; 算法流程
**「递归划分」** 从顶至底递归地 **将数组从中点切为两个子数组**,直至长度为 1
“划分阶段”从顶至底递归地将数组从中点切为两个子数组,直至长度为 1
1. 计算数组中点 `mid` ,递归划分左子数组(区间 `[left, mid]` )和右子数组(区间 `[mid + 1, right]`
2. 递归执行 `1.` 步骤,直至子数组区间长度为 1 时,终止递归划分;
2. 递归执行步骤 `1.` ,直至子数组区间长度为 1 时,终止递归划分;
**「回溯合并」** 从底至顶地将左子数组和右子数组合并为一个 **有序数组**
需要注意,由于从长度为 1 的子数组开始合并,所以 **每个子数组都是有序的**。因此,合并任务本质是要 **将两个有序子数组合并为一个有序数组**
“合并阶段”从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的。
=== "<1>"
![归并排序步骤](merge_sort.assets/merge_sort_step1.png)
@ -54,10 +52,10 @@ comments: true
=== "<10>"
![merge_sort_step10](merge_sort.assets/merge_sort_step10.png)
观察发现,归并排序的递归顺序就是二叉树的「后序遍历」。
观察发现,归并排序的递归顺序与二叉树的后序遍历相同,具体来看:
- **后序遍历**:先递归左子树、再递归右子树、最后处理根节点。
- **归并排序**:先递归左子树、再递归右子树、最后处理合并。
- **后序遍历**:先递归左子树,再递归右子树,最后处理根节点。
- **归并排序**:先递归左子数组,再递归右子数组,最后处理合并。
=== "Java"
@ -484,30 +482,24 @@ comments: true
}
```
下面重点解释一下合并方法 `merge()` 的流程
合并方法 `merge()` 代码中的难点包括
1. 初始化一个辅助数组 `tmp` 暂存待合并区间 `[left, right]` 内的元素,后续通过覆盖原数组 `nums` 的元素来实现合并;
2. 初始化指针 `i` , `j` , `k` 分别指向左子数组、右子数组、原数组的首元素;
3. 循环判断 `tmp[i]``tmp[j]` 的大小,将较小的先覆盖至 `nums[k]` ,指针 `i` , `j` 根据判断结果交替前进(指针 `k` 也前进),直至两个子数组都遍历完,即可完成合并。
合并方法 `merge()` 代码中的主要难点:
- `nums` 的待合并区间为 `[left, right]` ,而因为 `tmp` 只复制了 `nums` 该区间元素,所以 `tmp` 对应区间为 `[0, right - left]` **需要特别注意代码中各个变量的含义**。
- 判断 `tmp[i]``tmp[j]` 的大小的操作中,还 **需考虑当子数组遍历完成后的索引越界问题**,即 `i > leftEnd``j > rightEnd` 的情况,索引越界的优先级是最高的,例如如果左子数组已经被合并完了,那么不用继续判断,直接合并右子数组元素即可。
- **在阅读代码时,需要特别注意各个变量的含义**。`nums` 的待合并区间为 `[left, right]` ,但由于 `tmp` 仅复制了 `nums` 该区间的元素,因此 `tmp` 对应区间为 `[0, right - left]`
- 在比较 `tmp[i]``tmp[j]` 的大小时,**还需考虑子数组遍历完成后的索引越界问题**,即 `i > leftEnd``j > rightEnd` 的情况。索引越界的优先级是最高的,如果左子数组已经被合并完了,那么不需要继续比较,直接合并右子数组元素即可。
## 11.5.2. &nbsp; 算法特性
**时间复杂度 $O(n \log n)$** :划分形成高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,总体使用 $O(n \log n)$ 时间
**时间复杂度 $O(n \log n)$** :划分产生高度为 $\log n$ 的递归树,每层合并的总操作数量为 $n$ ,因此总体时间复杂度为 $O(n \log n)$ 。
**空间复杂度 $O(n)$** 需借助辅助数组实现合并,使用 $O(n)$ 大小的额外空间;递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间,因此是“非原地排序”。
**空间复杂度 $O(n)$** :递归深度为 $\log n$ ,使用 $O(\log n)$ 大小的栈帧空间;合并操作需要借助辅助数组实现,使用 $O(n)$ 大小的额外空间;因此是“非原地排序”。
在合并时,不改变相等元素的次序,是“稳定排序”。
在合并过程中,相等元素的次序保持不变,因此归并排序是“稳定排序”。
## 11.5.3. &nbsp; 链表排序 *
归并排序有一个很特别的优势,用于排序链表时有很好的性能表现,**空间复杂度可被优化至 $O(1)$** ,这是因为
归并排序在排序链表时具有显著优势,空间复杂度可以优化至 $O(1)$ ,原因如下
- 由于链表可仅通过改变指针来实现节点增删,因此“将两个短有序链表合并为一个长有序链表”无需使用额外空间,即回溯合并阶段不用像排序数组一样建立辅助数组 `tmp`
- 通过使用「迭代」代替「递归划分」,可省去递归使用的栈帧空间;
- 由于链表仅需改变指针就可实现节点的增删操作,因此合并阶段(将两个短有序链表合并为一个长有序链表)无需创建辅助链表。
- 通过使用“迭代划分”替代“递归划分”,可省去递归使用的栈帧空间;
> 详情参考:[148. 排序链表](https://leetcode-cn.com/problems/sort-list/solution/sort-list-gui-bing-pai-xu-lian-biao-by-jyd/)
具体实现细节比较复杂,有兴趣的同学可以查阅相关资料进行学习。

View File

@ -4,15 +4,15 @@ comments: true
# 11.4. &nbsp; 快速排序
「快速排序 Quick Sort」是一种基于“分治思想”的排序算法,速度很快、应用很广
「快速排序 Quick Sort」是一种基于分治思想的排序算法,运行高效,应用广泛
快速排序的核心操作为「哨兵划分」,其目标为:选取数组某个元素为 **基准数**,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。「哨兵划分」的实现流程为:
快速排序的核心操作是「哨兵划分」,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程为:
1. 以数组最左端元素作为基准数,初始化两个指针 `i` , `j` 指向数组两端;
2. 设置一个循环,每轮中使用 `i` / `j` 分别寻找首个比基准数大 / 小的元素,并交换此两元素;
3. 不断循环步骤 `2.` ,直至 `i` , `j` 相遇时跳出,最终把基准数交换至两个子数组的分界线;
1. 选取数组最左端元素作为基准数,初始化两个指针 `i``j` 分别指向数组的两端;
2. 设置一个循环,在每轮中使用 `i``j`)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素;
3. 循环执行步骤 `2.` ,直到 `i``j` 相遇时停止,最后将基准数交换至两个子数组的分界线;
「哨兵划分」执行完毕后,原数组被划分成两个部分,即 **左子数组****右子数组**,且满足 **左子数组任意元素 < 基准数 < 右子数组任意元素**。因此,接下来我们只需要排序两个子数组即可
哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足“左子数组任意元素 $\leq$ 基准数 $\leq$ 右子数组任意元素”。因此,我们接下来只需对这两个子数组进行排序
=== "<1>"
![哨兵划分步骤](quick_sort.assets/pivot_division_step1.png)
@ -43,7 +43,7 @@ comments: true
!!! note "快速排序的分治思想"
哨兵划分的实质是将 **一个长数组的排序问题** 简化为 **两个短数组的排序问题**
哨兵划分的实质是将一个长数组的排序问题简化为两个短数组的排序问题。
=== "Java"
@ -288,11 +288,9 @@ comments: true
## 11.4.1. &nbsp; 算法流程
1. 首先,对数组执行一次「哨兵划分」,得到待排序的 **左子数组****右子数组**
2. 接下来,对 **左子数组****右子数组** 分别 **递归执行**「哨兵划分」……
3. 直至子数组长度为 1 时 **终止递归**,即可完成对整个数组的排序;
观察发现,快速排序和「二分查找」的原理类似,都是以对数阶的时间复杂度来缩小处理区间。
1. 首先,对原数组执行一次「哨兵划分」,得到待排序的左子数组和右子数组;
2. 然后,对左子数组和右子数组分别递归执行「哨兵划分」;
3. 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序;
![快速排序流程](quick_sort.assets/quick_sort_overview.png)
@ -451,29 +449,31 @@ comments: true
## 11.4.2. &nbsp; 算法特性
**时间复杂度 $O(n \log n)$** :平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。
**时间复杂度 $O(n \log n)$** 平均情况下,哨兵划分的递归层数为 $\log n$ ,每层中的总循环数为 $n$ ,总体使用 $O(n \log n)$ 时间。
最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间,因此是“非稳定排序”。
最差情况下,每轮哨兵划分操作都将长度为 $n$ 的数组划分为长度为 $0$ 和 $n - 1$ 的两个子数组,此时递归层数达到 $n$ 层,每层中的循环数为 $n$ ,总体使用 $O(n^2)$ 时间;因此快速排序是“自适应排序”。
**空间复杂度 $O(n)$** 输入数组完全倒序下,达到最差递归深度 $n$ 。由于未借助辅助数组空间,因此是“原地排序”。
**空间复杂度 $O(n)$** 在输入数组完全倒序的情况下,达到最差递归深度 $n$ 。由于未使用辅助数组,因此算法是“原地排序”。
**非稳定排序**:哨兵划分最后一步可能会将基准数交换至相等元素的右边
在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧,因此是“非稳定排序”
## 11.4.3. &nbsp; 快排为什么快?
命名能够看出,快速排序在效率方面一定“有两把刷子”。快速排序的平均时间复杂度虽然与「归并排序」和「堆排序」一致,但实际 **效率更高**,这是因为
名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与「归并排序」和「堆排序」相同,但通常快速排序的效率更高,原因如下
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ 不如归并排序,但绝大部分情况下,快速排序可以达到 $O(n \log n)$ 的复杂度
- **缓存使用效率高**哨兵划分操作时,将整个子数组加载入缓存中,访问元素效率很高。而诸如「堆排序」需要跳跃式访问元素,因此不具有此特性。
- **复杂度的常数系数低**:在提及的三种算法中,快速排序的 **比较**、**赋值**、**交换** 三种操作的总体数量最少(类似于「插入排序」快于「冒泡排序」的原因)
- **出现最差情况的概率很低**:虽然快速排序的最差时间复杂度为 $O(n^2)$ 没有归并排序稳定,但在绝大多数情况下,快速排序能在 $O(n \log n)$ 的时间复杂度下运行
- **缓存使用效率高**在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像「堆排序」这类算法需要跳跃式访问元素,从而缺乏这一特性。
- **复杂度的常数系数低**:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与「插入排序」比「冒泡排序」更快的原因类似
## 11.4.4. &nbsp; 基准数优化
**普通快速排序在某些输入下的时间效率变差**。举个极端例子,假设输入数组是完全倒序的,由于我们选取最左端元素为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,从而 **左子数组长度为 $n - 1$、右子数组长度为 $0$** 。这样进一步递归下去,**每轮哨兵划分后的右子数组长度都为 $0$** ,分治策略失效,快速排序退化为「冒泡排序」
**快速排序在某些输入下的时间效率可能降低**。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 $n - 1$ 、右子数组长度为 $0$ 。如此递归下去,每轮哨兵划分后的右子数组长度都为 $0$ ,分治策略失效,快速排序退化为「冒泡排序」。
为了尽量避免这种情况发生,我们可以优化一下基准数的选取策略。首先,在哨兵划分中,我们可以 **随机选取一个元素作为基准数**。但如果运气很差,每次都选择到比较差的基准数,那么效率依然不好
为了尽量避免这种情况发生,**我们可以优化哨兵划分中的基准数的选取策略**。例如,我们可以随机选取一个元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意
进一步地,我们可以在数组中选取 3 个候选元素(一般为数组的首、尾、中点元素),**并将三个候选元素的中位数作为基准数**,这样基准数“既不大也不小”的概率就大大提升了。当然,如果数组很长的话,我们也可以选取更多候选元素,来进一步提升算法的稳健性。采取该方法后,时间复杂度劣化至 $O(n^2)$ 的概率极低。
需要注意的是,编程语言通常生成的是“伪随机数”。如果我们针对伪随机数序列构建一个特定的测试样例,那么快速排序的效率仍然可能劣化。
为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),**并将这三个候选元素的中位数作为基准数**。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 $O(n^2)$ 的概率大大降低。
=== "Java"
@ -792,9 +792,9 @@ comments: true
## 11.4.5. &nbsp; 尾递归优化
**普通快速排序在某些输入下的空间效率变差**。仍然以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 0 ,那么将形成一个高度为 $n - 1$ 的递归树,此时使用的栈帧空间大小劣化至 $O(n)$
**在某些输入下,快速排序可能占用空间较多**。以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 $0$ ,递归树的高度会达到 $n - 1$ ,此时需要占用 $O(n)$ 大小的栈帧空间
为了避免栈帧空间的累积,我们可以在每轮哨兵排序完成后,判断两个子数组的长度大小,仅递归排序较短的子数组。由于较短的子数组长度不会超过 $\frac{n}{2}$ ,因此这样做能保证递归深度不超过 $\log n$ ,即最差空间复杂度被优化至 $O(\log n)$ 。
为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,**仅对较短的子数组进行递归**。由于较短子数组的长度不会超过 $\frac{n}{2}$ ,因此这种方法能确保递归深度不超过 $\log n$ ,从而将最差空间复杂度优化至 $O(\log n)$ 。
=== "Java"
@ -1001,8 +1001,8 @@ comments: true
不行,当我们以最左端元素为基准数时,必须先“从右往左查找”再“从左往右查找”。这个结论有些反直觉,我们来剖析一下原因。
哨兵划分 `partition()` 的最后一步是交换 `nums[left]``nums[i]` 完成交换后,基准数左边的元素都 `<=` 基准数,**这就要求最后一步交换前 `nums[left] >= nums[i]` 必须成立**。假设我们先“从左往右查找”,那么如果找不到比基准数更小的元素,**则会在 `i == j` 时跳出循环,此时可能 `nums[j] == nums[i] > nums[left]`** 也就是说,此时最后一步交换操作会把一个比基准数更大的元素交换至数组最左端,导致哨兵划分失败。
哨兵划分 `partition()` 的最后一步是交换 `nums[left]``nums[i]` 完成交换后,基准数左边的元素都 `<=` 基准数,**这就要求最后一步交换前 `nums[left] >= nums[i]` 必须成立**。假设我们先“从左往右查找”,那么如果找不到比基准数更小的元素,**则会在 `i == j` 时跳出循环,此时可能 `nums[j] == nums[i] > nums[left]`**也就是说,此时最后一步交换操作会把一个比基准数更大的元素交换至数组最左端,导致哨兵划分失败。
举个例子,给定数组 `[0, 0, 0, 0, 1]` ,如果先“从左向右查找”,哨兵划分后数组为 `[1, 0, 0, 0, 0]` ,这个结果是不的。
举个例子,给定数组 `[0, 0, 0, 0, 1]` ,如果先“从左向右查找”,哨兵划分后数组为 `[1, 0, 0, 0, 0]` ,这个结果是不正确的。
再深想一步,如果我们选择 `nums[right]` 为基准数,那么正好反过来,必须先“从左往右查找”。
再深入思考一下,如果我们选择 `nums[right]` 为基准数,那么正好反过来,必须先“从左往右查找”。

View File

@ -4,31 +4,31 @@ comments: true
# 11.8. &nbsp; 基数排序
节介绍的计数排序适用于数据量 $n$ 大但数据范围 $m$ 不大的情况。假设需要排序 $n = 10^6$ 个学号数据,学号是 $8$ 位数字,那么数据范围 $m = 10^8$ 很大,使用计数排序则需要开辟巨大的内存空间,而基数排序则可以避免这种情况。
一节我们介绍了计数排序,它适用于数据量 $n$ 较大但数据范围 $m$ 较小的情况。假设我们需要对 $n = 10^6$ 个学号进行排序,而学号是一个 $8$ 位数字,这意味着数据范围 $m = 10^8$ 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
「基数排序 Radix Sort」主体思路与计数排序一致,也通过统计出现次数实现排序,**并在此基础上利用位与位之间的递进关系,依次对每一位执行排序**,从而获得排序结果。
「基数排序 Radix Sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序**,从而得到最终的排序结果。
## 11.8.1. &nbsp; 算法流程
上述的学号数据为例,设数字最低位为第 $1$ 位、最高位为第 $8$ 位,基数排序的流程为
学号数据为例,假设数字的最低位是第 $1$ 位,最高位是第 $8$ 位,基数排序的步骤如下
1. 初始化位数 $k = 1$
2. 对学号的第 $k$ 位执行「计数排序」,完成后,数据即按照第 $k$ 位从小到大排序;
3. 将 $k$ 自增 $1$ ,并返回第 `2.` 步继续迭代,直至排序完所有位后结束;
2. 对学号的第 $k$ 位执行「计数排序」。完成后,数据会根据第 $k$ 位从小到大排序;
3. 将 $k$ 增加 $1$ ,然后返回步骤 `2.` 继续迭代,直到所有位都排序完成后结束;
![基数排序算法流程](radix_sort.assets/radix_sort_overview.png)
<p align="center"> Fig. 基数排序算法流程 </p>
下面来剖析代码实现。对于一个 $d$ 进制的数字 $x$ 其第 $k$ 位 $x_k$ 的计算公式为
下面来剖析代码实现。对于一个 $d$ 进制的数字 $x$ 要获取其第 $k$ 位 $x_k$ ,可以使用以下计算公式:
$$
x_k = \lfloor\frac{x}{d^{k-1}}\rfloor \mod d
$$
其中 $\lfloor a \rfloor$ 代表对浮点数 $a$ 执行向下取整,$\mod d$ 代表对 $d$ 取余。学号数据的 $d = 10$ , $k \in [1, 8]$ 。
其中 $\lfloor a \rfloor$ 表示对浮点数 $a$ 向下取整,而 $\mod d$ 表示对 $d$ 取余。对于学号数据,$d = 10$ 且 $k \in [1, 8]$ 。
此外,我们需要小幅改动计数排序代码,使之可以根据数字第 $k$ 位执行排序。
此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 $k$ 位进行排序。
=== "Java"
@ -492,12 +492,12 @@ $$
!!! question "为什么从最低位开始排序?"
对于先后两轮排序,第二轮排序可能会覆盖第一轮排序的结果,比如第一轮认为 $a < b$ 而第二轮认为 $a > b$ ,则第二轮会取代第一轮的结果。由于数字高位比低位的优先级更高,所以要先排序低位再排序高位。
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果 $a < b$ 而第二轮排序结果 $a > b$ ,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,我们应该先排序低位再排序高位。
## 11.8.2. &nbsp; 算法特性
**时间复杂度 $O(n k)$** :设数据量为 $n$ 、数据为 $d$ 进制、最大为 $k$ ,则对某一位执行计数排序使用 $O(n + d)$ 时间,排序 $k$ 位使用 $O((n + d)k)$ 时间;一般情况下 $d$ 和 $k$ 都比较小,此时时间复杂度近似为 $O(n)$ 。
**时间复杂度 $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(n + d)$** :与计数排序相同,基数排序需要借助长度为 $n$ 和 $d$ 的数组 `res``counter` ,因此一种“非原地排序”。
与计数排序一致,基数排序也是稳定排序。相比于计数排序,基数排序可适用于数值范围较大的情况,**但前提是数据必须可以被表示为固定位数的格式,且位数不能太大**。比如浮点数就不适合使用基数排序,因为其位数 $k$ 太大,可能时间复杂度 $O(nk) \gg O(n^2)$ 。
基数排序与计数排序一样,都属于稳定排序。相较于计数排序,基数排序适用于数值范围较大的情况,**但前提是数据必须可以表示为固定位数的格式,且位数不能过大**。例如,浮点数不适合使用基数排序,因为其位数 $k$ 过大,可能导致时间复杂度 $O(nk) \gg O(n^2)$ 。

View File

@ -4,16 +4,17 @@ comments: true
# 11.9. &nbsp; 小结
- 冒泡排序通过交换相邻元素来实现排序。通过增加标志位实现提前返回,我们可将冒泡排序的最佳时间复杂度优化至 $O(N)$ 。
- 插入排序每轮将待排序区间内元素插入至已排序区间的正确位置,从而实现排序。插入排序的时间复杂度虽为 $O(N^2)$ ,但因为总体操作少而很受欢迎,一般用于小数据量的排序工作
- 快速排序基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,从而导致时间复杂度劣化至 $O(N^2)$ ,通过引入中位数基准数或随机基准数可大大降低劣化概率。尾递归方法可以有效减小递归深度,将空间复杂度优化至 $O(\log N)$ 。
- 归并排序包含划分和合并两个阶段,是分而治之的标准体现。对于归并排序,排序数组需要借助辅助数组,空间复杂度为 $O(N)$ ;而排序链表的空间复杂度可以被优化至 $O(1)$ 。
- 桶排序分为三步,数据分桶、桶内排序、合并结果,体现分治策略,适用于体量很大的数据。桶排序的难点在于数据的平均划分
- 计数排序是桶排序的一种特例,通过统计数据出现次数来实现排序;适用于数据量大但数据范围不大的情况,并且要求数据可以被转化为正整数。
- 基数排序通过依次排序各位来实现数据排序,要求数据可以被表示为固定位数的数字。
- 冒泡排序通过交换相邻元素来实现排序。通过添加一个标志位来实现提前返回,我们可以将冒泡排序的最佳时间复杂度优化到 $O(n)$ 。
- 插入排序每轮将待排序区间内的元素插入到已排序区间的正确位置,从而完成排序。虽然插入排序的时间复杂度为 $O(n^2)$ ,但由于单元操作相对较少,它在小数据量的排序任务中非常受欢迎
- 快速排序基于哨兵划分操作实现排序。在哨兵划分中,有可能每次都选取到最差的基准数,导致时间复杂度劣化至 $O(n^2)$ 。引入中位数基准数或随机基准数可以降低这种劣化的概率。尾递归方法可以有效地减少递归深度,将空间复杂度优化到 $O(\log n)$ 。
- 归并排序包括划分和合并两个阶段,典型地体现了分治策略。在归并排序中,排序数组需要创建辅助数组,空间复杂度为 $O(n)$ ;然而排序链表的空间复杂度可以优化至 $O(1)$ 。
- 桶排序包含三个步骤:数据分桶、桶内排序和合并结果。它同样体现了分治策略,适用于数据体量很大的情况。桶排序的关键在于对数据进行平均分配
- 计数排序是桶排序的一个特例,它通过统计数据出现的次数来实现排序。计数排序适用于数据量大但数据范围有限的情况,并且要求数据能够转换为正整数。
- 基数排序通过逐位排序来实现数据排序,要求数据能够表示为固定位数的数字。
![排序算法对比](summary.assets/sorting_algorithms_comparison.png)
<p align="center"> Fig. 排序算法对比 </p>
- 总体来看,我们追求运行快、稳定、原地、正向自适应性的排序。显然,如同其它数据结构与算法一样,同时满足这些条件的排序算法并不存在,我们需要根据问题特点来选择排序算法。
- 总体来看,我们追求运行快、稳定、原地、正向自适应性的排序。显然,如同其他数据结构与算法一样,同时满足这些条件的排序算法并不存在,我们需要根据问题特点来选择排序算法。
- 总的来说,我们希望找到一种排序算法,具有高效率、稳定、原地以及正向自适应性等优点。然而,正如其他数据结构和算法一样,没有一种排序算法能够同时满足所有这些条件。在实际应用中,我们需要根据数据的特性来选择合适的排序算法。