【数据结构与算法】时间复杂度和空间复杂度

作者:一只大喵咪1201
专栏:《数据结构与算法》
格言:你只管努力,剩下的交给时间!
【数据结构与算法】时间复杂度和空间复杂度_第1张图片

时间复杂度和空间复杂度

  • 前言
  • 数据结构与算法
  • 时间复杂度
    • 大O渐近表示法
    • 例题
    • 常见复杂度对比
  • 空间复杂度
    • 例题
  • 总结

前言

本喵的C语言基础学习到此告一段落,接下来开始学习数据结构与算法,还是和以前一样,本喵会继续分享自己的所学所得,在巩固自己知识的同时希望能够给大家提供一些帮助。当前本喵学的数据结构是属于初级阶段,是通过C语言来实现的,等到后面学了C++会继续分享更加高级的数据结构。一些算法会穿插在整个学习的过程中,不多废话,我们进入正文。

数据结构与算法

数据结构是什么?

数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。

通俗来讲:数据结构就是如何管理内存中的数据。

那么有一个问题,数据库是什么呢?它和数据结构有什么区别?

通俗来讲:数据库是如果管理硬盘中的数据。

数据结构和数据库的区别就是管理的数据所在的位置不同。

算法是什么?

算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。

通俗来讲:算法就是系列的计算步骤,用来将输入数据转化成输出结果。

如何学好数据结构和算法呢?

也没有别的捷径,就是死磕代码。


磕成这样就可以了,哈哈。

这里本喵建议不能光死磕,要注意画图和思考

时间复杂度

对于一个算法,我们怎么知道它的好坏呢?也就是怎么衡量它的效率呢?
我们先看一个例子:

//计算斐波那契数列
int Fib(int n)
{
	if (n < 3)
		return 1;
	else
		return Fib(n - 1) + Fib(n - 2);
}

这是通过递归的方法求斐波那契数列,我们可以看到,代码量很少,但是代码少就一定是好的吗?效率就一定高吗?以什么方式来衡量呢?首先我们可以考虑从程序执行所消耗的时间来判断一个程序的效率是否高。

一段程序执行所花费的时间可以通过在具体机器上运行来得出,但是有那么多的机器,我们将程序在各个机器上都跑一下吗?显然是不现实的,并且同一个程序在不同的机器上运行所花费的时间都不一样的。

所以我们需要通过别的方式来代替时间来衡量程序的执行效率。我们在这里提出一个概念,时间频度,它是用来表示程序中语句的执行次数。

时间频度T(n)

  • 时间频度T(n)是一个数学函数表达式,
  • 自变量是n,表示算法的规模大小,n越大表示执行的语句就越多
  • T(n)是函数值,表示算法中语句执行的次数

【数据结构与算法】时间复杂度和空间复杂度_第2张图片

在上图中的程序,蓝色框中的语句只执行一次,红色框中的语句会执行n次。

  • 其中,n表示的是问题规模
  • 只执行的一次的语句有4条,条用函数属于执行一条语句,执行n次的语句有俩条
  • 一共要执行2n+4条语句,所以这里的时间频度T(n)就是2n+4

这样我们就求出了这段程序中语句执行的次数,也就是该程序的时间频度T(n)。
我们来看一段代码:

// 请计算一下Func1基本操作执行了多少次?
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);
}

我们在这里仅统计程序中的基本操作的执行次数来计算T(n)的大小。
【数据结构与算法】时间复杂度和空间复杂度_第3张图片
将进行++count执行的次数统计出来就是我们要求的时间频度T(n)。

这里,T(n) = N^2 + 2*N + 10

当N是不同值的时候通过这个函数表达式就可以得到不同的时间频度,也就是执行次数。

我们在这里是在计算精确的执行次数,但是我们仅仅是判断一下它的效率高低,所以是没有必要计算精确的执行次数的,这里我们采用大O渐近表示法。

大O渐近表示法

