算法效率的衡量方式 - 时间复杂度与空间复杂度

算法

算法是解决特定问题求解步骤的描述,取零个或多个值作为输入,经过一系列的计算步骤,产生一个或多个值作为输出,这一系列的计算步骤,就叫做算法

只看定义也许并不是很容易理解,那接下来我将举一个简单的例子来说明什么是算法

问题:编写程序计算 1+2+3+...+n

这种难度的问题大家一定都能计算出来,而且可能还都会想出很多种思路,其实这些解决问题的不同的思路,就是很多种不同的算法

那么上面的问题该怎么解决呢,最简单的做法就是将1-n之间的数依次相加起来

算法1:

void Sum1(int n)
{
	int i = 0;
	int sum = 0;
	for (i = 1; i <= n; i++)
	{
		sum += i;
	}
	printf("%d\n", sum);
}

除此之外,我们还可以用等差数列求和公式:(首项 + 末项) × 项数 ÷ 2 = 和

算法2:

void Sum2(int n)
{
	int sum = 0;
	sum = (1 + n) * n / 2;
	printf("%d\n", sum);
}

可以看到,虽然只是一个简单的问题,却可以写出两种不同的算法,虽然每一种都可以得到正确的结果,但是它们的效率其实是不同的。算法1种语句执行了1+(n+1)+n+1=2n+3次,算法2中语句执行了1+1+1=3次,显然算法1的效率是比算法2慢的,而当输入量n的数值越来越大时,两个算法之间效率的差距也会越来越大,而这个输入量n的多少,也被称为问题输入规模


算法优劣的衡量

既然算法有好坏之分,那么我们应该用什么方式判断算法的好坏呢?

我最初的想法是根据程序的运行时间来判断,我认为应该不只是我一个人在第一时间有这种想法吧,但是很遗憾,这种想法是有很大缺陷的

  • 首先,如果想计算程序的运行时间,那么我们首先就先要将代码正确完整的编辑好,如果有好几种算法,而每个算法都要编辑好一个程序,当我们最后一个一个将所有程序的运行时间都统计好,再从中挑出运行时间最短的程序,并认为它的算法就是最好的(先不讨论这种用时间判断算法好坏是否正确),那当我们将“最优算法”找到后,其它算法编写出来的程序就都没有用了,而如果我们绞尽脑汁编辑一个算法的程序花费了大量的时间,当我们最终编辑好后,测试出它是一个差得不行的算法,那岂不是整个人都要疯掉了
  • 而且不同的计算机运行相同代码所耗费的时间也是不同的,不知道各位有没有过这样一个经历,同一款游戏,同时启动,别人可能都玩了一局了,你用几年前的计算机可能连游戏都没登陆上。代码也是如此,相同的一段代码受到CPU,操作系统,编译器等等影响,在不同设备上的运行时间是不同的,而就算是同一台机器,在不同情况下,运行时间也不可能完全相同
  • 除了上述原因,问题的输入规模也是一个影响运行时间的因素,因为现在的计算机它的计算效率是很高的,像上面的算法1和算法2,当n比较小时运行时间几乎相等,只有当n特别特别大时,它们的差距才能显现出来,而问题规模明显影响运行时间的例子,我之前写过一个函数的博客,分别用递归与迭代编写求斐波那契数的程序,当问题规模n为1,2,3这种小的数时,运行时间基本没有差别,当问题规模是50时,用迭代实现的程序很快就能求出结果,而递归实现的快十分钟都没求出结果(等不下去了,直接关了),很显然,用递归求斐波那契的算法特别差

显然,我们不应该用程序运行时间来判断算法的好坏,那么接下来就介绍两种判断算法优劣的方式

1.时间复杂度

算法中的基本操作的执行次数,为算法的时间复杂度。

分析一个算法的时间复杂度(推导大O阶方法)

  1. 用常数1取代运行时间中的所有加法常数
  2. 在修改后的运行次数函数中,只保留最高阶项
  3. 如果最高阶项存在且其系数不是1,就去除系数

最后得到的结果就是大O阶

了解了计算时间复杂度的方法,下面我举几个例子来说明

例一

void Sum2(int n)
{
	int sum = 0;
	sum = (1 + n) * n / 2;
	printf("%d\n", sum);
}

此代码是上述求和的算法2,可以看到执行了3次,根据推导规则,要将常数3改为1,修改后符合要求,因此这个算法的时间复杂度是O(1)。在这个算法中,无论输入规模是多少,程序语句的执行次数是不变的,具有这种时间复杂度的算法,又叫常数阶

