【数据结构与算法之美】笔记二:复杂度分析

笔记来自王争老师的《数据结构与算法之美》课程,学习之余,整理的笔记。
内容为精简版,便于日后复习以及查阅。如有需要,推荐购买专栏课程。
课程地址:数据结构与算法之美


复杂度分析(上):如何分析、统计算法的执行效率和资源消耗?

1. 为什么需要复杂度分析?

一开始先思考,把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种评估算法执行效率的方法是正确的。很多数据结构和算法书籍还给这种方法起了一个名字,叫事后统计法。但是,这种统计方法有非常大的局限性:

  • 测试结果非常依赖测试环境

测试环境中硬件的不同会对测试结果有很大的影响。比如,我们拿同样一段代码,分别用Intel Core i9处理器和Intel Core i3处理器来运行,不用说,i9处理器要比i3处理器执行的速度快很多。还有,比如原本在这台机器上a代码执行的速度比b代码要快,等我们换到另一台机器上时,可能会有截然相反的结果。

  • 测试结果受数据规模的影响很大

比如以排序算法为例,对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。比如,对于小规模的数据排序,插入排序可能反倒会比快速排序要快!
所以,我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。

2. 大O复杂度

首先看两端代码,对比一下复杂度

int cal(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; ++i) {
    sum = sum + i;
    }
    return sum;
}

从CPU的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的CPU执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以假设每行代码执行的时间都一样,为unit_time,总的代码执行时间记为T(n)

以上一段代码为例,第2、3行代码分别需要1个unit_time的执行时间,第4、5行都运行了n遍,所以需要2n*unit_time的执行时间,所以这段代码总的执行时间就是** T ( n ) = ( 2 n + 2 ) T(n)=(2n+2) T(n)=(2n+2)unit_time*。

那么来看下一段代码:

int cal(int n) {
    int sum = 0;
    int i = 1;
    int j = 1;
    for (; i <= n; ++i) {
        j = 1;
        for (; j <= n; ++j) {
            sum = sum + i * j;
        }
    }
}

就上面这一段代码来说,第2、3、4行代码,每行都需要1个unit_time的执行时间,第5、6行代码循环执行了n遍,需要2n*unit_time的执行时间,第7、8行代码循环执行了 n 2 n^2 n2遍,所以需要 2 n 2 2n^2 2n2 * unit_time的执行时间。所以,整段代码总的执行时间** T ( n ) = ( 2 n 2 + 2 n + 3 ) T(n)=(2n^2+2n+3) T(n)=(2n2+2n+3)unit_time*。

尽管我们不知道unit_time的具体值,但是通过这两段代码执行时间的推导过程,我们可以得到一个非常重要的规律,那就是,所有代码的执行时间T(n)与每行代码的执行次数n成正比。 得到一个公式:

T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))

这里的T(n)表示代码的执行时间;n表示数据规模的大小;f(n)表示每次代码执行的次数总和;O表示代码的执行时间T(n)与f(n)表达式成正比

因此,第一段代码的 T ( n ) = O ( 2 n + 2 ) T(n)=O(2n+2) T(n)=O(2n+2),第二段代码的 T ( n ) = O ( 2 n 2 + 2 n + 3 ) T(n)=O(2n^2+2n+3) T(n)=O(2n2+2n+3),这种表示方法就是大O的时间复杂度表示法大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

当n很大时,你可以把它想象成10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,如果用大O表示法表示刚讲的那两段代码的时间复杂度,就可以记为: T ( n ) = O ( n ) T(n) = O(n) T(n)=O(n);$ T(n) = O(n^2)$

3. 时间复杂度分析

如何分析一段的时间复杂度,这里有三个比较使用的方法分享:

  • 1. 只关注循环执行次数最多的一段代码

一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的n的量级,就是整段要分析代码的时间复杂度。比如刚才的第一段代码,第2,3行代码都是常量级的执行时间,与n的大小无关,所以对复杂度并没有影响。循环执行次数最多的是第4,5行代码,这两行被执行了n此,所以总的时间复杂度就是 O ( n ) O(n) O(n)

  • 2. 加法法则:总复杂度等于量级最大的那段代码的复杂度

总的时间复杂度就等于量级最大的那段代码的时间复杂度。那我们将这个规律抽象成公式就是:
如果 T 1 ( n ) = O ( f ( n ) ) , T 2 ( n ) = O ( g ( n ) ) T1(n)=O(f(n)),T2(n)=O(g(n)) T1(n)=O(f(n))T2(n)=O(g(n))
那么 T ( n ) = T 1 ( n ) + T 2 ( n ) = m a x ( O ( f ( n ) ) , O ( g ( n ) ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))) T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n))).
这里有一点要说明,就是,即便这段代码循环10000次、100000次,只要是一个已知的数,跟n无关,照样也是常量级的执行时间。当n无限大的时候,就可以忽略。
管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以
不管常量的执行时间多大,我们都可以忽略掉,因为它本身对增长趋势并没有影响。

  • 3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

