【数据结构】时间、空间复杂度实例分析

跌倒了,就重新站起来,继续向前走;傻坐在地上是没用的。💓💓💓

目录

•✨说在前面

🍋知识点一:算法的效率

[• 🌰1.斐波那契数列的第n项](#• 🌰1.斐波那契数列的第n项)

[• 🌰2.算法的复杂度](#• 🌰2.算法的复杂度)

🍋知识点二:时间复杂度

[• 🌰1.时间复杂度的概念](#• 🌰1.时间复杂度的概念)

[• 🌰2.大O的渐进表示法](#• 🌰2.大O的渐进表示法)

🔥复杂度的一般分析法则

🔥总结求解复杂度的方法

[• 🌰3.时间复杂度的量级](#• 🌰3.时间复杂度的量级)

[• 🌰4.时间复杂度增长趋势](#• 🌰4.时间复杂度增长趋势)​​​​​​​

[• 🌰5.时间复杂度计算案例](#• 🌰5.时间复杂度计算案例)

🔥案例1:单个同量级for循环

🔥案例2:多个同量级for循环

🔥案例3:常数控制的for循环

🔥案例4:strchr函数的时间复杂度

🔥案例5:冒泡排序的时间复杂度

🔥案例6:调整语句为i=i*2的for循环

🔥案例7:二分查找的时间复杂度

🔥案例8:递归求阶乘

🔥案例9:递归求斐波那契数列的第n项

🍋知识点三:空间复杂度

[• 🌰1.空间复杂度的概念](#• 🌰1.空间复杂度的概念)

[• 🌰2.空间复杂度计算案例](#• 🌰2.空间复杂度计算案例)

🔥案例1:冒泡排序的空间复杂度

🔥案例2:递归求阶乘

[• ✨SumUp结语](#• ✨SumUp结语)


•✨说在前面

亲爱的读者们大家好!💖💖💖,我们又见面了,在之前的阶段我们学习了顺序表、链表,包括单链表和双向链表,还刷了一些算法OJ练习。**这些练习,我们有时可以有多种思路和方法解决,那我们如何对这些方法进行取舍呢?哪些方法是最优的呢?**为此,我们必须进入学习时间复杂度和空间复杂度的相关知识,相信你学习完后就可以回答这个问题了。

👇👇👇

💘💘💘知识连线时刻(直接点击即可)

🎉🎉🎉复习回顾🎉🎉🎉

【数据结构】顺序表专题详解(带图解析)

【数据结构】单链表专题详细分析

【数据结构】双向循环链表专题解析

博主主页传送门:愿天垂怜的博客

🍋知识点一:算法的效率

• 🌰1.斐波那契数列的第n项

在讲解时间复杂度与空间复杂度之前,我们先看一个简单的例子:

练习:写一个程序,求出斐波那契数列的第n项的值。

**方法1:**迭代法

cpp 复制代码
long long Fibonacci(int n)
{
	int x1 = 1;
	int x2 = 1;
	int x3 = 1;
	while (n >= 3)
	{
		x3 = x1 + x2;
		x1 = x2;
		x2 = x3;
		n--;
	}
	return x3;
}

**方法2:**递归法

cpp 复制代码
long long Fibonacci(int n)
{
	if (n == 1 || n == 2)
		return 1;
	return Fibonacci(n - 1) + Fibonacci(n - 2);
}

由观察不难发现,递归的写法明显要比迭代的写法要短的多,那是不是就说明递归的写法就比迭代的写法更好呢?其实不然,实际上用递归来写的话它的运行效率将会大大降低。

比如求第50项,就要先得到49项和48项,要得到第49项,就要得到48项和47项......它会执行很多很多次,这是它效率不高的原因。

所以,项数较大时,我们还是用循环(迭代)的方式来实现。

所以说,我们不能只根据程序的长短就果断地判断程序的好坏。

• 🌰2.算法的复杂度

那究竟如何衡量算法的好坏呢?就是要看程序的时间复杂度空间复杂度。

算法在编写成可执行的程序后,运行时需要耗费时间资源和空间(内存)资源,因此衡量一个算法好坏,一般从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。

时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小,所以对于空间复杂度很是在乎,但结果计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度,所以我们如今已经不需要再特别关注一个算法的空间复杂度,转而更加关注他的时间复杂度。

🍋知识点二:时间复杂度

• 🌰1.时间复杂度的概念

定义: 在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。

一个算法执行所消耗的时间,从理论上来说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?这显然非常麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比,算法中的基本操作的执行次数,为算法的时间复杂度。

即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。

• 🌰2.大O的渐进表示法

实际问题中我们不需要精确的计算执行次数,而只需要大概的执行次数,即使用大O的渐进表示法。

公式如下:

🎉参数:

**• T(n):**代码执行所需要的时间,它是n的函数,T(n)代表了算法的时间复杂度。

**• n:**数据规模的大小,但有可能不止一个,如两个for循环分别循环m、n次,则该参数为m、n。

**• f(n):**每行代码执行的次数总和,它是n的函数,但我们只关注最大量级的那一项。

**• O:**表示T(n)与f(n)之间的关系为正比例,即一个算法所花费的时间与其中语句的执行次数成正比。

🔥 复杂度的一般分析法则

1)单端代码看高频:比如for、while循环。

2)多段代码取最大:比如一段代码中有单循环和多重循环,那么取多重循环的复杂度。

3)嵌套代码求乘积:例如递归、多重循环的结构。

4)多个规模求加法:比如算法中有两个参数控制了两个for循环,那么此时复杂度取二者复杂度之和。

🎉举例:

cpp 复制代码
//请计算一下Func1中++count语句总共执行了多少次?
void Func(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);
}

根据复杂度的分析原则,在上面的这个函数Func1中,第一个循环是嵌套结构,for循环嵌套结构执行了n^2次,第二个for循环执行了2n次,最后一个while循环执行了10次。

由此,这个函数中基本语句的执行次数f(n)的表达式为:f(n)=n^2+2n+10,所以T(n)=O(n^2+2n+10)。但是,大O的渐进表示法并不具体代表代码真正的执行时间,而是代表代码执行时间随数据规模n增长的变化趋势。

当n很大时,比如1000、100000,此时公式中的低阶、常量、系数三部分不左右增长趋势,所以都可以忽略,只关注最高阶的那一项就可以了。

所以最终,Func的时间复杂度表示为:T(n)=O(n^2)。

🔥总结求解复杂度的方法

1)只关注循环执行次数最多的一段代码,总复杂度等于量级最大的那段代码的复杂度

2)加法法则:若控制两个for循环的数量级相同,则总复杂度取二者复杂度之和。

3)乘法法则:嵌套循环的复杂度等于嵌套内外代码复杂度的乘积。

• 🌰3.时间复杂度的量级

**多项式阶:**随着数据规模的增长,算法的执行时间和空间占用,按照多项式的比例增长,包括:O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n^2)(平方阶)、O(n^3)(立方阶)。

**非多项式阶:**随着数据规模的增长,算法的执行时间和空间占用暴增,这类算法性能极差。包括,

O(2^n)(指数阶)、O(n!)(阶乘阶)。

• 🌰4.时间复杂度增长趋势

常见不同数量级的复杂度增长趋势图如下:

另外有些算法的时间复杂度存在最好、平均和最快的情况:

**🎉最坏情况:**代码在最坏情况下执行的时间复杂度,即任意输入规模的最大运行次数(上界)。

**🎉平均情况:**代码在所有情况下执行的次数的加权平均值,即任意输入规模的期望运行次数。

**🎉最好情况:**代码在最理想情况下执行的时间复杂度,任意输入规模的最小运行次数(下界)。

比如:在一个长度为N的数组中搜索数据x

最坏情况:N次找到

平均情况:N/2次找到

最好情况:1次找到

在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据的时间复杂度为O(N)。

• 🌰5.时间复杂度计算案例

🔥案例1:单个同量级for循环

计算Func2的时间复杂度。

cpp 复制代码
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);
}