这些语句在程序运行时都是一条一条执行的,这样的语句的执行次数并不会被输入值n所影响,因此无论有多少条这样的语句,到最后都会根据规则一被常数1取代,而如果函数中除了常数1以外还有其它高次项,那么这个常数1也会被去除。这样来看,若算法中有以输入规模n作为判断条件的循环语句,那么在计算时间复杂度时关键分析循环的执行情况即可

例二

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

在上述分析中,初识数据结构的人会不会对while循环的分析有疑问呢,为什么是执行10次,而不是执行M次? 事实上,在这段代码中传过来的参数只有N,而M是在这个函数中定义的变量,有一个值为10的变量是算法本身的一部分,因此无论输入规模N是多少,while循环体执行的次数是不会改变的。

根据分析,此算法执行次数为2N+10次,根据上述规则先将其改为2N+1,再改为2N,最后去掉系数2,化为N,因此此算法的时间复杂度为O(N),也叫做线性阶

例三

在例二中,初学者可能对M有疑问,那么接下来我们来看例三

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

根据分析,此算法执行次数为M+N次,和例二不同的是,这个函数传过来的参数为N和M,输入规模也就是N和M,它们两个任意一个大小的改变都会影响到对应循环体的执行次数,因此,此算法的时间复杂度为O(M+N)

在继续接下来的例题前,这介绍要下一个知识点

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

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

而一般在没有特殊说明的情况下,都取最坏情况的时间复杂度、

各位可能会有些疑惑这三种情况在什么时候才会发生,又是怎么判断的,那么接下来就对例四进行复杂度的分析

例四

这里再介绍一个库函数strchr,它的原型如下:

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

它在一个字符串中查找一个由使用者传过去的字符,如果找到了就返回其在字符串中第一次出现的地址,如果没找到就返回一个空指针

函数模拟实现如下:

const char* my_strchr(const char* str, int c)
{
	while (*str)
	{
		if (*str == c)
		{
			return str;
		}
		else
		{
			str++;
		}
	}
	return NULL;
}

 我们不能确定字符串的长度会是多少,因此将字符串长度假设为N,当我们要找的字符刚好就在字符串的第一个位置,那么就是最好情况,时间复杂度是O(1),由于要查找的字符在每一个位置的概率都是相同的,因此平均时间复杂度就是O(N/2),如果要找的字符在字符串最后一个位置(不算'\0'),那么就是最坏情况,时间复杂度是O(N)。而这种算法的时间复杂度我们取最坏时间复杂度为O(N)

例五

在之前的博客我写过一次冒泡排序,例五就来判断冒泡排序的时间复杂度

void bubble_sort(int arr[], int n)
{
	int i = 0;
	for (i = 0; i < n-1; i++)
	{
		int flag = 1;
		int j = 0;
		for (j = 0; j < n - 1 - i; j++)
		{
			if (arr[j] < arr[j + i])
			{
				int tmp = arr[j];
				arr[j] = arr[j + i];
				arr[j + i] = tmp;

				flag = 0;
 			}
		}
		if (flag == 1)
		{
			break;
		}
	}
}

按照冒泡排序的思想,每进行一轮排序,下轮排序就少了一个数,因此下一轮内循环执行的次数要比上一轮排序少一次,如果有不懂的可以看一下我之前关于冒泡排序的博客,下面附上链接

C语言 - 冒泡排序_ImpEvday_Wang的博客-CSDN博客

或者我们也可以根据代码找出规律,当i=0时,内循环执行了n-1次,当i=1时,内循环执行了n-2次,......,当i=n-2时,内循环执行了1次,循环结束。可以看出执行的次数是个等差数列,那么根据等差数列求和公式,总执行次数为 ((n-1)+1)×(n-1)÷2 = n^2/2-n/2,根据推导规则,保留最高项后为n^2/2,再去掉其系数后得到n^2。因此冒泡排序的时间复杂度为O(n^2),也叫做平方阶

例六

接下来我们分析二分查找的时间复杂度

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

根据二分查找的思想,每次查找,范围将缩小一倍。若原数组有n个数,第一次查找后,范围缩小到n/2,第二次查找后,范围又缩小了一倍,变为n/2^2,这样一直找下去,直到范围中只剩1个数了,该数就是我们要查找的数,这样就可以得到一个公式,若我们查找的次数为x,n/2^x=1,2^x=n,那么查找的次数(也就是执行的次数)就是logn(其实是log以2为底n的对数,但电脑中打不出来)。因此二分查找的时间复杂度为O(logn),又被称为对数阶

下面附上我二分查找的博客链接,感兴趣的朋友可以阅读一下

