如何分析,统计算法的执行效率和资源消耗?
数据结构与算法其根本是解决”快“和”省“的问题,就是要想马儿不吃草,又想马儿跑得快,即如何让代码运行得更快,如何让代码更省存储空间。因此,执行效率这一概念便是算法一个非常重要的考量指标。
其实,只要提到数据结构与算法,就一定无法脱离时间,空间复杂度来分析。而且,一个老师说过:复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构与算法的内容基本上就掌握了一半。
为什么我们需要复杂度分析呢? 为什么我们不直接把代码跑一边,通过统计和监控,就能得到算法执行时间和占用的内存大小。为什么还需要时间和空间复杂度分析呢?
1.测试结果非常依赖测试环境
测试环境中硬件不同对测试结果有着巨大的影响。比如i9和i3处理器运行一段代码,毫无疑问的i9要比i3快很多。
2.测试结果受着数据规模的影响很大
比如说排序算法,对于同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,数据本就有序,那排序算法就不需要什么操作,执行时间就会非常之短。比如说,小规模的数据排序,插入排序可能要比快速排序要快。
所以,我们需要一个不用具体数据赖测试,就可以大致地估计算法的执行效率的方法。这既是时间复杂度与空间复杂度分析。
下面我们看一段代码
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
这段代码非常简单,就是求1—>n的累加和。
从CPU的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的CPU执行的个数和时间不一样,但是,这里我们粗略估计每行代码时间都一样,所以总执行时间便是(2n+2)。 也就是 2行和3行执行一次。4行和5行执行了n次。
int cal(int n) {
int sum =0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for(; j <= n; ++j) {
sum = sum + i * j;
}
}
}
这里第2,3,4行代码,每行执行一次(3),第5,6行代码循环执行了n遍(2n), 第7,8行代码执行了n2 遍(2n2),总体来说就是 (2n2+2n+3)。用大O表示法便是O(n2),也称为时间复杂度。
时间复杂度分析
时间复杂度分析主要有这几个诀窍:
1.只关注循环执行次数最多的一段代码
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
比如这一个代码的时间复杂度就是O(n)。我们只需要关注for循环中代码执行的次数就好。
2.加法法则:总复杂度等于量级最大的那段代码的复杂度
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;
}
这个代码分为三个部分,分别求sum1,sum2,sum3。我们可以分析每一段的复杂度如何将其放一块,再取一个量级最大的作为整段代码的复杂度。
这里整段代码时间复杂度是O(n2),第一个小for循环块是100次,是常量级别的执行时间。 第二段和第三段分别是O(n)和O(n2)。所以,我们取最大的量级,整段代码的时间复杂度便是O(n2)。也就是说:总的时间复杂度分析就等于量级最大那段代码的时间复杂度
3.乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
也就是说如果外层for循环是n次,该外层for循环里还有一个循环次数是n次的for循环。两个for循环嵌套便是n*n,时间复杂度便是O(n2)。落到实际代码上便是。
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;
}
这段代码的时间复杂度便是O(n2)。
几种常见的时间复杂度量级
1.O(1)
int i = 8;
int j = 6;
int sum = i + j;
这里要强调的一点是这段代码即便又三行它的时间复杂度也是O(1),而不是O(3),只要是常量级的都用O(1)表示。
2.O(logn)、O(nlogn)
i=1;
while (i <= n) {
i = i * 2;
}
从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结 束。还记得我们高中学过的等比数列吗?实际上,变量 i 的取值就是一个等比数列。如果我 把它一个一个列出来,就应该是这个样子的:
**2^0^ 2^1^ 2^2^ ...2^k^ ... 2^x^ = n**
所以,我们只要知道 x 值是多少,就知道这行代码执行的次数了。通过 2 =n 求解 x 这个 问题我们想高中应该就学过了,我就不多说了。x=log n,所以,这段代码的时间复杂度就 是 O(log n)。
现在再来看另一段代码
i=1;
while (i <= n) {
i = i * 3;
}
根据刚刚的思路,很简单就能看出来,这段代码的时间复杂度为 O(log3n)。
3. 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))。