数据结构与算法(一)——复杂度分析(上)

数据结构与算法(一)—— 复杂度分析(上)

基础知识就像是一座大楼的地基,只有打好基础,才能造成万丈高楼。数据结构与算法是一个程序员的内功,只有基础足够扎实,才能有效提高自己的技术能力,写出更高效、扩展性更好的优秀代码。写这个系列记录一下自己学习的历程,希望自己能不断学习、总结和积累数据结构与算法的知识。这个系列的总结主要参考王争老师的讲解,以及一些数据结构和算法方面的书籍。
#什么是数据结构?算法又是什么?

  • 从广义上说,数据结构是计算机存储、组织数据的方式,是指相互之间存在一种或多种特定关系的数据元素的集合。算法是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。简单来说,数据结构就是指一组数据的存储结构,算法则是操作数据的一组方法。

    • 以图书馆存放书籍为例,一般来说,为了方便查找和管理,图书管理员一般会将书籍分门别类,按一定的规律编号进行摆放,也就是书籍的“存储”,而按照一定规律编号,就是书籍这种“数据”的存储结构。
    • 而当我们需要对这些“数据”进行某种操作,比如查找一本书时,应该如何操作呢?有很多种方法,可以一本一本地找,也可以根据书籍类别的编号找到对应的书架,再依次查询。笼统地说,这些查找办法都是算法。
  • 从狭义上讲,就是指某些常见的著名的数据结构和算法,比如队列、堆、栈、排序、二分查找等等,这篇系列也是主要用于记录学习这些数据结构与算法的笔记和总结,主要是以Java语言描述为主。

  • 数据结构和算法是相辅相成的,数据结构是为算法服务的,算法要作用在特定的数据结构之上。
    #复杂度分析

复杂度分析是数据结构与算法中最重要的概念,几乎占了这门课的半壁江山,是数据结构和算法的精髓

一、什么是复杂度分析

数据结构和算法解决的是如何更快、更省地存储和处理数据的问题,即如何让代码运行更快、更节省存储空间,这就需要一个考量效率和资源消耗来衡量所编写代码的执行效率的方法,这就是复杂度分析方法,包括时间复杂度分析和空间复杂度分析。

二、为什么需要复杂度分析

在实际应用中,或许通过统计、监控所有代码的执行过程,就能得到更准确的算法执行的时间和占用的内存大小,这种评估算法执行效率的方法,很多数据结构和算法书籍称为“事后统计法”。那么既然有更准确的统计方法为什么还需要做复杂度分析呢?因为这种统计方法存在非常大的局限性。

  1. 测试结果非常依赖测试环境 测试环境硬件的不同会对测试结果有很大的影响。
  2. 测试结果受数据的性质影响很大 比如升序排序算法,不同有序度的待排序数据的执行时间会有很大的区别。如果数据已经是有序的,那么排序算法不需要做任何操作,执行时间就会非常短,而如果数据是降序的或者是杂乱无章的,那么排序算法就要执行多次,执行时间自然也会边长。
  3. 测试结果受数据规模的影响很大 如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。

三、大O复杂度表示法

算法的执行效率,粗略地讲,就是算法代码执行的时间,那么如何在不运行代码的情况下“看出”代码的执行时间呢?
首先,先来看一段简单的代码,求1,2,3…n的累加和。怎么估算这段代码的执行时间呢?

第一个例子

	1 public int cum(int n) {
	2		int sum = 0;
	3		int i = 1;
	4		for (; i <= n; i++) {
	5    		sum = sum + i;
	6		}
	7		return sum;
	8 }

虽然从CPU的角度来看,每行代码所消耗的内存大小,执行时间都不一样,但是由于是粗略计算,所以首先假设每行代码执行的时间都一样,为utime,然后再进行分析。

先分析第2、3行,显然分别都需要1个utime的执行时间。
再分析第4、5行,这是一个循环,循环次数为n,所以都运行了n遍,这两行的执行时间是2n * utime,所以这段代码总的执行时间就是(2n+2)* utime。记所有代码的执行时间为T(n),那么T(n)与每行代码的执行次数成正比。

再看一段代码,比上一段代码稍微复杂了一点点。

第二个例子

  1  public int cum(int n) {
  2		int sum = 0;
  3		int i = 1;
  4		int j = 1;
  5		for (; i <= n; i++) {
  6			j = 1;
  7			for (; j <= n; j++) {
  8				sum = sum + i * j;
  9			}
  10		}
  11		return sum;
  12 }

同理,第2、3、4行执行时间都是1个utime时间,第5、6行代码循环次数为n,所以这两行代码的执行时间为2n * utime。

对于第7、8行代码,由于是嵌套循环,所以每执行1次第5行和第6行,就执行n次第7行和第8行,所以第7、8行代码循环执行了n2遍,,所以需要2n2 * utime的执行时间,所以所有代码总的执行时间为T(n)=(2n2+2n+3)* utime。

上面得出了两段代码的总的执行时间,由这两个推导过程可以看出,所有代码的执行时间T(n)与每行代码的执行次数n成正比。
用公式表示为:

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

