一、时间复杂度:衡量算法运行效率
1. 什么是时间复杂度?
时间复杂度不是程序运行了多少秒,而是衡量:
当输入规模 nnn 增大时,算法需要执行的基本操作次数如何增长。
这里的"基本操作"可以是赋值、比较、加法、交换、数组访问等。我们通常假设每种基本操作耗时固定,记为 O(1)O(1)O(1)。
例如:
- 访问数组元素
arr[0]:一次操作,O(1)O(1)O(1) - 遍历长度为 nnn 的数组:执行 nnn 次操作,O(n)O(n)O(n)
- 两层嵌套循环,每层执行 nnn 次:执行 n2n^2n2 次操作,O(n2)O(n^2)O(n2)
时间复杂度关注的是增长趋势,不是具体次数。
2. 大 OOO 符号
大 OOO 表示算法运行时间的上界。正式定义:
若存在正常数 ccc 和 n0n_0n0,使得对于所有 n≥n0n \ge n_0n≥n0,都有:
0≤f(n)≤c⋅g(n) 0 \le f(n) \le c \cdot g(n) 0≤f(n)≤c⋅g(n)
则称:
f(n)=O(g(n)) f(n) = O(g(n)) f(n)=O(g(n))
简单说:当 nnn 足够大时,f(n)f(n)f(n) 的增长不会超过 g(n)g(n)g(n) 的某个常数倍。
大 OOO 会忽略:
- 常数项,例如 3n+53n + 53n+5 记为 O(n)O(n)O(n)
- 低阶项,例如 n2+100n+50n^2 + 100n + 50n2+100n+50 记为 O(n2)O(n^2)O(n2)
- 常数系数,例如 2n22n^22n2 记为 O(n2)O(n^2)O(n2)
除此之外还有:
- Ω\OmegaΩ:下界
- Θ\ThetaΘ:紧确界
但日常算法分析中,最常用的是大 OOO。
3. 常见时间复杂度类型
按效率从高到低排列:
| 复杂度 | 名称 | 增长特点 | 典型场景 |
|---|---|---|---|
| O(1)O(1)O(1) | 常数时间 | 不随 nnn 变化 | 访问数组元素 |
| O(logn)O(\log n)O(logn) | 对数时间 | 每次问题规模减半 | 二分查找 |
| O(n)O(n)O(n) | 线性时间 | 与 nnn 成正比 | 遍历数组 |
| O(nlogn)O(n \log n)O(nlogn) | 线性对数时间 | 高效排序 | 归并排序、快速排序平均 |
| O(n2)O(n^2)O(n2) | 平方时间 | 两层嵌套循环 | 冒泡排序、选择排序 |
| O(n3)O(n^3)O(n3) | 立方时间 | 三层嵌套循环 | 普通矩阵乘法 |
| O(2n)O(2^n)O(2n) | 指数时间 | 每层翻倍 | 朴素斐波那契、子集枚举 |
| O(n!)O(n!)O(n!) | 阶乘时间 | 全排列 | 生成所有排列 |
大小关系:
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!) O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n) < O(n!) O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)
注意:对数底数不影响大 OOO,因为:
logan=logbnlogba \log_a n = \frac{\log_b n}{\log_b a} logan=logbalogbn
分母是常数,可以忽略。所以 O(log2n)O(\log_2 n)O(log2n)、O(log10n)O(\log_{10} n)O(log10n) 都写成 O(logn)O(\log n)O(logn)。
二、如何计算时间复杂度?
计算时间复杂度一般按以下步骤:
- 找出基本操作。
- 分析循环执行次数。
- 嵌套循环通常相乘。
- 并列循环通常相加。
- 递归算法列递归式。
- 忽略常数和低阶项。
- 只保留最高阶项。
下面通过例子详细说明。
例 1:常数时间 O(1)O(1)O(1)
python
def get_first(arr):
return arr[0]
无论数组多长,只访问第一个元素一次。
所以:
T(n)=O(1) T(n) = O(1) T(n)=O(1)
例 2:单层循环 O(n)O(n)O(n)
python
def sum_array(arr):
total = 0
for x in arr:
total += x
return total
循环执行 nnn 次,每次做一次加法。
T(n)=n⋅O(1)=O(n) T(n) = n \cdot O(1) = O(n) T(n)=n⋅O(1)=O(n)
例 3:并列循环 O(n+m)O(n + m)O(n+m)
python
def two_loops(n, m):
for i in range(n):
print(i)
for j in range(m):
print(j)
第一个循环 nnn 次,第二个循环 mmm 次。
T(n,m)=O(n+m) T(n, m) = O(n + m) T(n,m)=O(n+m)
如果 m=nm = nm=n,则为:
O(n) O(n) O(n)
例 4:双层嵌套循环 O(n2)O(n^2)O(n2)
python
def nested_loop(n):
for i in range(n):
for j in range(n):
print(i, j)
外层执行 nnn 次,内层也执行 nnn 次。
总次数:
n×n=n2 n \times n = n^2 n×n=n2
所以:
T(n)=O(n2) T(n) = O(n^2) T(n)=O(n2)
例 5:三角形嵌套循环仍然是 O(n2)O(n^2)O(n2)
python
def triangle_loop(n):
for i in range(n):
for j in range(i):
print(i, j)
执行次数:
0+1+2+⋯+(n−1)=n(n−1)2 0 + 1 + 2 + \cdots + (n-1) = \frac{n(n-1)}{2} 0+1+2+⋯+(n−1)=2n(n−1)
展开:
n22−n2 \frac{n^2}{2} - \frac{n}{2} 2n2−2n
忽略常数和低阶项:
T(n)=O(n2) T(n) = O(n^2) T(n)=O(n2)
虽然比标准双层循环少一半,但增长趋势仍是平方级。
例 6:循环变量倍增 O(logn)O(\log n)O(logn)
python
def logarithmic_loop(n):
i = 1
while i < n:
print(i)
i *= 2
执行次数 kkk 满足:
2k≥n 2^k \ge n 2k≥n
所以:
k=⌈log2n⌉ k = \lceil \log_2 n \rceil k=⌈log2n⌉
因此:
T(n)=O(logn) T(n) = O(\log n) T(n)=O(logn)
典型场景:二分查找、平衡二叉树查找。
例 7:二分查找 O(logn)O(\log n)O(logn)
python
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
每次比较后,搜索范围大约减半:
n→n2→n4→⋯→1 n \rightarrow \frac{n}{2} \rightarrow \frac{n}{4} \rightarrow \cdots \rightarrow 1 n→2n→4n→⋯→1
需要次数:
log2n \log_2 n log2n
所以:
T(n)=O(logn) T(n) = O(\log n) T(n)=O(logn)
空间复杂度:迭代版本只用了几个变量,O(1)O(1)O(1)。
例 8:归并排序 O(nlogn)O(n \log n)O(nlogn)
归并排序递归式:
T(n)=2T(n2)+O(n) T(n) = 2T\left(\frac{n}{2}\right) + O(n) T(n)=2T(2n)+O(n)
解释:
- 分成两个规模为 n/2n/2n/2 的子问题:2T(n/2)2T(n/2)2T(n/2)
- 合并两个有序数组需要 O(n)O(n)O(n)
根据主定理:
T(n)=O(nlogn) T(n) = O(n \log n) T(n)=O(nlogn)
空间复杂度:需要额外数组,O(n)O(n)O(n)。
例 9:递归阶乘 O(n)O(n)O(n)
python
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
递归式:
T(n)=T(n−1)+O(1) T(n) = T(n-1) + O(1) T(n)=T(n−1)+O(1)
展开:
T(n)=T(n−1)+1=T(n−2)+2=⋯=O(n) T(n) = T(n-1) + 1 = T(n-2) + 2 = \cdots = O(n) T(n)=T(n−1)+1=T(n−2)+2=⋯=O(n)
所以时间复杂度:
O(n) O(n) O(n)
空间复杂度:递归调用栈深度为 nnn,所以:
O(n) O(n) O(n)
例 10:朴素斐波那契 O(2n)O(2^n)O(2n)
python
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
递归式近似:
T(n)=T(n−1)+T(n−2)+O(1) T(n) = T(n-1) + T(n-2) + O(1) T(n)=T(n−1)+T(n−2)+O(1)
这棵递归树接近指数增长:
T(n)=O(2n) T(n) = O(2^n) T(n)=O(2n)
更精确地说,约为:
O(φn) O(\varphi^n) O(φn)
其中 φ≈1.618\varphi \approx 1.618φ≈1.618,但通常写 O(2n)O(2^n)O(2n)。
空间复杂度:递归栈最大深度为 nnn,所以 O(n)O(n)O(n)。
如果使用记忆化,时间复杂度降为 O(n)O(n)O(n),空间复杂度为 O(n)O(n)O(n)。
例 11:全排列 O(n!)O(n!)O(n!)
python
def permute(arr, start):
if start == len(arr):
print(arr)
return
for i in range(start, len(arr)):
arr[start], arr[i] = arr[i], arr[start]
permute(arr, start + 1)
arr[start], arr[i] = arr[i], arr[start]
第一次有 nnn 种选择,第二次有 n−1n-1n−1 种,第三次 n−2n-2n−2 种......
总排列数:
n×(n−1)×(n−2)×⋯×1=n! n \times (n-1) \times (n-2) \times \cdots \times 1 = n! n×(n−1)×(n−2)×⋯×1=n!
所以:
T(n)=O(n!) T(n) = O(n!) T(n)=O(n!)
空间复杂度:递归栈深度 nnn,即 O(n)O(n)O(n),不计输出结果。
例 12:最好、最坏、平均情况
以顺序查找为例:
python
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1
- 最好情况:第一个元素就是目标,O(1)O(1)O(1)
- 最坏情况:目标不存在或在最后,O(n)O(n)O(n)
- 平均情况:大约检查 n/2n/2n/2 个元素,仍为 O(n)O(n)O(n)
所以通常说顺序查找时间复杂度是:
O(n) O(n) O(n)
三、空间复杂度
1. 定义
空间复杂度衡量算法运行过程中所需存储空间随输入规模 nnn 的增长关系。
严格来说,总空间包括:
- 输入数据本身
- 算法代码
- 临时变量
- 辅助数据结构
- 递归调用栈
但在算法分析中,通常只关注额外辅助空间,不把输入本身算进去。
2. 常见空间复杂度
| 复杂度 | 名称 | 典型场景 |
|---|---|---|
| O(1)O(1)O(1) | 常数空间 | 冒泡排序、迭代二分 |
| O(logn)O(\log n)O(logn) | 对数空间 | 递归二分、平衡递归 |
| O(n)O(n)O(n) | 线性空间 | 复制数组、归并排序 |
| O(n2)O(n^2)O(n2) | 平方空间 | 二维矩阵、邻接矩阵 |
3. 空间复杂度计算例子
例 1:冒泡排序 O(1)O(1)O(1)
冒泡排序只用了几个临时变量,如 i、j、temp,不随 nnn 增大而增大。
空间复杂度:
O(1) O(1) O(1)
例 2:复制数组 O(n)O(n)O(n)
python
def copy_array(arr):
new_arr = []
for x in arr:
new_arr.append(x)
return new_arr
新数组长度与输入相同。
空间复杂度:
O(n) O(n) O(n)
例 3:递归二分查找 O(logn)O(\log n)O(logn)
递归版二分查找每次调用栈深度为:
logn \log n logn
所以空间复杂度:
O(logn) O(\log n) O(logn)
迭代版则是:
O(1) O(1) O(1)
例 4:归并排序 O(n)O(n)O(n)
归并排序需要额外数组来合并两个有序子数组。
空间复杂度:
O(n) O(n) O(n)
递归栈深度是 O(logn)O(\log n)O(logn),但辅助数组 O(n)O(n)O(n) 更大,所以总体为 O(n)O(n)O(n)。
四、算法稳定性
1. 什么是稳定性?
排序算法的稳定性是指:
对于数组中值相同的元素,排序后它们的相对顺序是否保持不变。
如果相同值的元素排序后仍然保持原来的先后顺序,则算法是稳定的。
例如原数组:
text
[(3, A), (1, B), (3, C), (2, D)]
按数字升序排序。
稳定排序结果:
text
[(1, B), (2, D), (3, A), (3, C)]
两个 333 中,原来的 AAA 在 CCC 前面,排序后仍然 AAA 在 CCC 前面。
不稳定排序可能得到:
text
[(1, B), (2, D), (3, C), (3, A)]
两个 333 的相对顺序改变了。
2. 常见排序算法稳定性
| 排序算法 | 时间复杂度 | 空间复杂度 | 是否稳定 |
|---|---|---|---|
| 冒泡排序 | 最好 O(n)O(n)O(n),最坏 O(n2)O(n^2)O(n2) | O(1)O(1)O(1) | 稳定 |
| 插入排序 | 最好 O(n)O(n)O(n),最坏 O(n2)O(n^2)O(n2) | O(1)O(1)O(1) | 稳定 |
| 归并排序 | O(nlogn)O(n \log n)O(nlogn) | O(n)O(n)O(n) | 稳定 |
| 快速排序 | 平均 O(nlogn)O(n \log n)O(nlogn),最坏 O(n2)O(n^2)O(n2) | 平均 O(logn)O(\log n)O(logn),最坏 O(n)O(n)O(n) | 不稳定 |
| 选择排序 | O(n2)O(n^2)O(n2) | O(1)O(1)O(1) | 不稳定 |
| 堆排序 | O(nlogn)O(n \log n)O(nlogn) | O(1)O(1)O(1) | 不稳定 |
3. 为什么选择排序不稳定?
原数组:
text
[(5, A), (5, B), (2, C)]
选择排序第一轮找到最小值 222,然后与第一个元素 5A5A5A 交换:
text
[(2, C), (5, B), (5, A)]
两个 555 的顺序从原来的:
text
5A, 5B
变成了:
text
5B, 5A
所以选择排序不稳定。
4. 为什么冒泡排序稳定?
冒泡排序只在前一个元素大于后一个元素时交换。
如果两个元素相等:
python
if arr[j] > arr[j + 1]:
swap(arr[j], arr[j + 1])
使用 > 而不是 >=,相等时不会交换。
因此相同值的元素相对顺序不会改变,所以冒泡排序稳定。
5. 为什么归并排序稳定?
归并排序在合并两个有序数组时,如果左右两边元素相等,优先取左边的元素。
例如:
text
左边:[3A]
右边:[3B]
合并时先取 3A,再取 3B,保持原顺序。
所以归并排序稳定。
五、总结
1. 时间复杂度
时间复杂度衡量算法操作次数随输入规模 nnn 的增长趋势。
大 OOO 忽略常数和低阶项,只保留最高阶。
常见复杂度:
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(2n)<O(n!) O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(2^n) < O(n!) O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(2n)<O(n!)
2. 计算时间复杂度
- 单层循环:O(n)O(n)O(n)
- 双层嵌套:通常 O(n2)O(n^2)O(n2)
- 每次减半:O(logn)O(\log n)O(logn)
- 递归分治:列递归式,如归并排序 O(nlogn)O(n \log n)O(nlogn)
- 指数递归:如朴素斐波那契 O(2n)O(2^n)O(2n)
- 全排列:O(n!)O(n!)O(n!)
3. 空间复杂度
空间复杂度衡量额外辅助空间增长,常见有 O(1)O(1)O(1)、O(logn)O(\log n)O(logn)、O(n)O(n)O(n)。
4. 稳定性
稳定性表示相同值元素排序后相对顺序是否保持。
- 稳定排序:冒泡、插入、归并。
- 不稳定排序:快速、选择、堆。
掌握这些概念后,你就可以分析大多数常见算法的时间复杂度、空间复杂度和稳定性。