1

前文链接:算法笔记 -【复杂度分析】

从一个小栗子开始

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)
结论:
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系
一般均摊时间复杂度就等于最好情况时间复杂度。

总结

前文总结下来的内容是关于时间复杂度的分析多引入了几个概念:

  • 最好时间复杂度
  • 最坏时间复杂度
  • 期望平均时间复杂度
  • 均摊时间复杂度

这些复杂度描述方法只是作为复杂度分析的一个补充,在实际中更多地作为代码优化的一个参考,所谓概念无关紧要,重要的是这里面涉及到的一些简单的数学分析方法,了解了这些方法在实际应用中就可以更好得组织我们的代码,同时预知一部分性能问题。


innocence
11 声望1 粉丝

undefined