This commit is contained in:
krahets 2023-08-24 17:47:18 +08:00
parent 92a0853ab8
commit c6f0dfbdc8
10 changed files with 958 additions and 59 deletions

View File

@ -20,6 +20,7 @@ icon: material/timer-sand
## 本章内容
- [2.1   算法效率评估](https://www.hello-algo.com/chapter_computational_complexity/performance_evaluation/)
- [2.2   时间复杂度](https://www.hello-algo.com/chapter_computational_complexity/time_complexity/)
- [2.3   空间复杂度](https://www.hello-algo.com/chapter_computational_complexity/space_complexity/)
- [2.4   小结](https://www.hello-algo.com/chapter_computational_complexity/summary/)
- [2.2   迭代与递归](https://www.hello-algo.com/chapter_computational_complexity/iteration_and_recursion/)
- [2.3   时间复杂度](https://www.hello-algo.com/chapter_computational_complexity/time_complexity/)
- [2.4   空间复杂度](https://www.hello-algo.com/chapter_computational_complexity/space_complexity/)
- [2.5   小结](https://www.hello-algo.com/chapter_computational_complexity/summary/)

View File

@ -0,0 +1,833 @@
---
comments: true
---
# 2.2   迭代与递归
在数据结构与算法中,重复执行某个任务是很常见的,其与算法的复杂度密切相关。而要重复执行某个任务,我们通常会选用两种基本的程序结构:迭代和递归。
## 2.2.1   迭代
「迭代 iteration」是一种重复执行某个任务的控制结构。在迭代中程序会在满足一定的条件下重复执行某段代码直到这个条件不再满足。
### 1.   for 循环
`for` 循环是最常见的迭代形式之一,**适合预先知道迭代次数时使用**。
以下函数基于 `for` 循环实现了求和 $1 + 2 + \dots + n$ ,求和结果使用变量 `res` 记录。需要注意的是Python 中 `range(a, b)` 对应的区间是“左闭右开”的,对应的遍历范围为 $a, a + 1, \dots, b-1$ 。
=== "Java"
```java title="iteration.java"
/* for 循环 */
int forLoop(int n) {
int res = 0;
// 循环求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; i++) {
res += i;
}
return res;
}
```
=== "C++"
```cpp title="iteration.cpp"
/* for 循环 */
int forLoop(int n) {
int res = 0;
// 循环求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
res += i;
}
return res;
}
```
=== "Python"
```python title="iteration.py"
def for_loop(n: int) -> int:
"""for 循环"""
res = 0
# 循环求和 1, 2, ..., n-1, n
for i in range(1, n + 1):
res += i
return res
```
=== "Go"
```go title="iteration.go"
[class]{}-[func]{forLoop}
```
=== "JS"
```javascript title="iteration.js"
[class]{}-[func]{forLoop}
```
=== "TS"
```typescript title="iteration.ts"
[class]{}-[func]{forLoop}
```
=== "C"
```c title="iteration.c"
[class]{}-[func]{forLoop}
```
=== "C#"
```csharp title="iteration.cs"
[class]{iteration}-[func]{forLoop}
```
=== "Swift"
```swift title="iteration.swift"
[class]{}-[func]{forLoop}
```
=== "Zig"
```zig title="iteration.zig"
[class]{}-[func]{forLoop}
```
=== "Dart"
```dart title="iteration.dart"
[class]{}-[func]{forLoop}
```
=== "Rust"
```rust title="iteration.rs"
[class]{}-[func]{for_loop}
```
图 2-1 展示了该求和函数的流程框图。
![求和函数的流程框图](iteration_and_recursion.assets/iteration.png)
<p align="center"> 图 2-1 &nbsp; 求和函数的流程框图 </p>
此求和函数的操作数量与输入数据大小 $n$ 成正比,或者说成“线性关系”。实际上,**时间复杂度描述的就是这个“线性关系”**。相关内容将会在下一节中详细介绍。
### 2. &nbsp; while 循环
`for` 循环类似,`while` 循环也是一种实现迭代的方法。在 `while` 循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环。
下面,我们用 `while` 循环来实现求和 $1 + 2 + \dots + n$ 。
=== "Java"
```java title="iteration.java"
/* while 循环 */
int whileLoop(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 2, ..., n-1, n
while (i <= n) {
res += i;
i += 1; // 更新条件变量
}
return res;
}
```
=== "C++"
```cpp title="iteration.cpp"
/* while 循环 */
int whileLoop(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 2, ..., n-1, n
while (i <= n) {
res += i;
i += 1; // 更新条件变量
}
return res;
}
```
=== "Python"
```python title="iteration.py"
def while_loop(n: int) -> int:
"""while 循环"""
res = 0
i = 1 # 初始化条件变量
# 循环求和 1, 2, ..., n-1, n
while i <= n:
res += i
i += 1 # 更新条件变量
return res
```
=== "Go"
```go title="iteration.go"
[class]{}-[func]{whileLoop}
```
=== "JS"
```javascript title="iteration.js"
[class]{}-[func]{whileLoop}
```
=== "TS"
```typescript title="iteration.ts"
[class]{}-[func]{whileLoop}
```
=== "C"
```c title="iteration.c"
[class]{}-[func]{whileLoop}
```
=== "C#"
```csharp title="iteration.cs"
[class]{iteration}-[func]{whileLoop}
```
=== "Swift"
```swift title="iteration.swift"
[class]{}-[func]{whileLoop}
```
=== "Zig"
```zig title="iteration.zig"
[class]{}-[func]{whileLoop}
```
=== "Dart"
```dart title="iteration.dart"
[class]{}-[func]{whileLoop}
```
=== "Rust"
```rust title="iteration.rs"
[class]{}-[func]{while_loop}
```
`while` 循环中,由于初始化和更新条件变量的步骤是独立在循环结构之外的,**因此它比 `for` 循环的自由度更高**。
例如在以下代码中,条件变量 $i$ 每轮进行了两次更新,这种情况就不太方便用 `for` 循环实现。
=== "Java"
```java title="iteration.java"
/* while 循环(两次更新) */
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 2, 4, 5...
while (i <= n) {
res += i;
i += 1; // 更新条件变量
res += i;
i *= 2; // 更新条件变量
}
return res;
}
```
=== "C++"
```cpp title="iteration.cpp"
/* while 循环(两次更新) */
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 2, 4, 5...
while (i <= n) {
res += i;
i += 1; // 更新条件变量
res += i;
i *= 2; // 更新条件变量
}
return res;
}
```
=== "Python"
```python title="iteration.py"
def while_loop_ii(n: int) -> int:
"""while 循环(两次更新)"""
res = 0
i = 1 # 初始化条件变量
# 循环求和 1, 2, 4, 5...
while i <= n:
res += i
i += 1 # 更新条件变量
res += i
i *= 2 # 更新条件变量
return res
```
=== "Go"
```go title="iteration.go"
[class]{}-[func]{whileLoopII}
```
=== "JS"
```javascript title="iteration.js"
[class]{}-[func]{whileLoopII}
```
=== "TS"
```typescript title="iteration.ts"
[class]{}-[func]{whileLoopII}
```
=== "C"
```c title="iteration.c"
[class]{}-[func]{whileLoopII}
```
=== "C#"
```csharp title="iteration.cs"
[class]{iteration}-[func]{whileLoopII}
```
=== "Swift"
```swift title="iteration.swift"
[class]{}-[func]{whileLoopII}
```
=== "Zig"
```zig title="iteration.zig"
[class]{}-[func]{whileLoopII}
```
=== "Dart"
```dart title="iteration.dart"
[class]{}-[func]{whileLoopII}
```
=== "Rust"
```rust title="iteration.rs"
[class]{}-[func]{while_loop_ii}
```
总的来说,**`for` 循环的代码更加紧凑,`while` 循环更加灵活**,两者都可以实现迭代结构。选择使用哪一个应该根据特定问题的需求来决定。
### 3. &nbsp; 嵌套循环
我们可以在一个循环结构内嵌套另一个循环结构,以 `for` 循环为例:
=== "Java"
```java title="iteration.java"
/* 双层 for 循环 */
String nestedForLoop(int n) {
StringBuilder res = new StringBuilder();
// 循环 i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; i++) {
// 循环 j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; j++) {
res.append("(" + i + ", " + j + "), ");
}
}
return res.toString();
}
```
=== "C++"
```cpp title="iteration.cpp"
/* 双层 for 循环 */
string nestedForLoop(int n) {
ostringstream res;
// 循环 i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
// 循环 j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; ++j) {
res << "(" << i << ", " << j << "), ";
}
}
return res.str();
}
```
=== "Python"
```python title="iteration.py"
def nested_for_loop(n: int) -> str:
"""双层 for 循环"""
res = ""
# 循环 i = 1, 2, ..., n-1, n
for i in range(1, n + 1):
# 循环 j = 1, 2, ..., n-1, n
for j in range(1, n + 1):
res += f"({i}, {j}), "
return res
```
=== "Go"
```go title="iteration.go"
[class]{}-[func]{nestedForLoop}
```
=== "JS"
```javascript title="iteration.js"
[class]{}-[func]{nestedForLoop}
```
=== "TS"
```typescript title="iteration.ts"
[class]{}-[func]{nestedForLoop}
```
=== "C"
```c title="iteration.c"
[class]{}-[func]{nestedForLoop}
```
=== "C#"
```csharp title="iteration.cs"
[class]{iteration}-[func]{nestedForLoop}
```
=== "Swift"
```swift title="iteration.swift"
[class]{}-[func]{nestedForLoop}
```
=== "Zig"
```zig title="iteration.zig"
[class]{}-[func]{nestedForLoop}
```
=== "Dart"
```dart title="iteration.dart"
[class]{}-[func]{nestedForLoop}
```
=== "Rust"
```rust title="iteration.rs"
[class]{}-[func]{nested_for_loop}
```
图 2-2 给出了该嵌套循环的流程框图。
![嵌套循环的流程框图](iteration_and_recursion.assets/nested_iteration.png)
<p align="center"> 图 2-2 &nbsp; 嵌套循环的流程框图 </p>
在这种情况下,函数的操作数量与 $n^2$ 成正比,或者说算法运行时间和输入数据大小 $n$ 成“平方关系”。
我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”、“四次方关系”、以此类推。
## 2.2.2 &nbsp; 递归
「递归 recursion」是一种算法策略通过函数调用自身来解决问题。它主要包含两个阶段。
1. **递**:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
2. **归**:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
而从实现的角度看,递归代码主要包含三个要素。
1. **终止条件**:用于决定什么时候由“递”转“归”。
2. **递归调用**:对应“递”,函数调用自身,通常输入更小或更简化的参数。
3. **返回结果**:对应“归”,将当前递归层级的结果返回至上一层。
观察以下代码,我们只需调用函数 `recur(n)` ,就可以完成 $1 + 2 + \dots + n$ 的计算:
=== "Java"
```java title="recursion.java"
/* 递归 */
int recur(int n) {
// 终止条件
if (n == 1)
return 1;
// 递:递归调用
int res = recur(n - 1);
// 归:返回结果
return n + res;
}
```
=== "C++"
```cpp title="recursion.cpp"
/* 递归 */
int recur(int n) {
// 终止条件
if (n == 1)
return 1;
// 递:递归调用
int res = recur(n - 1);
// 归:返回结果
return n + res;
}
```
=== "Python"
```python title="recursion.py"
def recur(n: int) -> int:
"""递归"""
# 终止条件
if n == 1:
return 1
# 递:递归调用
res = recur(n - 1)
# 归:返回结果
return n + res
```
=== "Go"
```go title="recursion.go"
[class]{}-[func]{recur}
```
=== "JS"
```javascript title="recursion.js"
[class]{}-[func]{recur}
```
=== "TS"
```typescript title="recursion.ts"
[class]{}-[func]{recur}
```
=== "C"
```c title="recursion.c"
[class]{}-[func]{recur}
```
=== "C#"
```csharp title="recursion.cs"
[class]{recursion}-[func]{recur}
```
=== "Swift"
```swift title="recursion.swift"
[class]{}-[func]{recur}
```
=== "Zig"
```zig title="recursion.zig"
[class]{}-[func]{recur}
```
=== "Dart"
```dart title="recursion.dart"
[class]{}-[func]{recur}
```
=== "Rust"
```rust title="recursion.rs"
[class]{}-[func]{recur}
```
图 2-3 展示了该函数的递归过程。
![求和函数的递归过程](iteration_and_recursion.assets/recursion_sum.png)
<p align="center"> 图 2-3 &nbsp; 求和函数的递归过程 </p>
虽然从计算角度看,迭代与递归可以得到相同的结果,**但它们代表了两种完全不同的思考和解决问题的范式**。
- **迭代**:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- **递归**:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
以上述的求和函数为例,设问题 $f(n) = 1 + 2 + \dots + n$ 。
- **迭代**:在循环中模拟求和过程,从 $1$ 遍历到 $n$ ,每轮执行求和操作,即可求得 $f(n)$ 。
- **递归**:将问题分解为子问题 $f(n) = n + f(n-1)$ ,不断(递归地)分解下去,直至基本情况 $f(0) = 0$ 时终止。
### 1. &nbsp; 调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,**递归通常比迭代更加耗费内存空间**。
- 递归调用函数会产生额外的开销。**因此递归通常比循环的时间效率更低**。
如图 2-4 所示,在触发终止条件前,同时存在 $n$ 个未返回的递归函数,**递归深度为 $n$** 。
![递归调用深度](iteration_and_recursion.assets/recursion_sum_depth.png)
<p align="center"> 图 2-4 &nbsp; 递归调用深度 </p>
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出报错。
### 2. &nbsp; 尾递归
有趣的是,**如果函数在返回前的最后一步才进行递归调用**,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为「尾递归 tail recursion」。
- **普通递归**:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- **尾递归**:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无需继续执行其他操作,因此系统无需保存上一层函数的上下文。
以计算 $1 + 2 + \dots + n$ 为例,我们可以将结果变量 `res` 设为函数参数,从而实现尾递归。
=== "Java"
```java title="recursion.java"
/* 尾递归 */
int tailRecur(int n, int res) {
// 终止条件
if (n == 0)
return res;
// 尾递归调用
return tailRecur(n - 1, res + n);
}
```
=== "C++"
```cpp title="recursion.cpp"
/* 尾递归 */
int tailRecur(int n, int res) {
// 终止条件
if (n == 0)
return res;
// 尾递归调用
return tailRecur(n - 1, res + n);
}
```
=== "Python"
```python title="recursion.py"
def tail_recur(n, res):
"""尾递归"""
# 终止条件
if n == 0:
return res
# 尾递归调用
return tail_recur(n - 1, res + n)
```
=== "Go"
```go title="recursion.go"
[class]{}-[func]{tailRecur}
```
=== "JS"
```javascript title="recursion.js"
[class]{}-[func]{tailRecur}
```
=== "TS"
```typescript title="recursion.ts"
[class]{}-[func]{tailRecur}
```
=== "C"
```c title="recursion.c"
[class]{}-[func]{tailRecur}
```
=== "C#"
```csharp title="recursion.cs"
[class]{recursion}-[func]{tailRecur}
```
=== "Swift"
```swift title="recursion.swift"
[class]{}-[func]{tailRecur}
```
=== "Zig"
```zig title="recursion.zig"
[class]{}-[func]{tailRecur}
```
=== "Dart"
```dart title="recursion.dart"
[class]{}-[func]{tailRecur}
```
=== "Rust"
```rust title="recursion.rs"
[class]{}-[func]{tail_recur}
```
两种递归的过程对比如图 2-5 所示。
- **普通递归**:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
- **尾递归**:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
![尾递归过程](iteration_and_recursion.assets/tail_recursion_sum.png)
<p align="center"> 图 2-5 &nbsp; 尾递归过程 </p>
请注意许多编译器或解释器并不支持尾递归优化。例如Python 默认不支持尾递归优化,因此即使函数是尾递归形式,但仍然可能会遇到栈溢出问题。
### 3. &nbsp; 递归树
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。
!!! question
给定一个斐波那契数列 $0, 1, 1, 2, 3, 5, 8, 13, \dots$ ,求该数列的第 $n$ 个数字。
设斐波那契数列的第 $n$ 个数字为 $f(n)$ ,易得两个结论。
- 数列的前两个数字为 $f(1) = 0$ 和 $f(2) = 1$ 。
- 数列中的每个数字是前两个数字的和,即 $f(n) = f(n - 1) + f(n - 2)$ 。
按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 `fib(n)` 即可得到斐波那契数列的第 $n$ 个数字。
=== "Java"
```java title="recursion.java"
/* 斐波那契数列:递归 */
int fib(int n) {
// 终止条件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 递归调用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回结果 f(n)
return res;
}
```
=== "C++"
```cpp title="recursion.cpp"
/* 斐波那契数列:递归 */
int fib(int n) {
// 终止条件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 递归调用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回结果 f(n)
return res;
}
```
=== "Python"
```python title="recursion.py"
def fib(n: int) -> int:
"""斐波那契数列:递归"""
# 终止条件 f(1) = 0, f(2) = 1
if n == 1 or n == 2:
return n - 1
# 递归调用 f(n) = f(n-1) + f(n-2)
res = fib(n - 1) + fib(n - 2)
# 返回结果 f(n)
return res
```
=== "Go"
```go title="recursion.go"
[class]{}-[func]{fib}
```
=== "JS"
```javascript title="recursion.js"
[class]{}-[func]{fib}
```
=== "TS"
```typescript title="recursion.ts"
[class]{}-[func]{fib}
```
=== "C"
```c title="recursion.c"
[class]{}-[func]{fib}
```
=== "C#"
```csharp title="recursion.cs"
[class]{recursion}-[func]{fib}
```
=== "Swift"
```swift title="recursion.swift"
[class]{}-[func]{fib}
```
=== "Zig"
```zig title="recursion.zig"
[class]{}-[func]{fib}
```
=== "Dart"
```dart title="recursion.dart"
[class]{}-[func]{fib}
```
=== "Rust"
```rust title="recursion.rs"
[class]{}-[func]{fib}
```
观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如图 2-6 所示,这样不断递归调用下去,最终将产生一个层数为 $n$ 的「递归树 recursion tree」。
![斐波那契数列的递归树](iteration_and_recursion.assets/recursion_tree.png)
<p align="center"> 图 2-6 &nbsp; 斐波那契数列的递归树 </p>
本质上看,递归体现“将问题分解为更小子问题”的思维范式,这种分治策略是至关重要的。
- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略都直接或间接地应用这种思维方式。
- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。

View File

@ -9,14 +9,14 @@ comments: true
1. **找到问题解法**:算法需要在规定的输入范围内,可靠地求得问题的正确解。
2. **寻求最优解法**:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
因此,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。
也就是说,在能够解决问题的前提下,算法效率是衡量算法优劣的主要评价指标,它包括以下两个维度。
- **时间效率**:算法运行速度的快慢。
- **空间效率**:算法占用内存空间的大小。
简而言之,**我们的目标是设计“既快又省”的数据结构与算法**。而有效地评估算法效率至关重要,因为只有这样我们才能将各种算法进行对比,从而指导算法设计与优化过程。
效率评估方法主要分为两种:实际测试理论估算。
效率评估方法主要分为两种:实际测试理论估算。
## 2.1.1 &nbsp; 实际测试
@ -30,20 +30,23 @@ comments: true
由于实际测试具有较大的局限性,我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为「渐近复杂度分析 asymptotic complexity analysis」简称「复杂度分析」。
复杂度分析评估的是算法运行所需的时间和空间资源,**它描述了随着输入数据大小的增加,算法所需时间(空间)的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解。
复杂度分析体现算法运行所需的时间(空间)资源与输入数据大小之间的关系。**它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势**。这个定义有些拗口,我们可以将其分为三个重点来理解。
1. “时间和空间资源”分别对应「时间复杂度 time complexity」和「空间复杂度 space complexity」。
2. “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
3. “增长趋势”表示复杂度分析关注的是算法时间与空间的增长趋势,而非具体的运行时间或占用空间
- “时间和空间资源”分别对应「时间复杂度 time complexity」和「空间复杂度 space complexity」。
- “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
- “时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的“快慢”
**复杂度分析克服了实际测试方法的弊端**。首先,它独立于测试环境,分析结果适用于所有运行平台。其次,它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能
**复杂度分析克服了实际测试方法的弊端**,体现在以下两个方面
如果你对复杂度分析的概念仍感到困惑,无须担心,我们会在后续章节中详细介绍。
- 它独立于测试环境,分析结果适用于所有运行平台。
- 它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
## 2.1.3 &nbsp; 复杂度的重要性
!!! tip
复杂度分析为我们提供了一把评估算法效率的“标尺”,帮助我们衡量了执行某个算法所需的时间和空间资源,并使我们能够对比不同算法之间的效率
如果你仍对复杂度的概念感到困惑,无须担心,我们会在后续章节中详细介绍
复杂度是个数学概念,对于初学者可能比较抽象,学习难度相对较高。从这个角度看,复杂度分析可能不太适合作为第 1 章的内容
复杂度分析为我们提供了一把评估算法效率的“标尺”,使我们可以衡量执行某个算法所需的时间和空间资源,对比不同算法之间的效率
然而,当我们讨论某个数据结构或算法的特点时,难以避免要分析其运行速度和空间使用情况。因此,在深入学习数据结构与算法之前,**建议你先对复杂度建立初步的了解,以便能够完成简单算法的复杂度分析**。
复杂度是个数学概念,对于初学者可能比较抽象,学习难度相对较高。从这个角度看,复杂度分析可能不太适合作为最先介绍的内容。然而,当我们讨论某个数据结构或算法的特点时,难以避免要分析其运行速度和空间使用情况。
综上所述,建议你在深入学习数据结构与算法之前,**先对复杂度分析建立初步的了解,以便能够完成简单算法的复杂度分析**。

View File

@ -2,11 +2,11 @@
comments: true
---
# 2.3 &nbsp; 空间复杂度
# 2.4 &nbsp; 空间复杂度
「空间复杂度 space complexity」用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似只需将“运行时间”替换为“占用内存空间”。
## 2.3.1 &nbsp; 算法相关空间
## 2.4.1 &nbsp; 算法相关空间
算法在运行过程中使用的内存空间主要包括以下几种。
@ -26,7 +26,7 @@ comments: true
![算法使用的相关空间](space_complexity.assets/space_types.png)
<p align="center"> 图 2-9 &nbsp; 算法使用的相关空间 </p>
<p align="center"> 图 2-15 &nbsp; 算法使用的相关空间 </p>
=== "Java"
@ -292,7 +292,7 @@ comments: true
```
## 2.3.2 &nbsp; 推算方法
## 2.4.2 &nbsp; 推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。
@ -656,9 +656,9 @@ comments: true
```
## 2.3.3 &nbsp; 常见类型
## 2.4.3 &nbsp; 常见类型
设输入数据大小为 $n$ ,图 2-10 展示了常见的空间复杂度类型(从低到高排列)。
设输入数据大小为 $n$ ,图 2-16 展示了常见的空间复杂度类型(从低到高排列)。
$$
\begin{aligned}
@ -669,7 +669,7 @@ $$
![常见的空间复杂度类型](space_complexity.assets/space_complexity_common_types.png)
<p align="center"> 图 2-10 &nbsp; 常见的空间复杂度类型 </p>
<p align="center"> 图 2-16 &nbsp; 常见的空间复杂度类型 </p>
!!! tip
@ -1274,7 +1274,7 @@ $$
}
```
如图 2-11 所示,此函数的递归深度为 $n$ ,即同时存在 $n$ 个未返回的 `linear_recur()` 函数,使用 $O(n)$ 大小的栈帧空间:
如图 2-17 所示,此函数的递归深度为 $n$ ,即同时存在 $n$ 个未返回的 `linear_recur()` 函数,使用 $O(n)$ 大小的栈帧空间:
=== "Java"
@ -1417,7 +1417,7 @@ $$
![递归函数产生的线性阶空间复杂度](space_complexity.assets/space_complexity_recursive_linear.png)
<p align="center"> 图 2-11 &nbsp; 递归函数产生的线性阶空间复杂度 </p>
<p align="center"> 图 2-17 &nbsp; 递归函数产生的线性阶空间复杂度 </p>
### 3. &nbsp; 平方阶 $O(n^2)$
@ -1635,7 +1635,7 @@ $$
}
```
如图 2-12 所示,该函数的递归深度为 $n$ ,在每个递归函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $n / 2$ ,因此总体占用 $O(n^2)$ 空间:
如图 2-18 所示,该函数的递归深度为 $n$ ,在每个递归函数中都初始化了一个数组,长度分别为 $n, n-1, n-2, ..., 2, 1$ ,平均长度为 $n / 2$ ,因此总体占用 $O(n^2)$ 空间:
=== "Java"
@ -1796,11 +1796,11 @@ $$
![递归函数产生的平方阶空间复杂度](space_complexity.assets/space_complexity_recursive_quadratic.png)
<p align="center"> 图 2-12 &nbsp; 递归函数产生的平方阶空间复杂度 </p>
<p align="center"> 图 2-18 &nbsp; 递归函数产生的平方阶空间复杂度 </p>
### 4. &nbsp; 指数阶 $O(2^n)$
指数阶常见于二叉树。观察图 2-13 ,高度为 $n$ 的“满二叉树”的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间:
指数阶常见于二叉树。观察图 2-19 ,高度为 $n$ 的“满二叉树”的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间:
=== "Java"
@ -1968,7 +1968,7 @@ $$
![满二叉树产生的指数阶空间复杂度](space_complexity.assets/space_complexity_exponential.png)
<p align="center"> 图 2-13 &nbsp; 满二叉树产生的指数阶空间复杂度 </p>
<p align="center"> 图 2-19 &nbsp; 满二叉树产生的指数阶空间复杂度 </p>
### 5. &nbsp; 对数阶 $O(\log n)$
@ -1976,7 +1976,7 @@ $$
再例如将数字转化为字符串,输入一个正整数 $n$ ,它的位数为 $\log_{10} n + 1$ ,即对应字符串长度为 $\log_{10} n + 1$ ,因此空间复杂度为 $O(\log_{10} n + 1) = O(\log n)$ 。
## 2.3.4 &nbsp; 权衡时间与空间
## 2.4.4 &nbsp; 权衡时间与空间
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。

View File

@ -2,7 +2,7 @@
comments: true
---
# 2.4 &nbsp; 小结
# 2.5 &nbsp; 小结
**算法效率评估**
@ -26,7 +26,7 @@ comments: true
- 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时间点下的空间复杂度。
- 常见空间复杂度从小到大排列有 $O(1)$ 、$O(\log n)$ 、$O(n)$ 、$O(n^2)$ 、$O(2^n)$ 等。
## 2.4.1 &nbsp; Q & A
## 2.5.1 &nbsp; Q & A
!!! question "尾递归的空间复杂度是 $O(1)$ 吗?"

View File

@ -2,7 +2,7 @@
comments: true
---
# 2.2 &nbsp; 时间复杂度
# 2.3 &nbsp; 时间复杂度
运行时间可以直观且准确地反映算法的效率。如果我们想要准确预估一段代码的运行时间,应该如何操作呢?
@ -189,7 +189,7 @@ $$
但实际上,**统计算法的运行时间既不合理也不现实**。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
## 2.2.1 &nbsp; 统计时间增长趋势
## 2.3.1 &nbsp; 统计时间增长趋势
时间复杂度分析统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。
@ -430,7 +430,7 @@ $$
}
```
图 2-1 展示了以上三个算法函数的时间复杂度。
图 2-7 展示了以上三个算法函数的时间复杂度。
- 算法 `A` 只有 $1$ 个打印操作,算法运行时间不随着 $n$ 增大而增长。我们称此算法的时间复杂度为“常数阶”。
- 算法 `B` 中的打印操作需要循环 $n$ 次,算法运行时间随着 $n$ 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。
@ -438,7 +438,7 @@ $$
![算法 A 、B 和 C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png)
<p align="center"> 图 2-1 &nbsp; 算法 A 、B 和 C 的时间增长趋势 </p>
<p align="center"> 图 2-7 &nbsp; 算法 A 、B 和 C 的时间增长趋势 </p>
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
@ -446,7 +446,7 @@ $$
- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作的运行时间的统计”简化为“计算操作的数量的统计”,这样以来估算难度就大大降低了。
- **时间复杂度也存在一定的局限性**。例如,尽管算法 `A``C` 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 `B` 的时间复杂度比 `C` 高,但在输入数据大小 $n$ 较小时,算法 `B` 明显优于算法 `C` 。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
## 2.2.2 &nbsp; 函数渐近上界
## 2.3.2 &nbsp; 函数渐近上界
给定一个输入大小为 $n$ 的函数:
@ -632,13 +632,13 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
T(n) = O(f(n))
$$
如图 2-2 所示,计算渐近上界就是寻找一个函数 $f(n)$ ,使得当 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别,仅相差一个常数项 $c$ 的倍数。
如图 2-8 所示,计算渐近上界就是寻找一个函数 $f(n)$ ,使得当 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别,仅相差一个常数项 $c$ 的倍数。
![函数的渐近上界](time_complexity.assets/asymptotic_upper_bound.png)
<p align="center"> 图 2-2 &nbsp; 函数的渐近上界 </p>
<p align="center"> 图 2-8 &nbsp; 函数的渐近上界 </p>
## 2.2.3 &nbsp; 推算方法
## 2.3.3 &nbsp; 推算方法
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。因为在实际使用中,我们只需要掌握推算方法,数学意义就可以逐渐领悟。
@ -895,9 +895,9 @@ $$
</div>
## 2.2.4 &nbsp; 常见类型
## 2.3.4 &nbsp; 常见类型
设输入数据大小为 $n$ ,常见的时间复杂度类型如图 2-3 所示(按照从低到高的顺序排列)。
设输入数据大小为 $n$ ,常见的时间复杂度类型如图 2-9 所示(按照从低到高的顺序排列)。
$$
\begin{aligned}
@ -908,7 +908,7 @@ $$
![常见的时间复杂度类型](time_complexity.assets/time_complexity_common_types.png)
<p align="center"> 图 2-3 &nbsp; 常见的时间复杂度类型 </p>
<p align="center"> 图 2-9 &nbsp; 常见的时间复杂度类型 </p>
!!! tip
@ -1600,11 +1600,11 @@ $$
}
```
图 2-4 对比了常数阶、线性阶和平方阶三种时间复杂度。
图 2-10 对比了常数阶、线性阶和平方阶三种时间复杂度。
![常数阶、线性阶和平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png)
<p align="center"> 图 2-4 &nbsp; 常数阶、线性阶和平方阶的时间复杂度 </p>
<p align="center"> 图 2-10 &nbsp; 常数阶、线性阶和平方阶的时间复杂度 </p>
以冒泡排序为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1, n-2, \dots, 2, 1$ 次,平均为 $n / 2$ 次,因此时间复杂度为 $O((n - 1) n / 2) = O(n^2)$ 。
@ -1884,7 +1884,7 @@ $$
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 $1$ 个细胞,分裂一轮后变为 $2$ 个,分裂两轮后变为 $4$ 个,以此类推,分裂 $n$ 轮后有 $2^n$ 个细胞。
图 2-5 和以下代码模拟了细胞分裂的过程,时间复杂度为 $O(2^n)$ 。
图 2-11 和以下代码模拟了细胞分裂的过程,时间复杂度为 $O(2^n)$ 。
=== "Java"
@ -2110,7 +2110,7 @@ $$
![指数阶的时间复杂度](time_complexity.assets/time_complexity_exponential.png)
<p align="center"> 图 2-5 &nbsp; 指数阶的时间复杂度 </p>
<p align="center"> 图 2-11 &nbsp; 指数阶的时间复杂度 </p>
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 $n$ 次分裂后停止:
@ -2249,7 +2249,7 @@ $$
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。
图 2-6 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$ 。
图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$ 。
=== "Java"
@ -2422,7 +2422,7 @@ $$
![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png)
<p align="center"> 图 2-6 &nbsp; 对数阶的时间复杂度 </p>
<p align="center"> 图 2-12 &nbsp; 对数阶的时间复杂度 </p>
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一个高度为 $\log_2 n$ 的递归树:
@ -2753,11 +2753,11 @@ $$
}
```
图 2-7 展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 $n$ ,树共有 $\log_2 n + 1$ 层,因此时间复杂度为 $O(n \log n)$ 。
图 2-13 展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为 $n$ ,树共有 $\log_2 n + 1$ 层,因此时间复杂度为 $O(n \log n)$ 。
![线性对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic_linear.png)
<p align="center"> 图 2-7 &nbsp; 线性对数阶的时间复杂度 </p>
<p align="center"> 图 2-13 &nbsp; 线性对数阶的时间复杂度 </p>
主流排序算法的时间复杂度通常为 $O(n \log n)$ ,例如快速排序、归并排序、堆排序等。
@ -2769,7 +2769,7 @@ $$
n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1
$$
阶乘通常使用递归实现。如图 2-8 和以下代码所示,第一层分裂出 $n$ 个,第二层分裂出 $n - 1$ 个,以此类推,直至第 $n$ 层时停止分裂:
阶乘通常使用递归实现。如图 2-14 和以下代码所示,第一层分裂出 $n$ 个,第二层分裂出 $n - 1$ 个,以此类推,直至第 $n$ 层时停止分裂:
=== "Java"
@ -2961,11 +2961,11 @@ $$
![阶乘阶的时间复杂度](time_complexity.assets/time_complexity_factorial.png)
<p align="center"> 图 2-8 &nbsp; 阶乘阶的时间复杂度 </p>
<p align="center"> 图 2-14 &nbsp; 阶乘阶的时间复杂度 </p>
请注意,因为当 $n \geq 4$ 时恒有 $n! > 2^n$ ,所以阶乘阶比指数阶增长得更快,在 $n$ 较大时也是不可接受的。
## 2.2.5 &nbsp; 最差、最佳、平均时间复杂度
## 2.3.5 &nbsp; 最差、最佳、平均时间复杂度
**算法的时间效率往往不是固定的,而是与输入数据的分布有关**。假设输入一个长度为 $n$ 的数组 `nums` ,其中 `nums` 由从 $1$ 至 $n$ 的数字组成,每个数字只出现一次,但元素顺序是随机打乱的,任务目标是返回元素 $1$ 的索引。我们可以得出以下结论。

View File

@ -1307,7 +1307,7 @@ $$
// 若超过背包容量,则不选硬币 i
dp[i][a] = dp[i-1][a]
} else {
// 不选和选硬币 i 这两种方案的较小值
// 不选和选硬币 i 这两种方案之和
dp[i][a] = dp[i-1][a] + dp[i][a-coins[i-1]]
}
}

View File

@ -69,7 +69,17 @@ status: new
=== "Go"
```go title="binary_search_edge.go"
[class]{}-[func]{binarySearchLeftEdge}
/* 二分查找最左一个 target */
func binarySearchLeftEdge(nums []int, target int) int {
// 等价于查找 target 的插入点
i := binarySearchInsertion(nums, target)
// 未找到 target ,返回 -1
if i == len(nums) || nums[i] != target {
return -1
}
// 找到 target ,返回索引 i
return i
}
```
=== "JS"
@ -223,7 +233,19 @@ status: new
=== "Go"
```go title="binary_search_edge.go"
[class]{}-[func]{binarySearchRightEdge}
/* 二分查找最右一个 target */
func binarySearchRightEdge(nums []int, target int) int {
// 转化为查找最左一个 target + 1
i := binarySearchInsertion(nums, target+1)
// j 指向最右一个 target i 指向首个大于 target 的元素
j := i - 1
// 未找到 target ,返回 -1
if j == -1 || nums[j] != target {
return -1
}
// 找到 target ,返回索引 j
return j
}
```
=== "JS"

View File

@ -92,7 +92,27 @@ status: new
=== "Go"
```go title="binary_search_insertion.go"
[class]{}-[func]{binarySearchInsertionSimple}
/* 二分查找插入点(无重复元素) */
func binarySearchInsertionSimple(nums []int, target int) int {
// 初始化双闭区间 [0, n-1]
i, j := 0, len(nums)-1
for i <= j {
// 计算中点索引 m
m := i + (j-i)/2
if nums[m] < target {
// target 在区间 [m+1, j] 中
i = m + 1
} else if nums[m] > target {
// target 在区间 [i, m-1] 中
j = m - 1
} else {
// 找到 target ,返回插入点 m
return m
}
}
// 未找到 target ,返回插入点 i
return i
}
```
=== "JS"
@ -307,7 +327,27 @@ status: new
=== "Go"
```go title="binary_search_insertion.go"
[class]{}-[func]{binarySearchInsertion}
/* 二分查找插入点(存在重复元素) */
func binarySearchInsertion(nums []int, target int) int {
// 初始化双闭区间 [0, n-1]
i, j := 0, len(nums)-1
for i <= j {
// 计算中点索引 m
m := i + (j-i)/2
if nums[m] < target {
// target 在区间 [m+1, j] 中
i = m + 1
} else if nums[m] > target {
// target 在区间 [i, m-1] 中
j = m - 1
} else {
// 首个小于 target 的元素在区间 [i, m-1] 中
j = m - 1
}
}
// 返回插入点 i
return i
}
```
=== "JS"

View File

@ -92,7 +92,7 @@ hide:
<h3 align="left"> 作者简介 </h3>
靳宇栋 ([Krahets](https://leetcode.cn/u/jyd/))大厂高级算法工程师上海交通大学硕士。力扣LeetCode全网阅读量最高博主其 LeetBook《图解算法数据结构》已被订阅 24 万本。
靳宇栋 ([Krahets](https://leetcode.cn/u/jyd/))大厂高级算法工程师上海交通大学硕士。力扣LeetCode全网阅读量最高博主其 LeetBook《图解算法数据结构》已被订阅 26 万本。
---