浅谈分治算法的时间复杂度分析

来源:互联网 发布:淘宝店突然流量变0 编辑:程序博客网 时间:2024/06/05 13:36

在我的周围, 发现好多的同事, 朋友, 对一个算法进行时间复杂度分析时,尤其是递归函数进行分析时, 比较吃力, 因此特写这篇文章, 给刚做程序员或者对分治算法(Divide and Conquer),递归(Recursive)算法时间复杂度不太会分析的同学吧. 如有不对之处,共同探讨学习。

 

关于分治算法是这样定义的:

为解决一个给定的问题, 算法需要一次或多次的递归调用其自身来解决相关的子问题.即我们把一个大规模的问题划分为n个规模较小的而结构与原来相似的子问题,递归解决这些子问题,然后再合并其结果。这样就得到了最终的结果。

从步骤上来讲, 可以分为三步:

1. 分解(Divide): 将一个问题为N的问题分解成一系列子问题。

2. 解决(Conquer): 递归的处理每个子问题.如果子问题足够小,则直接给出解。

3. 合并(Combine): 把每个已经处理好的子目问题合并,得到最终的解。

 

递归调用时, 最关键的就是递归调用栈的深度. 我们也可以理解为分治算法中,被分成的段数。 也就是步骤中的第1步中所分成的子序列的个数。 假设这个递归调用深度我们用 D(n)来表示。

另外一个就是在每次处理一个具体子序列时,所用的时间复杂度,我们用C来表示。

则最终的这个函数的时间复杂度为:

 

 C *D(n)  (注:教科书上是:αT(n/b) + D(n) + C(n), 这个问题我们后面再讲)。

 

讲到这里, 肯定很多人都想到了快速排序算法, 这是一个典型的递归函数.在这里用表示如下

QuickSort(int low, high; int * p)

{

//得到分界点nPos

nPos = GetDividedPosition(low, high,p);

//对分界点左边排序

QuickSort(low, nPos-1, p);

// 对分界点右边排序

QuickSort(nPos+1, high, p);

}

在这里, 获取分界点函数GetDividePostion它的效率是n的,即给定一个数组,只需要一次遍历就可以得到结果,因此它是一个Ѳ(n)的效率。那D(n)是多少呢?

从函数中我们可以分析出,每次它都是把序列分成两部分, 在最优的情况下,即每次都分成两等份,那它的问题子序列个数就是Log2个,那在最好情况下的时间复杂度就可以理解为Ѳ(NLog2N)

我们可以讨论一个它的最差情况是什么样的。在最差情况下,待排序序列是从大到小的,而要排成从小到大的,如果按照每次取基准点为数组的第一个值的话,每次得到的nPos为序列的第一个值,快速排序中形成的子序列第一次划分为1, n-1, 第二次为 1, n-2  , 第三次 1, n-3,递归的层高就是n-1层,效率就是 N * (N-1) Ѳ(N2), 因此,在快速排序中获取基准点时,一般都要改进下.有一种改进方法就是每是取的参照点是取p[low], p[High], p[(Low+High)/2], 取这三个值中的中间值,这样分隔开的子序列就会更逼近使两个子序列个数相等, 更逼近NLog2N。

下面我们再来看一个例子:汉诺塔。

递归函数一般这样写:

void hanoi(int n,char one ,chartwo,char three)

     if(n==1)  move(one,three);

    else{
     hanoi(n-1,one,three,two);  // 需要递归n-1次的移动
     move(one,three);          //  只需要移动1次
     hanoi(n-1,two,one,three); //  需要 递归n-1次移动
    }

   }

   它时间效率又是怎么样的呢?

   从这里看,第一次的移动接口是一个常数,我们就可以认为是Ѳ(1)了,那它的递归层数是多少呢?

   很明显,每一次的调用都是减1的,且要移动两次,如上面标识我们可以得出递推公式:

           Cn+1 = 2Cn + 1 , 当n为1时,C1 = 1

   我们通过变换求下Cn的通项:

       Cn+1 = 2Cn +1

     =>(Cn+1 +1) = 2(Cn + 1)

    => (Cn+1 +1)/(Cn + 1) = 2

   根据等比数列的通项公式an= a1 * qn-1, C1 = 1

    => Cn + 1 (C1+1)2n-2

    => C 2n-1  -1 。  即 Ѳ (2n)。很大啊~~~~~ 

 

   或者这样证明:

    Cn = 1 +2Cn

      = 1 + 2 + 22Cn-2

      = 1 + 2 + 22 + 23 + ... +2n-1C

        这个为 首项为1,末项为2n-1C1 比例系数为2的等比数列求和.

      = 1(1-2n-1)/(1-2) = 2n-1 - 1

