【Super数据结构】数据结构入门first step!了解些概念和时空复杂度计算!

在这里插入图片描述

🏠关于此专栏:Super数据结构专栏将使用C/C++语言介绍顺序表、链表、栈、队列等数据结构,每篇博文会使用尽可能多的代码片段+图片的方式。
🐎博主首页:Jammingpro
🚪归属专栏:Super数据结构
🎯每日努力一点点,技术累计看得见


数据结构是什么

数据结构从表面意思看,就是存储数据的物理结构。在我们编写程序时,我们需要考虑以什么样的方式存储数据。这就类似于生活中,我们喝咖啡会用马克杯,喝排骨汤会用碗。虽然用马克杯喝排骨汤也是可以的,但用碗会更合适。因而,我们在编写代码时,需要寻找合适的数据结构来存储数据。

在这里插入图片描述
上面是使用大白话解释的数据结构的概念,下面看一下正式的数据结构的概念:数据结构是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。

什么是算法

算法就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。

在生活中,煮饭需要先洗米、再加水、再打开电饭煲开关。这里的输入就是大米,而输出就是可食用的白米饭,从输入得到输出的一系列步骤就是算法
在这里插入图片描述

数据结构和算法的重要性

要吃饭得用碗,想存数据就得定下数据结构;要吃饭就得先做饭,想得到输出就得经过一系列算法步骤。在编程过程中,数据结构与算法无处不在,且非常重要。但数据结构、算法数量繁多,后续将在专栏中陆续介绍。

这篇博文并不介绍某一数据结构,而是先来谈论怎么计算效率。我们在考虑选择哪一种算法时,无非考虑这个算法快不快、用的内存多不多。下面将介绍如何计算算法的时间效率和空间效率(即算法的时间复杂度和空间复杂度)。

复杂度计算

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

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

下面是一段使用循环计算1+2+3+…+100000的代码👇

long long Count()
{
	long long sum = 0;
	for(int i = 1; i <= 100000; ++i)
		sum += i;
	return i;
}

下面是一段使用等差数列求和公式计算1+2+3+…+100000的代码👇

long long Count()
{
	return (100000 * (1 + 100000)) / 2;
}

从上面的代码中,我们可以看出,第一种方式需要重复执行sum += i代码十万次,而第二种方式只需要执行1次。两个算法的速度天差地别,下面我们来讨论一下,如何科学且简单的计算算法的复杂度。

时间复杂度计算

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

下面,我们来看一段程序,我们一起数数这个程序中,count++一共执行了多少次

void JammingPro(int n)
{
	int count = 0;
	for(int i = 0; i < n; i++)
		for(int j = 0; j < n; j++)
		{
			count++;
		}
	//上面两层for循环,共执行了n^2次count++
	for(int i = 0; i < 3 * n; i++)
		count++;
	//上面的for循环,共执行了3n次count++
	int NUM = 10;
	for(int i = 0; i < NUM; i++)
		count++;
	//上面的for循环,共执行了10次count++
	return 0;
}

有上面的代码,我们可以得到count++的执行次数 F ( N ) = N 2 + 3 ∗ N + 10 F(N)=N^2+3*N+10 F(N)=N2+3N+10。实际中我们计算时间复杂度时,并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法

那什么是大O渐进表示法呢?
在这里插入图片描述
上面的式子 F ( N ) = N 2 + 3 ∗ N + 10 F(N)=N^2+3*N+10 F(N)=N2+3N+10中,在N不断增大时,结果如下:

NF(N)
10130
10010210
10001002010

从表格中可以发现,在N不断增大时,整个F(N)的结果基本由最高此项 N 2 N^2 N2控制,其他项对整个F(N)的结果的影响微乎其微。因此,我们引入了大O表示法。

首先我们来看看一个概念=>大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
下面我们再聊聊推导大O阶方法:

1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。

使用大O的渐进表示法以后,Func1的时间复杂度为:

NF(N)
10100
10010000
10001000000

通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。

另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界),也就是运行最慢的情况
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界),也就是运行最快的情况

例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)。

知道了大O表示法是什么、怎么算,那我们就可以着手练一下啦~

在这里插入图片描述
第1个小可爱(Φ皿Φ)

int Func(int N)
{
	int count = 0;
	for(int i = 0; i < 8 * N; i++)
	{
		count++;
	}
	int M = 666;
	for(int i = 0; i < M; i++)
	{
		count++;
	}
	return count;
}

