【数据结构——复杂度】

目录

一、复杂度

衡量一个算法的好坏,我们一般从两个维度来衡量:

  • 时间复杂度: 主要衡量一个算法的运行快慢
  • 空间复杂度: 主要衡量一个算法运行所需要的额外空间

更关注时间复杂度的原因:

在计算机发展早期,由于计算机的存储容量很小,所以人们会很在乎空间复杂度。但随着硬件技术的发展(摩尔定律),计算机的存储容量已经达到了很高的程度,内存容量已不再是瓶颈,所以如今已经不需要再特别关注一个算法的空间复杂度,时间复杂度成为了衡量算法优劣的主要标准

二、时间复杂度

在计算机科学中,时间复杂度是一个函数,它定量描述了一个算法的运行时间。

理论上来说,一个算法执行所需要的时间不能被计算出来,但是可以通过语句执行次数来分析,找到语句执行次数与问题规模N之间的数学表达式,这就是时间复杂度

1.估算时间复杂度的关键

  • 大O渐进表示法:我们只关心对运行时间影响最大的部分,忽略常数项和低阶项
  • 关注N很大的情况:当N很小时,算法差异不明显,CPU都能跑得飞快,因此我们对此情况不做讨论

通过简单观察和计算我们可以发现N越大对后面部分对函数整体的影响越小

我们可以通过clock函数来计算一下一个函数从开始到结束的所需时间:

c 复制代码
#include<stdio.h>
#include<time.h>
int main()
{
    int begin1=clock();
    int n=10000000000;
    int x=10;
    for(int i=0;i<n;i++)
    {
        ++x;
    }
    int end1=clock();
    printf("%d\n",x);
    printf("%d\n",end1-begin1);//计算中间消耗毫秒数
    return 0;
}

2.大O渐进表示法

大O渐进表示法: 是对复杂度的大概估算法,用于描述数据规模扩大时,算法时间与空间的增长趋势,仅保留对它影响最大的那个项(最高阶项),然后进行尽可能的简化(忽略常数项与低阶项),是复杂度分析的专用记号

即用于大概估算算法属于哪个量级

推导大O阶方法:

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

例:计算以下算法的时间复杂度

c 复制代码
#include<stdio.h>
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);
}

这个算法严格一点说应该是2*N+10,但它的时间复杂度是O(N)

原因:随着N无限变大,后面的10对它来说就没有影响了,其次是这里的系数2需要去掉,当N无限大时,系数对整体的影响也不大了

3.常见的时间复杂度对比

例子 时间复杂度 量级
5201314 O(1) 常数阶
3n+4 O(n) 线性阶
3n2+4n+5 O(n2) 平方阶
3log(2)n+4 O(logn) 对数阶
2n+3nlog(2)n+14 O(nlogn) nlogn阶
n3+2n2+4n+6 O(n3) 立方阶
2n O(2n) 指数阶

Tips:时间复杂度O(logN)中,如果是以2为底的底数,我们习惯省略底数2写作logN,但其他底数不可省略

4.最好、最坏、平均情况

很多算法的执行时间和输入数据有关,比如:

c 复制代码
const char* strchr(const char* str,int character)
{
	while(*str)
	{
		if(*str==character)
			return str;
		else
			++str;
	}
}

存在以下三种情况:

  1. 字符在第一位就找到,执行1次
  2. 字符在最后一位或不存在,执行N次
  3. 字符在中间,执行N/2次

由此我们可知有些算法的时间复杂度存在为三种情况:

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

实际工程中,我们统一按最坏情况来分析时间复杂度,所以这个函数的时间复杂度为O(N)

时间复杂度的意义:

当一个题有多个思路时,分析不同思路的时间复杂度,能够让我们通过对比写出最优解
Tips:计算时间复杂度不能单纯的数循环次数,而是要根据逻辑来算

因为有些时候数循环我们是数不出来的:

c 复制代码
#include<stdio.h>
void func(int n)
{
	int x=0;
	for(int i=1;i<n;i*=2)
	{
		++x;
	}
	printf("%d\n",x);
}
int main()
{
	func(8);
	func(1024);
	func(1024*1024);
}

逻辑:这个算法是12 2*2...*2,循环走了多少次就有多少个2相乘,因此这里是2x=N,可得x=logN

5.部分时间复杂度计算示例

常见时间复杂度的效率排序: O(1)>O(logN)>O(N)>O(NlogN)>O(N2)>O(2N)

下面就几个常见的时间复杂度量级进行举例

(1)O(1)常数阶

含义: 无论数据规模多大,执行次数固定不变,不随n变化

c 复制代码
int a=10;
int b=20;
int c=a+b;

(2)O(N)线性阶

含义: 执行次数和数据规模n成正比

这里比较经典的是递归时间复杂度:所有调用次数累加

计算阶乘递归的时间复杂度例:

c 复制代码
long long Fac(size_t N)
{
	if(0=N)
		return 1;
	return Fac(N-1)*N;
}

这里合计一共要递归N+1次(0~N),每次递归调用都是常数次,从0到N累加为执行次数,所以这里的时间复杂度为O(N)

