vs2019怎么统计程序有多少行代码_代码复杂度分析

16365affd0b9d703dcdf11e48f517e36.png
导读:从代码复杂度分析的重要性到大 O 表示法介绍,讲解常见的 O(1)、O(log(n)) 、O(n) 、(n²) 、 (2^n) 复杂度,最好、最坏、平均、均摊时间复杂度。

​我们都知道,数据结构与算法是计算机编程重要的基石,好的数据结构与算法,可以让代码执行效率更高、更省空间。那么我们应该如何评判一个算法的好坏呢?通过代码「执行效率」判断,越好的算法,执行效率越高。那又该怎么评判自己编写的算法代码执行效率如何呢?这就是我们今天主要学习的内容了,用来判断代码执行效率的「时间、空间复杂度分析」。

在正式介绍代码复杂度分析之前,我们需要先明白,为什么算法执行效率如此重要。这里我用「斐波那契数列」的求解,来演示不同算法复杂度的巨大差距。

斐波那契数列(Fibonacci sequence):又称黄金分割数列、因数学家莱昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称为「兔子数列」,指的是这样一个数列:0、1、1、2、3、5、8、13、21、34、……在数学上,斐波那契数列以如下被以递推的方法定义:F(1)=1,F(2)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 3,n ∈ N*)

我们将上面「斐波那契数列」的定义,转换成下列的 Java 代码。

public int fib(int n){
    if(n == 0 || n == 1)  return n;
    return fib(n-1) + fib(n-2);
}

上面的代码,我们使用了递归的方式来求解「斐波那契数列」,这也是求解「斐波那契数列」最简单的方式。如果我把上面使用递归求解「斐波那契数列」的方式分解一下,那么我们会得到下面这张图。

549756633eac59dcb0a5cb0b0f623206.png
图片来源于网络,侵权删

我们可以发现,随着 n 的增大,计算的次数会按照 2 的指数级增加,这里会重复计算大量的表达式。假设这里的 n 等于 50 ,2^50 等于 1125899906842624 。虽然用递归的方式求解「斐波那契数列」的代码很简洁,但是显然这不是一个好的求解方式。下面我们对这个递归方式求解「斐波那契数列」方式做一个改进,代码如下。

Map<Integer,Integer> map = new HashMap<>();
​
public int fib(int n){
  if(n == 0 || n == 1)  return n;
  if (map.containsKey(n)) return map.get(n);
  int sum = fib(n-1) + fib(n-2);
  map.put(n,sum);
  return sum;
}

上面的代码通过加入 HashMap 缓存之前运算过的表达式结果,使得每次表达式不会重复计算,而 HashMap 获取元素的时间复杂度是「常数」(不会随着 n 的增大而增加运算时间)级别的(想了解 HashMap 时间复杂度为什么是常数级别,可以参考我之前的文章「Java容器框架学习整理」),通过 HashMap 的缓存,使得整个「斐波那契数列」的求解变成了线性增长,即 n 为多少,代码的执行次数就是多少。假设这里 n 为 50 ,则这段代码只用执行 50 次便可以得出结果,对比之前的递归请求 n 为 50 ,需要执行 1125899906842624 次之间巨大的差距,效率大大的提升,通过这个对比,大家应该明白了分析不同代码复杂度的重要性了。

这里两个递归方式,空间的使用都会随着 n 的增大,使得空间的使用随着 n 线性级别的增大,使用遍历的方式求解「斐波那契数列」,时间也是线性级别的,空间却可以降低到常数级别,大家感兴趣可以自己下去看一下,如何用遍历的方式求解「斐波那契数列」。

通过上面「斐波那契数列」的求解,不同代码之间复杂度的巨大差异,我们知道了代码复杂度分析重要性。代码复杂度分析我们主要分为时间复杂度和空间复杂度,下面我们一起看一下什么是时间复杂度,什么是空间复杂度。

时间复杂度:时间复杂度全称是渐进式时间复杂度(asymptotic time complexity),用来表示代码执行随着数据规模增长,当前算法所消耗的时间变化趋势。
空间复杂度:空间复杂度的全称是渐进式空间复杂度(asymptotic space complexity),用来表示代码执行随着数据规模增长,当前算法所消耗的内存空间变化趋势。

