hello-algo/docs/chapter_computational_complexity/time_complexity.md
krahets de6945f55a Polish the contents
1. Array and linked list.
2. Computational complexity.
3. Fix a mistake in counting_sort.md
2023-04-07 22:31:50 +08:00

1629 lines
40 KiB
Markdown
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 时间复杂度
## 统计算法运行时间
运行时间可以直观且准确地反映算法的效率。然而,如果我们想要准确预估一段代码的运行时间,应该如何操作呢?
1. **确定运行平台**,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
2. **评估各种计算操作所需的运行时间**,例如加法操作 `+` 需要 1 ns乘法操作 `*` 需要 10 ns打印操作需要 5 ns 等。
3. **统计代码中所有的计算操作**,并将所有操作的执行时间求和,从而得到运行时间。
例如以下代码,输入数据大小为 $n$ ,根据以上方法,可以得到算法运行时间为 $6n + 12$ ns 。
$$
1 + 1 + 10 + (1 + 5) \times n = 6n + 12
$$
=== "Java"
```java title=""
// 在某运行平台下
void algorithm(int n) {
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n 次
for (int i = 0; i < n; i++) { // 1 ns 每轮都要执行 i++
System.out.println(0); // 5 ns
}
}
```
=== "C++"
```cpp title=""
// 在某运行平台下
void algorithm(int n) {
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n
for (int i = 0; i < n; i++) { // 1 ns 每轮都要执行 i++
cout << 0 << endl; // 5 ns
}
}
```
=== "Python"
```python title=""
# 在某运行平台下
def algorithm(n: int) -> None:
a = 2 # 1 ns
a = a + 1 # 1 ns
a = a * 2 # 10 ns
# 循环 n 次
for _ in range(n): # 1 ns
print(0) # 5 ns
```
=== "Go"
```go title=""
// 在某运行平台下
func algorithm(n int) {
a := 2 // 1 ns
a = a + 1 // 1 ns
a = a * 2 // 10 ns
// 循环 n 次
for i := 0; i < n; i++ { // 1 ns
fmt.Println(a) // 5 ns
}
}
```
=== "JavaScript"
```javascript title=""
// 在某运行平台下
function algorithm(n) {
var a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n
for(let i = 0; i < n; i++) { // 1 ns 每轮都要执行 i++
console.log(0); // 5 ns
}
}
```
=== "TypeScript"
```typescript title=""
// 在某运行平台下
function algorithm(n: number): void {
var a: number = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n
for(let i = 0; i < n; i++) { // 1 ns 每轮都要执行 i++
console.log(0); // 5 ns
}
}
```
=== "C"
```c title=""
// 在某运行平台下
void algorithm(int n) {
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n
for (int i = 0; i < n; i++) { // 1 ns 每轮都要执行 i++
printf("%d", 0); // 5 ns
}
}
```
=== "C#"
```csharp title=""
// 在某运行平台下
void algorithm(int n)
{
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n
for (int i = 0; i < n; i++)
{ // 1 ns 每轮都要执行 i++
Console.WriteLine(0); // 5 ns
}
}
```
=== "Swift"
```swift title=""
// 在某运行平台下
func algorithm(n: Int) {
var a = 2 // 1 ns
a = a + 1 // 1 ns
a = a * 2 // 10 ns
// 循环 n
for _ in 0 ..< n { // 1 ns
print(0) // 5 ns
}
}
```
=== "Zig"
```zig title=""
```
然而实际上**统计算法的运行时间既不合理也不现实**。首先我们不希望预估时间和运行平台绑定因为算法需要在各种不同的平台上运行其次我们很难获知每种操作的运行时间这给预估过程带来了极大的难度
## 统计时间增长趋势
时间复杂度分析采取了一种不同的方法其统计的不是算法运行时间**而是算法运行时间随着数据量变大时的增长趋势**。
时间增长趋势这个概念较为抽象我们通过一个例子来加以理解假设输入数据大小为 $n$给定三个算法 `A``B``C`
- 算法 `A` 只有 $1$ 个打印操作算法运行时间不随着 $n$ 增大而增长我们称此算法的时间复杂度为常数阶」。
- 算法 `B` 中的打印操作需要循环 $n$ 算法运行时间随着 $n$ 增大呈线性增长此算法的时间复杂度被称为线性阶」。
- 算法 `C` 中的打印操作需要循环 $1000000$ 但运行时间仍与输入数据大小 $n$ 无关因此 `C` 的时间复杂度和 `A` 相同仍为常数阶」。
=== "Java"
```java title=""
// 算法 A 时间复杂度常数阶
void algorithm_A(int n) {
System.out.println(0);
}
// 算法 B 时间复杂度线性阶
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
System.out.println(0);
}
}
// 算法 C 时间复杂度常数阶
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
System.out.println(0);
}
}
```
=== "C++"
```cpp title=""
// 算法 A 时间复杂度常数阶
void algorithm_A(int n) {
cout << 0 << endl;
}
// 算法 B 时间复杂度线性阶
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
cout << 0 << endl;
}
}
// 算法 C 时间复杂度常数阶
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
cout << 0 << endl;
}
}
```
=== "Python"
```python title=""
# 算法 A 时间复杂度常数阶
def algorithm_A(n: int) -> None:
print(0)
# 算法 B 时间复杂度:线性阶
def algorithm_B(n: int) -> None:
for _ in range(n):
print(0)
# 算法 C 时间复杂度:常数阶
def algorithm_C(n: int) -> None:
for _ in range(1000000):
print(0)
```
=== "Go"
```go title=""
// 算法 A 时间复杂度:常数阶
func algorithm_A(n int) {
fmt.Println(0)
}
// 算法 B 时间复杂度:线性阶
func algorithm_B(n int) {
for i := 0; i < n; i++ {
fmt.Println(0)
}
}
// 算法 C 时间复杂度常数阶
func algorithm_C(n int) {
for i := 0; i < 1000000; i++ {
fmt.Println(0)
}
}
```
=== "JavaScript"
```javascript title=""
// 算法 A 时间复杂度常数阶
function algorithm_A(n) {
console.log(0);
}
// 算法 B 时间复杂度线性阶
function algorithm_B(n) {
for (let i = 0; i < n; i++) {
console.log(0);
}
}
// 算法 C 时间复杂度常数阶
function algorithm_C(n) {
for (let i = 0; i < 1000000; i++) {
console.log(0);
}
}
```
=== "TypeScript"
```typescript title=""
// 算法 A 时间复杂度常数阶
function algorithm_A(n: number): void {
console.log(0);
}
// 算法 B 时间复杂度线性阶
function algorithm_B(n: number): void {
for (let i = 0; i < n; i++) {
console.log(0);
}
}
// 算法 C 时间复杂度常数阶
function algorithm_C(n: number): void {
for (let i = 0; i < 1000000; i++) {
console.log(0);
}
}
```
=== "C"
```c title=""
// 算法 A 时间复杂度常数阶
void algorithm_A(int n) {
printf("%d", 0);
}
// 算法 B 时间复杂度线性阶
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
printf("%d", 0);
}
}
// 算法 C 时间复杂度常数阶
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
printf("%d", 0);
}
}
```
=== "C#"
```csharp title=""
// 算法 A 时间复杂度常数阶
void algorithm_A(int n)
{
Console.WriteLine(0);
}
// 算法 B 时间复杂度线性阶
void algorithm_B(int n)
{
for (int i = 0; i < n; i++)
{
Console.WriteLine(0);
}
}
// 算法 C 时间复杂度常数阶
void algorithm_C(int n)
{
for (int i = 0; i < 1000000; i++)
{
Console.WriteLine(0);
}
}
```
=== "Swift"
```swift title=""
// 算法 A 时间复杂度常数阶
func algorithmA(n: Int) {
print(0)
}
// 算法 B 时间复杂度线性阶
func algorithmB(n: Int) {
for _ in 0 ..< n {
print(0)
}
}
// 算法 C 时间复杂度常数阶
func algorithmC(n: Int) {
for _ in 0 ..< 1000000 {
print(0)
}
}
```
=== "Zig"
```zig title=""
```
![算法 A, B, C 的时间增长趋势](time_complexity.assets/time_complexity_simple_example.png)
相较于直接统计算法运行时间时间复杂度分析有哪些优势和局限性呢
**时间复杂度能够有效评估算法效率**例如算法 `B` 的运行时间呈线性增长 $n > 1$ 时比算法 `A` 慢,在 $n > 1000000$ 时比算法 `C` 慢。事实上,只要输入数据大小 $n$ 足够大,复杂度为「常数阶」的算法一定优于「线性阶」的算法,这正是时间增长趋势所表达的含义。
**时间复杂度的推算方法更简便**。显然,运行平台和计算操作类型都与算法运行时间的增长趋势无关。因此在时间复杂度分析中,我们可以简单地将所有计算操作的执行时间视为相同的“单位时间”,从而将“计算操作的运行时间的统计”简化为“计算操作的数量的统计”,这样的简化方法大大降低了估算难度。
**时间复杂度也存在一定的局限性**。例如,尽管算法 `A``C` 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 `B` 的时间复杂度比 `C` 高,但在输入数据大小 $n$ 较小时,算法 `B` 明显优于算法 `C` 。在这些情况下,我们很难仅凭时间复杂度判断算法效率高低。当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
## 函数渐近上界
设算法的计算操作数量是一个关于输入数据大小 $n$ 的函数,记为 $T(n)$ ,则以下算法的操作数量为
$$
T(n) = 3 + 2n
$$
=== "Java"
```java title=""
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 循环 n 次
for (int i = 0; i < n; i++) { // +1每轮都执行 i ++
System.out.println(0); // +1
}
}
```
=== "C++"
```cpp title=""
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 循环 n
for (int i = 0; i < n; i++) { // +1每轮都执行 i ++
cout << 0 << endl; // +1
}
}
```
=== "Python"
```python title=""
def algorithm(n: int) -> None:
a: int = 1 # +1
a = a + 1 # +1
a = a * 2 # +1
# 循环 n 次
for i in range(n): # +1
print(0) # +1
```
=== "Go"
```go title=""
func algorithm(n int) {
a := 1 // +1
a = a + 1 // +1
a = a * 2 // +1
// 循环 n 次
for i := 0; i < n; i++ { // +1
fmt.Println(a) // +1
}
}
```
=== "JavaScript"
```javascript title=""
function algorithm(n) {
var a = 1; // +1
a += 1; // +1
a *= 2; // +1
// 循环 n
for(let i = 0; i < n; i++){ // +1每轮都执行 i ++
console.log(0); // +1
}
}
```
=== "TypeScript"
```typescript title=""
function algorithm(n: number): void{
var a: number = 1; // +1
a += 1; // +1
a *= 2; // +1
// 循环 n
for(let i = 0; i < n; i++){ // +1每轮都执行 i ++
console.log(0); // +1
}
}
```
=== "C"
```c title=""
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 循环 n
for (int i = 0; i < n; i++) { // +1每轮都执行 i ++
printf("%d", 0); // +1
}
}
```
=== "C#"
```csharp title=""
void algorithm(int n)
{
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 循环 n
for (int i = 0; i < n; i++) // +1每轮都执行 i ++
{
Console.WriteLine(0); // +1
}
}
```
=== "Swift"
```swift title=""
func algorithm(n: Int) {
var a = 1 // +1
a = a + 1 // +1
a = a * 2 // +1
// 循环 n
for _ in 0 ..< n { // +1
print(0) // +1
}
}
```
=== "Zig"
```zig title=""
```
$T(n)$ 是一次函数说明时间增长趋势是线性的因此可以得出时间复杂度是线性阶
我们将线性阶的时间复杂度记为 $O(n)$ 这个数学符号称为 $O$ 记号 Big-$O$ Notation」,表示函数 $T(n)$ 渐近上界 Asymptotic Upper Bound」。
推算时间复杂度本质上是计算操作数量函数 $T(n)$”的渐近上界接下来我们来看函数渐近上界的数学定义
!!! abstract "函数渐近上界"
若存在正实数 $c$ 和实数 $n_0$ 使得对于所有的 $n > n_0$ ,均有
$$
T(n) \leq c \cdot f(n)
$$
则可认为 $f(n)$ 给出了 $T(n)$ 的一个渐近上界,记为
$$
T(n) = O(f(n))
$$
![函数的渐近上界](time_complexity.assets/asymptotic_upper_bound.png)
从本质上讲,计算渐近上界就是寻找一个函数 $f(n)$ ,使得当 $n$ 趋向于无穷大时,$T(n)$ 和 $f(n)$ 处于相同的增长级别,仅相差一个常数项 $c$ 的倍数。
## 推算方法
渐近上界的数学味儿有点重,如果你感觉没有完全理解,也无需担心。因为在实际使用中,我们只需要掌握推算方法,数学意义可以逐渐领悟。
根据定义,确定 $f(n)$ 之后,我们便可得到时间复杂度 $O(f(n))$ 。那么如何确定渐近上界 $f(n)$ 呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
### 1) 统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述 $c \cdot f(n)$ 中的常数项 $c$ 可以取任意大小,**因此操作数量 $T(n)$ 中的各种系数、常数项都可以被忽略**。根据此原则,可以总结出以下计数简化技巧:
1. **忽略与 $n$ 无关的操作**。因为它们都是 $T(n)$ 中的常数项,对时间复杂度不产生影响。
2. **省略所有系数**。例如,循环 $2n$ 次、$5n + 1$ 次等,都可以简化记为 $n$ 次,因为 $n$ 前面的系数对时间复杂度没有影响。
3. **循环嵌套时使用乘法**。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用上述 `1.``2.` 技巧。
以下示例展示了使用上述技巧前、后的统计结果。
$$
\begin{aligned}
T(n) & = 2n(n + 1) + (5n + 1) + 2 & \text{完整统计 (-.-|||)} \newline
& = 2n^2 + 7n + 3 \newline
T(n) & = n^2 + n & \text{偷懒统计 (o.O)}
\end{aligned}
$$
最终,两者都能推出相同的时间复杂度结果,即 $O(n^2)$ 。
=== "Java"
```java title=""
void algorithm(int n) {
int a = 1; // +0技巧 1
a = a + n; // +0技巧 1
// +n技巧 2
for (int i = 0; i < 5 * n + 1; i++) {
System.out.println(0);
}
// +n*n技巧 3
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
System.out.println(0);
}
}
}
```
=== "C++"
```cpp title=""
void algorithm(int n) {
int a = 1; // +0技巧 1
a = a + n; // +0技巧 1
// +n技巧 2
for (int i = 0; i < 5 * n + 1; i++) {
cout << 0 << endl;
}
// +n*n技巧 3
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
cout << 0 << endl;
}
}
}
```
=== "Python"
```python title=""
def algorithm(n: int) -> None:
a: int = 1 # +0技巧 1
a = a + n # +0技巧 1
# +n技巧 2
for i in range(5 * n + 1):
print(0)
# +n*n技巧 3
for i in range(2 * n):
for j in range(n + 1):
print(0)
```
=== "Go"
```go title=""
func algorithm(n int) {
a := 1 // +0技巧 1
a = a + n // +0技巧 1
// +n技巧 2
for i := 0; i < 5 * n + 1; i++ {
fmt.Println(0)
}
// +n*n技巧 3
for i := 0; i < 2 * n; i++ {
for j := 0; j < n + 1; j++ {
fmt.Println(0)
}
}
}
```
=== "JavaScript"
```javascript title=""
function algorithm(n) {
let a = 1; // +0技巧 1
a = a + n; // +0技巧 1
// +n技巧 2
for (let i = 0; i < 5 * n + 1; i++) {
console.log(0);
}
// +n*n技巧 3
for (let i = 0; i < 2 * n; i++) {
for (let j = 0; j < n + 1; j++) {
console.log(0);
}
}
}
```
=== "TypeScript"
```typescript title=""
function algorithm(n: number): void {
let a = 1; // +0技巧 1
a = a + n; // +0技巧 1
// +n技巧 2
for (let i = 0; i < 5 * n + 1; i++) {
console.log(0);
}
// +n*n技巧 3
for (let i = 0; i < 2 * n; i++) {
for (let j = 0; j < n + 1; j++) {
console.log(0);
}
}
}
```
=== "C"
```c title=""
void algorithm(int n) {
int a = 1; // +0技巧 1
a = a + n; // +0技巧 1
// +n技巧 2
for (int i = 0; i < 5 * n + 1; i++) {
printf("%d", 0);
}
// +n*n技巧 3
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
printf("%d", 0);
}
}
}
```
=== "C#"
```csharp title=""
void algorithm(int n)
{
int a = 1; // +0技巧 1
a = a + n; // +0技巧 1
// +n技巧 2
for (int i = 0; i < 5 * n + 1; i++)
{
Console.WriteLine(0);
}
// +n*n技巧 3
for (int i = 0; i < 2 * n; i++)
{
for (int j = 0; j < n + 1; j++)
{
Console.WriteLine(0);
}
}
}
```
=== "Swift"
```swift title=""
func algorithm(n: Int) {
var a = 1 // +0技巧 1
a = a + n // +0技巧 1
// +n技巧 2
for _ in 0 ..< (5 * n + 1) {
print(0)
}
// +n*n技巧 3
for _ in 0 ..< (2 * n) {
for _ in 0 ..< (n + 1) {
print(0)
}
}
}
```
=== "Zig"
```zig title=""
```
### 2) 判断渐近上界
**时间复杂度由多项式 $T(n)$ 中最高阶的项来决定**这是因为在 $n$ 趋于无穷大时最高阶的项将发挥主导作用其它项的影响都可以被忽略
以下表格展示了一些例子其中一些夸张的值是为了强调系数无法撼动阶数这一结论 $n$ 趋于无穷大时这些常数变得无足轻重
<div class="center-table" markdown>
| 操作数量 $T(n)$ | 时间复杂度 $O(f(n))$ |
| ---------------------- | -------------------- |
| $100000$ | $O(1)$ |
| $3n + 2$ | $O(n)$ |
| $2n^2 + 3n + 2$ | $O(n^2)$ |
| $n^3 + 10000n^2$ | $O(n^3)$ |
| $2^n + 10000n^{10000}$ | $O(2^n)$ |
</div>
## 常见类型
设输入数据大小为 $n$ ,常见的时间复杂度类型包括(按照从低到高的顺序排列):
$$
\begin{aligned}
O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!) \newline
\text{常数阶} < \text{对数阶} < \text{线性阶} < \text{线性对数阶} < \text{平方阶} < \text{指数阶} < \text{阶乘阶}
\end{aligned}
$$
![时间复杂度的常见类型](time_complexity.assets/time_complexity_common_types.png)
!!! tip
部分示例代码需要一些预备知识包括数组递归算法等如果遇到不理解的部分请不要担心可以在学习完后面章节后再回顾现阶段请先专注于理解时间复杂度的含义和推算方法
### 常数阶 $O(1)$
常数阶的操作数量与输入数据大小 $n$ 无关即不随着 $n$ 的变化而变化
对于以下算法尽管操作数量 `size` 可能很大但由于其与数据大小 $n$ 无关因此时间复杂度仍为 $O(1)$
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{constant}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{constant}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{constant}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{constant}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{constant}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{constant}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{constant}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{constant}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{constant}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{constant}
```
### 线性阶 $O(n)$
线性阶的操作数量相对于输入数据大小以线性级别增长线性阶通常出现在单层循环中
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{linear}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{linear}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{linear}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{linear}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{linear}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{linear}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{linear}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{linear}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{linear}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{linear}
```
遍历数组和遍历链表等操作的时间复杂度均为 $O(n)$ 其中 $n$ 为数组或链表的长度
!!! question "如何确定输入数据大小 $n$ "
**数据大小 $n$ 需根据输入数据的类型来具体确定**例如在上述示例中我们直接将 $n$ 视为输入数据大小在下面遍历数组的示例中数据大小 $n$ 为数组的长度
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{arrayTraversal}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{arrayTraversal}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{array_traversal}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{arrayTraversal}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{arrayTraversal}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{arrayTraversal}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{arrayTraversal}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{arrayTraversal}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{arrayTraversal}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{arrayTraversal}
```
### 平方阶 $O(n^2)$
平方阶的操作数量相对于输入数据大小以平方级别增长平方阶通常出现在嵌套循环中外层循环和内层循环都为 $O(n)$ 因此总体为 $O(n^2)$
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{quadratic}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{quadratic}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{quadratic}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{quadratic}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{quadratic}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{quadratic}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{quadratic}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{quadratic}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{quadratic}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{quadratic}
```
![常数阶、线性阶、平方阶的时间复杂度](time_complexity.assets/time_complexity_constant_linear_quadratic.png)
冒泡排序为例外层循环执行 $n - 1$ 内层循环执行 $n-1, n-2, \cdots, 2, 1$ 平均为 $\frac{n}{2}$ 因此时间复杂度为 $O(n^2)$
$$
O((n - 1) \frac{n}{2}) = O(n^2)
$$
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{bubbleSort}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{bubbleSort}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{bubble_sort}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{bubbleSort}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{bubbleSort}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{bubbleSort}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{bubbleSort}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{bubbleSort}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{bubbleSort}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{bubbleSort}
```
### 指数阶 $O(2^n)$
!!! note
生物学的细胞分裂是指数阶增长的典型例子初始状态为 $1$ 个细胞分裂一轮后变为 $2$ 分裂两轮后变为 $4$ 以此类推分裂 $n$ 轮后有 $2^n$ 个细胞
指数阶增长非常迅速在实际应用中通常是不可接受的若一个问题使用暴力枚举求解的时间复杂度为 $O(2^n)$ 那么通常需要使用动态规划贪心算法等方法来解决
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{exponential}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{exponential}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{exponential}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{exponential}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{exponential}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{exponential}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{exponential}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{exponential}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{exponential}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{exponential}
```
![指数阶的时间复杂度](time_complexity.assets/time_complexity_exponential.png)
在实际算法中指数阶常出现于递归函数例如以下代码不断地一分为二经过 $n$ 次分裂后停止
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{expRecur}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{expRecur}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{exp_recur}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{expRecur}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{expRecur}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{expRecur}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{expRecur}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{expRecur}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{expRecur}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{expRecur}
```
### 对数阶 $O(\log n)$
与指数阶相反对数阶反映了每轮缩减到一半的情况”。对数阶仅次于常数阶时间增长缓慢是理想的时间复杂度
对数阶常出现于二分查找分治算法体现了一分为多化繁为简的算法思想
设输入数据大小为 $n$ 由于每轮缩减到一半因此循环次数是 $\log_2 n$ $2^n$ 的反函数
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{logarithmic}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{logarithmic}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{logarithmic}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{logarithmic}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{logarithmic}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{logarithmic}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{logarithmic}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{logarithmic}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{logarithmic}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{logarithmic}
```
![对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic.png)
与指数阶类似对数阶也常出现于递归函数以下代码形成了一个高度为 $\log_2 n$ 的递归树
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{logRecur}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{logRecur}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{log_recur}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{logRecur}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{logRecur}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{logRecur}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{logRecur}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{logRecur}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{logRecur}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{logRecur}
```
### 线性对数阶 $O(n \log n)$
线性对数阶常出现于嵌套循环中两层循环的时间复杂度分别为 $O(\log n)$ $O(n)$
主流排序算法的时间复杂度通常为 $O(n \log n)$ 例如快速排序归并排序堆排序等
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{linearLogRecur}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{linearLogRecur}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{linear_log_recur}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{linearLogRecur}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{linearLogRecur}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{linearLogRecur}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{linearLogRecur}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{linearLogRecur}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{linearLogRecur}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{linearLogRecur}
```
![线性对数阶的时间复杂度](time_complexity.assets/time_complexity_logarithmic_linear.png)
### 阶乘阶 $O(n!)$
阶乘阶对应数学上的全排列问题给定 $n$ 个互不重复的元素求其所有可能的排列方案方案数量为
$$
n! = n \times (n - 1) \times (n - 2) \times \cdots \times 2 \times 1
$$
阶乘通常使用递归实现例如以下代码第一层分裂出 $n$ 第二层分裂出 $n - 1$ 以此类推直至第 $n$ 层时终止分裂
=== "Java"
```java title="time_complexity.java"
[class]{time_complexity}-[func]{factorialRecur}
```
=== "C++"
```cpp title="time_complexity.cpp"
[class]{}-[func]{factorialRecur}
```
=== "Python"
```python title="time_complexity.py"
[class]{}-[func]{factorial_recur}
```
=== "Go"
```go title="time_complexity.go"
[class]{}-[func]{factorialRecur}
```
=== "JavaScript"
```javascript title="time_complexity.js"
[class]{}-[func]{factorialRecur}
```
=== "TypeScript"
```typescript title="time_complexity.ts"
[class]{}-[func]{factorialRecur}
```
=== "C"
```c title="time_complexity.c"
[class]{}-[func]{factorialRecur}
```
=== "C#"
```csharp title="time_complexity.cs"
[class]{time_complexity}-[func]{factorialRecur}
```
=== "Swift"
```swift title="time_complexity.swift"
[class]{}-[func]{factorialRecur}
```
=== "Zig"
```zig title="time_complexity.zig"
[class]{}-[func]{factorialRecur}
```
![阶乘阶的时间复杂度](time_complexity.assets/time_complexity_factorial.png)
## 最差、最佳、平均时间复杂度
**某些算法的时间复杂度不是固定的,而是与输入数据的分布有关**例如假设输入一个长度为 $n$ 的数组 `nums` 其中 `nums` 由从 $1$ $n$ 的数字组成但元素顺序是随机打乱的算法的任务是返回元素 $1$ 的索引我们可以得出以下结论
- `nums = [?, ?, ..., 1]` 即当末尾元素是 $1$ 需要完整遍历数组此时达到 **最差时间复杂度 $O(n)$**
- `nums = [1, ?, ?, ...]` 即当首个数字为 $1$ 无论数组多长都不需要继续遍历此时达到 **最佳时间复杂度 $\Omega(1)$**
函数渐近上界使用大 $O$ 记号表示代表最差时间复杂度」。相应地,“函数渐近下界 $\Omega$ 记号来表示代表最佳时间复杂度」。
=== "Java"
```java title="worst_best_time_complexity.java"
[class]{worst_best_time_complexity}-[func]{randomNumbers}
[class]{worst_best_time_complexity}-[func]{findOne}
```
=== "C++"
```cpp title="worst_best_time_complexity.cpp"
[class]{}-[func]{randomNumbers}
[class]{}-[func]{findOne}
```
=== "Python"
```python title="worst_best_time_complexity.py"
[class]{}-[func]{random_numbers}
[class]{}-[func]{find_one}
```
=== "Go"
```go title="worst_best_time_complexity.go"
[class]{}-[func]{randomNumbers}
[class]{}-[func]{findOne}
```
=== "JavaScript"
```javascript title="worst_best_time_complexity.js"
[class]{}-[func]{randomNumbers}
[class]{}-[func]{findOne}
```
=== "TypeScript"
```typescript title="worst_best_time_complexity.ts"
[class]{}-[func]{randomNumbers}
[class]{}-[func]{findOne}
```
=== "C"
```c title="worst_best_time_complexity.c"
[class]{}-[func]{randomNumbers}
[class]{}-[func]{findOne}
```
=== "C#"
```csharp title="worst_best_time_complexity.cs"
[class]{worst_best_time_complexity}-[func]{randomNumbers}
[class]{worst_best_time_complexity}-[func]{findOne}
```
=== "Swift"
```swift title="worst_best_time_complexity.swift"
[class]{}-[func]{randomNumbers}
[class]{}-[func]{findOne}
```
=== "Zig"
```zig title="worst_best_time_complexity.zig"
// 生成一个数组元素为 { 1, 2, ..., n }顺序被打乱
pub fn randomNumbers(comptime n: usize) [n]i32 {
var nums: [n]i32 = undefined;
// 生成数组 nums = { 1, 2, 3, ..., n }
for (nums) |*num, i| {
num.* = @intCast(i32, i) + 1;
}
// 随机打乱数组元素
const rand = std.crypto.random;
rand.shuffle(i32, &nums);
return nums;
}
// 查找数组 nums 中数字 1 所在索引
pub fn findOne(nums: []i32) i32 {
for (nums) |num, i| {
// 当元素 1 在数组头部时达到最佳时间复杂度 O(1)
// 当元素 1 在数组尾部时达到最差时间复杂度 O(n)
if (num == 1) return @intCast(i32, i);
}
return -1;
}
```
!!! tip
实际应用中我们很少使用最佳时间复杂度」,因为通常只有在很小概率下才能达到可能会带来一定的误导性相反,「最差时间复杂度更为实用因为它给出了一个效率安全值”,让我们可以放心地使用算法
从上述示例可以看出最差或最佳时间复杂度只出现在特殊分布的数据这些情况的出现概率可能很小因此并不能最真实地反映算法运行效率相较之下**「平均时间复杂度可以体现算法在随机输入数据下的运行效率** $\Theta$ 记号来表示
对于部分算法我们可以简单地推算出随机数据分布下的平均情况比如上述示例由于输入数组是被打乱的因此元素 $1$ 出现在任意索引的概率都是相等的那么算法的平均循环次数则是数组长度的一半 $\frac{n}{2}$ 平均时间复杂度为 $\Theta(\frac{n}{2}) = \Theta(n)$
但在实际应用中尤其是较为复杂的算法计算平均时间复杂度比较困难因为很难简便地分析出在数据分布下的整体数学期望在这种情况下我们通常使用最差时间复杂度作为算法效率的评判标准
!!! question "为什么很少看到 $\Theta$ 符号"
可能由于 $O$ 符号过于朗朗上口我们常常使用它来表示平均复杂度」,但从严格意义上看这种做法并不规范在本书和其他资料中若遇到类似平均时间复杂度 $O(n)$”的表述请将其直接理解为 $\Theta(n)$