【初阶数据结构】算法复杂度

发布于:2024-08-21 ⋅ 阅读:(76) ⋅ 点赞:(0)

目录

一、算法效率

1.1 为什么要衡量算法的好坏

1.2 算法的复杂度

1.3 复杂度在校招中的考察

二、时间复杂度

2.1 时间复杂度的概念

Func1 执行的基本操作次数 :

2.2 大O的渐进表示法

常见复杂度对比

一般算法常见的复杂度如下:

​编辑

2.3常见时间复杂度计算举例

实例1:

实例2:

实例3:

实例4:

实例5:

实例6:

实例7:

实例8:

注:

虽然时间复杂度并不是计算大概时间,但是通过计算算法大概所处量级,以及CPU每秒大概处理次数,我们是可以估算出程序大概时间的。通过上述实例,我们发现随着数据增多,达到亿级,诸如是只有理论意义,没有实践意义,正常来说,O()级算法速度就有些不太行了,以上n次方更大的一些算法就没什么意义了。

三、空间复杂度

实例1:

实例2:

实例3:

实例4

​编辑

四. 复杂度的oj练习

5.1消失的数字OJ

思路一:

思路二:

思路三:

5.2旋转数组OJ

思路一:

思路二:

思路三:


一、算法效率

1.1 为什么要衡量算法的好坏

对于我们所要解决的问题来说,解决的方法各有不同,每个人各有各的道理,并且受制于所用的设备的性能环境不同,我们很难精确比较一些方法不太明显的差别,因此,如何脱离环境直接衡量一个算法本身的好坏呢?比如对于以下斐波那契数列:


long long Fib(int N)
{
	if (N < 3)
		return 1;

	return Fib(N - 1) + Fib(N - 2);
}

斐波那契数列的递归实现方式非常简洁,对于我们的直观感受来说越简洁的算法似乎越好,但简洁一定好吗?那该如何衡量其好与坏呢?

1.2 算法的复杂度

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

时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。但是对于物联网等领域,受制于机器设备本身的存储容量,空间复杂度仍然是重要的考量方面。

1.3 复杂度在校招中的考察

对于很多的算法题来说,都会有时间与空间复杂度的限制,以此迫使答题者寻找更优的解决方法

二、时间复杂度

2.1 时间复杂度的概念

时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个数学函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。但是需要注意的是我们现在使用的电脑中的CPU最差每一秒都可以执行上亿次的指令,因此在计算时间复杂度时,我们都是运用极限的思想,考虑算法在数据极其大或者其他极端情况下起主导作用的因素,忽略次要因素。

注:程序中的诸如打印,创建变量等操作,由于这些指令执行次数很少,我们可以忽略这些,我们计算算法执行操作次数更关注算法中循环语句或者栈桢开辟的次数。

所以,虽然时间复杂度计算的是次数,但是主要考虑的是次数所处的量级。

// 请计算一下Func1中++count语句总共执行了多少次?
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);
}

Func1 执行的基本操作次数 :

上图中,我们发现随着N的增大,N^{2}这一项是影响Func1执行次数公式的主要因素,因此,我们计算时间复杂度时,我们忽略其他项只保留N^{2}这一项(CPU执行速度以亿计,N较小时保留与不保留其他项,机器都可以在秒内完成,对于人来说效率是一样的;N非常大时,N^{2}起主导作用,影响数据的量级,其他项增加或减小的数量相比N^{2}造成的量级可以忽略不计)。Func1的时间复杂度就是O(N^{2})(大O的渐进表示法)

所以,计算时间复杂度,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数(数据的量级),那么这里我们使用大O的渐进表示法。

2.2 大O的渐进表示法

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

推导大O阶方法:

1、用常数1取代运行时间中的所有加法常数。

2、在修改后的运行次数函数中,只保留最高阶项。

3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。 使用大O的渐进表示法以后,Func1的时间复杂度为:

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

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

最坏情况:任意输入规模的最大运行次数(上界),即通过这种算法解决某一问题最坏算法需要执行多少次。例如:在一个长度为N数组中搜索一个数据x,最坏的情况就是遍历数组的中吗,每一个数据,使用for遍历N次,时间复杂度就是O(N)。