C语言 - 二分查找_ImpEvday_Wang的博客-CSDN博客

这里我们介绍下一个知识点

递归算法的总执行次数 = 递归次数 × 每次递归中执行的次数

例七

long long Factorial(int N)
{
	if (N == 0)
	{
		return 1;
	}
	else
	{
		return Factorial(N - 1) * N;
	}
}

如图所示,阶乘的递归算法一共调用了N次,每次递归执行的都是常数次,为O(1),二者相乘,得到阶乘算法的时间复杂度为O(N)

例八

前面提到过,用递归实现的求第N个斐波那契数,当N等于50的时候就已经需要很长时间计算了,那么这种算法的时间复杂度是多少呢

int Fib(int n)
{
	if (n <= 2)
	{
		return 1;
	}
	else
	{
		return Fib(n - 2) + Fib(n - 1);
	}
}

上图是算法的执行图,进行了大量的重复计算,随着层数的增加,它所需要进行的运算就越多,图中缺了一块的意思是当递归到N<=2就不再向下递归,由于从左至右函数的参数依次减小,因此提前结束递归的层数也会依次减小,因此会导致这些层并每没有完全填满,会缺少一部分。即便如此,缺少的这部分对于如此多的运算几乎是可以忽略不计的。

如果上图看着比较复杂,下面讲给出当N=5时的执行图

设缺的那块的执行次数是x次,那么此算法执行的次数是2^0+2^1+2^2+...+2^(N-3)+2^(N-2),是一个等比数列,根据等比数列求和公式 (2^0 - 2^(N-2)×2) / (1-2) = 2^(N-1) -1 -X = 2^N × 2^(-1) -1 -X ,根据推导规则,只保留最高次项后得2^N,因此用递归实现求斐波那契数的算法的时间复杂度是O(2^N),又叫指数阶。很显然,具有这种时间复杂度的算法十分可怕,我们应该避免写出这种算法

常用的时间复杂度耗费时间排序

数据结构 第1节 算法的时间复杂度和空间复杂度 

O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

而根据求斐波那契数的递归算法的分析,时间复杂度是O(2^n)的算法运行耗费时间已经很长了,而实际上当时间复杂度到了O(n^2),那么当输入规模n逐渐变大时,它的算法耗费时间的上升速度也是极为恐怖的,因此在设计算法时要尽量将时间复杂度控制在O(n^2)之前

至此,时间复杂度的内容就全部分享结束了,接下来介绍衡量算法的第二种方式

2.空间复杂度

是一个算法在运行过程中临时额外占用存储空间大小的量度

分析空间复杂度依然是推导大O阶方式

接下来依然举例说明

例一 判断冒泡排序的空间复杂度

void bubble_sort(int arr[], int n)
{
	int i = 0;
	for (i = 0; i < n-1; i++)
	{
		int flag = 1;
		int j = 0;
		for (j = 0; j < n - 1 - i; j++)
		{
			if (arr[j] < arr[j + i])
			{
				int tmp = arr[j];
				arr[j] = arr[j + i];
				arr[j + i] = tmp;

				flag = 0;
 			}
		}
		if (flag == 1)
		{
			break;
		}
	}
}

下图是此算法中变量在内存中的存储

可以看到,变量i、变量flag、变量j都是算法本身需要开辟的空间,无论输入规模是多少,这三个变量都会这样开辟,而没有额外空间的使用,因此冒泡排序的空间复杂度是O(1)

这里介绍空间复杂度的第二个知识点

当分析递归算法的空间复杂度时,关键是递归的深度

例二 求N的阶乘的递归算法的空间复杂度

long long Factorial(int N)
{
	if (N == 0)
	{
		return 1;
	}
	else
	{
		return Factorial(N - 1) * N;
	}
}

可以看到递归了N层,因此空间复杂度是O(N)

例三 求斐波那契数的递归算法的空间复杂度

int Fib(int n)
{
	if (n <= 2)
	{
		return 1;
	}
	else
	{
		return Fib(n - 2) + Fib(n - 1);
	}
}

在分析这个算法的空间复杂度之前,我们要先想明白一个道理,时间不可以重复利用,而空间是可以重复利用的,而递归算法在执行时并不是一次性把所有递归分支运算需要的空间都开辟出来(容易栈溢出),它的执行过程时一条分支接着一条分支的执行下去,因此递归算法所需要额外开辟的空间就是它最深的层数,在这里递归最多为N-1层,根据推导规则,求斐波那契数的递归算法的空间复杂度为O(N)

至此,空间复杂度也全部介绍完毕

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值