初阶数据结构【1】--时间复杂度和空间复杂度(绝对最详细且通俗易懂的博客,不看一下吗?)

数据结构

咱们在讲结构体的那个章节中,提到过数据结构。那么今天我们就来详细讲解数据结构

  • 概念:数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在⼀种或多种特定关系的数据元素的集合。语言转换数据结构就是对数据进行存储和管理的一种方式。比如,我们存储4个整型数据,我们可以直接创建4个整型变量进行存储,进行代码展示:
int a=1;
int b=2;
int c=3;
int d=4;

当然,我们还可以用数组进行存储,进行代码展示:

int arr[4]={1 , 2 , 3 ,4};

直接创建4个整型变量进行存储就是一种数据结构,用数组进行存储也是一种数据结构。所以,数据结构就是数据存储的方式。我们可以存储数据,还以对存储的数据进行管理比如,更改数据,增加或删除数据等)。讲到这里,我想大家有一些疑问了——我们直接创建变量或者用数组这两个数据结构(存储方式),不就够了吗?干嘛还要学那么多的数据结构呢?我们举个生活中的例子:我们都知道,一年四季,不同的季节要穿不同的衣服,你总不能穿个夏季的衣服过四季吧!!!。数据结构也是这个道理,没有一种单一的数据结构对所有的场景都有用,所以我们要学各式各样的数据结构,如:线性表、树、图、哈希等。

算法

说到算法,就给人一种高大尚的感觉,感觉那是神圣不可及的事。其实,算法没那么高级,离我们很近。

  • 概念算法(Algorithm)就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出简单来说算法就是解决问题的方法,用来将输入数据转化成输出结果。比如,我们之前写的排序方法——冒泡排序。我们的需求是对数据进行排序,那么什么办法能实现呢?(这就需要我们进行思考了),然后我们就想到一种方法——相邻元素进行比较。这个解决问题的办法就是算法。所以,算法一点也不神奇,一点也不高大尚。
  • 数据结构和算法的关系这俩的关系就像是“连体婴儿”。他们俩谁也离不开谁。因为,数据结构是存储数据的方式,我们就要根据具体的场景(具体的需求),想个方法(算法)去把数据给存进去

算法复杂度

我们知道,解决问题的方法有很多种,其中有些方法解决问题的效率很高,有些方法解决问题的效率很低。算法也是这个道理,那么怎样去衡量一个算法的好坏呢?我们先来写个算法题:点击:力扣-旋转数组
大家来看下这个算法题目:在这里插入图片描述
我们先来讲一下大致思路:由题目可知,我们要对数组里面的数据进行K次的旋转。我们可以先创建个变量,把这个数组的最后一个元素给存里面,以便后面我们一次转完后,把这个值放在首元素。我们使相邻的元素进行值的交换,这就使得整体向后移动。
进行代码展示:

void rotate(int* nums, int numsSize, int k) {
    while (k--) {
        int temp = nums[numsSize - 1]; // 把最后一个元素给取出来
        for (int i = numsSize - 1; i > 0; i--) {
            nums[i] = nums[i - 1];
        }
        nums[0] = temp;
    }
}

我们展示一下这个在线平台的运行结果图:在这里插入图片描述
在这里插入图片描述
我们发现,我们写的代码通过了两个用例测试。但是,提交后却没有通过,38个测试用例中,我们通过了37个测试用例,有一个测试用例没通过。它警告我们:超出时间限制。这说明了,我们写的代码没有语法的错误,还算能完成大部分任务。但是,对于个别测试用例就不能了。这个网站的结果从一方面说明了,我们的算法还不太好,需要继续优化。那么,我们到底怎样去评判一个算法的好坏呢?难道只通过在线平台吗?那肯定不现实。这就要讲到算法复杂度了。

  • 算法复杂度算法复杂度就是用来衡量算法的好坏的,用来评估算法的运行效率。 我们知道算法的本质还是代码,代码的运行是需要时间和空间的。我们从生活的常识认知就可以通过时间和空间来初步的判断算法的好坏,比如,算法的运行时间越短,算法占有的空间越小,那就可以说明这个算法就越好。所以,我们又可以从两个方面进行评估算法——时间和空间,即:时间复杂度空间复杂度

