我们知道衡量算法好坏的两个因素分别是空间复杂度和时间复杂度。首先我们来看看空间复杂度。 + J1 D) u9 }8 X t5 Q* J: ]/ o) [8 U) t X, H6 ~, U
那么,什么是空间复杂度呢?我们知道,时间复杂度是执行算法的时间成本,那么,笼统来讲,空间复杂度就是算法执行的空间成本。在运行一段程序时,我们不仅要执行各种运算指令,同时也会根据需要,存储一些临时的中间数据,以便后续指令可以更方便地继续执行。那么存储这些中间数据所需要地成本也属于算法的空间复杂度。那么什么时候需要存储一些中间数据呢?举例来说:有一个需求,需要从给定的一列数中找出重复的数字。那么针对这个需求,最朴素的方法就是双重循环,即遍历整个数列,每遍历到一个新的整数就开始回溯之前遍历过的所有整数,看看遍历过的这些整数里有没有与待遍历的数值相同的。假如给定的数列是:3、1、2、5、4、9、7、2。! G/ m9 @; x3 u- Z$ C
* h: n# Q% U0 F' ]# k. Z& g& `! c
1 E+ O$ |8 S8 n s. Z
以上需求使用双重循环当然可以得到最终结果,但是其时间复杂度为。对我们来说,这个时间复杂度还是有些高的,我们需要改变算法来提高效率,最简单的方法就是使用中间数据来提高效率。 那么如何利用中间数据呢?还是以上述数列为例,当遍历整个数列时,每遍历一个整数,就把该整数存储起来,就像放到字典中一样。当遍历下一个整数时,不必再去回溯向前比较,而是直接去字典中查找,看看有没有对应的整数即可。那么遍历上述数列之后得到的一个字典结构的数据为: / L- R! c4 L1 `0 g m' Z+ v% m- E
- Q: L8 l$ l) V5 W1 g0 P3 q
该字典结构的数据左边的 key 代表整数的值,右边的 value 代表整数出现的次数。当遍历到最后一个整数 2 时,发现 2 在字典中已经出现过,那么就证明该数列中重复的数字是 2。 0 Y5 J& U; c2 \( v, J+ ` + K9 Y" `3 @# [' v- \+ W由于读写字典本身的时间复杂度是,所以整个算法的时间复杂度是,和最初的双重循环的方法相比,效率大幅度提高了。上述提到的字典结构的数据,其实是一种特殊的数据结构,叫“散列表”。这个数据结构需要开辟一定的内存空间来存储有用的数据信息。但是内存空间是有限的,在时间复杂度相同的情况下,算法占用的空间自然是越小越好,即空间复杂度越小越好。和时间复杂度类似,空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度,它同样使用大 O 表示法。程序占用空间大小的计算公式为,其中 n 为问题的规模,f(n) 为算法所占用存储空间的函数。9 T& w1 Z1 h( Q( Y; Q6 S$ t9 R
" B) W: b) }' I, w( i6 Q( q那么空间复杂度该如何计算呢?和时间复杂度类似,空间复杂度也有几种不同的增长趋势。常见的空间复杂度有下面几种情形。, i/ W$ w& E" z7 U& y
9 {& {3 B" K' L, g1 J6 W2 u场景一:常量空间。当算法的存储空间大小固定,和输入规模没有直接的关系时,空间复杂度记作。 $ J/ C! i' i3 b! l6 f 5 l* X! P( z$ U P; W s场景二:线性空间。当算法分配的空间是一个线性的集合时,比如数组,并且集合大小和输入规模 n 成正比时,空间复杂度记作。 * g8 d, @2 f% i K1 `! C' G8 E$ X/ ]% ` m5 A* H0 c5 k/ E
场景三:二维空间。当算法分配的空间是一个二维数组集合,并且集合的长度和宽度都与输入规模 n 成正比时,空间复杂度记作。6 q$ o! C/ p, s) E1 C1 e
! q8 b5 U2 n6 P; p) N# d) x9 ]5 E6 m
场景四:递归空间。递归是一个比较特殊的场景。虽然递归代码中并没有显式地声明变量或集合,但是计算机在执行程序时,会专门分配一块内存,用来存储“方法调用栈”。“方法调用栈”包括入栈和出栈两个动作。当进入一个新方法时,执行入栈操作,把调用的方法和参数信息压入栈中。当方法返回时,执行出栈操作,把调用的方法和信息从栈中弹出。如有一个递归程序: 3 J! F" @( H3 Y8 _$ C" G# t/ x& V ^3 G1 s" E% m0 o
void fun(int n) { % | t$ m9 i* g: _! z9 v if (n <= 1) { " P! N/ H' |7 X- I- m return; # M& d e( Y" K! V } 2 @; t& J& ]5 w4 u) v9 t& i5 z fun(n - 1); 4 y; P9 m: ]* S' w# H: B // do something2 o+ f- b# P4 { L q8 |
} 0 Y4 G# c: R) q' c0 O& n& Z' w* Y8 [8 D" R6 X) L
假如初始传入参数值为 5,那么方法 fun(5) 的调用信息先入栈 . t5 |' W: f/ K' | 2 D6 _* d) s- T : V& F0 f0 B: ]' m6 p, v' w接下来递归调用相同的方法,方法 fun(4) 的调用信息入栈& H( o8 c/ a. P @" Y/ h: H - ?' P/ ]8 v1 x; H9 Y0 L( `以此类推,递归越来越深,入栈的元素就越来越多/ d; u: Q1 L& M, x5 R8 N 1 a4 ?" G. w2 p; e" I
最终,“方法调用栈”的全部元素会一一出栈。 l2 f/ J R2 V5 D0 K2 I ^. ^ # L6 j5 \1 r7 T0 C由上述出入栈的过程可以看出,执行递归操作所需要的内存空间和递归的深度成正比。纯粹的递归操作的空间复杂度也是线性的,如果递归的深度是 n,那么空间复杂度就是。% ?: z% X+ V0 h
/ z- o' }9 G+ I# f人们之所以花时间去评估算法的时间复杂度和空间复杂度,其根本原因是计算机的运算速度和空间资源是有限的。就好像一个大财主基本上不必为日常开销伤脑筋;而一个没有多少积蓄的穷人,就不得不为日常花销精打细算。对于计算机系统来说也是如此。虽然目前计算机的 CPU 处理速度不断飙升,内存和硬盘空间也越来越大,但是面对庞大而复杂的数据和业务,我们仍然要精打细算,选择最有效的利用方式。但是正如鱼和熊掌不可兼得一样,很多时候,我们不得不在时间复杂度和空间复杂度之间进行取舍。平时基本上都是采用牺牲空间复杂度来换取时间复杂度。 J/ j* D! l* h# f