Algorithm Master Road:算法的时间/空间复杂度

Posted Alex Hub

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Algorithm Master Road:算法的时间/空间复杂度相关的知识,希望对你有一定的参考价值。

很多同学都觉得算法很难,难以入门,难以理解,更难以掌握和运用,其实归根溯源,我们可以把所有的问题都通过枚举法来解决,但是受困于「时间」「空间」的因素,有的时候并不能枚举所有的情况,所以需要通过精妙的算法设计避免枚举一些显而易见错误的情况。

那么既然说到了「时间」「空间」,这篇文章就跟大家聊一下算法设计过程中必须要考虑的时间复杂度空间复杂度

什么是时间复杂度和空间复杂度?

算法的时间复杂度和空间复杂度是对算法执行效率的分析,也是一个对算法的度量单位,目的是看算法实际是否可行,并且当同一个问题有多种解法时,可以进行时间和空间性能上的比较,以便从中挑选出较优算法。

衡量算法执行效率的方法有两种:

  1. 事后统计法
  2. 事前分析法

事后统计法需要先将算法实现,然后测算其真正的时间和空间开销。这种方法的缺陷很显然,一是必须把算法转换成可执行的程序,二是时空开销的测算结果依赖于计算机的软硬件等环境因素。

虽然我们都希望自己的算法更高效,但是每设计出来一个算法都写出程序来测算时空开销显然是不现实的,所有我们通常采用事前分析法,在编程前就尽量准确地估计程序的时空开销,通过数学分析和计算来估计算法的复杂度。

时间复杂度

算法的执行时间

先来看几个定义:
语句频度:一条语句的重复执行次数称为语句频度。
单位时间:由于语句的执行要由源程序经编译程序翻译成目标代码,目标代码经装配再执行,因此语句执行一次实际所需的具体时间跟机器软、硬件环境相关,所以设每条语句执行一次所需的时间均为单位时间。

一个算法的执行时间大致上等于其所有语句执行时间的总和,而语句的执行时间=语句频度×执行一次所需时间。

如果不考虑计算机的软硬件等环境因素,影响算法时间代价的最主要因素是问题规模,问题规模是算法求解问题输入量的多少,是问题大小的本质表示,一般用整数n表示。

问题规模n对不同的问题含义不同,例如:

  • 排序算法:n为参加排序的记录数
  • 矩阵运算:n为矩阵的阶数
  • 多项式运算:n为多项式的项数
  • 集合运算:n为集合中元素的个数
  • 树有关运算:n为树的结点个数
  • 图有关运算:n为图的顶点数或边数

显然n越大算法的执行时间越长。

例如:求两个n阶矩阵的乘积算法

矩阵乘法定义:

for(int i = 1; i < n + 1; i++) {	// 频度: n + 1 <思考:为什么是 n+1 而不是 n ?> 
	for(int j = 1; j < n + 1; j++) {	// 频度: n * (n + 1) 
		c[i][j] = 0;	// 频度: n^2 
		for(int k = 1; k < n + 1; k++) {	// 频度: n^2 * (n + 1)
			c[i][j] = c[i][j] + a[i][k] * b[k][j];	// 频度: n^3
		}
	}
}

该算法中所有语句频度之和,是矩阵阶数n的函数,用f(n)表示: f ( n ) = 2 n 3 + 3 n 2 + 2 n + 1 f(n)=2n^3+3n^2+2n+1 f(n)=2n3+3n2+2n+1

算法的时间复杂度定义

通常,算法的执行时间是随问题规模增长而增长的,因此对算法的评价通常只需考虑其随问题规模增长的趋势。这种情况下,我们只需要考虑当问题规模充分大时,算法中基本语句的执行次数在渐进意义下的阶。

比如矩阵的乘积算法中,当n趋向于无穷大时,显然有: lim ⁡ n → ∞ f ( n ) n 3 = lim ⁡ n → ∞ 2 n 3 + 3 n 2 + 2 n + 1 n 3 = 2 \\lim_{n\\to \\infty}\\frac{f(n)}{n^{3}}=\\lim_{n\\to \\infty}\\frac{2n^3+3n^2+2n+1}{n^3}=2 limnn3f(n)=limnn32n3+3n2+2n+1=2

也就是说,当n充分大时,f(n)和n3之比是一个不等于零的常数,即f(n)和n3是同阶的,或者说f(n)和n3的数量级相同。

在这里,我们用“O”来表示数量级,记作T(n)=O(f(n))=O(n3),其中T(n)=O(f(n))就是算法的时间复杂度,它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,也叫算法的渐进时间复杂度,简称时间复杂度。

最好、最坏和平均时间复杂度

对于某些问题的算法,其基本语句的频度不仅仅与问题的规模相关,还依赖于其它因素。

例如:在一维数组a中顺序查找某个值等于e的元素,并返回其所在位置:

