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

递归调用时, 最关键的就是递归调用栈的深度. 我们也可以理解为分治算法中, 被分成的段数。 也就是步骤中的第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)是多少呢?
从函数中我们可以分析出,每次它都是把序列分成两部分, 在最优的情况下,即每次都分成两等份,那它的问题子序列个数就是 Log2N 个,那在最好情况下的时间复杂度就可以理解为 Ѳ(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 ,char two,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
     => Cn  =  2n-1  - 1 。  即  Ѳ (2n)。很大啊~~~~~ 
 
    或者这样证明:
    Cn = 1 + 2Cn
       = 1 + 2 + 22Cn-2
       = 1 + 2 + 22 + 23 + ... + 2n-1C1 
        这个为 首项为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 * Log2M 和 z(标题下子标题个数) * 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中, 则添加
    if oTitleHash.FindNode(iItem.TitleID) != Null then (3)
       oItemHash.Add(iItem.ID, iItem.TitleID);
  }
  for 对于ItemDetail中的每一条记录 iRecord do { (4)
    // 从iRecord记录中得到ItemID值 : nItemID 在oItemHash表中查找是否存在
    if oItemHash.FindNode(nItemID) != NULL then (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), 是线性的.

分治问题时间复杂度分析通常可以通过递归树来进行推导。 递归树是一种树形结构,它以递归深度为纵轴,以每层递归的规模为横轴,展示了分治算法的递归过程。每个节点示一个递归调用,节点的子节点示递归调用的子问题。节点的代价示该递归调用的时间复杂度分治算法时间复杂度取决于三个因素:子问题的规模、子问题的数量和合并子问题的代价。通常情况下,分治算法时间复杂度可以示为: T(n) = aT(n/b) + f(n) 其中,a 示递归调用的次数,n/b 示每次递归调用的规模,f(n) 示合并子问题的代价。根据递归树可以推导出 T(n) 的解。 若子问题的规模为常数级别,即 b = 1,则 T(n) 的时间复杂度为 O(a^n)。这种情况下,递归调用的次数和问题规模无关,由于每个递归调用都需要一定的时间代价,因此时间复杂度会指数级别增长。 若子问题的规模为线性级别,即 b = n,则 T(n) 的时间复杂度为 O(n^loga)。这种情况下,递归调用的次数和问题规模呈对数级别增长,由于每个递归调用的时间代价相同,因此时间复杂度与子问题的数量和合并子问题的代价相关。 若子问题的规模为超线性级别,即 b > n,则 T(n) 的时间复杂度为 O(f(n))。这种情况下,递归调用的次数不会增长,时间复杂度取决于合并子问题的代价。 综上所述,分治问题时间复杂度分析需要考虑子问题的规模、子问题的数量和合并子问题的代价。递归树是一种常用的工具,可以帮助我们理解分治算法的递归过程,推导出时间复杂度的解。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值