前置内容2:复杂度分析

注意:本文中所提到的\(\log n\),都指的是\(\log _2n\),另外所有除法都为整除,此外不讨论多种\(O\)记号写法所代表的意义之间的差别。

对于一个确定的问题,衡量一种解题方法的标准有两种:一是正确性,二是运行效率。

如果某个算法是错误的,那么没有讨论的价值。而效率的衡量是相对不容易的,因为这涉及到硬件等因素的影响。那么我们如何从这个角度衡量算法优劣呢?


主要来看看时间方面的效率。

可以发现,对于一组确定的数据,某个非随机化的程序在不同的机器上运行的时间可能不同,但是流程是相同的。换句话说,每一次的操作都是相同的。于是我们就可以用程序执行基本操作的次数来表示它的运行效率。注意基本操作指的是四则运算,单次函数调用,条件判断等操作;循环,递归函数等不算基本操作。

一般来说,这个基本操作的次数应该是输入数据规模\(n\)的函数\(T(n)\)。我们一般不容易直接算出\(T(n)\)的具体值,此时我们需要一个能够近似表达\(T(n)\)且较为容易计算的另一个函数\(f(n)\),称为时间复杂度。记作\(T(n)=O(f(n))\),或称\(T(n)\)\(f(n)\)同阶。

如果按照百科的定义,那么\(f(n)\)的选取应该满足:\(\lim \limits_{n\to+\infty}\frac{T(n)}{f(n)}=c\),其中\(c\)为一个非零常数。这表示,当\(n\)足够大时,\(T(n)\)\(f(n)\)的差距只在系数上体现。

通俗的说,\(f(n)\)必须保留\(T(n)\)的最高次项,同时可以删去其他项以及最高次项的系数。注意这里\(T(n)\)并不一定是多项式,因此所谓最高次项,指的就是当\(n\)趋于无穷时\(T(n)\)中最大的一项。那么我们可以得到一些常用函数之间的关系(小于号表示当\(n\)趋于无穷时结果的大小):

\(1<\log n<\sqrt n<n<n\log n<n^2<...<n^k<C_n^k<2^n<...<k^n<n!\)

其中\(k\)都为大于2的常数。

例如,若\(T(n)=c_kn^k+c_{k-1}n^{k-1}+...+c_1n^1+c_0\)\(c_i\)是常数,这是一个多项式函数,那么\(T(n)=O(n^k)\)

除了上面列举的这些以外,还有一些可能会出现的时间复杂度,如\(O(\alpha(n))\)(并查集均摊复杂度),\(O(\log \log n)\)(埃氏筛法等),\(O(n\log^2 n)\)(树链剖分/树套树/点分治/其他数据结构乱堆),\(O(n\times 2^n)\)(常见状压dp/部分容斥计数题)等。

在一般的比赛和OJ中,某个复杂度能够做到的最大数据规模是相对确定的。但是也只以此作为参考,因为较大或较小的常数是会对运行效率产生影响的。另外,下面表中的值是近似的,一般稍微超出一点也可做,同时也不代表在规模内就一定可做。(单纯的\(O(1)\)\(O(\log n)\)不常见,且可做范围极大,不列入其内)

时间复杂度 时限\(1s\)时可做规模
\(O(\sqrt n)\) \(n\leq10^{14}\)
\(O(n)\) \(n\leq 10^7\)
\(O(n\log n)\) \(n\leq 10^6\)
\(O(n\sqrt n)\) \(n\leq 4\times 10^4\)
\(O(n^2)\) \(n\leq3\times10^3\)
\(O(n^2\log n)\) \(n\leq 10^3\)
\(O(n^3)\) \(n\leq200\)
\(O(2^n)\) \(n\leq 20\)
\(O(n!)\) \(n\leq 10\)

若输入数据规模由多个变量同时决定,那么时间复杂度函数也可以由一元转为多元:例如\(T(n,m)=O(n^2m)\)

当我们得到题目的数据范围时,也可以尝试倒着思考这道题可能需要用怎么样的时间复杂度的算法来解决,也许能够给予一定的提示。

对于以循环为主体的程序,我们可以通过分析循环来分析程序的时间复杂度。而分析循环时,我们一般分析它的执行次数。

例1:分析以下代码片段时间复杂度:

for (int i=2;i<=n;i++) {
    ans++;
}
for (int i=2;i<=n;i++) {
    ans+=i;
}

加法和自加都是基本运算,因此我们只分析两个循环的运行次数。两个循环都运行了\(n-1\)次,即\(O(n)\)次,因此总的时间复杂度为\(O(n)+O(n)=O(n)\)

例2:分析以下代码片段时间复杂度:

for (int i=2;i<=n;i++) {
    for (int j=2;j<=n;j++) {
        ans+=j;
    }
}

两个循环都运行了\(O(n)\)次,然而这次是嵌套式的循环,时间复杂度为\(O(n)\times O(n)=O(n^2)\)