int find(int e) {
	for(int i = 0; i < n; i++) {
		if(a[i] == e) {
			return i + 1;
		}
	}
	return -1;
}

容易看出,此算法中基本语句的频度不仅与问题规模n有关,还与输入实例中数组a[i]的各元素值及e的取值有关。

假如运气爆棚,每次要找的元素e正好是数组中的第一个元素,那么不论数组的规模多大,基本语句的频度f(n)=1;假如运气极差,每次要找的元素e是数组的最后一个元素,则基本语句的频度f(n)=n。

对于一个算法来说,需要考虑各种可能出现的情况,以及每一种情况出现的概率,一般情况下,可假设待查找的元素在数组中所有位置上出现的可能性相同,则可取基本语句的频度在最好情况和最坏情况的平均值,即f(n)=n/2。

最坏时间复杂度:在最坏情况下,算法的时间复杂度
最好时间复杂度:在最好情况下,算法的时间复杂度
平均时间复杂度:所有可能输入实例在等概率出现的情况下,算法的期望运行时间

通常只讨论算法在最坏情况下的时间复杂度,确定算法执行时间的上界,以保证算法的运行时间不会比它更长。

时间复杂度分析举例

分析算法时间复杂度的基本方法为:找出所有语句中语句频度最大的那条语句作为基本语句,计算基本语句的频度得到问题规模n的某个函数f(n),取其数量级用符号“O”表示即可。

定理 1.1:若 f ( n ) = a m n m + a m − 1 n m − 1 + . . . + a 1 n + a 0 f(n)=a_{m}n^{m}+a_{m-1}n^{m-1}+...+a_{1}n+a_{0} f(n)=amnm+am1nm1+...+a1n+a0是一个m次多项式,则T(n)=O(nm)。

定理 1.1 说明,在计算算法时间复杂度时,可以忽略所有低次幂和最高次幂的系数,这样可以简化算法分析,也体现出了增长率的含义。

下面举例说明一些常见的时间复杂度。

常数阶

例:获取程序支持的最大值。

const int MAXN = 1024;
int get_max() {
	return MAXN;
}

这个比较好理解,一共就一句话,没有循环,是常数时间,表示为 O(1)。

实际上,如果算法的执行时间是一个与问题规模n无关的常数,那么算法的时间复杂度为T(n)=O(1),称为常数阶。

对数阶

例:给定n(n<1000)个元素的有序数组a和整数v,求v在数组中对的下标,若不存在则返回-1。

这是一个常见的查找问题,我们可以用O(n)的算法遍历整个数组,然后去找v的值。当然,也有更快的办法,注意到题目中的条件,数组a是有序的,所以我们可以利用二分查找来实现。

int binary_search(int n, int a[], int v) {
	int l = 0, r = n - 1;
	while(l <= r) {
		mid = (l + r) >> 1;
		if(a[mid] == v) 
		    return mid;
		else if(a[mid] < v)
			r = mid + 1;
		else
			l = mid + 1;
	}
	return -1;
}

由于我们每次都可以把搜索范围缩小一半,假设基本语句的频度为f(n),则有2f(n)<n,f(n)<log2n,所以算法的时间复杂度为T(n)=O(log2n),称为对数阶。

线性阶

例:给定n(n<1000)个元素ai,求其中奇数有多少个?

判断一个数是偶数还是奇数,只需要求它除上 2 的余数是 0 还是 1,那么我们把所有数都判断一遍,并且对符合条件的情况进行计数,最后返回这个计数器就是答案。

int count_odd(int n, int a[]) {
    int cnt = 0;
    for(int i = 0; i < n; ++i) {
        if(a[i] & 1)
            ++cnt;
    }
    return cnt;
}

其中a & 1等价于a % 2

这个就是经典的线性时间复杂度O(n),称为线性阶。

线性对数阶

例:给定n(n<1000)个元素的有序数组a,求有多少个二元组(i, j),满足ai+aj=1024?其中(i<j)

枚举ai,然后在[i+1, n)范围内查找是否存在aj=1024-ai,存在则计数器+1,而这个查找的过程可以采用二分查找。

int count_odd(int n, int a[]) {
    int cnt = 0;
    for (int i = 0; i < n; ++i) {
		int l = i + 1, r = n - 1;
		while (l <= r) {
			mid = (l + r) >> 1;
			if(a[mid] == 1024 - a[i]) 
			    ++cnt;
			else if(a[mid] < v)
				r = mid + 1;
			else
				l = mid + 1;
		}
    }
    return cnt;
}

该算法的时间复杂度为T(n)=O(nlog2n),称为线性对数阶。

平方阶

例:给定n(n<1000)个元素ai,求有多少个二元组(i, j),满足ai+aj是奇数?其中(i<j)

还是秉承枚举法的思想,需要两个变量i和j,枚举ai和aj,再对ai+aj进行奇偶性判断。

