所谓算法的“时间复杂度”,你可以将其理解为算法“要花费的时间量”。比如说,让你用抹布将家里完完全全打扫一遍(看成算法吧……)大概要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)——简要讨论算法的时间复杂度的更多相关文章

  1. 深入浅出数据结构C语言版(4)——表与链表

    在我们谈论本文具体内容之前,我们首先要说明一些事情.在现实生活中我们所说的"表"往往是二维的,比如课程表,就有行和列,成绩表也是有行和列.但是在数据结构,或者说我们本文讨论的范围内 ...

  2. 深入浅出数据结构C语言版(12)——从二分查找到二叉树

    在很多有关数据结构和算法的书籍或文章中,作者往往是介绍完了什么是树后就直入主题的谈什么是二叉树balabala的.但我今天决定不按这个套路来.我个人觉得,一个东西或者说一种技术存在总该有一定的道理,不 ...

  3. 深入浅出数据结构C语言版(17)——有关排序算法的分析

    这一篇博文我们将讨论一些与排序算法有关的定理,这些定理将解释插入排序博文中提出的疑问(为什么冒泡排序与插入排序总是执行同样数量的交换操作,而选择排序不一定),同时为讲述高级排序算法做铺垫(高级排序为什 ...

  4. 深入浅出数据结构C语言版(5)——链表的操作

    上一次我们从什么是表一直讲到了链表该怎么实现的想法上:http://www.cnblogs.com/mm93/p/6574912.html 而这一次我们就要实现所说的承诺,即实现链表应有的操作(至于游 ...

  5. 深入浅出数据结构C语言版(1)——什么是数据结构及算法

    在很多数据结构相关的书籍,尤其是中文书籍中,常常把数据结构与算法"混合"起来讲,导致很多人初学时对于"数据结构"这个词的意思把握不准,从而降低了学习兴趣和学习信 ...

  6. 深入浅出数据结构C语言版(8)——后缀表达式、栈与四则运算计算器

    在深入浅出数据结构(7)的末尾,我们提到了栈可以用于实现计算器,并且我们给出了存储表达式的数据结构(结构体及该结构体组成的数组),如下: //SIZE用于多个场合,如栈的大小.表达式数组的大小 #de ...

  7. 深入浅出数据结构C语言版(3)——递归简论

      相信学习过C语言的读者都已经接触过递归(不论是谭浩强的C程序设计还是C Primer Plus都有递归程序),本文就是对递归的基本原则进行简要介绍.首先,我们写一个基本的递归函数作为例子: int ...

  8. 深入浅出数据结构C语言版(6)——游标数组及其实现

    在前两次博文中,我们由表讲到数组,然后又由数组的缺陷提出了指针式链表(即http://www.cnblogs.com/mm93/p/6576765.html中讲解的带有next指针的链表).但是指针式 ...

  9. 深入浅出数据结构C语言版(7)——特殊的表:队列与栈

    从深入浅出数据结构(4)到(6),我们分别讨论了什么是表.什么是链表.为什么用链表以及如何用数组模拟链表(游标数组),而现在,我们要进入到对线性表(特意加了"线性"二字是因为存在多 ...

随机推荐

  1. 语句 if else

    语句 语句是指程序命令,都是按照顺序执行的.语句在程序中的执行顺序称为“控制流”或“执行流”. 根据程序对运行时所收到的输入的响应,在程序每次运行时控制流可能有所不同. 语句间的标点符号必须是英文标点 ...

  2. 蓝桥网试题 java 基础练习 字符串对比

    -------------------------------------------------------------------------------- java有很多可以拿来用的方法为什么不 ...

  3. 【死磕Java并发】-----深入分析volatile的实现原理

      通过前面一章我们了解了synchronized是一个重量级的锁,虽然JVM对它做了很多优化,而下面介绍的volatile则是轻量级的synchronized.如果一个变量使用volatile,则它 ...

  4. java实现简单的单点登录_转

    摘要:单点登录(SSO)的技术被越来越广泛地运用到各个领域的软件系统当中.本文从业务的角度分析了单点登录的需求和应用领域:从技术本身的角度分析了单点登录技术的内部机制和实现手段,并且给出Web-SSO ...

  5. Ionic start 创建项目报错 Error with start undefined

    转自:http://blog.csdn.net/wenzigui_qy/article/details/52874542 在Installing npm packages的时候报错,如下: Insta ...

  6. Mac和Linux系统的:Arp欺骗源码

    linux系统, 简化版的ARP欺骗工具 精简版, 没有很多代码, 只要把准备好的数据, 发送给到网卡接口, 利用这个工具, 可以让局域网内的一台计算机暂时掉线: #include <stdio ...

  7. 一个想法(续四):IT技术联盟创业众筹进度公示

    为了将整个创业过程更加的公开公正透明化,特开此篇用于展示众筹进度. 首轮众筹进度如下:(每天24点更新1次)

  8. Spring MVC 的环境搭建和入门小程序

    1.1.下载spring框架包. 1.1.1百度搜索Spring Framework. 进入spring官网,在网页右边选择想要下载的版本.如图 1.1.2进入页面按Ctrl+F搜索Distribut ...

  9. 纪中集训 Day 7

    今天超级不爽啊啊啊啊 尼玛我三道题都想出来了就是没对一道,第一题没理负数尼玛题目没告诉我,第二题尼玛题目也没说最近的点是第(l+r)/2而不是距离为(a[l]+a[r])/2啊啊啊啊,第三题没打GCD ...

  10. 解析java泛型(一)

    对于我们java中的泛型,可能很多人知道怎么使用并且使用的还不错,但是我认为想要恰到好处的使用泛型,还是需要深入的了解一下它的各种概念和内部原理.本文将尽可能的囊括java泛型中的重要的概念.主要内容 ...