函数Func2中,第一个for循环执行了2N次,第二个while循环的执行次数是M,但是M为已知量10,量级最大的为2N,所以Func2的时间复杂度为O(N)

🔥案例2:多个同量级for循环

计算Func3的时间复杂度。

cpp 复制代码
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);
}

函数Func3中,第一个for循环执行了M次,第二个for循环执行了N次,且M、N量级相同,所以Func3的时间复杂度为O(M+N),或O(max(M,N))

注意:若M>>N,则时间复杂度为O(M),若M<<N,则时间复杂度为O(N)。

🔥案例3:常数控制的for循环

计算Func4的时间复杂度。

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

函数Func4中,控制for循环的次数为常数,代码不随n的增长而增长,常数阶的时间复杂度为O(1),即Func4的时间复杂度为O(1)

🔥案例4:strchr函数的时间复杂度

**strchr:**strchr() 用于查找字符串中的一个字符,并返回该字符在字符串中第一次出现的位置。

strchr函数模拟实现代码如下:

cpp 复制代码
const char* my_strchr(const char* str, int character)
{
	assert(str);
	while (*str)
	{
		if (*str == character)
			return str;
		else
			str++;
	}
}

在strchr查找的过程中,最好的情况是字符character就在下一个字符,一次就可以找到,也就是常数阶,此时时间复杂度为O(1);最坏的情况是字符character在离位置的无穷远处,也就是线性阶。此时时间复杂度为O(N);取最坏的情况,所以strchr的时间复杂度为O(N)