类似于加法法则的公式,这里得出一个乘法法则:
T 1 ( n ) = O ( f ( n ) ) , T 2 ( n ) = O ( g ( n ) ) T1(n)=O(f(n)),T2(n)=O(g(n)) T1(n)=O(f(n))T2(n)=O(g(n))
那么 T ( n ) = T 1 ( n ) ∗ T 2 ( n ) = O ( f ( n ) ) ∗ O ( g ( n ) ) = O ( f ( n ) ∗ g ( n ) ) T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)) T(n)=T1(n)T2(n)=O(f(n))O(g(n))=O(f(n)g(n)).

4. 几种常见时间复杂度实例分析

image_1f476ls6513p21ll816ft1mhh15up9.png-296.2kB

对于罗列的复杂度量级,我们可以粗略地分为两类:多项式量级非多项式量级。其中,非多项式量级只有两个: O ( 2 n ) O(2^n) O(2n) O ( n ! ) O(n!) O(n!)
当数据规模n越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。因此,关于NP时间复杂度我就不展开讲了。我们主要来看几种常见的多项式时间复杂度。

4.1 O(1)

首先你必须明确一个概念,O(1)只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如这段代码,即便有3行,它的时间复杂度也是O(1),而不是O(3)。

int i = 8;
int j = 6;
int sum = i + j;

总结:只要代码的执行时间不随n的增大而增长,这样代码的时间复杂度我们都记作O(1)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)

4.2 O(logn)、O(nlogn)

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。举个例子:

i=1;
while (i <= n) {
    i = i * 2;
}

这里分析一下,第三行代码是循环执行次数最多的。所以,我们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。从代码中可以看出,变量i的值从1开始取,每循环一次就乘以2。当大于n时,循环结束。还记得我们高中学过的等比数列吗?实际上,变量i的取值就是一个等比数列。如果我把它一个一个列出来,就应该是这个样子的:

2 0 , 2 1 , 2 2 , . . . 2 k , . . . , 2 x = n 2^0,2^1,2^2,...2^k,...,2^x=n 20,21,22,...2k,...,2x=n

所以,我们只要知道x值是多少,就知道这行代码执行的次数了。通过2x=n求解x这个问题我们想高中应该就学过了,我就不多说了。x=log2n,所以,这段代码的时间复杂度就是 O ( l o g 2 n ) O(log_2n) O(log2n)

现在,我把代码稍微改下,你再看看,这段代码的时间复杂度是多少?

i=1;
while (i <= n) {
    i = i * 3;
}

根据刚才的思路,我们可以得出,修改后的代码的时间复杂度为 O ( l o g 3 n ) O(log_3n) O(log3n)

实际上,不管是以2为底、以3为底,还是以10为底,我们可以把所有对数阶的时间复杂度都记为O(logn)。为什么呢?

我们知道,对数之间是可以互相转换的, l o g 3 n log_3n log3n 就等于 l o g 3 2 ∗ l o g 2 n log_32 * log_2n log32log2n,所以 O ( l o g 3 n ) = O ( C ∗ l o g 2 n ) O(log_3n) = O(C * log_2n) O(log3n)=O(Clog2n),其中 C = l o g 3 2 C=log_32 C=log32是一个常量。基于我们前面的一个理论:在采用大O标记复杂度的时候,可以忽略系数,即 O ( C f ( n ) ) = O ( f ( n ) ) O(Cf(n))=O(f(n)) O(Cf(n))=O(f(n))。所以, O ( l o g 2 n ) O(log_2n) O(log2n)就等于 O ( l o g 3 n ) O(log_3n) O(log3n)。因此,在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O ( l o g n ) O(logn) O(logn)

如果理解了这里讲的O(logn),那O(nlogn)就很容易理解了。还记得我们刚讲的乘法法则吗?如果一段代码的时间复杂度是O(logn),我们循环执行n遍,时间复杂度就是O(nlogn)了。而且,O(nlogn)也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是O(nlogn)

4.3 O(m+n)、O(m*n)

这个我们看一个例子,代码的时间复杂度由两个数据的规模来决定:

int cal(int m, int n) {
    int sum_1 = 0;
    int i = 1;
    for (; i < m; ++i) {
        sum_1 = sum_1 + i;
    }
    int sum_2 = 0;
    int j = 1;
    for (; j < n; ++j) {
        sum_2 = sum_2 + j;
    }
    return sum_1 + sum_2;
}

