微算法的优化
在很多软件系统中,有很多算法是存在“不足”的,只要我们仔细思考代码,很多看似微小的算法也存在优化的可能性,虽然大部分改变不了系统本质,但是却降低了系统“变坏”的可能性。
曾经在开发某个服务器框架过程中,需要实现一个基于磁盘的CACHE模块mydiskalloc,该分配器的分配函数中有一个子过程是要从多个具有不同大小规格的空闲块链表找出一个符合需求的空闲数据块。这是一个朴素的遍历算法,根据需求大小从一个适当的链表开始判断链表是否为空,如果不为空则取头元素并退出,否则继续寻找下一个链表。这个子过程里面遍历的链表数目最多是O(10^3)级别。一般情况下,可能这个代价并不会很大,毕竟CPU是很快的,而且磁盘CACHE的瓶颈也不会是在CPU上,但是在海量操作的时候还是能节省不少CPU时间的。假如现在有1000个空闲数据块链表List0到List999,其空闲块布局如下图所示:
每个链表存储的空闲数据块大小是有范围的,而且不会重叠,从小到大排列,其中第0个链表是特殊链表做特殊处理的,存储一些大块空闲块。如果要分配一个100K的数据块,那么将从List50开始遍历;如果要分配一个4K的数据块,那么将从List2开始遍历,假如List2不为空(如上图所示,该链表第一个数据块5K符合要求),那么马上可以找到符合要求的数据块,但是假如List2到List1000都是空的,那么这个遍历将显得低效率,特别是当cache用的比较满的时候,空闲链表上的空闲数据块并不会很多。
我们知道在字符串匹配算法中KMP算法采取了巧妙的前缀匹配思想实现蛙跳似算法,借鉴这个思想,如果在分配数据块的时候可以不需要那个遍历子过程那岂不是更好?于是开始优化,增加了一个索引数组,数组大小与链表个数一样大,其中第i项的值Value(i)表示当要从链表List(i)找数据块时,应该跳到链表List(Value(i))查找,其中Value(i)>=i或者Value(i)=0。假设目前空闲块的布局如下:
那么该索引数组的值如下所示:
比如第2项的值为4,表示List2为空,如要找List2的空闲块可以直接跳到List4进行查找,因为List3是空的,可以跳过。同理第5到第998项的值为999,因为List5到List998都是空的,如果要查找这些链表的空闲数据块可以跳到List999去找。显然,在分配的时候运用这个索引表可以大大缩小遍历计算量,但是维护这个索引表可能需要一些代价。比如说在分配之后或者释放之后,这个空闲块的布局可能会改变,那么这个索引表理论上也需要更新,否则下次使用的时候可能不准确。下面对分配和释放数据块进行详细分析:
首先分析分配操作对索引表的影响。假设某次分配一个数据块,这个数据块属于List(i),分三种情况:
1)这个数据块整块分配给用户使用
2)这个数据块进行了分裂,一部分分配给用户使用,另一部分独立返回到空闲链表List(j),其中j<=i或者i=0&&j>=0
3)这个数据块进行了分裂,一部分分配给用户使用,另一部分与已有的空闲数据块合并之后返回到空闲链表List(j),j与i的关系未定
对于1)这种情况,由于List(i)少了一个数据块,如果List(i)之后还是非空,则无影响;如果变为空链表,这个时候可能会对索引表的第i,i-1,...1项有影响,使得下次分配的时候有可能会访问到List(i)。不过这个无所谓,因为即使访问到了,也只是多一次判断而已(之后可以修正),所以这种情况无需调整索引表;
对于2)这种情况,其实是在1)上多了一个空闲块的影响。List(j)由于多了一个空闲块,如果原来List(j)不是空链表,那么没影响。如果List(j)原本是空链表,那么之后将变为非空链表,这个时候必须要调整索引表。因为索引表的第j,j-1,j-2,...,1项的值可能会大于j(即List(j)会被跳过不访问)。所以需要从第j项往前,把那些索引值比j大的项的值修改为j,直到发现有一个项的索引值比j小则退出。这个修改的代价是不定的,跟空闲块布局相关。从数学上分析,该计算量介于O(1)到O(N)之间。
对于3)这种情况,其实跟情况2)类似,只是j的值有可能更大一点,对索引表的调整是一样的。另外由于合并了一个数据块,假设该被合并的数据块原本属于List(k),那么有可能导致List(k)变为空链表,这个处理与情况1)完全一样,可以不要调整索引表,留待后续访问到了修正即可。
其次看看释放操作对索引表的影响,假设用户释放了一个数据块,那么有三种情况:
1)这个数据块单独得返回到List(i)空闲链表
2)这个数据块与已有的一个数据块(假设属于List(j))合并(前合并或者后合并)之后返回到空闲链表List(k),k>=j,or k=0
3)这个数据块与已有的两个数据块(假设分别属于List(j)\List(k))合并(同时发生前合并和后合并)之后返回到空闲链表List(t),t>=j, t>=k, or t=0
对于1)这种情况,如果原来List(i)不是空链表,那么无影响,如果原来是空链表,那么之后变成非空链表,需要对索引表进行调整,调整的方法与分配操作的第2)情况类似。
对于2)这种情况,其实本质上跟分配操作的第3)情况是一样的,都是某个链表多了一个数据块,另一个链表少了一个数据块,调整也是类似的。
对于3)这种情况,实质上只是在2)情况上又少了一个数据块,对索引表的调整与情况2)一样。
综合上述分析的结果,其实只需要在分配操作的地方对索引表的某个项进行简单的修正,以及在有新的空闲块加入某个空闲链表的时候对索引表的若干个项进行修正即可。维护索引表的代价并不大。
下面采用增加索引表的算法进行了一下程序实验,对比没有索引表的老算法,结果如下。测试用例:测试程序调用mydiskalloc分配器(空闲链表数目为1024个),随机穿插alloc、get、free三种操作(其实get操作不影响实验结果),数据块大小的范围是2K-2048K的之间,大小随机。(计算代价,指的是在遍历空闲链表集合的计算量或者在维护索引表的计算量,这两种计算量从数学上来说等价,但是实际代码上看后者比前者稍微多一点点开销;对于老算法没有维护索引表的计算量,对于新算法则两者兼有;计算代价越少越好)
Alloc次数 |
Get次数 |
Free次数 |
计算代价 |
|
老算法 |
2000 |
2662 |
1311 |
238809 |
新算法 |
2000 |
2652 |
1318 |
10241 |
Alloc次数 |
Get次数 |
Free次数 |
计算代价 |
|
老算法 |
2000 |
4478 |
893 |
327399 |
新算法 |
2000 |
4505 |
893 |
9423 |
Alloc次数 |
Get次数 |
Free次数 |
计算代价 |
|
老算法 |
2000 |
7763 |
156 |
720146 |
新算法 |
2000 |
7652 |
150 |
7395 |
Alloc次数 |
Get次数 |
Free次数 |
计算代价 |
|
老算法 |
2000 |
7602 |
63 |
875670 |
新算法 |
2000 |
7771 |
44 |
5640 |
可见增加索引表之后的算法,从计算代价上来说大大降低了,特别是随着空闲块数量的减少(free次数减少),两者差距越来越大,普遍降低了2个数量级。一个较少的改动,可以带来计算量的急剧下降,这个也是微算法优化极好的例子。时刻记住我们在大学所学的一些经典算法与数据结构及其蕴含的思想,许多现实的问题往往只是教科书上抽象问题的不同表述,但是解决问题的思想却是相通的,比如基数排序、快速排序、B树、A*算法、蒙特卡罗算法、小波变换等思想精髓。