🔥案例5:冒泡排序的时间复杂度

计算冒泡排序BubSort的时间复杂度。

cpp 复制代码
void bubSort(int arr[], int length)
{
	assert(arr);
	int flag = 1;
	while (flag && length--)
	{
		flag = 0;
		for (int i = 0; i < length; i++)
		{
			if (arr[i] > arr[i + 1])
			{
				int temp = arr[i];
				arr[i] = arr[i + 1];
				arr[i + 1] = temp;
				flag = 1;
			}
		}
	}
}

冒泡排序中,由类似案例2的嵌套循环结构,但是像案例2这样的嵌套循环,它的内层for循环的控制参数是不变的,也就是说,内部循环一共执行了M次,每次执行内部循环都需要循环N次,所以总共的执行次数是M*N,这很好理解,但现在冒泡排序的内部循环一共执行了length次,而第一次for循环length次,第二次length-1次,第三次length-2次...,若考虑最坏的情况,最后到1次。这种情况怎么处理呢?

**显然内部for循环的循环次数是一个等差数列,其中公差d=2,首项a1=1,尾项an=length(简写为n),项数为length。**对于这样一个等差数列,我们可以对其求和,所得到的结果不就是所有的执行次数了吗?

根据高斯求和公式,很容易计算出等差数列的前n项和,即基本操作的执行次数,显然为平方阶,量级为N^2,所以冒泡排序Bubsort的时间复杂度为O(N^2)

其实对于案例2这样每次都是循环固定次数的嵌套,或者说,**任意的双层嵌套都可以转化为数列求和的问题,**如案例2,显然就是一个公差d=0,首项为N,尾项为N,项数为M的等差数列

根据高斯求和公式,也能够分析出基本操作的执行次数,即时间复杂度为O(MN)

🔥案例6:调整语句为i=i*2的for循环

计算Func5的时间复杂度。

cpp 复制代码
void Func5(int n)
{
	int x = 0;
	for (int i = 1; i < n; i *= 2)
	{
		x++;
	}
}

函数Func5中,这个for循环的调整语句为i*=2,即i每次都是前一次的两倍。这种循环是有规律的,这个我们后面再说。我们先直接分析这个函数,假设基本语句x++执行了N次,那么有循环语句可得:

所以我们得到,执行次数N为对数阶,所以时间复杂度为O(logN)这里需要注意,为了方便起见,当底数为2时,我们直接将底数2省略不写,

🔥案例7:二分查找的时间复杂度

计算二分查找BinarySearch的时间复杂度。

cpp 复制代码
int BinarySearch(int arr[], int length, int x)
{
	assert(arr);
	int left = 0;
	int right = length - 1;
	while (left <= right)
	{
		int mid = left + right - ((left - right) >> 1);
		if (arr[mid] < x)
			left = mid + 1;
		else if (arr[mid] > x)
			right = mid - 1;
		else if (mid == x)
			return x;
	}
	return -1;
}

在二分查找函数BinarySearch中,如果我们直接观察while循环的话,其实是不太好看出来的,因为left和right时不时都在改变,此时我们不要死盯代码,一定要理解它的实际意义,也可以看图进行观察

最好的情况我们很容易想到,就是要查找的x就在中间,我们一下就找到了,此时是时间复杂度为O(1)。那最坏的情况呢?其实就是当要查找的x在数组的两端的时候,比如x是第一个元素,此时我们设mid到x的距离为n,**则mid会以每次靠近一半的速度逼近第一个元素x,也就是每次都除以2,直到这个值等于1,**就找到了第一个元素。我们设循环执行了N次,mid第一次的位置为n,则

同样地,如果x是最后一个元素,也是同样的道理。显然为对数阶,所以二分查找BinarySearch的

的时间复杂度为O(logN)

🔥案例8:递归求阶乘

计算Fact的时间复杂度。

cpp 复制代码
long long Fact(size_t N)
{
	if (0 == N)
		return 1;

	return Fac(N - 1) * N;
}

这是一个递归求阶乘的函数。在函数Fact中,我们假设N=5的情况如下:

可以发现,每次递归的单个函数都是常数阶,即O(1),而递归总共调用了5+1=6次(Fact(5)、Fact(4)、Fact(3)、Fact(2)、Fact(1)、Fact(0))。

