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

  • 前言

数据结构是什么呢?其实数据结构就是计算机存储和组织数据的一种形式,这些数据存在一种或多种特定关系的数据元素的集合。

算法又是什么?数值分析中我们对一个复杂的数学问题,会通过设定特定的算法将这个复杂的数学问题转化成加减乘除进行计算,这也是算法,事实上,算法就是定义良好的计算过程,用来将输入的数据转化成输出结果。

1.算法的效率

算法有稳定和不稳定之分,也有快慢之分,那我们该如何去衡量一个算法的优劣?先看如下示例:

cpp 复制代码
long Fib(int n)
{
	if (n < 3)
		return 1;
	return Fib(n - 1) + Fib(n - 2);
}
int main()
{
	int i = 50;
	int ret = Fib(i);
	printf("%d\n", ret);
	return 0;
}

这是一个计算斐波那契数列的一段代码,大家可以在自己的电脑上运行一下,看看需要多长时间才能出结果。是不是需要很长时间才能运行出来,这是因为这段看似很简洁的代码,其中套用了大量的函数递归,进行了大量的计算,导致了电脑一直在运算当中。那么这个算法就不好,50的斐波那契数列就需要相当长的时间去计算,拿到一段代码,我们总不能先运行一下看看,再去决定算法的优劣吧,显然这是不合适的,那该如何衡量算法的好与坏呢?这就是本篇博客需要介绍的内容。

算法的复杂度:

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

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

2.时间复杂度

时间复杂度按一般的定义来讲就是一个函数,它定量描述了一个算法的运算时间。这个运算时间能算出来吗?显然不能,但可以通过计算机去运行之后知道,但如上所述,难道我们写的每一个代码都要运行一下才能知道它的快慢吗?这显然也是不合适的,所以才有了时间复杂度这个分析方式,一个算法所花费的时间与其中语句的执行次数成正比,因此算法中的基本操作执行次数,就为算法的时间复杂度。废话少叙,我们通过代码去分析:

cpp 复制代码
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执行的基本操作次数:F(N)=N^2+2*N+10

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

可以观察到当N越来越大时,2*N与10这些小量我们是不是就可以不太关注了,就好像有100还会在乎千八百吗?因此我们就采取大O的渐进表示法,上述例子中时间复杂度就时O(N^2)。

大O的渐进表示法:

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

注意:有些算法中的时间复杂度存在最好、平均、及最坏的情况。例如我们在一个数组中寻找一个数,有可能第一次就找到,也有可能在中间找到,也有可能在最后找到。对于这样的算法求他们的复杂度就是关注算法的最坏运行情况。

看如下几个例子的分析加深我们对时间复杂度的判断:

cpp 复制代码
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次,因此时间复杂度为 O(N)

cpp 复制代码
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次,有两个未知数M和N,时间复杂度为 O(N+M)

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

基本操作执行了100次,时间复杂度为 O(1)

cpp 复制代码
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词,以此类推一直到倒数第二个经过一次交换,因此总共的次数就是1+2+3+...+n-2+n-1=(N*(N+1)/2次,所以时间复杂度是O(N^2)

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

这是二分法查找的算法,我们在计算时间复杂度的时候,大多数观察循环是很难去计算出这样的一个复杂度,应该通过算法知道这段代码是在做什么事情,通过分析背景,了解其中的过程再去计算。二分法去查找,最坏的情况是不是一直分到最后才找到,那如何计算呢?看下图:

设查找的次数为x,总的数据为n,那么2^x=n,反推x=,因此时间复杂度便是O()

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

这是一个简单的函数递归类型,就是通过递归实现1*2*3*...*(n-1)*n ,也就是说一共递归了n次,所以时间复杂度就是O(n)

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

很显然这是求解斐波那契数列的递归算法实现的时间复杂度的问题,这个比较复杂我们通过画图来分析它,如图所示:

事实上虽然其中的很多项算不到最后,如N-5、N-4等等肯定算不到最后一行,但是呢,我们说计算时间复杂度是不是个渐进的,这些空缺无伤大雅,因此递归实现斐波那契数列的时间复杂度是O(2^N)可以看到是呈指数级增长的,所以在本篇开头可以看到N=50的时候就已经需要算很长时间,因为在计算的过程当中,有许多重复的量在计算如上图所示,N-2、N-3等会计算好几遍导致了计算很慢。可以想象一下,2^10= 1024看成1000,2^30就是十亿级,2^50就是千万亿级,我们一般电脑的cpu计算速度一般也就是亿单位级别,你想千万亿它需要计算多长时间。

3.空间复杂度

空间复杂度算的是变量的个数,也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度,也使用大O渐进表示法。现在我们一般不太考虑空间复杂度,除非是在对空间要求比较苛刻的情况下,例如嵌入式行业,因为空间极其宝贵,因此比较看重空间的利用。

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

如上图几个例子中:

cpp 复制代码
//冒泡排序
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;
	}
}
//斐波那契数列
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;
}
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
    if (N == 0)
        return 1;
    return Fac(N - 1) * N;
}

冒泡排序定义了5个变量,是常数所以空间复杂度是O(1),斐波那契数列把一些常数项忽略掉,由于定义了n+1个空间,看作n那么空间复杂度是O(n),递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间。空间复杂度为O(N)。

4.常见复杂度对比

在数学中我们已经知道这些函数在自变量逐渐增长的过程中函数的变化趋势,因此在此总结了一些常见复杂度渐进表示方式:

相关推荐
Swift社区25 分钟前
【Swift 算法实战】利用 KMP 算法高效求解最短回文串
vue.js·算法·leetcode
萌の鱼26 分钟前
leetcode 73. 矩阵置零
数据结构·c++·算法·leetcode·矩阵
好看资源平台27 分钟前
‌KNN算法优化实战分享——基于空间数据结构的工业级实战指南
数据结构·算法
AllYoung_36230 分钟前
WebUI 部署 Ollama 可视化对话界面
人工智能·深度学习·算法·语言模型·aigc·llama
孤独得猿43 分钟前
贪心算法精品题
算法·贪心算法
姜西西_1 小时前
合并区间 ---- 贪心算法
算法·贪心算法
Duramentee1 小时前
C++ 设计模式 十九:观察者模式 (读书 现代c++设计模式)
c++·观察者模式·设计模式
不平衡的叉叉树1 小时前
使用优化版的编辑距离算法替代ES默认的评分算法
java·算法
黑色的山岗在沉睡1 小时前
P1038 [NOIP 2003 提高组] 神经网络
算法
m0_748240441 小时前
Rust 错误处理(下)
java·算法·rust