其中,T(n)表示所有代码的执行时间,n表示数据规模的大小,f(n)表示所有代码的执行次数,O表示T(n) 和f(n)成正比。

那么,如果使用上面这条格式分别表示前两个例子的总运行时间T(n),那么第一个例子就是

  • T(n) = O(2n+2)

第二个例子就是

  • T(n)=O(2n2+2n+3)

这就是大O时间复杂度表示法,表示数据规模n和运行时间T(n)具有某种函数关系。要注意的是,大O时间复杂度并不表示代码真正执行的具体时间,,而是表示代码执行时间随数据规模增长的变化趋势,所以
也叫作渐进时间复杂度。

先从上面两个例子分析,当n很大时,比如1000、10000甚至无穷大时,第一个例子中的常量2和系数2都不影响增长趋势,都可以忽略不计;而对于第二个例子,除了常量和系数外,低阶函数2n对增长趋势的影响也不大,所以也可以忽略,只需要记录增长趋势最明显的n2就可以。

在这个基础上,如果再用大O表示法表示前面两个例子的代码的时间复杂度,结果就是

第一个例子:T(n) = O(n);第二个例子:T(n)=O(n2)。
也就是说,在实际使用中,常常将公式T(n)中的低阶、常量和系数都忽略,只记录一个最大的量级

四、时间复杂度分析方法

要使用大O表示法表示时间复杂度,还是要首先进行时间复杂度的分析。

(一)单段代码只关注循环执行次数最多的一段代码

由于大O表示法只是表示运行时间随数据规模增长的变化趋势,也只是记录公式中的最大阶的量级,所以在分析一段代码的时间复杂度时,就只需要关注循环执行次数最多的那一段代码就可以了。这段代码执行次数的n的量级,也是整段代码要分析的时间复杂度。

分析前面的第一个例子:

1 public int cum(int n) {
2 	int sum = 0;
3	int i = 1;
4	for (; i <= n; i++) {
5	    sum = sum + i;
6	}
7	return sum;
8 }
  1. 第2行和第3行代码都是方法被调用多少次,就执行多少次,单从这段代码来看,就是被执行一次,也就是说2、3行都是常量级的执行时间,与n无关,可忽略。
  2. 再看第4、5行的for循环代码,这是循环执行次数最多的,所以只需要重点关注这块代码,而这块代码被执行了n次,所以总的时间复杂度就是O(n)。

(二)多段代码的加法法则

在进行多段代码的分析时,总复杂度等于量级最大的那段代码的复杂度
先来看代码

1 public int cal(int n){
2	 int sum_1 = 0;
3	 for(int p = 1; p < 100;p++){
4		sum_1 = sum_1 + p;
5	 }
6	
7	 int sum_2 = 0;
8	 for(int q = 1; q < n;q++){
9		sum_2 = sum_2 + q;
10	 }
11	
12	 int sum_3 = 0;
13	 int j = 1;
14	 for(int i = 1; i < n;i++){
15		for(j =1;j<= n; j++){
16			sum_3 = sum_3 + i * j;
17		}
18	 }
19	
20	 return sum_1 + sum_2 + sum_3;
21  }
  1. 先看第一个for循环的时间复杂度,这段代码循环执行了100次,也就是一个常量的执行时间,与n无关。要注意的是,由于时间复杂度表示的是代码执行时间与数据规模增长的变化趋势,所以即使这段代码循环执行了10000次甚至100000次,只要这个次数是一个固定的常量,与n无关,那么这段代码的执行时间就是常量级的,即O(1)。
  2. 再看第二个for循环和第三个for循环,跟上面分析的一样,它们的时间复杂度分别O(n)和O(n2)。
  3. 那么,在分析cal函数这段函数的时间复杂度时,就取其中最大的量级,即取O(n2)。也就是说这整段代码的总的时间复杂度就等于量级最大的那段代码的时间复杂度。
  4. 归纳如下:
    • 如果T1(n) = O(f(n)),T2(n)= O(g(n))
    • 那么T(n) = T1(n) + T2(n) = max{O(f(n)),O(g(n))} = O{max(f(n),g(n))}
      ###(三)嵌套代码的乘法法则
      嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。
      用公式表示就是:
  • 如果T1(n) = O(f(n)),T2(n)= O(g(n));
  • 那么T(n) = T1(n) * T2(n) = O(f(n)) * O(g(n)) = O(f(n) * g(n))

将上面加法法则中的代码做些修改,在cal()函数中嵌套另一个sum()函数:

1 public int cal(int n){
2 	 int sum_1 = 0;
3	 for(int p = 1; p < n;p++){
4		sum_1 = sum_1 + sum(i);
5	 }
6 }

7 public int sum(int n){
8	 int sum_2 = 0;
9	 for(int i=1; i<n; i++){
10		sum_2 = sum_2 + i;
11	 }
12	 return sum_2;
13 }

先看cal函数,如果sum()函数只是一个普通的参数,那么3~5行代码就相当与前面加法法则分析的第二段代码,复杂度为T1(n) = O(n)。但是由于sum()函数本身也具有一个循环操作,时间复杂度为T2(n) = O(n),所以根据公式,整个cal()函数的时间复杂度就是:

