NOIP学习小站
西安交通大学附属中学航天学校

算法时间复杂度

在计算机科学中,算法的时间复杂度是一个函数,它定性描述该算法的运行时间。时间复杂度常用大O符号表述,一般描述为与问题规模\(n\)相关的函数\(O(f(n))\),但不包括这个函数的低阶项和最高阶系数。

一、时间复杂度的定义

一个算法花费的时间与算法中基本语句的执行次数成正比例,哪个算法中基本语句执行次数多,它花费时间就多。一个算法中的基本语句执行次数称为语句频度或时间频度,记为\(T(n)\)。

一般情况下,算法中基本操作重复执行的次数是问题规模\(n\)的某个函数,用\(T(n)\)表示,若有某个辅助函数\(f(n)\),使得当\(n\)趋近于无穷大时,\(T(n)/f (n)\)的极限值为不等于零的常数,则称\(f(n)\)是\(T(n)\)的同数量级函数(大学高等数学知识),而\(O(f(n)) \)为算法的渐进时间复杂度,简称时间复杂度。

在各种不同算法中,若算法中语句执行次数为一个常数,则时间复杂度为\(O(1)\)或\(O(c)\)(\(c\)为常量),另外,在时间频度\(T(n)\)不相同时,时间复杂度\(O(f(n))\)有可能相同,如\(T(n)=n^2+3n+4\)与\(T(n)=4n^2+2n+1\)它们的频度不同,但时间复杂度相同(时间复杂度不考虑\(T(n)\)的低阶项和最高阶项系数),都为\(O(n^2)\)。

常见的时间复杂度有:\(O(1)\)、\(O(c)\)(\(c\)为常量)、\(O(\log{n})\)、\(O(n)\)、\(O(n\log{n})\)、\(O(m*n)\)、\(O(n^2)\)、\(O(n^3)\)、\(O(2^n)\)、\(O(n!)\)等。解决同一个问题的不同算法,时间复杂度越低,则算法效率越高。

二、时间复杂度的计算方法

根据定义,可以归纳出时间复杂度的基本计算步骤:

  1. 计算出基本操作的执行次数\(T(n)\):
    基本操作即算法中的每条语句(注意调用函数很特别,调用函数的语句往往不能简单认为是1次基本操作),语句的执行次数也叫做语句的频度。在做算法分析时,一般要考虑最坏的情况
  2. 计算出\(T(n)\)的数量级:
    求\(T(n)\)的数量级,只要将\(T(n)\)进行如下一些操作:忽略常量、低次幂,去掉最高次幂的系数,得到\(T(n)\)的数量级函数\(f(n)\)。
  3. 用\(O(f(n))\)来表示时间复杂度。

简而言之:如果一个算法的执行次数是 \(T(n)\),那么只保留最高次项,同时忽略最高项的系数后得到函数\( f(n)\),此时算法的时间复杂度就是 \(O(f(n))\)。

举一个例子来说明:

#include<iostream>
using namespace std;
int main()
{
	int s1,s2,n,i,j;
	s1 = 0;
	s2 = 0;
	cin>>n;
	for(i=1;i<=n;i++){
		s1 += i;
		for(j=1;j<=n;j*=2){
			s2 += j;
		}
	}
	cout<<s1<<" "<<s2<<endl;			 
    return 0;
} 

程序5、6、7、8行语句的频度均为1,外层循环赋初值i=1频度为1,这部分总频度为5;

循环体中i<=n、s1+=i、j=1、i++,这4句执行次数均为\(n\)(严格计算的话,条件判断语句执行次数是\(n+1\)次),这部分总频度为\(4n\);

内层循环中j<=n、s2+=j、j++,这3句执行次数均为\(n\log_2{n}\)(高中数学对数知识),这部分总频度为\(3n\log_2{n}\);

第15、16行语句的频度为1,这部分总频度为2。

经过上面的分析,可知本程序\(T(n)=5+4n+3n\log_2{n}+2\),整理得\(T(n)=3n\log_2{n}+4n+7\),忽略掉\(T(n)\)中的常量、低次幂和最高次幂的系数,得到\(T(n)\)的数量级函数\(f(n) = n\log_2{n}\),所以算法的时间复杂度是\(O(n\log_2{n})\),可以再进一步简记为\(O(n\log{n})\)。