上面我们知道了语句的执行次数T(n),假设在这个程序中有f(n)个赋值函数,f(n)的值同样随着规模大小的增加而增加。

  • 当n趋向于无穷的时候,T(n)/f(n)不等于零,称f(n)是T(n)的同量级函数,用T(n)= O(f(n))来表示,我们称O(f(n))是该算法的渐近时间复杂度,简称时间复杂度。

大O符号(Big O notation):是用于描述函数渐进行为的数学符号。

大O的推导方法:

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

我们用该方法将上面的时间频度T(n)表示为时间复杂度O(f(n))。

  • T(n) = N^2 + 2*N + 10
  • 最高项就是N^ 2项,而且系数是1,所以这里的时间复杂度为O(N^ 2)

这里我们去除了对结果影响不大的项,简洁明了的表示出了要执行的次数。

本喵讲讲如何理解它

  • T(N)已经得出,它是一个比较精确的函数
  • 我们找一个和它同量级的另外一个函数f(n),此时T(N)的渐近表示就是O(f(n)),也就是时间复杂度
  • 具体来说:
  • 当N 趋向于无穷的时候,要求(N^ 2 + 2N + 10)/ f(N) 不等于0的话,f(N)=N^ 2以及f(N)=kN^2(其中k是不为0的常数)都是符合要求的
  • 为了方便我们就取f(N)=N^ 2,那么时间复杂度就是O(f(N))=O(N^ 2)

我们在看一个算法的时间复杂度的时候,只需要关注它基本的操作执行的次数即可。

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

  • 最坏情况:任意输入规模的最大运行次数(上界)
  • 平均情况:任意输入规模的期望运行次数
  • 最好情况:任意输入规模的最小运行次数(下界)

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

  • 最好情况:1次找到
  • 最坏情况:N次找到
  • 平均情况:N/2次找到

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

例题

接下来再来几个例子练练手

// 计算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);
}

这里的时间复杂度是O(2*N),去除了影响很小的常数项10。

// 计算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(M+N)
  • 如果说M远大于N或者N远大于M,那么时间复杂度就是O(N)或者O(M)
  • 如果M和N没有量级之间的差距,也可以写成O(N)或者O(M)
// 计算Func4的时间复杂度?
void Func4(int N) 
{
	int count = 0;
	for (int k = 0; k < 100; ++k) 
	{ 
		++count; 
	}
	printf("%d\n", count);
}
  • 这里的算法就是属于大O推导中的第一种情况,100甚至再大的一个常数都用1来代替
  • 所以这里的时间复杂度是O(1)
const char* strchr(const char* str, int character);
  • 这里的时间复杂度是O(n)
  • strchr函数是采用遍历的方式查找某个字符,基本语句执行的次数就是n次
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个元素,冒泡排序需要进行n-1趟
  • 第一趟需要交换n-1次
  • 第二趟需要交换n-2次
  • 。。。
  • 以此类推,这是一个等差数列
  • 次数最高项是n^2项
  • 所以该算法的时间复杂度是O(n^2)
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;
}
  • 这是一个二分查找的算法
  • 一共n个元素,每查找一次抛弃一半
  • (1/2)^x=n,其中x是执行基本操作查找的次数,n是元素个数
  • 可以求出x=logn,以2为地n的对数,也就是查找的次数
  • 所以该算法的时间复杂度为O(logn)
long long Factorial(size_t N)
{
	return N < 2 ? N : Factorial(N - 1) * N;
}
  • 该算法是一个递归,其中函数的调用和乘法是基本操作
  • 函数调用N次,每次仅进行一次相乘
  • 所以基本操作的次数就是N
  • 此算法的时间复杂度就是O(N)
// 计算斐波那契递归Fibonacci的时间复杂度?
long long Fibonacci(size_t N)
{
	return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2);
}

这里需要画图
【数据结构与算法】时间复杂度和空间复杂度_第4张图片

  • 通过分析,我们知道需要调用函数的次数是2^ (N-1),系数补成1后可以写成2^ N
  • 所以该算法的时间复杂度是O(2^N)