揭晓答案的时刻:O(N)
为什么呢?第一个for循环执行8*N次,第二个for循环执行666次, F ( N ) = 2 ∗ N + 666 F(N)=2*N+666 F(N)=2N+666。根据大O表示法规则,只保留最高次项,并将最高次项前的常数去除。因此,最终答案为O(N)。

第2个小可爱(Φ皿Φ)

void LaoBa(int M, int N)
{
	for(int i = 0; i < M; i++)
	{
		printf("Making berger\n");
	}
	for(int i = 0; i < N; i++)
	{
		printf("Making drink\n");
	}
}

揭晓答案的时刻:O(M+N)
为什么呢?第一个for循环执行M次,第二个for循环执行N次。M和N并没有任何关系,不存在谁更高次,谁更低次的问题。例如:M=100000,N=1,又例如:M=1,N=100000呢?因而M和N都应该保留,即O(M+N)。

第3个小可爱(Φ皿Φ)

void singSong(int N)
{
	for(int i = 0; i < 100; i++)
	{
		printf("Monkey Brother, Monkey Brother~\n");
	}
}

揭晓答案的时刻:O(1)
为什么呢?这里的for循环执行了100次,与传入的变量N无关。因为常数应被替换为1,因此答案为O(1)。

第4个小可爱(Φ皿Φ)

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

揭晓答案的时刻:O(N)
为什么呢?这个函数是C语言库自带的函数,它的作用是再str字符串中寻找第一个等于character的字符。因此,这个函数必须遍历整个str字符串,故答案为O(N)。

第5个小可爱(Φ皿Φ)

void BubbleSort(int arr[], int len)
{
	int exchange = 0;
	for(int i = 0; i < len - 1; i++)
	{
		exchange = 0;
		for(int j = 0; j < len - i - 1; j++)
		{
			if(arr[j] > arr[j + 1])
			{
				Swap(&arr[j], &arr[j + 1]);
				exchange = 1;
			}
		}
		if(exchange == 0)
		{
			break;
		}
	}
}

揭晓答案的时刻: O ( N 2 ) O(N^2) O(N2)
为什么呢?冒泡排序需要内层和外层两层循环,外层需要执行N-1次,内层随外层i的增大而每次减小。第一次内层循环需要执行N-2次,第二次内层循环需要执行N-3次,…。内层循环的执行次数随i增大,每次减少1,形成等差数列。F(N)=(N-2)(N-2+1)/2。因此答案为O(N^2)。

第6个小可爱(Φ皿Φ)

int BinarySort(int arr[], int len, int k)
{
	int left = 0;
	int right = len - 1;
	int mid = 0;
	while(left <= right)
	{
		mid = left + (right - left) / 2;
		if(arr[mid] > k)
		{
			right = mid - 1;
		}
		else if(arr[mid] < k)
		{
			left = mid + 1;
		}
		else
		{
			return mid;
		}
	}
	return -1;
}

揭晓答案的时刻: O ( l o g N ) O(logN) O(logN)
为什么呢?上面代码为二分查找,每次查找范围缩减一半。在最坏的情况下,要一直缩减到right<left的情况,即N/2/2/2/…=1,将这个式子转换为 N / ( 2 T ) = 1 N/(2^T)=1 N/(2T)=1,再转换为 N = 2 T N=2^T N=2T,对等号两侧取对数得到 T = l o g N T=logN T=logN。所以答案就是O(logN)。

第7个小可爱(Φ皿Φ)

int Func(N)
{
	if(N <= 1) return 1;
	return N * Func(N - 1);
}

揭晓答案的时刻: O ( N ) O(N) O(N)
为什么呢?上面程序是一个递归程序,当N=100时,则其将调用Func(99),Func(99)将调用Fun(98),以此类推,最终会调用99次程序。经过举例分析,我们可以知道,Func(N)将执行N-1次,而每次执行当前层次的Func函数体消耗的时间复杂度为O(1),故答案为O(N)。

第8个小可爱(Φ皿Φ)

int Fib(int N)
{
	if(N < 3) return 1;
	return Fib(N - 1) + Fib(N - 2);
}

