数据结构一:算法效率分析(时间复杂度和空间复杂度)-重点

       在学习具体的数据结构和算法之前,每一位初学者都要掌握一个技能,即善于运用时间复杂度和空间复杂度来衡量一个算法的运行效率。所谓算法,即解决问题的方法。同一个问题,使用不同的算法,虽然得到的结果相同,但耗费的时间和资源肯定有所差异。这也就意味着,如果解决问题的算法有多种,我们就需要从中选出最好的那一个。那么,怎么判断哪个算法更好(或者更优)呢?在满足准确性和健壮性的基础上,还有一个重要的筛选条件,即通过算法所编写出的程序的运行效率。程序的运行效率具体可以从 2 个方面衡量,分别为:程序的运行时间和程序运送所需的内存空间大小。本篇博客详细总结如何计算一个算法的时间复杂度和空间复杂度,根据代码的编写形式分为:非递归式(循环)和递归代码的时间复杂度,空间复杂度计算,根据出题的形式又可分为:给定代码计算时间复杂度和空间复杂度,或者给定题目请设计算法同时给出时间复杂度和空间复杂度;后者作为笔试面试的重点考查方式,需要重点掌握!

一、算法复杂度

         算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。 时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外内存空间。根据算法编写出的程序,运行时间更短,运行期间占用的内存更少,该算法的运行效率就更高,算法也就更好。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计 算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。

 二、复杂度在校招中的考察

三、时间复杂度     

3.1 时间复杂度的概念,如何理解?

        在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。记作:T(n)=O(f(n)),其中f(n)是问题规模n的某个函数。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。O(语句总的执行次数),即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。

理解概念的两个关键点:

     1. 用计算语句总的执行次数,来度量时间的;

     2. 把握好问题的规模这个概念,抓住问题规模;

3.2 推导大O阶方法

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

推导大O阶方法:

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

       通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。 另外有些算法的时间复杂度存在最好、平均和最坏情况:

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

       例如:在一个长度为N数组中搜索一个数据x 最好情况:1次找到 最坏情况:N次找到平均情况:N/2次找到在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)。

      总结:O(执行次数)    ---->   O(阶次/数量级)

举个栗子:

实现getsum函数求和1+2+3+...+n
int getsum(int n)
{
    int result=0;   执行1次
    for(int i=1;i<=n;i++)   执行次数为:1+n+n
    {
         result+=i;        执行次数为:n
    }
    return result;         执行1次
}

 分析:

   问题规模体现在形参n上,当n不同,计算求和的项数就不同,计算总的执行次数为f(n)=1+1+n+n+n+1=3n+3;  经过化简可知:O(n) ,可以总结出:像定义变量、循环初始条件、循环判断条件、循环自增变量等这些都不会影响最终的结果(当n较大时),在计算总的执行次数时可以不计算它们!只需要看循环内部语句总的执行次数。

3.3  常见的时间复杂度

3.3.1 常数阶

#include <stdio.h>
int main()
{
   int res=0,n=100; 执行1次
   res=(1+n)*n/2;   执行1次
   printf("%d",res); 执行1次
   return 0;         执行1次
}

分析:这个算法的运行次数函数是f(n)=4。根据我们推导大O阶的方法,第一步就是把常数项 4改为1。在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为O(1)

#include <stdio.h>
int main()
{
   int res=0,n=100; 执行1次
   res=(1+n)*n/2;   执行第1次
   res=(1+n)*n/2;   执行第2次
   res=(1+n)*n/2;   执行第3次
   res=(1+n)*n/2;   执行第4次
   res=(1+n)*n/2;   执行第5次
   res=(1+n)*n/2;   执行第6次
   res=(1+n)*n/2;   执行第7次
   res=(1+n)*n/2;   执行第8次
   res=(1+n)*n/2;   执行第9次
   res=(1+n)*n/2;   执行第10次
   printf("%d",res); 执行1次
   return 0;         执行1次
}

     事实上无论n为多少,上面的两段代码就是4次和13次执行的差异。这种与问题的大小无关(n的多少),即没有问题规模,执行时间恒定的算法,我们称之为具有 0(1)的时间复杂度,又叫常数阶。O(K)=O(1)

      对于分支结构而言,无论是真,还是假,执行的次数都是恒定的,不会随着 n的变大而发生变化,所以单纯的分支结构 (不包含在循环结构中),其时间复杂度也是0(1)。

