冒泡排序的故事
年关将近,丐帮各分舵派代表前往总舵向帮主进贡。只见帮主门前立了一列梅花桩,各分舵代表立于桩上,等待帮主接见。(梅花桩上的数字代表梅花桩的编号,代表身上的数字代表其布袋数量,布袋数量越多辈分越高)
此时各代表所在位置如图示:
总管传话,请辈分最高的代表站到最前面(右边)来,其他人依次按辈分大小排队,排队期间禁止喧哗,并不得掉下梅花桩。
看官您也许会说,辈分最高的代表站到最右边来,这还不简单?让4号桩的6袋弟子和5号桩的4袋弟子换个位置不就行了吗。你这是站在“上帝视角”观看,当然全局在胸,一眼就能看出代表们的辈分高低。可是这些代表们来自不同分舵,彼此并不熟悉,只有站在相邻梅花桩上的代表才能互相交流比较辈分大小,虽然他们都有武功,但为了不从梅花桩上掉下来,也只能和相邻的代表交换位置。
现在队伍中谁的辈分最高大家并不清楚,需要扫描一遍整个队伍,才能找到他,并让他依次把位置换到最右边来。那么,扫描的顺序如何?是从左向右,还是从右向左呢?相信聪明的你心中已经有了答案。
因为辈分最高的人最终要站在最右边的梅花桩上,所以我们从最左边的代表开始扫描。第一步是让0号梅花桩和1号梅花桩上的代表交流,由于5袋弟子的辈分更高,所以他和3袋弟子的交换位置;第二步是5袋与1袋弟子交换位置;第三步是5袋与2袋弟子交换位置,此时5袋弟子已经站在了3号梅花桩上;第四步,5袋与6袋弟子交流,发现他们无需交换位置,于是原地不动;第五步是6袋与4袋弟子交换位置。就这样,一趟扫描下来,我们总共比较了5次,交换了4次位置,使得辈分最高的6袋弟子站在了最右边的梅花桩上。此时各代表所在位置如图示:
如果用python语言来描述这个过程,代码如下:
a = [5,3,1,2,4,6]
#各代表的初始位置
right = len(a) - 1 #最右边的梅花桩编号
for i in range(right):#从左向右扫描
if a[i] > a[i+1]: #如果辈分高的代表在左边,则交换位置
a[i],a[i+1] = a[i+1],a[i]
print(a) #输出此时各代表的位置
现在6袋弟子已经站到正确的梅花桩上了,那剩下的5名代表又该如何操作才能依次排序呢?相信聪明的你心中已经有了答案。
没错,就是按照同样的方法从左到右扫描一遍,把辈分最高的5袋弟子排到4号梅花桩上;然后依次把4,3,2袋弟子排好,最后1袋弟子就不用再扫描了,因为只剩下他一个人了,只能原地不动。
因此,我们要对6个代表排序,就总共需要扫描5轮,而且每轮扫描的结果都是把当前辈分最高的弟子排到最右边。这种只比较和交换相邻元素的算法,就像水中气泡浮起,人称冒泡排序。如果用python语言来描述这个过程,代码如下:
a =[5,3,1,2,4,6] #各代表的初始位置
for right in range(len(a)-1,0,-1): #控制待排序队伍的右边界
for iin range(right): #从左向右扫描
if a[i] > a[i+1]: #如果辈分高的代表在左边,则交换位置
a[i],a[i+1] = a[i+1],a[i]
print(a) #输出此时各代表的位置
程序输出如下:
[3, 1, 2, 4, 5,6]
[1, 2, 3, 4, 5,6]
[1, 2, 3, 4, 5,6]
[1, 2, 3, 4, 5,6]
[1, 2, 3, 4, 5,6]
仔细观察每轮扫描后的结果,发现实际上第2轮扫描后,整个序列就已经是有序的了,之所以还要进行后3轮扫描,完全是各代表“不知庐山真面目,只缘身在此山中”。我们注意到,如果排序未完成,则在扫描过程中一定会出现交换操作;反过来说,如果某轮扫描过程中未出现交换操作,则表明此时队伍已经是有序的了,可以提前跳出循环。
为了知道某轮扫描中是否存在交换操作,我们可以让每位代表都准备一面旗子,若相邻的两个代表需要交换位置,则由辈分小的代表举旗示意。一轮扫描下来,我们观察一下是否有人举旗,如果有,说明排序仍未完成,需要继续扫描;否则表示排序结束。
改进后的python代码如下:
a =[5,3,1,2,4,6] #各代表的初始位置
for right in range(len(a)-1,0,-1): #控制待排序队伍的右边界
flag = False #每轮扫描开始前均无人举旗
for i in range(right): #从左向右扫描
if a[i] > a[i+1]: #如果辈分高的代表在左边,则交换位置,并举旗示意
a[i],a[i+1] = a[i+1],a[i]
flag = True
if not flag: #若本轮扫描结束后无人举旗,则排序结束
break
print(a) #输出此时各代表的位置
程序输出如下:
[3, 1, 2, 4, 5,6]
[1, 2, 3, 4, 5,6]
通过举旗示意的方法,我们可以尽早发现排序是否完成,从而避免不必要的扫描,应该说算法有了很大的改进。那有没有更好的方法呢?答案是有。
按照前面的算法,我们每轮扫描的结果都是把当前辈分最高的弟子排到最右边,然后让右边界左移1位,使得下一轮扫描时的范围缩小一格。每轮扫描过后,扫描范围只缩小了一格,效率是不是有点低?快速缩小扫描范围,这就是我们思考的方向。
为了便于大家理解,我们先对各代表的初始位置做一些调整:
我们注意到第一轮排序后,举旗的代表是2袋和1袋弟子。虽然我们第一轮排序的目标是确定6袋弟子的位置,但由于4,5,6袋弟子本身就处在正确的位置上,他们无需进行交换操作,最后一次交换事件发生在1号和2号梅花桩上,由3袋弟子和1袋弟子交换位置,而2号梅花桩右侧的代表都是原地不动的,相当于这一轮扫描不仅确定了6袋弟子的位置,实际上连3袋弟子及其前辈们的位置都一并确定了。那么下一轮扫描的时候,右边界就不是4号梅花桩,而是1号梅花桩了。因此我们只要记住本轮扫描中最后一个举旗代表的位置,就可以把它作为下一轮扫描的右边界,从而快速缩小扫描的范围。
更进一步思考,前面的算法我们只缩小了右边界,左边界一直都是0,这显然是不够的。如果我们能够左右开弓,让左边界右移,这样扫描范围不就缩小得更快了吗?那如何改变左边界呢?答案就是从右向左扫描,把辈分最小的弟子转移到最左边。综合起来就是先向右扫描一轮,选出最大值,并确定下一轮扫描的右边界;然后向左扫描一轮,选出最小值,并确定新的左边界。这样左右交错冒泡,可以大大提高排序的效率。
交错冒泡排序算法(又称鸡尾酒排序)python代码如下:
a = [3,2,1,4,5,6] #各代表的初始位置
left, right = 0, len(a)-1
while left < right:
swap_pos = left #先假设最近一次发生交换操作的位置为left
for i in range(left,right): #顺序扫描a[left,right]
if a[i] > a[i+1]: #如果辈分高的代表在左边,则交换位置,并记录交换位置
a[i], a[i+1] = a[i+1], a[i]
swap_pos = i
right = swap_pos #设置新的右边界为最近一次发生交换操作的位置
for i in range(right,left,-1): #逆序扫描a[left,right]
if a[i] < a[i-1]: #如果辈分低的代表在右边,则交换位置,并记录交换位置
a[i], a[i-1] = a[i-1], a[i]
swap_pos = i
left = swap_pos #设置新的左边界为最近一次发生交换操作的位置
print(a) #输出此时各代表的位置
小结:冒泡排序算法的特点是只能比较和交换相邻的元素。
基础冒泡排序算法有两重循环,外层循环负责控制待排序数组的右边界,每次向左移动一位,直到只剩下一个元素为止;内层循环负责从左向右扫描,依次比较相邻元素,若有需要则进行交换操作,每轮扫描下来可将最大元素移动到右边界位置。
改进冒泡算法通过设置交换标记,可以知道数组是否已经完成排序,提前跳出外层循环,从而避免无效的扫描。
交错冒泡排序算法通过快速调整左右边界,大幅度减少扫描的范围和次数,效率最高。先从左向右扫描,确保将最大值放到最右边,并记录最近一次发生交换操作的位置,作为下一轮扫描的右边界;再从右向左扫描,,确保将最小值放到最左边,并记录最近一次发生交换操作的位置,作为下一轮扫描的左边界。如此交错冒泡,直至左右边界重合,完成排序。