时间空间复杂度

浅学数据结构与算法


时间空间复杂度

复杂度分析

什么是复杂度?

  1. 数据结构和算法解决是“如何让计算机更快时间、更省空间的解决问题”.
  2. 因此需从执行时间和占用内存空间两个维度来评估数据结构和算法的性能.
  3. 分别用时间复杂度和空间复杂度两个概念来描述性能问题,二者统称为复杂度.
  4. 复杂度描述的是算法执行时间(或占用内存空间)与数据规模的增长关系.

为什么需要复杂度分析?

  1. 测试结果非常依赖测试环境

    ​ 当我们的算法在不同的测试环境下面有不同的结果的时候,很难判断出该算法的好坏情况.比如,用一段代码在不同的电脑上跑,一台电脑是用 i9 的处理器,另一台是i3的处理器,那么它们的差距肯定很大,i9的处理能力肯定比i3的要快很多.因此无法正确的判断算法的好坏.

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

    ​ 比如使用排序算法来举例,不同的测试数据,它的数据的有序程度不一样,就好比测试数据1,2,3,4,5,6,77,6,5,4,3,2,1做排序比较,这样的数据,前者执行时间很短,后者执行时间很长.另外, 如果测试的数据规模太小了,测试结果就无法真实的反映算法的性能.对于小规模的数据排序,插入排序可能会比快速排序快.

    帮助理解:

    1. 和性能测试相比,复杂度分析有不依赖执行环境、成本低、效率高、易操作、指导性强的特点.
    2. 掌握复杂度分析,将能编写出性能更优的代码,有利于降低系统开发和维护成本.

​ 因此,综合考虑上面的两个因素,我们需要具体的测试数据来测试我们编写的算法,就可以粗略的估计算法的执行效率的方法.因此引入时间,空间复杂度分析方法.

大O复杂度表示法

​ 算法的执行效率,简单来说就是算法代码的执行时间. 从 CPU 的角度来看,每一行代码都执行着类似的操作:读数据-运算-写数据. 因此 我们可以将每行代码的执行时间假设为单位执行时间,设为\(unit\_time\).

 int cal(int n) {
   int sum = 0;				// 执行 1 次 	即 1 个 unit_time
   int i = 1;				// 执行 1 次	即 1 个 unit_time
   int j = 1;				// 执行 1 次	即 1 个 unit_time
   for (; i <= n; ++i) { 	// 执行 n 次	即 n 个 unit_time
     j = 1;					// 执行 n 次	即 n 个 unit_time
     for (; j <= n; ++j) { 	// 执行 n*n 次	即 n*n 个 unit_time
       sum = sum +  i * j;	// 执行 n*n 次	即 n*n 个 unit_time
     }
   }
 }

定义整段代码的执行时间为T(n), 则有

\[T(n) = 1 + 1 + 1 + n + n + n^2 + n^2 = 2n^2+2n+3 \]

即这段代码的执行时间为$T(n) =(2n^2+2n+3)*unit_time \(​​​​,\)unit_time$​​​​的具体是多少,我们无需关心,根据上面的推导过程,总结出一个规律: 所有代码的执行时间 T(n) 与每行代码的执行次数 f(n) 成正比.将此规律总结成一个公式,即:

\[T(n) = O(f(n)) \]

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

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

​ 当数据规模很大, 即当\(n→+∞\)​​​​​时, 由于公式中的低阶、常量、系数三部分对算法的执行时间增长趋势影响程度不大,所以都可以忽略.我们只需要记录一个最大量级就可以了,如果用大 O 表示法表示刚刚那段代码时间复杂度,就可以记为:\(T(n) = O(n^2)\)​​.

就比如上面的例子: 由\(T(n) = O(2n^2+2n+3)\)​. 在此公式中, 低阶是\(2n\), 常量是\(3\), 系数是\(2\),这些东西都可以忽略不计,也就是说:\(T(n)=O(2n^2+2n+3)=O(n^2)\)​​

时间复杂度分析

