复杂度分析"/>
浅谈分治算法的时间复杂度分析
在我的周围, 发现好多的同事, 朋友, 对一个算法进行时间复杂度分析时,尤其是递归函数进行分析时, 比较吃力, 因此特写这篇文章, 给刚做程序员或者对分治算法(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)是多少呢?
从函数中我们可以分析出,每次它都是把序列分成两部分, 在最优的情况下,即每次都分成两等份,那它的问题子序列个数就是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 ,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
=> 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中, 则添加
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), 是线性的.
发布评论