从代码中可以看出,m和n是表示两个数据规模。我们无法事先评估m和n谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则省略掉其中一个。所以,上面代码的时间复杂度就是O(m+n)

针对这种情况,原来的加法法则就不正确了,我们需要将加法规则改为:
T 1 ( m ) + T 2 ( n ) = O ( f ( m ) + g ( n ) ) T1(m) + T2(n) = O(f(m) + g(n)) T1(m)+T2(n)=O(f(m)+g(n))。但是乘法法则继续有效: T 1 ( m ) ∗ T 2 ( n ) = O ( f ( m ) ∗ f ( n ) ) T1(m)*T2(n) = O(f(m) * f(n)) T1(m)T2(n)=O(f(m)f(n))

5. 空间复杂度分析

前面讲过,**时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。**类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系

这里拿一个具体的例子来给你说明。(这段代码有点“傻”,一般没人会这么写,这么写只是为了方便解释。)

void print(int n) {
    int i = 0;
    int[] a = new int[n];
    for (i; i <n; ++i) {
        a[i] = i * i;
    }
    for (i = n-1; i >= 0; --i) {
        print out a[i]
    }
}

跟时间复杂度分析一样,我们可以看到,第2行代码中,我们申请了一个空间存储变量i,但是它是常量阶的,跟数据规模n没有关系,所以我们可以忽略。第3行申请了一个大小为n的int类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是O(n)。

我们常见的空间复杂度就是O(1)、O(n)、O(n^2),像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。所以,对于空间复杂度,掌握刚我说的这些内容已经足够了。

这里对上述内容进行一个总结:

复杂度也叫渐进复杂度,包括:

  • 时间复杂度
  • 空间复杂度

用来分析算法执行效率与数据规模之间的增长关系,可以粗略地表示,越高阶复杂度的算法,执行效率越低常见的复杂度并不多,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n^2)。等你学完整个专栏之后,你就会发现几乎所有的数据结构和算法的复杂度都跑不出这几个。

image_1f47861dlprgne4h8qbb6u6nm.png-209.6kB

复杂度分析(下):浅析最好、最坏、平均、均摊时间复杂度

同一段代码,在不同输入的情况下,复杂度量级有可能是不一样的。因此引入了四个概念:

  • 最好情况时间复杂度(best case time complexity):代码在最理想情况下执行的时间复杂度。
  • 最坏情况时间复杂度(worst case time complexity):代码在最坏情况下执行的时间复杂度。
  • 平均情况时间复杂度(average case time complexity):用代码在所有情况下执行的次数的加权平均值表示。
  • 均摊时间复杂度(amortized time complexity):在代码执行的所有复杂度情况中绝大部分是低级别的复杂度,个别情况是高级别复杂度且发生具有时序关系时,可以将个别高级别复杂
    度均摊到低级别复杂度上。基本上均摊结果就等于低级别复杂度。

1.同一段代码在不同情况下时间复杂度会出现量级差异,为了更全面,更准确的描述代码的时间复杂度,所以引入这4个概念。
2.代码复杂度在不同情况下出现量级差别时才需要区别这四种复杂度。大多数情况下,是不需要区别分析它们的。

6. 最好、最坏、平均情况时间复杂度 & 均摊时间复杂度

这里还是举一个例子:

// n表示数组array的长度
int find(int[] array, int n, int x) {
    int i = 0;
    int pos = -1;
    for (; i < n; ++i) {
        if (array[i] == x) pos = i;
    }
    return pos;
}

这段代码要实现的功能是,在一个无序的数组(array)中,查找变量x出现的位置。如果没有找到,就返回-1。按照上节课讲的分析方法,这段代码的复杂度是O(n),其中,n代表数组的长度。

我们在数组中查找一个数据,并不需要每次都把整个数组都遍历一遍,因为有可能中途找到就可以提前结束循环了。但是,这段代码写得不够高效。我们可以这样优化一下这段查找代码。