通过时间、空间复杂度的定义,我们可以发现「代码执行随着数据规模增长」是关键,下面我们举一个累加的代码来说明。

public int accumulation(int n){
    int sum = 0;
    for(int i = 1; i <= n; i++){
        sum += i;
    }
    return sum;
}

这段代码非常简单,就是求 1 到 n 累加的和。从 CPU 的角度看,这段代码中的每一行,CPU 都是执行着:读数据 - 运算 - 写数据 的操作。这里我们假设这段代码中每行代码执行时间相同(实际每行代码执行时间可能并不相同,但是我们这里只是是做一个粗略估算,可以忽略实际执行时间差距),每行代码执行需要 1 毫秒,我们分析一下这段代码执行需要多少时间。

第 2 行代码和第 6 行代码只执行一次,所以执行第 2 行代码和第 6 行代码总共花费 2 毫秒。第 3 行代码和第 4 行代码执行 n 次,所以执行第 3 行代码和第 4 行代码总共花费 2n 毫秒。这段代码总共执行时间是 2+2n 毫秒。

通过这段代码的执行时间,我们可以发现,代码执行时间与每行代码执行次数成正比。假设这里我们用时间的单词 Time 的首字母 T 表示所有代码执行的时间,用 f 表示每行代码执行的次数总和。根据这个规律,我们可以得出下面这个公式。

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

上述公式中 n 表示数据规模,T(n) 表示代码执行的时间,f(n) 表示每行代码执行的次数总和。公式中的大 O 表示代码执行时间 T(n) 与 f(n) 成正比,这也是目前算法中最主流的表示方式「大 O 表示法」。

大 O 表示法:算法的时间复杂度通常用大 O 符号表述,定义为 T[n] = O(f(n))。称函数 T(n) 以 f(n) 为界或者称 T(n) 受限于 f(n)。 如果一个问题的规模是 n,解这一问题的某一算法所需要的时间为 T(n)。T(n) 称为这一算法的「时间复杂度」。当输入量 n 逐渐加大时,时间复杂度的极限情形称为算法的“渐近时间复杂度”。

我们了解了大 O 表示法的定义后,我们来想一下为什么大 O 表示法会成为代码执行效率主流的表示方式,除了大 O 表示法,我们还有什么方式知道代码的执行效率吗?「事后统计法」也可以体现算法的执行效率,但是目前采用的「事后统计法」的并不多,那么我们一起看看什么是「事后统计法」以及它的不足。

事后统计法:通过统计、监控实际代码的运行,获取算法执行的时间和占用的内存大小。

使用「事后统计法」我们确实是可以通过算法执行的时间和占用的内存大小,来知道代码的执行效率。但是「事后统计法」的缺点也非常明显。「事后统计法」最大的不足,是过于依赖硬件环境,我们都知道不同的 CPU 、内存 、硬盘的性能相差非常大,所以事后统计法不能准确的体现出代码的执行效率。还有「事后统计法」需要代码运行后,才可以知道代码的执行效率,等到代码运行完,程序员才知道问题在哪里,再去修改代码,具有一定的滞后性。

大 O 表示法可以很好的解决「事后统计法」的两个不足,相对于「事后统计法」我们也可以称大 O 表示法为「事前统计法」,因为大 O 表示法能在代码运行前就知道代码的执行效率,而且最重要的是它不依赖于硬件环境,我们就能分析自己代码的复杂度。

了解完大 O 表示法的含义,我们一起看看大 O 表示法计算复杂度应该遵循的原则。

复杂度与循环执行次数最多的一段代码有关,与公式中的常量、低阶、系数无关。

因为前面说过,大 O 表示法表示的是一种随着数据规模增长的变化趋势,我们用之前求累加的代码来说明这条原则。

public int accumulation(int n){
    int sum = 0;
    for(int i = 1; i <= n; i++){
        sum += i;
    }
    return sum;
}

我们之前分析过这段代码执行时间为 2 + 2n ,式中的 2 是常量,2n 中 2 的是系数,它们都是确定了的,并不会随着数据量 n 的增大而变化,它们不能左右变化趋势,所以复杂度与它们无关,可以忽略,这段代码最后的时间复杂度就是 O(n) 。