常见复杂度对比

我们通过上面的例题可以看到,求出的时间复杂度是各种各样的,但是有绝大多数算法的时间复杂度都是我们常见的,如下表

类型 大O表示 适用
常数复杂度 O(1) 有限次基本操作
对数复杂度 O(log(N)) 有序数组,二分查找
线性复杂度 O(N) 一个数组,遍历查找
混合复杂度 O(Nlog(N)) 俩个数组,一个有序,求交集
平方复杂度 O(N^2) 俩个无序数组的交集
指数复杂度 O(2^N) 递归求斐波那契数列

而它们之间的大小关系如下:

O(1) < O(long(N)) < O(N) < O(Nlog(N)) < O(N2) < O(2N)

空间复杂度

上面我们是通过时间复杂度的角度来衡量一个算法的执行效率,接下来本喵介绍另一个角度,空间复杂度,它和时间复杂度很相似,不同之处在于空间复杂度统计的是程序所占用的临时空间。

我们同样使用大O的渐近表示方式来统计临时占用存储空间大小来表示空间复杂度。

  • 函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
  • 统计的空间是专门为该算法的实现而开辟的空间

计算程序所占字节的大小是非常复杂的,所以这里我们只统计临时变量的个数。

// 计算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; 
	} 
}

这是一个冒泡排序的的算法,那么它的空间复杂度是多少呢?
【数据结构与算法】时间复杂度和空间复杂度_第5张图片

  • 为冒泡排序服务而创建的变量
  • size_t end,仅创建一次,在整个函数执行完毕后销毁
  • size_t i和int exchange 在每次进入外循环的时候创建,出外循环的时候销毁,销毁后再创建还是在同一位置,所以只开辟一块空间
  • 所以它们开辟的临时变量所占的空间也是有限个的,所以空间复杂度是O(1)

注意:时间是一去不复返的,只能累加,空间是一直存在的,可以重复利用。

例题

// 计算Fibonacci的空间复杂度?
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; 
}

这是一个求斐波那契数列的算法,不用递归

  • 形参n是本来就存在的,因为它要接收实参,所以它不能算是空间复杂度中的临时空间
  • 在该算法中,malloc开辟了n+1个临时空间,其他的空间都是有限个,是常数
  • 所以该算法的空间复杂度是O(n)
// 计算阶乘递归Factorial的空间复杂度?
long long Factorial(size_t N) 
{ 
	return N < 2 ? N : Factorial(N - 1) * N; 
}
  • 每调用一次函数就会开辟一个临时空间
  • 该算法是以递归的方式实现的,需要调用N次该函数,所以开辟了N个临时空间
  • 空间复杂度是O(N)
// 计算斐波那契递归Fibonacci的时间复杂度?
long long Fibonacci(size_t N) 
{
	return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2);
}

采用递归方式求斐波那契额数列时,空间复杂度是多少?
【数据结构与算法】时间复杂度和空间复杂度_第6张图片

  • 程序从左向右执行,所以先执行Fibonacci(N-1)
  • 所以程序按照蓝色的箭头递归下去,并且开辟N个空间
  • 当递归返回后开辟的空间就被释放了,如图中绿色箭头所示的路线
  • 另一路递归开始后,还是会创建相同的空间,并且新空间的位置还是前一路递归所开辟空间的位置
  • 所以递归过程中调用的所有函数都这在N个空间上,共用这N个空间
  • 所以该算法的空间复杂度是O(N)

总结

以上便是时间复杂度和空间复杂度的计算思路,它们是衡量一个算法效率的俩个重要指标。
另外,由于我们现在的设备存储空间都很大,所以我们不用太关注所占用空间的大小,我们经常用空间换取时间来提高程序的效率。
希望对大家有所帮助。

你可能感兴趣的:(数据结构与算法,数据结构,算法,c语言)