数据结构与算法(一)时间复杂度与空间复杂度(上)

复杂度分析:如何分析、统计算法的执行效率和资源消耗

大o复杂度表示法

算法的执行效率,粗略的讲,就是算法代码的执行时间,但是,如何在不运行代码的情况下,用“肉眼”得到一段代码的执行时间呢?

以下代码:

func Cal(n int) int{
	var sum int =0     // unit_time 	1 行
	for i:=0;i<n;i++{  // n * unit_time 2 行
		sum += i	   // n * unit_time 3 行
	}
	return sum		   // unit_time     4 行
}

从CPU的角度,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的CPU执行个数,执行的时间都不一样,所以可以假设每行代码的执行时间都一样,为unit_time。在这个假设的基础上,这段代码的总执行时间是多少呢?

第1行与第4行执行要2个unit_time,第2行与第3行都运行了n遍,所以要2n * unit_time ,所以这段代码的执行时间为(2n + 2)unit_time ,可以看出,所有代码的执行时间T(n)与每行代码的执行次数成正比

按照这个思路,我们再看这段代码

func Cal(n int) int{
	var sum int =0			// unit_time 		 1 行
	for i:=0;i<n;i++{		// n * unit_time 	 2 行
		for j :=0;j<n;j++{	// n * n * unit_time 3 行
			sum += i * j 	// n * n * unit_time 4 行
		}
	}
	return sum				// unit_time 		 5 行
}

得到的结果是这段代码的总执行时间为:
T ( n ) = ( 2 n 2 + n + 2 ) T(n) = (2 n^2 + n + 2) T(n)=(2n2+n+2)
尽管我们不知道unit_time的具体执行时间,但是通过这两端代码执行时间的推导过程,得出,所有代码的执行时间T(n)与每行代码的执行次数成正比.

我们可以得到一个公式。注意,大O就要登场了!
T ( n ) = O ( f ( n ) ) T(n) = O(f(n)) T(n)=O(f(n))
其中T(n)表示代码的执行时间,n表示数据规模;f(n)表示每行代码执行次数的总和。因为这是一个公式,所以用f(n)表示。公式中的O,表示代码的执行时间T(n)与f(n)表达式成正比。

所以,第一个例子中的T(n) = O(2n + 2),第二个例子中的T(n) = O(2 n2 + n + 2) 这就是大O时间复杂度表示法。大O时间复杂度实际上并不表示代码真正的执行时间,而表示代码执行时间随着数据规模增长的变化趋势,所以,也叫作渐进时间复杂度,简称时间复杂度.

当n很大时,比如1000000000.而公式中的低阶、常亮、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,如果用大O表示刚才两段代码的时间复杂度,就可以记为:T(n) = O(n) ,T(n) = O(n2)

时间复杂度分析

前面介绍了大O时间复杂度的由来和表示方法。现在我们来看一下,如何分析一段代码的时间复杂度?我这有三个比较实用的方法分享给你。

  1. 只关注循环执行次数最多的一段代码

    刚才说了,大O这种复杂度表示方法只表现一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数。只需要记录一个最大阶的量级就可以了。所以我们在分析一个算法。一段代码的时间复杂度的时候,也只关注执行次数最多的那一段代码就可以了。拿前面的例子

    func Cal(n int) int{
    	var sum int =0     // unit_time 	1 行
    	for i:=0;i<n;i++{  // n * unit_time 2 行
    		sum += i	   // n * unit_time 3 行
    	}
    	return sum		   // unit_time     4 行
    }
    

    整段代码的1、 4 行都是常量级的执行次数,与n的大小无关,所以对复杂度没有影响。执行次数最多的2、 3行代码都被执行了n次,所以时间复杂度为O(2n),再忽略掉系数,这段代码总的时间复杂度就是O(n)的了。

  2. 加法法则:总的复杂度等于量级最大的那段代码的复杂度

    试着分析这段代码,再看分析的思路是否与我一样

    func Cal(n int) int{
    	var sum_1 int =0
    	var sum_2 int =0
    	var sum_3 int =0
    	for i:=0;i<100;i++{ 	// 1段
    		sum_1 += i
    	}
    	for j:=0;j<n;j++{		// 2段
    		sum_2 += j
    	}
    	for i:=0;i<n;i++{		// 3段
    		for j:=0;j<n;j++{
    			sum_3 += i * j
    		}
    	}
    	return  sum_1 + sum_2 + sum_3
    }
    

    这段代码分成了3个部分,分别求出了sum_1、 sum_2 、 sum_3。我们可以先分别求出每一段的时间复杂度,再取量级最大的作为代码的整体复杂度.

    第一段代码的时间复杂度是多少呢?这段代码执行了100,次,与n无关,是一个常量的执行时间。

    这里要强调一下,即便这段代码循环了1000000次,只要是一个已知数,与n无关,照样也是常量级别时间。当n无限大的时候,就可以忽略。尽管对代码的执行有很大影响,但是回到时间复杂度的概念来说,它表示一个算法执行效率与数据增长规模的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉。因为它本身对增长趋势并没有影响。

    那第二段与第三段的时间复杂度很容易看出,是O(n)和O(n2)。

    综合这三段代码的时间复杂度,我们取其中最大的量级。所以,整段代码的时间复杂度就是O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。抽象成公式
    如 果 T 1 ( n ) = O ( f ( n ) ) , T 2 ( n ) = O ( g ( n ) ) , 那 么 T ( n ) = T 1 ( n ) + T 2 ( n ) = m a x ( O ( f ( n ) ) , O ( g ( n ) ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) 如果 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)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n)))

  3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