怎么分析时间复杂度?

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

    int cal(int n) { 
        int sum = 0; 	// 执行 1 次 	即 1 个 unit_time
        int i = 1;		// 执行 1 次 	即 1 个 unit_time
        for (; i <= n; ++i) { 	// 执行 n 次 	即 n 个 unit_time
            sum = sum + i; 		// 执行 n 次 	即 n 个 unit_time
        } 
        return sum;		// 执行 1 次 	即 1 个 unit_time
    }
    

    这段代码的时间复杂度就是\(T(n)=O(2n+3)\)​,按照上面的方法,常量,系数,低阶忽略不计,这段代码最终的时间复杂度就是\(T(n)=O(n)\)​.

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

    int cal(int n) {
       int sum_1 = 0;		// 执行 1 次 	即 1 个 unit_time
       int p = 1;			// 执行 1 次 	即 1 个 unit_time
    /*
    	这个代码执行了100次,与n规模无关,所以他是常量
        就算这个循环里面的循环次数是10000还是1000000,只要是一个已知的数据,与n无关,那么它就是常量级的执行时间
        当 n 无限大的时候,就可以忽略.尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉.因为它本身对增长趋势并没有影响.
    */
       for (; p < 100; ++p) {// 执行 100 次 即 100 个 unit_time
         sum_1 = sum_1 + p;	// 执行 100 次 即 100 个 unit_time
       }
    
       int sum_2 = 0;	// 执行 1 次 即 1 个 unit_time
       int q = 1;		// 执行 1 次 即 1 个 unit_time
       for (; q < n; ++q) {	// 执行 n 次 即 n 个 unit_time
         sum_2 = sum_2 + q;	// 执行 n 次 即 n 个 unit_time
       }
     
       int sum_3 = 0;	// 执行 1 次 即 1 个 unit_time
       int i = 1;		// 执行 1 次 即 1 个 unit_time
       int j = 1;		// 执行 1 次 即 1 个 unit_time
       for (; i <= n; ++i) {	// 执行 n 次 即 n 个 unit_time
         j = 1; 				// 执行 n 次 即 n 个 unit_time
         for (; j <= n; ++j) {	// 执行 n*n 次 即 n*n 个unit_time
           sum_3 = sum_3 +  i * j;//执行 n*n 次,即 n*n个unit_time
         }
       }
     
       return sum_1 + sum_2 + sum_3;// 执行 1 次 即 1 个unit_time
     }
    

    ​ 这段代码的时间复杂度就是\(T(n)=O(100+2)+O(2n+2)+O(2n^2+2n+3)+O(1)\)​​​ ,按照上面的方法,常量,系数,低阶忽略不计,这段代码最终的时间复杂度就是\(T(n)=O(n^2)\)​​​.

    ​ 综合上面的代码,可以总结出: 总的时间复杂度就等于量级最大的那段代码的时间复杂度. 将其抽象为公式,就是

    ​ $$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)))$$

    举个例子:

    ​ 假设:\(T1(n)=O(2n+1),T2(n)=O(2n^2+3n+4)\)

    ​ 则\(T(n)=T1(n)+T2(n)=O(2n+1) + O(2n^2+3n+4)\)

    ​ 因为\(2n+1 < 2n^2+3n + 4\)

    ​ 所以\(max(O(2n+1), O(2n^2+3n + 4))) = O(2n^2+3n + 4)\)

    ​ 因此\(T(n)=O(2n^2+3n+4)\)

    ​ 即\(T(n) = O(n^2)\)

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

    若\(T1(n)=O(f(n)),T2(n)=O(g(n));\)​

    则 \(T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)).\)

还是用上面的例子:

​ 假设:\(T1(n)=O(2n+1),T2(n)=O(2n^2+3n+4)\)​

​ 为方便计算,去掉常量,系数,低阶, :去掉这些东西不会影响最终的复杂度结果

​ 得到\(T1(n) = O(n), T2(n) = O(n^2)\)

​ 则\(T(n)=T1(n)*T2(n)=O(n) * O(n^2)\)

​ 则\(T(n) =O(n) * O(n^2)=O(n*n^2)\)

​ 即\(T(n) = O(n^3)\)​

若有不解,请对自行计算\(O((2n+1)*(2n^2+3n+4))\) ,其结果依旧为\(O(n^3)\)​

落实到具体的代码上,我们可以把乘法法则看成是嵌套循环

int cal(int n) {
   int ret = 0; 	// 执行 1 次 即 1 个 unit_time
   int i = 1;		// 执行 1 次 即 1 个 unit_time
   for (; i < n; ++i) {	// 执行 n 次 即 n 个 unit_time
     ret = ret + f(i);	// 执行 n 次 即 n 个 unit_time
   } 
 } 
 // 执行一次 f()函数的复杂度为O(n)
 int f(int n) {
  int sum = 0;		// 执行 1 次 即 1 个 unit_time
  int i = 1;		// 执行 1 次 即 1 个 unit_time	
  for (; i < n; ++i) {	// 执行 n 次 即 n 个 unit_time
    sum = sum + i;		// 执行 n 次 即 n 个 unit_time
  } 
  return sum;		// 执行 1 次 即 1 个 unit_time
 }

