算法基础1:复杂度分析
什么是算法复杂度
算法(Algorithm)是用来操作数据、解决程序问题的一组方法。
对于同一个程序问题,使用不同的算法,也许最终得到的结果一样,但是程序执行过程中消耗的资源和时间却会有很大的区别。
我们通常从两个维度衡量算法的效率:
- 时间复杂度(Time Complexity):程序执行算法所消耗的时间。
- 空间复杂度(Space Complexity):程序执行算法所需要占用的额外内存空间。
然而,时间和空间往往不能同时做到最优,所以需要根据场景取舍。
n 变大时,资源消耗如何增长,而不是某台机器上的一次实测耗时。时间复杂度
时间复杂度描述程序运行算法所需要的时间。运行时间越短,通常意味着效率越高。
我们来学习如何计算这个时间复杂度,很多人想的是把这个算法程序运行然后计算时间即可,但是我们得考虑运行环境,比如在不同配置的电脑上运行结果相差很大。
为了便于分析,假设每一行代码的运行时间都为 1 Btime,那么算法的总运行时间就等于代码执行次数的总和。
下面我们来看一下这段代码(提供了各种语言的例子):
def dogeggs_sum (n):
sum = 0
for dogegg in range(n):
sum += dogegg
return sum
fun dogeggs_sum(n: Int): Int {
var sum = 0
for (dogegg in 0 until n) {
sum += dogegg
}
return sum
}
func dogeggs_sum(_ n: Int) -> Int {
var sum = 0
for dogegg in 0..<n {
sum += dogegg
}
return sum
}
int dogeggs_sum(int n) {
var sum = 0;
for (var dogegg = 0; dogegg < n; dogegg++) {
sum += dogegg;
}
return sum;
}
function dogeggs_sum(n) {
let sum = 0;
for (let dogegg = 0; dogegg < n; dogegg++) {
sum += dogegg;
}
return sum;
}
在上面的假设下,这段累加求和代码的总运行时间是多少呢?
- 第二行代码需要 1 Btime 的运行时间,赋值操作。
- 第四和第五行代码分别运行了n次,所以这两行各需要n * Btime的运行时间。
所以总的运行时间就是 (1+2n) * Btime。
我们一般用 T 函数来表示总运行时间,所以上面总的运行时间就可以表达为:
- T(n) = (1+2n)*Btime。
这里用 n 表示数据量,也可以把它理解为数据集的规模或函数参数。
通过上面公式,我们发现T(n)和代码执行行数(次数)成正比关系,发现这个规律很重要,因为这告诉我们数据量和运行时间之间有正向趋势!
大 $O$ 表示法
大 $O$ 表示法不是用来精确计算算法的运行时间,而是用来表示增长趋势:
$$ T(n) = O(f(n)) $$
n 作为数据量大小,可以取 1、100、10000 甚至更大的数。当数据量越来越大时,代码中的某些部分对增长趋势的影响会逐渐变小。
还是以上面代码为例,当n=1000时,1+2n = 2001;当n=10000时,1+2n=20001,当n持续增大时,常数1和系数2对于最后的结果越来越没存在感了,即对趋势的变化影响不大。
这里常数 1 的影响很容易理解,但系数 2 常常让人困惑:明明 2n 看起来比 n 大很多,为什么也可以忽略?
大 $O$ 不是用来比较 2n 和 n 谁在某个具体输入下更快,
它是用来区分:当 n 越来越大时,总量是以什么形式暴涨,如果还不懂,在下面我们会进一步解释。
看完下面两段代码进一步学习一下求时间复杂度。
def dogeggs_sum (n):
sum = 0
for dogegg in range(n):
for i in range(n):
sum += dogegg * i
return sum
fun dogeggs_sum(n: Int): Int {
var sum = 0
for (dogegg in 0 until n) {
for (i in 0 until n) {
sum += dogegg * i
}
}
return sum
}
func dogeggs_sum(_ n: Int) -> Int {
var sum = 0
for dogegg in 0..<n {
for i in 0..<n {
sum += dogegg * i
}
}
return sum
}
int dogeggs_sum(int n) {
var sum = 0;
for (var dogegg = 0; dogegg < n; dogegg++) {
for (var i = 0; i < n; i++) {
sum += dogegg * i;
}
}
return sum;
}
function dogeggs_sum(n) {
let sum = 0;
for (let dogegg = 0; dogegg < n; dogegg++) {
for (let i = 0; i < n; i++) {
sum += dogegg * i;
}
}
return sum;
}
我们一步步来计算时间复杂度。
- 第二行代码需要运行
1次。 - 第四行代码需要运行
n次。 - 第五行代码需要运行
n²次。 - 第六行代码也需要运行
n²次。
所以总的运行次数为 f(n) = 1 + n + 2n²。
当 n = 5 时,f(n) = 1 + 5 + 2 × 25。
当 n = 10000 时,f(n) = 1 + 10000 + 2 × 100000000。
注意到了吗?很明显可以看出,n² 对程序运行次数起到了决定性作用。
反倒是常数 1 和系数 2 对最终趋势影响不大,可以忽略。因此:
n 增长最快的那一段代码。所以得到下面的结果:
| 表达式 | 大 $O$ 类型 | n = 10 | n = 100 | n = 1000 | 特点 |
|---|---|---|---|---|---|
1 + 2n | O(n) 线性 | 21 | 201 | 2001 | 随 n 等比例增长 |
1 + n + 2n² | O(n²) 平方 | 211 | 20101 | 2001001 | 随 n 平方增长 |
大 $O$ 时间复杂度并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。复杂度分析通常遵循以下原则:
- 复杂度与具体的常系数无关,例如
O(n)和O(2n)表示同样的复杂度。 - 多项式复杂度相加时,保留增长更快的项,例如
O(n²) + O(n) = O(n²)。
我们再看下面代码:
def dogeggs_sum (n):
sum1 = 0
for dogegg1 in range(n):
sum1 += dogegg1
sum2 = 0
for dogegg2 in range(n):
for i in range(n):
sum2 += dogegg2 * i
sum3 = 0
for dogegg3 in range(n):
for i in range(n):
for j in range(n):
sum3 += dogegg3 * i * j
return sum1 + sum2 + sum3
fun dogeggs_sum(n: Int): Int {
var sum1 = 0
for (dogegg1 in 0 until n) {
sum1 += dogegg1
}
var sum2 = 0
for (dogegg2 in 0 until n) {
for (i in 0 until n) {
sum2 += dogegg2 * i
}
}
var sum3 = 0
for (dogegg3 in 0 until n) {
for (i in 0 until n) {
for (j in 0 until n) {
sum3 += dogegg3 * i * j
}
}
}
return sum1 + sum2 + sum3
}
func dogeggs_sum(_ n: Int) -> Int {
var sum1 = 0
for dogegg1 in 0..<n {
sum1 += dogegg1
}
var sum2 = 0
for dogegg2 in 0..<n {
for i in 0..<n {
sum2 += dogegg2 * i
}
}
var sum3 = 0
for dogegg3 in 0..<n {
for i in 0..<n {
for j in 0..<n {
sum3 += dogegg3 * i * j
}
}
}
return sum1 + sum2 + sum3
}
int dogeggs_sum(int n) {
var sum1 = 0;
for (var dogegg1 = 0; dogegg1 < n; dogegg1++) {
sum1 += dogegg1;
}
var sum2 = 0;
for (var dogegg2 = 0; dogegg2 < n; dogegg2++) {
for (var i = 0; i < n; i++) {
sum2 += dogegg2 * i;
}
}
var sum3 = 0;
for (var dogegg3 = 0; dogegg3 < n; dogegg3++) {
for (var i = 0; i < n; i++) {
for (var j = 0; j < n; j++) {
sum3 += dogegg3 * i * j;
}
}
}
return sum1 + sum2 + sum3;
}
function dogeggs_sum(n) {
let sum1 = 0;
for (let dogegg1 = 0; dogegg1 < n; dogegg1++) {
sum1 += dogegg1;
}
let sum2 = 0;
for (let dogegg2 = 0; dogegg2 < n; dogegg2++) {
for (let i = 0; i < n; i++) {
sum2 += dogegg2 * i;
}
}
let sum3 = 0;
for (let dogegg3 = 0; dogegg3 < n; dogegg3++) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
sum3 += dogegg3 * i * j;
}
}
}
return sum1 + sum2 + sum3;
}
根据上面学习的知识:
这段代码的 T(n) = O(n) + O(n²) + O(n³),按照我们取“主导”部分,显然前面两个都不及第三个,所以 T(n) = O(n³)。
身下的我们就是要多去练习找一下时间复杂度,加强记忆即可。
常见时间复杂度
算法学习过程中,我们会遇到各种各样的时间复杂度,当常见的时间复杂度都在下面之列:
| 复杂度 | 名称 | 增长特点 | 典型例子 |
|---|---|---|---|
O(1) | 常数复杂度 | 输入再大,操作次数不变 | 取数组第 k 个元素;简单加减赋值 |
O(log n) | 对数复杂度 | n 翻倍,只多固定几步,增长极慢 | 从 1 开始每次乘 2 |
O(n) | 线性复杂度 | 操作数和 n 成正比 | 遍历一次数组,单循环 |
O(n log n) | 线性对数 | 比线性慢、比平方快 | 归并排序、快速排序(平均) |
O(n²) | 平方复杂度 | n 扩大 10 倍,工作量 ×100 | 双层 for 循环,冒泡排序 |
O(n³) | 立方复杂度 | n 扩大 10 倍,工作量 ×1000 | 三层嵌套循环 |
O(2ⁿ) | 指数复杂度 | n 稍微增加,计算量爆炸 | 暴力枚举全部子集;朴素递归求斐波那契 |
O(n!) | 阶乘复杂度 | 比指数增长更快,n 很小就跑不动 | 全排列暴力枚举 |
其中复杂度大小比较:
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
下面每个复杂度我们看一下。例子都按同一件事来写,方便对照循环是怎么把规模抬上去的。
O(1) 常数:按下标取值
注意这是一个特殊的复杂度,不是只执行一次的代码时间复杂度就是 O(1),只要你说常熟,像O(2),O(3),O(1000)在复杂度上的表示都是 O(1)。
def get_kth(nums, k):
return nums[k]
fun get_kth(nums: IntArray, k: Int): Int {
return nums[k]
}
func get_kth(_ nums: [Int], _ k: Int) -> Int {
return nums[k]
}
int get_kth(List<int> nums, int k) {
return nums[k];
}
function get_kth(nums, k) {
return nums[k];
}
数组按下标取值,不管里面有 10 个数还是 1000 万个数,都只做一次。没有跟着 n 走的循环,所以是常数复杂度。k 必须落在下标范围内。
O(log n) 对数:每次翻倍
dogegg 从 1 开始,每次乘 2,直到不小于 n。循环转几圈,就是 log2(n)。
def dogegg_double(n):
dogegg = 1 # 从 1 开始
while dogegg < n: # 还没长到 n,就再乘一次 2
dogegg = dogegg * 2 # 1 → 2 → 4 → 8,圈数就是 log2(n)
return dogegg
fun dogegg_double(n: Int): Int {
var dogegg = 1 // 从 1 开始
while (dogegg < n) { // 还没长到 n,就再乘一次 2
dogegg = dogegg * 2 // 1 → 2 → 4 → 8,圈数就是 log2(n)
}
return dogegg
}
func dogegg_double(_ n: Int) -> Int {
var dogegg = 1 // 从 1 开始
while dogegg < n { // 还没长到 n,就再乘一次 2
dogegg = dogegg * 2 // 1 → 2 → 4 → 8,圈数就是 log2(n)
}
return dogegg
}
int dogegg_double(int n) {
var dogegg = 1; // 从 1 开始
while (dogegg < n) { // 还没长到 n,就再乘一次 2
dogegg = dogegg * 2; // 1 → 2 → 4 → 8,圈数就是 log2(n)
}
return dogegg;
}
function dogegg_double(n) {
let dogegg = 1; // 从 1 开始
while (dogegg < n) { // 还没长到 n,就再乘一次 2
dogegg = dogegg * 2; // 1 → 2 → 4 → 8,圈数就是 log2(n)
}
return dogegg;
}
dogegg 的变化是 1 → 2 → 4 → 8 → …。每转一圈就多乘一个 2,停下来时,是 x 个 2 连乘,结果不小于 n:
2 × 2 × … × 2 >= n
这 x 个 2 连乘,就是 2 的 x 次方对应 n:
2ˣ >= n
两边取以 2 为底的对数,圈数就是:
x = log2(n)
n 正好是 2 的整数次方时等号成立。对不齐时,x 比 log2(n) 略大,向上取整,趋势不变。
- n = 8:
2 × 2 × 2 = 8,x = 3,也就是log2(8) = 3 - n 大约 1000:
2¹⁰ = 1024,x 大约 10 - n 大约 100 万:
2²⁰ ≈ 104万,x 大约 20
n 从 1000 涨到 100 万,圈数只从大约 10 变成大约 20。底数是 2 还是 10 不用写进大 O,换底只差一个固定倍数,固定倍数会被抹掉,所以写成 O(log n)。
O(n) 线性:找最大值
每个元素都要看一眼,看完一遍就是 O(n)。数据量翻倍,比较次数也大约翻倍。下面默认数组里至少有一个数。
def find_max(nums):
best = nums[0]
for x in nums:
if x > best:
best = x
return best
fun find_max(nums: IntArray): Int {
var best = nums[0]
for (x in nums) {
if (x > best) best = x
}
return best
}
func find_max(_ nums: [Int]) -> Int {
var best = nums[0]
for x in nums {
if x > best { best = x }
}
return best
}
int find_max(List<int> nums) {
var best = nums[0];
for (final x in nums) {
if (x > best) best = x;
}
return best;
}
function find_max(nums) {
let best = nums[0];
for (const x of nums) {
if (x > best) best = x;
}
return best;
}
O(n log n) 线性对数:归并排序
先把数组对半拆开,拆到底再两两合并。拆分大约有 log n 层,每一层都要把 n 个数扫一遍,合起来就是 O(n log n)。归并排序每次都是这个量级。
def merge_sort(nums):
if len(nums) <= 1:
return nums
mid = len(nums) // 2
left = merge_sort(nums[:mid])
right = merge_sort(nums[mid:])
merged = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
merged.extend(left[i:])
merged.extend(right[j:])
return merged
fun merge_sort(nums: List<Int>): List<Int> {
if (nums.size <= 1) return nums
val mid = nums.size / 2
val left = merge_sort(nums.subList(0, mid))
val right = merge_sort(nums.subList(mid, nums.size))
val merged = mutableListOf<Int>()
var i = 0
var j = 0
while (i < left.size && j < right.size) {
if (left[i] <= right[j]) {
merged.add(left[i])
i++
} else {
merged.add(right[j])
j++
}
}
merged.addAll(left.subList(i, left.size))
merged.addAll(right.subList(j, right.size))
return merged
}
func merge_sort(_ nums: [Int]) -> [Int] {
if nums.count <= 1 { return nums }
let mid = nums.count / 2
let left = merge_sort(Array(nums[..<mid]))
let right = merge_sort(Array(nums[mid...]))
var merged: [Int] = []
var i = 0
var j = 0
while i < left.count && j < right.count {
if left[i] <= right[j] {
merged.append(left[i])
i += 1
} else {
merged.append(right[j])
j += 1
}
}
merged.append(contentsOf: left[i...])
merged.append(contentsOf: right[j...])
return merged
}
List<int> merge_sort(List<int> nums) {
if (nums.length <= 1) return nums;
final mid = nums.length ~/ 2;
final left = merge_sort(nums.sublist(0, mid));
final right = merge_sort(nums.sublist(mid));
final merged = <int>[];
var i = 0;
var j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
merged.add(left[i]);
i++;
} else {
merged.add(right[j]);
j++;
}
}
merged.addAll(left.sublist(i));
merged.addAll(right.sublist(j));
return merged;
}
function merge_sort(nums) {
if (nums.length <= 1) return nums;
const mid = Math.floor(nums.length / 2);
const left = merge_sort(nums.slice(0, mid));
const right = merge_sort(nums.slice(mid));
const merged = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
merged.push(left[i]);
i++;
} else {
merged.push(right[j]);
j++;
}
}
return merged.concat(left.slice(i), right.slice(j));
}
O(n²) 平方:冒泡排序
相邻两个数比大小,较大的往后换。外层走 n 轮,内层每一轮也跟着 n 走,所以是平方复杂度。n 扩大 10 倍,比较次数大约变成 100 倍。下面的实现会改原数组。
def bubble_sort(nums):
n = len(nums)
for i in range(n):
for j in range(n - 1 - i):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
return nums
fun bubble_sort(nums: IntArray): IntArray {
val n = nums.size
for (i in 0 until n) {
for (j in 0 until n - 1 - i) {
if (nums[j] > nums[j + 1]) {
val tmp = nums[j]
nums[j] = nums[j + 1]
nums[j + 1] = tmp
}
}
}
return nums
}
func bubble_sort(_ nums: [Int]) -> [Int] {
var nums = nums
let n = nums.count
for i in 0..<n {
for j in 0..<(n - 1 - i) {
if nums[j] > nums[j + 1] {
nums.swapAt(j, j + 1)
}
}
}
return nums
}
List<int> bubble_sort(List<int> nums) {
final n = nums.length;
for (var i = 0; i < n; i++) {
for (var j = 0; j < n - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
final tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
return nums;
}
function bubble_sort(nums) {
const n = nums.length;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
const tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
return nums;
}
O(n³) 立方:朴素矩阵乘法
两个 n×n 的矩阵相乘,结果里一共有 n² 个格子,每个格子都要做 n 次乘加。三层循环套在一起,就是 O(n³)。n 扩大 10 倍,工作量大约变成 1000 倍。
def multiply(a, b):
n = len(a)
c = [[0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
for k in range(n):
c[i][j] += a[i][k] * b[k][j]
return c
fun multiply(a: List<List<Int>>, b: List<List<Int>>): List<List<Int>> {
val n = a.size
val c = MutableList(n) { MutableList(n) { 0 } }
for (i in 0 until n) {
for (j in 0 until n) {
for (k in 0 until n) {
c[i][j] += a[i][k] * b[k][j]
}
}
}
return c
}
func multiply(_ a: [[Int]], _ b: [[Int]]) -> [[Int]] {
let n = a.count
var c = Array(repeating: Array(repeating: 0, count: n), count: n)
for i in 0..<n {
for j in 0..<n {
for k in 0..<n {
c[i][j] += a[i][k] * b[k][j]
}
}
}
return c
}
List<List<int>> multiply(List<List<int>> a, List<List<int>> b) {
final n = a.length;
final c = List.generate(n, (_) => List.filled(n, 0));
for (var i = 0; i < n; i++) {
for (var j = 0; j < n; j++) {
for (var k = 0; k < n; k++) {
c[i][j] += a[i][k] * b[k][j];
}
}
}
return c;
}
function multiply(a, b) {
const n = a.length;
const c = Array.from({ length: n }, () => Array(n).fill(0));
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
for (let k = 0; k < n; k++) {
c[i][j] += a[i][k] * b[k][j];
}
}
}
return c;
}
O(2ⁿ) 指数:朴素递归斐波那契
fib(n) 要先算出 fib(n - 1) 和 fib(n - 2),同一次调用会拆成两支。n 每增加 1,调用次数大约翻倍,所以是指数复杂度。教材里常写成 O(2ⁿ)。n 稍微大一点就慢到不能用,它只适合拿来看增长趋势。
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
fun fib(n: Int): Int {
if (n <= 1) return n
return fib(n - 1) + fib(n - 2)
}
func fib(_ n: Int) -> Int {
if n <= 1 { return n }
return fib(n - 1) + fib(n - 2)
}
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
O(n!) 阶乘:全排列
n 个互不相同的元素,第一个位置有 n 种选法,第二个位置剩 n − 1 种,一直乘到 1。路径总数就是 n!。n = 10 已经是三百多万,n = 12 接近 5 亿,所以只能枚举很小的 n。
def permutations(nums):
result = []
used = [False] * len(nums)
path = []
def dfs():
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
path.append(nums[i])
dfs()
path.pop()
used[i] = False
dfs()
return result
fun permutations(nums: IntArray): List<List<Int>> {
val result = mutableListOf<List<Int>>()
val used = BooleanArray(nums.size)
val path = mutableListOf<Int>()
fun dfs() {
if (path.size == nums.size) {
result.add(path.toList())
return
}
for (i in nums.indices) {
if (used[i]) continue
used[i] = true
path.add(nums[i])
dfs()
path.removeAt(path.lastIndex)
used[i] = false
}
}
dfs()
return result
}
func permutations(_ nums: [Int]) -> [[Int]] {
var result: [[Int]] = []
var used = Array(repeating: false, count: nums.count)
var path: [Int] = []
func dfs() {
if path.count == nums.count {
result.append(path)
return
}
for i in 0..<nums.count {
if used[i] { continue }
used[i] = true
path.append(nums[i])
dfs()
path.removeLast()
used[i] = false
}
}
dfs()
return result
}
List<List<int>> permutations(List<int> nums) {
final result = <List<int>>[];
final used = List<bool>.filled(nums.length, false);
final path = <int>[];
void dfs() {
if (path.length == nums.length) {
result.add(List<int>.from(path));
return;
}
for (var i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true;
path.add(nums[i]);
dfs();
path.removeLast();
used[i] = false;
}
}
dfs();
return result;
}
function permutations(nums) {
const result = [];
const used = Array(nums.length).fill(false);
const path = [];
function dfs() {
if (path.length === nums.length) {
result.push(path.slice());
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true;
path.push(nums[i]);
dfs();
path.pop();
used[i] = false;
}
}
dfs();
return result;
}
平均复杂度
除了数据规模,输入数据的具体情况也会影响算法的运行时间。
我们来看这么一段代码:
def find_word(lst, word):
flag = -1
for i in range(len(lst)):
if lst[i] == word:
flag = i
break
return flag
fun find_word(lst: List<String>, word: String): Int {
var flag = -1
for (i in lst.indices) {
if (lst[i] == word) {
flag = i
break
}
}
return flag
}
func find_word(_ lst: [String], _ word: String) -> Int {
var flag = -1
for i in 0..<lst.count {
if lst[i] == word {
flag = i
break
}
}
return flag
}
int find_word(List<String> lst, String word) {
var flag = -1;
for (var i = 0; i < lst.length; i++) {
if (lst[i] == word) {
flag = i;
break;
}
}
return flag;
}
function find_word(lst, word) {
let flag = -1;
for (let i = 0; i < lst.length; i++) {
if (lst[i] === word) {
flag = i;
break;
}
}
return flag;
}
上面这段代码用于查找变量 word 在列表 lst 中出现的位置,可以用来解释“数据的具体情况”是什么意思。
拿 lst = ['a', 'b', 'c', 'd'] 来数一遍。循环碰到 word 就 break,所以比几次完全看 word 落在哪:
| word | 停在哪 | 循环次数 |
|---|---|---|
'a' | 第 1 个就命中 | 1 |
'b' | 第 2 个命中 | 2 |
'c' | 第 3 个命中 | 3 |
'd' | 最后一个才命中 | 4 |
'e' | 列表里没有,只能走完 | 4 |
- 第 1 个就命中时,后面都不用看,这是最好情况,时间复杂度是
O(1)。 word在末尾,或者像'e'这样根本不在列表里,都要把整个列表走完,这是最坏情况,时间复杂度是O(n)。
这就是数据具体情况不同,代码的时间复杂度不同。我们得出下面 3 种情况:
- 最好时间复杂度(Best Case):最理想情况下的复杂度,例如
word恰好是列表lst的第 1 个元素,对应O(1)。 - 最坏时间复杂度(Worst Case):最差情况下的复杂度,例如
word在列表末尾或不存在,对应O(n)。 - 平均时间复杂度(Average Case):综合各种输入情况及其发生概率得到的期望复杂度。
那么怎么计算平均时间复杂度呢?需要用到概率论的知识。
我们仍然用上面的例子来说明:
- 从整体看,查找变量
x在列表lst中的位置有两种情况:存在或不存在。假设两种情况的概率都是1/2。 - 如果
x出现在列表lst中,那么它可能出现在0到n - 1的任意位置,假设每个位置的概率都相同,都是1/n。
每个出现的概率(即权重)知道了,所以平均时间复杂度为:
“在列表里”的概率是 1/2,这一半还要再平分给 n 个位置,所以 x 落在某个具体位置的概率是 1/2 × 1/n = 1/(2n);不在列表里的概率仍然是 1/2。
落在第 1 个位置要循环 1 次,第 2 个位置要循环 2 次,一直到第 n 个位置要循环 n 次;不在列表里也要循环 n 次。平均循环次数就是这些情况的加权和:
普通平均是每个数权重一样:加起来,再除以个数。加权平均是每个数先乘自己的权重,再相加。权重大的数把结果拉得更近。
三次考试可以看出来:
- 平时 60 分,权重
0.2 - 期中 80 分,权重
0.3 - 期末 90 分,权重
0.5
加权平均是 60×0.2 + 80×0.3 + 90×0.5 = 81。普通平均是 (60+80+90)/3 ≈ 76.7。期末占了一半权重,所以 81 更靠近 90,而不是停在三个分数的正中间。
权重必须加起来等于 1(这里 0.2 + 0.3 + 0.5 = 1)。这时加权和本身就是平均值,不用再除一次。
上面公式里,循环次数是要平均的数,出现概率就是权重。n 个位置各占 1/(2n),再加上「不在列表里」的 1/2,权重正好加总为 1,所以 T(n) 直接就是平均循环次数。
平均循环次数是这些情况的加权和:
$$ \begin{aligned} T(n) &= \frac{1}{2n} + \frac{2}{2n} + \cdots + \frac{n}{2n} + \frac{n}{2} \ &= \frac{1 + 2 + \cdots + n}{2n} + \frac{n}{2} \ &= \frac{n(n + 1)}{4n} + \frac{n}{2} \ &= \frac{3n + 1}{4} \end{aligned} $$
在 (3n + 1) / 4 中,系数 3/4 和常数 1/4 都不改变增长趋势,所以平均时间复杂度是 O(n)。
空间复杂度
空间复杂度和时间复杂度一样,关注的是增长趋势;它描述代码运行过程中额外占用的内存空间。
代码在计算机中运行所占用的存储空间主要分为 3 部分:
- 代码本身所占用的
- 输入数据所占用的
- 临时变量所占用的
前面两个部分是本身就要占用这些空间,与代码性能无关,所以我们在衡量代码的空间复杂度时,只关心运行过程中临时占用的内存空间。
空间复杂度记作 S(n),表达形式与时间复杂度一样:
$$ S(n) = O(f(n)) $$
这里 n 表示数据集大小,f(n) 表示规模 n 对应的存储空间函数。
我们用下面代码来学习如何分析空间复杂度:
def dogeggs_sum(lst):
sum = 0
for i in range(len(lst)):
sum += lst[i]
return sum
fun dogeggs_sum(lst: List<Int>): Int {
var sum = 0
for (i in lst.indices) {
sum += lst[i]
}
return sum
}
func dogeggs_sum(_ lst: [Int]) -> Int {
var sum = 0
for i in 0..<lst.count {
sum += lst[i]
}
return sum
}
int dogeggs_sum(List<int> lst) {
var sum = 0;
for (var i = 0; i < lst.length; i++) {
sum += lst[i];
}
return sum;
}
function dogeggs_sum(lst) {
let sum = 0;
for (let i = 0; i < lst.length; i++) {
sum += lst[i];
}
return sum;
}
上述代码用于求列表 lst 的所有元素之和。根据前面的约定,这里只计算临时变量占用的额外空间。
形参 lst 所占用的空间不计,那么剩下的临时变量只有 sum 和 i。它们都是常数阶,与规模 n 无关,因此整段代码的空间复杂度是 S(n) = O(1)。
常见空间复杂度
常见的空间复杂度和时间复杂度类似,也可以按照增长速度从小到大排列:
O(1) < O(log n) < O(n) < O(n²)
| 复杂度 | 名称 | 典型情况 | 文章中的例子 |
|---|---|---|---|
O(1) | 常数空间 | 只使用固定数量的变量,额外空间不随 n 增长 | 求和、原地冒泡排序 |
O(log n) | 对数空间 | 递归调用每次把问题规模缩小一半,调用栈深度是 log n | 归并排序的递归调用栈 |
O(n) | 线性空间 | 临时数组、递归调用栈或辅助结构的大小和 n 成正比 | 归并排序的临时数组、全排列的 path |
O(n²) | 平方空间 | 需要保存 n × n 规模的表格或结果 | 矩阵乘法的结果矩阵 |
下面分别看这些空间复杂度是怎么产生的。
O(1) 常数空间:只保存固定数量的变量
前面的 dogeggs_sum(lst) 只使用了 sum 和 i 两个临时变量。无论列表里有 10 个元素还是 1000 万个元素,这两个变量占用的空间都不会随 n 增长,所以空间复杂度是 O(1)。
前面的冒泡排序也是 O(1) 额外空间:它只使用了 n、i、j 和交换时的 tmp。虽然时间复杂度是 O(n²),但它直接在原数组上交换元素,没有另建一个和 n 一样大的数组,所以空间复杂度仍然是 O(1)。
这里要注意“输入数据所占用的空间”和“额外空间”的区别。冒泡排序输入的数组本身可能有 n 个元素,但那是调用者已经提供的输入空间,通常不计入这段算法的额外空间复杂度。
O(log n) 对数空间:递归调用栈
归并排序每次把数组分成两半。虽然递归调用总共会处理 n 个元素,但沿着其中一条递归路径,规模是:
n → n / 2 → n / 4 → ... → 1
因此递归深度是 O(log n)。如果只计算递归调用栈,不计算合并时创建的临时数组,那么归并排序的栈空间是 O(log n)。
下面这个函数每次把 n 除以 2,直到 n 不大于 1。它没有创建和 n 相关的数组,但递归调用栈最多有 log n 层,所以额外空间复杂度是 O(log n)。
def halve_count(n):
if n <= 1:
return 1
return 1 + halve_count(n // 2)
fun halve_count(n: Int): Int {
if (n <= 1) return 1
return 1 + halve_count(n / 2)
}
func halve_count(_ n: Int) -> Int {
if n <= 1 { return 1 }
return 1 + halve_count(n / 2)
}
int halve_count(int n) {
if (n <= 1) return 1;
return 1 + halve_count(n ~/ 2);
}
function halve_count(n) {
if (n <= 1) return 1;
return 1 + halve_count(Math.floor(n / 2));
}
不过,文章中的归并排序实现还会创建 left、right 和 merged 等数组。它们在同一层合计最多保存 O(n) 个元素,所以整个实现的额外空间复杂度应记为 O(n),而不是只看递归栈写成 O(log n)。
O(n) 线性空间:辅助数组或线性递归
参考下面的代码,result 每循环一次就多保存一个元素,最多保存 n 个元素,因此额外空间复杂度是 O(n)。
def create_list(n):
result = []
for i in range(n):
result.append(i)
return result
fun create_list(n: Int): List<Int> {
val result = mutableListOf<Int>()
for (i in 0 until n) {
result.add(i)
}
return result
}
func create_list(_ n: Int) -> [Int] {
var result: [Int] = []
for i in 0..<n {
result.append(i)
}
return result
}
List<int> create_list(int n) {
final result = <int>[];
for (var i = 0; i < n; i++) {
result.add(i);
}
return result;
}
function create_list(n) {
const result = [];
for (let i = 0; i < n; i++) {
result.push(i);
}
return result;
}
文章中的归并排序需要一个和输入规模相近的 merged 数组,因此额外空间是 O(n)。这也是归并排序和原地排序的一个重要区别:它用更多空间换取了稳定且规整的排序过程。
前面的全排列代码也会使用线性空间保存当前状态:
used数组需要保存n个布尔值,是O(n)。path最深时保存n个元素,是O(n)。- 递归调用栈最多有
n层,是O(n)。
所以,如果不把最终返回的 result 计算在内,全排列的辅助空间复杂度是 O(n)。但是这段代码会把所有排列都保存下来,结果本身有 n! 个排列,每个排列长度为 n,因此返回结果占用的空间是 O(n × n!)。是否把输出结果计入空间复杂度,要看题目讨论的是“辅助空间”还是“总空间”。
朴素递归斐波那契也是一个容易混淆的例子。它的时间复杂度接近 O(2ⁿ),但同一时刻沿着一条递归路径最多只有 n 层调用,所以调用栈空间是 O(n)。时间增长很快,并不代表空间也一定是指数增长。
O(n²) 平方空间:保存二维数据
下面的代码先创建 n 个一维列表,再让每个列表保存 n 个元素。最终得到的是一个 n × n 的二维列表,需要保存 n² 个元素,所以空间复杂度是 O(n²)。
def create_matrix(n):
matrix = []
for i in range(n):
row = []
for j in range(n):
row.append(j)
matrix.append(row)
return matrix
fun create_matrix(n: Int): List<List<Int>> {
val matrix = mutableListOf<List<Int>>()
for (i in 0 until n) {
val row = mutableListOf<Int>()
for (j in 0 until n) {
row.add(j)
}
matrix.add(row)
}
return matrix
}
func create_matrix(_ n: Int) -> [[Int]] {
var matrix: [[Int]] = []
for _ in 0..<n {
var row: [Int] = []
for j in 0..<n {
row.append(j)
}
matrix.append(row)
}
return matrix
}
List<List<int>> create_matrix(int n) {
final matrix = <List<int>>[];
for (var i = 0; i < n; i++) {
final row = <int>[];
for (var j = 0; j < n; j++) {
row.add(j);
}
matrix.add(row);
}
return matrix;
}
function create_matrix(n) {
const matrix = [];
for (let i = 0; i < n; i++) {
const row = [];
for (let j = 0; j < n; j++) {
row.push(j);
}
matrix.push(row);
}
return matrix;
}
前面的矩阵乘法创建了结果矩阵 c:它有 n 行、每行 n 个元素,所以需要保存 n² 个元素,空间复杂度是 O(n²)。
如果题目只计算“除输出以外的辅助空间”,那么可以把已经返回的结果矩阵 c 排除,此时三层循环本身只使用 i、j、k 等固定数量的变量,辅助空间是 O(1)。但如果讨论算法运行期间占用的总空间,或者把输出空间也算进去,就应写成 O(n²)。
如何分析空间复杂度
分析空间复杂度时,可以按下面的顺序检查:
找出随规模增长的数据结构
检查代码中新建的数组、列表、哈希表或矩阵,并判断它们的大小是否随着 n 增长。
计算递归调用栈
找出递归函数,计算一条递归路径上最多同时存在多少层调用。
确认是否计算输出空间
判断题目是否把返回结果计入空间复杂度。没有特别说明时,通常讨论的是额外空间复杂度。
合并同时存在的空间
同一时间同时存在的空间取总和;不同阶段先后使用、不会同时存在的空间,取占用量最大的阶段。
O(1);归并排序整体是 O(n);全排列的辅助空间是 O(n),但保存全部结果时是 O(n × n!);矩阵乘法包含输出矩阵时是 O(n²)。空间复杂度的关键不是数循环执行了多少次,而是看运行过程中同时保存了多少与 n 有关的数据。