刚才讲了一个复杂度分析中的加法法则,这还有一个乘法法则。类比一下:
如 果 T 1 ( n ) = O ( f ( n ) ) , T 2 ( n ) = O ( g ( n ) ) , 那 么 T ( n ) = T 1 ( n ) × T 2 ( n ) = O ( f ( n ) × O ( g ( n ) ) = O ( 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)) 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))
也就是说,假设 T1(n)=O(n),T2(n)=O(n^2),则T(n)= T1(n) × T2(n) = O(n3)

几种常见的时间复杂度案例分析

虽然代码千差万别,但是常见的复杂度量级并不多。稍微总结了一下,这些复杂量几乎涵盖了你今后可能接触的所有代码的复杂量级。

复杂量级时间复杂度
常量阶O(1)
对数阶O(logn)
线性阶O(n)
线性对数阶O(nlogn)
平方阶O(n2),O(n3),…O(nk)
阶乘阶O(n!)
指数阶O(2n)

对于刚罗列的复杂度量级,我们可以粗略的分成两类,多项式量级和非多项式量级。其中非多项式量级只有两个:O(2n)和O(n!)。

当数据规模n越来越大时,多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以非多项式时间复杂度的算法是非常低效的算法。我们主要来看几种常见的多项式时间复杂度

  1. O(1)

首先必须明确一个概念,O(1)只是常量级时间的一种表示方法,并不是指只执行了一行代码,比如这段代码,即便有三行,它的时间复杂度也是O(1),而不是O(3)。

	var sum_1 int =0
	var sum_2 int =0
	var sum_3 int =0

只要代码的执行时间不随n的增大而增长,这样代码的时间复杂度我们都记作O(1)。或者说,一般情况下,只要算法中不存在循环,递归语句,即使有成千上万行的代码,其时间复杂度也是O(1)。

  1. O(logn),O(nlogn)

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度,通过例子说明一下:

func Cal(n int) int{
	var sum int = 0
	for i:=1;i<n;i=2*i{  // 最多
		sum += 1		 // 最多
	}
	return  sum
}

根据前面的时间复杂度分析方法,判断出第2,3行的代码执行次数最多,假设执行次数为x,结果就是2x=n,求得

x=log2n。时间复杂度就是O(log2n)

现在,稍微改一下代码:

func Cal(n int) int{
	var sum int = 0
	for i:=1;i<n;i=3*i{  // 最多
		sum += 1		 // 最多
	}
	return  sum
}

按照刚才的思路,求出复杂度为O(log3n)。

实际上,不管是以2为底、以3为底、还是以10,我们可以把所有对数阶的时间复杂度都记为O(logn)。为什么呢?

我们知道,对数之间是可以互相转换的log3n就等于log32 * log2n ,所以O(log3n) =O(C * log2n )其中C是一个常量,基于前面的理论,在大O时间复杂度计算时,系数可以忽略,,因此,在对数阶时间复杂度的表示方法里,我们忽略对数的"底",统一表示为O(logn)。

如果了理解了前面的O(logn),那么O(nlogn)就很容易理解了。如果一段代码的时间复杂度是O(logn),我们循环执行n遍,时间复杂度就是O(nlogn)。而且O(nlogn)也是一种非常常见的算法时间复杂度。比如,归并排序,快速排序的时间复杂度都是O(nlogn).

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

我们再看一种跟前面都不一样的时间复杂度,代码的复杂度由两个数据的规模来决定。

func Cal(n,m int) int{
	var sum_1 int = 0
	var sum_2 int = 0
	for i:=0;i<n;i++{
		sum_1 += i
	}
	for i:=0;i<m;i++{
		sum_2 += i
	}
	return sum_1 + sum_2
}

从代码的角度看,m、n表示两个数据规模。我们无法评估m,n谁的量级大,所以我们在表示复杂度时不能简单的使用加法法则,省去其中一个。所以上面代码的时间复杂度为O(m+n)

空间复杂度分析

前面,我们花了很长时间去讲大O表示时间复杂度分析,理解前面讲的内容,空间复杂度分析方法学起来就特别简单了。

空间复杂度全称就是渐进式空间复杂度,表示算法的存储空间与数据规模间的增长关系。

举例说明:

func Cal(n int) *[]int{
	var n_list []int
	for i:=0;i<n;i++{
		n_list = append(n_list,i)
	}

	return &n_list
}

跟时间复杂度一样,这里我们使用了一个切片,它最后存储大小与n有关,所以代码的空间复杂度为O(n),我们常见的空间复杂度就是O(1),O(n),O(n2),像O(logn),O(nlogn)这样的对数阶复杂度平时都用不到。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值