数据结构和算法解决的是“快”和“省”的问题。让代码更快,更省存储空间。
如何衡量执行效率和资源消耗呢?
- 执行效率:渐进时间复杂度(时间复杂度)
- 资源消耗:渐进空间复杂度(空间复杂度)
一般来说,越高阶复杂度的算法,执行效率越低。
事后统计法
一般来说,我们运行一遍代码,通过统计、监控就能得到算法的执行时间和占用的内存大小。这种方法其实也是正确的,被称为事后统计法。
那我们为什么还需要复杂度分析呢?因为事后统计法有一定的局限性:
- 测试结果非常依赖测试环境
- 测试结构受数据规模影响大
因此,我们需要一个不需要具体的测试数据,就能粗略地得出算法执行效率的方法。
这就是时间、空间复杂度分析法。
复杂度量级
复杂度量级可以分为多项式量级和非多项式量级。
其中,非多项式量级只有两个:O(2**n**) 和 O(n!)
常见的复杂度量级
时间复杂度为非多项式量级的算法问题称作为:NP(Non-Deterministic Polynomial,非确定多项式)问题
当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法是非常低效的算法。
大O复杂度表示法
时间复杂度:
全称是 渐进时间复杂度,它不代表代码真正的执行时间,而是表示算法的执行时间与数据规模之间的增长关系。
空间复杂度:
全称是 渐进空间复杂度,与时间复杂度同理,它表示的是算法的存储空间与数据规模之间的增长关系。
那我们如何用公式表示复杂度?使用大O复杂度表示法:
- 时间复杂度:
**T(n) = O(f(n))** - 空间复杂度:
**S(n) = O(f(n))**
各项含义:
**T(n)**代表代码执行的时间**S(n)**代表代码占用的存储空间**n**表示数据规模的大小**f(n)**表示每行代码执行的次数总和,由于是一个公式,因此用f(n)**O**表示代码的执行时间T(n)与f(n)表达式成正比
公式中的低阶、常量、系数三部分不左右增长趋势,因此都可以忽略,仅需要记录最大量级:
- T(n) = O(3n) 剔除后就为 T(n) = O(n)。
- T(n) = O(3n²) 剔除后就为 T(n) = O(n²)。
因此在进行分析完成后,我们需要剔除这些部分,才是我们要的复杂度表示法。
分析法则 - 时间复杂度
1. 关注最大量级
大O复杂度表示法仅表示一种变化趋势,仅需记录最大量级。所以我们在分析一段代码时,只需要关注循环执行次数最多的一段代码。
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)));
但若是有两个数据规模m和n,我们就不能简单的套用上述法则,需要修改:T1(m) + T2(n) = O(f(m) + g(n))
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(m)*T2(n) = O(f(m) * f(n))
时间复杂度分析示例(多项式量级)
示例1 - 线性阶 O(n)
如下,求 1,2,3,…n 的累加和,如何估算代码的执行时间:
function cal(n) {let sum = 0let i = 0for (; i <= n; i++) {sum = sum + i}return sum}
假设每行代码执行时间为 ut,计算代码总执行时间 T(n):
- 2、3行,各需要 1ut ,共 2ut 。
- 4、5行,各运行了n遍,因此需要 2n*ut 的执行时间。
所以总代码执行时间为:T(n) = (2n+2)*ut
使用大O表示法,剔除低阶、常数、系数后得出:T(n)=O(n)
示例2 - 平方阶 O(n²)
按上面的思路,再来看一段代码:
function cal(n) {let sum = 0let i = 0let j = 1for (; i <= n; i++) {j = 1for (; j <= n; j++) {sum = sum + i * j}}return sum}
假设每行代码执行时间为 ut,计算代码总执行时间 T(n):
- 2、3、4行,各需要 1ut,共 3ut
- 5、6行,各需要 nut,共 2nut
- 7、8行,需要 n²ut,共 2n²ut
所以总代码执行时间为:T(n) = (2n²+2n+3)*ut
使用大O表示法,剔除低阶、常数、系数后得出(仅保留最大量级):T(n)=O(n²)
示例2 - 常量阶 O(1)
var i = 8var j = 6var sum = i + j
假设每行代码执行时间为 ut,计算代码总执行时间 T(n):
- 1、2、3行,各需要 1ut,共 3ut
所以总代码执行时间为:T(n) = 3ut
使用大O表示法:T(n)=O(1)
只要代码的执行时间不随n的增大而增长,这样代码的时间复杂度都记为 O(1)。
即一般情况下,只要算法中不存在循环、递归语句,即使有上万行的代码,时间复杂度也为 O(1)。
示例3 - 加法法则
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;}
这段代码可以分为三段来看,分别求 sum_1、sum_2、sum_3:
- 得出,sum_1部分执行了100次,即 O(1)
- sum_2部分执行了n次,即 O(n)
- sum_3部分执行了n²次,即 O(n²)
根据前面的加法法则,总时间复杂度等于量级最大的那段代码的时间复杂度,即:T(n) = T1(n)+T2(n) = max(O(f(n)), O(g(n))) = O(max(f(n), g(n)))
示例4 - 乘法法则
int cal(int n) {int ret = 0;int i = 1;for (; i < n; ++i) {ret = ret + f(i);}}int f(int n) {int sum = 0;int i = 1;for (; i < n; ++i) {sum = sum + i;}return sum;}
根据前面的乘法法则,嵌套代码的复杂度等于嵌套内外代码复杂度的乘积,即:T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n))
f() 函数本身的时间复杂度为 O(n)cal() 本身的复杂度也为 O(n)
f() 函数在 4~6 行中被调用了n次,因次整个时间复杂度为:T(n) = T1(n)*T2(n) = O(n²)
示例5 - 对数阶 O(logn)
对数阶时间复杂度较为常见,同时也最难分析:
i=1;while (i <= n) {i = i * 2;}
上述的变量 i 的取值如同一个等比数列:
等比数列
只需知道x值是多少,就能知道这行代码执行的次数。
通过2x=n求解得出:x=log2n
所以时间复杂度为:O(log2n)
再看一个例子:
i = 1;while (i <= n){i = i * 3;}
很容易看出来,这段代码的时间复杂度为: O(log3n)
对数之间是能互相转换的,即 log3n = log32 log2n,
所以 O(log3n) = O(C log2n),其中 C=log32 是一个常量,可以忽略,所以能忽略对数的底。
因此上述两例的时间复杂度统一表示为:T(n) = O(logn)
示例6 - 线性对数阶 O(nlogn)
O(nlogn) 时间复杂度很常见,归并、快速排序的时间复杂度都是它。
基于乘法法则,若一段代码的时间复杂度是 O(logn),
我们循环执行它n遍,时间复杂度就成 O(nlogn) 了。
示例7 - 两个数据的规模 O(m+n)、O(m*n)
代码复杂度由两个数据的规模来决定的时间复杂度:
int cal(int m, int n) {int sum_1 = 0;int i = 1;for (; i < m; ++i) {sum_1 = sum_1 + i;}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) * f(n))
空间复杂度分析
示例1 - 线性阶 O(n)
void print(int n) {int i = 0;int[] a = new int[n];for (i; i <n; ++i) {a[i] = i * i;}for (i = n-1; i >= 0; --i) {print out a[i]}}
第2行,申请了一个空间存储变量i,但是常量阶的,跟数据规模n没有关系,所以可以忽略。
第三行申请了一个大小为n的int类型数组,剩下的代码没有占用更多的空间。
因此整段代码的空间复杂度就是 O(n)
常见的空间复杂度就是O(1)、O(n)、O(n2 )。对数阶的复杂度平时都用不到。
且空间复杂度的分析比时间复杂度的分析要简单很多。
最好、最坏情况时间复杂度
我们先分析如下代码:
// 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;}return pos;}
很容易看出来,这段代码的时间复杂度是 O(n)。其中 n 代表数组的长度。
但是我们在数组中查找一个数据时,不需要遍历完整个数组,可能中途找到就能结束循环了。所以上面的代码写的不够高效,可以优化如下:
// 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;}
若数组第一个元素就等于 x,则不需要继续遍历剩下的 n-1 个数据。此时的时间复杂度就是 O(1) 。
若数组中不存在 x,则要遍历完整个数组,此时时间复杂度为 O(n)。
最好情况时间复杂度:最理想的情况下,执行这段代码的时间复杂度。此处为 O(1) 。
最坏情况时间复杂度:最糟糕的情况下,执行这段代码的时间复杂度。此处为 O(n) 。
平均情况时间复杂度
最好、最坏情况时间复杂度都是极端情况下的代码复杂度,发生概率并不大。
为了更好表示平均情况下的复杂度,就需要平均情况时间复杂度。
上述的例子中,x 的位置共有 n + 1 种情况:
- 在数组的 0~n-1 位置中
- 不在数组中
我们将每种情况下,查找需要遍历的元素个数累加起来,再除以n+1,就能得到遍历的元素个数平均值:
时间复杂度中,可以省略系数、低阶、常量。因此简化后,平均时间复杂度就是 O(n) 。
上述结论虽然正确,但是推导过程存在一个问题:没有将各种情况发生的概率考虑进去。
x要么在数组中,要么不在数组中。
若将每种情况发生的概率也考虑进去,那么计算过程应该为:
这个值就是概率论中的加权平均值,也叫期望值。
因此平均情况时间复杂度的全称应为:
- 加权平均时间复杂度
- 期望时间复杂度
上面的结果使用大O表示法后,去掉系数与常量,该段代码的加权平均时间复杂度仍是O(n)
均摊时间复杂度
均摊时间复杂度对应的分析方法:摊还分析(平摊分析)。
// array表示一个长度为n的数组// 代码中的array.length就等于nint[] 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;}
上述代码中:
最好情况时间复杂度为: O(1)
最坏情况时间复杂度为: O(n)
平均情况时间复杂度为: O(1)
平均情况时间复杂度的计算过程。
这个例子的 insert 与上个例子的 find 的例子,有两个区别:
find在极端情况下,复杂度才为O(1)。但insert大部分情况下都为O(1)。insert的O(1)、O(n)的时间复杂度的插入,出现的频率很有规律,且有一定的前后时序关系。一般都是一个O(n)插入后,紧跟着 n-1 个O(1)的插入操作。
因此针对这种特殊场景,不需要像分析平均复杂度那样,找出所有的输入情况及发生概率,然后再计算加权平均值。
针对这种特殊场景,有种更简单的分析方法:摊还分析法。通过其得到的时间复杂度为:均摊时间复杂度。
还是上面的例子,每次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作。
将耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,一组连续的操作的均摊时间复杂度就是 O(1) 。
均摊时间复杂度的应用场景:
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,偶尔时间复杂度较高,且这些操作之间存在前后连贯的时序关系。此时,我们就可以将这一组操作放在一块分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
常见的复杂度量级
等比数列