// n表示数组array的长度
int find(int[] array, int n, int x) {
    int i = 0;
    int 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正好是数组的第一个元素,这个时候对应的时间复杂度就是最好情况时间复杂度,即为O(1)。

  • 最坏情况时间复杂度

最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。如果数组中没有要查找的变量x,我们需要把整个数组都遍历一遍才行,所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度,即为O(n)。

  • 平均情况时间复杂度

我们都知道,最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好地表示平均情况下的复杂度,我们需要引入另一个概念:平均情况时间复杂度,后面我简称为平均时间复杂度。

那么,在这里的平均时间复杂度又该怎么分析呢?

根据刚才的例子,要查找的变量x在数组中的位置,有n+1种情况:在数组的0~n-1位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以n+1,就可以得到需要遍历的元素个数的平均值,即:

1 + 2 + 3 + ⋯ + n + n n + 1 = n ( n + 3 ) 2 ( n + 1 ) \frac{1+2+3+\cdots+n+n}{n+1}=\frac{n(n+3)}{2(n+1)} n+11+2+3++n+n=2(n+1)n(n+3)

我们知道,时间复杂度的大O标记法中,可以省略掉系数、低阶、常量,所以,咱们把刚刚这个公式简化之后,得到的平均时间复杂度就是O(n)。但这里有一个问题是,这n+1种情况并不是等概率出现的,具体来说:

我们知道,要查找的变量x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概率都为1/2。另外,要查找的数据出现在0~n-1这n个位置的概率也是一样的,为1/n。所以,根据概率乘法法则,要查找的数据出现在0~n-1中任意位置的概率就
是1/(2n)。

因此,前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:

1 × 1 2 n + 2 × 1 2 n + 3 × 1 2 n + ⋯ + n × 1 2 n + n × 1 2 = 3 n + 1 4 \begin{aligned} & 1 \times \frac{1}{2 n}+2 \times \frac{1}{2 n}+3 \times \frac{1}{2 n}+\cdots+n \times \frac{1}{2 n}+n \times \frac{1}{2} =\frac{3 n+1}{4} \end{aligned} 1×2n1+2×2n1+3×2n1++n×2n1+n×21=43n+1

这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度

引入概率之后,前面那段代码的加权平均值为(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)。所以,根据加权平均的计算方法,我们求得的平均时
间复杂度就是:
1 × 1 n + 1 + 1 × 1 n + 1 + ⋯ + 1 × 1 n + 1 + n × 1 n + 1 = 1 = O ( 1 ) 1 \times \frac{1}{n+1}+1 \times \frac{1}{n+1}+\cdots+1 \times \frac{1}{n+1}+n \times \frac{1}{n+1}=1=O(1) 1×n+11+1×n+11++1×n+11+n×n+11=1=O(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)。这就是均摊分析的大致思路。具体来说:

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度

很多数据结构和算法的书籍对于平均时间复杂度和均摊时间复杂度进行了区分,但个人认为,均摊时间复杂度就是一种特殊的平均时间复杂度,没必要花太多精力去区分它们。最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。

课后思考

分析一下下面这个add()函数的时间复杂度:

// 全局变量,大小为10的数组array,长度len,下标i。
int array[] = new int[10];
int len = 10;
int i = 0;
// 往数组中添加一个元素
void add(int element) {
    if (i >= len) { // 数组空间不够了
        // 重新申请一个2倍大小的数组空间
        int new_array[] = new int[len*2];
        // 把原来array数组中的数据依次copy到new_array
        for (int j = 0; j < len; ++j) {
            new_array[j] = array[j];
        }
        // new_array复制给array,array现在大小就是2倍len了
        array = new_array;
        len = 2 * len;
    }
    // 将element放到下标为i的位置,下标i加一
    array[i] = element;
    ++i;
}

参考答案(先上结果):
最好情况时间复杂度:O(1)
最坏情况时间复杂度:O(n)
平均情况时间复杂度:O(1)
均摊时间复杂度:O(1)

具体过程可以参考用户“阿杜S考特”的分析:
当i < len时, 即 i = 0,1,2,…,n-1的时候,for循环不走,所以这n次的时间复杂度都是O(1);
当i >= len时, 即 i = n的时候,for循环进行数组的copy,所以只有这1次的时间复杂度是O(n);
平均情况时间复杂度(average case time complexity):

第一种计算方式:
1 + 1 + 1 + ⋯ + 1 + n n + 1 = 2 n n + 1 \frac{1+1+1+\cdots+1+n}{n+1}=\frac{2n}{n+1} n+11+1+1++1+n=n+12n
【注:式子中1+1+…+1中有n个1】,所以平均复杂度为O(1);

第二种计算方式(加权平均法,又称期望):
1 × 1 n + 1 + 1 × 1 n + 1 + ⋯ + 1 × 1 n + 1 + n × 1 n + 1 = O ( 1 ) 1 \times \frac{1}{n+1}+1 \times \frac{1}{n+1}+\cdots+1 \times \frac{1}{n+1}+n \times \frac{1}{n+1}=O(1) 1×n+11+1×n+11++1×n+11+n×n+11=O(1)
所以加权平均时间复杂度为O(1);

第三种计算方式(均摊时间复杂度):前n个操作复杂度都是O(1),第n+1次操作的复杂度是O(n),所以把最后一次的复杂度分摊到前n次上,那么均摊下来每次操作的复杂度为O(1)。


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值