From c6f0dfbdc8d57dc4d71cd49a74cd29ad8896c337 Mon Sep 17 00:00:00 2001 From: krahets Date: Thu, 24 Aug 2023 17:47:18 +0800 Subject: [PATCH] build --- chapter_computational_complexity/index.md | 7 +- .../iteration_and_recursion.md | 833 ++++++++++++++++++ .../performance_evaluation.md | 27 +- .../space_complexity.md | 28 +- chapter_computational_complexity/summary.md | 4 +- .../time_complexity.md | 44 +- .../unbounded_knapsack_problem.md | 2 +- chapter_searching/binary_search_edge.md | 26 +- chapter_searching/binary_search_insertion.md | 44 +- index.md | 2 +- 10 files changed, 958 insertions(+), 59 deletions(-) create mode 100644 chapter_computational_complexity/iteration_and_recursion.md diff --git a/chapter_computational_complexity/index.md b/chapter_computational_complexity/index.md index f3dc24ac1..a3d3bcc1c 100644 --- a/chapter_computational_complexity/index.md +++ b/chapter_computational_complexity/index.md @@ -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/) diff --git a/chapter_computational_complexity/iteration_and_recursion.md b/chapter_computational_complexity/iteration_and_recursion.md new file mode 100644 index 000000000..8b7e045a5 --- /dev/null +++ b/chapter_computational_complexity/iteration_and_recursion.md @@ -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) + +

图 2-1   求和函数的流程框图

+ +此求和函数的操作数量与输入数据大小 $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 给出了该嵌套循环的流程框图。 + +![嵌套循环的流程框图](iteration_and_recursion.assets/nested_iteration.png) + +

图 2-2   嵌套循环的流程框图

+ +在这种情况下,函数的操作数量与 $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 展示了该函数的递归过程。 + +![求和函数的递归过程](iteration_and_recursion.assets/recursion_sum.png) + +

图 2-3   求和函数的递归过程

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

图 2-4   递归调用深度

+ +在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出报错。 + +### 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 所示。 + +- **普通递归**:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。 +- **尾递归**:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。 + +![尾递归过程](iteration_and_recursion.assets/tail_recursion_sum.png) + +

图 2-5   尾递归过程

+ +请注意,许多编译器或解释器并不支持尾递归优化。例如,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」。 + +![斐波那契数列的递归树](iteration_and_recursion.assets/recursion_tree.png) + +

图 2-6   斐波那契数列的递归树

+ +本质上看,递归体现“将问题分解为更小子问题”的思维范式,这种分治策略是至关重要的。 + +- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略都直接或间接地应用这种思维方式。 +- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。 diff --git a/chapter_computational_complexity/performance_evaluation.md b/chapter_computational_complexity/performance_evaluation.md index 500bed562..2f5ec2f23 100644 --- a/chapter_computational_complexity/performance_evaluation.md +++ b/chapter_computational_complexity/performance_evaluation.md @@ -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 章的内容。 +复杂度分析为我们提供了一把评估算法效率的“标尺”,使我们可以衡量执行某个算法所需的时间和空间资源,对比不同算法之间的效率。 -然而,当我们讨论某个数据结构或算法的特点时,难以避免要分析其运行速度和空间使用情况。因此,在深入学习数据结构与算法之前,**建议你先对复杂度建立初步的了解,以便能够完成简单算法的复杂度分析**。 +复杂度是个数学概念,对于初学者可能比较抽象,学习难度相对较高。从这个角度看,复杂度分析可能不太适合作为最先介绍的内容。然而,当我们讨论某个数据结构或算法的特点时,难以避免要分析其运行速度和空间使用情况。 + +综上所述,建议你在深入学习数据结构与算法之前,**先对复杂度分析建立初步的了解,以便能够完成简单算法的复杂度分析**。 diff --git a/chapter_computational_complexity/space_complexity.md b/chapter_computational_complexity/space_complexity.md index 0812c2d67..93e7e93a5 100755 --- a/chapter_computational_complexity/space_complexity.md +++ b/chapter_computational_complexity/space_complexity.md @@ -2,11 +2,11 @@ comments: true --- -# 2.3   空间复杂度 +# 2.4   空间复杂度 「空间复杂度 space complexity」用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。 -## 2.3.1   算法相关空间 +## 2.4.1   算法相关空间 算法在运行过程中使用的内存空间主要包括以下几种。 @@ -26,7 +26,7 @@ comments: true ![算法使用的相关空间](space_complexity.assets/space_types.png) -

图 2-9   算法使用的相关空间

