[学懂数据结构]谈谈复杂度(篇一)

186 阅读4分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第15天,点击查看活动详情

前言

        我们知道,学好数据结构和算法的一个前提就是搞懂复杂度的概念和分析。

        本文就来分享一波作者对复杂度的学习心得与见解。本篇属于第一篇,主要介绍时间复杂度的一些内容。

        笔者水平有限,难免存在纰漏,欢迎指正交流。

时间复杂度

定义

        在进行算法分析时,语句总的执行次数T(n)是关于问题输入规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。

        算法的时间复杂度,也就是算法的时间量度,和T(n)成正比,记作T(n) = O(f(n))。这表示随n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题输入规模n的函数。

        这样用大写O( )来体现时间复杂度的记法称为大O记法

        一般情况下,随着n增大,T(n)增长最慢的算法为最优算法。

如何推导大O阶

推导方法

1.用常数1取代所有加法常数

2.只保留最高阶项,其他的去掉

3.如果最高阶项存在且其系数不为1,则去除其系数

        要注意,这里的阶不是只看指数的,而是看f(n)的增长率的,增长率高的就为高阶,低的为低阶。 所以一些数学函数的增长率关系必须得清楚。

        实际上,低阶项并不是说毫无影响,只是说相对于高阶项而言影响微乎其微到可以忽略,因为我们要看的是量级蜕变,就比如n和n^2 ,不是一个量级的,差距显著,而n和2n仍处在一个量级。

常见大O阶的说明

常数阶

 int main()
 {
     int sum = 0;//执行1次
     int i = 0;//执行1次
     for(i = 0; i < 10; i++)//执行11次
     {
         sum += i;//执行10次
     }
     printf("%d", sum);//执行1次
     
     return 0;
 }

        上面代码的执行次数是固定的常数,与n无关,我们称之为具有O(1)的时间复杂度,又叫常数阶

        无论常数是多少,我们都记作O(1)。

线性阶

        分析算法的复杂度,其中的一个关键就是要分析循环结构的运行情况。

 int add(int n)
 {
     int i = 0;
     int sum = 0;
     for(i = 0; i < n; i++)
     {
         sum += i;
     }
     
    return sum;
 }
 

        循环体中代码要执行n次,最高阶项就是n,所以易知时间复杂度为O(n)。

对数阶

 void mul(int n)
 {
     int cnt = 1;
     while(cnt < n)
     {
         cnt *= 2;
     }
     
 }

        cnt只要小于n就会一直进入循环乘以2,直到比n要大为止,在这个过程中,我们假设乘了x个2,那么就有2x =n,也就是x=log2n。所以时间复杂度为O(logn)。要注意的是,logn是对以2为底n的对数的简写。

平方阶

        下面是一个很简单的例子,对于时间复杂度为O(n2 )。

 for(i = 0; i < n; i++)
 {
     for(j = 0; j < n; j++)
     {
         //执行O(1)的操作
     }
 }

        一般而言,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。

 for(i = 0; i < n; i++)
 {
     for(j = 0; j < m; j++)
     {
         //执行O(1)的操作
     }
 }

        这里时间复杂度是不是就是O(m*n)呢?不一定,得看m和n的相对大小:

若是m>>n,则时间复杂度为O(m^2 )

若是n>>m,则时间复杂度为O(n^2)

