• 【数据结构与算法】如何衡量一个算法的好坏?


    1. 算法复杂度

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

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

    1.1 时间复杂度

      时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。

      一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。分析算法复杂度步骤如下:

    1. 找出算法的基本运算,即主要的操作。
    2. 估算出执行次数,这通常与算法思想、问题规模和特定输入有关。

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

    1.2 大O渐进表示法

      大O符号(Big O notation):是用于描述函数渐进行为的数学符号,用它可以表达一个算法运行时间的最小上界。

    1.2.1 例子引入推导大O法

    例:下面fun函数的时间复杂度,用大O法如何表示呢?

    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;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

      首先我们得找到基本运算:即自增操作,而这三个自增操作执行次数,如果要精确地算的话:f(n) = n2 + 2 * n + 10。

      实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。先说结论,fun函数的复杂度用大O法表示为:O(n2)。

    推导大O阶方法:

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

    为什么要这样呢?我们可以对fun函数往大的问题规模去对比:

    • n = 10:F(N) = 100 + 20 + 10;
    • n = 100:F(N) = 10000 + 200 + 10;
    • n = 1000:F(N) = 1000000 + 2000 + 10;
    • n = 10000:F(N) = 100000000 + 20000 + 10;

    可以发现n越大,直至当n趋于无穷,剩下的 2 * n + 10的次数已经不重要了,去掉了这几项,也不对结果造成影响。

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

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

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

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

    在实际中一般情况关注的是算法的最坏运行情况,说一个算法的时间复杂度默认指的是最坏情况,即这个算法我们不能保证它一直都是最好情况,所以数组中搜索数据时间复杂度为O(N)。

    1.2.2 举例

    // 复杂度为O(n),其中M并不会随着输入而影响复杂度。
    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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    // 复杂度为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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    这个复杂度也可以这么说,当N远大于M时,复杂度为O(N),反过来就是O(M)。

    // 常数词,O(1)
    void Func4(int N)
    {
    	int count = 0;
    	for (int k = 0; k < 100; ++ k)
    	{
    		++count;
    	}
    	printf("%d\n", count);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    // 最坏O(n),不明白的话得你弄清楚strchr的功能(从一个字符串查找某个字符)
    const char * strchr ( const char * str, int character );
    
    • 1
    • 2
    // 最坏执行了(N*(N+1)/2次,O(n^2)
    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;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    // 最坏O(logn)
    int BinarySearch(int* a, int n, int x)
    {
    	assert(a);
    	int begin = 0;
    	int end = n-1;
    	// [begin, end]:begin和end是左闭右闭区间,因此有=号
    	while (begin <= end)
    	{
    		int mid = begin + ((end-begin)>>1);
    		if (a[mid] < x)
    			begin = mid+1;
    		else if (a[mid] > x)
    			end = mid-1;
    		else
    			return mid;
    	}
    	return -1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    // 递归了N次,O(n)
    long long Fac(size_t N)
    {
    	if(0 == N)
    		return 1;
    	return Fac(N-1)*N;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    // O(2^n),可以画递归树自己理解,或者用递归方程求解
    long long Fib(size_t N)
    {
    	if(N < 3)
    		return 1;
    	return Fib(N-1) + Fib(N-2);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    常见的复杂度如下:

    1. 常数阶:O(1);
    2. 线性阶:O(n);
    3. 平方阶:O(n2);
    4. 对数阶:O(logn);
    5. 线性对数阶:O(nlongn);
    6. 立方阶:O(n3);
    7. 指数阶:O(2n)。

    注意对数阶是以2为底的(log2n),通常省略底数2。前六个复杂度属于多项式时间算法的渐进复杂度;第七个属于指数时间算法的渐进复杂度,其中还有阶乘阶O(n!)和O(nn),指数阶已经很离谱了,后面这两种就更离谱了,所以不常见。

    1.3 空间复杂度

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

    1.3.1 细分

    程序运行所需的存储空间包括两部分:

    1. 固定空间需求:包括程序代码、常量、变量等;

    2. 可变空间需求:与程序处理的数据规模有关,如1000大小的数组和10大小数组显然是不同的;以及与算法执行所需的额外空间,比如递归算法会需要栈空间,不过要注意的是栈帧空间是可以复用的,一旦上次函数递归调用结束,栈内存空间就还给操作系统了,下次递归调用再申请使用这块空间。

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

    1.3.2 举例

    // 开辟了常数个额外空间,因此空间复杂度为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;
    		}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    只要这个程序的空间复杂度不会随着输入规模的增大而增大,那么空间复杂度就是常数阶。

    // 动态开辟了N个空间,空间复杂度为 O(N)。
    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    // 递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间,空间复杂度为O(N)。
    long long Fac(size_t N)
    {
    	if(N == 0)
    	return 1;
    	return Fac(N-1)*N;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    2.什么是好的算法?

    一个好的算法应该具有以下特点:

    1. 正确性。

      算法的执行结果应当是解决某个问题的,否则不叫算法,最多称之为程序或是程序的正确性。程序健壮性与算法正确性有直接关系,算法最终会被编写为程序,而程序健壮性与算法正确性互补,算法应当被正确地编写为健壮的程序。

      对于大型系统,指望算法“完全正确”是不可能的,不过对于程序而言也许做不到完全正确,但却可以要求程序是健壮的。当存在不合法的操作时,程序能做出处理而不会引起严重后果。

       正确的程序不一定是健壮的,而健壮的程序不一定完全正确。一个程序应当能在正常的情况下运行,也应当能在“异常”情况下运行并做出处理,这是程序的可靠性。

    1. 简明性。

      算法思想的逻辑清晰,易于阅读和理解,易于编码和调试。这个特性或许有些主观,但简明性并不是可有可无的特性,这是算法设计者应当争取的,更容易理解的算法在编写为程序后,也往往能减少更多的错误。不过遗憾的是简明的算法,不一定高效,这与算法的高效性有矛盾,但不冲突,可以结合两者做到算法的最优。

    1. 高效性。

      指的是执行一个算法所需花费的时间是高效率的,且有效地使用内存。当程序规模较大时,设计算法经常会为了高的时间效率而占用更多的内存空间效率,虽说随着计算机硬件高速发展,现已太不关注内存的占用情况。但是如果为了高效的时间,却会导致算法的可读性变差,这并不是明智的选择。因此算法设计者应当考虑到算法的简明性和高效性,做出折中办法。

    1. 最优性。

      算法执行时间已经达到求解该问题所需时间的下界,即没有比它更快的算法了,最多持平。算法最优性与求解的问题复杂度有关,如果一个算法在一个问题的最坏情况下任然能得到正确的结果,那么可以认为这个算法是最优的。

    3. 影响程序运行时间的因数

    算法会被编写为程序,所以程序运行时间与算法时间复杂度有着密切关系。

    1. 算法的好坏。

    如果排除计算机系统和硬件情况,这是根本和决定性因数。

    1. 问题的规模。

    一般指输入的数据量,比如对1000个数排序和1000000个数排序。

    1. 特定的输入数据。

    例如从一个顺序表中查询指定输入的值,这个值在顺序表中的位置很关键。如果刚好在第一个,那么很快就能查到;如果是最后一个的话,那就是最坏情况了。

    1. 计算机系统和硬件。

    对于影响程序运行是很好理解的,但这个情况针对算法而言是不考虑的,算法只考虑算法自身能不能做到最优,而不是去要求更好的硬件。

  • 相关阅读:
    企事业单位/公司电脑文件透明加密保护 | 防泄密软件\系统!
    Vue向pdf文件中添加二维码
    GBase 8a优化建议
    springboot+jwt做登录鉴权(附完整代码)
    JavaWeb----Ajax技术
    LeetCode练习4——删除有序数组中的重复项
    A-Level经济真题每期一练(23)
    基本微信小程序的购物商城系统
    系统架构师备考倒计时19天(每日知识点)
    用Threejs做一只会动的3D玉兔祝大家中秋快乐
  • 原文地址:https://blog.csdn.net/m0_52602233/article/details/134079690