最好情况:任意输入规模的最小运行次数(下界) 例如:在一个长度为N数组中搜索一个数据x,最好的情况是数组的第一个数据就是X,算法执行一次就行了,因此算法的时间复杂度就是O(1)。

平均情况:任意输入规模的期望运行次数。使用算法解决问题时可能出现的每种情况的次数相加取平均。例如:在一个长度为N数组中搜索一个数据x,x可能在数组的第一到最后中任意位置,即所有可能出现的情况是N中,假设N在第一个位置,找到X算法就要执行1次,在第二个位置执行2次,依次类推,平均每种情况执行次数是(1+2+……+N)/N,即N/2次。

实际情况中,程序的运行会遇到各种因素制约,所以在实际中一般情况关注的是算法的最坏运行情况,这样可以提供较好的预期,做好准备,所以在一个长度为N数组中搜索一个数据x使用for循环遍历的时间复杂度为O(N)。

常见复杂度对比

一般算法常见的复杂度如下:

注:不存在O(0),执行次数为常数(确定,不会随数据变化。)的算法复杂度统一为O(1).

2.3常见时间复杂度计算举例

实例1:

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

实例1基本操作执行了2N+10次(忽略了打印与创建变量的指令),通过推导大O阶方法知道,忽略10和2N的系数(当数据非常大到上一次,系数的影响相较于量级,可以忽略不计),时间复杂度为 O(N)

实例2:

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

实例2基本操作执行了M+N次,有两个未知数M和N,如果不知道大小·时,时间复杂度为 O(N+M);如果M远大于N,时间复杂度O(M);如果N远大于N,时间复杂度O(N)(抓住数据无限大下影响算法执行次数的主导因素)。当然也可以这么写O(max(M,N))。

实例3:

// 计算Func4的时间复杂度?
void Func4(int N)
{
	int count = 0;
	for (int k = 0; k < 100; ++k)
	{
		++count;
	}
	printf("%d\n", count);
}

实例3基本操作执行了10次,通过推导大O阶方法,时间复杂度为 O(1)。

实例4:

// 计算strchr的时间复杂度?
const char * strchr ( const char * str, int character );

实例4基本操作执行最好1次(开头找到),最坏N次(最后找到),时间复杂度一般看最坏,时间复杂度为 O(N)。

实例5:

// 计算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基本操作执行最好N次,最坏执行了(N*(N+1)/2次,通过推导大O阶方法+时间复杂度一般看最坏,时间复杂度为 O(N^2)

实例6:

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

实例6基本操作执行最好1次,最坏O(logN)次,时间复杂度为 O(logN)。

注:logN在算法分析中表示是底数为2,对数为N,但是因为写的时候需要支持专业公式2,否则不好写底数,时间复杂度当中,为了方便可以省略底数2,直接写成logN,其他底层不能省略。但是有些地方会写成lgN(会与底层为10的混淆,不推荐这么写)。

相比于O(N)的算法,我们发现当数据特别大时,O(logN)的优势非常大。

但是就二分查找本身而言,这种算法需要需要有序的数据才能查找,查找之前先排序这就要一些消耗,更重要的时二分查找基于数组,数组本身并不方便插入算出,因此二分查找这种算法实际并不好。

实例7:

// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
	if (0 == N)
		return 1;

	return Fac(N - 1) * N;
}

实例7对于递归来说,程序会重复的调用指令进行栈桢的开辟,每个栈桢中的指令再执行,我们将每个栈桢执行的次数相加就是总次数。对于实例七,基本操作递归了N次(Fac(N - 1) * N是Fac(N - 1)返回结果后再与N相乘,对调用的次数不影响。),时间复杂度为O(N)。

实例8:

// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
	if (N < 3)
		return 1;

	return Fib(N - 1) + Fib(N - 2);
}

