diff --git a/Contents/09.Algorithm-Base/01.Enumeration-Algorithm/01.Enumeration-Algorithm.md b/Contents/09.Algorithm-Base/01.Enumeration-Algorithm/01.Enumeration-Algorithm.md index 2670cf4e..09631a76 100644 --- a/Contents/09.Algorithm-Base/01.Enumeration-Algorithm/01.Enumeration-Algorithm.md +++ b/Contents/09.Algorithm-Base/01.Enumeration-Algorithm/01.Enumeration-Algorithm.md @@ -2,17 +2,22 @@ > **枚举算法(Enumeration Algorithm)**:也称为穷举算法,指的是按照问题本身的性质,一一列举出该问题所有可能的解,并在逐一列举的过程中,将它们逐一与目标状态进行比较以得出满足问题要求的解。在列举的过程中,既不能遗漏也不能重复。 -枚举算法是设计最简单、最基本的搜索算法,其核心思想是通过列举问题的所有状态,将它们逐一与目标状态进行比较,从而得到满足条件的解。 +枚举算法的核心思想是:通过列举问题的所有状态,将它们逐一与目标状态进行比较,从而得到满足条件的解。 -- **枚举算法的优点**: - - 容易编程实现,也容易调试。 - - 建立在考察大量状态、甚至是穷举所有状态的基础上,所以算法的正确性比较容易证明。 -- **枚举算法的缺点**: - - 效率比较低,不适合求解规模较大的问题。 +由于枚举算法要通过列举问题的所有状态来得到满足条件的解,因此,在问题规模变大时,其效率一般是比较低的。但是枚举算法也有自己特有的优点: + +1. 多数情况下容易编程实现,也容易调试。 +2. 建立在考察大量状态、甚至是穷举所有状态的基础上,所以算法的正确性比较容易证明。 所以,枚举算法通常用于求解问题规模比较小的问题,或者作为求解问题的一个子算法出现,通过枚举一些信息并进行保存,而这些消息的有无对主算法效率的高低有着较大影响。 -## 2. 枚举算法解题思路 +## 2. 枚举算法的解题思路 + +### 2.1 枚举算法的解题思路 + +枚举算法是设计最简单、最基本的搜索算法。是我们在遇到问题时,最应该优先考虑的算法。 + +因为其实现足够简单,所以在遇到问题时,我们往往可以先通过枚举算法尝试解决问题,然后在此基础上,再去考虑其他优化方法和解题思路。 采用枚举算法解题的一般思路如下: @@ -26,6 +31,52 @@ 2. 加强约束条件,缩小枚举范围。 3. 根据某些问题特有的性质,例如对称性等,避免对本质相同的状态重复求解。 +### 2.2 枚举算法的简单应用 + +下面举个著名的例子:「百钱买百鸡问题」。这个问题是我国古代数学家张丘在「算经」一书中提出的。该问题叙述如下: + +> **百钱买百鸡问题**:鸡翁一,值钱五;鸡母一,值钱三;鸡雏三,值钱一;百钱买百鸡,则鸡翁、鸡母、鸡雏各几何? + +翻译一下,意思就是:公鸡一只五块钱,母鸡一只三块钱,小鸡三只一块钱。现在我们用 100ドル$ 块钱买了 100ドル$ 只鸡,问公鸡、母鸡、小鸡各买了多少只? + +下面我们根据算法的一般思路来解决一下这道题。 + +1. 确定枚举对象、枚举范围和判断条件,并判断条件设立的正确性。 + + 1. 确定枚举对象:枚举对象为公鸡、母鸡、小鸡的只数,那么我们可以用变量 $x$、$y$、$z$ 分别来代表公鸡、母鸡、小鸡的只数。 + 2. 确定枚举范围:因为总共买了 100ドル$ 只鸡,所以 0ドル \le x, y, z \le 100,ドル则 $x$、$y$、$z$ 的枚举范围为 $[0, 100]$。 + 3. 确定判断条件:根据题意,我们可以列出两个方程式:5ドル \times x + 3 \times y + \frac{z}{3} = 100,ドル$x + y + z = 100$。在枚举 $x$、$y$、$z$ 的过程中,我们可以根据这两个方程式来判断是否当前状态是否满足题意。 + +2. 一一枚举可能的情况,并验证是否是问题的解。 + + 1. 根据枚举对象、枚举范围和判断条件,我们可以顺利写出对应的代码。 + + ```Python + class Solution: + def buyChicken(self): + for x in range(101): + for y in range(101): + for z in range(101): + if z % 3 == 0 and 5 * x + 3 * y + z // 3 == 100 and x + y + z == 100: + print("公鸡 %s 只,母鸡 %s 只,小鸡 %s 只" % (x, y, z)) + ``` + +3. 考虑提高枚举算法的效率。 + + 1. 在上面的代码中,我们枚举了 $x$、$y$、$z,ドル但其实根据方程式 $x + y + z = 100,ドル得知:$z$ 可以通过 $z = 100 - x - y$ 而得到,这样我们就不用再枚举 $z$ 了。 + 2. 在上面的代码中,对 $x$、$y$ 的枚举范围是 $[0, 100],ドル但其实如果所有钱用来买公鸡,最多只能买 20ドル$ 只,同理,全用来买母鸡,最多只能买 33ドル$ 只。所以对 $x$ 的枚举范围可改为 $[0, 20],ドル$y$ 的枚举范围可改为 $[0, 33]$。 + + ```Python + class Solution: + def buyChicken(self): + for x in range(21): + for y in range(34): + z = 100 - x - y + if z % 3 == 0 and 5 * x + 3 * y + z // 3 == 100: + print("公鸡 %s 只,母鸡 %s 只,小鸡 %s 只" % (x, y, z)) + ``` + + ## 3. 枚举算法的应用 ### 3.1 两数之和 @@ -36,20 +87,44 @@ #### 3.1.2 题目大意 -- **描述**:给定一个整数数组 `nums` 和一个整数目标值 `target`。 -- **要求**:在该数组中找出和为 `target` 的两个整数,并输出这两个整数的下标。 +**描述**:给定一个整数数组 `nums` 和一个整数目标值 `target`。 + +**要求**:在该数组中找出和为 `target` 的两个整数,并输出这两个整数的下标。可以按任意顺序返回答案。 + +**说明**: + +- 2ドル \le nums.length \le 10^4$。 +- $-10^9 \le nums[i] \le 10^9$。 +- $-10^9 \le target \le 10^9$。 +- 只会存在一个有效答案。 + +**示例**: + +- 示例 1: + +```Python +输入:nums = [2,7,11,15], target = 9 +输出:[0,1] +解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。 +``` + +- 示例 2: + +```Python +输入:nums = [3,2,4], target = 6 +输出:[1,2] +``` #### 3.1.3 解题思路 这里说下枚举算法的解题思路。 -使用两重循环枚举数组中每一个数 `nums[i]`、`nums[j]`。判断所有的 `nums[i] + nums[j]` 是否等于 `target`。 - -- 如果出现 `nums[i] + nums[j] == target`,则说明数组中存在和为 `target` 的两个整数,将两个整数的下标 `i`、`j` 输出即可。 +##### 思路 1:枚举算法 -利用两重循环进行枚举的时间复杂度为 $O(n^2)$。 +1. 使用两重循环枚举数组中每一个数 `nums[i]`、`nums[j]`,判断所有的 `nums[i] + nums[j]` 是否等于 `target`。 +2. 如果出现 `nums[i] + nums[j] == target`,则说明数组中存在和为 `target` 的两个整数,将两个整数的下标 `i`、`j` 输出即可。 -#### 3.1.4 代码 +##### 思路 1:代码 ```Python class Solution: @@ -61,6 +136,11 @@ class Solution: return [] ``` +##### 思路 1:复杂度分析 + +- **时间复杂度**:$O(n^2)$ +- **空间复杂度**:$O(1)$。 + ### 3.2 计数质数 #### 3.2.1 题目链接 @@ -69,25 +149,48 @@ class Solution: #### 3.2.2 题目大意 -**描述**:给定 一个非负整数 `n`。 +**描述**:给定 一个非负整数 $n$。 + +**要求**:统计小于 $n$ 的质数数量。 + +**说明**: + +- 0ドル \le n \le 5 * 10^6$。 + +**示例**: -**要求**:统计小于 `n` 的质数数量。 +- 示例 1: + +```Python +输入 n = 10 +输出 4 +解释 小于 10 的质数一共有 4 个, 它们是 2, 3, 5, 7。 +``` + +- 示例 2: + +```Python +输入:n = 1 +输出:0 +``` #### 3.2.3 解题思路 这里说下枚举算法的解题思路(注意:提交会超时,只是讲解一下枚举算法的思路)。 -对于小于 `n` 的每一个数 `x`,我们可以枚举区间 `[2, x - 1]` 上的数是否是 `x` 的因数,即是否存在能被 `x` 整数的数。如果存在,则该数 `x` 不是质数。如果不存在,则该数 `x` 是质数。 +##### 思路 1:枚举算法(超时) + +对于小于 $n$ 的每一个数 $x,ドル我们可以枚举区间 $[2, x - 1]$ 上的数是否是 $x$ 的因数,即是否存在能被 $x$ 整数的数。如果存在,则该数 $x$ 不是质数。如果不存在,则该数 $x$ 是质数。 -这样我们就可以通过枚举 `[2, n - 1]` 上的所有数 `x`,并判断 `x` 是否为质数。 +这样我们就可以通过枚举 $[2, n - 1]$ 上的所有数 $x,ドル并判断 $x$ 是否为质数。 -在遍历枚举的同时,我们维护一个用于统计小于 `n` 的质数数量的变量 `cnt`。如果符合要求,则将计数 `cnt` 加 `1`。最终返回该数目作为答案。 +在遍历枚举的同时,我们维护一个用于统计小于 $n$ 的质数数量的变量 `cnt`。如果符合要求,则将计数 `cnt` 加 1ドル$。最终返回该数目作为答案。 -考虑到如果 `i` 是 `x` 的因数,则 $\frac{x}{i}$ 也必然是 `x` 的因数,则我们只需要检验这两个因数中的较小数即可。而较小数一定会落在 $[2, \sqrt x]$ 上。因此我们在检验 `x` 是否为质数时,只需要枚举 $[2, \sqrt x]$ 中的所有数即可。 +考虑到如果 $i$ 是 $x$ 的因数,则 $\frac{x}{i}$ 也必然是 $x$ 的因数,则我们只需要检验这两个因数中的较小数即可。而较小数一定会落在 $[2, \sqrt x]$ 上。因此我们在检验 $x$ 是否为质数时,只需要枚举 $[2, \sqrt x]$ 中的所有数即可。 -利用枚举算法单次检查单个数的时间复杂度为 $O(\sqrt{n}),ドル检查 `n` 个数的整体时间复杂度为 $O(n \sqrt{n})$。 +利用枚举算法单次检查单个数的时间复杂度为 $O(\sqrt{n}),ドル检查 $n$ 个数的整体时间复杂度为 $O(n \sqrt{n})$。 -#### 3.2.4 代码 +##### 思路 1:代码 ```Python class Solution: @@ -105,6 +208,11 @@ class Solution: return cnt ``` +##### 思路 1:复杂度分析 + +- **时间复杂度**:$O(n \times \sqrt{n})$。 +- **空间复杂度**:$O(1)$。 + ### 3.3 统计平方和三元组的数目 #### 3.3.1 题目链接 @@ -113,27 +221,46 @@ class Solution: #### 3.3.2 题目大意 -**描述**:给你一个整数 `n`。 +**描述**:给你一个整数 $n$。 **要求**:请你返回满足 1ドル \le a, b, c \le n$ 的平方和三元组的数目。 **说明**: -- **平方和三元组**:指的是满足 $a^2 + b^2 = c^2$ 的整数三元组 `(a, b, c)` 。 +- **平方和三元组**:指的是满足 $a^2 + b^2 = c^2$ 的整数三元组 $(a, b, c)$。 +- 1ドル \le n \le 250$。 + +**示例**: + +- 示例 1: + +```Python +输入 n = 5 +输出 2 +解释 平方和三元组为 (3,4,5) 和 (4,3,5)。 +``` + +- 示例 2: + +```Python +输入:n = 10 +输出:4 +解释:平方和三元组为 (3,4,5),(4,3,5),(6,8,10) 和 (8,6,10)。 +``` #### 3.3.3 解题思路 -枚举算法。 +##### 思路 1:枚举算法 -我们可以在 `[1, n]` 区间中枚举整数三元组 `(a, b, c)` 中的 `a` 和 `b`。然后判断 $a^2 + b^2$ 是否小于等于 `n`,并且是完全平方数。 +我们可以在 $[1, n]$ 区间中枚举整数三元组 $(a, b, c)$ 中的 $a$ 和 $b$。然后判断 $a^2 + b^2$ 是否小于等于 $n,ドル并且是完全平方数。 -在遍历枚举的同时,我们维护一个用于统计平方和三元组数目的变量 `cnt`。如果符合要求,则将计数 `cnt` 加 `1`。最终,我们返回该数目作为答案。 +在遍历枚举的同时,我们维护一个用于统计平方和三元组数目的变量 `cnt`。如果符合要求,则将计数 `cnt` 加 1ドル$。最终,我们返回该数目作为答案。 利用枚举算法统计平方和三元组数目的时间复杂度为 $O(n^2)$。 -- 注意:在计算中,为了防止浮点数造成的误差,并且两个相邻的完全平方正数之间的距离一定大于 `1`,所以我们可以用 $\sqrt{a^2 + b^2 + 1}$ 来代替 $\sqrt{a^2 + b^2}$。 +- 注意:在计算中,为了防止浮点数造成的误差,并且两个相邻的完全平方正数之间的距离一定大于 1ドル,ドル所以我们可以用 $\sqrt{a^2 + b^2 + 1}$ 来代替 $\sqrt{a^2 + b^2}$。 -#### 3.3.4 代码 +##### 思路 1:代码 ```Python class Solution: @@ -147,69 +274,7 @@ class Solution: return cnt ``` -## 4. 二进制枚举子集 - -### 4.1 二进制枚举子集简介 - -先来介绍一下「子集」的概念。 - -- **子集**:如果集合 `A` 的任意一个元素都是集合 `S` 的元素,则称集合 `A` 是集合 `S` 的子集。可以记为 $A \in S$。 - -有时候我们会遇到这样的问题:给定一个集合 `S`,枚举其所有可能的子集。 - -枚举子集的方法有很多,这里介绍一种简单有效的枚举方法:「二进制枚举子集算法」。 - -对于一个元素个数为 `n` 的集合 `S` 来说,每一个位置上的元素都有选取和未选取两种状态。我们可以用数字 `1` 来表示选取该元素,用数字 `0` 来表示不选取该元素。 - -那么我们就可以用一个长度为 `n` 的二进制数来表示集合 `S` 或者表示 `S` 的子集。其中二进制的每一位数都对应了集合中某一个元素的选取状态。对于集合中第 `i` 个元素(`i` 从 `0` 开始编号)来说,二进制对应位置上的 `1` 代表该元素被选取,`0` 代表该元素未被选取。 - -举个例子来说明一下,比如长度为 `5` 的集合 `S = {5, 4, 3, 2, 1}`,我们可以用一个长度为 `5` 的二进制数来表示该集合。 - -比如二进制数 `11111` 就表示选取集合的第 `0` 位、第 `1` 位、第 `2` 位、第 `3` 位、第 `4` 位元素,也就是集合 `{5, 4, 3, 2, 1}` ,即集合 `S` 本身。如下表所示: - -| 集合 S 对应位置(下标) | 4 | 3 | 2 | 1 | 0 | -| :---------------------- | :--: | :--: | :--: | :--: | :--: | -| 二进制数对应位数 | 1 | 1 | 1 | 1 | 1 | -| 对应选取状态 | 选取 | 选取 | 选取 | 选取 | 选取 | - -再比如二进制数 `10101` 就表示选取集合的第 `0` 位、第 `2` 位、第 `5` 位元素,也就是集合 `{5, 3, 1}`。如下表所示: - -| 集合 S 对应位置(下标) | 4 | 3 | 2 | 1 | 0 | -| :---------------------- | :--: | :----: | :--: | :----: | :--: | -| 二进制数对应位数 | 1 | 0 | 1 | 0 | 1 | -| 对应选取状态 | 选取 | 未选取 | 选取 | 未选取 | 选取 | - -再比如二进制数 `01001` 就表示选取集合的第 `0` 位、第 `3` 位元素,也就是集合 `{5, 2}`。如下标所示: - -| 集合 S 对应位置(下标) | 4 | 3 | 2 | 1 | 0 | -| :---------------------- | :----: | :--: | :----: | :----: | :--: | -| 二进制数对应位数 | 0 | 1 | 0 | 0 | 1 | -| 对应选取状态 | 未选取 | 选取 | 未选取 | 未选取 | 选取 | - -通过上面的例子我们可以得到启发:对于长度为 `5` 的集合 `S` 来说,我们只需要从 `00000` ~ `11111` 枚举一次(对应十进制为 0ドル \sim 2^5 - 1$)即可得到长度为 `5` 的集合 `S` 的所有子集。 - -我们将上面的例子拓展到长度为 `n` 的集合 `S`。可以总结为: - -- 对于长度为 `5` 的集合 `S` 来说,只需要枚举 0ドル \sim 2^n - 1$(共 2ドル^n$ 种情况),即可得到所有的子集。 - -### 4.2 二进制枚举子集代码 - -```Python -class Solution: - def subsets(self, S): # 返回集合 S 的所有子集 - n = len(S) # n 为集合 S 的元素个数 - sub_sets = [] # sub_sets 用于保存所有子集 - for i in range(1 << n): # 枚举 0 ~ 2^n - 1 - sub_set = [] # sub_set 用于保存当前子集 - for j in range(n): # 枚举第 i 位元素 - if i>> j & 1: # 如果第 i 为元素对应二进制位删改为 1,则表示选取该元素 - sub_set.append(S[j]) # 将选取的元素加入到子集 sub_set 中 - sub_sets.append(sub_set) # 将子集 sub_set 加入到所有子集数组 sub_sets 中 - return sub_sets # 返回所有子集 -``` - -## 参考资料 +##### 思路 1:复杂度分析 -- 【书籍】算法竞赛入门经典:训练指南 - 刘汝佳,陈锋 著 -- 【书籍】ACM-ICPC 程序设计系列 - 算法设计与实现 - 陈宇 吴昊 主编 -- 【博文】[枚举排列和枚举子集 - CUC ACM-Wiki](https://cuccs.github.io/acm-wiki/search/enumeration/) \ No newline at end of file +- **时间复杂度**:$O(n^2)$。 +- **空间复杂度**:$O(1)$。 \ No newline at end of file diff --git a/Contents/09.Algorithm-Base/02.Recursive-Algorithm/01.Recursive-Algorithm.md b/Contents/09.Algorithm-Base/02.Recursive-Algorithm/01.Recursive-Algorithm.md index 91628b60..8abb57c9 100644 --- a/Contents/09.Algorithm-Base/02.Recursive-Algorithm/01.Recursive-Algorithm.md +++ b/Contents/09.Algorithm-Base/02.Recursive-Algorithm/01.Recursive-Algorithm.md @@ -2,9 +2,9 @@ > **递归(Recursion)**:指的是一种通过重复将原问题分解为同类的子问题而解决的方法。在绝大数编程语言中,可以通过在函数中再次调用函数自身的方式来实现递归。 -举个例子来说明一下递归算法。比如阶乘的计算方法在数学上的定义为: +举个简单的例子来了解一下递归算法。比如阶乘的计算方法在数学上的定义为: -$fact(n) = \begin{cases} 1 & \text{if n = 0} \cr n * fact(n - 1) & \text{if n> 0} \end{cases}$ +$fact(n) = \begin{cases} 1 & \text{n = 0} \cr n * fact(n - 1) & \text{n> 0} \end{cases}$ 根据阶乘计算方法的数学定义,我们可以使用调用函数自身的方式来实现阶乘函数 `fact(n)` ,其实现代码可以写作: @@ -15,7 +15,7 @@ def fact(n): return n * fact(n - 1) ``` -以 `n = 6` 为例,上述代码中阶乘函数 `fact(6)` 的计算过程如下: +以 `n = 6` 为例,上述代码中阶乘函数 `fact(6):` 的计算过程如下: ```Python fact(6) @@ -36,7 +36,7 @@ fact(6) 上面的例子也可以用语言描述为: -1. 函数从 `fact(6)` 开始,一层层地调用 `fact(5)`、`fact(4)`、... 一直到最底层的 `fact(0)`。 +1. 函数从 `fact(6)` 开始,一层层地调用 `fact(5)`、`fact(4)`、...... 一直调用到最底层的 `fact(0)`。 2. 当 `n == 0` 时,`fact(0)` 不再继续调用自身,而是直接向上一层返回结果 `1`。 3. `fact(1)` 通过下一层 `fact(0)` 的计算结果得出 `fact(1) = 1 * 1 = 1`,从而向上一层返回结果 `1`。 4. `fact(2)` 通过下一层 `fact(1)` 的计算结果得出 `fact(2) = 2 * 1 = 2 `,从而向上一层返回结果 `2`。 @@ -52,7 +52,7 @@ fact(6) 1. 先逐层向下调用自身,直到达到结束条件(即 `n == 0`)。 2. 然后再向上逐层返回结果,直到返回原问题的解(即返回 `fact(6) == 720`)。 -这两个部分也可以叫做「递推过程」和「归回过程」,如下面两幅图所示: +这两个部分也可以叫做「递推过程」和「回归过程」,如下面两幅图所示: ![](https://qcdn.itcharge.cn/images/20220407160648.png) @@ -71,18 +71,18 @@ fact(6) 递归的数学模型其实就是「数学归纳法」。这里简单复习一下数学归纳法的证明步骤: -1. 证明当 `n = b` (`b` 为基本情况,通常为 `0` 或者 `1`)时,命题成立。 -2. 证明当 `n> b` 时,假设 `n = k` 时命题成立,那么可以推导出 `n = k + 1` 时命题成立。这一步不是直接证明的,而是先假设 `n = k` 时命题成立,利用这个条件,可以推论出 `n = k + 1` 时命题成立。 +1. 证明当 $n = b$ ($b$ 为基本情况,通常为 0ドル$ 或者 1ドル$)时,命题成立。 +2. 证明当 $n> b$ 时,假设 $n = k$ 时命题成立,那么可以推导出 $n = k + 1$ 时命题成立。这一步不是直接证明的,而是先假设 $n = k$ 时命题成立,利用这个条件,可以推论出 $n = k + 1$ 时命题成立。 -通过以上两步证明,就可以说:当 `n>= b` 时,命题都成立。 +通过以上两步证明,就可以说:当 $n>= b$ 时,命题都成立。 我们可以从「数学归纳法」的角度来解释递归: -- **递归终止条件**:数学归纳法第一步中的 `n = b` ,可以直接得出结果。 -- **递推过程**:数学归纳法第二步中的假设部分(假设 `n = k` 时命题成立),也就是假设我们当前已经知道了 `n = k` 时的计算结果。 -- **回归过程**:数学归纳法第二步中的推论部分(根据 `n = k` 推论出 `n = k + 1`),也就是根据下一层的结果,计算出上一层的结果。 +- **递归终止条件**:数学归纳法第一步中的 $n = b,ドル可以直接得出结果。 +- **递推过程**:数学归纳法第二步中的假设部分(假设 $n = k$ 时命题成立),也就是假设我们当前已经知道了 $n = k$ 时的计算结果。 +- **回归过程**:数学归纳法第二步中的推论部分(根据 $n = k$ 推论出 $n = k + 1$),也就是根据下一层的结果,计算出上一层的结果。 -事实上,数学归纳法的思考过程也正是在解决某些数列问题时,可以使用递归算法的原因。比如阶乘、数组前 `n` 项和、斐波那契数列等等。 +事实上,数学归纳法的思考过程也正是在解决某些数列问题时,可以使用递归算法的原因。比如阶乘、数组前 $n$ 项和、斐波那契数列等等。 ## 3. 递归三步走 @@ -107,9 +107,9 @@ fact(6) 那么我们应该如何思考「递推过程」和「回归过程」呢,又该如何写出递归中的递推公式呢? -如果一个问题 A,可以分解为若干个规模较小、与原问题形式相同的子问题 B、C、D,那么这些子问题就可以用相同的解题思路来解决。我们可以假设 B、C、D 已经解决了,然后只需要考虑在这个基础上去思考如何解决问题 A 即可。不需要再一层层往下思考子问题与子子问题、子子问题与子子子问题之间的关系。这样理解起来就简单多了。 +如果一个问题 $A,ドル可以分解为若干个规模较小、与原问题形式相同的子问题 $B$、$C$、$D,ドル那么这些子问题就可以用相同的解题思路来解决。我们可以假设 $B$、$C$、$D$ 已经解决了,然后只需要考虑在这个基础上去思考如何解决问题 $A$ 即可。不需要再一层层往下思考子问题与子子问题、子子问题与子子子问题之间的关系。这样理解起来就简单多了。 -从问题 A 到分解为子问题 B、C、D 的思考过程其实就是递归的「递推过程」。而从子问题 B、C、D 的解到问题 A 的解的思考过程其实就是递归的「回归过程」。想清楚了「如何划分子问题」和「如何通过子问题来解决原问题」这两个过程,也就想清楚了递归的「递推过程」和「回归过程」。 +从问题 $A$ 到分解为子问题 $B$、$C$、$D$ 的思考过程其实就是递归的「递推过程」。而从子问题 $B$、$C$、$D$ 的解回到问题 $A$ 的解的思考过程其实就是递归的「回归过程」。想清楚了「如何划分子问题」和「如何通过子问题来解决原问题」这两个过程,也就想清楚了递归的「递推过程」和「回归过程」。 然后,我们只需要考虑原问题与子问题之间的关系,就能够在此基础上,写出递推公式了。 @@ -123,9 +123,9 @@ fact(6) 在写出递推公式和明确终止条件之后,我们就可以将其翻译成代码了。这一步也可以分为 3 步来做: -1. 定义递归函数:明确函数意义、传入参数、返回结果等。 -2. 书写递归主体:提取重复的逻辑,缩小问题规模。 -3. 明确递归终止条件:给出递归终止条件,以及递归终止时的处理方法。 +1. **定义递归函数**:明确函数意义、传入参数、返回结果等。 +2. **书写递归主体**:提取重复的逻辑,缩小问题规模。 +3. **明确递归终止条件**:给出递归终止条件,以及递归终止时的处理方法。 #### 3.3.1 定义递归函数 @@ -198,15 +198,27 @@ def recursion(大规模问题): **示例**: +- 示例 1: + ```Python -输入 n = 2 -输出 1 -解释 f(2) = f(1) + f(0) = 1 + 0 = 1 +输入:n = 2 +输出:1 +解释:F(2) = F(1) + F(0) = 1 + 0 = 1 +``` + +- 示例 2: + +```Python +输入:n = 3 +输出:2 +解释:F(3) = F(2) + F(1) = 1 + 1 = 2 ``` #### 5.1.3 解题思路 -根据递归三步走策略,写出对应的递归代码。 +##### 思路 1:递归算法 + +根据我们的递推三步走策略,写出对应的递归代码。 1. 写出递推公式:`f(n) = f(n - 1) + f(n - 2)`。 2. 明确终止条件:`f(0) = 0, f(1) = 1`。 @@ -217,7 +229,7 @@ def recursion(大规模问题): 1. `if n == 0: return 0` 2. `if n == 1: return 1` -#### 5.1.4 代码 +##### 思路 1:代码 ```Python class Solution: @@ -229,6 +241,11 @@ class Solution: return self.fib(n - 1) + self.fib(n - 2) ``` +##### 思路 1:复杂度分析 + +- **时间复杂度**:$O((\frac{1 + \sqrt{5}}{2})^n)$。具体证明方法参考 [递归求斐波那契数列的时间复杂度,不要被网上的答案误导了 - 知乎](https://zhuanlan.zhihu.com/p/256344121)。 +- **空间复杂度**:$O(n)$。每次递归的空间复杂度是 $O(1),ドル 调用栈的深度为 $n,ドル所以总的空间复杂度就是 $O(n)$。 + ### 5.2 二叉树的最大深度 #### 5.2.1 题目链接 @@ -243,25 +260,29 @@ class Solution: **说明**: -- 二叉树的深度:根节点到最远叶子节点的最长路径上的节点数。 -- 叶子节点:没有子节点的节点。 +- **二叉树的深度**:根节点到最远叶子节点的最长路径上的节点数。 +- **叶子节点**:没有子节点的节点。 **示例**: +- 示例 1: + ```Python -输入 [3,9,20,null,null,15,7] +输入:[3,9,20,null,null,15,7] 对应二叉树 3 / \ 9 20 / \ 15 7 -输出 3 -解释 该二叉树的最大深度为 3 +输出:3 +解释:该二叉树的最大深度为 3 ``` #### 5.2.3 解题思路 +##### 思路 1: 递归算法 + 根据递归三步走策略,写出对应的递归代码。 1. 写出递推公式:`当前二叉树的最大深度 = max(当前二叉树左子树的最大深度, 当前二叉树右子树的最大深度) + 1`。 @@ -272,7 +293,7 @@ class Solution: 2. 书写递归主体:`return max(self.maxDepth(root.left) + self.maxDepth(root.right))`。 3. 明确递归终止条件:`if not root: return 0` -#### 5.2.4 代码 +##### 思路 1:代码 ```Python class Solution: @@ -283,6 +304,11 @@ class Solution: return max(self.maxDepth(root.left), self.maxDepth(root.right)) + 1 ``` +##### 思路 1:复杂度分析 + +- **时间复杂度**:$O(n),ドル其中 $n$ 是二叉树的节点数目。 +- **空间复杂度**:$O(n)$。递归函数需要用到栈空间,栈空间取决于递归深度,最坏情况下递归深度为 $n,ドル所以空间复杂度为 $O(n)$。 + ## 参考资料 - 【书籍】算法竞赛入门经典:训练指南 - 刘汝佳,陈锋 著 diff --git a/Contents/09.Algorithm-Base/03.Divide-And-Conquer-Algorithm/01.Divide-And-Conquer-Algorithm.md b/Contents/09.Algorithm-Base/03.Divide-And-Conquer-Algorithm/01.Divide-And-Conquer-Algorithm.md index 5dab20c6..2f7efd7f 100644 --- a/Contents/09.Algorithm-Base/03.Divide-And-Conquer-Algorithm/01.Divide-And-Conquer-Algorithm.md +++ b/Contents/09.Algorithm-Base/03.Divide-And-Conquer-Algorithm/01.Divide-And-Conquer-Algorithm.md @@ -10,19 +10,19 @@ ### 1.2 分治算法和递归算法的异同 -从定义上来看,分治算法的思想和之前我们讲过的递归算法的思想是一样的,都是把规模大的问题不断分解为子问题。 +从定义上来看,分治算法的思想和递归算法的思想是一样的,都是把规模大的问题不断分解为子问题。 -其实,分治算法和递归算法的关系是包含与被包含的关系,可以看做 `递归算法 ∈ 分治算法`。 +其实,分治算法和递归算法的关系是包含与被包含的关系,可以看做:`递归算法 ∈ 分治算法`。 分治算法从实现方式上来划分,可以分为两种:「递归算法」和「迭代算法」。 ![](https://qcdn.itcharge.cn/images/20220414093828.png) -实际上,分治算法一般都比较适合使用递归算法来实现。但除了递归算法之外,分治算法还可以通过迭代算法来实现。比较常见的例子有:快速傅里叶变换算法、二分查找算法、非递归实现的归并排序算法等等。 +分治算法一般都比较适合使用递归算法来实现。但除了递归算法之外,分治算法还可以通过迭代算法来实现。比较常见的例子有:快速傅里叶变换算法、二分查找算法、非递归实现的归并排序算法等等。 ### 1.3 分治算法的适用条件 -分治算法能够解决的问题,一般需要满足以下 `4` 个条件: +分治算法能够解决的问题,一般需要满足以下 4ドル$ 个条件: 1. 原问题可以分解为若干个规模较小的相同子问题。 2. 分解出来的子问题可以独立求解,即子问题之间不包含公共的子子问题。 @@ -31,19 +31,19 @@ ## 2. 分治算法的基本步骤 -使用分治算法解决问题主要分为 `3` 个步骤: +使用分治算法解决问题主要分为 3ドル$ 个步骤: 1. **分解**:把要解决的问题分解为成若干个规模较小、相对独立、与原问题形式相同的子问题。 2. **求解**:递归求解各个子问题。 3. **合并**:按照原问题的要求,将子问题的解逐层合并构成原问题的解。 -其中第 `1` 步中将问题分解为若干个子问题时,最好使子问题的规模大致相同。换句话说,将一个问题分成大小相等的 `k` 个子问题的处理方法是行之有效的。在许多问题中,可以取 `k = s2`。这种使子问题规模大致相等的做法是出自一种平衡子问题的思想,它几乎总是比子问题规模不等的做法要好。 +其中第 1ドル$ 步中将问题分解为若干个子问题时,最好使子问题的规模大致相同。换句话说,将一个问题分成大小相等的 $k$ 个子问题的处理方法是行之有效的。在许多问题中,可以取 $k = 2$。这种使子问题规模大致相等的做法是出自一种平衡子问题的思想,它几乎总是比子问题规模不等的做法要好。 -其中第 `2` 步的「递归求解各个子问题」指的是按照同样的分治策略进行求解,即通过将这些子问题分解为更小的子子问题来进行求解。就这样一直分解下去,直到分解出来的子问题简单到只用常数操作时间即可解决为止。 +其中第 2ドル$ 步的「递归求解各个子问题」指的是按照同样的分治策略进行求解,即通过将这些子问题分解为更小的子子问题来进行求解。就这样一直分解下去,直到分解出来的子问题简单到只用常数操作时间即可解决为止。 -在完成第 `2` 步之后,最小子问题的解可用常数时间求得。然后我们再按照递归算法中回归过程的顺序,由底至上地将子问题的解合并起来,逐级上推就构成了原问题的解。 +在完成第 2ドル$ 步之后,最小子问题的解可用常数时间求得。然后我们再按照递归算法中回归过程的顺序,由底至上地将子问题的解合并起来,逐级上推就构成了原问题的解。 -按照分而治之的策略,在编写分治算法的代码时,也是按照上面的 `3` 个步骤来编写的,其对应的伪代码如下: +按照分而治之的策略,在编写分治算法的代码时,也是按照上面的 3ドル$ 个步骤来编写的,其对应的伪代码如下: ```Python def divide_and_conquer(problem): # problem 为问题规模 @@ -142,8 +142,8 @@ $T(n) = \begin{cases} \begin{array} \ O{(1)} & n = 1 \cr 2T(n/2) + O(n) & n> 1 我们使用归并排序算法来解决这道题。 -1. **分解**:将待排序序列中的 `n` 个元素分解为左右两个各包含 `n / 2` 个元素的子序列。 -2. **求解**:递归将子序列进行分解和排序,直到所有子序列长度为 `1`。 +1. **分解**:将待排序序列中的 $n$ 个元素分解为左右两个各包含 $\frac{n}{2}$ 个元素的子序列。 +2. **求解**:递归将子序列进行分解和排序,直到所有子序列长度为 1ドル$。 3. **合并**:把当前序列组中有序子序列逐层向上,进行两两合并。 使用归并排序算法对数组排序的过程如下图所示。 @@ -189,14 +189,14 @@ class Solution: #### 4.2.2 题目大意 -**描述**:给定一个含有 `n` 个元素有序的(升序)整型数组 `nums` 和一个目标值 `target`。 +**描述**:给定一个含有 $n$ 个元素有序的(升序)整型数组 `nums` 和一个目标值 `target`。 -**要求**:返回 `target` 在数组 `nums` 中的位置,如果找不到,则返回 `-1`。 +**要求**:返回 `target` 在数组 `nums` 中的位置,如果找不到,则返回 $-1$。 **说明**: - 假设 `nums` 中的所有元素是不重复的。 -- `n` 将在 `[1, 10000]`之间。 +- $n$ 将在 $[1, 10000]$ 之间。 - $-9999 \le nums[i] \le 9999$。 **示例**: @@ -211,7 +211,7 @@ class Solution: 我们使用分治算法来解决这道题。与其他分治题目不一样的地方是二分查找不用进行合并过程,最小子问题的解就是原问题的解。 -1. **分解**:将数组的 `n` 个元素分解为左右两个各包含 `n / 2` 个元素的子序列。 +1. **分解**:将数组的 $n$ 个元素分解为左右两个各包含 $\frac{n}{2}$ 个元素的子序列。 2. **求解**:取中间元素 `nums[mid]` 与 `target` 相比。 1. 如果相等,则找到该元素; 2. 如果 `nums[mid] < target`,则递归在左子序列中进行二分查找。

AltStyle によって変換されたページ (->オリジナル) /