前文链接:算法笔记 -【复杂度分析】
从一个小栗子开始
function find(ar, x) {
let i = 0
let pos = -1
for (; i < ar.length; i++) {
if (ar[i] === x) {
pos = i
}
}
return pos
}
很容易看出上段代码是为了从数组ar中找出与x相等的项的索引,(细心的同学可能会发现该方法返回的索引是最后一个匹配项,但是这里我们只讨论算法复杂度问题,所以假设ar一定是一个没有重复项的数组,这样便于排除干扰)但是稍微分析就会发现上述代码是优化空间的,因为假设在第1次遍历的时候就找到了x的索引,后面的遍历实际上是没有意义的。
优化:
function find(ar, x) {
let i = 0
let pos = -1
for (; i < ar.length; i++) {
if (ar[i] === x) {
pos = i
break
}
}
return pos
}
经过优化后的代码理论上耗时应该会比之前的代码更少,但是我们怎么衡量这次优化呢?通过之前的复杂度分析我们知道前后两段代码的复杂度都为O(n)
,但此时我们就会有疑问了:优化后的代码复杂度还是O(n)
吗?
更细致的复杂度分析
最好/最坏情况时间复杂度
如前栗子,
function find(ar, x) {
let i = 0
let pos = -1
for (; i < ar.length; i++) {
if (ar[i] === x) {
pos = i
break
}
}
return pos
}
代码的理论执行耗时有些情况不仅与数据规模n有关,还跟具体的条件有关;
最好情况时间复杂度为O(1)
(即在第一次遍历就找到x)
最坏情况时间复杂度为O(n)
(即在最后一次遍历才找到x)
平均情况时间复杂度
无论最好情况时间复杂度还是最坏情况时间复杂度都是极端情况,实际参考意义不大,这里就需要引入一个新的概念:平均情况时间复杂度
具体分析
要查找数组中x的索引其实有n+1
中情况:找不到和在位置0、1、2...n-1,每一中情况的耗时除以n+1
,即为平均耗时:(1 + 2 + 3 + ... n + n) / (n + 1)
-> n(n + 3) / 2(n + 1)
-> T(n) = O(n)
所以平均情况时间复杂度为O(n)
分析方法改进
我们前面的算法实际上是建立在每种情况出现的概率相同的基础上,但实际上,每种情况的出现概率并不相同:
从数组ar中查找x,首先x在ar中和不在ar中的概率都为1/2
,当x在ar中时,在每个位置的概率都为1/n
,所以:(1 * 1/n + 2 * 1/n + ... n * 1/n + n) / 2
-> (3n + 1) / 4
(这个值就是加权平均值或者期望值)
-> T(n) = O(n)
(得出的时间复杂度为加权平均时间复杂度或期望时间复杂度)
其实不必在意概念,只要知道改进后的复杂度算法更加可靠和科学一点就行了
回到最初我们进行的一个小优化:在找到x之后停止后面的遍历,但经过一轮分析似乎优化前后的复杂度没有变化,那我们这个优化还有意义吗?答案是肯定的,因为没有优化前最好情况复杂度和最坏情况复杂度都为O(n)
,复杂度是稳定的,代码n次遍历都会被执行,但是优化后,最好情况复杂度为O(1)
,最坏情况复杂度为O(n)
,复杂度是不稳定的,代码的n次遍历不在被必然执行
均摊时间复杂度
平均时间复杂度就能解决复杂度分析的所有问题吗?
来看一个栗子:(伪代码)
let ar = new Array(n)
let amount = 0
function insert(val) {
if (amount === ar.length) {
let sum = 0
for (let i = 0; i < ar.length; i++) {
sum += ar[i]
}
ar = new Array(n)
ar[0] = sum
amount = 1
}
ar[amount] = val
amount++
}
这里我们尝试用之前复杂度分析的方法对其进行分析:
第一次执行,即ar为长度为n的空数组,计数amount为0,此时不需要进行遍历,复杂度为O(1)
第二次执行,如果n > 2,则同第一次执行,复杂度为O(1)
...
第n次执行,同第一次执行,复杂度为O(1)
第n+1次执行,需要将数组ar中所有项求和放入ar[0],然后再进行插入,此时需要进行数组求和,复杂度为O(n)
,此后数组ar剩余长度为n-1
我们发现此后n-1次执行的复杂度都为O(1)
,然后就会再次出现一次复杂度为O(n)
的执行,也就是说该段程序的复杂度呈现一定的规律:每出现一次O(n)
的复杂度,余下的n-1次的复杂度就为O(1)
,这样周而复始
最好时间复杂度:O(1)
最坏时间复杂度:O(n)
期望平均时间复杂度:1 * 1 /(n + 1) + 1 * 1 /(n + 1) + ... n * (n + 1)
-> O(1)
但是在求期望平均时间复杂度的时候和前一小结的栗子有点不同:最好和最坏时间复杂度出现是有规律的(每次出现一个O(n)
复杂度的操作后面必跟着n-1个O(1)
复杂度的操作),并不是完全随机的,如果单纯用平均时间复杂度描述其耗时趋势不是很准确,所以这里就需要一种新的描述方式:均摊时间复杂度
每一次 O(n)
的插入操作,都会跟着 n-1 次 O(1)
的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)
结论:
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系
一般均摊时间复杂度就等于最好情况时间复杂度。
总结
前文总结下来的内容是关于时间复杂度的分析多引入了几个概念:
- 最好时间复杂度
- 最坏时间复杂度
- 期望平均时间复杂度
- 均摊时间复杂度
这些复杂度描述方法只是作为复杂度分析的一个补充,在实际中更多地作为代码优化的一个参考,所谓概念无关紧要,重要的是这里面涉及到的一些简单的数学分析方法,了解了这些方法在实际应用中就可以更好得组织我们的代码,同时预知一部分性能问题。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。