单独看 cal() 函数.假设 f() 只是一个普通的操作,那第 4~6 行的时间复杂度就是,\(T1(n) = O(n)\)​.但 f() 函数本身不是一个简单的操作,它的时间复杂度是\(T2(n) = O(n)\)​​,所以,整个 cal() 函数的时间复杂度就是\(T(n) = T1(n) * T2(n) = O(n*n) = O(n^2)\)​

比较常见的时间复杂度量级

时间空间复杂度

对于上面的复杂度量级,可以分为两类,即多项式量级非多项式量级.其中,非多项式量级只有两个:\(O(2^n) 和 O(n!)\)​ , 而一般时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非确定多项式)问题. 当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长. 所以,非多项式时间复杂度的算法其实是非常低效的算法.

常见的多项式时间复杂度

1.\(O(1)\)

​ \(O(1)\) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码.

int a = 0;		// 执行 1 次 即 1 个 unit_time
int b = 1;		// 执行 1 次 即 1 个 unit_time	
int sum = a + b; // 执行 1 次 即 1 个 unit_time	

这段代码的时间度为\(O(1)\), 而不是\(O(3)\)​

总结: 只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1).

帮助理解: 一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是\(Ο(1)\)​.

值得注意的是: 即使存在循环语句,如果循环的次数是一个常量,那么它也是\(O(1)\) ,比如下面的代码:

int cal(){
    int sum = 0;		// 执行 1 次 	即 1 个 unit_time
   	int p = 1;			// 执行 1 次 	即 1 个 unit_time
   	for (; p < 100; ++p) {// 执行 100 次 即 100 个 unit_time
     sum = sum + p;	// 执行 100 次 即 100 个 unit_time
   }
}

这个代码执行了100次,与n规模无关,所以他是常量,他的时间复杂度就是\(O(1)\)​
就算这个循环里面的循环次数是10000还是1000000,只要是一个已知的数据,与n无关,那么它就是常量级的执行时间
当 n 无限大的时候,就可以忽略.尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉.因为它本身对增长趋势并没有影响

2. \(O(logn)与O(nlogn)\)​​​​

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度.

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

​ 这里的循环变量每一次都会发生变化,代码执行 1 到\(x\)​​​​​ 次,\(i\)​​​​​​的值的变化情况为\(1,2,4,...,2^x\)​​​​​​,即\(2^0,2^1,2^2,...,2^x\)​​​​​​,可以看出,\(i\)​​​​​​的变化序列就是一个等比数列.

​ 那么这段代码执行了多少次? 看他的上标,第一次执行的时候,\(2\)的上标我们设定为\(x\),\(i\)的具体值为\(i\),因此,\(2^0 = 1\), 可以表示成\(2^x=i\), 此时\(x=0,i=1\).

​ 因此,当\(2\)​​​的上标为\(x\)​​时,这个循环就执行了\(x + 1\)​​​次,此时\(i的值为 i = 2^x\)​​​

​ 也就是说,我们要知道这段代码执行的次数,那么只需要计算出\(x\)​​​​​的值然后在加1,即可知道这段代码执行了多少次.即计算\(x = log_2i+1\)​​​​ .

​ 当\(i\)​​​等于\(1\)​​​的时候,\(x = log_21+1=0+1 = 1\)​​​​​, 代码执行了一次

​ 当\(i\)​​​​ 等于 16的时候,\(x=log_216+1 = 4+1 = 5\)​​, 代码执行了五次

问:当\(i\)的问题规模为\(n\)时,代码执行的次数是多少? 答:\(x = log_2n+1\)​

即这段代码的时间复杂度就是\(O(log_2n+1)\)​​,而 忽略常量,系数,低阶 后,也就是我们熟知的\(O(logn)\)​​.​

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

这段代码的时间复杂度是\(O(log_3n)\).

实际上,不管是以2为底、以3为底,以10为底,都可以将所有对数阶的时间复杂度记为\(O(logn)\).

​ 为啥?因为对数之间可以进行相互转换的.且看如何转换:

  1. \(log_3n = x\),则有\(3^x=n\)
  2. 等式两边取以2为底的对数,则有\(log_23^x=log_2n\)​
  3. 则有\(x\cdot log_23=log_2n\)​​, 即\(x = \frac{log_2n}{log_23}\)​
  4. \(\because \log_3 n = x , \therefore \log_3 n=\frac{\log_2 n}{log_23}\)​​​
  5. \(\therefore log_3n = \frac{1}{log_23} \cdot log_2n\)​​​ 是一个常量, 我们可以记为\(C\)
  6. 即\(log_3n = C \cdot log_2n\) , 将 常量,系数,低阶都忽略后,最后都能得到相同的结果:
  7. \(O(\log_3 n) = O(C \cdot \log_2 n) = O(\log n)\)​​