1.时间复杂度

讲到这个概念,很自然的就会想到程序运行的时间。那么程序运行的时间怎么算呢?在这里,我们可以用 clock()函数进行时间差这个函数计算的是程序运行到这个函数时的时间,我们直接计算两次调用这个函数的时间差,就可以算出一段程序运行的时间。进行代码展示:

#define  _CRT_SECURE_NO_WARNINGS	1
#include <stdio.h>
int main()
{
	int t1 = clock();  //计算程序运行到这的时间t1
	for (int i = 0; i < 100000; i++)
	{
		for (int j = 0; j < 10000; j++)
		{
			int k = 0;
		}
	}
	int t2 = clock();  //计算程序运行到这的时间t2
	printf("%d\n", t2 - t1);  //两次的时间差,就是这个循环嵌套的运行时间
	return 0;
}

我给大家展示三次运行的结果图:在这里插入图片描述在这里插入图片描述在这里插入图片描述
通过三次运行结果图,我们就能发现其中的猫腻——三次运行的时间不同。代码都是同一个代码,计算的时间竟然不一样,这就很震惊了。所以,我们评估算法的好坏是不用时间去计算的,况且我们还无法精确计算出时间(每次的运行时间还不一样)不用时间去评估的原因如下

* 1.因为程序运行时间和编译环境和运行机器的配置都有关系,比如,同一个算法程序,
用一个老编译器进行编译和新编译器编译,在同样机器下运行时间不同。
这能说明这个算法的本体有问题吗?显然不能说明。
* 2.同一个算法程序,用一个老低配置机器和新高配置机器,运行时间也不一样。
* 3.只有写完程序后,才能对程序进行时间测试。况且,每次测试(运行)的时间还不一样。
所以,测出时间也是无法评估的。

我们把能用时间去评估算法好坏的情况都给想的差不多了,但是,都不行。那是不是就没有什么办法了呢?先给大家看个公式,如图所示:在这里插入图片描述
上面公式的含义就是,程序运行的时间就是每条语句运行的时间乘以每条语句的执行次数,最后对每种语句进行求和,就得出程序运行的时间每条语句的运行时间,我们是无法知道的,而且语句运行时间对于问题的研究是没影响的。其实,在同一个编译环境下,每条语句运行的时间都是差不多的,它们的差别是微乎其微的。可以忽略不计。所以,我们就假设每条语句运行的时间是相同的。那么,就剩每条语句执行的次数的问题了,每条语句执行的次数我们是可以精确的算出的。在假设,每条语句执行的时间相同的前提下,我们的每条语句执行次数就与程序运行时间成正比了。所以,我们就可以用每条语句执行的次数的总和,粗估的表示程序运行的时间(因为我们假设每条语句运行时间相同了,直接用次数代表程序运行的时间了)。总结就是拿语句执行次数来代表程序运行的时间。经过上述的解释和描述,我们的程序运行时间就可以写成如下的公式,如图所示:在这里插入图片描述
我们先来一个练习题,以便我们引出时间复杂度的概念:

// 请计算一下这个程序的所有语句执行的总次数
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;
		}
}

这个程序中所有语句的执行次数总和如图所示:在这里插入图片描述

我们已经把每条语句的运行时间都假设相同了,发现程序运行时间的函数表达式还是很麻烦,无法直观的看出结果这个时候就要引出时间复杂度的概念了——我们写完一个程序运行时间的函数表达式后,最后的结果用大O表示法表示最后的结果,那么这个最后的结果就是时间复杂度

  • 大O的渐进表示法规则:大O符号,是用于描述函数进阶行为的数学符号。
    • 1.时间复杂度函数式T(N)中,只保留最高阶项,去掉那些低阶项,因为当N不断变大时,低阶项对结果影响越来越小,当N无穷大时,就可以忽略不计了
    • 2.如最高阶项存在且不是1,则去除这个项目的常数系数,因为当N不断变大时,这个系数对结果的影响越来越小,当N无穷大时,就可以忽略不计了
    • 3.T(N)中如果没有N相关的项目,只有常数项,用常数1取代所有加法常数