根据上面的例子,这里我们可以给出一些经验性的结论

  • 一个顺序结构的代码,时间复杂度是 O(1)
  • 一个简单的 for 循环,时间复杂度是 O(n)
  • 两个顺序执行的 for 循环,时间复杂度是 O(n)+O(n)=O(2n),忽略系数,最后时间复杂度也是 O(n)
  • 两个嵌套 for 循环,时间复杂度是 O(n²)

然后我们一起看一下几种常见的复杂度分析。

  • 常数阶 O(1):O(1) 是常数级复杂度的表示方式,并不是指只有一行代码执行的空间、时间复杂度。只要代码的执行时间不随着数据量 n 的增大而增加,这样的代码的复杂度都记做 O(1)。或者说,一般情况下,只要算法中不存在循环、递归语句,即使有成千上万行代码,复杂度也是 O(1),比如下面的代码,虽然有三行,但是复杂度依然是 O(1)。
int i = 5;
int j = 6;
int k = 7;
  • 线性阶 O(n):复杂度随着数据规模 n 增大而增加,比如下面这个求解「斐波那契数列」,空间、时间复杂度都随着数据规模 n 增大而增加。
Map<Integer,Integer> map = new HashMap<>();
​
public int fib(int n){
  if(n == 0 || n == 1)  return n;
  if (map.containsKey(n)) return map.get(n);
  int sum = fib(n-1) + fib(n-2);
  map.put(n,sum);
  return sum;
}
  • 对数阶 O(log(n)):复杂度随着数据规模增大,复杂度以对数规模增加。我们直接看下面的代码(时间复杂度以对数增加,空间复杂度是 O(1))
while(n > 0){
    n = n / 2;
}
  • 平方阶(n²):常见于嵌套循环,直接看代码。
for(int i = 0; i < n ; i++){
  for(int j = 0; j < n ; j++){
  }
}
  • 指数阶(2^n):指数阶的复杂度是很恐怖的,这是我们应该尽量避免的。比如我们之前用递归求解「斐波那契数列」,n 为 50 时间复杂度就达到了恐怖的 1125899906842624。
public int fib(int n){
    if(n == 0 || n == 1)  return n;
    return fib(n-1) + fib(n-2);
}

上述的几种常见的复杂度从好到差排名为

O(1)、O(log(n)) 、O(n) 、(n²) 、 (2^n)

复杂度分析还分为最好、最坏、平均、均摊复杂度,下面我简单介绍一下这四种复杂度。

  • 最好复杂度:最理想情况下,执行代码的复杂度。比如 HashMap 获取元素,理想情况下 key 的 hash 值都不冲突,那么 HashMap 获取元素的时间复杂度就是 O(1);
  • 最坏复杂度:在最糟糕的情况下,执行代码的复杂度。这里我依然以 HashMap 获取元素作为示例,最糟糕的情况下,所有 key 的 hash 值都冲突,JDK 1.8 之前 HashMap 退化成单链表,那么 HashMap 获取元素的时间复杂度就是 O(n);JDK 1.8 之后 HashMap 退化成红黑树,那么 HashMap 获取元素的时间复杂度就是 O(log (n));
  • 平均复杂度:首先我们需要明确知道,这里的平均是加权平均,这里我还是用 HashMap 举例,假设元素在数组上的概率是二分之一,在单链表和红黑树上的概率都是四分之一,那么 HashMap 获取元素的平均复杂度就应该为 (1/2 O(1) + 1/4 O(n) + 1/4 O(log (n)))/4;
  • 均摊复杂度:将复杂度多的操作均摊到复杂度低的操作上。这里还是以 HashMap 举例,添加元素时,HashMap 到达一定情况下需要进行扩容,扩容时的复杂度比正常添加元素的复杂度高,但是扩容并不经常发生,将此次添加元素扩容的复杂度均摊到正常添加元素的复杂度,这就是均摊复杂度。一般均摊复杂度等于最好复杂度,比如这里 HashMap 的均摊复杂度为 O(1);
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值