+

图 2-15   算法使用的相关空间

=== "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 @@ $$ ![常见的空间复杂度类型](space_complexity.assets/space_complexity_common_types.png) -

图 2-10   常见的空间复杂度类型

+

图 2-16   常见的空间复杂度类型

!!! 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) -

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

+

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

### 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 @@ $$ ![递归函数产生的平方阶空间复杂度](space_complexity.assets/space_complexity_recursive_quadratic.png) -

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

+

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

### 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 @@ $$ ![满二叉树产生的指数阶空间复杂度](space_complexity.assets/space_complexity_exponential.png) -

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

+

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

### 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   权衡时间与空间 理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常是非常困难的。 diff --git a/chapter_computational_complexity/summary.md b/chapter_computational_complexity/summary.md index a138dc5da..1d430a391 100644 --- a/chapter_computational_complexity/summary.md +++ b/chapter_computational_complexity/summary.md @@ -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)$ 吗?" diff --git a/chapter_computational_complexity/time_complexity.md b/chapter_computational_complexity/time_complexity.md index 77e738bea..f4b1f45e0 100755 --- a/chapter_computational_complexity/time_complexity.md +++ b/chapter_computational_complexity/time_complexity.md @@ -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 @@ $$ ![算法 A 、B 和 C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png) -

图 2-1   算法 A 、B 和 C 的时间增长趋势

+

图 2-7   算法 A 、B 和 C 的时间增长趋势

相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢? @@ -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$ 的倍数。 ![函数的渐近上界](time_complexity.assets/asymptotic_upper_bound.png) -

图 2-2   函数的渐近上界

+

图 2-8   函数的渐近上界

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

图 2-3   常见的时间复杂度类型

+

图 2-9   常见的时间复杂度类型

!!! tip @@ -1600,11 +1600,11 @@ $$ } ``` -图 2-4 对比了常数阶、线性阶和平方阶三种时间复杂度。 +图 2-10 对比了常数阶、线性阶和平方阶三种时间复杂度。 ![常数阶、线性阶和平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png) -

图 2-4   常数阶、线性阶和平方阶的时间复杂度

+

图 2-10   常数阶、线性阶和平方阶的时间复杂度

以冒泡排序为例,外层循环执行 $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) -

图 2-5   指数阶的时间复杂度

+

图 2-11   指数阶的时间复杂度

在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 $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) -

图 2-6   对数阶的时间复杂度

+

图 2-12   对数阶的时间复杂度

与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一个高度为 $\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) -

图 2-7   线性对数阶的时间复杂度

+

图 2-13   线性对数阶的时间复杂度

主流排序算法的时间复杂度通常为 $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) -

图 2-8   阶乘阶的时间复杂度

+

图 2-14   阶乘阶的时间复杂度

请注意,因为当 $n \geq 4$ 时恒有 $n! > 2^n$ ,所以阶乘阶比指数阶增长得更快,在 $n$ 较大时也是不可接受的。 -## 2.2.5   最差、最佳、平均时间复杂度 +## 2.3.5   最差、最佳、平均时间复杂度 **算法的时间效率往往不是固定的,而是与输入数据的分布有关**。假设输入一个长度为 $n$ 的数组 `nums` ,其中 `nums` 由从 $1$ 至 $n$ 的数字组成,每个数字只出现一次,但元素顺序是随机打乱的,任务目标是返回元素 $1$ 的索引。我们可以得出以下结论。 diff --git a/chapter_dynamic_programming/unbounded_knapsack_problem.md b/chapter_dynamic_programming/unbounded_knapsack_problem.md index 4f6c1e59f..2908565e8 100644 --- a/chapter_dynamic_programming/unbounded_knapsack_problem.md +++ b/chapter_dynamic_programming/unbounded_knapsack_problem.md @@ -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]] } } diff --git a/chapter_searching/binary_search_edge.md b/chapter_searching/binary_search_edge.md index 997aaa9e8..6b9464f1b 100644 --- a/chapter_searching/binary_search_edge.md +++ b/chapter_searching/binary_search_edge.md @@ -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" diff --git a/chapter_searching/binary_search_insertion.md b/chapter_searching/binary_search_insertion.md index e5b243a20..99fe000f6 100644 --- a/chapter_searching/binary_search_insertion.md +++ b/chapter_searching/binary_search_insertion.md @@ -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" diff --git a/index.md b/index.md index bacf155ff..81a3af132 100644 --- a/index.md +++ b/index.md @@ -92,7 +92,7 @@ hide:

作者简介

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