原文地址
数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法一个非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这里就要用到我们今天要讲的内容:时间、空间复杂度分析。
为什么需要复杂度分析?
首先,我可以肯定地说,你这种评估算法执行效率的方法是正确的。很多数据结构和算法书籍还给这种方法起了一个名字,叫事后统计法。但是,这种统计方法有非常大的局限性。
-
- 测试结果非常依赖测试环境
测试环境中硬件的不同会对测试结果有很大的影响。比如,我们拿同样一段代码,分别用 Intel Core i9 处理器和 Intel Core i3 处理器来运行,不用说,i9 处理器要比 i3 处理器执行的速度快很多。还有,比如原本在这台机器上 a 代码执行的速度比 b 代码要快,等我们换到另一台机器上时,可能会有截然相反的结果。
-
- 测试结果受数据规模的影响很大
后面我们会讲排序算法,我们先拿它举个例子。对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。比如,对于小规模的数据排序,插入排序可能反倒会比快速排序要快!
所以,我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。这就是我们今天要讲的时间、空间复杂度分析方法。
大 O 复杂度表示法
算法的执行效率,粗略地讲,就是算法代码执行的时间
这里有段非常简单的代码,求 1,2,3…n 的累加和。现在,我就带你一块来估算一下这段代码的执行时间。
1function cal(n) {
2 var sum = 0;
3 var i = 1;
4 for (; i <= n; ++i) {
5 sum = sum + i;
6 }
7 return sum;
8 }
从 CPU 的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的 CPU 执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以假设每行代码执行的时间都一样,为 unit_time。在这个假设的基础之上,这段代码的总执行时间是多少呢?
第 2、3 行代码分别需要 1 个 unit_time 的执行时间,第 4、5 行都运行了 n 遍,所以需要 2nunit_time 的执行时间,所以这段代码总的执行时间就是 (2n+2)unit_time。可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数成正比。
按照这个分析思路,我们再来看这段代码。
1funtion cal(n) {
2 var sum = 0;
3 var i = 1;
4 var j = 1;
5 for (; i <= n; ++i) {
6 j = 1;
7 for (; j <= n; ++j) {
8 sum = sum + i * j;
9 }
10 }
11}
我们依旧假设每个语句的执行时间是 unit_time。那这段代码的总执行时间 T(n) 是多少呢?
第 2、3、4 行代码,每行都需要 1 个 unit_time 的执行时间,第 5、6 行代码循环执行了 n 遍,需要 2n * unit_time 的执行时间,第 7、8 行代码循环执行了 n2遍,所以需要 2n2 * unit_time 的执行时间。所以,整段代码总的执行时间
T(n) = (2n2+2n+3)*unit_time。
尽管我们不知道 unit_time 的具体值,但是通过这两段代码执行时间的推导过程,我们可以得到一个非常重要的规律:
所有代码的执行时间 T(n) 与每行代码的执行次数 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(2n2+2n+3)。这就是大 O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
当 n 很大时,你可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,如果用大 O 表示法表示刚讲的那两段代码的时间复杂度,就可以记为:
T(n) = O(n); T(n) = O(n2)。
推导的过程
T(n) = (2n+2)*unit_time -> T(n) = O(2n+2) -> T(n) = O(n)
T(n) = (2n2+2n+3)*unit_time => T(n) = O(2n2+2n+3) -> T(n) = O(n2)
时间复杂度分析
如何分析一段代码的时间复杂度?三个比较实用的方法
只关注循环执行次数最多的的一段代码
大 O 这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。所以,我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。
1function cal(n) { 2 var sum = 0; 3 var i = 1; 4 for (; i <= n; ++i) { 5 sum = sum + i; 6 } 7 return sum; 8 }
2.3代码都是常量级别的执行时间,与n的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是滴4、5行代码,所以这块代码要重点分析。那两行代码执行了n次,所以总的时间复杂度就是O(n)-
加法法则:总复杂度等于量级最大的那段代码的复杂度
综合这三段代码的时间复杂度(分别是O(1), O(n), O(n2)),我们取其中最大的量级。所以,整段代码的时间复杂度就为 O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。那我们将这个规律抽象成公式就是:
如果 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))).
-
乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
类似嵌套循环的,都是用乘法来处理大O
- O(1),O(n),O(nlogn),O(nlogn),O(n^2)
- 大O描述的是算法的运行时间和输入数据之间的关系
O(n)是nums中的元素个数算法和n呈线性关系,忽略了常数。实际是
T = c1*n + c2;
但是
T = 2*n + 2 O(n)
T = 2000*n + 10000 O(n)
T = 1*n*n + 0 O(n^2)
上面的表达式中第三个n下于3000的时候都是比前面的要小的,但是在n接近无穷的时候,
就是不一样了,所以O是渐进时间复杂度描述n趋近于无穷的情况
- O一般是计算最坏的结果
- 均摊复杂度,有时早规律出现的时候可以使用均摊复杂度
- 复杂度震荡,在边界情况下,来回操作,过于着急(Eager)解决方案就是Lazy
源: Big O Cheat Sheet.
以下是一些最常用的 大O标记法 列表以及它们与不同大小输入数据的性能比较。
大O标记法 | 计算10个元素 | 计算100个元素 | 计算1000个元素 |
---|---|---|---|
O(1) | 1 | 1 | 1 |
O(log N) | 3 | 6 | 9 |
O(N) | 10 | 100 | 1000 |
O(N log N) | 30 | 600 | 9000 |
O(N^2) | 100 | 10000 | 1000000 |
O(2^N) | 1024 | 1.26e+29 | 1.07e+301 |
O(N!) | 3628800 | 9.3e+157 | 4.02e+2567 |
O(log N)和O(N log N)分析
对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。我通过一个例子来说明一下。
i=1;
while (i <= n) {
i = i * 2;
}
根据我们前面讲的复杂度分析方法,第三行代码是循环执行次数最多的。所以,我们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。
从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。还记得我们高中学过的等比数列吗?实际上,变量 i 的取值就是一个等比数列。如果我把它一个一个列出来,就应该是这个样子的:
20 21 22 ... 2k ... 2n = n
2的0次方
所以,我们只要知道 x 值是多少,就知道这行代码执行的次数了。通过 2x=n 求解 x 这个问题我们想高中应该就学过了,我就不多说了。x=log2n,所以,这段代码的时间复杂度就是 O(log2n)
如果换成i= i * 3 就是O(log3n)
实际上,不管是以 2 为底、以 3 为底,还是以 10 为底,我们可以把所有对数阶的时间复杂度都记为 O(logn)。为什么呢?
我们知道,对数之间是可以互相转换的,log3n 就等于 log32 * log2n,所以 O(log3n) = O(C * log2n),其中 C=log32 是一个常量。基于我们前面的一个理论:在采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))。所以,O(log2n) 就等于 O(log3n)。因此,在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。
如果你理解了我前面讲的 O(logn),那 O(nlogn) 就很容易理解了。还记得我们刚讲的乘法法则吗?如果一段代码的时间复杂度是 O(logn),我们循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。
O(m+n) 、O(m*n)
我们再来讲一种跟前面都不一样的时间复杂度,代码的复杂度由两个数据的规模来决定。老规矩,先看代码!
function cal(m, n) {
var sum_1 = 0;
var i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}
var sum_2 = 0;
var j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}
从代码中可以看出,m 和 n 是表示两个数据规模。我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。
针对这种情况,原来的加法法则就不正确了,我们需要将加法规则改为:T1(m) + T2(n) = O(f(m) + g(n))。但是乘法法则继续有效:T1(m)*T2(n) = O(f(m) *f(n))。
数据结构操作的复杂性
数据结构 | 连接 | 查找 | 插入 | 删除 |
---|---|---|---|---|
数组 | 1 | n | n | n |
栈 | n | n | 1 | 1 |
队列 | n | n | 1 | 1 |
链表 | n | n | 1 | 1 |
哈希表 | - | n | n | n |
二分查找树 | n | n | n | n |
B树 | log(n) | log(n) | log(n) | log(n) |
红黑树 | log(n) | log(n) | log(n) | log(n) |
AVL树 | log(n) | log(n) | log(n) | log(n) |
数组排序算法的复杂性
名称 | 最优 | 平均 | 最坏 | 内存 | 稳定 |
---|---|---|---|---|---|
冒泡排序 | n | n^2 | n^2 | 1 | Yes |
插入排序 | n | n^2 | n^2 | 1 | Yes |
选择排序 | n^2 | n^2 | n^2 | 1 | No |
堆排序 | n log(n) | n log(n) | n log(n) | 1 | No |
归并排序 | n log(n) | n log(n) | n log(n) | n | Yes |
快速排序 | n log(n) | n log(n) | n^2 | log(n) | No |
希尔排序 | n log(n) | 取决于差距序列 | n (log(n))^2 | 1 | No |
空间复杂度分析
大 O 表示法和时间复杂度分析,理解了前面讲的内容,空间复杂度分析方法学起来就非常简单了。
前面我讲过,时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
我还是拿具体的例子来给你说明。
(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
我还是拿具体的例子来给你说明。(这段代码有点“傻”,一般没人会这么写,我这么写只是为了方便给你解释。)
1function print(n) {
2 var i = 0;
3 var a = [];
4 for (i; i = 0; --i) {
8 console.log(a[i])
9 }
跟时间复杂度分析一样,我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。
我们常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。所以,对于空间复杂度,掌握刚我说的这些内容已经足够了。
最好最坏情况时间复杂度
先看例子:
// n 表示数组 array 的长度
// indexOf
funcrion find(array, n, x) {
var i = 0;
var pos = -1;
for (; i < n; ++i) {
if (array[i] == x) pos = i;
}
return pos;
}
你应该可以看出来,这段代码要实现的功能是,在一个无序的数组(array)中,查找变量 x 出现的位置。如果没有找到,就返回 -1。按照上节课讲的分析方法,这段代码的复杂度是 O(n),其中,n 代表数组的长度。
我们在数组中查找一个数据,并不需要每次都把整个数组都遍历一遍,因为有可能中途找到就可以提前结束循环了。但是,这段代码写得不够高效。我们可以这样优化一下这段查找代码。
// n 表示数组 array 的长度
function find(array, n, x) {
var i = 0;
var pos = -1;
for (; i < n; ++i) {
if (array[i] == x) {
pos = i;
break;
}
}
return pos;
}
这个时候,问题就来了。我们优化完之后,这段代码的时间复杂度还是 O(n) 吗?很显然,咱们上一节讲的分析方法,解决不了这个问题。
因为,要查找的变量 x 可能出现在数组的任意位置。如果数组中第一个元素正好是要查找的变量 x,那就不需要继续遍历剩下的 n-1 个数据了,那时间复杂度就是 O(1)。但如果数组中不存在变量 x,那我们就需要把整个数组都遍历一遍,时间复杂度就成了 O(n)。所以,不同的情况下,这段代码的时间复杂度是不一样的。
为了表示代码在不同情况下的不同时间复杂度,我们需要引入三个概念:最好情况时间复杂度、最坏情况时间复杂度和平均情况时间复杂度。
顾名思义,最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。就像我们刚刚讲到的,在最理想的情况下,要查找的变量 x 正好是数组的第一个元素,这个时候对应的时间复杂度就是最好情况时间复杂度。
同理,最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。就像刚举的那个例子,如果数组中没有要查找的变量 x,我们需要把整个数组都遍历一遍才行,所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度。
平均情况时间复杂度
我们都知道,最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好地表示平均情况下的时间复杂度,需要进入一个新的概念:平均情况时间复杂度,后面简称平均时间复杂度。
分析上面的例子平均复杂度怎么计算,在n+1中情况:在数组中0~0-1位置中和不在数组中
把每一种情况累加起来,然后在除以n+1,就可以得到需要遍历元素个数的平均值:
我们知道,时间复杂度的大 O 标记法中,可以省略掉系数、低阶、常量,所以,咱们把刚刚这个公式简化之后,得到的平均时间复杂度就是 O(n)。
这个结论虽然是正确的,但是计算过程稍微有点儿问题。究竟是什么问题呢?我们刚讲的这 n+1 种情况,出现的概率并不是一样的。我带你具体分析一下。(这里要稍微用到一点儿概率论的知识,不过非常简单,你不用担心。)
我们知道,要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。
因此,前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:
这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。
引入概率之后,前面那段代码的加权平均值为 (3n+1)/4。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 O(n)。
你可能会说,平均时间复杂度分析好复杂啊,还要涉及概率论的知识。实际上,在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。像我们上一节课举的那些例子那样,很多时候,我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。
均摊时间复杂度
到此为止,你应该已经掌握了算法复杂度分析的大部分内容了。下面我要给你讲一个更加高级的概念,均摊时间复杂度,以及它对应的分析方法,摊还分析(或者叫平摊分析)。
均摊时间复杂度,听起来跟平均时间复杂度有点儿像。对于初学者来说,这两个概念确实非常容易弄混。我前面说了,大部分情况下,我们并不需要区分最好、最坏、平均三种复杂度。平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。
老规矩,我还是借助一个具体的例子来帮助你理解。(当然,这个例子只是我为了方便讲解想出来的,实际上没人会这么写。)
// array 表示一个长度为 n 的数组
// 代码中的 array.length 就等于 n
int[] array = new int[n];
int count = 0;
void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum = sum + array[i];
}
array[0] = sum;
count = 1;
}
array[count] = val;
++count;
}
我先来解释一下这段代码。这段代码实现了一个往数组中插入数据的功能。当数组满了之后,也就是代码中的 count == array.length 时,我们用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。
那这段代码的时间复杂度是多少呢?你可以先用我们刚讲到的三种时间复杂度的分析方法来分析一下。
最理想的情况下,数组中有空闲空间,我们只需要将数据插入到数组下标为 count 的位置就可以了,所以最好情况时间复杂度为 O(1)。最坏的情况下,数组中没有空闲空间了,我们需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)。
那平均时间复杂度是多少呢?答案是 O(1)。我们还是可以通过前面讲的概率论的方法来分析。
假设数组的长度是 n,根据数据插入的位置的不同,我们可以分为 n 种情况,每种情况的时间复杂度是 O(1)。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。而且,这 n+1 种情况发生的概率一样,都是 1/(n+1)。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是:
至此为止,前面的最好、最坏、平均时间复杂度的计算,理解起来应该都没有问题。但是这个例子里的平均复杂度分析其实并不需要这么复杂,不需要引入概率论的知识。这是为什么呢?我们先来对比一下这个 insert() 的例子和前面那个 find() 的例子,你就会发现这两者有很大差别。
首先,find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。这是 insert()第一个区别于 find() 的地方。
我们再来看第二个不同的地方。对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。
所以,针对这样一种特殊场景的复杂度分析,我们并不需要像之前讲平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。
针对这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度我们起了一个名字,叫均摊时间复杂度。
那究竟如何使用摊还分析法来分析算法的均摊时间复杂度呢?
我们还是继续看在数组中插入数据的这个例子。每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。你都理解了吗?
均摊时间复杂度和摊还分析应用场景比较特殊,所以我们并不会经常用到。为了方便你理解、记忆,我这里简单总结一下它们的应用场景。如果你遇到了,知道是怎么回事儿就行了。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
对数据规模有一个概念和分析
如果要想在1s之内解决问题:
- O(n2)的算法可以处理大约104级别的数据
- O(n)的算法可以处理大约10^8级别的数据
- O(nlogn)的算法可以处理大约10^级别的数据
递归算法的复杂度分析
只进行一次的递归,递归深度logn 时间复杂度O(logn)
function binarySearch(arr, l, r, target) {
if (l < r){
return -1;
}
let mid = l + (r-l)/2;
if(arr[mid] == target) {
return mid;
} else if (arr[mid] > target) {
return binarySearch(arr, l , mid-1, target);
} else {
return binarySearch(arr, mid+1, r, target;
}
}
如果递归函数中,只进行一次递归调用,递归深度为depth;
在每个递归函数中,时间复杂度为T;
则总体的时间复杂度为O(T*depth)
多次递归调用
function f(n) {
if (n==0) {
return 1;
}
return f(n-1) + f(n-1)
}
n层
logn层,分治算法
避免复杂度的震荡
java数组动态扩容
假设我们现在有一个数组,这个数组的容量为n,并且现在也装满了元素,那么现在我们再调用一下addLast操作,显然在添加一个新的元素的时候会需要扩容(扩容会耗费O(N)的时间),之后我们马上进行removeLast操作(根据我们之前的逻辑,在上一个操作里通过扩容,容量变为了2n,在我们删除1个元素之后,元素又变为了n = 2n/2,根据我们代码中的逻辑,会触发缩容的操作,同样耗费了O(n)的时间);那么我们如果再addLast、removeLast…等相继依次操作。
对于addLast和removeLast来说,都是每隔n次操作都会触发resize,而不会每次都触发
但是现在我们制造了一种情景:同时看addLast和removeLast的时候,每一次都会耗费O(n)的复杂度,那么这就是复杂度的震荡
resize的复杂度分析——出现复杂度震荡的原因及解决方案
removeLast时resize过于着急(采用了Eager的策略: 一旦我们的元素变为当前容积的1/2的时候,我们马上就把当前的容积也缩容为1/2)
解决方案: Lazy (在线段树中,也会用到类似的思路)
当元素变为当前容积的1/2时,不着急把当前容积缩容,而是等等;如果后面一直有删除操作的话,当删除元素到整个数组容积的1/4时,那么这样看来我们的数组确实用不了这么大的容积,此时我们再来进行缩容,缩容整个数组的1/2(这样,即便我们要添加元素,也不需要马上触发扩容操作)
当 size == capacity / 4时,才将capacity减半!
复杂度分析的4个概念
一、复杂度分析的4个概念
- 1.最坏情况时间复杂度:代码在最理想情况下执行的时间复杂度。
- 2.最好情况时间复杂度:代码在最坏情况下执行的时间复杂度。
- 3.平均时间复杂度:用代码在所有情况下执行的次数的加权平均值表示。
- 4.均摊时间复杂度:在代码执行的所有复杂度情况中绝大部分是低级别的复杂度,个别情况是高级别复杂度且发生具有时序关系时,可以将个别高级别复杂度均摊到低级别复杂度上。基本上均摊结果就等于低级别复杂度。
二、为什么要引入这4个概念?
1.同一段代码在不同情况下时间复杂度会出现量级差异,为了更全面,更准确的描述代码的时间复杂度,所以引入这4个概念。
2.代码复杂度在不同情况下出现量级差别时才需要区别这四种复杂度。大多数情况下,是不需要区别分析它们的。
三、如何分析平均、均摊时间复杂度?
1.平均时间复杂度
代码在不同情况下复杂度出现量级差别,则用代码所有可能情况下执行次数的加权平均值表示。
2.均摊时间复杂度
两个条件满足时使用:1)代码在绝大多数情况下是低级别复杂度,只有极少数情况是高级别复杂度;2)低级别和高级别复杂度出现具有时序规律。均摊结果一般都等于低级别复杂度。