【LeetCode Cookbook(C++ 描述)】时间复杂度与空间复杂度

本系列文章仅是 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×3Hello

不难理解,这一函数的内层循环是简单的从 0 到   n − 1 \ n - 1  n1 的循环,总是执行   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  2kn,两边同时取对数,不难得出   k ≥ log ⁡ 2 n \ k \geq \log_2 n  klog2n。考虑到程序实际运行情况,外层循环实际执行的次数应为   ⌊ 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  n1 的所有整数,判断是否存在能整除   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  n2 次判断,很快就能得出其时间复杂度为   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×xn 代替   x ≤ n \ x \leq \sqrt n  xn 是为了避免 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=2n1=O(2n)

关于更加复杂的递归的复杂度分析,请参考主定理 M a s t e r   T h e o r e m Master \ Theorem Master Theorem)。主定理中针对各种复杂情况都给出了正确的结论。

呜啊?

  • 9
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值