对于这些主体为循环的程序来说,并列式的循环复杂度相加,嵌套式的循环复杂度相乘。而且对于两个多项式来说,满足如下运算律:

\(O(n^p)+O(n^q)=O(n^{\max(p,q)})\qquad O(n^p)\times O(n^q)=O(n^{p+q})\)

根据复杂度大O记号的定义,很容易证明这一点。

例3:分析以下代码片段时间复杂度:

for (int i=1;i<=n;i+=i) {
    for (int j=1;j<=n;j++) {
        ans+=j;
    }
}

外层循环每次\(i\)都变为原来的两倍,因此执行次数为\(O(\log n)\),内层为\(O(n)\),总时间复杂度为\(O(n\log n)\)

例4:分析以下代码片段时间复杂度:

注:本例为未经优化的埃氏筛法的模型。

for (int i=1;i<=n;i++) {
    for (int j=i;j<=n;j+=i) {
        ans+=j;
    }
}

这题可以当成一个结论来记。其实每次\(j\)的循环次数为\(O(\frac{n}{i})\)。那么总时间复杂度为\(O(\frac{n}{1}+\frac{n}{2}+...+\frac{n}{n})\)

推导过程非常复杂,需要函数极限的概念,这里略过,可以证明得如下结论:

\(O(\frac{n}{1}+\frac{n}{2}+...+\frac{n}{n})=O(\sum\limits_{i=1}^n\frac{n}{i})=O(n\log n)\)

以上是一些只与循环有关的代码的复杂度分析。下面来看看递归函数的复杂度情况。

例5:分析下列代码运行\(f(1,n)\)的时间复杂度:

注:本例为线段树/平衡树的建树模型。

void f (int l,int r) {
    if (l==r) {
        return;
    }
    int mid=(l+r)/2;
    f(l,mid),f(mid+1,r);
    return;
}

遇到这样的问题,我们设\(T(n)\)\(r-l=n\)时运行\(f(l,r)\)的复杂度,容易发现这于\(l,r\)无关(只与差有关),那么我们可以得到关于\(T(n)\)的方程:

\(T(n)=\begin{cases}2\times T(\frac{n}{2})\qquad n\ne 0\\1\qquad\qquad\ \ \ \ \ \ n=0\end{cases}\)

而我们可以将上面的式子展开:

\(T(n)=2\times T(\frac{n}{2})=2^2\times T(\frac{n}{2^2})=2^{O(\log n)}\times T(0)=O(n)\)

因此\(T(n)=O(n)\),这也说明了为什么线段树/平衡树建树时采用这种方法比一个个加入要快。

例6:分析下列代码运行\(f(1,n)\)的时间复杂度:

注:本例为归并排序等分治算法的基本模型。

void f (int l,int r) {
    if (l==r) {
        return;
    }
    for (int i=l;i<=r;i++) {
        ans++;
    }
    int mid=(l+r)/2;
    f(l,mid),f(mid+1,r);
    return;
}

\(T(n)\)\(r-l=n\)时运行\(f(l,r)\)的复杂度。本例与上例相比,多了一个\(l\)\(r\)的循环,那么有方程:

\(T(n)=\begin{cases}2\times T(\frac{n}{2})+n\qquad n\ne 0\\1\qquad\qquad\qquad \ \ \ \ \ n=0\end{cases}\)

接下来对于这个方程有两种理解方式:

(1)将上面的第一个式子进行展开:

\(T(n)=2\times T(\frac{n}{2})+n=2^2\times T(\frac{n}{2^2})+2\times\frac{n}{2}+n\)

\(T(n)=2^k\times T(\frac{n}{2^k})+\sum\limits_{i=0}^{k-1}2^i\times\frac{n}{2^i}\)

\(k=O(\log n)\)时,有:

\(T(n)=2^{O(\log n)}\times T(0)+\sum\limits_{i=1}^{O(\log n)}n=O(n+n\times\log n)=O(n\log n)\)

于是我们得到了:\(T(n)=O(n\log n)\)

(2)利用递归层数分析:

\(f(1,\frac{n}{2^k}),f(\frac{n}{2^k}+1,\frac{n}{2^k}\times 2),...,f(n-\frac{n}{2^k}+1,n)\)这些长度为\(\frac{n}{2^k}\)的函数定义为第\(k\)层函数,则一开始调用的是一个第0层函数\(f(1,n)\)。我们注意到,\(k\in [0,O(\log n)]\)内都是有意义的,而且对于每一层的调用函数来说,一定是覆盖了从1到n的所有数。这个从上面的分类标准就能看得出来,这些段覆盖了1-n。我们知道每个函数单独计算的计算量是它的长度,因此对于每一层函数来说,时间复杂度的和就是:

\(2^k\times O(\frac{n}{2^k})=O(n)\)

所以对于所有层来说,时间复杂度就是:

\(T(n)=O(n)\times O(\log n)=O(n\log n)\)

一样得到了正确结果。


