【C】时间复杂度和空间复杂度(详解,例题,OJ试题)

前言:有了C语言的基础知识,但对于一个代码我们可以用算法和数据结构优化,如何优化我们后续再聊。本章我们需要学习衡量优化的标准是什么?我们知道,对于一个程序,衡量其好坏主要有他运行的时间和占用的空间两部分,下面我们来仔细的讨论一下。

1、算法效率

如何衡量一个程序的好坏呢?我们下面来看一段C代码:

long long Fib(int N)
{
 if(N < 3)
 return 1;
 
 return Fib(N-1) + Fib(N-2);
}
这是一段实现斐波那契数列的C函数,看上去使用递归简洁明了,实际上真的是这样吗?
我们考察应该从时间、空间两个维度来判断,即算法的复杂度:
算法在编写成可执行程序后,运行时需要耗费时间资源和空间 ( 内存 ) 资源 。因此 衡量一个算法的好坏,一般 是从时间和空间两个维度来衡量的 ,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间 。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。

2、时间复杂度

2.1时间复杂度概念

时间复杂度的定义:在计算机科学中, 算法的时间复杂度是一个函数 ,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法 的时间复杂度。
即:找到某条基本语句与问题规模 N 之间的数学表达式,就是算出了该算法的时间复杂度。
举个例子:

void Func1(int N)
{
	int count = 0;
	for (int i = 0; i < N; ++i)
	{
		for (int j = 0; j < N; ++j)
		{
			++count;
		}
	}

	for (int k = 0; k < 2 * N; ++k)
	{
		++count;
	}
	int M = 10;
	while (M--)
	{
		++count;
	}printf("%d\n", count);
}

计算一下++count语句计算了多少次?其实也就是简单的循环的累加,

答案:f(N)=N^2+2*N+10

N=10时,f(N)=130

N=100时,f(N)=10210

N=1000时,f(N)=1002010

我们发现,主导f(N)的主导值取决于最大指数的项。

实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要 大概执行次数,那么这 里我们使用大 O 的渐进表示法。

2.2大O的渐进表示法

O 符号( Big O notation ):是用于描述函数渐进行为的数学符号。
推导大 O 阶方法:
1 、用常数 1 取代运行时间中的所有加法常数。
2 、在修改后的运行次数函数中,只保留最高阶项。
3 、如果最高阶项存在且不是 1 ,则去除与这个项目相乘的常数。得到的结果就是大 O 阶。
使用大 O 的渐进表示法以后, Func1 的时间复杂度为:
N = 10 F(N) = 100
N = 100 F(N) = 10000
N = 1000 F(N) = 1000000
通过上面我们会发现大 O 的渐进表示法 去掉了那些对结果影响不大的项 ,简洁明了的表示出了执行次数。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数 ( 上界 )
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数 ( 下界 )
例如:在一个长度为 N 数组中搜索一个数据 x
最好情况: 1 次找到
最坏情况: N 次找到
平均情况: N/2 次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为 O(N)
下面我们介绍几个代表性的例子并作详细讲解。

2.3时间复杂度例题

1、我们先看上面出现过的一段代码:

void Func1(int N)
{
	int count = 0;
	for (int i = 0; i < N; ++i)
	{
		for (int j = 0; j < N; ++j)
		{
			++count;
		}
	}

	for (int k = 0; k < 2 * N; ++k)
	{
		++count;
	}
	int M = 10;
	while (M--)
	{
		++count;
	}printf("%d\n", count);
}

这里我们知道了count次数统计为f(N)=N^2+2*N+10,次数的决定效果最大的项是N^2,那么用大O方法表示该段代码的时间复杂度就是O(N^2)

2、我们在进一步看一段代码:


 //Func2的时间复杂度?
void Func2(int N)
{
	int count = 0;
	for (int k = 0; k < 2 * N; ++k)
	{
		++count;
	}
	int M = 10;
	while (M--)
	{
		++count;
	}
	printf("%d\n", count);
}

count统计的次数是2*N+10,我们知道了用大O表示法要去掉常数项10了,那么

时间复杂度是O(2*N)还是其他的呢?

其实他的时间复杂度是O(N),虽然次数统计的决定项是2*N,但是到决定他数量级的其实只是N的一次方,这里可以忽略系数,所以是O(N).

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);
}

这里看似是常数项,很多人会毫不迟疑的打出答案是O(1),那么真的正确吗?

其实这题有两个变量M,N,他们对于时间复杂度的影响是同一级别的,并且M,N的值是不一定的

所以这道题的时间复杂度是O(M+N)