实例8每调用一次函数,return返回处都会递归两个函数,每个函数又会递归两次,整体每层递归次数成2的指数增长(部分区域递归会提前结束,数量实际小于2^{N-1}-1,但数据相对于整体较小,可以忽略不计),根据大O渐进法表示,我们忽略次要因素,2^{N-1}-1可以简化成2^{N}次,时间复杂度为O(2^N)。

注:

虽然时间复杂度并不是计算大概时间,但是通过计算算法大概所处量级,以及CPU每秒大概处理次数,我们是可以估算出程序大概时间的。通过上述实例,我们发现随着数据增多,达到亿级,诸如O(2^{N})是只有理论意义,没有实践意义,正常来说,O(n^{2})级算法速度就有些不太行了,以上n次方更大的一些算法就没什么意义了。

三、空间复杂度

空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度

空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义(我们眼中极小的1M,都有1024*1024B),所以空间复杂度算的是变量的个数。 空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法

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

常见的空间复杂度有O(1),O(N),O(N^{2}),正常创建变量是O(1),根据数据量n创建一维数组是O(N),创建二维数组是O(N^{2})。

实例1:

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

计算空间复杂度,只计算为了使用该算法说使用的变量,如实例1创建end,exchage,i,以及调用的Swap中创建的变量,总共使用了常数个额外空间(形参中的数组a不是为了使用该算法专门创建的不计算在内),所以空间复杂度为 O(1)。

实例2:

// 计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前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;
}

实例2中为了使用的算法,专门创建了包含n+1个longlong类型变量(空间)的数组以及变量i,算法整体的消耗取决于n,根据大O的渐进表示法,空间复杂度为 O(N)

实例3:

// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
	if (N == 0)
		return 1;

	return Fac(N - 1) * N;
}

实例3递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间。合计相加,空间复杂度为O(N)

实例4

// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
	if (N < 3)
		return 1;

	return Fib(N - 1) + Fib(N - 2);
}

对于递归函数来说,空间复杂度并不是直接看函数的递归次数,执行Fib(N - 1) + Fib(N - 2)这一步时,函数会先将左边递归完在递归右边,而左边函数内部同样会分成左右两边递归,因此就这样不断递归下去,整体的函数递归逻辑符合二叉数(数据结构的一种)的中序遍历的逻辑(前序遍历这里也差不多)。

在这样的逻辑中,空间复杂度并不是将所有的调用都加上算出2^{N},函数的递归与销毁是一层一层栈帧压栈与出栈过程,斐波那契按照中序遍历进行递归,所以严格来说如上图同一层的函数实际上同一时间只能存在一个分支,因此,对于斐波那契函数来说,空间复杂就是看它从树顶往下最长的一条分支,即它的空间消耗取决于它的树高,斐波那契的空间复杂度等于树高,通过观察,我们发现,假设传递N,那么树高大约是N-1层,因此使用大O渐进表示法是O(N)。(本实例理解需要先理解函数栈帧的开辟销毁相关过程与二叉树相关知识,目前不理解,可先暂时跳过)

四. 复杂度的oj练习

5.1消失的数字OJ

思路一:

先排序,再依次查找,如果下一个值不等于前一个+1,下一个值就是消失的数字。

这种思路效率会受制于排序算法的效率,即使使用快排,时间复杂度在O(N*logN);如果我们使用计数排序,那么会出现O(N)的空间复杂度消耗,这种方法是常见的空间换时间的做法。

思路二:



int missingNumber(int* nums, int numsSize){
    int sum = 0;
    for(int i = 0;i <= numsSize;i++)
    {
        sum += i;
    }
    for(int i = 0;i < numsSize;i++)
    {
        sum -= nums[i];
    }
    return sum;
}

求和0到N,再依次减去数组中的值,剩下的那个数字就是消失数字,循环N次求和,这种算法的时间复杂度是O(N),只创建常数个变量,空间复杂度是再遍历数组减去值,不过如果N太大,用来储存求和数值的变量会有溢出的风险。

思路三:

将0到N依次异或,再将数组元素依次异或,根据相同元素异或为0,0与其他数异或还是其他数本身,最后留下来的数字就是缺失的数字。这种算法只使用单层循环,不额外开辟数组,时间复杂度为O(N),空间复杂度为O(1)。



int missingNumber(int* nums, int numsSize){
    int num = 0;
    for(int i = 0;i <= numsSize;i++)
    {
        num ^= i;
    }
    for(int i = 0;i < numsSize;i++)
    {
        num ^= nums[i];
    }
    return num;
}

5.2旋转数组OJ

当轮转次数k为7、14、21等7的倍数,我们发现数组相当于没有轮转,当k为10,时就相当于先轮转7次(相当于不轮转),再轮转3次。因此使用k %= numsSize得到真实的轮转次数。

思路一:



void rotate(int* nums, int numsSize, int k) {
    k %= numsSize;
    while(k--){
        int num = nums[numsSize - 1];//保存数组末尾的值
    for(int i = numsSize - 1 ; i >= 1 ; i--){//将数据后移
        nums[i] = nums[i - 1];
    }
    nums[0] = num;//修改数组开始的数组
    }
}

将末尾的数值保存起来,然后将前面的数值后移,再将保存的值放在数组开头,形成轮转一位数字的效果,再根据k值重复上述操作。

不过这种思路走不通,因为轮转一位,我们需要循环n-1次将其他数据后移,再根据要轮转的数字根数重复上诉操作k次,算法执行次数大概是k*(n-1),根据大O渐进表示法,随着数据非常大时,k*(n-1)会接近n^{2},因此这个算法时间复杂度为O(n^{2}),不满足题目要求。

思路二:

void reverse(int* nums,int left,int right){
    while(left <= right){
        int tmp = nums[left];
        nums[left] = nums[right];
        nums[right] = tmp;
        left++;
        right--;
    }
}
void rotate(int* nums, int numsSize, int k) {
    k %= numsSize; 
    reverse(nums,0,numsSize - k - 1);前n-k个逆置
    reverse(nums,numsSize - k,numsSize - 1);后k个逆置
    reverse(nums,0,numsSize - 1);整体逆置

}

根据一位大佬总结的规律,轮转数组可以根据要轮转的数量k,将数组前n-k个数据逆置(排序的顺序反过来),再将后k个数据逆置,最后将数据整体逆置,数组就会变成轮转完成的数组了。

这个算法我们主要的消耗是将一段数组进行循环逆置,需要遍历每个元素进行逆置,因此,逆置函数的时间复杂度是O(N),空间复杂度是O(1)。不过,这个思路,对于之前没有接触过这类型的读者来说并不容易想到。

思路三:

void rotate(int* nums, int numsSize, int k) {
    int* arr = (int*)malloc(sizeof(int) * numsSize);
    k %= numsSize;
    memcpy(arr , nums + numsSize - k , sizeof(int) * k);
    memcpy(arr + k, nums , sizeof(int) * (numsSize - k));
    memcpy(nums , arr , sizeof(int) * numsSize);

}

思路三的做法较为直接一点,这道题目的最直接来看,就是轮转多少步,数组从右往左就有多少的个数据挪到数组左边,其余数据挪到右边。因此,我们动态开辟一个与原数组一样大小的数组用于存放轮转后的数组,如上图,我们使用memcpy根据k值为3,将5,6,7直接复制到tmp对应轮转后的位置,再将剩余1,2,3,4也复制到轮转后的对应位置,然后将tmp数组中的值整个复制回nums中。

本思路时间空间复杂度的计算,需注意我们使用了memcpy来实现我们的目标,所以虽然memcpy是系统自带的,我们仍然要考虑它的消耗。由于memcpy复制使用单层循环来实现,所以时间复杂度是O(N),由于开辟tmp数组来辅助,所以空间复杂度是O(N)。于思路一相比我们发现,我们仅消耗O(N)的空间,就将时间由O(N^{2})提升值O(N),提升不可谓不大,这种思路其实就是一种典型的以空间换时间的做法,上文我们提过,除了特殊领域,我们更关注时间上的消耗,因此以空间换时间是一种非常常见的思路。