复杂度分析
算法的复杂度指的是执行该算法的程序在运行时所需要的时间和空间(内存)资源,复杂度分析主要是从时间复杂度和空间复杂度两个层面来考虑。
大O(big O)表示法
在了解时间复杂度之前,我们需要知道怎么用数学符号将它表示出来。
我们知道,一个算法的执行时间 = 该算法中每条语句执行时间之和。假设每条语句执行一次所需要的时间为单位时间,那么一个算法的执行时间就和算法中需要执行语句的次数成正比,即是等于所有语句的频度(执行次数)之和。
用T[n]表示代码的执行时间,n表示数据规模的大小,f(n)表示每行代码执行的次数总和,算法执行时间的公式为:
$$
T[n] = O(f(n))
$$
O表示的是代码执行时间随数据规模增长的变化趋势,也叫做渐进时间复杂度(asymptotic time complexity
),简称时间复杂度. 下面我们看一个具体的例子:
public int GetSum(int n)
{
int sum = 0;
int i = 0;
int j = 0;
for(; i < n; i++)
{
sum += i;
for(; j < n; j++)
{
sum = sum + i * j;
}
}
return sum;
}
在上面例子中,由于已经假设每条语句执行一次所需时间为单位时间,第3,4,5行执了一次,第6,8行分别执行了n次,第9,11行分别执行了n^2次,可以得出
$$
T(n) = O(2n^2 + 2n + 3)
$$
当n的值非常大时,比如n=100000或者更大时,公式中的低阶,常数和系数三部分并不左右增长趋势,因此可以忽略不计,简单点,我们可以将公式表示为:
$$
T(n) = O(n^2)
$$
很多时候,在求一个算法的时间复杂度时,我们都会将n看作一个很大的数,因此只要它的高阶项,不要低阶项,也不要高阶的系数,在后面的例子中还会有所体现.
时间复杂度分析
前面我们已经知道了big O
表示法的由来以及如何表示,下面我们具体讲解如何计算一段代码的时间复杂度.我们只需要记住它的一条原则:只要高阶项,不要低阶项,也不要高阶项的系数.
为什么可以这么说呢?因为前面已经说过了,big O
表示法只是表示一种变化趋势,当执行次数n变得无穷大时,整个变化趋势基本上是由高阶项所决定的,低阶项对它的影响微乎其微.看下面几个例子
public int cal(int n)
{
int sum_1 = 0;
int p = 1;
for (; p < 100; ++p)
{
sum_1 = sum_1 + p;
}
int sum_2 = 0;
int q = 1;
for (; q < n; ++q)
{
sum_2 = sum_2 + q;
}
int sum_3 = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i)
{
j = 1;
for (; j <= n; ++j)
{
sum_3 = sum_3 + i * j;
}
}
return sum_1 + sum_2 + sum_3;
}
根据上面的法则,我们可以轻易的得出,它的时间复杂度为 O(n^2)
再看一个例子:
public int cal(int n)
{
int ret = 0;
int i = 1;
for (; i < n; ++i)
{
ret = ret + f(i);
}
}
private int f(int n)
{
int sum = 0;
int i = 1;
for (; i < n; ++i)
{
sum = sum + i;
}
return sum;
}
函数cal的时间复杂度是多少呢? 第一个for循环里面嵌套了一个函数,嵌套的函数中又有一个for循环,因此时间复杂度为O(n^2)
常见时间复杂度分析
下面是常见的几种复杂度,简单解释其中的几种
- O(1) : 表示常量级的时间复杂度,并不是说只执行了一条语句,只要执行语句的数量是常量,都可以用它来表示
- O(logn) : 对数级,在分析复杂度时,不管对数的底是多少,根据数学公式,都可以将其化成底为2的对数,而在big O表示法中,我们忽略系数,所以在对数阶时间复杂度的表示方法中,统一为O(logn)
时间复杂度的四种类型
大部分代码的复杂度分析按照上述法则分析都足以应付,但对于少部分代码,它们的时间复杂度会随着输入数据的顺序,位置不同而存在量级的差距.在这种情况下,我们才需要使用到最好时间复杂度,最坏时间复杂度,平均时间复杂度,均摊时间复杂度去分析这部分代码.
看这个例子,思考一下它的时间复杂度该怎么表示呢?
public int find(int[] array, int x)
{
int i = 0;
int pos = -1;
for (; i < array.Length; ++i)
{
if (array[i] == x)
{
pos = i;
break;
}
}
return pos;
}
代码很简单,遍历数组array
,查看是否存在值为x
的数字,如果有,返回其下标,否则返回-1
.
如果数组中第一个元素的值等于x,则它的时间复杂度是O(1),如果数组中不存在值等于x的元素,则它的时间复杂度为O(n),也就是说,在不同情况下,它的复杂度不一样,因此我们需要分情况进行讨论
最好时间复杂度
指的是在理想情况(最好情况)下,执行这段代码的时间复杂度,上面例子中,它的最好时间复杂度为O(1).
最坏时间复杂度
指的是在最坏情况下,执行这段代码的时间复杂度,上面例子中,它的最坏时间复杂度为O(n).
平均时间复杂度
指的是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度,它的公式如下。
其中A(n)表示平均时间复杂度,S是规模为n的实例集,实例I∈S
的概率为PI
,算法对实例I执行的基本运算次数是tI
。
上面排序算法中,有 n+1 种情况(在数组中有 n 种情况,不在数组中有 1 种情况),我们分别需要查找 次。假设每种情况出现的概率都一样,那所有的查找次数平均下来即为 ,加权平均和为 ,根据我们前面时间复杂度的加法原则,我们去掉低阶项,去掉系数以后这种情况最终时间复杂度的大小为 。
上面的例子结论虽然是正确的,由于 n + 1
种情况出现的概率不一样,因此并不能按照上面的方式进行计算。首先我们知道,要查找一个数,这个数要么在数组中,要么不在数组中,为了方便理解,我们假设它们的概率都是1/2
,如果在数组中,被遍历到的概率是1/n
(因为有n个位置,每个位置出现的概率都相同), 所以数据被查找到的概率是1/2 * 1/n
即1/2n
,所以它的平均复杂度的计算过程为:
均摊时间复杂度
均摊时间复杂度(amortized time complexity)
,它对应的分析方法为摊还分析或者平摊分析。
听起来与平均时间复杂度有点类似,比较容易弄混,平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
空间复杂度分析
时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity
),表示算法的存储空间与数据规模之间的增长关系。它的分析规则与时间复杂度一样,也是只要高阶项,不要低阶项,也不要高阶项的系数,看下面的例子:
public void print(int n)
{
int i = 0;
int[] a = new int[n];
for (; i < n; ++i)
{
a[i] = i * i;
}
for (i = n - 1; i >= 0; --i)
{
Console.WriteLine(a[i]);
}
}
显而易见,其忽略低阶项和常数项,其空间复杂度为 O(n)