若是m和n差不多大,则时间复杂度就为O(m*n)

        再看看这个例子:

 for(i = 0; i < n; i++)
 {
     for(j = 0; j <= i; j++)
     {
         //执行O(1)的操作
     }
 }

        i=0时,内循环执行1次,i=1时,内循环执行2次......i=n-1时,内循环执行n次,也就是说,总的执行次数为1+2+3+...+n,求和得(n+1)n/2也就是1/2n^2 +1/2n,时间复杂度为O(n^2 )。

        来个调用函数相关的例子:

 void fun(int cnt)
 {
     int j = 0;
     for(j = cnt; j < n; j++)
     {
         //执行O(1)的操作
     }
 }
 ​
 int main()
 {
     int i = 0;
     int j = 0;
     int n = 0;
     scanf("%d", &n);
     
     fun(n);//执行次数为n
     for(i = 0; i < n; i++)
     {
         fun(i);//执行次数为n*n
     }
     
     for(i = 0; i < n; i++)//执行次数为n(n+1)/2
     {
         for(j = i; j < n; j++)
         {
             //执行O(1)的操作
         }
     }
     
     return 0;
 }

        乍一看是不是还有点复杂的说,其实还是平方阶的,我们分析一波。

        常数次的就不看了,第一个fun(n)的,调用一次fun函数,里面执行了n次,第一个for循环,循环里面调用了fun函数,调用一次就是n次,总共调用了n次,所以是n*n次。

        而对于第二个for循环,是个嵌套循环,注意内循环的初始化条件为j=i,当i=0时,内循环执行n次,i=1时,内循环执行n-1次......当i=n-2时,内循环执行2次,i=n-1时,内循环执行1次,总的执行次数为n+n-1+n-2+...+2+1,求和得到n(n+1)/2。

        所以程序的执行次数大概为3/2n^2 +3/2n,时间复杂度就为O(n^2)。

常见的时间复杂度

扫描全能王 2022-07-25 12.10_14

        从低阶到高阶排序:

O(1) < O(logn) < O(n) < O(nlongn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

image-20220725183634365

        我们最常讨论的其实也就以下几个时间复杂度

扫描全能王 2022-07-25 12.10_15

最坏情况和平均情况

三种情况

        我们前面讲的例子其实执行次数是可以预估出来的,因为较为固定,但是有一些算法它的执行次数时不确定的,这时候就有几种可能情况了。

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

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

最好情况:1次找到

最坏情况:n次找到

平均情况:n/2次找到

        在实际中,一般情况下关注的是算法的最坏运行情况,也就是所谓的时间复杂度指的是最坏情况下的时间复杂度,比如上面例子中数组中搜索数据时间复杂度为O(n)。

实例练习

二分查找

 int BinarySearch(int* a, int n, int x)
 {
     assert(a);
     int begin = 0;
     int end = n-1;
     // [begin, end]:beginend是左闭右闭区间,因此有=号
     while (begin <= end)
     {
         int mid = begin + ((end-begin)/2);
         if (a[mid] < x)
             begin = mid+1;
         else if (a[mid] > x)
             end = mid-1;
         else
             return mid;
     }
     return -1
 }

        二分查找查找次数其实是不确定的,最好情况下1次就能找到,最坏情况下要一直二分直到left>right,有n个数字,每次二分都会截断一半的数字,最后截断到只剩下一个数字,其中假设截断了x次,每次截断n都要除以2,所以最后有n/2x =1,也就是执行次数x=logn,时间复杂度就为O(logn)。

阶乘递归

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

        前面好像没有讲过递归欸,那你觉得时间复杂度会是多少呢?

        每次进入函数都会调用一次N-1代入后的函数,直到N的值变为0,所以实际上执行了f(N-1)、f(N-2)......f(3)、f(2)、f(1)、f(0)这么多函数,执行次数为N,时间复杂度为O(n)。

image-20220725180937782

斐波那契递归

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

        这个就比较复杂了,最好知道什么是二叉树(后面才学),简单来说二叉树就是每次分支最多只有两个分支的树形结构,如图

image.png

        二叉树和斐波那契递归又有什么关系呢?斐波那契递归的函数调用过程其实可以用二叉树来表示,如图

img

        这样一看就很直观了,明显发现这种算法有很多重复计算,所以随着n的增大,算法效率应该很低,那到底怎样计算时间复杂度呢?

        注意看图,每一层的执行次数可以数这一层有多少函数调用来计算,而且还有一定规律:都是2的指数倍,比如第一层是2^0 ,第二层是2^1 ,第三层是2^2 ,以此类推,第n层就是2^(n-1) ,这下时间复杂度不就出来了吗——O(2^n)。


以上就是本文全部内容了,感谢观看,你的支持就是对我最大的鼓励~

u=779924663,1900594976&fm=253&fmt=auto&app=120&f=JPEG.webp