(以下关于Master定理的内容参考洛谷日报文章时空复杂度分析及master定理 By Chanis,Master定理证明参考文章Master—Theorem 主定理的证明和使用

上面我们看到了两个例子,都是递归的复杂度分析,也就是某个规模下的复杂度与其分治后的规模形成递归关系,下面就这类例子进行经验总结。(主要的作用是noip初赛等...除了提高组初赛外通常情况很难遇到这样的复杂度计算)

考虑这个递推式:

\(T(n)=aT(\frac{n}{b})+f(n)\qquad (T(1)=1,\ \ a,b>1)\)

实际上就是对上面两道题的扩展,下面来根据\(a,b,f(n)\)的不同取值进行分类讨论。注意这里\(f(n)\)是一个多项式,下面记\(f(n)=O(n^d)\),也就是将\(f(n)\)直接写作\(n^d\)

这个式子实际是在说:有一个分治算法,对于规模为\(n\)的问题,每一层会分治为\(a\)个规模为\(\frac{n}{b}\)的子问题,将所有子问题结果合并并得到最终结果的过程所需复杂度为\(f(n)\),求解算法全过程的复杂度。

上面关于归并排序复杂度证明的例子已经给了我们方向,我们尝试将上面的两种方法融合,通过层数与和式展开共同解决这个问题。

第0层:即原始层,合并结果的时间复杂度为\(n^d\)级别;

第1层:共有\(a\)个子问题,每个子问题合并的复杂度为\((\frac{n}{b})^d\),总时间复杂度为\(\frac{a}{b^d}\times n^d\),注意这里利用了分离项分析法,我们把常数\(a,b\)放到一侧,变量\(n\)放到另一侧;

第2层:共有\(a^2\)个子问题,每个子问题合并复杂度为\((\frac{n}{b^2})^d\),总时间复杂度为\((\frac{a}{b^d})^2\times n^d\)

\(k\)层(\(k=\log_bn\)):共有\(a^k\)个子问题,每个子问题合并复杂度为\((\frac{n}{b^k})^d\),总时间复杂度为\((\frac{a}{b^d})^k\times n^d\),已经到达了\(n=1\)的情况,递归停止。

容易发现,这是一个等比数列,设\(q=\frac{a}{b^d}\)即:

\(T(n)=n^d\times(q^k+...+q^2+q+1)\)

利用等比数列求和的高次方差公式,我们得到:

\(T(n)=\frac{n^d\times(q^{k+1}-1)}{q-1}\)

且慢,漏掉了\(q=1\)的情况讨论,这时\(T(n)=n^d\times k=n^d\times \log_bn\),即\(T(n)=O(n^d\log_b n)\)。如果将\(\log_2b\)视作常数,则\(T(n)=O(n^d\log n)\)

下面显然是要分析\(q-1\)的正负了。

  1. \(q-1>0\),则\(T(n)=O(n^d\times q^k)=O(n^d\times \frac{a^k}{b^{dk}})\),又\(k=\log_bn\),所以\(T(n)=O(n^d\times \frac{a^k}{n^d})=O(a^k)\),于是我们可以利用对数换底公式,\(T(n)=O(a^{\log_bn})=O((n^{\log_na})^{\log_bn})=O(n^{\log_ba})\),于是我们成功得到了\(T(n)\)的表达式。
  2. \(q-1<0\),则\(T(n)=O(n^d)\),另一项为常数,对乘积无影响,非常简单。

于是我们得到了Master定理:

对于分治算法的时间复杂度:

\(T(n)=aT(\frac{n}{b})+n^d\qquad (T(1)=1,\ \ a,b>1)\)

  1. \(\log_ba<d\),则\(T(n)=O(n^d)\)
  2. \(\log_ba=d\),则\(T(n)=O(n^d\log n)\)
  3. \(\log_ba>d\),则\(T(n)=O(n^{\log_ba})\)

至此我们已经学会了分析一些基本的循环以及递归程序的时间复杂度,在写完程序后可以分析自己代码的时间复杂度,从而预测能否通过题目。

除了时间复杂度外,还有空间复杂度。空间复杂度主要由数组所占内存和递归函数层数有关。计算数组所用空间的公式是:\(sizeof(type)\times length\),其中\(type\)是int,char,bool,long long等,各有不同的占用字节数。length就是数组的大小。按照这个方式算出得到的内存值若小于题目内存限制,则至少在数组方面是可以满足题目要求的。

空间复杂度和时间复杂度类似,只保留最高次项,例如一个二维数组\(f[2\times n][n+1]\)的空间复杂度为\(O(n^2)\)。大多数情况下空间复杂度不太会成为程序优化的瓶颈,这里不多赘述了。(具体的优化内容主要会在滚动数组优化dp等具体板块提及)

至此我们已经解决开篇提出的效率问题。从时间和空间两个角度对这个问题进行了分析,得到了时间和空间复杂度的概念,这也会成为之后衡量算法优劣的最重要标准之一。

猜你喜欢

转载自www.cnblogs.com/ix35/p/11973207.html
今日推荐