递归调用时, 最关键的就是递归调用栈的深度. 我们也可以理解为分治算法中, 被分成的段数。 也就是步骤中的第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), 是线性的.
分治算法的时间复杂度分析
最新推荐文章于 2024-07-03 18:00:14 发布