test 3
int i = 1;
for (i=1; i<=n; i++){
    int j=1;
     while (j <= n)  {
       j = j * 2;
     }
}

这一段代码的时间复杂度就是\(O(n\log n)\)​

test 4
int cal(int n) {
   int ret = 0; 	// 执行 1 次 即 1 个 unit_time
   int i = 1;		// 执行 1 次 即 1 个 unit_time
   for (; i < n; ++i) {	// 执行 n 次 即 n 个 unit_time
     ret = ret + f(i);	// 执行 n 次 即 n 个 unit_time
   } 
 } 
 // 执行一次 f()函数的复杂度为O(logn)
 int f(int n) {
  int sum = 0;		// 执行 1 次 即 1 个 unit_time
  int i = 1;		// 执行 1 次 即 1 个 unit_time	
  for (; i < n; i=i+2) {	// 执行 logn 次 即 logn 个 unit_time
    sum = sum + i;		// 执行 logn 次 即 logn 个 unit_time
  } 
  return sum;		// 执行 1 次 即 1 个 unit_time
 }

这段代码的时间复杂度也是\(O(n\log n)\)​

​ 如果一段代码的时间复杂度是\(O(\log n)\)​​​​,我们循环执行\(n\)​​​​遍,时间复杂度就是\(O(n\log n)\)​​​​了.而且,\(O(n\log n)\)​​​​ 也是一种非常常见的算法时间复杂度.比如,归并排序、快速排序的时间复杂度都是\(O(n\log n)\)​​​​.

3.\(O(m+n)与O(m*n)\)​

​ 还有一种跟前面都不一样的时间复杂度,代码的复杂度由两个数据的规模来决定.

int cal(int m, int n) {
    // 以下代码数据规模为m
  int sum_1 = 0;
  int i = 1;
  for (; i < m; ++i) {
    sum_1 = sum_1 + i;
  }

  	// 以下代码数据规模为n
  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)\)​​.

针对以上情况,原来的加法法则就不正确了,因此我们需要将加法法则改为\(T1(m)+T2(n)=O(f(m)+g(n))\)​​,

而乘法法则还是不变的:\(T1(m)*T2(n)=O(f(m)*g(n))\)

空间复杂度分析

基本概念

算法的存储空间随数据规模之间的增长关系,所以,也叫作渐进空间复杂度(asymptotic space complexity),简称空间复杂度.

示例

void print(int n) {
  int i = 0;	// 一个单位的存储空间
  int[] a = new int[n];	// 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(\log n),O(n\log n)\)​​.空间复杂度分析比时间复杂度分析要简单很多,基本上时间复杂度分析出来后,看一下分配资源的代码位置,空间复杂度就出来了.

小结

​ 复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,可以粗略地表示,越高阶复杂度的算法,执行效率越低. 常见的复杂度并不多,从低阶到高阶有:\(O(1) < O(\log n) < O(n) < O(n\log n) < O(n^2 )\)​​​​.请参考下列各个函数曲线图.

时间空间复杂度

时间复杂度扩展

  • 最好情况时间复杂度(best case time complexity)

  • 最坏情况时间复杂度(worst case time complexity)

  • 平均情况时间复杂度(average case time complexity)

  • 均摊时间复杂度(amortized time complexity)

最好、最坏情况时间复杂度

// 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;
}

这段代码功能是要在数组\(array\)​中查找出与\(x\)​的值相等的第一个元素的位置, 这段代码的最好情况时间复杂度是\(O(1)\)​, 最坏情况时间复杂度是\(O(n)\)​.

最好情况时间复杂度, 就是在最理想的情况下,执行这段代码的时间复杂度. 即数组中的第一个元素正好是要查找的变量\(x\), 那么就无需遍历剩下的的n-1个数据了.

最坏情况时间复杂度,就是在最糟糕的情况下,执行这段代码的时间复杂度.即数组中的不存在要查找的变量x, 也就是说要遍历一整个数组的情况.

平均时间复杂度

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

\[\cfrac{1+2+3+\cdots+n+n}{n+1} = \cfrac{n(n+3)}{2(n+1)} \]

忽略系数, 低阶, 常量 后得到的平均时间复杂度为 O(n).

​ 上面这个解法虽然结果是对的,但是它忽略了一个问题,那就是: 这 n+1种情况出现的概率并不是一样的.

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

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

