目录
0.前言
本篇博客所有代码以及思维导图都已上传至Gitee,可自取:
1.算法的复杂度简述
算法,是针对一个具体问题,我使用的某一个特定方法,而每种解决方法都要耗费时间资源和空间(内存)资源。衡量一个算法的好坏,我们通常是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
PS:由摩尔定律可知,我们的内存空间越来越有盈余,内存成本逐渐下降,而运行速度即CPU的算力其实遇到了瓶颈,所以我们在当代更加关注时间性能的提升。
2.时间复杂度
2.1 什么是时间复杂度
首先我们要确定一件事情,时间复杂度并不是算这种算法,全过程具体用了多少秒。因为在不同的环境下,计算的具体秒数是不同的。比如你在一个二十年前的电脑上跑,和在一个搭载3090的拯救者上跑,其计算秒数一定是不同的。我们无法掌控具体的运算时间。
时间复杂度的量度,是基本操作的执行次数。在同一段代码中,其基本操作的个数是可以具体量化的。例如我们这个算法要解决这个问题,需要做1000次基本操作,你这个算法要做1000*1000次基本操作才能跑出来,而不是各个算法具体要多少秒跑出来。
void Func1(int N)
{
int count = 0;
//第一个for循环
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count;
}
}
//第二个for循环
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
//while循环
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
例如如上代码,要计算时间复杂度,我们就要求出执行基本操作的次数,第一个for循环基本操作的次数就是N次,第二个for循环中,基本操作的次数是2*N,还有while循环基本操作的次数是10次。所以Func1基本操作的总数是3*N+10次。
时间复杂度并不是用精确的基本操作次数3*N+10来表示,事实上,时间复杂度使用的大O表示法,来粗略的表示基本操作次数,下面我们看大O表示法如何化简表示基本操作次数。
2.2 大O表示法
2.2.1 例一
我们举一个例子,比如一个算法的基本操作次数为
N=10 | F(10) | 230 |
N=100 | F(100) | 20210 |
N=1000 | F(1000) | 2002010 |
N=10000 | F(10000) | 200020010 |
我们看到随着N的逐渐增加,影响基本操作次数的主导因素到了上,其他的一次项和常数项并不能左右整体的大小。大O表示法,其实就是只要影响力最大的那一项。也就是说在N -> 无穷,越来越大时,只保留最影响主体最大的那一项。
所以我们提取出这一项,然后大O表示法还需要去掉系数,只需要去单位大小即可,如写成,0.5*N写成N。
所以某个算法的具体操作次数,用大O表示法,得出该算法的时间复杂度为 。
2.2.2 大O表示法规则
1、用常数1取代运行时间中的所有加法常数。2、在修改后的运行次数函数中,只保留最高阶项。3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。4、在实际中一般情况关注的是算法的最坏运行情况。
我们在接下来的代码实践当中进行规则应用,学习时间复杂度的计算方法。
2.2.3 例二
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k)
{
++count;
}
for (int k = 0; k < N ; ++ k)
{
++count;
}
printf("%d\n", count);
}
在这个例子当中,我们可以算出基本操作的次数,为M+N,而我们知道计算时间复杂度,取的是当数趋于无穷大时,在次数大小起主导作用的项。而M和N我们并不知道谁更占主体。所以此时需要分情况讨论。
情况一:如果M远大于N,那时间复杂度为O(M);
情况一:如果N远大于M,那时间复杂度为O(N);
情况一:如果M和N相当,差不多大,那时间复杂度为O(M)或 O(N)都可以;
2.2.4 例三
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
Func4基本操作的次数是100,而根据大O表示法的规则,Func4的时间复杂度就是O(1),这里的1并不是指1次,而值得是常数次。
2.2.5 例四
const char * strchr ( const char * str, int character )
{
int i = 0;
while(str[i] != '\0')
{
if(str[i]==character)
{
return str+i;
}
}
return NULL;
}
在一个字符串str当中,寻找character这个字符,我们就需要遍历这个字符串,查看有无这个字符。可是有可能这个字符一下子就能找到,也有可能遍历到最后才能找到,情况是多样的。
许多算法的时间复杂度存在最好、平均和最坏情况:最坏情况:任意输入规模的最大运行次数 ( 上界 )平均情况:任意输入规模的期望运行次数最好情况:任意输入规模的最小运行次数 ( 下界 )如我们刚才的搜索字符串中特定字符,最坏情况下基本搜寻操作的次数是N,平均次数是N/2,最好情况是1常数次。
在时间复杂度计算中,我们是关注的是算法的最坏运行情况,所以字符串中搜索字符的时间复杂度为O(N)
2.2.6 例五
// 计算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;
}
}
冒泡排序所需要做的基本操作次数,最坏的情况下是0+1+2+...+N-1=次,也即,而最好情况下(也就是一开始就是顺序的情况下),我们要做的基本操作次数只需要次。
时间复杂度是一个悲观的预期。最好 平均 最坏✔
所以冒泡排序时间复杂度为->,即。
2.2.7 例六
// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1;
while (begin < end)
{
int mid = begin + ((end-begin)>>1);
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid;
else
return mid;
}
return -1;
}
再看一个二分查找算法。
时间复杂度看的基本操作的次数,不能简单的,而是要深入过程,深入思想,看其所作基本操作的次数。
首先我们回忆一下二分查找算法的基本查找逻辑:
二分查找就是对一个顺序(我们以从小到大为例)的数组,每次对比剩下区域的中间大小的数,如果比中间数大则继续操作上半段数,比中间数小则继续操作下半段数,相等则意味着找到了,就结束查找。这样我们就可以看到每次都在砍掉数组的一半长度,直到把数组砍到只剩下最后一个元素,才为查找完毕。
而时间复杂度算的是最悲观的情况,所以我们取的是最悲观的情况,也就是每次砍掉一半,砍到最后只剩一个元素。
然后我们以二分查找(其实是log2N时间复杂度的任何算法)为例,看一下二分查找这个算法在效率上的优秀。
所以我们看到,在10亿中查一个人,我们仅需要查找30次。而在20亿中查,我们也仅需要查30+1==31次。这查找效率堪称优秀!
2.2.8 例七
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
递归的时间复杂度算法:需要看栈帧展开图
算法其实就是 递归的次数(展开过的栈帧的个数) 乘以 每次函数调用的基本操作次数。也即递归次数*每次递归调用的次数。
2.2.9 例八
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
画递归的栈帧展开图,求出递归次数。然后我们可以看出每次递归调用的基本操作次数都是1。两者相乘。
所以事实上,斐波那契数列的递归写法其实就是一个实际没用的算法,因为太慢了。
现在我们来建立一个观念,O(2^N)的时间复杂度实际上是非常非常非常慢的。n取30,仅仅求一个Fib(30),我们要做的基本操作次数就要跑10亿次!而n取40,仅仅求一个Fib(40),我们要做的基本操作次数居然达到了壹万亿次!会产生指数爆炸的现象。
所以我们求斐波那契数列实际上用循环迭代法,而不用递归算法。
2.3 时间复杂度计算总结
1、用常数1取代运行时间中的所有加法常数。2、在修改后的运行次数函数中,只保留最高阶项。3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。4、在实际中一般情况关注的是算法的最坏运行情况。5、递归的时间复杂度算法:递归次数×每次递归调用的次数。
3.空间复杂度
3.1 空间复杂度的量度
空间复杂度,是 临时 额外 最大所占用存储空间大小的量度。
空间复杂度并不是具体精确到,一个算法,即你要解决这个问题,要耗费具体多少个字节。空间复杂度求法的基本量度应该是所用变量的个数。同时我们要注意,这个变量个数有桑耳限定词,临时 额外 最大的所用变量个数。
同时我们表示空间复杂度,也是采用大O表示法,来粗略的表示所用变量的个数。
我们在具体的实例中理解空间复杂度的求法。
3.2 例一
// 计算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;
}
}
计算冒泡排序的空间复杂度。
下面我们进行变量个数的统计(临时 额外 最大的所用变量个数)。我们看到次程序当中有n个变量的数组a,也有我们定义的变量end,i,exchange。但是我们计算空间复杂度,看的是额外定义的变量个数,看的是为了解决这个问题而新额外定义的变量。所以int* a数组的n个变量不参与统计。
然后我们理解临时最大的变量个数,计算的是所有时刻当中,所占用的空间的最大值,也就看的是在所有时刻中,选出变量的个数最多的那个时刻。
比如说我们这个冒泡排序的例子中,是在进入最内层的那个for循环当中,我们临时使用的变量个数到达最大(end + exchange + i)3个。
再根据大O表示法进行化简,我们得到冒泡排序的空间复杂度为O(1)。【3->O(3)->O(1)】
3.3 例二
// 计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前n项
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;
}
我们看到Fibonacci函数中,在堆区开辟了(n+1)个变量,还有在栈区开辟了1个指针变量,此外就再无定义的变量个数了
3.4 例三
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
对于 递归调用函数算法的空间复杂度,其计算方法是 展开栈帧的次数*每个栈帧最大所用的变量个数。
开辟了N+1个栈帧,每个栈帧使用了常数个空间,所以额外最大临时定义的变量数就是(N+1)* 常数。所以O((N+1)* 常数 ), 空间复杂度为O(N)。
3.5 例四
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
递归函数算法的空间复杂度算法,并不是像时间复杂度一样,需要你算出所有的展开的总个数再乘以每个栈帧的总变量个数,我们首先要知道一件事:时间是不断累积的,而空间是可以重复利用的。我们举一个生动的例子进行说明:
例如我们有一个缸和在仓库中很多的桶,但是每一个桶都有一个特性,用一次就会化成空气,用完就得去仓库里拿新的一个。我们需要用一趟一趟的用水桶把水缸填满,这时候我们一桶一桶把水缸的盛满的过程中,其实我们会用坏很多的桶,空间复杂度是看所有时刻中使用变量个数的最大的时刻,并不一定是全过程你使用过的所有变量个数的累计,而你即使用过那么多的桶,假设我们用过1000个桶,而在所有时刻内,我们最多所用到的总空间其实就是只有一个水桶(之前的水桶都化成空气了,并没有占我空间),所以只能说此时的空间复杂度只能是一个水桶。
对于递归调用函数算法的空间复杂度,其计算方法更准确的说是 曾展开过的栈帧的最大次数*每个栈帧最大所用的变量个数。(PS:这个 曾展开过的栈帧的最大次数,说明白一点,就
是函数递归,栈帧所到达的最大深度)
下图是Fib展开过的所有栈帧数量的累计:
下面我看这个过程中Fib算法的栈帧展开过程:[注意看最大递归深度]
我们看整个变化的过程当中:栈帧展开的最大深度(函数栈帧所占栈区的最大空间)为N-1,而每一次展开的栈帧中,我们所使用到的变量个数都是1。(N-1)*1 = N-1,所以用大O表示法,我们知道Fib的空间复杂度是O(N)。
4.复杂度OJ(带你在实战中分析复杂度)
下面我们做几个力扣OJ的题,来具体应用刚刚学到的时间复杂度和空间复杂度的分析。
4.1 P1 消失的数字
下面是原题,在numsSize个数的数组nums当中,存储着0----n其中的n-1个数,我们现在需要找到是缺了哪一个数。
4.1.1解法一
可以对nums数组进行升序排序,然后对这个排序之后的数组进行遍历,当数组下标不等于改下标nums数组上的数据时,我们就找到了这个缺失的数。
int cmp(const void* e1,const void* e2)
{
return *((int*)e1) - *((int*)e2);
}
int missingNumber(int* nums, int numsSize){
//C语言快排&&cmp升序排列
qsort(nums,numsSize,sizeof(int),cmp);
for(int i=0;i<numsSize;++i)
{
if(nums[i]!=i){
return i;
}
}
return numsSize;
}
我们使用的是C语言中的qsort接口进行排序,其底层逻辑是quick sort快排,快排的时间复杂度是,然后遍历一遍nums的所需最坏也是,所以该种方法的时间复杂度就是+ = 。同时这个方法当中,我们只定义了常数个变量,所以空间复杂度是。
4.1.2解法二
我们让(0+1+2+3+......+n)减去(nums[0]+nums[1]+nums[2]+nums[3]+......nums[n-1]),最后的结果就是缺少消失的数字。
int missingNumber(int* nums, int numsSize){
//numsSize是nums的元素数目大小n
//(0+1+2+3+......+n)
//减去(nums[0]+nums[1]+nums[2]+nums[3]+......nums[n-1])
long long sum = 0;
for(int i=0;i<numsSize;++i)
{
sum+=i;
sum-=nums[i];
}
sum+=numsSize;
return sum;
}
这样我们看到整体方法当中,额外所使用的变量个数为常数个,所以空间复杂度是O(1)。
而整体只是遍历了数组一遍,基本操作的次数是N,所以时间复杂度是O(N)。
4.1.3解法三
新开辟一块n+1大小的数组,下标从0到n,哪一个位置有值,我们就在哪个下标的位置上填入值。最后遍历查看,哪个下标没有被赋值,哪个下标就是缺少的数字。
int missingNumber(int* nums, int numsSize){
//开辟一段n+1的数组
int* ptmp = (int*)malloc(sizeof(int)*(numsSize+1));
//设置初始值为0
memset(ptmp,0,sizeof(int)*(numsSize+1));
//然后根据nums对每个位置赋值
for(int i=0;i<numsSize;++i)
{
//若出现则下标处赋值为1
ptmp[nums[i]] = 1;
}
for(int i=0;i<numsSize+1;++i)
{
if(ptmp[i]==0)
return i;
}
return -1;
}
我们额外开辟使用了(n+1)个变量的数组,所以空间复杂度就是O(N)。
过程当中我们对数组中每一个元素赋值为0,然后赋值1,最后检查,遍历了三次数组。总体的基本操作次数有3N,所以本算法的时间复杂度就是O(N)。
4.1.4解法四
利用异或的特性,示例就是a^b^b^c^c == a,我们让0去异或0,1,2,3......n,以及异或nums[0],nums[1],nums[2]......nums[n-1],在两个序列当中都存在的数异或后就都没了,最后得出的结果就是那个缺少的数字。
int missingNumber(int* nums, int numsSize) {
int ret = 0;
for (int i = 0; i < numsSize; ++i)
{
ret ^= nums[i];
ret ^= i;
}
ret ^= numsSize;
return ret;
}
我们只使用了两个额外的变量,所以空间复杂度就是O(1)。
然后全过程的基本操作我们只是遍历了一次,所以时间复杂度就是O(N)。
4.2 P2 左旋转字符串
4.2.1 暴力挪动法
实现整体的挪动,就把每一个字符都往前挪,一共挪动k次。
char* reverseLeftWords(char* s, int n){
//把字符串s往左旋n位
char* start = s;
int len = strlen(s);
while(n--)
{
char tmp = s[0];
int i = 0;
s=start;
while(i<len-1)
{
//进行往下一个元素的覆盖
s[i] = s[i+1];
i++;
}
s[len-1]=tmp;
}
return start;
}
分析这个方法,对于这个N个元素的字符串,我们总共挪动了n次,每次挪动都会做N次基本操作。所以该方法的基本操作次数是n*N,当n的大小和N差不多大时,该种方法是较坏的情况,时间复杂度是一种悲观的预期,故时间复杂度是O(N^2)。
在本算法当中我们只是定义了常数个变量,所以空间复杂度是O(1)。
4.2.2 空间换时间
另外开辟n个空间的大小,存储s[0]----s[n-1]的数据,然后让后面的字符都依次从s[0]开始往后填入,最后再填入我们的存储的n个首元素即可。
char* reverseLeftWords(char* s, int n){
char* ptmp = (char*)malloc(sizeof(char)*n);
for(int i=0;i<n;++i)
{
ptmp[i]=s[i];
}
int index = 0;
int i = n;
while(s[i]!='\0')
{
s[index++]=s[i++];
}
i = 0;
while(n--)
{
s[index++]=ptmp[i++];
}
return s;
}
这种方法下,我们新开辟使用了(n+1)个变量,当n逼近于N时,是悲观的情况,所以空间复杂度就是O(N)。
本次方法我们遍历了两个字符串的长度,基本操作的个数是2*N,所以时间复杂度就是O(N)。
4.2.3 三旋转法
最后这个算法,是一个非常神奇且高效的算法,堪称是本题的最优解了。
我们首先对[0]----[n-1]这n个字符 && [n]----[strlen-1]这strlen-n个字符,分别进行reverse倒置,然后对整体字符串[0]----[strlen-1]进行reverse倒置,之后得到的结果就是左旋之后的结果。
如我们看“abcdef”,n==2时,"abcdef"=>"bafedc"=>"cdefab"。
void reversestring(char* s,int left,int right)
{
//旋转字符串s,从[left,right]区域的字符串
while(right>=left)
{
char tmp = s[left];
s[left] = s[right];
s[right] = tmp;
++left;--right;
}
}
char* reverseLeftWords(char* s, int n){
int len = strlen(s);
reversestring(s,0,n-1);
reversestring(s,n,len-1);
reversestring(s,0,len-1);
return s;
}
分析这个算法,我们可以看到,我们总共对这N个字符,做了两波基本操作,所以基本操作的次数就是2*N,所以时间复杂度就是O(N)。
而我们只使用了常数级别的变量,所以说空间复杂度就是O(1)。