算法复杂度讲解

一.概念

1.什么是算法复杂度

为了评判程序算法在计算机上运行的优劣,从而引入了算法复杂度这个概念.算法复杂度分为时间复杂度和空间复杂度,其作用:

  • 时间复杂度是指执行这个算法所需要的计算工作量,也就是算法的运行速度,运行快慢;
  • 而空间复杂度是指执行这个算法所需要的内存空间(额外空间);

时间和空间都是计算机资源的重要体现,而算法的复杂性就是体现在运行该算法时的计算机所需的资源多少,在计算机发展的早期,计算机的存储容量很小,所以对空间复杂度很是在乎,但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度,所以如今一般已经不需要再特别关注一个算法的空间复杂度

2.什么是空间复杂度

一个程序的空间复杂度是指运行完一个程序所需内存的大小,利用程序的空间复杂度,可以对程序的运行所需要的内存多少有个预先估计,一个程序执行时除了需要存储空间和存储本身所使用的指令、常数、变量和输入数据外,还需要一些对数据进行操作的工作单元和存储一些为现实计算所需信息的辅助空间。程序执行时所需存储空间包括以下两部分:

  • 固定部分:这部分空间的大小与输入/输出的数据的个数多少、数值无关。主要包括指令空间(即代码空间)、数据空间(常量、简单变量)等所占的空间,这部分属于静态空间。
  • 可变空间:这部分空间的主要包括动态分配的空间,以及递归栈所需的空间等,这部分的空间大小与算法有关

3.什么是时间复杂度

时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有把程序放在机器上跑起来测试,才能知道。但不可能也没有必要对每个算法都上机测试,所以才有了时间复杂度这个分析方式,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。并且一个算法所花费的时间与其中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。算法中语句的执行次数,称为语句频度或时间频度,记为T(n);

在上面提到的时间频度中,n称为问题的规模,当n不断变化时,时间频度T(n)也会不断变化,但有时想知道它变化时呈现的规律,为此,就引入时间复杂度概念。记为O(…),也称为大O表示法;

另外,时间频度不同,但时间复杂度可能相同。如:T(n)=n2+3n+4与T(n)=4n2+2n+1它们的频度不同,但时间复杂度相同,都为O(n2) (注意这里n2是n方的意思)

这里需要知道的是:时间复杂度去估算算法优劣的时候注重的是算法的潜力,也就是在数据规模有压力的情况之下(最坏情况)算法的执行频度:比如2个算法,在只有100条数据的时候,算法a比算法b快,但是在有10000条数据的时候算法b比算法a快,这时候认为算法b的时间复杂对更优;

如何估计程序运行时间呢,通常会估计算法的操作单元数量,来代表程序消耗的时间, 这里默认CPU的每个单元运行消耗的时间都是相同的。

假设算法的问题规模为n,那么操作单元数量便用函数f(n)来表示

随着数据规模n的增大,算法执行时间的增长率和f(n)的增长率相同,这称作为算法的渐近时间复杂度,简称时间复杂度,记为 O(f(n)),这里就引入了O,也就是使用大O渐进法来表示算法的时间复杂度

4.什么是大O

大O符号:是用于描述函数渐进行为的数学符号,可以用大O的渐进表示法来表示时间复杂度。同样的,也用大O的渐进表示法来表示空间复杂度。大O的渐进表示法是一个估算,算出的是大概次数所属量级,可以通过一个例子来讲解:假设原有的函数程序执行步为T(n),那么当T(n)=O(f(n))时,当且仅当存在正常数C和n0,使得T(n)≦C*f(n)对于所有n,n≧n0都成立。其中的f(n)这个函数用来描述原来的函数的数量级的渐进上界。使用大O符号时,用的O代表无穷大的渐进,它表示n趋近于无穷大时的一种情况。此时把O(f(n))称为算法的渐进时间复杂度,简称时间复杂度。时间复杂度的渐进上界表示如下图所示:

推导大O阶方法,也就是如何计算一个算法的时间复杂度:

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

从上面的相关定义可以了解到:算法中语句的基本执行次数,就是算法的时间复杂度,也就是说找到某条基本语句与问题规模N之间的数学表达式,也就算出了该算法的时间复杂度。

时间复杂度是一个悲观的预期,当一个算法随着输入不同、时间复杂度不同,做一个悲观的预期,看最坏的情况!

二.时间复杂度举例

用下面的表达式来表达:

T(n)=O(f(n))

常见的时间复杂度:

按增长量级递增排列,常见的时间复杂度有:

  • O(1):常数阶
  • O(logn):对数阶
  • O(n):线性阶
  • O(nlogn):线性对数阶
  • O(n^2):平方阶
  • O(n^3):立方阶
  • O(2^n):指数阶
  • O(n!):阶乘阶

从上面给出的算法时间复杂的的一个排行如下所示:

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

