前言
在上卷中我们知道了什么是复杂度,也通过几个案例初步了解了时间复杂度的计算方式,那么在下卷中我们继续来了解时间复杂度的计算和空间复杂度。
正文
时间复杂度(续前文)
我们先从上卷最后讲到的计算复杂度的示例开始。
推导大O阶规则
时间复杂度函数式T(N)中,只保留最⾼阶项,去掉那些低阶项,因为当N不断变⼤时, 低阶项对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
如果最⾼阶项存在且不是1,则去除这个项⽬的常数系数,因为当N不断变⼤,这个系数 对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
T(N)中如果没有N相关的项⽬,只有常数项,⽤常数1取代所有加法常数。
示例3
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
可以看出执行次数为100,T(N)=100,根据规则3,时间复杂度就为O(1)。括号里的1不是运行1次,而是代表所有常数。
示例4
// 计算strchr的时间复杂度?
const char * strchr (const char* str, int character)
{
const char* p_begin = str;
while (*p_begin != character)
{
if (*p_begin == '\0')
return NULL;
p_begin++;
}
return p_begin;
}
T(N)取决于查找的位置:
我们可以将字符串分为3个部分,则复杂度就有3种情况。O(1)则最好情况,O(N)是最坏情况,O(N/2)是平均情况但是由于去掉系数,所以最终也是O(N)。
总结
通过上面我们会发现,有些算法的时间复杂度存在最好、平均和最坏情况。
最坏情况:任意输⼊规模的最⼤运⾏次数(上界)平均情况:任意输⼊规模的期望运行次数
最好情况:任意输⼊规模的最⼩运⾏次数(下界)
⼤O的渐进表⽰法在实际中⼀般情况关注的是算法的上界,也就是最坏运⾏情况。
根据关注的是最坏运行情况,我们最终得到这个示例算法的时间复杂度为O(N)。
示例5
我们来看一个我们熟悉的排序算法:冒泡排序的时间复杂度。
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0) //说明已有序,直接跳出
break;
}
}
最好的情况是数组有序,O(N);最坏的情况:数组无序,外循环不止执行一次,执行不到exchange。
这种最坏的情况下,外循环控制的是内循环交换的次数,时间复杂度算的是内层循环交换的次数。
外循环执行第几次 | 内循环交换次数 |
---|---|
1 | n-1 |
2 | n-2 |
3 | n-3 |
…… | …… |
n-1 | 1 |
n | 0 |
T(N)=n-1+n-2+n-3+……+2+1+0
即等差数列求和:T(N)=(0+n-1)×n/2,去掉系数最后复杂度为O(N²)。
示例6
void func5(int n)
{
int cnt = 1;
while (cnt < n)
{
cnt *= 2;
}
}
假设我们现在n为10,再假设执行的次数为x
那么: 2 x < 10 2^x<10 2x<10
第几次循环 | cnt×2的值 |
---|---|
1 | 2 ( 2 1 2^1 21) |
2 | 4 ( 2 2 2^2 22) |
3 | 8 ( 2 3 2^3 23) |
4 | 16 ( 2 4 2^4 24) |
因为第3次循环得到的8仍然是小于10的,还会载进入循环,再执行一次,所以x(执行的次数)为4。
我们的时间复杂度计算的就是x。
我们知道
2 x = n x = l o g 2 n 2^x=n \\x=log_2n 2x=nx=log2n
所以时间复杂度为O( l o g 2 n log_2n log2n)。
补充:
注意课件中和书籍中 l o g 2 n log_2n log2n 、log n 、lg n 的表⽰
当n接近⽆穷⼤时,底数的⼤⼩对结果影响不⼤。因此,⼀般情况下不管底数是多少都可以省略不 写,即可以表⽰为log n
不同书籍的表⽰⽅式不同,以上写法差别不⼤,我们建议使⽤log n
对于计算机来说, 2 x 、 3 x 、 4 x 2^x 、3^x、 4^x 2x、3x、4x三者的区别并不大,所以底数可以直接省略不写。为什么底数对结果影响不大,也可以看看换底公式。
示例7
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
阶乘,即n! = 1×2×3×…… ×n,或者可以表示为n! = n×(n-1)!。0的阶乘为1。
我们知道N的阶乘,递归会创建N次函数栈帧,我们来看看一次函数栈帧的创建(也就是一次函数调用)执行的次数是多少,可以看出是O(1)。但是每一次函数的调用都有时间复杂度的消耗,N次0(1)要相加,所以最后的时间复杂度是O(N)。
总结就是单次递归的时间复杂度×递归次数。
至此,常见的几种时间复杂度计算已散落在各个示例中完成了讲解。
空间复杂度
空间复杂度也是⼀个数学表达式,是对⼀个算法在运⾏过程中因为算法的需要额外临时开辟的空间。
空间复杂度不是程序占⽤了多少bytes的空间,因为常规情况每个对象⼤⼩差异不会很⼤,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟时间复杂度类似,也使⽤⼤O渐进表⽰法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、⼀些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运⾏时候显式申请的额外空间来确定。
值得注意的一点是,前面我们提到的推导大O阶规则不止适用于时间复杂度,也适用于空间复杂度的计算。
示例1
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
第一个例子仍然是冒泡排序。
函数BubbleSort调用和形参都会创建空间,但是是在编译期间就已经确定好的,不去考虑。函数体是在运行时申请空间。
第一个for循环括号中创建n时消耗1个空间,下面创建exchange消耗1个空间,内循环创建i消耗1个空间,所以空间复杂度为O(1)。因为是常数。
示例2
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
Fac递归调⽤了N次,额外开辟了N个函数栈帧, 每个栈帧使⽤了常数个空间
因此空间复杂度为:O(N)。
示例3
如果此时我们得到的一个函数是这样动态开辟的:
int func(int n)
{
int arr[n]=malloc(sizeof(int)*n);
//……
}
空间复杂度为O(N)。
常见复杂度对比
这些线越陡峭说明n对其的影响太大了,复杂度不好。
至此,本文结束,祝阅读愉快😺