【数据结构】6. 时间与空间复杂度

文章目录

一、算法效率

如何衡量一个算法的好坏呢?比如对于以下斐波那契数列:

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

斐波那契数列的递归实现方式非常简洁,但简洁一定好吗?那该如何衡量其好与坏呢?

1、算法的复杂度

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

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

二、时间复杂度

1、时间复杂度的概念

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

c 复制代码
// 请计算一下Func1中++count语句总共执行了多少次?
void Fun1(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 执行的次数 :F(N)=N2+2*N+10。

  • N = 10、F(N) = 130
  • N = 100 、F(N) = 10210
  • N = 1000 、F(N) = 1002010

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

2、大O的渐进表示法

大O符号(Big O notation):是用于描述函数渐进行为的数学符号
推导大O阶方法:

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

使用大O的渐进表示法以后,Func1的时间复杂度为:O(N2)

  • N = 10 、F(N) = 100
  • N = 100 、 F(N) = 10000
  • N = 1000 、F(N) = 1000000

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

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

最坏情况:任意输入规模的最大运行次数(上界)。

平均情况:任意输入规模的期望运行次数 。

最好情况:任意输入规模的最小运行次数(下界)。

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

最好情况:1次找到

最坏情况:N次找到

平均情况:N/2次找到

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

3、常见时间复杂度计算

1)实例1

c 复制代码
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);
}

函数执行了2N+10次,影响因素最大的是N,因此时间复杂度是:O(N)

2)实例2

c 复制代码
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);
}

函数执行了N+M次,N和M的影响程度相同,因此时间复杂度是:O(N+M)

3)实例3

c 复制代码
void Func4(int N)
{
    int count = 0;
    for (int k = 0; k < 100; ++k)
    {
        ++count;
    }
    printf("%d\n", count);
}

函数执行的次数与N无关,因此时间复杂度为常数级别:O(1)

4)实例4

c 复制代码
//strchr:在字符串中查找指定字符的首次出现位置
const char* strchr(const char* str, int character)
{
    while (*str)
    {
        if (*str == character)
            return str;
        else
            ++str;
    }
}

最坏情况下函数需要遍历整个字符串,循环次数为N,因此时间复杂度为:O(N)

5)实例5

c 复制代码
//冒泡排序
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-1 + N-2 + ... + 2 + 1次,求和后就是N*(N-1)/2,影响因素最大的是N2 ,因此时间复杂度是:O(N2)

6)实例6

c 复制代码
//二分查找
int BinarySearch(int* a, int n, int x)
{
    assert(a);
    
    int begin = 0;
    int end = n-1;  //注意:这里是闭区间 [begin, end]
    
    while (begin <= end)  //闭区间条件:begin <= end
    {
        int mid = begin + ((end-begin)>>1);  //安全的中间值计算,避免溢出
        
        if (a[mid] < x)
            begin = mid+1;  //右半区间:[mid+1, end]
        else if (a[mid] > x)
            end = mid-1;    //左半区间:[begin, mid-1]
        else
            return mid;     //找到目标值
    }
    
    return -1;  //未找到
}

二分查找的最坏情况是查找区间只剩下最后一个数或者找不到,因此执行次数应该为N/2/2.../2=1,因此 N= log ⁡ 2 N \log_2 N log2N,时间复杂度为:O( log ⁡ N \log N logN)

7)实例7

c 复制代码
//阶乘
long long Fac(size_t N)
{
	if (0 == N)
		return 1;

	return Fac(N - 1) * N;
}

递归执行了N+1次,因此时间复杂度为:O(N)

8)实例8

c 复制代码
//斐波那契数
long long Fib(size_t N)
{
 if(N < 3)
 return 1;
 
 return Fib(N-1) + Fib(N-2);
}

画图演示:

递归总共执行了2N-1-1 次,因此时间复杂度为:O(2N)

由于时间复杂度太高,我们一般不使用递归的方式计算斐波拉契数列,而是使用迭代,如下:

c 复制代码
//斐波拉契数
long long Fib(int N)
{
	long f1 = 1;
	long f2 = 1;
	long f3 = 0;
	for (int i = 3; i < N; i++)
	{
		f3 = f1 + f2;
		f1 = f2;
		f2 = f3;
	}

	return f3;
}

现在的时间复杂度就变为了O(N)

三、空间复杂度

1、空间复杂度的概念

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

空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,空间复杂度其实算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。

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

常见的空间复杂度有:O(1)、O(N)、O(N^2)。

2、常见空间复杂度计算

实例1:

c 复制代码
//冒泡排序
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; 
    }
}

函数内部创建了三个变量:end、exchange、i,因此开辟的空间是常数级,空间复杂度为:O(1)

实例2:

c 复制代码
//递归求阶乘
long long Fac(size_t N)
{
	if (N == 0)
		return 1;

	return Fac(N - 1) * N;
}