每种时间复杂度有所不同,下面来详细了解这几种时间复杂度

1.O(1):常数阶

O(1)的算法是一些运算次数为常数的算法,当给定大小为n的输入,无论n为何值,最后算法执行的时间是个常量。例如:

temp=a;
a=b;
b=temp;

根据推导: 用常数1来取代运行时间中所有加法常数; 上面语句共三条操作,单条操作的频度为1,即使他有成千上万条操作,也只是个较大常数,这一类的时间复杂度为O(1);

又比如:

int func(int n)
{
    n++;
    return n*2;
}

上面的程序中,无论输入n的值如何变化,程序执行时间始终是个常量。简化处理一下,假如函数中每行语句的执行时间是1,则执行时间的数学表达式:

f(n) = 2

无论n为多大,最后的执行时间都是2这个固定值。虽然是运行时间为2,但是这里也用O(1)来表示,这里的1代表是一个常数。

这里可以总结一个规律:当算法中规运算次的算法数为常数时,一般时间复杂度都为O(1)

 2.O(logN):对数阶

什么是对数? a^x = N,(a>0 && a!=1),那么x即是以a为底,N的对数,记作

算法—时间复杂度[通俗易懂]

其中a叫做对数的底数,N叫做真数

相关案例如下:

    private static void 对数阶() { 
   
        int number = 1;//执行1次
        int n = 100;//执行1次

        while (number < n) { 
   
            number = number * 2; // 执行n/2次
            System.out.println("哈哈");//执行1次
        }

    }

假设n为100,number是1,小于100退出循环。

  • 第1次循环,number = 2,2^1。
  • 第2次循环,number = 4, 2^2。
  • 第3次循环,number = 8, 2^3。
  • 第x次循环,number = 2^x

也就是2^x=n得出x=log₂n,那么算法的运算次数为 1+ 2log₂n,同上可得,当n大于某个值时且C取大于2的任何一个整数时,存在 1+ 2log₂n < Cog₂n, 因此算法的时间复杂度渐近上界为О(f (n))=О(log n)。

那为什么算法复杂度为О(log n),而不是写成О(2log₂n), 这里根据推导,不关注log的底是多少呢?其实这里的底数对于研究程序运行效率不重要,写代码时要考虑的是数据规模n对程序运行效率的影响,常数部分则忽略,同样的,如果不同时间复杂度的倍数关系为常数,那也可以近似认为两者为同一量级的时间复杂度

假设有底数为2和3的两个对数函数,如上图。当x取N(数据规模)时,记

运用换底公式可得: 

可以发现y的值是一个常数,与变量N无关。也就是说不同的对数底数对应的时间复杂度之比为一个常数,不会随着底数的不同而不同,因此可以将不同底数的对数函数所代表的时间复杂度,当作是同一类复杂度处理,即抽象成一类问题

再比如:计算二分查找的时间复杂度

// 计算binarySearch的时间复杂度?
int binarySearch(int[] array, int value) {
  int begin = 0;
  int end = array.length - 1;
  while (begin <= end) {
      int mid = begin + ((end-begin) / 2);
      if (array[mid] < value)
          begin = mid + 1;
      else if (array[mid] > value)
          end = mid - 1;
      else
          return mid;
  }
  return -1;
}

 上述方法中的基本操作执行最好 1 次,最坏 O(logN) 次,时间复杂度为 O(logN)

ps : logN 在算法分析中表示是底数为 2 ,对数为 N 。有些地方会写成 lgN 。(建议通过折纸查找的方式讲解 logN 是怎么计算出来的) ( 因为二分查找每次排除掉一半的不适合值 , 一次二分剩下: n/2 两次二分剩下: n/2/2 = n/4)

 这里可以总结一个规律:当算法中规模为n时,只存在一个while循环时,一般时间复杂度都为O(logN)

3.O(N):线性阶

O(n)的算法是一些线性算法。例如:

    sum=0;                 
     for(i=0;i<n;i++)       
         sum++;

上面代码中第一行频度1,第二行频度为n,第三行频度为n,所以f(n)=n+n+1=2n+1。

根据推导: 只要高阶项,不要低阶项目,常数项置为1,去除高阶项的系数: 所以时间复杂度O(n)。这一类算法中操作次数和n正比线性增长。

 又比如:

// 计算func2的时间复杂度?
void func2(int N) {
int count = 0;
for (int k = 0; k < 2 * N ; k++) {
  count++;
}
int M = 10;
while ((M--) > 0) {
  count++;
}
System.out.println(count);
}

上述代码基本操作执行了 2N+10 次,所以,f(n) = 2N+10,通过推导大 O 阶方法知道,时间复杂度为 O(N)

再看一个例子:计算递归版阶乘 Fac 的时间复杂度