所以,我们现在回过头来再看刚才的代码,那么这个程序的时间复杂度就是O(N^2)

  • 练习【1】,如下:
    计算这个程序的count 时间复杂度
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);
}

这个程序的时间函数表达式:T(N)=2N+12,即时间复杂度为O(N)。——大O的第二条规则。

  • 练习【2】,如下:
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);
}

T(N)=M+N,即 O(M+N),O(M)或者O(N)。我们发现这个函数表达式有两个变量,这两个变量都会对结果有影响,我们就需要讨论了当M和N量级相同时,时间复杂度O(M+N),当M>>N时,时间复杂度O(M), 当N>>M时,时间复杂度O(N)

  • 练习【3】:如下:
const char * strchr ( const char* str, int character)
{
		const char* p_begin = s;
		while (*p_begin != character)
		{
				if (*p_begin == '\0')
				return NULL;
				p_begin++;
		}
			return p_begin;
}

这个程序的作用是:在一串字符串中遍历每个字符,找到自己目标的字符。像这种代码的执行情况就要分三种了。第一种情况:我们在开始第一个字符就找到目标字符,也就是说我们一次就找到目标字符了,即O(1)这种情况被称为最好的情况第二种情况:就是我们的字符在末尾,需要遍历整个字符串才能找到我们的目标字符,即O(N),这种情况被称为最坏的情况第三种情况:我们的目标字符既不在开头,也不再末尾,而是在两者之间,可能在这个字符串的1/2处,1/3处或1/10等,即O(系数*N)==O(N),这种情况被称为平均情况。我们不难发现,有些算法是存在多种情况的,我们进行如下的总结,如图所示:在这里插入图片描述
面对这种情况,我们取的是最坏的情况。因为,我们把最坏的情况给考虑进去了,那么最好的情况就自然而然包含了。

  • 练习【4】,如下:我们举个我们所熟知的冒泡排序算法,还是上面的三种情况给大家练练手。
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;
		}
}

最好的情况:整个数据刚好是有序的,那么我们只需要遍历一趟就OK了,即O(N+1)<=>O(N)。
最坏的情况:整个数据都乱序的,且是从大到小的乱序。需要排序N-1趟,且每趟还要排N-1对,所以,执行次数为 (N-1)+(N-2)+……+1这就是个等差数列,即O(N^2)。所以,最后这个程序的时间复杂度为O( N2)。

  • 练习【5】:我们这程序要用到数学中的对数。
void func5(int n)
{
	int cnt = 1;
	while (cnt < n)
	{
		cnt *= 2;
	}
}

给大家看一下推导图:在这里插入图片描述
n的输入不同,执行次数也会有所不同。但是从我们的推算中可以看出来,不同的n值,但是执行的次数有些是相同的。所以,我们可以选取画红线的数据来作为代表。时间复杂度就是看语句的执行次数,即求对数得,如图:在这里插入图片描述
在数学中,我们知道以底为10的 log 是可以省略底数的。在计算机中,我们的对数底数是可以直接省略的,因为底数,对我们研究时间复杂度的影响是微乎其微的,可以省略不写。如图所示:在这里插入图片描述

