深入浅出数据结构C语言版(2)——简要讨论算法的时间复杂度
所谓算法的“时间复杂度”,你可以将其理解为算法“要花费的时间量”。比如说,让你用抹布将家里完完全全打扫一遍(看成算法吧……)大概要5个小时,那么你用抹布打扫家里的“时间复杂度”就是5个小时。
但是,在对算法进行分析时,并没有那么简单。大部分情况下我们不能一眼看出算法执行完需要耗费多少时间,一方面是因为我们很难考虑执行算法的具体机器在各种操作上花费的时间,因为不同机器的运算速度不同,同一机器执行不同操作的所用时间也不一样。另一方面是我们很难统计算法到底执行了多少个“操作”,比如不起眼的a+=1其实算两个操作。所以我们对算法进行时间上的分析时,往往需要使用到“大概”这个概念。但即使是推算算法耗费的“大概”时间也是需要一些基本原则的,接下来我们就来看看如何推算算法的时间复杂度。(完整、严谨的算法分析比较复杂,本文只是写一些“入门”的概念与分析方法)
即使在现实生活中,我们也会遇到类似于分析算法时间一样的问题,比如有人问你多久能看完某本书,你可能会说“一个月之内”而不是具体的“19天”,又比如有人问你最快多久能完成某项任务,你可能会说“至少3天”而不是“70小时”。而我们对算法进行时间分析时也会用到类似的“技巧”,即不追求具体的时间耗费,而是追求“上界”或“下界”。
为了找出“上界”与“下界”,我们先要使用两个定义:
1.如果存在正常数c和n0,使得当N>=n0时T(N)<=c·f(N),则记为T(N)=O(f(N))
2.如果存在正常数c和n0,使得当N>=n0时T(N)>=c·f(N),则记为T(N)=Ω(f(N))
第一个定义的意思就是:当N超过某个值后,c·f(N)总是至少比T(N)要大。忽略常数因子,即f(N)至少与T(N)一样大。
类似的,第二个定义意思就是:当N超过某个值后,c·f(N)总是最多和T(N)一样大。
其实这两个定义就是为了比较两个函数之间的“相对增长率”,比如1000x和x2,虽然x<1000时1000x>x2,但是x2以更快的速度增长,因此x2最终会更大。
当我们说T(N)=O(f(N))时,其实就是说“T(N)是在以不快于f(N)的速度增长”,类似的T(N)=Ω(f(N))即“T(N)是在以不慢于f(N)的速度增长”。不难发现,O(f(N))就是T(N)的“上界”,Ω(f(N))就是T(N)的“下界”。
举例来说,N3比N2增长更快,因此N2=O(N3)与N3=Ω(N2)都是对的;2*N2与N2有着相同的相对增长率,因此N2=O(2*N2)与N2=Ω(2*N2)都是正确的。由于对算法进行时间分析时往往考虑“最坏情况”,所以我们通常计算的是O(f(N)),即“上界”,俗称“大O阶”。
正如文章开头说的,相同的算法在不同的机器上也会有不同的运行时间,同一台机器的不同操作也会有不同的时间开销。因此,我们假设我们的“计算机”所有运算如加减乘除、比较、赋值等都是耗费相同时间的,并且不考虑内存问题,从而后面讨论算法时间复杂度时,我们不再带单位,只关心“数值”。
接下来,让我们带着现有的概念与知识,来计算一个简单的函数可能花费的时间(也可以说时间复杂度,或者大O阶)
void func ( unsigned int N )
{
for ( int i = ; i < N ; ++i )
{
i = i ;
}
}
显然这个函数并没有什么意义,我们也只是拿来练练手算算时间开销罢了。那么接下来就让我们一步一步看看它要花费多少时间。
根据我们之前所说,所有运算耗费相同时间且不带单位,那么,初始化i花费1时间,每次循环需要执行一次比较,一次赋值,一次自增总共3时间,N次循环即3N时间,加上定义i的1时间,算法花费的总时间是3N+1。再回顾之前所说,对于算法,我们一般都是计算大O阶(即使这里我们算出了3N+1这样“比较准确”的时间花费),因此接下来我们要对3N+1计算大O阶。
但是3N+1的大O阶有很多很多,比如O(N2)、O(N3)等等(因为N2和N3的相对增长率肯定比3N+1大),究竟哪一个才是我们需要的?直觉告诉我们应该是“最接近的”,即O(N)(根据定义一,显然存在c=1000,n0=1这样的情况使得N成为3N+1的大O阶)。但是选择这个“最接近”的大O阶时有没有什么原则呢?原则当然还是有的,接下来我们就来说一说计算算法时间复杂度O()时的一些原则(和捷径)。
首先,我们要确定三个关于大O阶的法则:
1.如果T(N)=O(f(N)),G(N)=O(h(N)),那么T(N)+G(N)=max(O(f(N)) , O(h(N)))。T(N)*G(N)=O(f(N)*h(N))。
2.忽略时间花费中的常数项,比如3*N^2+3,直接简化为N^2
通过法则1中的加法规律(和法则2的简化办法),我们发现N2=O(N2),N=O(N),那么N2+N=max(O(N2) , O(N)) = O(N2)。因此,我们有了法则3:
3.如果T(N)是一个k次多项式,那么T(N)=O(N^k)。
法则2与法则3是我们常用的,因为算法的时间复杂度往往是一个多项式,而法则2和法则3告诉了我们如何大大简化该多项式来获得大O阶。假设一个算法花费时间3*N3+N2+3,那么根据法则2与法则3,我们可以直接得出其大O阶为O(N3)。
那么接下来的问题就只剩下如何得到那个原始的时间开销了,比如我们知道了时间花费是3*N2+3,那么我们可以得出大O阶为O(N2),但是问题在于3*N2+3该如何得到。其实这也是不难的。回顾之前我们分析了的那个无意义的函数,我们就会发现,时间复杂度中最重要的就是“不确定次数的循环”,因为顺序执行时不论有1000个还是10000个赋值、比较、算术运算,最后计算大O阶时都会变为常数项从而被忽略掉。至于为什么说是“不确定次数的”循环,原因就是如果次数确定,那么该循环也会变成一个常数项:
for ( int i = ; i< ;++i );
不难发现这个循环的时间花费其实是固定的1+10+9=20,是一个常数,而常数项是会被忽略的。
那么对于次数不定的循环(假定循环次数都由算法的输入参数N决定),那么我们有几个很简单的基本原则:
1.对于循环,运行时间最多为其内部语句的运行时间(比如4次运算)乘以循环次数(N)。
比如
for ( int i = ; i < N ;++i );
的运行时间最多为1*N,即O(N)
2.对于嵌套循环,根据原则1,不难发现就是内部循环的运行时间乘以外部循环次数(N)。
比如
for ( int i = ; i < N ; ++i )
for ( int j = ; j < N ; ++j );
的运行时间就是N*N,即O(N2)
3.对于顺序结构,只需要将各“部分”运行时间相加即可。(对于IF/ELSE结构,我们将整个IF/ELSE的运行时间假定为其中最大的一种情况,这样也许会比平均运行时间要大,但是保证了“上界”的要求)
比如
for ( int i = ; i < N ;++i ); for ( int i = ; i < N ; ++i )
for ( int j = ; j < N ; ++j );
的运行时间就是N+N*N=N^2+N,大O阶为O(N^2)
4.对于递归,如果其只是“遮了面纱”的循环,比如
int func ( int N )
{
if ( N<= ) return ;
return N*func ( N - );
}
那么其运行时间就以其循环形式计算,得出N。但实际情况中遇到的递归往往是难以化简为循环的,这时对递归的时间分析将比较复杂,本文不予讨论。
最后总结,由于诸多现实原因,对于算法的时间分析我们往往只计算个大概,而计算这个大概时我们最在乎的是代表着最坏情况的“上界”,也即大O阶。要想计算一个算法的大O阶,我们首先要计算其大致的时间花费,比如一个循环N次的循环体中有不确定的常数c次运算,此时我们不计较c的具体大小,直接将该循环体时间花费记为N,然后根据计算大O阶的简化原则将其简化,得出算法的大O阶。
虽然算法千千万,但是算法的时间复杂度,大O阶还是有一些规律的。什么规律呢?就是我们常见的大O阶是可以列举出来的。常见的大O阶按照从好到坏,也就是增长率从低到高,列举出来的话有:
常数级C
对数级logN
对数平方根级logN2
线性级N
N*logN
平方级N2
立方级N3
指数级2N
稍加分析就会发现其实它们的顺序就是函数增长率的顺序,有了这个顺序,我们就可以对一些算法的时间复杂度进行比较了。比如完成同一件事,一个算法是O(NlogN),另一个算法是O(N^2),那么显然当N很大时,前者比后者会快很多(观察函数图像也可以很明显的发现这一点)。
但是,对数级logN的复杂度是什么情况出现的呢?一般来说,如果一个算法用常数时间O(1)将问题的大小削减为其一部分,那么该算法就是O(logN)的。
虽然很多时候,一个算法的数据输入就不得不耗费Ω(N)的时间,因而整个算法最终的时间复杂度不会是O(logN),但为了说明O(logN)的情况,我们假设算法的数据已经输入到了内存中,那么作为O(logN)的典例就是二分查找(本例中假设数组已按从小到大排好顺序,我们要找出某个数在数组中的位置):
int BinarySearch ( const int A[] , int X, int N ) // X为要找的元素,N为数组大小
{ int Low=,High=N-,Mid;
while ( Low <= High )
{
Mid= ( Low + High ) / ;
if ( A[ Mid ] < X )
Low = Mid + ;
else if ( A[ Mid ] > X )
High = Mid - ;
else return Mid;
}
}
显然,循环体内部的运行时间为O(1),接下来分析循环的次数,循环从High-Low=N-1开始,到High-Low=-1结束,每次循环后High-Low的值都会“折半”,符合我们之前说的判断是否为logN级的条件,因而二分查找是O(logN)的。(即使不是削减为二分之一,而是三分之一、四分之一等,我们也记作logN级别)
文章写到这,相信读者对于基本的算法分析已经有了概念。但是算法分析并不只是这些东西,比如我们一直没有提到的类似于O()和Ω()的θ(),还有算法的空间复杂度(比如同一个算法用循环实现和递归实现的空间占用就会明显不同)等,并且在复杂的算法计算中还会用到高等数学的极限思想与计算方法。有相关兴趣的读者可以自行搜索关于算法分析的其它内容来了解。另外,对于不同的场景,算法的分析会有不同的要求,比如我们说忽略常数项,但如果这个常数项真的足够大而机器又足够慢,那么即使是常数项也不是随便忽略的。
深入浅出数据结构C语言版(2)——简要讨论算法的时间复杂度的更多相关文章
- 深入浅出数据结构C语言版(4)——表与链表
在我们谈论本文具体内容之前,我们首先要说明一些事情.在现实生活中我们所说的"表"往往是二维的,比如课程表,就有行和列,成绩表也是有行和列.但是在数据结构,或者说我们本文讨论的范围内 ...
- 深入浅出数据结构C语言版(12)——从二分查找到二叉树
在很多有关数据结构和算法的书籍或文章中,作者往往是介绍完了什么是树后就直入主题的谈什么是二叉树balabala的.但我今天决定不按这个套路来.我个人觉得,一个东西或者说一种技术存在总该有一定的道理,不 ...
- 深入浅出数据结构C语言版(17)——有关排序算法的分析
这一篇博文我们将讨论一些与排序算法有关的定理,这些定理将解释插入排序博文中提出的疑问(为什么冒泡排序与插入排序总是执行同样数量的交换操作,而选择排序不一定),同时为讲述高级排序算法做铺垫(高级排序为什 ...
- 深入浅出数据结构C语言版(5)——链表的操作
上一次我们从什么是表一直讲到了链表该怎么实现的想法上:http://www.cnblogs.com/mm93/p/6574912.html 而这一次我们就要实现所说的承诺,即实现链表应有的操作(至于游 ...
- 深入浅出数据结构C语言版(1)——什么是数据结构及算法
在很多数据结构相关的书籍,尤其是中文书籍中,常常把数据结构与算法"混合"起来讲,导致很多人初学时对于"数据结构"这个词的意思把握不准,从而降低了学习兴趣和学习信 ...
- 深入浅出数据结构C语言版(8)——后缀表达式、栈与四则运算计算器
在深入浅出数据结构(7)的末尾,我们提到了栈可以用于实现计算器,并且我们给出了存储表达式的数据结构(结构体及该结构体组成的数组),如下: //SIZE用于多个场合,如栈的大小.表达式数组的大小 #de ...
- 深入浅出数据结构C语言版(3)——递归简论
相信学习过C语言的读者都已经接触过递归(不论是谭浩强的C程序设计还是C Primer Plus都有递归程序),本文就是对递归的基本原则进行简要介绍.首先,我们写一个基本的递归函数作为例子: int ...
- 深入浅出数据结构C语言版(6)——游标数组及其实现
在前两次博文中,我们由表讲到数组,然后又由数组的缺陷提出了指针式链表(即http://www.cnblogs.com/mm93/p/6576765.html中讲解的带有next指针的链表).但是指针式 ...
- 深入浅出数据结构C语言版(7)——特殊的表:队列与栈
从深入浅出数据结构(4)到(6),我们分别讨论了什么是表.什么是链表.为什么用链表以及如何用数组模拟链表(游标数组),而现在,我们要进入到对线性表(特意加了"线性"二字是因为存在多 ...
随机推荐
- css3基础知识——回顾
1.属性选择器 完全匹配的属性选择器 [id=article]{} 示例: <style> input[type=text]{ border: 2px solid red;} </s ...
- Visual Studio项目模板与向导开发
在[Xamarin+Prism开发详解系列]里面经常使用到[Prism unity app]的模板创建Prism.Forms项目: 备注:由于Unity社区已经不怎么活跃,下一个版本将会有Autofa ...
- ZLG_GUI和3D显示的移植
最近学习NRF51822,想在OLED上移植个强大的GUI ,本来想学习emWIN的,甚至想直接学习自带GUI的嵌入式操作系统RTThread,但是......哎,太懒了.....现在觉得ZLG_GU ...
- OCR技术浅探: 语言模型(4)
由于图像质量等原因,性能再好的识别模型,都会有识别错误的可能性,为了减少识别错误率,可以将识别问题跟统计语言模型结合起来,通过动态规划的方法给出最优的识别结果.这是改进OCR识别效果的重要方法之一. ...
- 理解Node.js(译文)
前言 总括 :这篇文章十分生动形象的的介绍了Node,满足了读者想去了解Node的需求.作者是Node的第一批贡献者之一,德国前端大神.译者觉得作者的比喻很适合初学者理解Node,特此翻译. 译者 : ...
- Android EventBus 3.0 实例使用详解
EventBus的使用和原理在网上有很多的博客了,其中泓洋大哥和启舰写的非常非常棒,我也是跟着他们的博客学会的EventBus,因为是第一次接触并使用EventBus,所以我写的更多是如何使用,源码解 ...
- swift 运算符快速学习(建议懂OC或者C语言的伙伴学习参考)
昨晚看了swift 的运算符的知识点,先大概说一下,这个点和 c 或者oc 的算运符知识点一样,都是最基础最基础的.其他的最基本的加减乘除就不多说了.注意的有几点点..先说求余数运算: 一 :求余数运 ...
- 5天2亿活跃用户,QQ“LBS+AR”天降红包活动后台揭密
作者:Dovejbwang,腾讯后台开发工程师,参与“LBS+AR”天降红包项目,其所在“2016春节红包联合项目团队”获得2016公司级业务突破奖. 商业转载请联系腾讯WeTest获得授权,非商业转 ...
- springMVC整合Junit4进行单元测试
springMVC整合Junit4进行单元测试 标签: springMVC整合Junit4junit单元测试教程springMVC入门教程 spring(10) 版权声明:本文为博主原创文章,未 ...
- 自己写的python脚本(抄的别人的,自己改了改,用于整理大量txt数据并插入到数据库)
昨天,遇到了一个问题,有100w条弱口令数据,需要插入到数据库中,而且保存格式为txt. 身为程序员不可能一条一条的去写sql语句吧(主要是工作量太大,我也懒得干).所以,我 就百度了一下,看有没有相 ...