揭晓答案的时刻: O ( 2 N ) O(2^N) O(2N)
为什么呢?在我们调用Fib(N)时,它将调用Fib(N-1)和Fib(N-2)。而Fib(N-1)和Fib(N-2)会继续调用Fib函数。由下图可以看到第一层调用了 2 0 2^0 20次Fib函数,第二层调用了 2 1 2^1 21次Fib函数,第三层调用了 2 2 2^2 22次Fib函数,以此类推。调用次数构成了一个等比数列,则总调用次数为 F ( N ) = ( 1 − 2 N − 1 ) / ( 1 − 2 ) F(N)=(1-2^{N-1})/(1-2) F(N)=(12N1)/(12)。所以答案为O(2^N)。
在这里插入图片描述

空间复杂度计算

介绍完时间复杂度后,下面我们再来谈谈空间复杂度。

空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法

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

下面我们通过几个例子来了解空间复杂度的计算。

例子1:

void BubbleSort(int arr[], int len)
{
	int exchange = 0;
	for(int i = 0; i < len - 1; i++)
	{
		exchange = 0;
		for(int j = 0; j < len - i - 1; j++)
		{
			if(arr[j] > arr[j + 1])
			{
				Swap(&arr[j], &arr[j + 1]);
				exchange = 1;
			}
		}
		if(exchange == 0)
		{
			break;
		}
	}
}

这个程序就是刚刚在冒泡排序,它的时间复杂度为O(N^2),但它的空间复杂度仅为O(1)。为什么呢?它明明有N个元素,不应该是O(N)吗?因为空间复杂度只考虑额外申请的空间,数组自身占有的空间是不计算的。如果还有疑虑,先看看下一个例子吧。

例子2:

int* Fib(int N)
{
	if(N <= 0) return NULL;
	
	int* arr = (int*)malloc(sizeof(int) * N);
	arr[0] = 1;
	arr[1] = 1;
	for(int i = 2; i < N; i++)
	{
		arr[i] = arr[i - 1] + arr[i - 2];
	}
	return arr;
}

这个是另一种求解斐波那契数列的程序。在程序中,申请了N个空间,很容易得出,该程序的时间复杂度为O(N)。与上一个程序不同,这里是程序主动申请了N个空间,而上一个程序中数组的空间不是程序内申请的。

例子3:

int Func(N)
{
	if(N <= 1) return 1;
	return N * Func(N - 1);
}

这个程序的空间复杂度为O(N)。因为Func(N)被调用时,会建立1个栈帧,而在Func(N)执行完毕前,这个栈帧不会被释放。而Func(N)调用了Func(N-1),它需要等待Func(N-1)执行完才能释放栈帧。Func(N-1)被调用后建立栈帧,它调用了Func(N-2),Func(N-2)又建立栈帧,以此类推。因此这个程序的空间复杂度为O(N)。

例子4:

int Fib(int N)
{
	if(N < 3) return 1;
	return Fib(N - 1) + Fib(N - 2);
}

这个程序的空间复杂度也是O(N)。在Fib(N)执行过程中,会先调用Fib(N-1),等Fib(N-1)执行完毕后才会调用Fib(N-2)。而Fib(N-1)会先调用Fib(N-2),等Fib(N-2)执行完毕后再调用Fib(N-3),以此类推。一直执行到Fib(3)时,它调用Fib(2)并建立栈帧,等Fib(2)执行完后,将其栈帧释放后,再执行Fib(1),并为其建立栈帧。此是,Fib(2)与Fib(1)共用1个栈帧空间。待Fib(3)执行完毕后,将返回调用它的Fib(4),Fib(4)调用的Fib(3)最多需要2个栈帧的空间,因为Fib(1)和Fib(2)共用了同一个栈帧空间。同理Fib(4)再调用Fib(2)时,它将使用的是Fib(3)释放的栈帧空间。

常见复杂度对比

常见的复杂度如下表所示:

大O表示名称
O(1)常数阶
O(N)线性阶
O ( N 2 ) O(N^2) O(N2)平方阶
O(logN)对数阶
O(NlogN)nlogn阶
O ( N 3 ) O(N^3) O(N3)立方阶
O ( 2 N ) O(2^N) O(2N)指数阶
O(N!)阶乘阶

在这里插入图片描述


文章结语:这篇文章对时间复杂度、空间复杂度、数据结构与算法概念进行了简要的介绍。
🎈欢迎进入Super数据结构专栏,查看更多文章。
如果上述内容有任何问题,欢迎在下方留言区指正b( ̄▽ ̄)d

  • 9
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值