4、继续看起来

void Func4(int N)
{
	int count = 0;
	for (int k = 0; k < 100; ++k)
	{
		++count;
	}
	printf("%d\n", count);
}

计算Func4的复杂度。这道题我们明确的知道了循环循环了100次,是一个准确的常数次,所以根据规定,这道题的时间复杂度是O(1)

5、而我们继续往下看一个库函数:
 

const char* strchr(const char* str, int character);

如果之前没见过这个库函数,这里我们简略讲解一下

通过查询,这个函数的作用是在一个字符串中查找一个字符,若查找成功则返回这个字符所在的地址,如果找不到返回NULL。

那么这个函数的时间复杂度如何?查找是常数次,是O(1)?查找的平均次数理论上是N/2,是O(N/2)?还是其他情况?

其实,根据上面的规定,我们对于这段代码,考虑的是他的最坏情况,正确的标准答案是O(N)!!!

总结:如果一个算法,随着输入不同,时间复杂度不同,我们往往取一个悲观预期。

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;
	}
}

这道题目我们还是从循环次数来着手,并考虑最坏的情况(这里引用了exchange变量,如果有序程序就会停止,但我们考虑悲观预期):

第一次我们比较并交换N-1个数

第二我们比较并交换N-2个数

.......

最后一次我们比较并交换1个数

累加就是(N-1)+(N-2)+(N-3)+....+2+1

高斯公式计算也就是N*(N-1)/2

他的最高指数项是N^2

因此时间复杂度为O(N^2)

7、我们再来看一下二分查找的时间复杂度:

 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
	assert(a);
	int begin = 0;
	int end = n ;
	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;
}

当看到这道题目,或许有些人已经傻眼了,我们可以确定的是时间复杂度小于O(N),或许有的人考虑悲观预期,但是又不会算;有的人不假思索答一个O(N/2)。那么这段代码我们该如何思考呢?

其实我们首先确实要考虑悲观情况,最悲观的情况其实就是这个数找不到,那么我们查找完要经过多少次呢?

我们设找了x次,然后假设我们已经找到了最后一个数的时候,我们反过来想,也就是展开x次,所以x个2相乘等于N,x=log^2 N,因此时间复杂度是O(log^2 N)

可见效率非常高

 8、计算一下阶乘的时间复杂度:

 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
	if (0 == N)
		return 1;

	return Fac(N - 1) * N;
}

 下面我们看图解

其实也就是执行了N次递归,所以时间复杂度是O(N).

9、计算一下斐波那契数列的时间复杂度:

请看以下代码:
 

 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
	if (N < 3)
		return 1;

	return Fib(N - 1) + Fib(N - 2);
}

 

 这里我们画出过程图解,最后一次大致计算了2^N个(右枝比左枝少几项),也就是用了2^N的时间,所以这里的最大数量级就是2^N,可以理解为2^N最主要决定这个函数运行时间,时间复杂度就是O(2^N)。

这道题目的时间复杂度太大,所以这个递归的算法除了简易,实际上用处不大,N=50时计算所花费的时间已经不可想象了,指数式的爆炸增长。

3、空间复杂度

有了上面的学习,那么介绍空间复杂度就容易多了。现在随着硬件的升级,大部分题目其实对空间要求不严格了,而更多地采用空间换时间的做法,ok,小提一句,我们下面开始介绍空间复杂度即应用。

3.1空间复杂度概念

空间复杂度也是一个数学表达式,是对一个算法在运行过程中 临时占用存储空间大小的量度
空间复杂度不是程序占用了多少 bytes 的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟实践复杂度类似,也使用 O 渐进表示法
注意: 函数运行时所需要的栈空间 ( 存储参数、局部变量、一些寄存器信息等 ) 在编译期间已经确定好了,因 此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
注意!!!!是额外的空间,为变量本身开辟的空间并不包含哦!

3.2空间复杂度例题

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;
	}
}

有些对概念理解不清的人还是会认为是O(2^N),其实是不对的。我们来深入详解一下。

对于空间复杂度,我们先看的就是空间了,我们看这道题申请了什么空间,除了变量本身申请的空间外,额外申请的空间就是end和i的空间,常数个空间,因此空间复杂度就是O(1)。   

这里大家要学会与时间复杂度区别开。