*Tips:这里容易被Fac(N-1)N误导,误以为时间复杂度是O(N2)❌,这里的乘只代表返回值的结果相乘,而非次数相乘

(3)O(n2)平方阶

含义: 算法总操作次数与数据规模平方成正比,常见于双层循环嵌套,数据量增大后耗时会急剧飙升
经典案例: 冒泡排序、双重遍历暴力查找,均为O(n2)

c 复制代码
void bubbleSort(int arr[],int n)
{
	for(int i=0;i<n;i++)
	{
		for(int j=0;j<n-i-1;j++)
		{
			if(arr[j]>arr[j+1])
			{
				int temp=arr[j];
				arr[j]=arr[j+1];
				arr[j+1]=temp;
			}
		}
	}
}

这里双层循环的执行次数约为N*(N-1)/2,简化后时间复杂度为O(N2)

(4)O(logn)对数阶

含义: 数据规模翻倍,执行次数仅+1,算法效率极高
经典案例: 二分查找

c 复制代码
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-1;
		else
			return mid;
	}
	return -1;
}

二分查找每次都会把查找范围缩小一半,也是2x=N,得x=logN
Tips:二分查找我们在写的时候如果上面是左闭右闭,那么我们在下面也要保左闭右闭,若上面是左闭右开则下面也同样要和上面保持一致

二分查找与暴力查找的对比:

数据总数 二分查找 暴力查找
N O(logN) O(N)
1000 10 1000
100W 20 100W
100亿 30 100亿

我们可以很直观的感受到二分查找的查找效率非常之高,但是它属于外强中干,有自己的缺点
二分查找的缺点:

  1. 必须排序
  2. 必须用数组结构,数组结构不方便插入删除

(5)O(2N)指数阶

这里比较经典的是计算斐波那契递归的时间复杂度

c 复制代码
long long Fib(size_t N)
{
	if(N<3)
		return 1;
	return Fib(N-1)+Fib(N-2);
}

这里我们可以发现递归调用次数呈等比数列,累计递归调用次数是20+21+...+2(N-2),结果为2(N-1)-1

这里可以用等比数列的计算公式计算,也可以用错位相减法

由于斐波那契数列为指数级,效率太低,因此在现实情况中的意义较小

把递归改为非递归,用循环解决,可以将时间复杂度从O(N2)提升为O(N)

c 复制代码
long long Fib(size_t N)
{
	long long f1=1;
	long long f2=1;
	long long f3=0;
	for(size_t i=3;i<=N;i++)
	{
		f3=f1+f2;
		f1=f2;
		f2=f3;
	}
}

二、空间复杂度

1.空间复杂度的定义

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

空间复杂度算的是变量的个数,计算规则与时间复杂度类似,也使用大O渐进表示法
Tips:空间复杂度只计算额外申请的空间,函数运行时所需要的栈空间在编译期间已经确定,不算在内

2.经典示例

数组旋转:

c 复制代码
void _rotate(int* nums,int numsSize,int k,int temp)
{
	k%=numsize;
	int n=numsize;
	memcpy(tmp,num+n-k,sizeof(int)*k);
	memcpy(tmp+k,nums,sizeof(int)*(n-k));
	memcpy(nums,tmp,sizeof(int)*(n));
}
void rocate(int* nums,int numsSize,int k)
{
	int tmp[numsSize];
	_roate(nums,numsSize,k,tmp);
}

这里是拿空间换时间的做法

额外申请了大小为numsSize的数组,空间复杂度为O(N)

阶乘递归斐波那契数列:

c 复制代码
long long Fac(size_t N)
{
	if(N==0)
		return 1;
	return Fac(N-1)*N;
}

每个函数调用都会建立栈帧,空间最后都会销毁,主要看最多的时候建立多少

这里递归调用建立了N个栈帧,空间复杂度为O(N)

相关推荐
Hello:CodeWorld1 小时前
μC/OS vs FreeRTOS:嵌入式实时操作系统深度对比
c语言·开发语言·单片机
故事和你911 小时前
洛谷-【图论2-1】树2
开发语言·数据结构·c++·算法·动态规划·图论
中屹指纹浏览器1 小时前
2026指纹浏览器轻量化架构与资源调度技术:实现千级环境高效稳定运行
经验分享·笔记
咸甜适中2 小时前
rust语言学习笔记Trait之 From 和 Into (类型转换)
笔记·学习·rust
叶~小兮2 小时前
K8S优先级、Pod驱逐、HPA扩缩容 学习笔记
笔记·学习·kubernetes
星恒随风2 小时前
四天学完前端基础三件套(CSS篇)
前端·css·笔记·学习
05候补工程师2 小时前
【编译原理】语法制导翻译:属性分类、依赖图与求值逻辑全解析
经验分享·笔记·考研·自然语言处理·机器翻译
xiaoyuchidayuma2 小时前
【无标题】
笔记
努力努力再努力wz2 小时前
【Qt入门系列】深入理解信号与槽:从事件响应到自定义信号机制
c语言·开发语言·数据结构·数据库·c++·qt·mysql