本系列文章仅是 GitHub 大神 @halfrost 的刷题笔记 《LeetCode Cookbook》的提纲以及示例、题集的 C++转化。原书请自行下载学习。
时间复杂度的数据规模
- 1 s 内能解决问题的数据规模:106 ~107
数据规模 | 时间复杂度 | 算法举例 |
---|---|---|
10 | O ( n ! ) \ O(n!) O(n!) | p e r m u t a t i o n permutation permutation 排列(旅行推销员问题) |
20~30 | O ( 2 n ) \ O(2^n ) O(2n) | c o m b i n a t i o n combination combination 组合 |
50 | O ( n 4 ) \ O(n^4) O(n4) | DFS 搜索、DP 动态规划 |
100 | O ( n 3 ) \ O(n^3) O(n3) | 任意两点最短路径、DP 动态规划 |
1000 | O ( n 2 ) \ O(n^2) O(n2) | 稠密图、DP 动态规划 |
106 | O ( n log n ) \ O(n \log n ) O(nlogn) | 排序、堆( h e a p heap heap)、递归与分治 |
107 | O ( n ) \ O(n) O(n) | DP 动态规划、图遍历、拓扑排序、树遍历 |
109 | O ( n ) \ O(\sqrt n) O(n) | 筛素数、求平方根 |
1010 | O ( log n ) \ O(\log n) O(logn) | 二分搜索(查找) |
+ ∞ \ + \infty +∞ | O ( 1 ) \ O(1) O(1) | 数学相关算法 |
一些具有迷惑性的例子
void hello(int n) {
for (int sz = 1; sz < n; sz += sz)
for (int i = 0; i < n; i++)
cout << "Hello" << endl;
}
❗️易错点:该例子的时间复杂度为 O ( n log n ) \ O(n\log n) O(nlogn) 而非 O ( n 2 ) \ O(n^2) O(n2) 。
补全代码结构,传入参数
n
=
5
n = 5
n=5,最终输出共
15
=
5
×
3
\ 15 = 5 \times 3
15=5×3 个 Hello
。
不难理解,这一函数的内层循环是简单的从 0 到 n − 1 \ n - 1 n−1 的循环,总是执行 n \ n n 次;
而对于外层循环,每一次迭代 sz
都会翻倍(此时 sz += sz
等同于 sz *= 2
),这实际上是一个以 2 为指数增长的循环。假设外层循环执行了
k
\ k
k 次,则第
k
\ k
k 次迭代后,sz
的值至少为
2
k
\ 2^k
2k ,根据循环条件,sz
≥
\geq
≥ 0 时循环终止,则
k
\ k
k 应满足
2
k
≥
n
\ 2^k \geq n
2k≥n,两边同时取对数,不难得出
k
≥
log
2
n
\ k \geq \log_2 n
k≥log2n。考虑到程序实际运行情况,外层循环实际执行的次数应为
⌊
log
2
n
⌋
+
1
\ \lfloor \log_2 n\rfloor + 1
⌊log2n⌋+1 (例如,上述传参情况下,
n
=
5
n = 5
n=5,外层循环执行了
⌊
log
2
5
⌋
+
1
\ \lfloor \log_2 5\rfloor + 1
⌊log25⌋+1 即
2
+
1
=
3
\ 2 + 1 = 3
2+1=3 次)。
综上所述,由乘法原理,总的时间复杂度为 ( ⌊ log 2 n ⌋ + 1 ) × n \ (\lfloor \log_2 n\rfloor + 1) \times n (⌊log2n⌋+1)×n,考虑到 ⌊ log 2 n ⌋ \ \lfloor \log_2 n\rfloor ⌊log2n⌋ 是小于等于 log 2 n \ \log_2 n log2n 的最大整数,并且常数项在复杂度分析中一般被忽略,则该算法的时间复杂度的大 O O O 标记法为 O ( n log n ) \ O(n\log n) O(nlogn)。
另一个例子:
bool isPrime(int n) {
for (int x = 2; x * x <= n; x++)
if (n % x == 0) return false;
return true;
}
❗️易错点:该例子的时间复杂度为 O ( n ) \ O(\sqrt n) O(n) 而非 O ( n ) \ O(n) O(n) 。
这是一个判断输入参数是否为质数的算法,例如
n
=
2
n = 2
n=2 时返回 true
(2 为质数),
n
=
4
n = 4
n=4 时则返回 false
。
实现简单粗暴地判断质数的朴素方法是遍历 2 到 n − 1 \ n - 1 n−1 的所有整数,判断是否存在能整除 n \ n n 的整数:
bool isPrime(int n) {
for (int i = 2; i <= n - 1; i++)
if (n % i == 0) return false;
return true;
}
对此我们十分熟悉,对于一个整数,我们需要 n − 2 \ n - 2 n−2 次判断,很快就能得出其时间复杂度为 O ( n ) \ O(n) O(n),但这种算法在 n n n 较大的情况下是不可取的,并非是最优解。
对于一个小于 n n n 的整数 x x x,如果 n n n 不能整除 x x x,则 n n n 必然不能整除于 n x \frac{n}{x} xn。按照这样的思路,我们从 2 枚举到 n \sqrt n n 即可,判断 2 的同时也判断了 n 2 \frac{n}{2} 2n,……,以此类推,到 n \sqrt n n 的时候就已经全部判断完毕了,便可以得出本例子的解法。那么,本例子的时间复杂度也就呼之欲出了,对于一个整数,需要 n − 1 \sqrt n - 1 n−1 次判断,即 O ( n ) \ O(\sqrt n) O(n)。
或者,还可以注意到,当 x > n \ x \gt \sqrt n x>n 时, x × x \ x \times x x×x 一定大于 n n n,从而循环终止。
本例子中,我们用
x
×
x
≤
n
\ x \times x \leq n
x×x≤n 代替
x
≤
n
\ x \leq \sqrt n
x≤n 是为了避免 sqrt()
函数的调用,其在大量数据测试中时间消耗较为明显。实际上,判断质数的算法中仍有优于此种解法的,详情请见 @zz09 的这篇文章。
更多例子
有一个字符串数组,将数组中的每一个字符串按照字母序排序,之后再将整个字符串数组按照字典
序排序。假设数组中有
n
n
n 个字符串,最长的字符串的长度为
s
s
s,则其时间复杂度为
O
(
n
s
(
log
s
+
log
n
)
)
\ O(ns(\log s + \log n))
O(ns(logs+logn)) 而非
O
(
n
2
log
n
+
n
log
n
)
=
O
(
n
2
log
n
)
\ O(n^2\log n + n\log n) = O(n^2\log n)
O(n2logn+nlogn)=O(n2logn)。
误区在于,字符串的长度与数组的长度是没有关系的,所以这两个变量应当单独计算。对一个字符串排序的时间复杂度为 O ( s log s ) \ O(s\log s) O(slogs),推广到每一个数组中的字符串则为 O ( n s log s ) \ O(ns\log s) O(nslogs)。
要使整个字符串数组按照字典序排序,则先通过排序算法比较 O ( n log n ) \ O(n\log n) O(nlogn) 次,每次比较耗费 O ( s ) \ O(s) O(s),整个过程中时间复杂度为 O ( s n log n ) \ O(sn\log n) O(snlogn)。
因此,整体的时间复杂度为两个过程之和即 O ( n s ( log s + log n ) ) \ O(ns(\log s + \log n)) O(ns(logs+logn))。
空间复杂度
- 递归调用是有空间代价的,递归算法需要保存递归栈信息,所以花费的空间复杂度会比非递归算法要高。
递归的时间复杂度
一次递归调用
如果递归函数中,只进行了一次递归调用,且递归深度为 d e p t h depth depth,在每个递归函数中,时间复杂度为 T T T,那么总体的时间复杂度为 O ( T × d e p t h \ O(T \times depth O(T×depth)。
/// @brief The recursive implementation of binary search
/// @param arr Given array
/// @param l Lower bound
/// @param r Upper bound
/// @param target Given num
/// @return The index of target
int binarySearch(int arr[], int l, int r, int target {
if( l > r)
return -1;
int mid = l + (r - l)/2; // 防止溢出
if(arr[mid] == target)
return mid;
else if (arr[mid] > target)
return binarySearch(arr, l, mid - 1, target);
else
return binarySearch(arr, mid + 1, r, target);
}
在二分查找的递归实现中,每次递归调用都能将搜索区间减半,则递归深度最多为 log 2 n \log_2 n log2n 次,每次递归中时间复杂度均为 O ( 1 ) \ O(1) O(1),那么总的时间复杂度为 O ( log n ) \ O(\log n) O(logn)。
多次递归调用
int f(int n) {
assert(n >= 0);
if(n ==0)
return 1;
return f( n - 1 ) + f ( n - 1 );
}
直接计算所有递归调用的次数,基本上是等比数列求和,时间复杂度为 2 0 + 2 1 + 2 2 + . . . + 2 n = 2 n − 1 = O ( 2 n ) \ 2^0 + 2^1 + 2^2 + ... + 2^n = 2^{n-1} = O(2^n) 20+21+22+...+2n=2n−1=O(2n) 。
关于更加复杂的递归的复杂度分析,请参考主定理( M a s t e r T h e o r e m Master \ Theorem Master Theorem)。主定理中针对各种复杂情况都给出了正确的结论。