每次递归的调用就会创建一个函数栈帧(空间),总共调用N+1次递归,就会开辟N+1个函数栈帧,因此空间复杂度为:O(N)

实例3:

c 复制代码
//斐波拉契数
long long Fib(size_t N)
{
	if (N < 3)
		return 1;

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

同样每次递归调用都会开辟一块函数栈帧,但是在这里这个空间可以重复使用,总共开辟N-1个函数栈帧,因此空间复杂度为:O(N)

四、常见复杂度对比

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

五、时间复杂度的oj练习

两道时间复杂度的oj练习题,如下:

1、消失的数字

点击链接:https://leetcode-cn.com/problems/missing-number-lcci/

这道题我们有三种思路,但是题目要求时间复杂度在O(N)内,因此我们需要分析一下。
思路一: 先将数组排序(qsort快速排序),再依次查找,如果下一个值不等于前一个+1,那么下一个值就是消失的数字。

时间复杂度分析: 快速排序的平均时间复杂度是O(N* log ⁡ N \log N logN),查找的最坏情况下时间复杂度是O(N),发现快速排序的时间复杂度影响程度更大,因此整体的时间复杂度为:O(N log ⁡ N \log N logN)。不满足题意舍去。

思路二: 求和0到N(未消失),再依次减去数组所有的元素值,最后剩下的就是消失的元素。这种情况下时间复杂度为O(N),满足题意。

c 复制代码
int missingNumber(int* nums,int numsSize)
{
    int N=numsSize;
    int ret=(0+N)*(N+1)/2;
    for(int i=0;i<N;i++)
    {
        ret-=nums[i];
    }
    return ret;
}

经测试可以成功运行。但是有个问题就是N太大的时候存在溢出风险。

思路三: 采用异或(异或特性:任何数和0异或都等于本身,相同的数异或等于0,异或满足交换结合律),先用0与数组中的元素一一异或,再将这个整体与0到N的整数一一异或,最后剩下的就是消失的数。

c 复制代码
int missingNumber(int* nums, int numsSize) 
{  
    int N=numsSize;
    int x=0;
    for(int i=0;i<N;i++)
    {
        x^=nums[i];
    }

    for(int j=0;j<=N;j++)
    {
        x^=j;
    }  
    return x;
}

函数执行了2N+1次,因此时间复杂度为O(N),满足题意。

2、轮转数组

点击链接:https://leetcode-cn.com/problems/rotate-array/

思路一:每次实现一次旋转,总共旋转k%N次即可。

如图所示:

c 复制代码
void rotate(int* nums, int numsSize, int k) 
{
    int N=numsSize;
    k%=N;
    while(k--)
    {
        //旋转一次
        int temp=nums[N-1];
       for(int i=N-2;i>=0;i--)
       {
        nums[i+1]=nums[i];
       } 
       nums[0]=temp;
    }
}

函数会执行K*N次,因此时间复杂度为:O(K * N),当输入的数据很大时,时间复杂度就很大,就会超出时间限制。

如图所示:

思路二:采取三步反转法。

c 复制代码
void reverse(int* a,int left,int right)
{
    while(left<right)
    {
        int temp=a[left];
        a[left]=a[right];
        a[right]=temp;
        left++;
        right--;
    }
}

void rotate(int* nums, int numsSize, int k) 
{
    int N=numsSize;
    k%=N;
    
    reverse(nums,0,N-k-1);
    reverse(nums,N-k,N-1);
    reverse(nums,0,N-1);
}

函数执行了N-k + k + N=2N次,因此时间复杂度为O(N),相比思路一时间复杂度就大大的减少了。

相关推荐
coderSong25682 小时前
Java高级 |【实验八】springboot 使用Websocket
java·spring boot·后端·websocket
Mr_Air_Boy3 小时前
SpringBoot使用dynamic配置多数据源时使用@Transactional事务在非primary的数据源上遇到的问题
java·spring boot·后端
豆沙沙包?3 小时前
2025年- H77-Lc185--45.跳跃游戏II(贪心)--Java版
java·开发语言·游戏
年老体衰按不动键盘4 小时前
快速部署和启动Vue3项目
java·javascript·vue
咖啡啡不加糖4 小时前
Redis大key产生、排查与优化实践
java·数据库·redis·后端·缓存
liuyang-neu4 小时前
java内存模型JMM
java·开发语言
int型码农4 小时前
数据结构第八章(一) 插入排序
c语言·数据结构·算法·排序算法·希尔排序
UFIT4 小时前
NoSQL之redis哨兵
java·前端·算法
喜欢吃燃面4 小时前
C++刷题:日期模拟(1)
c++·学习·算法
刘 大 望4 小时前
数据库-联合查询(内连接外连接),子查询,合并查询
java·数据库·sql·mysql