决定算法复杂度的是执行次数最多的语句,一般也是最内层循环的语句,上述步骤可以简化为:

  1. 找到执行次数最多的语句(要考虑最坏的情况
  2. 计算该语句执行次数的数量级函数\(f(n)\)
  3. 用\(O(f(n))\)来表示算法时间复杂度

仍然以上述程序为例,执行次数最多的语句是内层循环体中的:s2 += j;,执行次数的数量级\(f(n)=n\log_2{n}\),所以算法时间复杂度为:\(O(n\log_2{n})\)。

此外要注意:算法时间复杂度分析要考虑最坏的情况,以下面程序为例:

#include<iostream>
using namespace std;
int main()
{
    int n,f;
	cin>>n>>f;
	for(int i=1;i<=n;i++){
		if(f==0){
			for(int j=1;j<=n;j++){
				//...考虑最坏情况,这里是执行次数最多的语句 
			}
		}else{
			//...
		}
	}             
    return 0;
}

最坏情况时(输入的f值为0),执行次数最多的是双重循环体中的语句,算法的时间复杂度为\(O(n^2)\)。

但是要注意,并不是出现循环,算法的时间复杂度就是\(O(n)\)、\(O(n*m)\)、\(O(n^2)\)之类的,也有可能是\(O(c)\)(\(c\)为常量),来看前面求“水仙花数”的程序:

#include<iostream>
using namespace std;
int main()
{
    for(int i=100;i<=999;i++){
        int g = i%10;
        int s = i/10%10;
        int b = i/100%10;
        if(g*g*g+s*s*s+b*b*b==i) cout<<i<<" ";
    }
    return 0;
} 
#include<iostream>
using namespace std;
int main()
{
    for(int b=1;b<=9;b++){
        for(int s=0;s<=9;s++){
            for(int g=0;g<=9;g++){
                //百位上的数b,十位上的数s,个位上的数g
                int n = b*100+s*10+g;
                if(g*g*g+s*s*s+b*b*b==n) cout<<n<<" ";
            }
        }
    }
    return 0;
} 

两段程序执行次数最多的语句都只执行900次,因此时间复杂度都是\(O(c)\)。

三、常见时间复杂度举例

1.\(O(1)\)或\(O(c)\)

前面计算\(\sum\limits_{i=1}^{n}i=1+2+3+...+n\)的结果,如果直接使用高中数学知识的等差数列求和公式\(\sum\limits_{i=1}^{n}i=\frac{n \times (n+1)}2\),算法的时间复杂度是\(O(c)\):

#include<iostream>
using namespace std;
int main()
{
    int n;
	cin>>n;
	cout<<n*(n+1)/2<<endl;   //n和n+1肯定有一个是偶数,n*(n+1)能被2整除      
    return 0;
}

2.\(O(\log{n})\)

求解方法是用变量\(a\)追踪木棍的长度,循环中每次折半,那么算法的时间复杂度是\(O(\log_2{n})\):

#include<iostream>
using namespace std;
int main()
{
    int a,days = 1;
    cin>>a;
    while(a>1){
        days ++;
        a /= 2;
    }
    cout<<days;
    return 0;
} 

3.\(O(n)\)

仍然是上面累加求和的例子\(\sum\limits_{i=1}^{n}i\),如果使用一重循环来累加求和,那么算法的时间复杂度是\(O(n)\):

#include<iostream>
using namespace std;
int main()
{
    int n,s = 0;
	cin>>n;
	for(int i=1;i<=n;i++) s += i;
	cout<<s<<endl;          
    return 0;
}

4.\(O(n\log{n})\)

对于1~\(n\)的每个正整数\(n_i\),计算\(2^{x_i} \ge n_i\)的最小值\(x_i\),如果不用\(cmath\)中的\(log2\)函数,可以这样编程:

#include<iostream>
using namespace std;
int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++){
        int j = 0,t = 1;    //t用来保存\(2^j\)的结果
        while(t<i){
            t *= 2;
            j++;
        }
        cout<<j<<endl;
    }   
    return 0;
}

执行次数最多的语句是内层循环的语句(并且是外层循环最后1次时),可知最大执行次数是\(n*\log_2{n}\) 次,那么算法的时间复杂度是\(O(n\log_2{n})\)。可以进一步优化算法,将时间复杂度降低到\(O(n)\):

#include<iostream>
using namespace std;
int main()
{
    int n,j = 0,t = 1;     //t用来保存\(2^j\)的结果
    cin>>n;
    for(int i=1;i<=n;i++){
    	cout<<j<<endl;
    	if(i>=t) j++,t *= 2;
	}
    return 0;
}

5.\(O(n*m)\)

输出\(n\)行\(m\)列由字符*拼成的字符矩形。借助双重循环实现,外层\(n\)次循环控制行数,内层\(m\)次循环控制每行字符数,那么算法的时间复杂度是\(O(n*m)\):

#include<iostream>
using namespace std;
int main()
{
    int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++) cout<<"*";
		cout<<endl;
	}        
    return 0;
}

6.\(O(n^2)\)

前面章节“阶乘累计求和”的例子,外层\(n\)次循环用来累加,内层\(i\)次循环计算\(i!\):

#include<iostream>
using namespace std;
int main()
{
    long long t,s = 0;
    int n;
    cin>>n;
    for(int i=1;i<=n;i++){
        t = 1;
        for(int j=1;j<=i;j++) t *= j;
        s += t; 
    }
    cout<<s;
    return 0;
} 

执行次数最多的语句是内层循环的语句,可知执行次数是\(\sum\limits_{i=1}^{n}i = 1+2+3+...+n = \frac{n \times (n+1)}{2}\) 次,那么算法的时间复杂度是\(O(n^2)\)。当然可以进一步优化程序,降低算法的时间复杂度到\(O(n)\):

#include<iostream>
using namespace std;
int main()
{
    long long t = 1,s = 0;
    int n;
    cin>>n;
    for(int i=1;i<=n;i++){
        t *= i;    //t是i! 
        s += t; 
    }
    cout<<s;
    return 0;
}

7.\(O(n^3)\)

团队里有\(n\)位选手,每位选手都参加了\(n\)次训练,每次训练分为\(n\)次单元,现在给定每位选手每次训练每个单元的持续时间,计算所有选手平均单元训练时间。很显然,使用三重循环,在最内层循环体中输入数据并累加,那么算法的时间复杂度是\(O(n^3)\):

#include<cstdio>
int main()
{
    int n,m,s = 0;
    scanf("%d",&n);
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			for(int k=1;k<=n;k++){
				scanf("%d",&m);
				s += m;
			}
		}
	}
	printf("%.3f",1.0*s/(n*n*n));
    return 0;
}