int count_odd_pair(int n, int a[]) {
    int cnt = 0;
    for(i = 0; i < n; ++i) {
        for(j = i+1; j < n; ++j) {
            if( (a[i] + a[j]) & 1) ++cnt;
        }
    }
    return cnt;
}

对循环语句只需要考虑循环体中语句的执行次数,以上程序段中频度最大的语句是if( (a[i] + a[j]) & 1) ++cnt;,其频度为 f ( n ) = n ( n − 1 ) 2 f(n)=\\frac{n(n-1)}{2} f(n)=2n(n1),所以该算法的时间复杂度为T(n)=O(n2),称为平方阶。

多数情况下,当有若干个循环语句时,算法的时间复杂度是由最深层循环内的基本语句的频度f(n)决定的。

立方阶

例:给定n(n<1000)个元素ai,求有多少个三元组(i, j, k),满足ai+aj+ak是奇数?其中(i<j<k)

相信通过前面两个例子的分析,可以直接给出代码了。

int count_odd_triple(int n, int a[]) {
    int cnt = 0;
    for(i = 0; i < n; ++i) {
        for(j = i+1; j < n; ++j) {
            for(int k = j+1; k < n; ++k) {
                if( (a[i] + a[j] + a[k]) & 1 )
                    ++cnt;
            }
        }
    }
    return cnt;
}

该程序段中频度最大的语句是:if( (a[i] + a[j] + a[k]) & 1 ) ++cnt;,这条最深层循环内的基本语句的频度依赖于各层循环变量的取值,算法的时间复杂度为T(n)=O(n3),称为立方阶。


常见的时间复杂度按数量级递增排序:常数阶O(1)<对数阶O(log2n)<线性阶O(n)<线性对数阶O(nlog2n)<平方阶O(n2)<立方阶O(n3)<…<k次方阶O(nk)<指数阶O(2n)<阶乘阶O(n!)。

时间复杂度的计算

对于很多时间复杂度的题目,很多都是在做题时一眼就能看出程序的时间复杂度,但是无法规范地表述其推导过程。在这里总结此类题型的两种形式以及做题技巧。

  1. 循环主体中的变量参与循环条件的判断
    此类题应该找出主体语句中与T(n)成正比的循环变量,将之代入条件中进行计算。
int m = 5;
while ((m + 1) * (m + 1) < n) {
	m++;
}

m++的次数恰好与T(n)成正比,记t为该程序的执行次数并令t=m-5,有m=t+5,则(t+5+1)(t+5+1)<n,得 t < n − 6 t<\\sqrt{n}-6 t<n 6,即 T ( n ) = O ( n ) T(n)=O(\\sqrt{n}) T(n)=O(n )

  1. 循环主体中的变量与循环条件无关
    此类题可采用数学归纳法或直接累计循环次数。多层循环时从内到外分析,忽略单步语句、条件判断语句,只关注主体语句的执行次数。此类问题又可分为递归程序和非递归程序。

    • 递归程序:使用公式进行递推

    • 非递归程序:直接累计次数

空间复杂度

算法的空间复杂度S(n)定义为该算法所需要的存储空间,它也是问题规模n的函数: S ( n ) = O ( f ( n ) ) S(n)=O(f(n)) S(n)=O(f(n))

一个程序在机器上执行时,除了需要寄存本身所用的指令、常数、变量和输入数据外,还需要一些对数据进行操作和存储一些为实现计算所需信息的辅助空间。

其中,对于输入数据所占的具体存储量取决于问题本身,与算法无关,这样只需分析该算法在实现时所需的辅助空格键就可以了。

例:数组逆序,将一维数组a中的n个数逆序存放到原数组中。

算法1:

for(int i = 0; i < n; i++) {
	b[i] = a[n - i - 1];
}
for(int i = 0; i < n; i++) {
	a[i] = b[i];
}

算法1需要另外借助一个大小为n的辅助数组b,所以其空间复杂度为O(n)。

算法2:

for(int i = 0; i < n / 2; i++) {
	t = a[i];
	a[i] = a[n - i - 1];
	a[n - i - 1] = t;
}

算法2仅需要另外借助一个变量t,与问题规模n大小无关,所以其空间复杂度为O(1)。

算法原地工作是指算法所需的辅助空间为常量,即S(n)=O(1)。


关于「 算法时间/空间复杂度 」 的内容到这里就结束了。

以上是关于Algorithm Master Road:算法的时间/空间复杂度的主要内容,如果未能解决你的问题,请参考以下文章

BZOJ 2750 Road

POJ 3352 Road Construction

POJ——T3352 Road Construction

poj 3352 : Road Construction ebcc

POJ 3352-Road Construction (图论-双边联通分支算法)

BZOJ 2752 [HAOI2012]高速公路(road)