\[1\times \cfrac{1}{2n} + 2\times\cfrac{1}{2n}+3\times\cfrac{1}{2n}+\cdots+n\times\cfrac{1}{2n}+n\times \cfrac{1}{2} = \cfrac{3n+1}{4} \]

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

​ 上式中 在要查找的变量x,在数组第一个位置的期望值为\(1\times{1 \over 2 n}\),意义为: 要遍历的元素的个数 乘以 变量x在该位置的概率, 同理 变量x不在数组中的出现的期望值:要遍历n个元素 乘上 变量x不在数组中的概率, 即\(n\times {1\over 2}\)

​ 因此上面那段代码的加权平均值就是\({3 n+1 \over 4}\),其加权平均时间复杂度还是\(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 循环遍历数组求和,并清空数组(即,将count重置为1即可),将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。

​ 最好情况时间复杂度 是\(O(1)\)​, 即数组有空闲空间,将元素之间存放到数组中下标为count的位置即可.

​ 最坏情况时间复杂度是\(O(n)\)​​​​​​​, 即数组满时, 也就是说count == array.length, 这时候就要进入if语句中,将数组遍历求和,然后将求和结果存入array[0]中,在将数据插入, 所以最坏时间复杂度是\(O(n)\)​​.

​ 平均时间复杂度是\(O(1)\)​​​​. 根据数据插入的位置的不同,我们可以分为\(n\)​​​​种情况,每种情况插入的概率为,每种情况的时间复杂度是\(O(1)\)​​​​​。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是\(O(n)\)。而且,这\(n+1\)种情况发生的概率一样,都是\({1\over n+1}\)​。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是\(O(1)\)​​,即:

\[1\times\cfrac1{n+1}+1\times\cfrac1{n+1}+\cdots+1\times\cfrac1{n+1}+n\times\cfrac1{n+1} = \cfrac{2n}{n+1} \]

之前的find()insert()两个函数作对比, 会发现有两个差别:

  • find()只有极端的情况,即要查找的元素就在数组第一个位置的时候才会有\(O(1)\)的复杂度,而insert(),大部分情况下时间复杂度都为\(O(1)\),只有数组元素满的时候才有O(n)的复杂度.
  • 对于 insert() 函数来说,\(O(1)\)时间复杂度的插入和\(O(n)\)​时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个\(O(n)\)插入之后,紧跟着\(n-1\)​个\(O(1)\)​​​​ 的插入操作,循环往复.

针对这种特殊的场景,可以使用更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度就是均摊时间复杂度.

均摊分析的大致思路

​ 每一次\(O(n)\)的插入操作,都会跟着\(n-1\) 次\(O(1)\)的插入操作,所以把耗时多的那次操作均摊到接下来的\(n-1\) 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是\(O(1)\)

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

​ 均摊时间复杂度就是一种特殊的平均时间复杂度,均摊时间复杂度是对平均时间复杂度的补充, 即摊还分析法, 专门解决大部分情况下时间复杂度均匀,而少数不均匀的问题.

平均与均摊时间复杂度的区别

平均时间复杂度

  • ​ 代码在不同情况下复杂度出现量级差别,则用代码所有可能情况下执行次数的加权平均值表示。

均摊时间复杂度

  • 代码在绝大多数情况下是低级别复杂度,只有极少数情况是高级别复杂度
  • 低级别和高级别复杂度出现具有时序规律。均摊结果一般都等于低级别复杂度。

课后一练

// 全局变量,大小为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;
}

分析

前提条件:假设数组的长度为\(n\)

​ 最好时间复杂度是\(O(1)\)​ , 即添加的元素个数不超过数组长度情况.

​ 最坏时间复杂度是\(O(n)\), 即添加的元素个数超过数组的当前长度,需要加长数组,并且将旧数组的所有元素都复制到新数组中.

​ 平均时间复杂度\(O(1)\)​​​​​​​,即令\(n\)​​​次的插入情况​和数组空间不够的特殊情况, 出现的概率为\({1\over n+1}\)​.即:

\[n\times(1\times\cfrac1{n+1}) + n\times\cfrac1{n+1} = \cfrac{2n}{n+1} = O(1) \]

​ 均摊时间复杂度O(1),可以大概这么理解:

\[\cfrac{(n*O(1) + O(n))}{n+1} = \cfrac{O(2n))}{n+1} =O(1) \]

当然, 这样想也可以: 由于最好的时间复杂度是\(O(1)\)​,因此,均摊的时间复杂度也是\(O(1)\).

上一篇:数据大屏可视化-DataV图表-排名轮播表


下一篇:fckeditor富文本编辑器支持从word复制粘贴保留格式和图片的插件