******************************************************************************************

   以上是一种简化的求解递归函数时,分析其时间复杂度的方法。在这里,为防止误人子弟,我还是把官方的公式再简单解释下:

    在分治算法中的三个步骤中, 我们假设分解和合并过程所用的时间分别为D(n),C(n), 设T(n)为处理一个规模为n的序列所消耗的时间为子序列个数,每一个子序列是原序列的1/b,α为把每个问题分解成α个子问题,则所消耗的时间为

    T(n)=    Ѳ(1)    如果n<=c(是n中一个可以直接求解的规模c。在上面两例中c都为1)

              αT(n/b)+ D(n) + C(n) 否则

  在上面的例子中, 我们同样可以用这个公式来套下试试.

  在快速排序中,α 是为2的, b也为2, 则分解(就是取参照点,可以认为是1),合并(把数组合并,为n), 因此D(n) + C(n) 是一个线性时间Ѳ(n).

  这样时间就变成了T(n) = 2T(n/2) +Ѳ(n).

  下面有点复杂了, 在每个层上的时间复杂度为:第在一层上是cn(c为比较一次时所用的时间), 在第二层上时数组被分成了两部分, 每部分为n/2, 则在第二层上时间为 c * n/2 + c* n/2 =cn, 同样在第三层上, 被分成了四部分, 时间为c*n/4 +c*n/4 + c*n/4 + c*n/4 =cn. 层高一共是按刚才说的是Log2n层,每一层上都是cn, 所以共消耗时间cn * Log2n; 则总时间:

  cn * Log2n +cn = cn(1+Log2n)  即Ѳ(nLog2n).

****************************************************************************************** 

  总结下, 我的总结的方法  C *D(n) 是抓住了递归算法中的最关键的地方αT(n/b)而得出的结论. 因为大O法只需要关注次数最高的部分就可以了, 可以简化你的分析过程.

  对于快速排序, C: 一个子项的处理时间Ѳ(n), D(n): 递归层数, 树高, Log2n,所以最终的结果是:

Ѳ(nLog2n). 对于汉诺塔, C: 一个子项的处理时间Ѳ(1), D(n): 递归层数, 树高,2n-1, 故最终结果是: 

Ѳ(2n). 很快就得出了结论.

 

 下面我们来做一个具体实例. 来自己设计一个算法, 并评价下时间复杂度. 问题是这样的,如下图:

 浅谈分治算法的时间复杂度分析

  图1

 其具体数据如下所示:

 浅谈分治算法的时间复杂度分析

  图2

  

    题目是:当给你任意一TitleID值 x, 根据TitleID <-- ItemID<-- ItemDetail 关系, 找出所有ItemDetailID值集合. 用代码写, 不用SQL.假设主键与外键都已经是排过序的.假设 Title表记录条目数量为M, Item表条目数量为N, 而Detail表记录数量为K, 且K>= 10N,  N>= 4M(只是表述一个数量关系, 没有其它意思. 就是想表达Detail表记录比较多,而且还挺多的). Item表中的数据只挂在Title表中的叶子节点下.

   比如,当TitleID为5(北京)时,其在Item中明细为4(东城区),5(西城区),6(海淀区),则ItemDetailID的集合应该是: 6(中关村),7(上地)。如果TitleID为2(中国)时,则结果就是: 1,2,3,4,5,6,7. 

 

   处理这个问题我们两种方案:

1. 先遍历ItemDetail表, 把Level为A的找出来,对于每条符合的记录再判断是否属于选中的TitleID值x. 符合, 则把ItemID值记录下来

  代码写法大概为:

  void GetDetailItemIDsByTitleID(const X:Integer)

 while 对于每个条ItemDetail中的记录detailRecord, 则:{ (0)

       在记录detailRecord 中:

        根据ItemID值 nItemID 在Item中找到TitleID值x1; (1)

 

        if x1==X 则: 把detailRecord.ID加到结果中 (2)

        else 

          在Title表中根据PID递归判断x1是不是属于x的孩子, 

            是,则加把detailRecord.ID加到结果中  (3)

    }

  }

  

  在这里我们假设Title表中的树高(PID层次)为d, 由于主键和外键是排过序的, 则计算结果如下: 

