1.2 数据结构的时间与空间复杂度
在算法和数据结构的分析中,时间复杂度和空间复杂度是两个非常重要的指标,它们分别衡量了算法运行时间和所需存储空间的增长速度。
理解这些概念有助于我们评估和比较不同数据结构在不同场景下的性能表现。
本节我们将对这两者的基本概念进行讲解,同时针对他们的计算方法进行解释与分析。
本节代码存放目录为 lesson1
什么是时间复杂度?
时间复杂度是一个函数,用来 描述算法执行过程中,随着输入规模的增大,运行时间的增长趋势。 时间复杂度通常用大O
符号来表示,表示最坏情况下的性能表现。
这听起来有一些抽象,我们以一个实例来进行说明,我们有如下所示的一个一维数组:
[1, 2, 3]
现在我使用循环遍历的方式来查找指定的元素1
,我们在第一次循环就可以找到1
。现在我们改变一下数组:
[2, 3, 1]
现在我们再来查找元素1
,我们需要遍历3
次才可以找到。由于元素具体在哪个位置我们是不知道的,所以最坏的情况下我们需要遍历3
次才可以找到。
接下来我们增加数组的长度:
[1, 2, 3, 4]
也可能为
[2, 3, 4, 1]
我们再次遍历查找元素1
,需要4
次才可以找到,也就是最坏的情况下我们需要遍历4
次才可以找到。
从上面的计算我们可以看出,当数组长度为3
时,我们最多需要3
次遍历找到;当数组长度为4
时,我们最多需要4
次遍历找到。
由此可推断,当数组长度为n
时,我们最多就需要n
次遍历才可以找到,如果n
是1000
,那么就是最多1000
次,如果n
是一万,那么最多就是一万次找到。
综上所述,我们就可以得出:在数组中使用遍历的方式查找元素,时间复杂度为O(n)
为了便于理解,我们再以一个例子做说明。我们定义一个数组,这个数组的数据都是有序的,并且是从1
开始的连续数字。
[1, 2, 3, 4]
现在我们需要查找元素1
,由于这个数组的元素都是有序的,并且从1
开始,那么我直接arr[1-1]
,也就是arr[0]
就可以直接获取到元素1
。
同样的,我们通过arr[1]
就可以获取到元素2
,通过arr[2]
就可以获取到元素3
,那么当有n
个元素时,我们通过arr[n-1]
就可以获取到元素n
。
从上面的例子我们可以得出,在这种情况下不管数组长度是多少,我们都只需要一次就可以直接获取到元素。那么这种情况下的时间复杂度就是:O[1]
。
其实在数组中,不论数组是否有序,只要我们知道元素的索引位置,访问任何一个元素的时间复杂度都是 O(1)
。这是因为数组是顺序存储的,内存地址连续,因此我们可以直接通过索引计算内存地址,快速获取元素。
通过上面两个例子我们应该可以大概知道时间复杂度的概念及计算方法了,我们可以简单理解为:时间就是次数或者步数,通过几次/几步可以查找到结果,时间复杂度就是最坏需要几次/几步。
上面我们讲的例子是比较简单的,直接通过n
、1
就可以表示,在实际应用中可能还有一些更复杂的计算,比如需要的次数可能是log n
、n^2
等。
总之,我们可以将时间复杂度理解为一种用来衡量算法效率的工具,描述随着输入规模增大,算法需要执行的操作次数(或步骤数)是如何变化的。
O
只是一个符号,由数学中的渐进增长的上界
引申出来,可以读作Order
,括号(n)
、(logn)
里面的内容就是步数的计算函数。
另外我们需要注意的一点就是:这里我们为了方便理解,所以使用了最坏需要几次
的描述,事实上我们也可以根据实际需求,将时间复杂度定义为平均需要几次
、最快需要几次
,一般来说我们在实际开发时都会以这三个维度同时进行评估。
什么是空间复杂度?
空间复杂度是指算法在运行时所需内存空间的增长情况 ,它反映了随着输入规模(n)
的增加,算法所需的内存空间如何变化。
理解了时间复杂度,我们理解空间复杂度就会简单的多,时间复杂度指的是步数如何变化,空间复杂度指的就是计算时内存如何变化。
我们同样以数组进行说明,定义数组如下:
[1, 2, 3, 4]
我们通过索引来查找元素1
,通过arr[0]
直接就可以访问到,那么此时产生的额外内存空间就是O(1)
这一个索引变量。
我们以代码来看一下:
go
arr1 := [4]int{1, 2, 3, 4}
fmt.Println(arr1[0])
在上面的代码中,我们定义了数组arr1
,通过索引arr[0]
直接访问了数组元素。
那么此时的内存占用是怎么样的呢?首先arr1
长度为4
,占用为O(4)
,另外通过索引访问产生了O(1)
。
当数组长度为n
时,数组本身占用就是O(n)
,索引访问为O(1)
,那么按照常理来说,现在的空间复杂度就是O(n+1)
。
但实际上此时我们只会将空间复杂度算作O(n)
,因为O(1)
是一个固定的,不管n
多大,访问时都是O(1)
,所以这一个计算就是没有必要的,我们只需要计算O(n)
即可。
所以对于数组来说,如果算上完整的数组定义、元素查找,那么他的空间复杂度就是O(n)
,如果仅仅只是计算访问时候的复杂度,那么就一直都是常数复杂度O(1)
。
我们再来看一个例子,如下代码所示:
go
func arrF() {
var (
arr1, arr2 [5]int
)
for i := 0; i < 5; i++ {
arr1[i] = i
}
for i, v := range arr1 {
arr2[5-i-1] = v
}
fmt.Println(arr1)
fmt.Println(arr2)
}
在上面的代码中,我们先是为数组一赋值,之后再将数组一反转赋值给了数组二。
我们假设5
在实际应用的时候是n
,那么此时空间复杂度就是arr
为O(n)
,arr2
为O(n)
,常量定义以及遍历为O(1)
我们忽略掉,那么最终整个arrF()
的空间复杂度就是O(2n)
。
复杂度的意义及权衡
上文我们讲解了时间复杂度和空间复杂度的概念及基本的计算方法。这两个概念直接影响到我们程序的性能。
-
时间复杂度:影响算法的执行效率,决定了算法在不同输入规模下需要的时间。
-
空间复杂度:影响算法的内存占用,决定了算法在不同输入规模下所需的内存资源。
在实际开发中,我们需要综合考虑这两者,以确定最优的解决方案。
我们经常会听到两个概念:以时间换空间 和 以空间换时间。
-
以时间换空间:通过增加计算量来减少内存消耗,适合在内存有限但时间要求不高的场景。
例如,使用递归算法虽然内存占用少,但执行时间可能较长。
-
以空间换时间:通过增加内存占用来减少执行时间,适合在内存充裕但对执行速度要求较高的场景。
例如,动态规划通过引入缓存来存储中间结果,避免重复计算,加快算法运行。
在实际开发中,我们应根据具体场景和需求,权衡时间和空间复杂度。例如,在实时系统中,执行时间是关键因素,而在嵌入式系统中,内存占用可能更为重要。
总的来说,选择算法和数据结构时,需要综合考虑两者,找到最符合当前需求的平衡点。
我的GitHub:https://github.com/swxctx