T(n) = T1(n) * T2(n) = O(n * n) = O(n2)

五、几种常见时间复杂度分析

在刷题或者实际分析中,常见的复杂度量级并不多,将其按数量级递增的顺序列出如下

  • 常量阶 O(1)
  • 对数阶 O(log n)
  • 线性阶 O(n)
  • 线性对数阶 O(nlog n)
  • 幂次方阶
    • 平方阶 O(n2)
    • 立方阶 O(n3)
    • k次方阶 O(nk)
  • 指数阶 O(2n)
  • 阶乘阶 O(n!)

上面的复杂度量级,可以分为两类,多项式量级和非多项式,而非多项式量级只有上面加粗的两个:指数阶 O(2n)阶乘阶 O(n!)

(一)非多项式量级

如果数据规模n越来越大,非多项式量级算法的执行时间会急剧增加,实际运行的时间也会无限增长。所以,非多项式时间复杂度算法是非常低效的算法,在实际应用中应该尽量避免使用。

(二)多项式量级

  1. O(1)

O(1)是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码,前面也说过即使是一个循环执行100次,1000次的代码,只要它的执行时间与n无关,那它的时间复杂度就是O(1)
####2. O(log n)、O(nlog n)
对数阶时间复杂度非常常见,也最难分析
先看代码

	1 int i = 1;
	2 while(i <= n){
	3  	i = i * 2;
	4 }

直接看while循环里的代码,因为它是被执行次数最多的,它的时间复杂度就是整段代码的时间复杂度。

  • 变量 i 从1开始取,每循环一次就乘以2;
  • 当大于n时,循环结束,执行完成。
  • 也就是说,i 的值是2的倍数,每执行一次就增加一倍,然后再与n比较,所以i的取值就是一个等比数列
    • 20,21,22,… ,2x = n
  • 此时代码的运算次数为x,通过求解2x = n就可以求出x,即x = log2n,所以这段代码的时间复杂度就是O(log2n)。
    同理,当把循环中的2换成3即 i = i * 3时,这段代码的时间复杂度就是O(log3n)。

由于对数之间可以互相转换,比如log3 n = log3 2 * log2 n,由于log3 2是一个常量,所以使用大O表示法时就可以忽略这个常量系数,即O(log2 n)= O(log3 n)。同理,任意一个对数都可以转换成一个常数乘以一个以其它数字为底的对数,所以在对数阶的时间复杂度的表示方法中,就可以忽略对数的底数,统一表示为O(log n)。

对于O(nlog n),就是通过前面的乘法法则得到的,比如一段代码的时间复杂度是O(log n),将其循环执行n遍,总的时间复杂度就是O(nlog n)了。
O(nlog n)是一种非常常见的算法时间复杂度,比如 归并排序、快速排序的时间复杂度都是O(nlog n)。

  1. O(m + n) 、O(m * n)

要注意的是,这里的加法跟前面的加法法则有些不一样,这里的代码的复杂度是由两个数据的规模来决定的,先看代码

1 public int cal(int n){
2  	int sum_1 = 0;
3  	for(int p = 1; p < m;p++){
4  		sum_1 = sum_1 + p;
5	    }
6	
7  	int sum_2 = 0;
8  	for(int q = 1; q < n;q++){
9  		sum_2 = sum_2 + q;
10	}
11	return sum_1 + sum_2 ;
12 }

可以看出,这两段代码的数据规模或循环执行次数分别是m和n。由于无法判断m和n的量级大小,所以不能直接简单地使用加法法则,即不能省略掉其中一个,只能将上面代码的时间复杂度记为O(m + n)。用公式表示修改后的加法法则:
T(n) = T1(m) + T2(n) = O(f(m) + g(n))。

其实可以发现,前面提到的加法法则其实是修改后的加法法则的一种具体实现,是m和n确定之后的计算结果,当确定m和n之间的量级大小之后,就可以之间根据前面所说的省略低阶、常量或系数,取量级最大的那个即可,所以T(n) = T1(m) + T2(n) = O(f(m) + g(n))才是加法法则的一般形式

对于乘法法则,即使m和n的量级大小未定,乘法法则也依旧有效:
T(n) = T1(m) * T2(n) = O(f(m) * g(n))

六、空间复杂度分析

类似时间复杂度,空间复杂度全称就是渐进空间复杂度,表示算法或代码的存储空间与数据规模之间的增长关系。

用定义一个数组来举例子

1  public void print(int n){
2	 int j = 2;
3	 int[] a = new int[n];
4	 for(i = 0; i < n; i++){
5		a[i] = i * j;
6	 }
7	
8	 for (i = n-1; i >= 0; i--){
9		system.out.println(a[i]);
10	 }
11 }
  • 跟分析时间复杂度一样,在第二行申请了一个空间存储变量i,但是它是常量阶的,可以忽略。
  • 在第三行申请了一个大小为n的int类型数组
  • 除此之外,其它的执行代码并不会占用多少空间,所以整段代码的空间复杂度就是O(n)。

空间复杂度分析比时间复杂度分析要简单,常见的空间复杂度也只有O(1)、O(n)、O(n2)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值