3.3.2 线性阶

      线性阶的循环结构会复杂很多。要确定某个算法的阶次,我们常常需要确定某个特定语句或某个语句集运行的次数。(关键是要分析O(1)语句执行的总次数)。因此,我们要分析算法的复杂度,关键就是要分析循环结构的运行情况。

示例一:
#include <stdio.h>
int main()
{
   int i=0;
   for(i=0;i<n;i++)
   {
      /*时间复杂度为O(1)的程序步骤序列 */
   }
   return 0;       
}

     上面这段代码,它的循环的时间复杂度为 O(n),因为循环体中的代码须要执行 n次。

总结:对于单层for循环,循环条件与问题规模n有关,时间复杂度一般都是O(n)

3.3.3 对数阶

实例1:
#include<stdio.h>
int main()
{
    int  count=1;
    while (count <n)
    {
         count *=2;
         /*时间复杂度为O(1)的程序步骤序列*/
    }
    return 0;
}


实例2:
#include<stdio.h>
int main()
{
    int  num;
    while (num!=0)
    {
        num/=2;
       /*时间复杂度为O(1)的程序步骤序列*/
    }
    return 0;
}

示例1:

     由于每次 count 乘以2之后,就距离n 更近了一分。也就是说,有多少个2相乘后大于 n,则会退出循环。由2^x=n 得到 x=log2n(x也代表次数)。所以这个循环的时间复杂度为0(logn)。

实例2:

     由于每次num除以2之后,就距离0 更近了一分。也就是说,有多少个2相除后等于0,则会退出循环。由n/2^x=1 ,得到 x=log2n(x也代表次数)。所以这个循环的时间复杂度为0(logn)。

3.3.4 平方阶

实例1:
#include<stdio.h>
int main()
{
    int i,j;
    for(i=0;i<n;i++)
    {
       for(j=0;j<n;j++)
        {
            /*时间复杂度为O(1)的程序步骤序列*/
        }
    }
    return 0;
}


实例2:
#include<stdio.h>
int main()
{
    int i,j;
    for(i=0;i<m;i++)
    {
       for(j=0;j<n;j++)
        {
            /*时间复杂度为O(1)的程序步骤序列*/
        }
    }
    return 0;
}

实例3:
#include<stdio.h>
int main()
{
    int i,j;
    for(i=0;i<n;i++)
    {
       for(j=0;j<=i;j++)
        {
            /*时间复杂度为O(1)的程序步骤序列*/
        }
    }
    return 0;
}

实例1:

       是一个循环嵌套,它的内循环刚才我们已经分析过,时间复杂度为O(n)。而对于外层的循环,不过是内部这个时间复杂度为 O(n)的语句,再循环n次。所以这段代码的时间复杂度为O(n^2)。

实例2:

    如果外循环的循环次数改为了m,时间复杂度就变为O(mxn)。

实例3:

     由于当i=0,内层循环执行1次,当i=1,内层循环执行2次,当i=2,内层循环执行3次......当i=n-1,内层循环执行n次,因此总的执行次数为:1+2+3+4+...+n即等差数列求和问题:(1+n)*n/2=n/2+n^2=O(n^2)

总结:总结:对于双层for循环,内外循环条件与问题规模n有关,时间复杂度一般都是O(n^2)

四、空间复杂度 

      空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。 空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。 注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因 此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。

     空间复杂度计算的是与问题规模有关的额外开辟的内存空间!!!比如:临时定义的变量与问题规模无关不能算进去。

五、常用的时间复杂度 

六、经典题目(非递归和递归代码比较)

6.1 计算1+2+3...+n的和

1.非递归方式
int getsum(int n)
{
   int res=0;
   for(int i=1;i<=n;i++)
   {
       res+=i;
   }
   return res;
}

2.递归方式
int getsum(int n)
{
    if(n==1)
      {
          return 1;
      }  
    else
      {
         return getsum(n-1)+n;
      }
}
  

分析:非递归方式:问题规模体现在形参上n

       时间复杂度:单层for循环,语句执行次数为n,所以时间复杂度为:O(n)

       空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)

