JS玩转算法之冒泡排序

260 阅读6分钟

JS玩转算法之冒泡排序

冒泡排序是计算机科学中的一种相对简单的排序算法。本文从自然界中的“冒泡”现象延伸到冒泡排序算法中,详细介绍了冒泡排序的实现原理。

自然界中的冒泡

自然界中的水泡在上浮时会逐渐变大。原因在于,水下的压强随着水深增加而增大,较小的气泡受到较大的压强,因而体积变小;而气泡在上浮过程中随着深度减小,所受压强也逐渐减小,因此体积逐渐变大。

代码中的冒泡

我们可以将自然界中的水泡理解成代码中的数组中的每一个数。在冒泡排序的实现过程中,我们从数组的第一个元素开始,依次与后面的每一个元素进行比较。如果当前元素比后面的元素大,就将其交换位置,否则保持不变。这样,经过一轮比较之后,数组中的最后一个数就是整个数组中最大的数。

以下就是用 JavaScript 代码实现的一次“冒泡”:

function getMaxNumByBubble(array) {
  // 这里的循环次数是 array.length - 1,因为最后一个数不需要和任何数进行比较
  for (let i = 0; i < array.length - 1; i++) {
    // 如果前者比后者大,则将两者的位置进行交换
    if (array[i] > array[i + 1]) {
      swap(array, i, i + 1);
    }
  }
}

// 交换位置的工具函数
function swap(arr, a, b) {
  let temp = arr[a];
  arr[a] = arr[b];
  arr[b] = temp;
}

我们来验证下 getMaxNumByBubble 方法,通过冒泡的方式,将数组arr中最大的数移至最后一位

const arr = [10, 4, 3, 1, 7, 9, 8];
getMaxNumByBubble(arr);

console.log(arr); // [4, 3, 1, 7, 9, 8, 10]

可以看到 getMaxNumByBubble 成功将数组中最大的数移到了最后一位;我们可以继续执行 getMaxNumByBubble 方法,看看会得到什么~

getMaxNumByBubble(arr2); // [4, 3, 1, 7, 8, 9, 10]
getMaxNumByBubble(arr2); // [3, 1, 4, 7, 8, 9, 10]
getMaxNumByBubble(arr2); // [1, 3, 4, 7, 8, 9, 10]

我们可以看到,在执行了3次循环后,得到了一个升序的数组,这其实就是一个冒泡排序的过程,也体现了冒泡排序的核心思想,每次冒泡都只管筛选出一个最大的数,循环一定的次数后,一个数组自然会变成有序数组。那么,外循环的次数是由什么决定的呢?

影响外循环次数的因素

实际上,循环次数和数组的长度以及数组元素的混乱程度有关,下面我们通过两个例子来说明这一点。

数组的混乱程度

下面介绍三个长度相同的数组,它们的混乱程度不同。将它们变为有序数组所需要的循环次数也不同。在最坏的情况下需要循环 n - 1 次,示例如下:

// 这是一个已经排好序的数组,循环 0 次
const arr1 = [1, 2, 3, 4, 5, 6, 7];


// 这是一个部分有序的数组,需要执行 getMaxNumByBubble 方法 2 次,才可变成有序数组
const arr2 = [1, 2, 3, 4, 7, 6, 5];
getMaxNumByBubble(arr2);  // [1, 2, 3, 4, 6, 5, 7]
getMaxNumByBubble(arr2);  // [1, 2, 3, 4, 5, 6, 7]


// 这是一个完全倒序的数组,需要执行 getMaxNumByBubble 方法 6 次,才可变成有序数组
const arr3 = [7, 6, 5, 4, 3, 2, 1];
getMaxNumByBubble(arr3);  // [6, 5, 4, 3, 2, 1, 7] 
getMaxNumByBubble(arr3);  // [5, 4, 3, 2, 1, 6, 7] 
getMaxNumByBubble(arr3);  // [4, 3, 2, 1, 5, 6, 7] 
getMaxNumByBubble(arr3);  // [3, 2, 1, 4, 5, 6, 7] 
getMaxNumByBubble(arr3);  // [2, 1, 3, 4, 5, 6, 7] 
getMaxNumByBubble(arr3);  // [1, 2, 3, 4, 5, 6, 7] 

数组长度

下面介绍三个混乱程度相同的数组,它们的长度不同。将它们变为有序数组所需的循环次数也不同。在最坏的情况下需要循环 n - 1 次,示例如下:

// 这是一个完全倒序的数组,需要执行 getMaxNumByBubble 方法 6 次,才可变成有序数组
const arr4 = [7, 6, 5, 4, 3, 2, 1];
getMaxNumByBubble(arr4);  // [6, 5, 4, 3, 2, 1, 7] 
getMaxNumByBubble(arr4);  // [5, 4, 3, 2, 1, 6, 7] 
getMaxNumByBubble(arr4);  // [4, 3, 2, 1, 5, 6, 7] 
getMaxNumByBubble(arr4);  // [3, 2, 1, 4, 5, 6, 7] 
getMaxNumByBubble(arr4);  // [2, 1, 3, 4, 5, 6, 7] 
getMaxNumByBubble(arr4);  // [1, 2, 3, 4, 5, 6, 7] 

// 这是一个完全倒序的数组,需要执行 getMaxNumByBubble 方法 4 次,才可变成有序数组
const arr5 = [7, 6, 5, 4, 3];
getMaxNumByBubble(arr5);  // [6, 5, 4, 3, 7] 
getMaxNumByBubble(arr5);  // [5, 4, 3, 6, 7] 
getMaxNumByBubble(arr5);  // [4, 3, 5, 6, 7] 
getMaxNumByBubble(arr5);  // [3, 4, 5, 6, 7] 

