0 概述
虽然和C相比,python的效率低一些,但是这 并不是说python中就不需要考虑效率问题了。
最近在写一个文本预测的工具的时候,需要建立一个二阶马尔可夫模型。我在最开始的版本中,以一个4M大小的文本作为原材料。分割过程需要10S以上,hash表的建立需要15秒,而排序(快排)则因各种原因没有排出来。所以我将目光转向了一些没有注意的地方。
事实证明,cProfile的运用会让我发现一些没有注意到的细节,而优化它们真的会为程序的运行节省很多时间!
到了目前的版本,分析7个总大小为20M的文本并建立数据索引,花费的时间是:分割20s、markov构建22s。相比于之前4M总耗时20秒以上,整体加速了200%!
这个优化的过程真的是很有意思也很有成就感的,因此,在下面详细的记录一下我认为重要的条目。代码地址
1 快排并不快?——快排的优化和改进
最基本的快排,qsort1
最开始的版本中,因为python的字典不像C++ 的set,基于红黑树实现,因此我需要对一个字典进行排序。
首先为了方便,我写了一个insert sort函数。在小文本规模的情况下,也就是对一个A+B词组的下一个可能词组成的字典排序,这个排序是可用的(甚至于再后来的比较中发现效率还很高)。
但是在我对全部的A+B序列排序时,这个O(n^2)的算法显然不能胜任排列15W个元素的工作(这是预料之中的)
因此我必须使用快排。这个版本称之为QSORT1,它的代码很简单,就是书本上最常见的——
while(i<j){while(i<j&&condition1) j--; assignment1 while(i<j&&condition2) i++; assignment2}
这样的形式。
O(nlgn)的算法应该很快了吧?结果却不尽人意,因为在30秒后还没有排完!虽然是间接访问,但是这个时间显然已经超出了合理的范围。一定是哪里出问题了
快排拓展之一:相等情况处理,Qsort2
QSORT1失败后,我对整个要排序数组的性质做了思考。
首先,这个数组规模最大可能达到1000(文本为圣经,其中大量出现the lord这样的词组);
其次,这个数组中有大量元素是相等的!
我之前常使用快排,但也仅限于C优化后的的qsort函数、或者自己写的简单快排,并没有认真的思考对==情况的处理会对性能有多么大的不同?
因此,基于以上两点,我写出了下面的这个程序:
def __quicksort__(self,top,end):
if top > end:
return
index_rand = top #norandom random.randint(top,end)
flag = self.list_DictKeys[index_rand] ;
i = top ; j = end
write_top = top ; write_end = end
list_Equal = []
list_Equal.append(flag)
while(i<j):
while(i<j):
if self.diction[self.list_DictKeys[j]] < self.diction[flag]:
self.list_DictKeys[write_end] = self.list_DictKeys[j]
j -= 1
write_end-=1
elif self.diction[self.list_DictKeys[j]] == self.diction[flag]:
list_Equal.append(self.list_DictKeys[j])
j-=1
else:
break
self.list_DictKeys[write_top] = self.list_DictKeys[j]
self.list_DictKeys[i] = self.list_DictKeys[j]
while(i<j):
if self.diction[self.list_DictKeys[i]]>self.diction[flag]:
self.list_DictKeys[write_top] = self.list_DictKeys[i]
i+=1
write_top += 1
elif self.diction[self.list_DictKeys[i]] == self.diction[flag]:
list_Equal.append(self.list_DictKeys[i])
i += 1
else:
break
self.list_DictKeys[write_end] = self.list_DictKeys[i]
self.list_DictKeys[j] = self.list_DictKeys[i]
len_list_Equal = len(list_Equal)
for index_Equal in range(len_list_Equal):
self.list_DictKeys[index_Equal + write_top] = list_Equal[index_Equal]
self.qsort_stack.append( [ top, write_top-1 ] )
self.qsort_stack.append( [ write_end+1, end ] )
思想很简单,但是改的时候还真是BUG百出。如果你觉得被上面的这个复杂的数据结构绕蒙了的话(我就是),不妨看看下面的这一段。
while(i<j):
while(i<j):
if a[j] < flag:
a[write_end] = a[j]
j -= 1
write_end-=1
elif a[j] == flag:
list_Equal.append(a[j])
j-=1
else:
break
a[write_top] = a[j]
a[i] = a[j]
while(i<j):
if a[i]>flag:
a[write_top] = a[i]
i+=1
write_top += 1
elif a[i] == flag:
list_Equal.append(a[i])
i += 1
else:
break
a[write_end] = a[i]
a[j] = a[i]
我花费了一些时间去整理快排简单代码背后的一些逻辑,将其中的一些整合部分分解,终于理清了整个的过程。(当然我也花费了一些时间,理清了那个复杂的数据结构)
结果证明,效率的提升是可观的。15W的规模花费时间0.3秒!
结论1—— 对于有大量重复元素的集合排序,等号处理对于性能有极大的提升作用!
快排拓展之二——递归改循环,栈的引入
为什么要自己维护一个函数栈?因为我在处理15W数据的时候栈溢出了!
可以计算一下,15W(约等于2^17)在最差情况下产生2^17个函数栈,一个函数栈占用4Byte(也可能是8Byte),那么。。。好吧我怎么算出上G了?
上面的版本快排并没有使用递归,而是使用了一个list容器实现栈。
对快排的认识仅仅在写出正确代码的同学,可能没有想过递归转换成循环。不过看了下面的代码,相信你会觉得不过如此嘛!
def qsort(a):
qsort_stack.append([0,len(a)-1])
while qsort_stack != [ ] :
top_end = qsort_stack.pop()
top = top_end[0]
end = top_end[1]
__quicksort_random__(a,top , end )
return a
是的,思想很简单,每个子函数尾部生产两个子栈,主循环不断的消耗这些子栈,虽然效率略微降低,但是极大节省了栈空间
快排拓展之三——random版本。
如果一个集合,恰好有序,还恰好是相反的顺序,那么快排就悲剧了!
所以我们有时候需要一个random机制来避免这种悲剧发生。(不要想random后恰好是最差情况,这种情况可以认为是不可能事件)
从概率的角度来讲,random后快排应该是处于大于O(nlgn)复杂度、但是远小于n2复杂度的性能。
那么,问题来了,随机化的快排究竟是不是适合我们的字典排序?
我认为不适合。
在对4M文本处理的时候,效果还不明显,甚至于random版本会有一些提升; 但是在20M版本中,random函数却消耗了1秒的时间
结合我们要处理队列——乱序字典 它本身属于有序的概率实在是太小了!
如果可以的话,能够研究一下python 的dict机制是再好不过的了,不过如果python不用红黑树的dic都能比C++基于红黑树的set,那C++就哭晕在厕所了。。。
所以我删去了提升并不明显、甚至因为频繁调用而消耗大量时间的random。
2.insert排序真的慢吗 ?——合理选择排序
无脑快排的代价
插入排序带来的神奇
if len(dict_NextWord) < 80:
list_NextWord_sorted = c_DictSorter(dict_NextWord).insertsort()
else:
list_NextWord_sorted = c_DictSorter(dict_NextWord).qsort()
原本排序会花费12秒,现在只需要1.6+1.8的时间了!