以递归的函数我们先看单个函数内部的阶,再看递归了多少次。如果是N,那么每个函数为O(1),递归了N+1次,那么总共为(N+1)O(1),所以递归求阶乘函数的时间复杂度为O(N)

🔥案例9:递归求斐波那契数列的第n项

计算Fibonacci函数的时间复杂度。

cpp 复制代码
long long Fibonacci(int n)
{
	if (n == 1 || n == 2)
		return 1;
	return Fibonacci(n - 1) + Fibonacci(n - 2);
}

同样的道理,我们看单个函数内部的阶,显然为O(1),那递归了多少次呢?

我们以n=5为例,此时我们能画出面类似于树状图的结构,在数的每个节点都进而伸出两个节点,第一行为个数为1(2^0),第二行个数为2(2^1),第三行个数为4(2^2)...以此类推,第n行的个数为2^n。但其实上大家能看到,这颗树是歪的,它的底部缺失了一块三角形的部分,但是当n很大时,这些缺失的部分相对于整体的个数其实就很少了,由此我们可以将每一行的递归次数看做一项,那整体就可以看做首项a1=1,公比q=2的等比数列,求和得到的即是全体递归的总次数。

虽然真正的递归次数没有这么多,但是它的量级是不会被影响的,依然是指数阶,所以用递归求斐波那契数列的第n项,它的时间复杂度为O(2^N)

稍许有些不严谨,在二叉树的部分我们还会提到。

🍋知识点三:空间复杂度

• 🌰1.空间复杂度的概念

**空间复杂度也是一个数学表达式,**是对一个算法在运行过程中临时占用存储空间大小的亮度。

空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。

空间复杂度计算规则基本和时间复杂度类似,也是大O渐进表示法。

注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式的额外空间来确定。

• 🌰2.空间复杂度计算案例

🔥案例1:冒泡排序的空间复杂度

计算冒泡排序BubSort的空间复杂度。

cpp 复制代码
void bubSort(int arr[], int length)
{
	assert(arr);
	int flag = 1;
	while (flag && length--)
	{
		flag = 0;
		for (int i = 0; i < length; i++)
		{
			if (arr[i] > arr[i + 1])
			{
				int temp = arr[i];
				arr[i] = arr[i + 1];
				arr[i + 1] = temp;
				flag = 1;
			}
		}
	}
}

在冒泡排序BubSort中,创建了:int flag = 1,int i = 0两个变量,为常数个,所以冒泡排序的空间复杂度为O(1)

注意:空间复杂度算的是算法中额外开辟的空间,所以数组arr和长度length并不算在内。

🔥案例2:递归求阶乘

计算Fact函数的空间复杂度。

cpp 复制代码
long long Fact(size_t N)
{
	if (0 == N)
		return 1;

	return Fac(N - 1) * N;
}

对于这样的一个递归函数,每次递归都会在栈上创建栈帧空间(函数栈帧),每个栈帧使用了常数个空间,所以空间复杂度为O(N)

• ✨SumUp结语

数据结构的学习一定要多画图,多理解,多思考,切忌直接抄写代码,就认为自己已经会了,一定到自己动手,才能明白自己哪个地方有问题。

如果大家觉得有帮助,麻烦大家点点赞,如果有错误的地方也欢迎大家指出~

相关推荐
Theodore_10222 小时前
4 设计模式原则之接口隔离原则
java·开发语言·设计模式·java-ee·接口隔离原则·javaee
网易独家音乐人Mike Zhou3 小时前
【卡尔曼滤波】数据预测Prediction观测器的理论推导及应用 C语言、Python实现(Kalman Filter)
c语言·python·单片机·物联网·算法·嵌入式·iot
‘’林花谢了春红‘’4 小时前
C++ list (链表)容器
c++·链表·list
----云烟----4 小时前
QT中QString类的各种使用
开发语言·qt
lsx2024064 小时前
SQL SELECT 语句:基础与进阶应用
开发语言
开心工作室_kaic5 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
向宇it5 小时前
【unity小技巧】unity 什么是反射?反射的作用?反射的使用场景?反射的缺点?常用的反射操作?反射常见示例
开发语言·游戏·unity·c#·游戏引擎
武子康5 小时前
Java-06 深入浅出 MyBatis - 一对一模型 SqlMapConfig 与 Mapper 详细讲解测试
java·开发语言·数据仓库·sql·mybatis·springboot·springcloud
转世成为计算机大神5 小时前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
搬砖的小码农_Sky5 小时前
C语言:数组
c语言·数据结构