2.空间复杂度

  • 前情引述:我们大费口舌的在这里讲解什么是时间复杂,是不是就能说明时间复杂度很重要呀?确实如此。相比于时间复杂度,空间复杂度就那么看重了早在以前的计算机中,我们的计算机存储很稀缺,当时主要是KB和MB的储存空间。所以,当时的程序员都是尽最大可能的去合理使用内存,去节省内存。但是,随着计算机这个行业的迅速发展,我们的存储来到了GB,TB和PB。有了更高的容量,况且价格还很低。所以,我们就不太去关注内存够不够的问题了,所以,空间复杂度就显得没那么重要了。虽然,空间没那么太在意了,但是我们还是要有节约内存的这个意识。比如,地球的水资源很多,但是可用的淡水占比很少,我们日常生活中感受不到水资源的紧缺,但是我们还能看到节约用水的标语,这就是提醒大家要有节约用水的意识(习惯)。
  • 空间复杂度的介绍我们计算一个算法的空间复杂度的时候,不是去计算它占有多大的空间,因为这本身是没有任何意义的。我们会在后面出一期《栈帧的创建和销毁》里面会详细讲解函数,形参和变量是怎么在内存中开辟空间的在内存中的栈区会先为函数创建一片空间,里面存放的是这个函数相关的寄存器指令,形参,常量和变量。这些都是与函数本体相关的,所以就存放在开辟的空间里。不管是什么用途的函数(程序),在栈区开辟的空间大小都是差不多的(差别微乎其微),所以用函数(程序)占有的大小来计算空间复杂度是没有意义的。在各个程序占有的空间大小差不多的前提下,我们就看谁在运行时所需要的额外空间了,谁用的少,谁的算法就好
  • 额外空间:那么怎样的算是额外空间呢?先给大家讲个常见的,函数调用就是一个。每次调用函数的时候,就需要额外申请空间。在一个程序里面。当遇到循环语句的时候,就需要另辟空间,因为在这个循环题里面,具体做什么指令,具体要多大的空间也是不知道的,只有运行的时候需要多少,就开辟多少,这就需要另辟空间了(自家的空间万一不够用了,计算机就是这样运行的)当我们输入的值对这个程序有影响的时候,比如,数组的元素个数。
void fun (int num)
{
		int arr [num];       
}

数组的空间大小随我们的输入值变化和变化。像这种不确定的情况,它也要另开辟空间,和循环体一样。

  • 空间复杂度的表示空间复杂度表示也是大O渐进表示法
  • 总结:当我们计算空间复杂度的时候,我们只需要关注数组的空间是否变化,是否是循环体和是否是函数调用,这的三个部分。其它的都不需要管,因为这些都放在函数(程序)的原本空间里
  • 练习【1】:
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(1)。讲到这里,可能有人有疑问了,局部变量都是有生命周期的,这来来回回的创建和销毁,不应该是N次吗?我们是空间复杂度,不是时间复杂度,无论创建多少次,你空间的总数还是不变的。比如,你在北京买套房,今天卖了,明天在上海又买一套房,折腾很多次结果你还是一套房,就是这个道理时间具有累积效应,空间没有累积效应

  • 练习【2】:
long long Fac(size_t N)
{
		if(N == 0)
		return 1;
		return Fac(N-1)*N;
}

这个程序的时间复杂度,咱们已经算过了O(N)。我们来演示一下这函数调用的情况示意图:在这里插入图片描述
这个程序的空间复杂度为O(N)。是不是每个递归函数的时间复杂度和空间复杂度都相同呢?不是,我们写个伪代码,如图所示:在这里插入图片描述
我们这个程序里面,既有循环语句,又有递归语句。每次递归后,创建的空间里面都会有循环语句。首先,因为递归了N个空间,且每个空间里面都有循环,且循环了N次。所以,时间复杂度:O(N ^ 2+N)<=>O(N ^2)。空间复杂度:O(N)。这种情况下,时间复杂度和空间复杂度就不同了。

总结

  • 1.时间复杂度就是计算每条语句的执行次数(也包含函数语句被调用的次数),时间复杂度具有累积效应。空间复杂度就是计算申请的额外空间——计算需要额外申请空间的程序体内变量个数,还有递归空间和变化数组空间。空间复杂度是没有累积效应的
  • 2.这些复杂度的本质还是数学,我们进行数学图的展示,让大家直观感受一下。如图所示:在这里插入图片描述
    不同的数学表达式,对应的趋势不一样,越平缓的曲线越好,算法复杂度也是如此

彩蛋时刻!!!

歌曲:《去看看世界多美》–贼好听哎!
知道数据结构学起来很困难。但是,我希望大家不要因此丧失了信心,我会持续学习数据结构并更新高质量博客,希望对大家有所帮助。咱们一起努力加油,去不断的挑战自己!!!
在这里插入图片描述
每章一句笃志前行,虽远必达感谢你能看到这里,点赞+关注+收藏+转发是对我最大的鼓励,咱们下期见!!!

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值