摘要: 冒泡排序应该是大部分人学到的第一个排序算法, 它思想简单, 是入门排序算法的好选择. 然而由于它的时间复杂度为O(n^2), 所以除了学习它的时间之外我们就很少的想到它了, 通常提到更多的还是快速排序等时间复杂度更低的排序算法. 然而, 在对经典的冒泡排序进行改善之后, 在一定的条件之下, 仍然有它的用武之地.
本文首先介绍了 3 种对经典冒泡排序的改进思想, 然后将这 3 种思想结合起来, 实现综合了各自优点的方法.
冒泡排序的经典实现
不再用很多篇幅来讨论冒泡排序的思想, 简而言之它是通过两两比较并交换而将最值放置到数组的最后位置. 具体实现可以用双层循环, 外层用来控制内层循环中最值上浮的位置, 内层用来进行两两比较和交换位置.
以将数组从小到大排序为例, 下面的部分都默认如此. 冒泡排序的经典实现如下:
function bubbleSort(array){
// 外层循环使用 end 来控制内层循环中极值最终上浮到的位置
for(let end = array.length - 1; end > 0; end--){
// 内层循环用来两两比较并交换
for(let i = 0; i < end; i++){
if(array[i] > array[i + 1]){
swap(array, i, i+1);
}
}
}
}
上面代码中用到函数 swap() 来交换数组两个位置的元素, 在下面的代码中都会用到这个函数, 具体如下:
function swap(arr, i, j){
// [arr[i],arr[i+1]] = [arr[i+1],arr[i]]; // ES6
const temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
改进 一: 处理在排序过程中数组整体已经有序的情况
若数组本来就是有序的或者在排序的过程中已经有序, 则没有必要继续下面的比较, 可以直接返回这个数组. 但是冒泡排序的经典实现仍然会继续挨个访问每个元素并且比较大小. 虽然这个时候只有比较操作而没有交换操作, 但这些比较操作仍然是没有必要的.
数组已经有序的标志是在一趟内层循环中没有发生元素的位置交换(swap)操作, 也就是说从开头到结尾的每个元素都小于它之后的元素.
利用上面的原理, 可以对经典实现进行改进: 设置一个变量用来记录在一轮内层循环中是否发生过元素的交换操作, 并在每一轮内层循环结束后判断是否发生了元素交换. 若没有发生元素交换, 则说明数组已有序, 程序返回; 否则不做任何操作, 开始下一轮循环:
function bubbleSortOpt1(array){
for(let end = array.length - 1; end > 0; end--){
let isSorted = true; // <== 设置标志变量 isSorted 初始值为 true
for(let i = 0; i < end; i++){
if(array[i] > array[i + 1]){
swap(array, i, i+1);
isSorted = false; // <== 发生了交换操作, 说明再这一轮中数组仍然无序, 将变量 isSorted 设置为 false
}
}
// 在一轮内层循环后判断 是否有序, 若有序则直接 停止程序; 否则开始下一轮循环
if(isSorted){
return ; // <== 数组已经有序, 停止函数的执行
}
}
}
改进思想 二: 数组局部有序
若数组是局部有序的, 例如从某个位置开始之后的数组已经有序, 则没有必要对这一部分数组进行比较了.
此时的改进方法是: 在遍历过程中可以记下最后一次发生交换事件的位置, 下次的内层循环就到这个位置终止, 可以节约多余的比较操作.
使用一个变量来保存最后一个发生了交换操作的位置, 并设置为下一轮内层循环的终止位置:
function bubbleSortOpt2(array){
let endPos = array.length - 1; // 记录这一轮循环最后一次发生交换操作的位置
while(endPos > 0){
let thisTurnEndPos = endPos; // <== 设置这一轮循环结束的位置
for(let i = 0; i < thisTurnEndPos; i++){
if(array[i] > array[i+1]){
swap(array, i, i+1);
endPos = i; // <== 设置(更新)最后一次发生了交换操作的位置
}
}
// 若这一轮没有发生交换,则证明数组已经有序,直接返回即可
if(endPos === thisTurnEndPos){
return ;
}
}
}
改进思想 三: 同时将最大最小值归位
在经典实现中, 每次将最大的值调整到当前数组的最后, 而没有对最小的值进行操作. 其实在同一轮外层循环中, 可以在把最大值调整到数组最后面的同时和把最小值调整到最前面, 只要在内层循环中在从前到后安排最大值的同时, 也从后向前安排这些最小值的位置就可以了, 这种思想称为双向冒泡排序.
说起来比较抽象, 看代码就比较容易明白了:
// 双向冒泡排序, 不仅把最大的放到最后, 同时把最小的放到最前
function bubbleSortOpt3(array){
// <== 设置每一轮循环的开始与结束位置
let start = 0,
end = array.length - 1;
while(start < end){
for(let i = start; i < end; i++){ // 从start位置end位置过一遍安排最大值的位置
if(array[i] > array[i+1]){
swap(array, i, i+1);
}
}
end --; // <== 由于当前最大的数已经放到了 end 位置, 故 end 位置向前移动
for(let i = end; i > start; i--){ // 从end向start位置过一遍, 安排最小值的位置
if(array[i] < array[i-1]){
swap(array, i, i-1);
}
}
start ++; // <== 由于当前最小的数已经放到了 start 位置, 故 start 位置向后移动
}
}
然而这种方法也有个缺点, 即每次向前向后移动一个位置, 即end--和start++. 无法处理前面部分所说的两种情况, 所以可以将这三种方法结合起来发挥各自的优势.
三种思想的结合
以上三种思想分别处理在排序过程中数组整体已经有序、数组局部有序、同时将最大最小值放置在合适位置的情况. 那么将以上三者的优点结合起来可以达到更好的效果.
循序渐进, 先说说其中两种思想的结合
思想1、2的结合
将思想1和2结合起来, 处理数组局部有序和排序过程中整体有序的情况:
function bubbleSortOpt1and2(array){
let endPos = array.length - 1; // 记录下一轮循环结束的位置, 也就是上一轮最后交换的位置
while(endPos > 0){
let isSorted = true; // 设置数组整体有序标志变量
let thisTurnEndPos = endPos; // 记录这一轮循环结束的位置
for(let i = 0; i < thisTurnEndPos; i++){
if(array[i] > array[i+1]){
swap(array, i, i+1);
endPos = i; // 这个位置发生了交换, 将这个位置记录下来
isSorted = false; // 设置本轮为无序
}
}
if(isSorted){ // 判断数组是否已经整体有序
console.log(endPos);
return;
}
}
}
思想2、3的结合
将思想 2和3 结合起来, 从双向同时处理最大最小值, 而且处理数组局部有序的情况
// 结合第2、3种改进方式的思想, 记录双向排序中每个方向的最后交换位置, 并更新下一轮循环的结束位置
function bubbleSortOpt2and3(array){
let start = 0, startPos = start,
end = array.length - 1, endPos = end;
while(start < end){
// 从前向后过一遍
for(let i = start; i < end; i++){
if(array[i] > array[i+1]){
swap(array, i, i+1);
endPos = i; // 记录这个交换位置
}
}
end = endPos; // 设置下一轮的遍历终点
// 从后向前过一遍
for(let i = end; i > start; i--){
if(array[i] < array[i-1]){
swap(array, i, i-1);
startPos = i; // 记录这个交换位置
}
}
start = startPos; // 设置下一轮的遍历终点
}
}
同时使用以上三种思想
在有了两两结合的基础后, 不难写出将这三种思想结合的代码:
function bubbleSortOptTriple(array){
let start = 0, startPos = start,
end = array.length - 1, endPos = end;
while(start < end){
let isSorted = true; // 设置有序无序的标志变量
// 从前向后过一遍
for(let i = start; i < end; i++){
if(array[i] > array[i+1]){
swap(array, i, i+1);
endPos = i; // 记录这个交换位置
isSorted = false; // 设置无序标志
}
}
if(isSorted){
return;
}
end = endPos; // 设置下一轮的遍历终点
// 从后向前过一遍
for(let i = end; i > start; i--){
if(array[i] < array[i-1]){
swap(array, i, i-1);
startPos = i; // 记录这个交换位置
isSorted = false; // 设置无序标志
}
}
if(isSorted){
return;
}
start = startPos; // 设置下一轮的遍历终点
}
}
这就是终点了吗?
其实上面的程序还是可以改进的: 我们没有必要另外设置一个变量来记录在一趟排序中数组是否已经有序,而是可以比较一轮循环结束后的 endPos 是否等于end, 如果等于, 则说明本轮没有对 endPos 进行更新, 也就是没有发生交换操作, 进一步说明数组已经有序了. 当然, 对于startPos和start同理.