注:临时变量res和i都与问题规模无关,无论求和项数是多少,都只开这两个O(2)-----O(1)

递归总结分析:

       重点:递归就是函数自己调用自己,发生函数调用就会发生函数栈帧的开辟,每进行一次函数调用就会在内存上开辟一块内存,且需要注意,只有当函数调用完毕(函数语句执行完毕,得到确切的值,返回给被调函数,这块内存才会被释放!!!)

       

       递归的时间复杂度:函数的调用次数

       递归的空间复杂度:递归的深度(二叉树的深度)

分析:递归方式:问题规模体现在形参上n

       在这个问题上,函数调用次数与递归深度都相同,都与问题规模有关,因此,时间复杂度和空间复杂度都是O(n)

6.2 斐波那契数列(递归与非递归实现)-重点理解

1.非递归的方式
int Fib(int n)
{
	int a = 1;
	int b = 1;
	int c = 1;
	while(n>=3)
	{
		c = a + b;
		a = b;
		b = c;
	}
	return c;
}


2.递归的方式
int Fib(int n)
{
	if(n<=2)
	
		return 1;
	
	else
		return Fib(n - 1) + Fib(n - 2);
}

分析:非递归方式:问题规模体现在形参上n

       时间复杂度:单层while循环,语句执行次数为n-3+1=n-2,所以时间复杂度为:O(n)

       空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)

分析:递归方式:问题规模体现在形参上n

       递归的时间复杂度:计算函数的调用总的次数

       递归的空间复杂度:递归的深度(二叉树的深度)

       注意:每一层的递归调用都会在栈上分配一块内存,有多少层递归调用就分配多少块相似的内存,所有内存加起来就是开辟的总的内存大小,同层占用一块内存!! 

6.3  二分查找算法(递归与非递归实现)-重点理解

1.非递归方式
int binarysearch(int* arr, int len, int val)
{
	assert(arr != NULL && len > 0);   //参数检验
	int left = 0, right = len - 1;
	while (left <= right)            //查找的条件
	{
		int midindex = (left + right) / 2;
		//面试进行优化,利用位运算更偏底层运行速度更快。
		//优化一:midindex = (left + right) >> 1; 
 
		// 提示:若: 数据量很大 [0,200] left=150,right = 160; -> 310,  有可能超出范围如何解决? 算术运算符操作的数据范围只能在基本数据类型的范围之内
		//优化二:midindex = ((right - left)>>1) + left;
		if (arr[midindex] == val)
		{
			return midindex;
		}
		else if (arr[midindex] < val)
		{
			left = midindex + 1;
		}
		else
		{
			right = midindex - 1;
		}
	}
	return -1;
}



2.递归方式
int binarysearch0(int* arr, int len, int value, int left, int right)
{
	if (left > right)
		return -1;
	int midindex = (left + right) / 2;
	if (arr[midindex] == value)
	{
		return midindex;
	}
	else if (arr[midindex] > value)
	{
		return binarysearch0(arr, len, value, left, midindex - 1);
	}
	else
	{
		return binarysearch0(arr, len, value, midindex + 1, right);
	}
}
 
int binarysearch(int* arr, int len, int value)
{
	assert(arr != NULL);
	return binarysearch0(arr, len, value, 0, len - 1);
}

分析:非递归方式:问题规模体现在形参上len,数组长度不同,问题规模就不同

       时间复杂度:每次查找查找区间减半,所以减了多少次,代表语句执行的总次数,logn

       空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)

分析:递归方式:问题规模体现在形参上查找的区间上,封装成函数。

       时间复杂度:每次查找查找区间减半,所以减了多少次,代表语句执行的总次数,logn

       空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)

七、给定题目设计算法,同时给出时间复杂度和空间复杂度(未知代码)

这种题型求解思路如下: 

       时间复杂度:访问问题规模中的元素总个数

       空间复杂度:与问题规模相关额外开辟的内存空间,没有额外开辟跟问题规模相关的内存O(1)

      以上便是我为大家带来的算法效率分析的内容,若有不足,望各位大佬在评论区指出,谢谢大家!可以留下你们点赞、收藏和关注,这是对我极大的鼓励,我也会更加努力创作更优质的作品。再次感谢大家!