long long Fac(size_t N)
{
    if (0 == N)
        return 1;
    return Fac(N - 1) * N;
}

 通过计算分析发现基本操作递归了N次,时间复杂度为O(N) 。使用大O渐进法表示结果如下:

递归算法:递归次数 * 每次递归调用次数

这里可以总结一个规律:当算法中规模为n时,只存在一个for循环时,一般时间复杂度都为O(N)

4.O(nlogn):线性对数阶

上面看了二分查找,是LogN的(LogN没写底数默认就是Log2N); 线性对数阶就是在LogN的基础上多了一个线性阶; 比如这么一个算法流程: 数组a和b,a的规模为n,遍历的同时对b进行二分查找,如下代码:

for(int i =0;i<n;i++)
    binary_search(b);
}

这里可以总结一个规律:当算法中规模为n时,存在一个for+while时,一般时间复杂度都为O(nlogn)

5.O(n^2):平方阶

其实就是一个普通嵌套的循环

    private static void 普通平方阶(){ 
   
        int n = 100;
        for (int i = 0; i < n; i++) { 
   //执行n次
            for (int j = 0; j < n; j++) { 
   //执行n次
                System.out.println("哈哈");
            }
        }
    }

 这种就是2层循环嵌套起来,都是执行n次,属于乘方关系,它的时间复杂度为O(n^2)

然后就是等差数列嵌套循环

    private static void 等差数列平方阶() { 
   
        int n = 100;
        for (int i = 0; i < n; i++) { 
   //执行n次
            for (int j = i; j < n; j++) { 
   //执行n - i次
                System.out.println("哈哈");
            }
        }
    }

基本式:

  • i = 0,循环执行次数是 n 次。
  • i = 1,循环执行次数是 n-1 次。
  • i = 2,循环执行次数是 n-2 次。
  • i = n-1,循环执行的次数是 1 次。

换算式:

  • result = n + (n – 1) + (n – 2) … + 1
  • 被加数递减,抽象为一个等差数列求n项和的问题,公差为1,带入公式,Sn = n(a1 + an ) ÷2
  • result = (n(n+1))/2
  • result = (n^2+n)/2
  • result = (n^2)/2 + n/2

经典的就是冒泡排序算法:

// 计算bubbleSort的时间复杂度?
void bubbleSort(int[] array) {
  for (int end = array.length; end > 0; end--) {
      boolean sorted = true;
      for (int i = 1; i < end; i++) {
          if (array[i - 1] > array[i]) {
  Swap(array, i - 1, i);
              sorted = false;
          }
      }
      if (sorted == true) {
          break;
      }
  }
}

基本操作执行最好N次,最坏执行了(N*(N+1)/2次,通过推导大O阶方法+时间复杂度一般看最坏,时间复杂度为 O(N^2) 。冒泡排序,前一个比后一个大就交换。这两个循环虽然嵌套在一起,但是里面的循环不是n,外面的循环也不是n,而是end,它是在变化的。{ N-1 N-2 N-3 ... 1 }  ,所以他是个等差数列,使用大O渐进法表示结果如下:

这里可以总结一个规律:当算法中规模为n时,存在for+for嵌套循环时,一般时间复杂度都为O(n^2)

 6.O(n^3):立方阶

三层嵌套的循环

  7.O(2^n):指数阶

2^n表示指数复杂度,随着n的增加,算法的执行时间成倍增加,它是一种爆炸式增长的情况

举个例子:

int func(int n)
{
    if(n==0) return 1;

    return func(n) + func(n-1)
}

上面的代码中,有两次递归调用,函数的执行时间就会和输入n成指数的关系。

因此,这里可以用O(2^n)表示

下面再看一个经典的算法:就是计算递归版斐波那契数 Fib 的时间复杂度

long long Fib(size_t N) {
    if(N < 3)
        return 1;
    return Fib(N-1) + Fib(N-2);
}

通过计算分析发现基本操作递归了2^N次,时间复杂度为O(2^N) 。使用大O渐进法表示结果如下:

这里可以总结一个规律:当算法中规模为n时,存在递归调用时,一般时间复杂度都为O(2^n)

8.O(n!):阶乘阶

对于阶乘关系的复杂度,最典型的例子就是旅行商问题:

        假设有一个旅行商人要拜访n+1个城市,他必须选择所要走的路径,路径的限制是每个城市只能拜访一次,而且最后要回到原来出发的城市,路径的选择目标是要求得的路径长度为所有路径之中的最小值

        这个问题最简单的方法是通过穷举法列出所有的排列组合:如果有n+1个城市,根据数学中学过的排列组合计算方法,可以算出所有组合数为n!,所以这种穷举法对应的时间复杂度也就是O(n!)

 好了,算法的时间复杂度就大致就这些了,理解了算法的时间复杂度以及大O渐进法,才能知道一个算法的优劣,后续才能设计出优美的算法~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值