1. 基本概念
1.1 算法原理
冒泡排序就是从序列中的第一个元素开始,依次对相邻的两个元素进行比较,如果前一个元素大于后一个元素则交换它们的位置。如果前一个元素小于或等于后一个元素,则不交换它们;这一比较和交换的操作一直持续到最后一个还未排好序的元素为止。
当这样的一趟操作完成时,序列中最大的未排序元素就被放置到了所有未排序的元素中最后的位置上,它就像水中的石块一样沉到了水底。而其它较小的元素则被移动到了序列的前面,就像水中的气泡冒到了水面一样。这就是为什么该算法被叫做冒泡排序的原因。
图1 冒泡排序的基本原理
图1展示了冒泡排序的基本原理。假设一个序列中共有 n 个元素,那么上面的比较和交换过程一共需要进行 n-1 趟:
第一趟需要比较序列中的所有元素,它的效果是将整个序列中最大的元素放置到了序列最后一个位置上。
第二趟只需要比较前面 n-1 个元素,因为前一趟中已经将最大的元素移到了它最终的位置上了。这一趟结束时,整个序列中第二大的元素就被放置到了倒数第二个位置上。
同样的,第三趟只需要比较前面 n-2 个元素。该趟结束时,序列中第三大的元素就被放到了倒数第三个位置上。
当进行第 i 趟的时候,需要比较的是前面 n-(i-1) 个元素,因为序列中最大的 i-1 个元素已经在前面的 i-1 趟排序中被排好了。注意,比较 n-(i-1) 个元素需要进行 n-i 次比较。
当最终到达第 n-1 趟的时候,只需要比较序列中最前面的两个数而已。该趟结束时,序列中第二小的数就被放置到了顺数第二个位置上。同时,序列中最小的数也被放到了第一个位置上。整个排序过程完成。
从以上对算法原理的讲解中,我们首先可以知道冒泡排序是一种交换排序,它需要进行大量的交换操作。其次,因为当两个元素相等时它们不会被交换,所以相等元素的相对位置在排序前后不会改变,因此冒泡排序又是一种稳定的排序算法。
假设我们有一个序列,它的元素分别为整数48、12、52、36、5,那么图2至图5则详细地展示了对该序列进行冒泡排序的整个过程。
图2 [48, 12, 52, 36, 5] 第1趟排序
图3 [48, 12, 52, 36, 5] 第2趟排序
图4 [48, 12, 52, 36, 5] 第3趟排序
图5 [48, 12, 52, 36, 5] 第4趟排序
下面我们用C语言来表达冒泡排序,可以看到代码非常简单,主要就是两层嵌套的循环语句。外层循环控制第1到第 n-1 趟排序,而内层循环则控制每一趟排序中对前面未排好序的元素进行比较和交换。
1.2 性能分析
我们对这第一版的冒泡排序进行一下性能分析。一共要进行 n-1 趟,且第 i 趟需要比较 n-i 次,所以总共需要比较(n-1) + (n-2) + ... + 1=n(n-1)/2次。
在最好的情况(序列中的元素已经排好序)下,总共需要交换0次;而在最坏的情况(序列中的元素为逆序时)下,每一次比较都要进行一次交换。因此总的元素移动次数为总的比较次数的3倍,即3n(n-1)/2;那么平均的元素移动次数为3n(n-1)/4。
因此,这第一版的冒泡排序的时间复杂度为O(n2);而空间复杂度为O(1),因为该算法只需要一个额外的变量用于交换元素。
2. 冒泡排序的第一次优化
序列中的元素有可能出现这样的情况,即经过前面几趟的排序后整个序列就已经排好序了,那么后面的那几趟排序就不需要再执行了。但是我们上面的第一版的冒泡排序即便是在这种情况下,仍然会执行所有的 n-1 趟的排序。即使后面几趟排序只进行比较而不需交换元素,但是当数据量很大的时候,这依旧会造成整体性能的明显下降。
因此,我们首先想到的优化方案就是当某一趟排序之后,如果整个序列已排好序了,那么就立即退出函数。这要怎么实现呢?其实很简单,只要在某一趟的排序中没有进行任何一次的元素交换,那么此时整个序列就排好序了。
因此,在每一趟排序的开始将一个标记swapped设置为0。在这一趟排序过程中,如果发生了数据交换,那么就将swapped设置为1。当这一趟排序结束,我们通过检查该swapped的值就可以知道整个序列是否已经排好序了。
假设我们有一个序列,它的元素分别为整数9、4、6、15、13。那么图6至图7则展示了经本次优化后的冒泡排序的完整执行过程。注意,虽然第一趟排序后整个序列就排好序了,但在第一趟排序中进行了元素交换(swapped被设置为1),算法此时并不知道整个序列已经排好了,所以还要进行第二趟排序。在第二趟排序中,不会进行任何元素交换(swapped最终为0),此时算法才知道整个序列已经是排好序了的。
图6 [9,4,6,15,13] 第1趟排序
图7 [9,4,6,15,13] 第2趟排序
在最好的情况下,第二版冒泡排序只需进行n-1次比较和0次元素移动;在最坏的情况下,还是进行n(n-1)/2次比较和3n(n-1)/2次元素移动。虽然这一版的冒泡排序的时间复杂度依旧是O(n2),但是和第一版相比肯定性能上更好。
3. 冒泡排序的第二次优化
在我们之前的想法中,当进行第 i 趟排序时,序列中只有最大的 i-1 个元素已经排好序了。因为那时我们认为每一趟仅排好一个元素,即它比较的所有元素中最大的那一个。因此第 i 趟排序的时候,需要对前面 n-(i-1) 个元素进行比较和交换。但其实此时这前 n-(i-1) 个元素中可能最大的那几个元素已经在它们最终的位置上了,这时第 i 趟实际需要比较的元素个数就可以小于n-(i-1)。
比如有一个序列24、30、12、40、50,那么第1趟排序之后的结果为24、12、30、40、50。在原来的想法中,第2趟需要比较前面4个数。但此时前4个数中最大的两个30和40已经在它们最终的位置上了,不需要再对它们进行位置上的调整。因此,第2趟可以只比较前两个数。
注意这个例子中,虽然在序列的初始状态中40和50就已经在它们最终的位置上了,但第1趟排序还是需要比较全部的5个数。因为此时没有任何信息可以将序列的这种特殊状态告知算法,某一趟是否可以执行比它原本理论上更少的比较次数,需要前一趟排序对序列状态的了解。
在每一趟排序中,我们都用一个变量lastIndex记录下本趟排序最后一次元素交换中前一个元素的下标。在该下标之后没有发生交换,说明该下标之后的所有元素都已经排好序了。那么下一趟排序就只需要对该下标及其之前的元素进行比较而已。这样下一趟排序需要比较的次数可能比原本需要的次数更少,也就在一定程度上提升了算法的效率。
图8 [24, 30, 12, 40, 50] 第1趟排序
图9 [24, 30, 12, 40, 50] 第2趟排序
图10 [24, 30, 12, 40, 50] 第3趟排序
图8至图10详细展示了经过第二次优化后的冒泡排序对[24, 30, 12, 40, 50]这个序列的执行情况。该例子中另一个值得注意的问题是,虽然在第2趟排序后整个序列就已经排好序了,但是第2趟中进行了一次元素交换而导致swapped等于1。因此第2趟后并不会立即退出函数,还要进行第3趟排序。在第3趟中内层循环不会执行而立即退出,因为此时lastIndex等于0,j(此时也等于0)小于lastIndex的条件不满足。在第3趟最后swapped为0,此时才退出算法。
这个例子的关键是要体会到为什么第2趟排序中只需比较前两个数而已,而不是前4个数。
(完)
举报/反馈