2、我们再看一下斐波那契数列的空间复杂度:

 计算Fibonacci的空间复杂度?
 返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{
	if (n == 0)
		return NULL;

	long long* fibArray = (long long*)malloc((n + 1) * sizeof(long long));//O(N)
	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

因此空间复杂度是O(N)。

但是有的斐波那契额数列的函数并没有malloc开辟空间

 计算斐波那契递归Fib的空间复杂度?
long long Fib(size_t N)
{
	if (N < 3)
		return 1;

	return Fib(N - 1) + Fib(N - 2);
}

我们来借用一下下面的图:

 我们知道递归是不断调用自己,调用Fib(5)直到最后符合条件返回值,这道题其实调用到最后是五个栈帧空间,调用后销毁返回,返回后的空间数总是比返回前小(并且空间是可以重复利用的,可以覆盖在之前销毁的空间上),这就知道了空间最大利用的时候是5.

以此类推,空间复杂度为O(N)。

小结:时间不可以重复利用,但是空间可以。

以上就是空间复杂度和时间复杂度的介绍,例题包含了很多种特殊情况,干货满满哦。

我们总结一下,有O(1),O(N),O(N^2),O(2^N)等等种情况,理想可用的代码通常是O(1),O(N)等复杂度小的,而O(2^N)这类指数式增长的对实际操作来说,没有太大的意义,所以我们在学习完本章后,要学会优化算法!

4、复杂度的OJ练习

题目一:力扣https://leetcode.cn/problems/missing-number-lcci/

数组nums包含从0n的所有整数,但其中缺了一个。请编写代码找出那个缺失的整数。你有办法在O(n)时间内完成吗? 

示例 1:

输入:[3,0,1]
输出:2

这道题我们最基本的想法是排序然后用条件语句比对,但是最基本的排序和比对时间复杂度太大,有什么能历遍一遍的方法呢?

我们进一步思考,我么是不是可以创建一个数组,先初始化成0,然后历遍数组把数值为n的数放在a[n]中,然后看数组中还是为初始化0的是哪一个(假设为a[k]),那么少的数就是哪一个(k)。这个方法确实有进步,但是还是完成不了O(N)的时间限制。

这里我们介绍一种比较巧妙的方法:

首先明确三点:1、任何数和本身进行异或运算都为0.因为异或运算是每个比特位进行,相同为0,相异为1。

2、0和任何数异或都还是那个数本身,还是异或特点推出来的。

3、异或运算有交换律,比如1^2^3=2^3^1

纳闷这道题目就可以利用异或的特点,先初始化一个0和这0到numsSize个数异或,再和数组中的数异或,得出的值就是缺少的值!

请看代码:

int missingNumber(int* nums, int numsSize)
{
	int num = 0;
	int i = 0;
	for (i = 0; i <= numsSize; i++)
	{
		num ^= i;
	}
	for (i = 0; i < numsSize; i++)
	{
		num ^= *nums;
		nums++;
	}

	return num;
}

题目二:

力扣https://leetcode.cn/problems/rotate-array/给你一个数组,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
解释:
向右轮转 1 步: [7,1,2,3,4,5,6]
向右轮转 2 步: [6,7,1,2,3,4,5]
向右轮转 3 步: [5,6,7,1,2,3,4]

来源:力扣(LeetCode)

  • 尽可能想出更多的解决方案,至少有 三种 不同的方法可以解决这个问题。
  • 你可以使用空间复杂度为 O(1) 的 原地 算法解决这个问题吗?

题解:这道题,我们先想,用最基本的数据结构可以先把最后一个数保存出来,然后前面的数移到后面,再把保存的最后一个数放进第一个位置,右移几次就实行多少次以上的步骤,这一方法可行,空间复杂度可行。

第二种思路是在开辟一块数组空间,然后按要求存放,空间复杂度太大,不可取。

第三种思路是大神发现的,我们来讲解一下:

右移k个数,我们先把前n-k个数倒置,再把后k个数倒置,然后整体倒置,就是右移k个数后得到的结果,是不是很巧妙呢?这里的倒置函数也非常好实现。请看代码:

void reverse(int *arr,int left, int right)
{
	int tmp = 0;
	while (left < right)
	{
		tmp = arr[left];
		arr[left] = arr[right];
		arr[right] = tmp;
		left++;
		right--;
	}
}
void rotate(int* nums, int numsSize, int k)
{
	if(k> numsSize)
	k = k % numsSize;
	reverse(nums, 0,numsSize-k- 1);
	reverse(nums , numsSize - k , numsSize - 1);
	reverse(nums, 0,numsSize - 1);
	
}

以上就是时间复杂度和空间复杂度详解,还望大家支持一下!谢谢!

猜你喜欢

转载自blog.csdn.net/m0_67821824/article/details/127421094