一个小项目中的Python中的性能优化细节——(上)从排序说起

本文分享了作者在Python项目中优化排序算法的经验,包括快速排序的改进及插入排序的应用场景选择,实现了程序运行效率的显著提升。

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排序真的慢吗 ?——合理选择排序


无脑快排的代价

        我对上面的那个改进后的快排很满意,所以很快的把它运用到了各种需要排序的地方。
        这样一来,结果应该快很多了吧?可是测试的时候,却发现,频繁调用快排的版本却比insert版本还要慢!
        为什么会这样?
        在对数据分析的时候我发现,后缀的元素个数大于100的词组,只有3000个左右,而其余的200W个词组,则大多处于20以下!
        这就意味着: 我们对于完全不需要快排的地方,调用了快排,反而因为频繁的函数调用大大降低了效率!

插入排序带来的神奇

        这时候无脑快排真的是太傻了!为什么不插入排序呢?
        我为此做了一个测试,10W次快排和插入排序,对于这个项目中的字典,词缀个数在90~110的区间内,二者时间是差不多的!
        因此就有了下面的代码:
			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的时间了!

        结论:快排不是唯一选择,插排有时也是神器。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值