mirror of
https://github.com/krahets/hello-algo.git
synced 2024-12-26 11:06:28 +08:00
build
This commit is contained in:
parent
ade482858a
commit
261332f372
26 changed files with 398 additions and 853 deletions
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 13.2. 一起参与创作
|
||||
# 14.2. 一起参与创作
|
||||
|
||||
!!! success "开源的魅力"
|
||||
|
||||
|
@ -10,7 +10,7 @@ comments: true
|
|||
|
||||
由于作者能力有限,书中难免存在一些遗漏和错误,请您谅解。如果您发现了笔误、失效链接、内容缺失、文字歧义、解释不清晰或行文结构不合理等问题,请协助我们进行修正,以帮助其他读者获得更优质的学习资源。所有[撰稿人](https://github.com/krahets/hello-algo/graphs/contributors)将在仓库和网站主页上展示,以感谢他们对开源社区的无私奉献!
|
||||
|
||||
## 13.2.1. 内容微调
|
||||
## 14.2.1. 内容微调
|
||||
|
||||
在每个页面的右上角有一个「编辑」图标,您可以按照以下步骤修改文本或代码:
|
||||
|
||||
|
@ -24,7 +24,7 @@ comments: true
|
|||
|
||||
由于图片无法直接修改,因此需要通过新建 [Issue](https://github.com/krahets/hello-algo/issues) 或评论留言来描述图片问题,我们会尽快重新绘制并替换图片。
|
||||
|
||||
## 13.2.2. 内容创作
|
||||
## 14.2.2. 内容创作
|
||||
|
||||
如果您有兴趣参与此开源项目,包括将代码翻译成其他编程语言、扩展文章内容等,那么需要实施 Pull Request 工作流程:
|
||||
|
||||
|
@ -34,7 +34,7 @@ comments: true
|
|||
4. 将本地所做更改 Commit ,然后 Push 至远程仓库;
|
||||
5. 刷新仓库网页,点击“Create pull request”按钮即可发起拉取请求;
|
||||
|
||||
## 13.2.3. Docker 部署
|
||||
## 14.2.3. Docker 部署
|
||||
|
||||
我们可以通过 Docker 来部署本项目。执行以下脚本,稍等片刻后,即可使用浏览器打开 `http://localhost:8000` 来访问本项目。
|
||||
|
||||
|
|
|
@ -2,49 +2,49 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 13.1. 编程环境安装
|
||||
# 14.1. 编程环境安装
|
||||
|
||||
## 13.1.1. 安装 VSCode
|
||||
## 14.1.1. 安装 VSCode
|
||||
|
||||
本书推荐使用开源轻量的 VSCode 作为本地 IDE ,下载并安装 [VSCode](https://code.visualstudio.com/) 。
|
||||
|
||||
## 13.1.2. Java 环境
|
||||
## 14.1.2. Java 环境
|
||||
|
||||
1. 下载并安装 [OpenJDK](https://jdk.java.net/18/)(版本需满足 > JDK 9)。
|
||||
2. 在 VSCode 的插件市场中搜索 `java` ,安装 Java Extension Pack 。
|
||||
|
||||
## 13.1.3. C/C++ 环境
|
||||
## 14.1.3. C/C++ 环境
|
||||
|
||||
1. Windows 系统需要安装 [MinGW](https://sourceforge.net/projects/mingw-w64/files/)([配置教程](https://blog.csdn.net/qq_33698226/article/details/129031241)),MacOS 自带 Clang 无需安装。
|
||||
2. 在 VSCode 的插件市场中搜索 `c++` ,安装 C/C++ Extension Pack 。
|
||||
|
||||
## 13.1.4. Python 环境
|
||||
## 14.1.4. Python 环境
|
||||
|
||||
1. 下载并安装 [Miniconda3](https://docs.conda.io/en/latest/miniconda.html) 。
|
||||
2. 在 VSCode 的插件市场中搜索 `python` ,安装 Python Extension Pack 。
|
||||
|
||||
## 13.1.5. Go 环境
|
||||
## 14.1.5. Go 环境
|
||||
|
||||
1. 下载并安装 [go](https://go.dev/dl/) 。
|
||||
2. 在 VSCode 的插件市场中搜索 `go` ,安装 Go 。
|
||||
3. 快捷键 `Ctrl + Shift + P` 呼出命令栏,输入 go ,选择 `Go: Install/Update Tools` ,全部勾选并安装即可。
|
||||
|
||||
## 13.1.6. JavaScript 环境
|
||||
## 14.1.6. JavaScript 环境
|
||||
|
||||
1. 下载并安装 [node.js](https://nodejs.org/en/) 。
|
||||
2. 在 VSCode 的插件市场中搜索 `javascript` ,安装 JavaScript (ES6) code snippets 。
|
||||
|
||||
## 13.1.7. C# 环境
|
||||
## 14.1.7. C# 环境
|
||||
|
||||
1. 下载并安装 [.Net 6.0](https://dotnet.microsoft.com/en-us/download) ;
|
||||
2. 在 VSCode 的插件市场中搜索 `c#` ,安装 c# 。
|
||||
|
||||
## 13.1.8. Swift 环境
|
||||
## 14.1.8. Swift 环境
|
||||
|
||||
1. 下载并安装 [Swift](https://www.swift.org/download/);
|
||||
2. 在 VSCode 的插件市场中搜索 `swift` ,安装 [Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang)。
|
||||
|
||||
## 13.1.9. Rust 环境
|
||||
## 14.1.9. Rust 环境
|
||||
|
||||
1. 下载并安装 [Rust](https://www.rust-lang.org/tools/install);
|
||||
2. 在 VSCode 的插件市场中搜索 `rust` ,安装 [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 12.1. 回溯算法
|
||||
# 13.1. 回溯算法
|
||||
|
||||
「回溯算法 Backtracking Algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。
|
||||
|
||||
|
@ -87,7 +87,21 @@ comments: true
|
|||
=== "C#"
|
||||
|
||||
```csharp title="preorder_find_nodes.cs"
|
||||
[class]{preorder_find_nodes}-[func]{preOrder}
|
||||
/* 前序遍历 */
|
||||
void preOrder(TreeNode root)
|
||||
{
|
||||
if (root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (root.val == 7)
|
||||
{
|
||||
// 记录解
|
||||
res.Add(root);
|
||||
}
|
||||
preOrder(root.left);
|
||||
preOrder(root.right);
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
@ -106,7 +120,7 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 在前序遍历中搜索节点 </p>
|
||||
|
||||
## 12.1.1. 尝试与回退
|
||||
## 13.1.1. 尝试与回退
|
||||
|
||||
**之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略**。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
|
||||
|
||||
|
@ -205,7 +219,25 @@ comments: true
|
|||
=== "C#"
|
||||
|
||||
```csharp title="preorder_find_paths.cs"
|
||||
[class]{preorder_find_paths}-[func]{preOrder}
|
||||
/* 前序遍历 */
|
||||
void preOrder(TreeNode root)
|
||||
{
|
||||
if (root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 尝试
|
||||
path.Add(root);
|
||||
if (root.val == 7)
|
||||
{
|
||||
// 记录解
|
||||
res.Add(new List<TreeNode>(path));
|
||||
}
|
||||
preOrder(root.left);
|
||||
preOrder(root.right);
|
||||
// 回退
|
||||
path.RemoveAt(path.Count - 1);
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
@ -255,7 +287,7 @@ comments: true
|
|||
=== "<11>"
|
||||
![preorder_find_paths_step11](backtracking_algorithm.assets/preorder_find_paths_step11.png)
|
||||
|
||||
## 12.1.2. 剪枝
|
||||
## 13.1.2. 剪枝
|
||||
|
||||
复杂的回溯问题通常包含一个或多个约束条件,**约束条件通常可用于“剪枝”**。
|
||||
|
||||
|
@ -353,7 +385,26 @@ comments: true
|
|||
=== "C#"
|
||||
|
||||
```csharp title="preorder_find_constrained_paths.cs"
|
||||
[class]{preorder_find_constrained_paths}-[func]{preOrder}
|
||||
/* 前序遍历 */
|
||||
void preOrder(TreeNode root)
|
||||
{
|
||||
// 剪枝
|
||||
if (root == null || root.val == 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 尝试
|
||||
path.Add(root);
|
||||
if (root.val == 7)
|
||||
{
|
||||
// 记录解
|
||||
res.Add(new List<TreeNode>(path));
|
||||
}
|
||||
preOrder(root.left);
|
||||
preOrder(root.right);
|
||||
// 回退
|
||||
path.RemoveAt(path.Count - 1);
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
@ -374,7 +425,7 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 根据约束条件剪枝 </p>
|
||||
|
||||
## 12.1.3. 常用术语
|
||||
## 13.1.3. 常用术语
|
||||
|
||||
为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例。
|
||||
|
||||
|
@ -391,7 +442,7 @@ comments: true
|
|||
|
||||
解、状态、约束条件等术语是通用的,适用于回溯算法、动态规划、贪心算法等。
|
||||
|
||||
## 12.1.4. 框架代码
|
||||
## 13.1.4. 框架代码
|
||||
|
||||
回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。为提升代码通用性,我们希望将回溯算法的“尝试、回退、剪枝”的主体框架提炼出来。
|
||||
|
||||
|
@ -627,17 +678,61 @@ def backtrack(state, choices, res):
|
|||
=== "C#"
|
||||
|
||||
```csharp title="backtrack_find_constrained_paths.cs"
|
||||
[class]{backtrack_find_constrained_paths}-[func]{isSolution}
|
||||
/* 判断当前状态是否为解 */
|
||||
bool isSolution(List<TreeNode> state)
|
||||
{
|
||||
return state.Count != 0 && state[^1].val == 7;
|
||||
}
|
||||
|
||||
[class]{backtrack_find_constrained_paths}-[func]{recordSolution}
|
||||
/* 记录解 */
|
||||
void recordSolution(List<TreeNode> state, List<List<TreeNode>> res)
|
||||
{
|
||||
res.Add(new List<TreeNode>(state));
|
||||
}
|
||||
|
||||
[class]{backtrack_find_constrained_paths}-[func]{isValid}
|
||||
/* 判断在当前状态下,该选择是否合法 */
|
||||
bool isValid(List<TreeNode> state, TreeNode choice)
|
||||
{
|
||||
return choice != null && choice.val != 3;
|
||||
}
|
||||
|
||||
[class]{backtrack_find_constrained_paths}-[func]{makeChoice}
|
||||
/* 更新状态 */
|
||||
void makeChoice(List<TreeNode> state, TreeNode choice)
|
||||
{
|
||||
state.Add(choice);
|
||||
}
|
||||
|
||||
[class]{backtrack_find_constrained_paths}-[func]{undoChoice}
|
||||
/* 恢复状态 */
|
||||
void undoChoice(List<TreeNode> state, TreeNode choice)
|
||||
{
|
||||
state.RemoveAt(state.Count - 1);
|
||||
}
|
||||
|
||||
[class]{backtrack_find_constrained_paths}-[func]{backtrack}
|
||||
/* 回溯算法 */
|
||||
void backtrack(List<TreeNode> state, List<TreeNode> choices, List<List<TreeNode>> res)
|
||||
{
|
||||
// 检查是否为解
|
||||
if (isSolution(state))
|
||||
{
|
||||
// 记录解
|
||||
recordSolution(state, res);
|
||||
return;
|
||||
}
|
||||
// 遍历所有选择
|
||||
foreach (TreeNode choice in choices)
|
||||
{
|
||||
// 剪枝:检查选择是否合法
|
||||
if (isValid(state, choice))
|
||||
{
|
||||
// 尝试:做出选择,更新状态
|
||||
makeChoice(state, choice);
|
||||
List<TreeNode> nextChoices = new List<TreeNode>() { choice.left, choice.right };
|
||||
backtrack(state, nextChoices, res);
|
||||
// 回退:撤销选择,恢复到之前的状态
|
||||
undoChoice(state, choice);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
@ -674,7 +769,7 @@ def backtrack(state, choices, res):
|
|||
|
||||
相较于基于前序遍历的实现代码,基于回溯算法框架的实现代码虽然显得啰嗦,但通用性更好,适用于各种不同的回溯算法问题。实际上,**所有回溯问题都可以在该框架下解决**。我们只需要根据问题特点来定义框架中的各个变量,实现各个方法即可。
|
||||
|
||||
## 12.1.5. 典型例题
|
||||
## 13.1.5. 典型例题
|
||||
|
||||
**搜索问题**:这类问题的目标是找到满足特定条件的解决方案。
|
||||
|
||||
|
|
|
@ -2,12 +2,10 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 10.2. 二分查找
|
||||
# 6.1. 二分查找
|
||||
|
||||
「二分查找 Binary Search」利用数据的有序性,通过每轮减少一半搜索范围来定位目标元素。
|
||||
|
||||
## 10.2.1. 算法实现
|
||||
|
||||
给定一个长度为 $n$ 的有序数组 `nums` ,元素按从小到大的顺序排列。数组索引的取值范围为:
|
||||
|
||||
$$
|
||||
|
@ -16,10 +14,10 @@ $$
|
|||
|
||||
我们通常使用以下两种方法来表示这个取值范围:
|
||||
|
||||
1. **双闭区间 $[0, n-1]$** ,即两个边界都包含自身;在此方法下,区间 $[0, 0]$ 仍包含 $1$ 个元素;
|
||||
2. **左闭右开 $[0, n)$** ,即左边界包含自身、右边界不包含自身;在此方法下,区间 $[0, 0)$ 不包含元素;
|
||||
1. **双闭区间 $[0, n-1]$** ,即两个边界都包含自身;在此方法下,区间 $[i, i]$ 仍包含 $1$ 个元素;
|
||||
2. **左闭右开 $[0, n)$** ,即左边界包含自身、右边界不包含自身;在此方法下,区间 $[i, i)$ 不包含元素;
|
||||
|
||||
### “双闭区间”实现
|
||||
## 6.1.1. 双闭区间实现
|
||||
|
||||
首先,我们采用“双闭区间”表示法,在数组 `nums` 中查找目标元素 `target` 的对应索引。
|
||||
|
||||
|
@ -253,9 +251,93 @@ $$
|
|||
}
|
||||
```
|
||||
|
||||
### “左闭右开”实现
|
||||
需要注意的是,**当数组长度非常大时,加法 $i + j$ 的结果可能会超出 `int` 类型的取值范围**。在这种情况下,我们需要采用一种更安全的计算中点的方法。
|
||||
|
||||
此外,我们也可以采用“左闭右开”的表示法,编写具有相同功能的二分查找代码。
|
||||
=== "Java"
|
||||
|
||||
```java title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
int m = (i + j) / 2;
|
||||
// 更换为此写法则不会越界
|
||||
int m = i + (j - i) / 2;
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
int m = (i + j) / 2;
|
||||
// 更换为此写法则不会越界
|
||||
int m = i + (j - i) / 2;
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```py title=""
|
||||
# Python 中的数字理论上可以无限大(取决于内存大小)
|
||||
# 因此无需考虑大数越界问题
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
m := (i + j) / 2
|
||||
// 更换为此写法则不会越界
|
||||
m := i + (j - i) / 2
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
|
||||
```javascript title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
let m = parseInt((i + j) / 2);
|
||||
// 更换为此写法则不会越界
|
||||
let m = parseInt(i + (j - i) / 2);
|
||||
```
|
||||
|
||||
=== "TypeScript"
|
||||
|
||||
```typescript title=""
|
||||
// (i + j) 有可能超出 Number 的取值范围
|
||||
let m = Math.floor((i + j) / 2);
|
||||
// 更换为此写法则不会越界
|
||||
let m = Math.floor(i + (j - i) / 2);
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title=""
|
||||
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
int m = (i + j) / 2;
|
||||
// 更换为此写法则不会越界
|
||||
int m = i + (j - i) / 2;
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
let m = (i + j) / 2
|
||||
// 更换为此写法则不会越界
|
||||
let m = i + (j - 1) / 2
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title=""
|
||||
|
||||
```
|
||||
|
||||
## 6.1.2. 左闭右开实现
|
||||
|
||||
我们可以采用“左闭右开”的表示法,编写具有相同功能的二分查找代码。
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -465,8 +547,6 @@ $$
|
|||
}
|
||||
```
|
||||
|
||||
### 两种表示对比
|
||||
|
||||
对比这两种代码写法,我们可以发现以下不同点:
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
@ -480,99 +560,13 @@ $$
|
|||
|
||||
在“双闭区间”表示法中,由于对左右两边界的定义相同,因此缩小区间的 $i$ 和 $j$ 的处理方法也是对称的,这样更不容易出错。因此,**建议采用“双闭区间”的写法**。
|
||||
|
||||
### 大数越界处理
|
||||
|
||||
当数组长度非常大时,加法 $i + j$ 的结果可能会超出 `int` 类型的取值范围。在这种情况下,我们需要采用一种更安全的计算中点的方法。
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
int m = (i + j) / 2;
|
||||
// 更换为此写法则不会越界
|
||||
int m = i + (j - i) / 2;
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
int m = (i + j) / 2;
|
||||
// 更换为此写法则不会越界
|
||||
int m = i + (j - i) / 2;
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```py title=""
|
||||
# Python 中的数字理论上可以无限大(取决于内存大小)
|
||||
# 因此无需考虑大数越界问题
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
m := (i + j) / 2
|
||||
// 更换为此写法则不会越界
|
||||
m := i + (j - i) / 2
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
|
||||
```javascript title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
let m = parseInt((i + j) / 2);
|
||||
// 更换为此写法则不会越界
|
||||
let m = parseInt(i + (j - i) / 2);
|
||||
```
|
||||
|
||||
=== "TypeScript"
|
||||
|
||||
```typescript title=""
|
||||
// (i + j) 有可能超出 Number 的取值范围
|
||||
let m = Math.floor((i + j) / 2);
|
||||
// 更换为此写法则不会越界
|
||||
let m = Math.floor(i + (j - i) / 2);
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title=""
|
||||
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
int m = (i + j) / 2;
|
||||
// 更换为此写法则不会越界
|
||||
int m = i + (j - i) / 2;
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title=""
|
||||
// (i + j) 有可能超出 int 的取值范围
|
||||
let m = (i + j) / 2
|
||||
// 更换为此写法则不会越界
|
||||
let m = i + (j - 1) / 2
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title=""
|
||||
|
||||
```
|
||||
|
||||
## 10.2.2. 复杂度分析
|
||||
## 6.1.3. 复杂度分析
|
||||
|
||||
**时间复杂度 $O(\log n)$** :其中 $n$ 为数组长度;每轮排除一半的区间,因此循环轮数为 $\log_2 n$ ,使用 $O(\log n)$ 时间。
|
||||
|
||||
**空间复杂度 $O(1)$** :指针 `i` , `j` 使用常数大小空间。
|
||||
|
||||
## 10.2.3. 优点与局限性
|
||||
## 6.1.4. 优点与局限性
|
||||
|
||||
二分查找效率很高,主要体现在:
|
||||
|
|
@ -1525,3 +1525,11 @@ $$
|
|||
例如“归并排序”算法,输入长度为 $n$ 的数组,每轮递归将数组从中点划分为两半,形成高度为 $\log n$ 的递归树,使用 $O(\log n)$ 栈帧空间。
|
||||
|
||||
再例如“数字转化为字符串”,输入任意正整数 $n$ ,它的位数为 $\log_{10} n$ ,即对应字符串长度为 $\log_{10} n$ ,因此空间复杂度为 $O(\log_{10} n) = O(\log n)$ 。
|
||||
|
||||
## 2.3.4. 权衡时间与空间
|
||||
|
||||
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。
|
||||
|
||||
**降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然**。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。
|
||||
|
||||
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此以空间换时间通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也是非常重要的。
|
||||
|
|
|
@ -2,15 +2,15 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 2.5. 小结
|
||||
# 2.4. 小结
|
||||
|
||||
### 算法效率评估
|
||||
**算法效率评估**
|
||||
|
||||
- 时间效率和空间效率是评价算法性能的两个关键维度。
|
||||
- 我们可以通过实际测试来评估算法效率,但难以消除测试环境的影响,且会耗费大量计算资源。
|
||||
- 复杂度分析可以克服实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。
|
||||
|
||||
### 时间复杂度
|
||||
**时间复杂度**
|
||||
|
||||
- 时间复杂度用于衡量算法运行时间随数据量增长的趋势,可以有效评估算法效率,但在某些情况下可能失效,如在输入数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣。
|
||||
- 最差时间复杂度使用大 $O$ 符号表示,即函数渐近上界,反映当 $n$ 趋向正无穷时,$T(n)$ 的增长级别。
|
||||
|
@ -19,7 +19,7 @@ comments: true
|
|||
- 某些算法的时间复杂度非固定,而是与输入数据的分布有关。时间复杂度分为最差、最佳、平均时间复杂度,最佳时间复杂度几乎不用,因为输入数据一般需要满足严格条件才能达到最佳情况。
|
||||
- 平均时间复杂度反映算法在随机数据输入下的运行效率,最接近实际应用中的算法性能。计算平均时间复杂度需要统计输入数据分布以及综合后的数学期望。
|
||||
|
||||
### 空间复杂度
|
||||
**空间复杂度**
|
||||
|
||||
- 类似于时间复杂度,空间复杂度用于衡量算法占用空间随数据量增长的趋势。
|
||||
- 算法运行过程中的相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,输入空间不计入空间复杂度计算。暂存空间可分为指令空间、数据空间、栈帧空间,其中栈帧空间通常仅在递归函数中影响空间复杂度。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 9.1. 图
|
||||
# 10.1. 图
|
||||
|
||||
「图 Graph」是一种非线性数据结构,由「顶点 Vertex」和「边 Edge」组成。我们可以将图 $G$ 抽象地表示为一组顶点 $V$ 和一组边 $E$ 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图。
|
||||
|
||||
|
@ -20,7 +20,7 @@ $$
|
|||
|
||||
那么,图与其他数据结构的关系是什么?如果我们把「顶点」看作节点,把「边」看作连接各个节点的指针,则可将「图」看作是一种从「链表」拓展而来的数据结构。**相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂**。
|
||||
|
||||
## 9.1.1. 图常见类型
|
||||
## 10.1.1. 图常见类型
|
||||
|
||||
根据边是否具有方向,可分为「无向图 Undirected Graph」和「有向图 Directed Graph」。
|
||||
|
||||
|
@ -46,13 +46,13 @@ $$
|
|||
|
||||
<p align="center"> Fig. 有权图与无权图 </p>
|
||||
|
||||
## 9.1.2. 图常用术语
|
||||
## 10.1.2. 图常用术语
|
||||
|
||||
- 「邻接 Adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。在上图中,顶点 1 的邻接顶点为顶点 2、3、5。
|
||||
- 「路径 Path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
|
||||
- 「度 Degree」表示一个顶点拥有的边数。对于有向图,「入度 In-Degree」表示有多少条边指向该顶点,「出度 Out-Degree」表示有多少条边从该顶点指出。
|
||||
|
||||
## 9.1.3. 图的表示
|
||||
## 10.1.3. 图的表示
|
||||
|
||||
图的常用表示方法包括「邻接矩阵」和「邻接表」。以下使用无向图进行举例。
|
||||
|
||||
|
@ -86,7 +86,7 @@ $$
|
|||
|
||||
观察上图可发现,**邻接表结构与哈希表中的「链地址法」非常相似,因此我们也可以采用类似方法来优化效率**。例如,当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 $O(n)$ 优化至 $O(\log n)$ ,还可以通过中序遍历获取有序序列;此外,还可以将链表转换为哈希表,将时间复杂度降低至 $O(1)$ 。
|
||||
|
||||
## 9.1.4. 图常见应用
|
||||
## 10.1.4. 图常见应用
|
||||
|
||||
实际应用中,许多系统都可以用图来建模,相应的待求解问题也可以约化为图计算问题。
|
||||
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 9.2. 图基础操作
|
||||
# 10.2. 图基础操作
|
||||
|
||||
图的基础操作可分为对「边」的操作和对「顶点」的操作。在「邻接矩阵」和「邻接表」两种表示方法下,实现方式有所不同。
|
||||
|
||||
## 9.2.1. 基于邻接矩阵的实现
|
||||
## 10.2.1. 基于邻接矩阵的实现
|
||||
|
||||
给定一个顶点数量为 $n$ 的无向图,则有:
|
||||
|
||||
|
@ -775,7 +775,7 @@ comments: true
|
|||
|
||||
```
|
||||
|
||||
## 9.2.2. 基于邻接表的实现
|
||||
## 10.2.2. 基于邻接表的实现
|
||||
|
||||
设无向图的顶点总数为 $n$ 、边总数为 $m$ ,则有:
|
||||
|
||||
|
@ -1459,7 +1459,7 @@ comments: true
|
|||
[class]{GraphAdjList}-[func]{}
|
||||
```
|
||||
|
||||
## 9.2.3. 效率对比
|
||||
## 10.2.3. 效率对比
|
||||
|
||||
设图中共有 $n$ 个顶点和 $m$ 条边,下表为邻接矩阵和邻接表的时间和空间效率对比。
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 9.3. 图的遍历
|
||||
# 10.3. 图的遍历
|
||||
|
||||
!!! note "图与树的关系"
|
||||
|
||||
|
@ -12,7 +12,7 @@ comments: true
|
|||
|
||||
与树类似,图的遍历方式也可分为两种,即「广度优先遍历 Breadth-First Traversal」和「深度优先遍历 Depth-First Traversal」,也称为「广度优先搜索 Breadth-First Search」和「深度优先搜索 Depth-First Search」,简称 BFS 和 DFS。
|
||||
|
||||
## 9.3.1. 广度优先遍历
|
||||
## 10.3.1. 广度优先遍历
|
||||
|
||||
**广度优先遍历是一种由近及远的遍历方式,从距离最近的顶点开始访问,并一层层向外扩张**。具体来说,从某个顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。
|
||||
|
||||
|
@ -336,7 +336,7 @@ BFS 通常借助「队列」来实现。队列具有“先入先出”的性质
|
|||
|
||||
**空间复杂度:** 列表 `res` ,哈希表 `visited` ,队列 `que` 中的顶点数量最多为 $|V|$ ,使用 $O(|V|)$ 空间。
|
||||
|
||||
## 9.3.2. 深度优先遍历
|
||||
## 10.3.2. 深度优先遍历
|
||||
|
||||
**深度优先遍历是一种优先走到底、无路可走再回头的遍历方式**。具体地,从某个顶点出发,访问当前顶点的某个邻接顶点,直到走到尽头时返回,再继续走到尽头并返回,以此类推,直至所有顶点遍历完成。
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 9.4. 小结
|
||||
# 10.4. 小结
|
||||
|
||||
- 图由顶点和边组成,可以被表示为一组顶点和一组边构成的集合。
|
||||
- 相较于线性关系(链表)和分治关系(树),网络关系(图)具有更高的自由度,因而更为复杂。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 6.2. 哈希冲突
|
||||
# 7.2. 哈希冲突
|
||||
|
||||
在理想情况下,哈希函数应为每个输入生成唯一的输出,实现 key 和 value 的一一对应。然而实际上,向哈希函数输入不同的 key 却产生相同输出的情况是存在的,这种现象被称为「哈希冲突 Hash Collision」。哈希冲突可能导致查询结果错误,从而严重影响哈希表的可用性。
|
||||
|
||||
|
@ -12,7 +12,7 @@ comments: true
|
|||
|
||||
另一方面,**可以考虑优化哈希表的表示以缓解哈希冲突**,常用方法包括「链式地址 Separate Chaining」和「开放寻址 Open Addressing」。
|
||||
|
||||
## 6.2.1. 哈希表扩容
|
||||
## 7.2.1. 哈希表扩容
|
||||
|
||||
哈希函数的最后一步通常是对桶数量 $n$ 取余,作用是将哈希值映射到桶索引范围,从而将 key 放入对应的桶中。当哈希表容量越大(即 $n$ 越大)时,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。
|
||||
|
||||
|
@ -20,7 +20,7 @@ comments: true
|
|||
|
||||
编程语言通常使用「负载因子 Load Factor」来衡量哈希冲突的严重程度,**定义为哈希表中元素数量除以桶数量**,常作为哈希表扩容的触发条件。在 Java 中,当负载因子 $> 0.75$ 时,系统会将 HashMap 容量扩展为原先的 $2$ 倍。
|
||||
|
||||
## 6.2.2. 链式地址
|
||||
## 7.2.2. 链式地址
|
||||
|
||||
在原始哈希表中,每个桶仅能存储一个键值对。**链式地址将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中**。
|
||||
|
||||
|
@ -41,7 +41,7 @@ comments: true
|
|||
|
||||
为了提高操作效率,**可以将链表转换为「AVL 树」或「红黑树」**,将查询操作的时间复杂度优化至 $O(\log n)$ 。
|
||||
|
||||
## 6.2.3. 开放寻址
|
||||
## 7.2.3. 开放寻址
|
||||
|
||||
「开放寻址」方法不引入额外的数据结构,而是通过“多次探测”来解决哈希冲突,**探测方主要包括线性探测、平方探测、多次哈希**。
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 6.1. 哈希表
|
||||
# 7.1. 哈希表
|
||||
|
||||
哈希表通过建立「键 key」与「值 value」之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个 key,则可以在 $O(1)$ 时间内获取对应的 value 。
|
||||
|
||||
|
@ -12,28 +12,21 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 哈希表的抽象表示 </p>
|
||||
|
||||
## 6.1.1. 哈希表效率
|
||||
除哈希表外,我们还可以使用数组或链表实现查询功能,各项操作的时间复杂度如下表所示。
|
||||
|
||||
除哈希表外,还可以使用以下数据结构来实现上述查询功能:
|
||||
|
||||
1. **无序数组**:每个元素为 `[学号, 姓名]` ;
|
||||
2. **有序数组**:将 `1.` 中的数组按照学号从小到大排序;
|
||||
3. **链表**:每个节点的值为 `[学号, 姓名]` ;
|
||||
4. **二叉搜索树**:每个节点的值为 `[学号, 姓名]` ,根据学号大小来构建树;
|
||||
|
||||
各项操作的时间复杂度如下表所示(详解可见[二叉搜索树章节](https://www.hello-algo.com/chapter_tree/binary_search_tree/))。无论是查找元素还是增删元素,哈希表的时间复杂度都是 $O(1)$ ,全面胜出!
|
||||
在哈希表中增删查改的时间复杂度都是 $O(1)$ ,全面胜出!因此,哈希表常用于对查找效率要求较高的场景。
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
| | 无序数组 | 有序数组 | 链表 | 二叉搜索树 | 哈希表 |
|
||||
| -------- | -------- | ----------- | ------ | ----------- | ------ |
|
||||
| 查找元素 | $O(n)$ | $O(\log n)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 插入元素 | $O(1)$ | $O(n)$ | $O(1)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 删除元素 | $O(n)$ | $O(n)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| | 数组 | 链表 | 哈希表 |
|
||||
| -------- | ------ | ------ | ------ |
|
||||
| 查找元素 | $O(n)$ | $O(n)$ | $O(1)$ |
|
||||
| 插入元素 | $O(1)$ | $O(1)$ | $O(1)$ |
|
||||
| 删除元素 | $O(n)$ | $O(n)$ | $O(1)$ |
|
||||
|
||||
</div>
|
||||
|
||||
## 6.1.2. 哈希表常用操作
|
||||
## 7.1.1. 哈希表常用操作
|
||||
|
||||
哈希表的基本操作包括 **初始化、查询操作、添加与删除键值对**。
|
||||
|
||||
|
@ -394,7 +387,7 @@ comments: true
|
|||
|
||||
```
|
||||
|
||||
## 6.1.3. 哈希函数
|
||||
## 7.1.2. 哈希函数
|
||||
|
||||
哈希表的底层实现为数组,同时可能包含链表、二叉树(红黑树)等数据结构,以提高查询性能(将在下节讨论)。
|
||||
|
||||
|
@ -1286,7 +1279,7 @@ $$
|
|||
}
|
||||
```
|
||||
|
||||
## 6.1.4. 哈希冲突
|
||||
## 7.1.3. 哈希冲突
|
||||
|
||||
细心的你可能已经注意到,**在某些情况下,哈希函数 $f(x) = x % 100$ 可能无法正常工作**。具体来说,当输入的 key 后两位相同时,哈希函数的计算结果也会相同,从而指向同一个 value 。例如,查询学号为 $12836$ 和 $20336$ 的两个学生时,我们得到:
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 6.3. 小结
|
||||
# 7.3. 小结
|
||||
|
||||
- 哈希表能够在 $O(1)$ 时间内将键 key 映射到值 value,效率非常高。
|
||||
- 常见的哈希表操作包括查询、添加与删除键值对、遍历键值对等。
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 8.2. 建堆操作 *
|
||||
# 9.2. 建堆操作 *
|
||||
|
||||
如果我们想要根据输入列表生成一个堆,这个过程被称为「建堆」。
|
||||
|
||||
## 8.2.1. 两种建堆方法
|
||||
## 9.2.1. 两种建堆方法
|
||||
|
||||
### 借助入堆方法实现
|
||||
|
||||
|
@ -155,7 +155,7 @@ comments: true
|
|||
}
|
||||
```
|
||||
|
||||
## 8.2.2. 复杂度分析
|
||||
## 9.2.2. 复杂度分析
|
||||
|
||||
为什么第二种建堆方法的时间复杂度是 $O(n)$ ?我们来展开推算一下。
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 8.1. 堆
|
||||
# 9.1. 堆
|
||||
|
||||
「堆 Heap」是一种满足特定条件的完全二叉树,可分为两种类型:
|
||||
|
||||
|
@ -19,7 +19,7 @@ comments: true
|
|||
- 我们将二叉树的根节点称为「堆顶」,将底层最靠右的节点称为「堆底」。
|
||||
- 对于大顶堆(小顶堆),堆顶元素(即根节点)的值分别是最大(最小)的。
|
||||
|
||||
## 8.1.1. 堆常用操作
|
||||
## 9.1.1. 堆常用操作
|
||||
|
||||
需要指出的是,许多编程语言提供的是「优先队列 Priority Queue」,这是一种抽象数据结构,定义为具有优先级排序的队列。
|
||||
|
||||
|
@ -307,7 +307,7 @@ comments: true
|
|||
|
||||
```
|
||||
|
||||
## 8.1.2. 堆的实现
|
||||
## 9.1.2. 堆的实现
|
||||
|
||||
下文实现的是大顶堆。若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 $\geq$ 替换为 $\leq$ )。感兴趣的读者可以自行实现。
|
||||
|
||||
|
@ -1276,7 +1276,7 @@ comments: true
|
|||
}
|
||||
```
|
||||
|
||||
## 8.1.3. 堆常见应用
|
||||
## 9.1.3. 堆常见应用
|
||||
|
||||
- **优先队列**:堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 $O(\log n)$ ,而建队操作为 $O(n)$ ,这些操作都非常高效。
|
||||
- **堆排序**:给定一组数据,我们可以用它们建立一个堆,然后依次将所有元素弹出,从而得到一个有序序列。当然,堆排序的实现方法并不需要弹出元素,而是每轮将堆顶元素交换至数组尾部并缩小堆的长度。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 8.3. 小结
|
||||
# 9.3. 小结
|
||||
|
||||
- 堆是一棵完全二叉树,根据成立条件可分为大顶堆和小顶堆。大(小)顶堆的堆顶元素是最大(小)的。
|
||||
- 优先队列的定义是具有出队优先级的队列,通常使用堆来实现。
|
||||
|
|
|
@ -1,262 +0,0 @@
|
|||
---
|
||||
comments: true
|
||||
---
|
||||
|
||||
# 10.3. 哈希查找
|
||||
|
||||
「哈希查找 Hash Searching」通过使用哈希表来存储所需的键值对,从而可在 $O(1)$ 时间内完成“键 $\rightarrow$ 值”的查找操作。
|
||||
|
||||
与线性查找相比,哈希查找通过利用额外空间来提高效率,体现了“以空间换时间”的算法思想。
|
||||
|
||||
## 10.3.1. 算法实现
|
||||
|
||||
例如,若我们想要在给定数组中找到目标元素 `target` 的索引,则可以使用哈希查找来实现。
|
||||
|
||||
![哈希查找数组索引](hashing_search.assets/hash_search_index.png)
|
||||
|
||||
<p align="center"> Fig. 哈希查找数组索引 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="hashing_search.java"
|
||||
/* 哈希查找(数组) */
|
||||
int hashingSearchArray(Map<Integer, Integer> map, int target) {
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
return map.getOrDefault(target, -1);
|
||||
}
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="hashing_search.cpp"
|
||||
/* 哈希查找(数组) */
|
||||
int hashingSearchArray(unordered_map<int, int> map, int target) {
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
if (map.find(target) == map.end())
|
||||
return -1;
|
||||
return map[target];
|
||||
}
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="hashing_search.py"
|
||||
def hashing_search_array(mapp: dict[int, int], target: int) -> int:
|
||||
"""哈希查找(数组)"""
|
||||
# 哈希表的 key: 目标元素,value: 索引
|
||||
# 若哈希表中无此 key ,返回 -1
|
||||
return mapp.get(target, -1)
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="hashing_search.go"
|
||||
/* 哈希查找(数组) */
|
||||
func hashingSearchArray(m map[int]int, target int) int {
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
if index, ok := m[target]; ok {
|
||||
return index
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
|
||||
```javascript title="hashing_search.js"
|
||||
/* 哈希查找(数组) */
|
||||
function hashingSearchArray(map, target) {
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
return map.has(target) ? map.get(target) : -1;
|
||||
}
|
||||
```
|
||||
|
||||
=== "TypeScript"
|
||||
|
||||
```typescript title="hashing_search.ts"
|
||||
/* 哈希查找(数组) */
|
||||
function hashingSearchArray(map: Map<number, number>, target: number): number {
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
return map.has(target) ? (map.get(target) as number) : -1;
|
||||
}
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="hashing_search.c"
|
||||
[class]{}-[func]{hashingSearchArray}
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="hashing_search.cs"
|
||||
/* 哈希查找(数组) */
|
||||
int hashingSearchArray(Dictionary<int, int> map, int target)
|
||||
{
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
return map.GetValueOrDefault(target, -1);
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="hashing_search.swift"
|
||||
/* 哈希查找(数组) */
|
||||
func hashingSearchArray(map: [Int: Int], target: Int) -> Int {
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
return map[target, default: -1]
|
||||
}
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title="hashing_search.zig"
|
||||
// 哈希查找(数组)
|
||||
fn hashingSearchArray(comptime T: type, map: std.AutoHashMap(T, T), target: T) T {
|
||||
// 哈希表的 key: 目标元素,value: 索引
|
||||
// 若哈希表中无此 key ,返回 -1
|
||||
if (map.getKey(target) == null) return -1;
|
||||
return map.get(target).?;
|
||||
}
|
||||
```
|
||||
|
||||
同样,若要根据目标节点值 target 查找对应的链表节点对象,也可以采用哈希查找方法。
|
||||
|
||||
![哈希查找链表节点](hashing_search.assets/hash_search_listnode.png)
|
||||
|
||||
<p align="center"> Fig. 哈希查找链表节点 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="hashing_search.java"
|
||||
/* 哈希查找(链表) */
|
||||
ListNode hashingSearchLinkedList(Map<Integer, ListNode> map, int target) {
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 null
|
||||
return map.getOrDefault(target, null);
|
||||
}
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="hashing_search.cpp"
|
||||
/* 哈希查找(链表) */
|
||||
ListNode *hashingSearchLinkedList(unordered_map<int, ListNode *> map, int target) {
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 nullptr
|
||||
if (map.find(target) == map.end())
|
||||
return nullptr;
|
||||
return map[target];
|
||||
}
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="hashing_search.py"
|
||||
def hashing_search_linkedlist(
|
||||
mapp: dict[int, ListNode], target: int
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="hashing_search.go"
|
||||
/* 哈希查找(链表) */
|
||||
func hashingSearchLinkedList(m map[int]*ListNode, target int) *ListNode {
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 nil
|
||||
if node, ok := m[target]; ok {
|
||||
return node
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
|
||||
```javascript title="hashing_search.js"
|
||||
/* 哈希查找(链表) */
|
||||
function hashingSearchLinkedList(map, target) {
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 null
|
||||
return map.has(target) ? map.get(target) : null;
|
||||
}
|
||||
```
|
||||
|
||||
=== "TypeScript"
|
||||
|
||||
```typescript title="hashing_search.ts"
|
||||
/* 哈希查找(链表) */
|
||||
function hashingSearchLinkedList(map: Map<number, ListNode>, target: number): ListNode | null {
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 null
|
||||
return map.has(target) ? (map.get(target) as ListNode) : null;
|
||||
}
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="hashing_search.c"
|
||||
[class]{}-[func]{hashingSearchLinkedList}
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="hashing_search.cs"
|
||||
/* 哈希查找(链表) */
|
||||
ListNode? hashingSearchLinkedList(Dictionary<int, ListNode> map, int target)
|
||||
{
|
||||
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 null
|
||||
return map.GetValueOrDefault(target);
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="hashing_search.swift"
|
||||
/* 哈希查找(链表) */
|
||||
func hashingSearchLinkedList(map: [Int: ListNode], target: Int) -> ListNode? {
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 null
|
||||
return map[target]
|
||||
}
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title="hashing_search.zig"
|
||||
// 哈希查找(链表)
|
||||
fn hashingSearchLinkedList(comptime T: type, map: std.AutoHashMap(T, *inc.ListNode(T)), target: T) ?*inc.ListNode(T) {
|
||||
// 哈希表的 key: 目标节点值,value: 节点对象
|
||||
// 若哈希表中无此 key ,返回 null
|
||||
if (map.getKey(target) == null) return null;
|
||||
return map.get(target);
|
||||
}
|
||||
```
|
||||
|
||||
## 10.3.2. 复杂度分析
|
||||
|
||||
**时间复杂度 $O(1)$** :哈希表的查找操作使用 $O(1)$ 时间。
|
||||
|
||||
**空间复杂度 $O(n)$** :其中 $n$ 是数组或链表的长度。
|
||||
|
||||
## 10.3.3. 优点与局限性
|
||||
|
||||
哈希查找的性能表现相当优秀,查找、插入、删除操作的平均时间复杂度均为 $O(1)$ 。尽管如此,哈希查找仍然存在一些问题:
|
||||
|
||||
- 辅助哈希表需要占用 $O(n)$ 的额外空间,意味着需要预留更多的计算机内存;
|
||||
- 构建和维护哈希表需要时间,因此哈希查找不适用于高频增删、低频查找的场景;
|
||||
- 当哈希冲突严重时,哈希表可能退化为链表,导致时间复杂度劣化至 $O(n)$ ;
|
||||
- 当数据量较小时,线性查找可能比哈希查找更快。这是因为计算哈希函数可能比遍历一个小型数组更慢;
|
||||
|
||||
因此,在实际应用中,我们需要根据具体情况灵活选择解决方案。
|
|
@ -1,343 +0,0 @@
|
|||
---
|
||||
comments: true
|
||||
---
|
||||
|
||||
# 10.1. 线性查找
|
||||
|
||||
「线性查找 Linear Search」是一种简单的查找方法,其从数据结构的一端开始,逐个访问每个元素,直至另一端为止。
|
||||
|
||||
## 10.1.1. 算法实现
|
||||
|
||||
例如,若我们想要在数组 `nums` 中查找目标元素 `target` 的对应索引,可以采用线性查找方法。
|
||||
|
||||
![在数组中线性查找元素](linear_search.assets/linear_search.png)
|
||||
|
||||
<p align="center"> Fig. 在数组中线性查找元素 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="linear_search.java"
|
||||
/* 线性查找(数组) */
|
||||
int linearSearchArray(int[] nums, int target) {
|
||||
// 遍历数组
|
||||
for (int i = 0; i < nums.length; i++) {
|
||||
// 找到目标元素,返回其索引
|
||||
if (nums[i] == target)
|
||||
return i;
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="linear_search.cpp"
|
||||
/* 线性查找(数组) */
|
||||
int linearSearchArray(vector<int> &nums, int target) {
|
||||
// 遍历数组
|
||||
for (int i = 0; i < nums.size(); i++) {
|
||||
// 找到目标元素,返回其索引
|
||||
if (nums[i] == target)
|
||||
return i;
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="linear_search.py"
|
||||
def linear_search_array(nums: list[int], target: int) -> int:
|
||||
"""线性查找(数组)"""
|
||||
# 遍历数组
|
||||
for i in range(len(nums)):
|
||||
if nums[i] == target: # 找到目标元素,返回其索引
|
||||
return i
|
||||
return -1 # 未找到目标元素,返回 -1
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="linear_search.go"
|
||||
/* 线性查找(数组) */
|
||||
func linearSearchArray(nums []int, target int) int {
|
||||
// 遍历数组
|
||||
for i := 0; i < len(nums); i++ {
|
||||
// 找到目标元素,返回其索引
|
||||
if nums[i] == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1
|
||||
}
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
|
||||
```javascript title="linear_search.js"
|
||||
/* 线性查找(数组) */
|
||||
function linearSearchArray(nums, target) {
|
||||
// 遍历数组
|
||||
for (let i = 0; i < nums.length; i++) {
|
||||
// 找到目标元素,返回其索引
|
||||
if (nums[i] === target) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
=== "TypeScript"
|
||||
|
||||
```typescript title="linear_search.ts"
|
||||
/* 线性查找(数组)*/
|
||||
function linearSearchArray(nums: number[], target: number): number {
|
||||
// 遍历数组
|
||||
for (let i = 0; i < nums.length; i++) {
|
||||
// 找到目标元素,返回其索引
|
||||
if (nums[i] === target) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="linear_search.c"
|
||||
[class]{}-[func]{linearSearchArray}
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="linear_search.cs"
|
||||
/* 线性查找(数组) */
|
||||
int linearSearchArray(int[] nums, int target)
|
||||
{
|
||||
// 遍历数组
|
||||
for (int i = 0; i < nums.Length; i++)
|
||||
{
|
||||
// 找到目标元素,返回其索引
|
||||
if (nums[i] == target)
|
||||
return i;
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="linear_search.swift"
|
||||
/* 线性查找(数组) */
|
||||
func linearSearchArray(nums: [Int], target: Int) -> Int {
|
||||
// 遍历数组
|
||||
for i in nums.indices {
|
||||
// 找到目标元素,返回其索引
|
||||
if nums[i] == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1
|
||||
}
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title="linear_search.zig"
|
||||
// 线性查找(数组)
|
||||
fn linearSearchArray(comptime T: type, nums: std.ArrayList(T), target: T) T {
|
||||
// 遍历数组
|
||||
for (nums.items) |num, i| {
|
||||
// 找到目标元素, 返回其索引
|
||||
if (num == target) {
|
||||
return @intCast(T, i);
|
||||
}
|
||||
}
|
||||
// 未找到目标元素,返回 -1
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
另一个例子,若需要在链表中查找给定目标节点值 `target` 并返回该节点对象,同样可以使用线性查找。
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="linear_search.java"
|
||||
/* 线性查找(链表) */
|
||||
ListNode linearSearchLinkedList(ListNode head, int target) {
|
||||
// 遍历链表
|
||||
while (head != null) {
|
||||
// 找到目标节点,返回之
|
||||
if (head.val == target)
|
||||
return head;
|
||||
head = head.next;
|
||||
}
|
||||
// 未找到目标节点,返回 null
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="linear_search.cpp"
|
||||
/* 线性查找(链表) */
|
||||
ListNode *linearSearchLinkedList(ListNode *head, int target) {
|
||||
// 遍历链表
|
||||
while (head != nullptr) {
|
||||
// 找到目标节点,返回之
|
||||
if (head->val == target)
|
||||
return head;
|
||||
head = head->next;
|
||||
}
|
||||
// 未找到目标节点,返回 nullptr
|
||||
return nullptr;
|
||||
}
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="linear_search.py"
|
||||
def linear_search_linkedlist(head: ListNode, target: int) -> ListNode | None:
|
||||
"""线性查找(链表)"""
|
||||
# 遍历链表
|
||||
while head:
|
||||
if head.val == target: # 找到目标节点,返回之
|
||||
return head
|
||||
head = head.next
|
||||
return None # 未找到目标节点,返回 None
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="linear_search.go"
|
||||
/* 线性查找(链表) */
|
||||
func linearSearchLinkedList(node *ListNode, target int) *ListNode {
|
||||
// 遍历链表
|
||||
for node != nil {
|
||||
// 找到目标节点,返回之
|
||||
if node.Val == target {
|
||||
return node
|
||||
}
|
||||
node = node.Next
|
||||
}
|
||||
// 未找到目标元素,返回 nil
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
|
||||
```javascript title="linear_search.js"
|
||||
/* 线性查找(链表)*/
|
||||
function linearSearchLinkedList(head, target) {
|
||||
// 遍历链表
|
||||
while(head) {
|
||||
// 找到目标节点,返回之
|
||||
if(head.val === target) {
|
||||
return head;
|
||||
}
|
||||
head = head.next;
|
||||
}
|
||||
// 未找到目标节点,返回 null
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
=== "TypeScript"
|
||||
|
||||
```typescript title="linear_search.ts"
|
||||
/* 线性查找(链表)*/
|
||||
function linearSearchLinkedList(head: ListNode | null, target: number): ListNode | null {
|
||||
// 遍历链表
|
||||
while (head) {
|
||||
// 找到目标节点,返回之
|
||||
if (head.val === target) {
|
||||
return head;
|
||||
}
|
||||
head = head.next;
|
||||
}
|
||||
// 未找到目标节点,返回 null
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="linear_search.c"
|
||||
[class]{}-[func]{linearSearchLinkedList}
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="linear_search.cs"
|
||||
/* 线性查找(链表) */
|
||||
ListNode? linearSearchLinkedList(ListNode head, int target)
|
||||
{
|
||||
// 遍历链表
|
||||
while (head != null)
|
||||
{
|
||||
// 找到目标节点,返回之
|
||||
if (head.val == target)
|
||||
return head;
|
||||
head = head.next;
|
||||
}
|
||||
// 未找到目标节点,返回 null
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="linear_search.swift"
|
||||
/* 线性查找(链表) */
|
||||
func linearSearchLinkedList(head: ListNode?, target: Int) -> ListNode? {
|
||||
var head = head
|
||||
// 遍历链表
|
||||
while head != nil {
|
||||
// 找到目标节点,返回之
|
||||
if head?.val == target {
|
||||
return head
|
||||
}
|
||||
head = head?.next
|
||||
}
|
||||
// 未找到目标节点,返回 null
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
=== "Zig"
|
||||
|
||||
```zig title="linear_search.zig"
|
||||
// 线性查找(链表)
|
||||
fn linearSearchLinkedList(comptime T: type, node: ?*inc.ListNode(T), target: T) ?*inc.ListNode(T) {
|
||||
var head = node;
|
||||
// 遍历链表
|
||||
while (head != null) {
|
||||
// 找到目标节点,返回之
|
||||
if (head.?.val == target) return head;
|
||||
head = head.?.next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## 10.1.2. 复杂度分析
|
||||
|
||||
**时间复杂度 $O(n)$** :其中 $n$ 代表数组或链表的长度。
|
||||
|
||||
**空间复杂度 $O(1)$** :无需借助额外的存储空间。
|
||||
|
||||
## 10.1.3. 优点与局限性
|
||||
|
||||
**线性查找具有极佳的通用性**。由于线性查找是逐个访问元素的,没有跳跃式访问,因此适用于数组和链表的查找。
|
||||
|
||||
**线性查找的时间复杂度较高**。当数据量 $n$ 较大时,线性查找的效率较低。
|
|
@ -2,17 +2,9 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 2.4. 权衡时间与空间
|
||||
# 12.2. 哈希优化策略
|
||||
|
||||
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。
|
||||
|
||||
**降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然**。我们将牺牲内存空间来提升算法运行速度的思路称为「以空间换时间」;反之,则称之为「以时间换空间」。
|
||||
|
||||
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此以空间换时间通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也是非常重要的。
|
||||
|
||||
## 2.4.1. 示例题目 *
|
||||
|
||||
以 LeetCode 全站第一题 [两数之和](https://leetcode.cn/problems/two-sum/) 为例。
|
||||
在算法题中,**我们时常通过将线性查找替换为哈希查找来降低算法的时间复杂度**。以 LeetCode 全站第一题 [两数之和](https://leetcode.cn/problems/two-sum/) 为例。
|
||||
|
||||
!!! question "两数之和"
|
||||
|
||||
|
@ -22,11 +14,11 @@ comments: true
|
|||
|
||||
你可以按任意顺序返回答案。
|
||||
|
||||
「暴力枚举」和「辅助哈希表」分别对应“空间最优”和“时间最优”的两种解法。遵循时间比空间更宝贵的原则,后者是本题的最佳解法。
|
||||
## 12.2.1. 线性查找:以时间换空间
|
||||
|
||||
### 方法一:暴力枚举
|
||||
考虑直接遍历所有可能的组合。开启一个两层循环,在每轮中判断两个整数的和是否为 `target` ,若是,则返回它们的索引。
|
||||
|
||||
考虑直接遍历所有可能的组合。通过开启一个两层循环,判断两个整数的和是否为 `target` ,若是,则返回它们的索引(即下标)。
|
||||
(图)
|
||||
|
||||
=== "Java"
|
||||
|
||||
|
@ -192,15 +184,17 @@ comments: true
|
|||
}
|
||||
```
|
||||
|
||||
该方法的时间复杂度为 $O(n^2)$ ,空间复杂度为 $O(1)$ ,**属于以时间换空间**。此方法时间复杂度太高,在大数据量下非常耗时。
|
||||
此方法的时间复杂度为 $O(n^2)$ ,空间复杂度为 $O(1)$ ,在大数据量下非常耗时。
|
||||
|
||||
### 方法二:辅助哈希表
|
||||
## 12.2.2. 哈希查找:以空间换时间
|
||||
|
||||
考虑借助一个哈希表,key-value 分别为数组元素和元素索引。循环遍历数组中的每个元素 num,并执行:
|
||||
考虑借助一个哈希表,将数组元素和元素索引构建为键值对。循环遍历数组中的每个元素 `num` 并执行:
|
||||
|
||||
1. 判断数字 `target - num` 是否在哈希表中,若是则直接返回该两个元素的索引;
|
||||
2. 将元素 `num` 和其索引添加进哈希表;
|
||||
|
||||
(图)
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="leetcode_two_sum.java"
|
||||
|
@ -378,4 +372,6 @@ comments: true
|
|||
}
|
||||
```
|
||||
|
||||
该方法的时间复杂度为 $O(N)$ ,空间复杂度为 $O(N)$ ,**体现了以空间换时间**。尽管此方法引入了额外的空间使用,但在时间和空间的整体效率更为均衡,因此它是本题的最优解法。
|
||||
此方法通过哈希查找将时间复杂度从 $O(n^2)$ 降低至 $O(n)$ ,大幅提升运行效率。
|
||||
|
||||
由于需要维护一个额外的哈希表,因此空间复杂度为 $O(n)$ 。**尽管如此,该方法的整体时空效率更为均衡,因此它是本题的最优解法**。
|
87
chapter_searching/searching_algorithm_revisited.md
Normal file
87
chapter_searching/searching_algorithm_revisited.md
Normal file
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
comments: true
|
||||
---
|
||||
|
||||
# 12.1. 搜索算法
|
||||
|
||||
「搜索算法 Searching Algorithm」用于在数据结构(例如数组、链表、树或图)中搜索一个或一组满足特定条件的元素。
|
||||
|
||||
我们已经学过数组、链表、树和图的遍历方法,也学过哈希表、二叉搜索树等可用于实现查询的复杂数据结构。因此,搜索算法对于我们来说并不陌生。在本节,我们将从更加系统的视角切入,重新审视搜索算法。
|
||||
|
||||
## 12.1.1. 暴力搜索
|
||||
|
||||
暴力搜索通过遍历数据结构的每个元素来定位目标元素。
|
||||
|
||||
- 「线性搜索」适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。
|
||||
- 「广度优先搜索」和「深度优先搜索」是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索是从初始节点开始,沿着一条路径走到头为止,再回溯并尝试其他路径,直到遍历完整个数据结构。
|
||||
|
||||
暴力搜索的优点是简单且通用性好,**无需对数据做预处理和借助额外的数据结构**。
|
||||
|
||||
然而,**此类算法的时间复杂度为 $O(n)$** ,其中 $n$ 为元素数量,因此在数据量较大的情况下性能较差。
|
||||
|
||||
## 12.1.2. 自适应搜索
|
||||
|
||||
自适应搜索利用数据的特有属性(例如有序性)来优化搜索过程,从而更高效地定位目标元素。
|
||||
|
||||
- 「二分查找」利用数据的有序性实现高效查找,仅适用于数组。
|
||||
- 「哈希查找」利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现查询操作。
|
||||
- 「树查找」在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。
|
||||
|
||||
此类算法的优点是效率高,**时间复杂度可达到 $O(\log n)$ 甚至 $O(1)$** 。
|
||||
|
||||
然而,**使用这些算法往往需要对数据进行预处理**。例如,二分查找需要预先对数组进行排序,哈希查找和树查找都需要借助额外的数据结构,维护这些数据结构也需要额外的时间和空间开支。
|
||||
|
||||
!!! note
|
||||
|
||||
自适应搜索算法常被称为查找算法,**主要关注在特定数据结构中快速检索目标元素**。
|
||||
|
||||
## 12.1.3. 搜索方法选取
|
||||
|
||||
给定大小为 $n$ 的一组数据,我们可以使用线性搜索、二分查找、树查找、哈希查找等多种方法在该数据中搜索目标元素。各个方法的工作原理如下图所示。
|
||||
|
||||
![多种搜索策略](searching_algorithm_revisited.assets/searching_algorithms.png)
|
||||
|
||||
<p align="center"> Fig. 多种搜索策略 </p>
|
||||
|
||||
上述几种方法的操作效率与特性如下表所示。
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
| | 线性搜索 | 二分查找 | 树查找 | 哈希查找 |
|
||||
| ------------ | -------- | ------------------ | ------------------ | --------------- |
|
||||
| 查找元素 | $O(n)$ | $O(\log n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 插入元素 | $O(1)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 删除元素 | $O(n)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 额外空间 | $O(1)$ | $O(1)$ | $O(n)$ | $O(n)$ |
|
||||
| 数据预处理 | / | 排序 $O(n \log n)$ | 建树 $O(n \log n)$ | 建哈希表 $O(n)$ |
|
||||
| 数据是否有序 | 无序 | 有序 | 有序 | 无序 |
|
||||
|
||||
</div>
|
||||
|
||||
除了以上表格内容,搜索算法的选择还取决于数据体量、搜索性能要求、数据查询与更新频率等。
|
||||
|
||||
**线性搜索**
|
||||
|
||||
- 通用性较好,无需任何数据预处理操作。加入我们仅需查询一次数据,那么其他三种方法的数据预处理的时间比线性搜索的时间还要更长。
|
||||
- 适用于体量较小的数据,此情况下时间复杂度对效率影响较小。
|
||||
- 适用于数据更新频率较高的场景,因为该方法不需要对数据进行任何额外维护。
|
||||
|
||||
**二分查找**
|
||||
|
||||
- 适用于大数据量的情况,效率表现稳定,最差时间复杂度为 $O(\log n)$ 。
|
||||
- 数据量不能过大,因为存储数组需要连续的内存空间。
|
||||
- 不适用于高频增删数据的场景,因为维护有序数组的开销较大。
|
||||
|
||||
**哈希查找**
|
||||
|
||||
- 适合对查询性能要求很高的场景,平均时间复杂度为 $O(1)$ 。
|
||||
- 不适合需要有序数据或范围查找的场景,因为哈希表无法维护数据的有序性。
|
||||
- 对哈希函数和哈希冲突处理策略的依赖性较高,具有较大的性能劣化风险。
|
||||
- 不适合数据量过大的情况,因为哈希表需要额外空间来最大程度地减少冲突,从而提供良好的查询性能。
|
||||
|
||||
**树查找**
|
||||
|
||||
- 适用于海量数据,因为树节点在内存中是离散存储的。
|
||||
- 适合需要维护有序数据或范围查找的场景。
|
||||
- 在持续增删节点的过程中,二叉搜索树可能产生倾斜,时间复杂度劣化至 $O(n)$ 。
|
||||
- 若使用 AVL 树或红黑树,则各项操作可在 $O(\log n)$ 效率下稳定运行,但维护树平衡的操作会增加额外开销。
|
|
@ -2,19 +2,11 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 10.4. 小结
|
||||
# 12.3. 小结
|
||||
|
||||
- 线性查找通过遍历数据结构并进行条件判断来完成查找任务。
|
||||
- 二分查找依赖于数据的有序性,通过循环逐步缩减一半搜索区间来实现查找。它要求输入数据有序,且仅适用于数组或基于数组实现的数据结构。
|
||||
- 哈希查找利用哈希表实现常数阶时间复杂度的查找操作,体现了空间换时间的算法思维。
|
||||
- 下表概括并对比了三种查找算法的特性和时间复杂度。
|
||||
|
||||
<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)$ |
|
||||
| 空间复杂度 | $O(1)$ | $O(1)$ | $O(n)$ |
|
||||
|
||||
</div>
|
||||
- 暴力搜索通过遍历数据结构来定位数据。线性搜索适用于数组和链表,广度优先搜索和深度优先搜索适用于图和树。此类算法通用性好,无需对数据预处理,但时间复杂度 $O(n)$ 较高。
|
||||
- 哈希查找、树查找和二分查找属于高效搜索方法,可在特定数据结构中快速定位目标元素。此类算法效率高,时间复杂度可达 $O(\log n)$ 甚至 $O(1)$ ,但通常需要借助额外数据结构。
|
||||
- 实际中,我们需要对数据体量、搜索性能要求、数据查询和更新频率等因素进行具体分析,从而选择合适的搜索方法。
|
||||
- 线性搜索适用于小型或频繁更新的数据;二分查找适用于大型、排序的数据;哈希查找适合对查询效率要求较高且无需范围查询的数据;树查找适用于需要维护顺序和支持范围查询的大型动态数据。
|
||||
- 用哈希查找替换线性查找是一种常用的优化运行时间的策略,可将时间复杂度从 $O(n)$ 降低至 $O(1)$ 。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 7.4. AVL 树 *
|
||||
# 8.4. AVL 树 *
|
||||
|
||||
在二叉搜索树章节中,我们提到了在多次插入和删除操作后,二叉搜索树可能退化为链表。这种情况下,所有操作的时间复杂度将从 $O(\log n)$ 恶化为 $O(n)$。
|
||||
|
||||
|
@ -20,7 +20,7 @@ comments: true
|
|||
|
||||
G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 $O(\log n)$ 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。
|
||||
|
||||
## 7.4.1. AVL 树常见术语
|
||||
## 8.4.1. AVL 树常见术语
|
||||
|
||||
「AVL 树」既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为「平衡二叉搜索树」。
|
||||
|
||||
|
@ -448,7 +448,7 @@ G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorit
|
|||
|
||||
设平衡因子为 $f$ ,则一棵 AVL 树的任意节点的平衡因子皆满足 $-1 \le f \le 1$ 。
|
||||
|
||||
## 7.4.2. AVL 树旋转
|
||||
## 8.4.2. AVL 树旋转
|
||||
|
||||
AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,**旋转操作既能保持树的「二叉搜索树」属性,也能使树重新变为「平衡二叉树」**。
|
||||
|
||||
|
@ -1186,7 +1186,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉
|
|||
}
|
||||
```
|
||||
|
||||
## 7.4.3. AVL 树常用操作
|
||||
## 8.4.3. AVL 树常用操作
|
||||
|
||||
### 插入节点
|
||||
|
||||
|
@ -1875,7 +1875,7 @@ AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉
|
|||
|
||||
AVL 树的节点查找操作与二叉搜索树一致,在此不再赘述。
|
||||
|
||||
## 7.4.4. AVL 树典型应用
|
||||
## 8.4.4. AVL 树典型应用
|
||||
|
||||
- 组织和存储大型数据,适用于高频查找、低频增删的场景;
|
||||
- 用于构建数据库中的索引系统;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 7.3. 二叉搜索树
|
||||
# 8.3. 二叉搜索树
|
||||
|
||||
「二叉搜索树 Binary Search Tree」满足以下条件:
|
||||
|
||||
|
@ -13,7 +13,7 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 二叉搜索树 </p>
|
||||
|
||||
## 7.3.1. 二叉搜索树的操作
|
||||
## 8.3.1. 二叉搜索树的操作
|
||||
|
||||
### 查找节点
|
||||
|
||||
|
@ -1076,38 +1076,23 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 二叉搜索树的中序遍历序列 </p>
|
||||
|
||||
## 7.3.2. 二叉搜索树的效率
|
||||
## 8.3.2. 二叉搜索树的效率
|
||||
|
||||
假设给定 $n$ 个数字,最常见的存储方式是「数组」。对于这串乱序的数字,常见操作的效率如下:
|
||||
给定一组数据,我们考虑使用数组或二叉搜索树存储。
|
||||
|
||||
- **查找元素**:由于数组是无序的,因此需要遍历数组来确定,使用 $O(n)$ 时间;
|
||||
- **插入元素**:只需将元素添加至数组尾部即可,使用 $O(1)$ 时间;
|
||||
- **删除元素**:先查找元素,使用 $O(n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
|
||||
- **获取最小 / 最大元素**:需要遍历数组来确定,使用 $O(n)$ 时间;
|
||||
|
||||
为了获得先验信息,我们可以预先将数组元素进行排序,得到一个「排序数组」。此时操作效率如下:
|
||||
|
||||
- **查找元素**:由于数组已排序,可以使用二分查找,平均使用 $O(\log n)$ 时间;
|
||||
- **插入元素**:先查找插入位置,使用 $O(\log n)$ 时间,再插入到指定位置,使用 $O(n)$ 时间;
|
||||
- **删除元素**:先查找元素,使用 $O(\log n)$ 时间,再在数组中删除该元素,使用 $O(n)$ 时间;
|
||||
- **获取最小 / 最大元素**:数组头部和尾部元素即是最小和最大元素,使用 $O(1)$ 时间;
|
||||
|
||||
观察可知,无序数组和有序数组中的各项操作的时间复杂度呈现“偏科”的特点,即有的快有的慢。**然而,二叉搜索树的各项操作的时间复杂度都是对数阶,在数据量 $n$ 较大时具有显著优势**。
|
||||
观察可知,二叉搜索树的各项操作的时间复杂度都是对数阶,具有稳定且高效的性能表现。只有在高频添加、低频查找删除的数据适用场景下,数组比二叉搜索树的效率更高。
|
||||
|
||||
<div class="center-table" markdown>
|
||||
|
||||
| | 无序数组 | 有序数组 | 二叉搜索树 |
|
||||
| ------------------- | -------- | ----------- | ----------- |
|
||||
| 查找指定元素 | $O(n)$ | $O(\log n)$ | $O(\log n)$ |
|
||||
| 插入元素 | $O(1)$ | $O(n)$ | $O(\log n)$ |
|
||||
| 删除元素 | $O(n)$ | $O(n)$ | $O(\log n)$ |
|
||||
| 获取最小 / 最大元素 | $O(n)$ | $O(1)$ | $O(\log n)$ |
|
||||
| | 无序数组 | 二叉搜索树 |
|
||||
| -------- | -------- | ----------- |
|
||||
| 查找元素 | $O(n)$ | $O(\log n)$ |
|
||||
| 插入元素 | $O(1)$ | $O(\log n)$ |
|
||||
| 删除元素 | $O(n)$ | $O(\log n)$ |
|
||||
|
||||
</div>
|
||||
|
||||
## 7.3.3. 二叉搜索树的退化
|
||||
|
||||
在理想情况下,我们希望二叉搜索树是“平衡”的,这样就可以在 $\log n$ 轮循环内查找任意节点。
|
||||
在理想情况下,二叉搜索树是“平衡”的,这样就可以在 $\log n$ 轮循环内查找任意节点。
|
||||
|
||||
然而,如果我们在二叉搜索树中不断地插入和删除节点,可能导致二叉树退化为链表,这时各种操作的时间复杂度也会退化为 $O(n)$ 。
|
||||
|
||||
|
@ -1115,7 +1100,7 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 二叉搜索树的平衡与退化 </p>
|
||||
|
||||
## 7.3.4. 二叉搜索树常见应用
|
||||
## 8.3.3. 二叉搜索树常见应用
|
||||
|
||||
- 用作系统中的多级索引,实现高效的查找、插入、删除操作。
|
||||
- 作为某些搜索算法的底层数据结构。
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 7.1. 二叉树
|
||||
# 8.1. 二叉树
|
||||
|
||||
「二叉树 Binary Tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含一个「值」和两个「指针」。
|
||||
|
||||
|
@ -135,7 +135,7 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 父节点、子节点、子树 </p>
|
||||
|
||||
## 7.1.1. 二叉树常见术语
|
||||
## 8.1.1. 二叉树常见术语
|
||||
|
||||
二叉树涉及的术语较多,建议尽量理解并记住。
|
||||
|
||||
|
@ -156,7 +156,7 @@ comments: true
|
|||
|
||||
请注意,我们通常将「高度」和「深度」定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。
|
||||
|
||||
## 7.1.2. 二叉树基本操作
|
||||
## 8.1.2. 二叉树基本操作
|
||||
|
||||
**初始化二叉树**。与链表类似,首先初始化节点,然后构建引用指向(即指针)。
|
||||
|
||||
|
@ -422,7 +422,7 @@ comments: true
|
|||
|
||||
需要注意的是,插入节点可能会改变二叉树的原有逻辑结构,而删除节点通常意味着删除该节点及其所有子树。因此,在二叉树中,插入与删除操作通常是由一套操作配合完成的,以实现有实际意义的操作。
|
||||
|
||||
## 7.1.3. 常见二叉树类型
|
||||
## 8.1.3. 常见二叉树类型
|
||||
|
||||
### 完美二叉树
|
||||
|
||||
|
@ -460,7 +460,7 @@ comments: true
|
|||
|
||||
<p align="center"> Fig. 平衡二叉树 </p>
|
||||
|
||||
## 7.1.4. 二叉树的退化
|
||||
## 8.1.4. 二叉树的退化
|
||||
|
||||
当二叉树的每层节点都被填满时,达到「完美二叉树」;而当所有节点都偏向一侧时,二叉树退化为「链表」。
|
||||
|
||||
|
@ -484,7 +484,7 @@ comments: true
|
|||
|
||||
</div>
|
||||
|
||||
## 7.1.5. 二叉树表示方式 *
|
||||
## 8.1.5. 二叉树表示方式 *
|
||||
|
||||
我们通常使用二叉树的「链表表示」,即存储单位为节点 `TreeNode` ,节点之间通过指针相连接。本文前述示例代码展示了二叉树在链表表示下的各项基本操作。
|
||||
|
||||
|
|
|
@ -2,13 +2,13 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 7.2. 二叉树遍历
|
||||
# 8.2. 二叉树遍历
|
||||
|
||||
从物理结构的角度来看,树是一种基于链表的数据结构,因此其遍历方式是通过指针逐个访问节点。然而,树是一种非线性数据结构,这使得遍历树比遍历链表更加复杂,需要借助搜索算法来实现。
|
||||
|
||||
二叉树常见的遍历方式包括层序遍历、前序遍历、中序遍历和后序遍历等。
|
||||
|
||||
## 7.2.1. 层序遍历
|
||||
## 8.2.1. 层序遍历
|
||||
|
||||
「层序遍历 Level-Order Traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
|
||||
|
||||
|
@ -250,7 +250,7 @@ comments: true
|
|||
|
||||
**空间复杂度**:在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 $\frac{n + 1}{2}$ 个节点,占用 $O(n)$ 空间。
|
||||
|
||||
## 7.2.2. 前序、中序、后序遍历
|
||||
## 8.2.2. 前序、中序、后序遍历
|
||||
|
||||
相应地,前序、中序和后序遍历都属于「深度优先遍历 Depth-First Traversal」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
comments: true
|
||||
---
|
||||
|
||||
# 7.5. 小结
|
||||
# 8.5. 小结
|
||||
|
||||
- 二叉树是一种非线性数据结构,体现“一分为二”的分治逻辑。每个二叉树节点包含一个值以及两个指针,分别指向其左子节点和右子节点。
|
||||
- 对于二叉树中的某个节点,其左(右)子节点及其以下形成的树被称为该节点的左(右)子树。
|
||||
|
|
Loading…
Reference in a new issue