(0): Detail表记录为K, 故为K;

(1):在Item表中根据 nItem值找到x1的开销为Log2N;

(2): 常数, 不考虑

(3): 的递归层次为d, Title表记录数为M, 则开销为d *Log2M; 

最后的结果:

  K * (Log2N + d* Log2M) + c. c为把nItem加到结果中的时间,不能把ID加重了, 我们用一个常量表示.

 时间复杂度我们可以表示为: Ѳ(K(Log2N + d* Log2M))

 

2. 先对Title递归,对于每一个具体的记录,找出其明细, 代码大概如下

void GetDetailItemIDsByTitleID(const X: Integer){

  if 如果X所指的标题为节子节点(1)

   直接处理标题ID为X的明细(X) (2)

  else {

   while 对于所有的当前标题的明细iTitleChildRecord记录 do (3)

    GetDetailItemIDsByTitleID(iTitleChildRecord.ID) (4)

 

}

(1): 判断为叶子节点,看PID有没有为X的就可以了, Log2M;

(2): 直接处理明细,则从子目表中定位到TitleID记录Log2N,假设平均一个标题下明细Item个数为y, 则为: Log2N + yLog2K

(3): Log2M

(4): 层数为d, 假设平均一个标题下子标题数为z, 则: d * z

最后:  d*z(Log2N + yLog2K)+ 2Log2M

 

我们来比较下:

  1. K * (Log2N + d* Log2M)

  2. d*z(Log2N +yItemLog2K) + 2Log2M

1中我们只看 k * d * Log2M, 2中我们只看d * z * y *Log2K 这样, 我们只需要关注

  k* Log2Mz(标题下子标题个数)* y(一条标题下条目个数) * Log2K 即可了. 之前的关系 K>= 10N,  N>= 4M, 你应该知道结论了!

 

  我举的这个例子, 在我们的项目中有经验数据,比如z 一般在10以内(用我们的话说就是某一章下的节一般不过超过10个), y(一章节下挂的子目一般最多在50个).M最多为2000, 而K一般都在在6万以上.在这种环境下, 第一种方案结果: 60000 * 11 = 66万, 第二种方案: 10 * 50 * 16 =8000=0.8万 

 

  以上主要是讲解如何对一个比较复杂的算法进行时间复杂度分析, 比较算法的优劣. 关于上面的例子, 大家看看有什么高招,看还有没有更好的办法? 学习ing!!!  

  

 ********补充关于最后一个例子我认为目前最好的算法 2009年11月17日9:09:06************

思路是把标题上的当前TitleID和子标题的ID放在Hash中, 然后再在Detail表中进行一次遍历.这样函数的效率可以认为是线性的, 实现如下:

 

void GetDetailItemIDsByTitleID(const X: Integer){

  创建一个哈希表oTitleHash, 是以TitleID为键Key

  创建一个ItemID与TitleID对应的Hash: oItemHash,Key为ItemID, Value为TitleID

  //通过一个递归函数对oTitleHash进行填充, 把X及其孩子填充到哈希表中

  RecursiveFillHashIDs(oTitleHash,X); (1)

  // 遍历Item表,对于TitleID存在于oTitleHash中的记录, 添加到oItemHash中

  for 对于Item表中的每一条记录 iItem do{  (2)

    //如果iItem这记录的titleID存在于oTitleHash中, 则添加

    ifoTitleHash.FindNode(iItem.TitleID) != Nullthen (3)

      oItemHash.Add(iItem.ID, iItem.TitleID);

  }

  for 对于ItemDetail中的每一条记录 iRecord do {(4)

   // 从iRecord记录中得到ItemID值 :nItemID 在oItemHash表中查找是否存在

    ifoItemHash.FindNode(nItemID) != NULLthen (5)

     把nItemID添加到结果集中

  }

 (1): 所消耗时间为递归层次 * 平均每个标题下的孩子数: d * z

 (2): 遍历所有的Item表记录: n

 (3): 哈希操作, 平均效率: 1

 (4): 遍历ItemDetail表中的记录: k

 (5): 哈希操作: 1

 故最后结果是: d*z + n * 1 + k*1 = d*z + n +k

 按经验数据, d深度一般为4, z 为10个左右, k为6万, n 为几百, 最终的结果就是6万左右, 取决于k的大小. 因此可以简单的认为效率为:

   Ѳ(k), 是线性的.

0 0