算法(algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,比如排序就有前面的十大经典排序和几种奇葩排序,虽然结果相同,但在过程中消耗的资源和时间却会有很大的区别,比如快速排序与猴子排序:)。
那么我们应该如何去衡量不同算法之间的优劣呢?
主要还是从算法所占用的「时间」和「空间」两个维度去考量。
时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
本小节将从「时间」的维度进行分析。
什么是大o
当看「时间」二字,我们肯定可以想到将该算法程序运行一篇,通过运行的时间很容易就知道复杂度了。
这种方式可以吗?当然可以,不过它也有很多弊端。
比如程序员小吴的老式电脑处理10w数据使用冒泡排序要几秒,但读者的imac pro 可能只需要0.1s,这样的结果误差就很大了。更何况,有的算法运行时间要很久,根本没办法没时间去完整的运行,还是比如猴子排序:)。
那有什么方法可以严谨的进行算法的时间复杂度分析呢?
有的!
「 远古 」的程序员大佬们提出了通用的方法:「 大o符号表示法 」,即t(n) = o(f(n))。
其中 n 表示数据规模 ,o(f(n))表示运行算法所需要执行的指令数,和f(n)成正比。
上面公式中用到的 landau符号是由德国数论学家保罗·巴赫曼(paul bachmann)在其1892年的著作《解析数论》首先引入,由另一位德国数论学家艾德蒙·朗道(edmund landau)推广。landau符号的作用在于用简单的函数来描述复杂函数行为,给出一个上或下(确)界。在计算算法复杂度时一般只用到大o符号,landau符号体系中的小o符号、θ符号等等比较不常用。这里的o,最初是用大写希腊字母,但现在都用大写英语字母o;小o符号也是用小写英语字母o,θ符号则维持大写希腊字母θ。
注:本文用到的算法中的界限指的是最低的上界。
常见的时间复杂度量级
我们先从常见的时间复杂度量级进行大o的理解:
常数阶o(1)
线性阶o(n)
平方阶o(n²)
对数阶o(logn)
线性对数阶o(nlogn)
o(1)
无论代码执行了多少行,其他区域不会影响到操作,这个代码的时间复杂度都是o(1)
1voidswaptwoints(int&a,int&b){2inttemp=a;3a=b;4b=temp;5}
o(n)
在下面这段代码,for循环里面的代码会执行 n 遍,因此它消耗的时间是随着 n 的变化而变化的,因此可以用o(n)来表示它的时间复杂度。
1intsum(intn){2intret=0;3for(inti=0;i<=n;i++){4ret+=i;5}6returnret;7}
特别一提的是 c * o(n) 中的 c 可能小于 1 ,比如下面这段代码:
1voidreverse(string&s){2intn=s.size();3for(inti=0;i
o(n²)
当存在双重循环的时候,即把 o(n) 的代码再嵌套循环一遍,它的时间复杂度就是 o(n²) 了。
1voidselectionsort(intarr[],intn){ 2for(inti=0;i
这里简单的推导一下
当 i = 0 时,第二重循环需要运行 (n - 1) 次
当 i = 1 时,第二重循环需要运行 (n - 2) 次
。。。。。。
不难得到公式:
1(n-1)+(n-2)+(n-3)+...+02=(0+n-1)*n/23=o(n^2)
当然并不是所有的双重循环都是 o(n²),比如下面这段输出 30n 次 hello,五分钟学算法:)的代码。
1voidprintinformation(intn){2for(inti=1;i<=n;i++)3for(intj=1;j<=30;j++)4cout<
o(logn)
1intbinarysearch(intarr[],intn,inttarget){ 2intl=0,r=n-1; 3while(ltarget)r=mid-1; 7elsel=mid+1; 8} 9return-1;10}
在二分查找法的代码中,通过while循环,成 2 倍数的缩减搜索范围,也就是说需要经过 log2^n 次即可跳出循环。
同样的还有下面两段代码也是 o(logn) 级别的时间复杂度。
1//整形转成字符串 2stringinttostring(intnum){ 3strings=; 4//n经过几次“除以10”的操作后,等于0 5while(num){ 6s+='0'+num%10; 7num/=10; 8} 9reverse(s)10returns;11}1voidhello(intn){2//n除以几次2到13for(intsz=1;sz
o(nlogn)
将时间复杂度为o(logn)的代码循环n遍的话,那么它的时间复杂度就是 n * o(logn),也就是了o(nlogn)。
1voidhello(){2for(m=1;mtarget) 7returnbinarysearch(arr,l,mid-1,target);//左边 8else 9returnbinarysearch(arr,mid+1,r,target);//右边10}
比如在这段二分查找法的代码中,每次在 [ l , r ] 范围中去查找目标的位置,如果中间的元素arr[mid]不是target,那么判断arr[mid]是比target大 还是 小 ,进而再次调用binarysearch这个函数。
在这个递归函数中,每一次没有找到target时,要么调用 左边 的binarysearch函数,要么调用 右边 的binarysearch函数。也就是说在此次递归中,最多调用了一次递归调用而已。根据数学知识,需要log2n次才能递归到底。因此,二分查找法的时间复杂度为 o(logn)。
求和
1intsum(intn){2if(n==0)return0;3returnn+sum(n-1)4}
在这段代码中比较容易理解递归深度随输入 n 的增加而线性递增,因此时间复杂度为 o (n)。
求幂
1//递归深度:logn2//时间复杂度:o(logn)3doublepow(doublex,intn){4if(n==0)return1.0;56doublet=pow(x,n/2);7if(n%2)returnx*t*t;8returnt*t;9}
递归深度为logn,因为是求需要除以 2 多少次才能到底。
② 递归中进行多次递归调用的复杂度分析
递归算法中比较难计算的是多次递归调用。
先看下面这段代码,有两次递归调用。
1//o(2^n)指数级别的数量级,后续动态规划的优化点2intf(intn){3if(n==0)return1;4returnf(n-1)+f(n-1);5}
递归树中节点数就是代码计算的调用次数。
比如 当n = 3时,调用次数计算公式为
1 + 2 + 4 + 8 = 15
一般的,调用次数计算公式为
2^0 + 2^1 + 2^2 + …… + 2^n= 2^(n+1) - 1= o(2^n)
与之有所类似的是 归并排序 的递归树,区别点在于
1. 上述例子中树的深度为n,而 归并排序 的递归树深度为logn。
2. 上述例子中每次处理的数据规模是一样的,而在 归并排序 中每个节点处理的数据规模是逐渐缩小的
因此,在如 归并排序 等排序算法中,每一层处理的数据量为 o(n) 级别,同时有logn层,时间复杂度便是 o(nlogn)。
最好、最坏情况时间复杂度
最好、最坏情况时间复杂度指的是特殊情况下的时间复杂度。
动图表明的是在数组 array 中寻找变量 x 第一次出现的位置,若没有找到,则返回 -1;否则返回位置下标。
1intfind(int[]array,intn,intx){2for(inti=0;i
在这里当数组中第一个元素就是要找的 x 时,时间复杂度是 o(1);而当最后一个元素才是 x 时,时间复杂度则是 o(n)。
最好情况时间复杂度就是在最理想情况下执行代码的时间复杂度,它的时间是最短的;最坏情况时间复杂度就是在最糟糕情况下执行代码的时间复杂度,它的时间是最长的。
平均情况时间复杂度
最好、最坏时间复杂度反应的是极端条件下的复杂度,发生的概率不大,不能代表平均水平。那么为了更好的表示平均情况下的算法复杂度,就需要引入平均时间复杂度。
平均情况时间复杂度可用代码在所有可能情况下执行次数的加权平均值表示。
还是以find函数为例,从概率的角度看, x 在数组中每一个位置的可能性是相同的,为 1 / n。那么,那么平均情况时间复杂度就可以用下面的方式计算:
((1 + 2 + … + n) / n + n) / 2 = (3n + 1) / 4
find函数的平均时间复杂度为 o(n)。
均摊复杂度分析
我们通过一个动态数组的push_back操作来理解均摊复杂度。
1template 2classmyvector{ 3private: 4t*data; 5intsize;//存储数组中的元素个数 6intcapacity;//存储数组中可以容纳的最大的元素个数 7//复杂度为o(n) 8voidresize(intnewcapacity){ 9t*newdata=newt[newcapacity];10for(inti=0;i
push_back实现的功能是往数组的末尾增加一个元素,如果数组没有满,直接往后面插入元素;如果数组满了,即size == capacity,则将数组扩容一倍,然后再插入元素。
例如,数组长度为 n,则前 n 次调用push_back复杂度都为 o(1) 级别;在第 n + 1 次则需要先进行 n 次元素转移操作,然后再进行 1 次插入操作,复杂度为 o(n)。
因此,平均来看:对于容量为 n 的动态数组,前面添加元素需要消耗了 1 * n 的时间,扩容操作消耗 n 时间 ,总共就是 2 * n 的时间,因此均摊时间复杂度为 o(2n / n) = o(2),也就是 o(1) 级别了。
可以得出一个比较有意思的结论:一个相对比较耗时的操作,如果能保证它不会每次都被触发,那么这个相对比较耗时的操作,它所相应的时间是可以分摊到其它的操作中来的。
用SIW技术设计一种方形腔体双膜窄带带通滤波器
什么是运放的饱和?运放积分电路上并联的电阻有什么作用?
射频识别系统中读写模块的软硬件设计介绍
中国电信对下半年发展进行工作部署,仍存在挑战和任务
圣邦微推出了两款迷你尺寸、超低功耗单节小微电池保护电路
我们应该如何去衡量不同算法之间的优劣呢?
电流传感器与电流互感器的区别
易来是如何在巨头环伺的格局下快速成长的?
区块链正在试图提高人们对治理主题的认识
电磁流量计的安装结构及电极材料选择
Vishay推业界体积最小的汽车级IHLP电感
软启动器的主要技术参数
AR影像助力智能保修 实现智能工厂检修零误差
机床电机测试系统解决方案
第三十二讲 可编程逻辑器件及应用
接触器自锁电路原理详解
反激有源钳位原理分析
组合机床的组成
日本研发出利用电网实现高速大容量数据通信技术
今年的六月不简单,5G行业取得一系列成果