赞
踩
echo编辑整理,欢迎转载,转载请声明文章来源。欢迎添加echo微信(微信号:t2421499075) 交流学习。
挺多同学本科都学习过数据结构和算法这门课,但是有没有想过这门课到底是解决什么问题?科学家设计这些数据结构和算法是要干嘛?
其实,最终的目的只有一个:让我们写的代码在计算机上运行的速度更快,使用的内存更省!*,可是如何才能知道我们写的代码使用多少运行时间和内存呢?这就需要分析算法时间复杂度和空间复杂度,只有学会分析这 2 者,才能知道我们设计的算法到底有没有性能的提升,不然你费了很大功夫想了一个算法,却发现运行速度慢如乌龟,得不偿失。
如果能够在运行算法之前就能知道算法大概的执行时间那就好了,而复杂度分析恰好可以解决这个问题!复杂度分析又分为 2 种:
这种就是写完算法直接放到机器上面跑,统计下到底用了多少时间和内存,但是这种方法有 2 个缺点:
测试结果依赖运行机器:性能强的机器当然需要的时间少
测试结果依赖于测试用的数据:比如对无序的数组和有序的数组排序的时间大不相同
那既然运行后分析有不可避免的缺点,有没有办法在纸上提前计算一下算法大概的执行时间和内存用量呢?当然有,就是今天的主角大 O 复杂度表示法!
我用一个例子来一步步解释大 O 复杂度表示法到底是什么意思:
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;
}
}
}
对 CPU 而言执行程序分为以下 3 步:
因为我们只是在运行前粗略地估计算法的运行时间,因此可以假设每行代码在 CPU 上运行的时间都相同,为 cputime,那么我们就可以直接计算出上面函数中所有代码执行的总时间(次数 x 单位时间),n 表示输入数据的规模:
对于每个确定的算法,所有代码的执行次数一定,那么上面的 T(n) 与所有代码的执行次数 2n2+2n+3 成正比关系,比例系数就是 CPU 执行每行代码的时间 cputime,因为可以把上式写成大 O 复杂度表示法:
其中:
但是要注意:大 O 复杂度表示法并不是计算代码准确的运行时间,而是表示一种代码执行时间随着数据规模 n 增长的变化趋势,记住不是准确的时间,只是一种趋势而已,因为实际工作的算法可能需要接受很大量级的数据,通过分析算法运行时间与输入数据规模的变化趋势就能大概知道一个算法能不能在实际环境中很好的工作。
但是呢,上面的大 O 表示法还是不够简洁,比如当算法代码很多的时候,那我们是不是要在后面(或者前面)加上很多项:
这也不方便,因此大佬们又想了方法:只需要保留最大量级的运行次数即可!这是因为当输入数据规模很大,比如 100000, 1000000 等,常数项 3、一阶项 2n 等低阶的运行次数对最高次项 2n2 的影响并不大,所有代码的运行次数基本与最高阶 2n2 的运行次数保持一致。
因此我们只需要保留最高阶的运行次数就行,并且系数也可以去掉,因为我们表示的是变化趋势,常量系数并不影响变化趋势:
这样最终的大 O 复杂度表示法就成型了!这里之所以写的那么详细是因为这个复杂度分析真的非常重要,因为我们只把算法写出来还不够,我们还要能够分析算法的优劣,并且以后的工作中如果需要选择数据结构来完成指定的功能,你也必须要提前考虑自己选择的数据结构的运行时间,这是非常重要的,你做的不是玩具,而是要能给用户提供体验良好的产品。
学完大 O 表示法后,分析时间空间复杂度就很容易了,就是把前面的推导过程在不同的算法上计算一下,不过常见的复杂度分析分为这 3 种。
前面说过,大 O 表示法只需要关注最高阶运行次数,而在实际的代码中最高阶运行次数的代码往往是嵌套循环的最内层代码,也就是循环执行次数最多的一段代码,这种情况我们就只需要分析它就可以:
int cal(int n) {
int sum = 0;
int i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
可以发现执行次数最多的代码是 sum = sum + i
,利用前面的大 O 分析法,很容易得出 T(n) = O(n)
加入我们的程序有 2 个单层的 n 次 for 循环,还有一个 2 层的 n x n 的 for 循环,注意这里每个循环的数据规模是一样的,都是 n,必须保证这一点:
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; }
这种情况下我们只需要分别求出每个 for 的时间复杂度,然后加起来取最大的量级就行:
总的 T(n):
因为我们取最大量级,所以如果忽略数据规模 n 的特殊情况,我们可以取上面 3 个复杂度的最大值为最终结果:
上面算法最终的时间复杂度即为 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; }
在 cal 函数的循环中每次都调用 f 函数来计算,所以总的时间复杂度是这两个函数的乘积:
注意不同与加法,乘法法则不是取最大的量级,而是直接幂次累加,要注意这点!
总结的几乎所有的复杂度如下图:
相信你应该听过 28 法则,20% 的技术能解决 80% 的问题,同样对复杂度分析,工作中常用就如下几个:
代码中不含有循环、递归外的代码执行时间都为 O(1),记住 O(1) 并不是表示所有代码只执行一次,而是表示这些代码的执行次数不会随着输入数据规模 n 的变化而变化,即变化趋势是一个常量:
int i = 8;
int j = 6;
int sum = i + j;
不管有多少行上面这样的代码,他们的时间复杂度都是 O(1)。
这两种称为对数阶复杂度,在排序算法中比较常见,比如归并排序,快速排序的复杂度都是 O(nlogn),但是这种复杂度分析相对难一点,看个例子:
i=1;
while (i <= n) {
i = i * 2;
}
求出:x = log2(n),则时间复杂度就是 O(log2n),依葫芦画瓢:
i = 1;
while (i <= n) {
i = i * 3;
}
这种复杂度有 2 个输入数据规模 m 和 n,这表示我们的算法接收 2 个数据的输入,但是因为数据规模不一定相同,所以不能简单的利用加法法则:
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;
}
这种情况下,不能取最大的量级,因为数据规模不一样,我们只需要做复杂度的相加即可:T(n) = O(m + n),一般性的公式如下:
T1(m) + T2(n) = O[f(m) + g(n)]
但乘法规则仍然有效,如果两个嵌套循环的数据规模不同也成立:
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
...
T1(m) * T2(n) = O[f(m) * g(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]
}
}
比如:
所以根据大 O 表示法,最终的空间复杂度就是 O(n),常用的空间复杂有:
空间复杂度分析比较简单,只需要看看有没有与数据规模相关的内存申请操作即可,我们的重点还是放在时间复杂度分析,不管是面试还是考试,时间复杂度都是必定会问到的。
除了以上各种情况的复杂度分析外,还需知道不同情况下的复杂度,主要分为 4 种情况:
下面简单总结下,都挺好理解的。
比如这个例子,在数组中查找元素 x 并返回索引:
// 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 是数组的第一个元素,那么我们只需要循环一次就可以结束算法,此时的时间复杂度为 O(1),也即最好情况时间复杂度,但是假如我们要查找的元素 x 不在数组中,那么我们就需要循环 n 次结束算法,此时的时间复杂度为 O(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:
所以根据概率乘法:要查找的数据 x 出现在数组中的任意位置的概率为 1/2n,先出现在数组中的概率乘以任意位置的概率,然后我们就可以将每个元素被找到时要查找的次数和对应概率相乘,最后再相加就得到算法的平均时间复杂度:
简单解释下:
那么最后的结果就是全部想加,因为可以省略系数,所以上面查找元素 x 算法的时间复杂度也是 O(n)。虽然存在这 3 种情况,但是实际工作中分析算法的时间复杂度时并不需要严格分析这 3 种不同情况。一般情况下,使用前面讲的复杂度分析方法得出复杂度即可,如果要详细推导的话,可以计算下平均时间复杂度。
这是一种特殊情况下的复杂度,并不是很常见,但还是了解下,它的主要思想是:把运行时间多的情况下的复杂度拆分,并均摊到运行时间少的情况下,看个例子:
// array表示一个长度为n的数组 // 代码中的array.length就等于n int[] 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; }
这个算法实现如下功能,往数组中插入一个元素 val:
利用前面学习的复杂度分析可以很容易知道这两种情况的时间复杂度分别为:O(n) 和 O(1),但是考虑实际运行情况,我们总是先把数组一个个位置存满 O(1),然后满了之后再执行求和的操作 O(n),并且这两种情况的发生是有规律的。
因此我们可以把比较耗时 O(n) 复杂度的求和操作的均摊到不太耗时的 O (1) 插入操作上,这样总体的时间复杂度就会变成 O(1),这就是均摊的思想,总结下均摊时间复杂度的应用场景:
今天学习的时空复杂度分析是数据结构和算法学习中非常重要的一环,只有学会分析时空复杂度,我们才能知道自己写的算法到底能够提升多快,如果不会分析复杂度,那只会盲目地选择数据结构,盲目地设计算法,时间复杂度就是我们设计算法的指标。
我们学习数据结构和算法的目的就是为了写出运行速度更快,存储用量更低的代码,如果都不会分析算法的执行时间和内存用量,那最后何谈学过这门课或者学会这门课呢?请务必重视复杂度分析,后面设计算法会经常用到。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。