build
This commit is contained in:
parent
92a0853ab8
commit
c6f0dfbdc8
@ -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/)
|
||||
|
||||
833
chapter_computational_complexity/iteration_and_recursion.md
Normal file
833
chapter_computational_complexity/iteration_and_recursion.md
Normal 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 展示了该求和函数的流程框图。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-1 求和函数的流程框图 </p>
|
||||
|
||||
此求和函数的操作数量与输入数据大小 $n$ 成正比,或者说成“线性关系”。实际上,**时间复杂度描述的就是这个“线性关系”**。相关内容将会在下一节中详细介绍。
|
||||
|
||||
### 2. 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. 嵌套循环
|
||||
|
||||
我们可以在一个循环结构内嵌套另一个循环结构,以 `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 给出了该嵌套循环的流程框图。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-2 嵌套循环的流程框图 </p>
|
||||
|
||||
在这种情况下,函数的操作数量与 $n^2$ 成正比,或者说算法运行时间和输入数据大小 $n$ 成“平方关系”。
|
||||
|
||||
我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”、“四次方关系”、以此类推。
|
||||
|
||||
## 2.2.2 递归
|
||||
|
||||
「递归 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 展示了该函数的递归过程。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-3 求和函数的递归过程 </p>
|
||||
|
||||
虽然从计算角度看,迭代与递归可以得到相同的结果,**但它们代表了两种完全不同的思考和解决问题的范式**。
|
||||
|
||||
- **迭代**:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
|
||||
- **递归**:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
|
||||
|
||||
以上述的求和函数为例,设问题 $f(n) = 1 + 2 + \dots + n$ 。
|
||||
|
||||
- **迭代**:在循环中模拟求和过程,从 $1$ 遍历到 $n$ ,每轮执行求和操作,即可求得 $f(n)$ 。
|
||||
- **递归**:将问题分解为子问题 $f(n) = n + f(n-1)$ ,不断(递归地)分解下去,直至基本情况 $f(0) = 0$ 时终止。
|
||||
|
||||
### 1. 调用栈
|
||||
|
||||
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
|
||||
|
||||
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,**递归通常比迭代更加耗费内存空间**。
|
||||
- 递归调用函数会产生额外的开销。**因此递归通常比循环的时间效率更低**。
|
||||
|
||||
如图 2-4 所示,在触发终止条件前,同时存在 $n$ 个未返回的递归函数,**递归深度为 $n$** 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-4 递归调用深度 </p>
|
||||
|
||||
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出报错。
|
||||
|
||||
### 2. 尾递归
|
||||
|
||||
有趣的是,**如果函数在返回前的最后一步才进行递归调用**,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为「尾递归 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 所示。
|
||||
|
||||
- **普通递归**:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
|
||||
- **尾递归**:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-5 尾递归过程 </p>
|
||||
|
||||
请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,但仍然可能会遇到栈溢出问题。
|
||||
|
||||
### 3. 递归树
|
||||
|
||||
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。
|
||||
|
||||
!!! 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」。
|
||||
|
||||

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

|
||||
|
||||
<p align="center"> 图 2-9 算法使用的相关空间 </p>
|
||||
<p align="center"> 图 2-15 算法使用的相关空间 </p>
|
||||
|
||||
=== "Java"
|
||||
|
||||
@ -292,7 +292,7 @@ comments: true
|
||||
|
||||
```
|
||||
|
||||
## 2.3.2 推算方法
|
||||
## 2.4.2 推算方法
|
||||
|
||||
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。
|
||||
|
||||
@ -656,9 +656,9 @@ comments: true
|
||||
|
||||
```
|
||||
|
||||
## 2.3.3 常见类型
|
||||
## 2.4.3 常见类型
|
||||
|
||||
设输入数据大小为 $n$ ,图 2-10 展示了常见的空间复杂度类型(从低到高排列)。
|
||||
设输入数据大小为 $n$ ,图 2-16 展示了常见的空间复杂度类型(从低到高排列)。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@ -669,7 +669,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-10 常见的空间复杂度类型 </p>
|
||||
<p align="center"> 图 2-16 常见的空间复杂度类型 </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 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-11 递归函数产生的线性阶空间复杂度 </p>
|
||||
<p align="center"> 图 2-17 递归函数产生的线性阶空间复杂度 </p>
|
||||
|
||||
### 3. 平方阶 $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 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-12 递归函数产生的平方阶空间复杂度 </p>
|
||||
<p align="center"> 图 2-18 递归函数产生的平方阶空间复杂度 </p>
|
||||
|
||||
### 4. 指数阶 $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 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-13 满二叉树产生的指数阶空间复杂度 </p>
|
||||
<p align="center"> 图 2-19 满二叉树产生的指数阶空间复杂度 </p>
|
||||
|
||||
### 5. 对数阶 $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 权衡时间与空间
|
||||
## 2.4.4 权衡时间与空间
|
||||
|
||||
理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
comments: true
|
||||
---
|
||||
|
||||
# 2.4 小结
|
||||
# 2.5 小结
|
||||
|
||||
**算法效率评估**
|
||||
|
||||
@ -26,7 +26,7 @@ comments: true
|
||||
- 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时间点下的空间复杂度。
|
||||
- 常见空间复杂度从小到大排列有 $O(1)$ 、$O(\log n)$ 、$O(n)$ 、$O(n^2)$ 、$O(2^n)$ 等。
|
||||
|
||||
## 2.4.1 Q & A
|
||||
## 2.5.1 Q & A
|
||||
|
||||
!!! question "尾递归的空间复杂度是 $O(1)$ 吗?"
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
comments: true
|
||||
---
|
||||
|
||||
# 2.2 时间复杂度
|
||||
# 2.3 时间复杂度
|
||||
|
||||
运行时间可以直观且准确地反映算法的效率。如果我们想要准确预估一段代码的运行时间,应该如何操作呢?
|
||||
|
||||
@ -189,7 +189,7 @@ $$
|
||||
|
||||
但实际上,**统计算法的运行时间既不合理也不现实**。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
|
||||
|
||||
## 2.2.1 统计时间增长趋势
|
||||
## 2.3.1 统计时间增长趋势
|
||||
|
||||
时间复杂度分析统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。
|
||||
|
||||
@ -430,7 +430,7 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
图 2-1 展示了以上三个算法函数的时间复杂度。
|
||||
图 2-7 展示了以上三个算法函数的时间复杂度。
|
||||
|
||||
- 算法 `A` 只有 $1$ 个打印操作,算法运行时间不随着 $n$ 增大而增长。我们称此算法的时间复杂度为“常数阶”。
|
||||
- 算法 `B` 中的打印操作需要循环 $n$ 次,算法运行时间随着 $n$ 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。
|
||||
@ -438,7 +438,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-1 算法 A 、B 和 C 的时间增长趋势 </p>
|
||||
<p align="center"> 图 2-7 算法 A 、B 和 C 的时间增长趋势 </p>
|
||||
|
||||
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
|
||||
|
||||
@ -446,7 +446,7 @@ $$
|
||||
- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作的运行时间的统计”简化为“计算操作的数量的统计”,这样以来估算难度就大大降低了。
|
||||
- **时间复杂度也存在一定的局限性**。例如,尽管算法 `A` 和 `C` 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 `B` 的时间复杂度比 `C` 高,但在输入数据大小 $n$ 较小时,算法 `B` 明显优于算法 `C` 。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
|
||||
|
||||
## 2.2.2 函数渐近上界
|
||||
## 2.3.2 函数渐近上界
|
||||
|
||||
给定一个输入大小为 $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$ 的倍数。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-2 函数的渐近上界 </p>
|
||||
<p align="center"> 图 2-8 函数的渐近上界 </p>
|
||||
|
||||
## 2.2.3 推算方法
|
||||
## 2.3.3 推算方法
|
||||
|
||||
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。因为在实际使用中,我们只需要掌握推算方法,数学意义就可以逐渐领悟。
|
||||
|
||||
@ -895,9 +895,9 @@ $$
|
||||
|
||||
</div>
|
||||
|
||||
## 2.2.4 常见类型
|
||||
## 2.3.4 常见类型
|
||||
|
||||
设输入数据大小为 $n$ ,常见的时间复杂度类型如图 2-3 所示(按照从低到高的顺序排列)。
|
||||
设输入数据大小为 $n$ ,常见的时间复杂度类型如图 2-9 所示(按照从低到高的顺序排列)。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@ -908,7 +908,7 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-3 常见的时间复杂度类型 </p>
|
||||
<p align="center"> 图 2-9 常见的时间复杂度类型 </p>
|
||||
|
||||
!!! tip
|
||||
|
||||
@ -1600,11 +1600,11 @@ $$
|
||||
}
|
||||
```
|
||||
|
||||
图 2-4 对比了常数阶、线性阶和平方阶三种时间复杂度。
|
||||
图 2-10 对比了常数阶、线性阶和平方阶三种时间复杂度。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-4 常数阶、线性阶和平方阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-10 常数阶、线性阶和平方阶的时间复杂度 </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 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-5 指数阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-11 指数阶的时间复杂度 </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 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-6 对数阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-12 对数阶的时间复杂度 </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)$ 。
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-7 线性对数阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-13 线性对数阶的时间复杂度 </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 @@ $$
|
||||
|
||||

|
||||
|
||||
<p align="center"> 图 2-8 阶乘阶的时间复杂度 </p>
|
||||
<p align="center"> 图 2-14 阶乘阶的时间复杂度 </p>
|
||||
|
||||
请注意,因为当 $n \geq 4$ 时恒有 $n! > 2^n$ ,所以阶乘阶比指数阶增长得更快,在 $n$ 较大时也是不可接受的。
|
||||
|
||||
## 2.2.5 最差、最佳、平均时间复杂度
|
||||
## 2.3.5 最差、最佳、平均时间复杂度
|
||||
|
||||
**算法的时间效率往往不是固定的,而是与输入数据的分布有关**。假设输入一个长度为 $n$ 的数组 `nums` ,其中 `nums` 由从 $1$ 至 $n$ 的数字组成,每个数字只出现一次,但元素顺序是随机打乱的,任务目标是返回元素 $1$ 的索引。我们可以得出以下结论。
|
||||
|
||||
|
||||
@ -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]]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
2
index.md
2
index.md
@ -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 万本。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user