// 这是一个完全倒序的数组,需要执行 getMaxNumByBubble 方法 3 次,才可变成有序数组
const arr6 = [7, 6, 5, 4];
getMaxNumByBubble(arr6);  // [6, 5, 4, 7]
getMaxNumByBubble(arr6);  // [5, 4, 6, 7]
getMaxNumByBubble(arr6);  // [4, 5, 6, 7]
  • 可以看出,循环次数主要受数组长度和混乱程度的影响。最坏的情况下(数组中的每一个数都比后一个数大),冒泡排序需要循环执行 n - 1 次。用代码来表示就是:
function bubbleSort(array) {
  for (let i = 0; i < array.length - 1; i++) {
    // getMaxNumByBubble
    for (let j = 0; j < array.length - 1; j++) {
      if (array[j] > array[j + 1]) {
        swap(array, j, j + 1);
      }
    }
  }
}

刚上面也提到了,这个算法是为了包含最坏的情况实现的,那么如果不是最坏的情况,该算法可以做哪些优化呢?

优化算法

减小内循环的边界

  • 在每执行一次冒泡后(也就是执行 getMaxNumByBubble),数组的最后一位或者多位可能已经是由当前数组中最大的几个数组成的有序部分。我们给代码添加一些注释来看下执行的过程
function bubbleSort(array) {
  for (let i = 0; i < array.length - 1; i++) {
  
    console.log(`----------第 ${i + 1} 轮冒泡-----------`);
    
    for (let j = 0; j < array.length - 1; j++) {
      console.log(`第 ${j + 1} 次内循环`);
      if (array[j] > array[j + 1]) {
        console.log(`${array[j]}${array[j + 1]} 交换,大值的索引为 ${j}`)
        swap(array, j, j + 1);
        console.log(`交换后数组为`, array);
      }
    }
    
    console.log('');
  }
}
  • 代码执行结果如下:
WechatIMG489.png
  • 我们可以从上图中看出,数组[3, 2, 1, 4, 5]在第一轮排序的过程中,交换了2次后,索引 1 后面的数已经变为有序[3, 4, 5],但还是执行了第3次和第4次循环,没有意义;同样在进行第二轮的时候,交换了1次,数组已全部变为有序,仍执行了后面3次无意义的循环。为了减少无意义的内循环,将每次发生交换时大值对应的索引记录下来,等这轮冒泡结束之后,将内循环的边界改为该索引,下一轮循环时将不会再对已经有序的部分比较了,优化后代码如下:
// 优化内循环边界后的代码
function bubbleSort(array) {
  let innerLoopBoundary = array.length - 1;
  let swapIndex = 0;
  for (let i = 0; i < array.length - 1; i++) {
  
    console.log(`----------第 ${i + 1} 轮冒泡-----------`);
    
    for (let j = 0; j < innerLoopBoundary; j++) {
      console.log(`第 ${j + 1} 次内循环`);
      if (array[j] > array[j + 1]) {
        console.log(`${array[j]}${array[j + 1]} 交换,大值的索引为 ${j}`)
        swap(array, j, j + 1);
        swapIndex = j;
        console.log(`交换后数组为`, array);
      }
    }
    
    innerLoopBoundary = swapIndex;
    console.log('');
  }
}
  • 优化后代码执行结果如下:
WechatIMG490.png
  • 我们发现,只优化了第2轮无意义的循环,第一轮的2次无意义的循环并没有被优化。这是因为我们必须要完整的循环完一轮后,才能知道最后一次交换的索引是多少,所以我们可以得出一个结论:(内循环边界 = 内循环总次数 - 上一轮冒泡最后一次交换时大值对应的索引)

减少外循环的次数

  • 还是上面那个例子,我们看下完整的执行结果
WechatIMG491.png

从上面的运行结果可以看出,在执行第 2 次外循环后,数组已经完全有序,但仍然执行了两次无意义的外循环。所以我们可以在每次外循环之前,先判断一下数组是否已经是有序的了,如果是有序的,就不需要再执行外循环了,优化后代码如下:

// 优化外循环后的代码
function bubbleSort(array) {
  let innerLoopBoundary = array.length - 1;
  let swapIndex = 0;
  for (let i = 0; i < array.length - 1; i++) {
  
    console.log(`----------第 ${i + 1} 轮冒泡-----------`);
    
    // 标志位,用来标识是否在内循环中做过交换
    let isSorted = true;
    
    for (let j = 0; j < innerLoopBoundary; j++) {
      console.log(`第 ${j + 1} 次内循环`);
      if (array[j] > array[j + 1]) {
        console.log(`${array[j]}${array[j + 1]} 交换,大值的索引为 ${j}`)
        swap(array, j, j + 1);
        swapIndex = j;
        isSorted = false;
        console.log(`交换后数组为`, array);
      }
    }
    
    innerLoopBoundary = swapIndex;
    
    // 如果为 true,则说明内循环中没做过交换,数组已经有序,跳出后续没意义的循环
    if (isSorted) {
      break;
    }
    
    console.log('');
  }
}
  • 执行结果如下:
WechatIMG492.png
  • 可以看到优化后的算法减少了一次外循环,仍然执行了第 3 轮冒泡;这是因为 isSorted 标志位只有在上一轮完全有序后,本轮才不会发生交换,才不会被改变。所以第 3 轮是必须要执行的,该方法优化不掉

总结

本文简单推导了下冒泡排序,提供了两种优化该算法的思路:通过减小内循环的边界和减少外循环的次数来优化算法,可以减少无意义的循环次数,提高算法效率。