This commit is contained in:
krahets
2023-12-02 06:24:05 +08:00
parent a4a23e2488
commit a7f5434009
93 changed files with 1463 additions and 1484 deletions

View File

@ -4,7 +4,7 @@ comments: true
# 2.3   时间复杂度
运行时间可以直观且准确地反映算法的效率。如果我们想准确预估一段代码的运行时间,应该如何操作呢?
运行时间可以直观且准确地反映算法的效率。如果我们想准确预估一段代码的运行时间,应该如何操作呢?
1. **确定运行平台**,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
2. **评估各种计算操作所需的运行时间**,例如加法操作 `+` 需要 1 ns ,乘法操作 `*` 需要 10 ns ,打印操作 `print()` 需要 5 ns 等。
@ -190,7 +190,7 @@ comments: true
}
```
根据以上方法,可以得到算法运行时间为 $(6n + 12)$ ns
根据以上方法,可以得到算法运行时间为 $(6n + 12)$ ns
$$
1 + 1 + 10 + (1 + 5) \times n = 6n + 12
@ -202,7 +202,7 @@ $$
时间复杂度分析统计的不是算法运行时间,**而是算法运行时间随着数据量变大时的增长趋势**。
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法函数 `A`、`B` 和 `C`
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 $n$ ,给定三个算法 `A`、`B` 和 `C`
=== "Python"
@ -466,10 +466,10 @@ $$
<p align="center"> 图 2-7 &nbsp; 算法 A、B 和 C 的时间增长趋势 </p>
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
相较于直接统计算法运行时间,时间复杂度分析有哪些特点呢?
- **时间复杂度能够有效评估算法效率**。例如,算法 `B` 的运行时间呈线性增长,在 $n > 1$ 时比算法 `A` 更慢,在 $n > 1000000$ 时比算法 `C` 更慢。事实上,只要输入数据大小 $n$ 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势所表达的含义。
- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
- **时间复杂度能够有效评估算法效率**。例如,算法 `B` 的运行时间呈线性增长,在 $n > 1$ 时比算法 `A` 更慢,在 $n > 1000000$ 时比算法 `C` 更慢。事实上,只要输入数据大小 $n$ 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。
- **时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作运行时间统计”简化为“计算操作数量统计”,这样一来估算难度就大大降低了。
- **时间复杂度也存在一定的局限性**。例如,尽管算法 `A` 和 `C` 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 `B` 的时间复杂度比 `C` 高,但在输入数据大小 $n$ 较小时,算法 `B` 明显优于算法 `C` 。在这些情况下,我们很难仅凭时间复杂度判断算法效率的高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
## 2.3.2 &nbsp; 函数渐近上界
@ -643,7 +643,7 @@ $$
}
```
设算法的操作数量是一个关于输入数据大小 $n$ 的函数,记为 $T(n)$ ,则以上函数的操作数量为:
设算法的操作数量是一个关于输入数据大小 $n$ 的函数,记为 $T(n)$ ,则以上函数的操作数量为:
$$
T(n) = 3 + 2n
@ -653,7 +653,7 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
我们将线性阶的时间复杂度记为 $O(n)$ ,这个数学符号称为「大 $O$ 记号 big-$O$ notation」表示函数 $T(n)$ 的「渐近上界 asymptotic upper bound」。
时间复杂度分析本质上是计算“操作数量函数 $T(n)$”的渐近上界,具有明确的数学定义。
时间复杂度分析本质上是计算“操作数量 $T(n)$”的渐近上界,具有明确的数学定义。
!!! abstract "函数渐近上界"
@ -667,19 +667,19 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
## 2.3.3 &nbsp; 推算方法
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。因为在实际使用中,我们只需要掌握推算方法,数学意义就可以逐渐领悟。
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无须担心。我们可以先掌握推算方法,在不断的实践中,就可以逐渐领悟其数学意义
根据定义,确定 $f(n)$ 之后,我们便可得到时间复杂度 $O(f(n))$ 。那么如何确定渐近上界 $f(n)$ 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
### 1. &nbsp; 第一步:统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述 $c \cdot f(n)$ 中的常数项 $c$ 可以取任意大小,**因此操作数量 $T(n)$ 中的各种系数、常数项都可以忽略**。根据此原则,可以总结出以下计数简化技巧。
针对代码,逐行从上到下计算即可。然而,由于上述 $c \cdot f(n)$ 中的常数项 $c$ 可以取任意大小,**因此操作数量 $T(n)$ 中的各种系数、常数项都可以忽略**。根据此原则,可以总结出以下计数简化技巧。
1. **忽略 $T(n)$ 中的常数项**。因为它们都与 $n$ 无关,所以对时间复杂度不产生影响。
2. **省略所有系数**。例如,循环 $2n$ 次、$5n + 1$ 次等,都可以简化记为 $n$ 次,因为 $n$ 前面的系数对时间复杂度没有影响。
3. **循环嵌套时使用乘法**。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第 `1.` 点和第 `2.` 点的技巧。
给定一个函数,我们可以用上述技巧来统计操作数量
给定一个函数,我们可以用上述技巧来统计操作数量
=== "Python"
@ -909,7 +909,7 @@ $T(n)$ 是一次函数,说明其运行时间的增长趋势是线性的,因
}
```
以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 $O(n^2)$ 。
以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 $O(n^2)$ 。
$$
\begin{aligned}
@ -921,7 +921,7 @@ $$
### 2. &nbsp; 第二步:判断渐近上界
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
**时间复杂度由 $T(n)$ 中最高阶的项来决定**。这是因为在 $n$ 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
表 2-2 展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 $n$ 趋于无穷大时,这些常数变得无足轻重。
@ -1447,7 +1447,7 @@ $$
### 3. &nbsp; 平方阶 $O(n^2)$
平方阶的操作数量相对于输入数据大小 $n$ 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 $O(n)$ ,因此总体为 $O(n^2)$
平方阶的操作数量相对于输入数据大小 $n$ 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 $O(n)$ ,因此总体的时间复杂度为 $O(n^2)$
=== "Python"
@ -1646,7 +1646,7 @@ $$
<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)$
以冒泡排序为例,外层循环执行 $n - 1$ 次,内层循环执行 $n-1$、$n-2$、$\dots$、$2$、$1$ 次,平均为 $n / 2$ 次,因此时间复杂度为 $O((n - 1) n / 2) = O(n^2)$
=== "Python"
@ -2283,13 +2283,13 @@ $$
}
```
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法来解决。
指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法来解决。
### 5. &nbsp; 对数阶 $O(\log n)$
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 $n$ ,由于每轮缩减到一半,因此循环次数是 $\log_2 n$ ,即 $2^n$ 的反函数。
图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$
图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 $O(\log_2 n)$ ,简记为 $O(\log n)$
=== "Python"
@ -2464,7 +2464,7 @@ $$
<p align="center"> 图 2-12 &nbsp; 对数阶的时间复杂度 </p>
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一高度为 $\log_2 n$ 的递归树:
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一高度为 $\log_2 n$ 的递归树:
=== "Python"
@ -2599,7 +2599,7 @@ $$
!!! tip "$O(\log n)$ 的底数是多少?"
准确来说,“一分为 $m$”对应的时间复杂度是 $O(\log_m n)$ 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:
准确来说,“一分为 $m$”对应的时间复杂度是 $O(\log_m n)$ 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:
$$
O(\log_m n) = O(\log_k n / \log_k m) = O(\log_k n)
@ -3358,12 +3358,12 @@ $$
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。**而最差时间复杂度更为实用,因为它给出了一个效率安全值**,让我们可以放心地使用算法。
从上述示例可以看出,最差最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。
从上述示例可以看出,最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,**平均时间复杂度可以体现算法在随机输入数据下的运行效率**,用 $\Theta$ 记号来表示。
对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 $1$ 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 $n / 2$ ,平均时间复杂度为 $\Theta(n / 2) = \Theta(n)$ 。
但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
!!! question "为什么很少看到 $\Theta$ 符号?"
可能由于 $O$ 符号过于朗朗上口,我们常常使用它来表示平均时间复杂度。但从严格意义上,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 $O(n)$”的表述,请将其直接理解为 $\Theta(n)$ 。
可能由于 $O$ 符号过于朗朗上口,因此我们常常使用它来表示平均时间复杂度。但从严格意义上,这种做法并不规范。在本书和其他资料中,若遇到类似“平均时间复杂度 $O(n)$”的表述,请将其直接理解为 $\Theta(n)$ 。