时间复杂度和空间复杂度
前言
最近在看算法相关的文章中,几乎在每道算法题的解法后都会写上该种解法的时间复杂度和空间复杂度,所以还是很有必要来好好了解这两个内容。
时间复杂度和空间复杂度是用来分析一个算法的效率的,也就是说一个算法的效率高低是由时间复杂度和空间复杂度决定的。时间复杂度主要用来衡量一个算法的运行速度,空间复杂度衡量一个算法需要的额外空间。
本文的内容来自网上的资料收集以及个人的理解,然后在时间复杂度big-O表示法的例子里介绍了几个来自labuladong算法大神的微信公众号,如果想要了解的更加深入的话,可以再去看看他文章里面举的其他例子。
时间复杂度
一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度
我们通常会使用big-O来表示算法的时间复杂度
在说明Big O的线性表示法之前,不知道大家是否会和我有个一样的疑问:为什么要使用Big O 来描述算法的渐进时间复杂度,而不用big-θ?
渐进符号有三个,一个是big-O 表示上确界,一个是big-θ 表示确界,一个是big-Ω表示下确界,严格来说,F(n) = Θ(g(n)) 等价于 F(n) = O(g(n)) && F(n) = Ω(g(n))。在某些情况下,big-θ描述的渐进复杂度不一定准确或正确,这是原因之一,其次,根据big-θ的定义, 如果要用 Θ(g(n)),**则必须同时证明 F(n) = Ω(g(n)), 而我们通常衡量一个算法的好坏看的是他最坏的情况下需要多少时间,他的下确界,也就是这个算法最好的时候能有多好,不存在参考价值,而且有些算法的下确界并不好证明。所以教科书中说的渐进时间复杂度通常说的都是最坏渐进时间复杂度,也就是上界紧确的big-O,普遍使用的也是big-O。
PS:但并不是说考虑一个算法的效率,只能考虑它的最坏情况,也可以考虑最好的或者平均的情况,三个渐进记号和最好、最坏、平均没有任何关系,你在哪种情况下考察就是哪种情况下的,只要你能根据不同渐进符号的定义证明其是成立的即可。
如果有兴趣了解详细一点该问题:可以点击此处为什么要使用Big O 来描述算法的渐进时间复杂度?
big-O 表示法
基本定义
首先看一下 Big O 记号的数学定义:
O(g(n))
= {f(n)
: 存在正常量c
和n_0
,使得对所有n ≥ n_0
,有0 ≤ f(n) ≤ c*g(n)
}
我们常用的这个符号O
其实代表一个函数的集合,比如O(n^2)
代表着一个由g(n) = n^2
派生出来的一个函数集合;我们说一个算法的时间复杂度为O(n^2)
,意思就是描述该算法的复杂度的函数属于这个函数集合之中。简单来说就是 f(n) ≤ c*g(n) 恒成立,如图所示:
所以根据big-O的定义和图中所示,不难看出big-O表示的是复杂度的上界。
换句话说,只要你给出的是一个上界,用 Big O 记号表示就都是正确的。
比如如下代码:
for (int i = 0; i < N; i++) {
print("hello world");
}
如果说这是一个算法,一个for循环语句,最多会循环n次,那么显然它的时间复杂度是O(N)
。但如果你非要说它的时间复杂度是O(N^2)
,严格意义上讲是可以的,因为O
记号表示一个上界嘛,这个算法的时间复杂度确实不会超过N^2
这个上界呀,虽然这个上界不够「紧」,但符合定义,所以没毛病。
上述例子太简单,非要扩大它的时间复杂度上界显得没什么意义。但有些算法的复杂度会和算法的输入数据有关,没办法提前给出一个特别精确的时间复杂度,那么在这种情况下,用 Big O 记号扩大时间复杂度的上界就变得有意义了。后文会举具体的例子,下面让我们先了解怎么用big-O来表示某个函数的时间复杂度
如何用big-O来表示 为什么要使用这样的规则
big-O表示法的推导规则
推导大O阶方法:
step 1、用常数1取代运行时间中的所有加法常数。
step 2、在修改后的运行次数函数中,只保留最高阶项。
step 3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
-
为什么我们要用1来表示所以的常数?
- 这是因为计算机的运行速度是非常快的,每秒可能就可以执行上亿此的运算,那么常数次的执行次数与我们计算机的运算速度相比,可能与执行一次的运行速度相差不会太大,所以我们就使用1来代替所有的常数项,那么只有循环次数为常数的算法的时间复杂度相应的就是O(1)。
-
为什么只保留最高阶项?
- 我们知道时间复杂度描述的对象是一个算法,而不是某一次的运算,那么当我们使用这个算法并向里面传入一个能够影响算法基本操作执行次数的变量时,我们并不能确定我们输入的N的值是多少,N就有可能是任何值,当N比较小时,也许别的项与最高阶项的结果差距并没有那么大,但是当N的值越来越大时,最高阶项的值与其他项的值的差距也就越来越大了,当我们的N在不断的变大时,因为其余项对结果的影响相对来说比较小,那么我们就可以忽略他们对结果的影响,只保留对结果影响最大的那一项来表示我们的时间复杂度。
-
为什么最高阶存在且不是1时,要去除掉与这个项目相称的常数?
- 同样的,因为当N在不断变得越来越大时,对结果影响最大的是这个最高阶项,而不是这个最高阶项前面的系数
ok~了解完了推导规则和为啥要用这样的规则后,来使用规则小试牛刀一下吧!
例子1:f(n) = 2N + 100 ==> 使用step1:2N + 1 ==> 使用step2:2N ==> 使用step3:N ==> finially: O(N)
例子2:f(n) = 2^(N+1) = 2 * 2^N ==>使用step3:2^N ==> finially: O(2^N)
例子3:f(n) = M + 3N + 99 ==> 使用step1: M + 3N + 1 ==> 使用step2:M + 3N ==> 使用step3:M + N ==> finially: O(M + N)
由简单到复杂来计算一下一些代码的big-O
题目1:求一个具有几个循环的函数的big-O
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N; ++i) // 外循环 N 次
{
for (int j = 0; j < N; ++j) // 内循环 N 次
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k) //循环 2*N 次
{
++count;
}
int M = 10;
while (M--) //循环M次 M = 10
{
++count;
}
printf("%d\n", count);
}
这个函数在调用的过程中使用了三个for循环和一个while循环,每循环一次我们说它进行了一次基本操作。那么这个函数执行基本操作的次数为F(N)=N * N+2N+10 = N²+2N+10
根据big-O的推导规则:f(n) = N²+2N+10 ==> 使用step1:N²+2N+1 ==> 使用step2: N² ==> 使用step3:N² ==> finially: O(N²)
所以该算法的时间复杂度为: O(N²)
题目2:求冒泡排序的big-O
var arr = [4,23,100,9,7,49,36,57];
for(var i=0;i<arr.length-1;i++){//确定轮数
for(var j=0;j<arr.length-i-1;j++){//确定每次比较的次数
if(arr[j]>arr[j+1]){
tem = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tem;
}
}
console.log("第"+i+"次排序"+arr)
}
console.log("最终排序:"+arr)
那么在这个算法里,我们怎么去计算他的基本操作次数呢?根据冒泡排序的原理,当i=0时,需要循环N次,当i=1时,需要循环N-1次,当i=2时,需要循环N-2次…根据规律,我们能够推算出 f(n) = N + (N-1) + (N-2) + … + 2 + 1的和,很明显是个等差数列,所以F(N)=N*(N+1)/2
根据big-O的推导规则:f(n) =N*(N+1)/2 = 1/2 * N² + 1/2 * N ==> 使用step2: 1/2 * N² ==> 使用step3:N² ==> finially: O(N²)
所以该算法的时间复杂度为: O(N²)
以上的这两个例子都是很简单的,循环的次数都是一个固定值,所以很容易能够推算出其基本次数。
但是有些算法的复杂度会和算法的输入有关系,没法提前给出一个很精确的时间复杂度,所以,在这种情况下,使用big-O记号扩大时间复杂度的上界就显得很有必要了。
接下来看题目3:
题目3:力扣上凑零钱问题的暴力递归解法 用big-O来表示节点总数
// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int dp(int[] coins, int amount) {
// base case
if (amount <= 0) return;
// 状态转移
for (int coin : coins) {
dp(coins, amount - coin);
}
}
当amount = 11, coins = [1,2,5]
时,算法的递归树就长这样:
假设金额amount
的值为N
,coins
列表中元素个数为K
,那么这棵递归树就是一棵K
叉树。但这棵树的生长和coins
列表中的硬币面额有直接的关系,所以这棵树的形状会很不规则,导致我们很难精确地求出树上节点的总数。
对于这种情况,比较简单的处理方式就是按最坏情况做近似处理:
这棵树的高度有多高?不知道,那就按最坏情况来处理,假设全都是面额为 1 的硬币,这种情况下树高为N
。
这棵树的结构是什么样的?不知道,那就按最坏情况来处理,假设它是一棵满K
叉树好了。
那么,这棵树上共有多少节点?都按最坏情况来处理,高度为N
的一棵满K
叉树,其节点总数为 (k^n-1)/k-1 (可以自行百度推导n层满k叉树节点总数),用 Big O 表示就是O(K^N)
。
当然,我们知道这棵树上的节点数其实没有这么多,但用O(K^N)
表示一个上界是没问题的。
所以,有时候你自己估算出来的时间复杂度和别人估算的复杂度不同,并不一定代表谁算错了,可能你俩都是对的,只是是估算的精度不同,一般来说只要数量级(线性/指数级/对数级/平方级等)能对上就没问题。
非递归算法中嵌套循环很常见,大部分场景下,只需把每一层的复杂度相乘就是总的时间复杂度,就像前面的题目1和题目2,但有时候只看嵌套循环的层数并不准确,还得看算法具体在做什么,接下来看题目4
题目4: 使用左右双指针来寻找 在数组中两个数之和为target 其对应的下标
// 左右双指针
int lo = 0, hi = nums.length;
while (lo < hi) {
int sum = nums[lo] + nums[hi];
int left = nums[lo], right = nums[hi];
if (sum < target) {
while (lo < hi && nums[lo] == left) lo++;
} else if (sum > target) {
while (lo < hi && nums[hi] == right) hi--;
} else {
while (lo < hi && nums[lo] == left) lo++;
while (lo < hi && nums[hi] == right) hi--;
}
}
这段代码看起来很复杂,大 while 循环里面套了好多小 while 循环,感觉这段代码的时间复杂度应该是O(N^2)
(N
代表nums
的长度)?
其实,你只需要搞清楚代码到底在干什么,就能轻松计算出正确的复杂度了。
这段代码就是个 左右双指针 嘛,lo
是左边的指针,hi
是右边的指针,这两个指针相向而行,相遇时外层 while 结束。
甭管多复杂的逻辑,你看lo
指针一直在往右走(lo++
),hi
指针一直在往左走(hi--
),它俩有没有回退过?没有。
所以这段算法的逻辑就是lo
和hi
不断相向而行,相遇时算法结束,那么它的时间复杂度就是线性的O(N)
。
下面来看看递归算法的big-O怎么表示
题目5:凑零钱问题 用big-O表示时间复杂度和空间复杂度
计算算法的时间复杂度,无非就是看这个算法做了些啥事儿,花了多少时间。而递归算法做的事情就是遍历一棵递归树,在树上的每个节点所做一些事情罢了。
所以:
递归算法的时间复杂度 = 递归的次数 x 函数本身的时间复杂度
递归算法的空间复杂度 = 递归堆栈的深度 + 算法申请的存储空间
或者再说得直观一点:
递归算法的时间复杂度 = 递归树的节点个数 x 每个节点的时间复杂度
递归算法的空间复杂度 = 递归树的高度 + 算法申请的存储空间
函数递归的原理是操作系统维护的函数堆栈,所以递归栈的空间消耗也需要算在空间复杂度之内,这一点不要忘了。
首先说一下动态规划算法,还是拿力扣上的凑零钱问题举例,它的暴力递归解法主体如下:
int dp(int[] coins, int amount) {
// base case
if (amount == 0) return 0;
if (amount < 0) return -1;
int res = Integer.MAX_VALUE;
// 时间 O(K)
for (int coin : coins) {
int subProblem = dp(coins, amount - coin);
if (subProblem == -1) continue;
res = Math.min(res, subProblem + 1);
}
return res == Integer.MAX_VALUE ? -1 : res;
}
当amount = 11, coins = [1,2,5]
时,该算法的递归树就长这样:
刚才说了这棵树上的节点个数为O(K^N)
,那么每个节点消耗的时间复杂度是多少呢?其实就是这个dp
函数本身的复杂度。
你看dp
函数里面有个 for 循环遍历长度为K
的coins
列表,所以函数本身的复杂度为O(K)
,故该算法总的时间复杂度为:
O(K^N) * O(K) = O(K^(N+1))
当然,之前也说了,这个复杂度只是一个粗略的上界,并不准确,真实的效率肯定会高一些。
这个算法的空间复杂度很容易分析:
dp
函数本身没有申请数组之类的,所以算法申请的存储空间为O(1)
;而dp
函数的堆栈深度为递归树的高度O(N)
,所以这个算法的空间复杂度为O(N)
。
暴力递归解法的分析结束,但这个解法存在重叠子问题,通过备忘录消除重叠子问题的冗余计算之后,相当于在原来的递归树上进行剪枝:
剪枝:
// 备忘录,空间 O(N)
memo = new int[N];
Arrays.fill(memo, -666);
int dp(int[] coins, int amount) {
if (amount == 0) return 0;
if (amount < 0) return -1;
// 查备忘录,防止重复计算
if (memo[amount] != -666)
return memo[amount];
int res = Integer.MAX_VALUE;
// 时间 O(K)
for (int coin : coins) {
int subProblem = dp(coins, amount - coin);
if (subProblem == -1) continue;
res = Math.min(res, subProblem + 1);
}
// 把计算结果存入备忘录
memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
return memo[amount];
}
通过备忘录剪掉了大量节点之后,虽然函数本身的时间复杂度依然是O(K)
,但大部分递归在函数开头就立即返回了,根本不会执行到 for 循环那里,所以可以认为递归函数执行的次数(递归树上的节点)减少了,从而时间复杂度下降。
剪枝之后还剩多少节点呢?根据备忘录剪枝的原理,相同「状态」不会被重复计算,所以剪枝之后剩下的节点数就是「状态」的数量,即memo
的大小N
。
所以,对于带备忘录的动态规划算法的时间复杂度,以下几种理解方式都是等价的:
递归的次数 x 函数本身的时间复杂度
= 递归树节点个数 x 每个节点的时间复杂度
= 状态个数 x 计算每个状态的时间复杂度
= 子问题个数 x 解决每个子问题的时间复杂度
= O(N) * O(K)
= O(NK)
像「状态」「子问题」属于动态规划类型问题特有的词汇,但时间复杂度本质上还是递归次数 x 函数本身复杂度,换汤不换药罢了。反正你爱怎么说怎么说吧,别把自己绕进去就行。
备忘录优化解法的空间复杂度也不难分析:
dp
函数的堆栈深度为「状态」的个数,依然是O(N)
,而算法申请了一个大小为O(N)
的备忘录memo
数组,所以总的空间复杂度为O(N) + O(N) = O(N)
。
虽然用 Big O 表示法来看,优化前后的空间复杂度相同,不过显然优化解法消耗的空间要更多,所以用备忘录进行剪枝也被称为「用空间换时间」。
如果你把自顶向下带备忘录的解法进一步改写成自底向上的迭代解法:
int coinChange(int[] coins, int amount) {
// 空间 O(N)
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
// 时间 O(KN)
for (int i = 0; i < dp.length; i++) {
for (int coin : coins) {
if (i - coin < 0) continue;
dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
该解法的时间复杂度不变,但已经不存在递归,所以空间复杂度中不需要考虑堆栈的深度,只需考虑dp
数组的存储空间,虽然用 Big O 表示法来看,该算法的空间复杂度依然是O(N)
,但该算法的实际空间消耗是更小的,所以自底向上迭代的动态规划是各方面性能最好的。
空间复杂度
空间复杂度的定义
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度 。空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
空间复杂度举例
例子1:冒泡排序的空间复杂度
var temp = 0;
for(var i=0;i<arr.length-1;i++){//确定轮数
for(var j=0;j<arr.length-i-1;j++){//确定每次比较的次数
if(arr[j]>arr[j+1]){
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
console.log("第"+i+"次排序"+arr)
}
console.log("最终排序:"+arr)
还是刚刚冒泡排序的代码,我们刚刚计算了它的时间复杂度,现在再来看一下它的空间复杂度,根据定义我们知道,空间复杂度是用来估算占用空间的大小的,那么我们就可以根据算法中创建的变量的个数来表示算法的空间复杂度,这个冒泡排序算法创建了3个变量,temp、i、j ,根据大O的渐进表示法的规则,该算法的空间复杂度就为O(1)。
例子2:循环方法计算斐波那契数列的空间复杂度
在计算时间复杂度时,我们知道使用递归的方法计算斐波那契数是一种非常低效的方法,而使用循环的方法就高效一些,那么循环的方法的空间复杂度又为多少?
long long* Fibonacci(size_t n)
{
if (n == 0)
return NULL;
long long* fibArray =(long long*)malloc((n + 1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
这就是循环方法计算斐波那契数的代码,我们发现在这个算法中,我们使用malloc开辟了一块元素个数为n+1的空间,那就相当于创建了n+1个变量,然后在for循环里使用了一个变量i,根据大O的线性表示法,该算法的空间复杂度就为O(N)。
例子3:使用递归算法的空间复杂度
使用递归算法的空间复杂度请看上面的时间复杂度部分的题目5