JavaScript 排序算法(ES6)

建议:
学习排序之前最好先看图解,这样可以更好的理解代码

本文中常使用 swap 函数,在这里提前列出来,以下就省略了。

function swap(arr, indexA, indexB) {
  //ES6 解构赋值
  [arr[indexA], arr[indexB]] = [arr[indexB], arr[indexA]];
}

冒泡排序 Bubble Sort (查看图解

简明解释

通过依次比较、交换相邻的元素大小(按照由小到大的顺序,如果符合这个顺序就不用交换)。
1 次这样的循环可以得到一个最大值,n - 1 次这样的循环可以排序完毕。

属性
  • 稳定
  • 时间复杂度 O(n²)
  • 交换 O(n²)
  • 对即将排序完成的数组进行排序 O(n)(但是这种情况下不如插入排序块,请继续看下文)
核心概念
  • 利用交换,将最大的数冒泡到最后
  • 使用缓存 postion 来优化
  • 使用双向遍历来优化
第一版:基本实现
function bubbleSort(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    for (let j = 0; j < i; j++) {
      if (arr[j] > arr[j + 1]) {
        swap(arr, j, j + 1);
      }
    }
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(bubbleSort(arr));
第二版:缓存 pos

设置一标志性变量 pos,用于记录每趟排序中最后一次进行交换的位置。 由于 pos 位置之后的记录均已交换到位,故在进行下一趟排序时只要扫描到 pos 位置即可。

function bubbleSort2(arr) {
  let i = arr.length - 1;

  while (i > 0) {
    let pos = 0;

    for (let j = 0; j < i; j++) {
      if (arr[j] > arr[j + 1]) {
        pos = j;
        swap(arr, j, j + 1);
      }
    }
    i = pos;
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(bubbleSort2(arr));
第三版:双向遍历

传统冒泡排序中每一趟排序操作只能找到一个最大值或最小值, 我们可以 在每趟排序中进行正向和反向两遍冒泡 , 一次可以得到两个最终值(最大和最小) , 从而使外排序趟数几乎减少了一半。

function bubbleSort3(arr) {
  let start = 0;
  let end = arr.length - 1;

  while (start < end) {
    for (let i = start; i < end; i++) {
      if (arr[i] > arr[i + 1]) {
        swap(arr, i, i + 1);
      }
    }
    end -= 1;
    for (let i = end; i > start; i--) {
      if (arr[i - 1] > arr[i]) {
        swap(arr, i - 1, i);
      }
    }
    start += 1;
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(bubbleSort3(arr));
第四版:结合 2&3

前两种优化方式(缓存 pos、双向遍历)的结合:

function bubbleSort4(arr) {
  let start = 0;
  let end = arr.length - 1;

  while (start < end) {
    let endPos = 0;
    let startPos = 0;

    for (let i = start; i < end; i++) {
      if (arr[i] > arr[i + 1]) {
        endPos = i;
        swap(arr, i, i + 1);
      }
    }
    end = endPos;
    for (let i = end; i > start; i--) {
      if (arr[i - 1] > arr[i]) {
        startPos = i;
        swap(arr, i - 1, i);
      }
    }
    start = startPos;
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(bubbleSort4(arr));
蚂蚁金服面试

来自于蚂蚁金服的一道面试题:

对于冒泡排序来说,能不能传入第二个参数(参数为函数),来控制升序和降序?(联想一下 array.sort())

function bubbleSort(arr, compareFunc) {
  for (let i = arr.length - 1; i > 0; i--) {
    for (let j = 0; j < i; j++) {
      if (compareFunc(arr[j], arr[j + 1])) {
        swap(arr, j, j + 1);
      }
    }
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(bubbleSort(arr, (a, b) => a > b));
console.log(bubbleSort(arr, (a, b) => b < a));

选择排序 Selection Sort(查看图解

简明解释

每一次内循环遍历寻找最小的数,记录下 minIndex,并在这次内循环结束后交换 minIndex 和 i 的位置。

重复这样的循环 n - 1 次即得到结果。

属性
  • 不稳定
  • Θ(n²) 无论什么输入,均为 Θ(n²)
  • Θ(n)交换: 注意,这里只有 n 次的交换,选择排序的唯一优点*

关于 Θ(n)
swaps:Selection sort has the property of minimizing the number of swaps. In
applications where the cost of swapping items is high, selection sort
very well may be the algorithm of choice.

可见即使是我们觉得最慢的选择排序,也有它的用武之地。

核心概念

“可预测”的时间复杂度,什么进来都是 O(n²),但不稳定,唯一的优点是减少了 swap 次数。

第一版:基本实现
function selectionSort(arr) {
  for (let i = 0, len = arr.length; i < len - 1; i++) {
    let minIndex = i;

    for (let j = i + 1; j < len; j++) {
      if (arr[j] < arr[minIndex]) {
        minIndex = j;
      }
    }
    if (i !== minIndex) {
      swap(arr, i, minIndex);
    }
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(selectionSort(arr));
第二版:找到最大值

如果你想在每次内循环中找到最大值并把其交换到数组的末尾(相比较 minIndex 有点麻烦),以下是实现的代码:

function selectionSort2(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    let maxIndex = i;

    for (let j = i - 1; j >= 0; j--) {
      if (arr[j] > arr[maxIndex]) {
        maxIndex = j;
      }
    }
    if (i !== maxIndex) {
      swap(arr, i, maxIndex);
    }
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(selectionSort2(arr));

插入排序 Insertion Sort(查看图解

简明解释

默认 a[0] 为已排序数组中的元素,从 arr[1] 开始逐渐往已排序数组中插入元素,从后往前一个个比较,如果待插入元素小于已排序元素,则已排序元素往后移动一位,直到待插入元素找到合适的位置并插入已排序数组。

经过 n - 1 次这样的循环插入后排序完毕。

属性
  • 稳定
  • 适合场景:对快要排序完成的数组时间复杂度为 O(n)
  • 非常低的开销
  • 时间复杂度 O(n²)
  • 由于它的优点(自适应,低开销,稳定,几乎排序时的O(n)时间),插入排序通常用作递归基本情况(当问题规模较小时)针对较高开销分而治之排序算法,
    如希尔排序或快速排序。
核心概念
  • 高性能(特别是接近排序完毕时的数组),低开销,且稳定
  • 利用二分查找来优化
第一版:基本实现
function insertionSort(arr) {
  for (let i = 1, len = arr.length; i < len; i++) {
    const temp = arr[i];
    let preIndex = i - 1;

    while (arr[preIndex] > temp) {
      arr[preIndex + 1] = arr[preIndex];
      preIndex -= 1;
    }
    arr[preIndex + 1] = temp;
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(insertionSort(arr));
二分查找算法

因为对于插入排序的优化方法是二分查找优化,这里补充一下二分查找的算法的实现。

核心概念是:折半

function binarySearch(arr, value) {
  let min = 0;
  let max = arr.length - 1;
  
  while (min <= max) {
    const mid = Math.floor((min + max) / 2);

    if (arr[mid] === value) {
      return mid;
    } else if (arr[mid] > value) {
      max = mid - 1;
    } else {
      min = mid + 1;
    }
  }

  return 'Not Found';
}

// test
const arr = [1, 2, 3];
console.log(binarySearch(arr, 2));  // 1
console.log(binarySearch(arr, 4));  // Not Found
第二版:使用二分查找

首先把二分查找算法做一点小修改,以适应我们的插入排序:

function binarySearch(arr, maxIndex, value) {
  let min = 0;
  let max = maxIndex;
  
  while (min <= max) {
    const mid = Math.floor((min + max) / 2);

    if (arr[mid] <= value) {
      min = mid + 1;
    } else {
      max = mid - 1;
    }
  }

  return min;
}

然后在查找插入位置时使用二分查找的方式来优化性能:

function insertionSort2(arr) {
  for (let i = 1, len = arr.length; i < len; i++) {
    const temp = arr[i];
    const insertIndex = binarySearch(arr, i - 1, arr[i]);

    for (let preIndex = i - 1; preIndex >= insertIndex; preIndex--) {
      arr[preIndex + 1] = arr[preIndex];
    }
    arr[insertIndex] = temp;
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(insertionSort2(arr));

希尔排序 Shell Sort(查看图解

简明解释

希尔排序是插入排序的改进版,它克服了插入排序只能移动一个相邻位置的缺陷(希尔排序可以一次移动 gap 个距离),利用了插入排序在排序几乎已经排序好的数组的非常快的优点。

使用可以动态定义的 gap 来渐进式排序,先排序距离较远的元素,再逐渐递进,而实际上排序中元素最终位置距离初始位置远的概率是很大的,所以希尔排序大大提升了性能(尤其是 reverse 的时候非常快,想象一下这时候冒泡排序和插入排序的速度)。

而且希尔排序不仅效率较高(比冒泡和插入高),它的代码相对要简短,低开销(继承插入排序的优点),追求这些特点(效率要求过得去就好,代码简短,开销低,且数据量较小)的时候希尔排序是好的 O(n·log(n)) 算法的替代品。

总而言之:希尔排序的性能优化来自增量队列的输入和 gap 的设定。

属性
  • 不稳定
  • 在快要排序完成的数组有 O(n·log(n)) 的时间复杂度(并且它对于反转数组的速度非常快)
  • O(n^3/2) time as shown (想要了解更多细节,请查阅 wikipedia Shellsort)
    关于不稳定:

我们知道, 单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在多次不同的插入排序过程中,
相同的元素可能在各自的插入排序中移动,可能导致相同元素相对顺序发生变化。因此, 希尔排序并不稳定。

关于 worse-case time 有一点复杂:
The worse-case time complexity of shell sort depends on the increment
sequence. For the increments 1 4 13 40 121…, which is what is used
here, the time complexity is O(n3/2). For other increments, time
complexity is known to be O(n4/3) and even O(n·log2(n)).

核心概念

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到 O(n) 的效率;
但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位 ; 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位 ;
其中 gap(增量)的选择是希尔排序的重要部分。只要最终 gap 为 1 任何 gap 序列都可以工作。算法最开始以一定的 gap 进行排序。然后会继续以一定 gap 进行排序,直到 gap = 1 时,算法变为插入排序。

Donald Shell 最初建议 gap 选择为 n / 2 并且对 gap 取半直到 gap 达到 1 。虽然这样取可以比 O(n²) 类的算法(插入排序、冒泡排序)更好,但这样仍然有减少平均时间和最差时间的余地。 (关于优化 gap 的细节涉及到复杂的数学知识,我们这里不做深究,详细可以参考 wikipedia 上的页面)

第一版:基本实现

Donald Shell 的最初建议(gap = n / 2)版代码(方便理解):

function shellSort(arr) {
  const len = arr.length;
  let gap = Math.floor(len / 2);

  while (gap > 0) {
    // 注意下面这段 for 循环和插入排序极为相似
    for (let i = gap; i < len; i++) {
      const temp = arr[i];
      let preIndex = i - gap;

      while (arr[preIndex] > temp) {
        arr[preIndex + gap] = arr[preIndex];
        preIndex -= gap;
      }
      arr[preIndex + gap] = temp;
    }
    gap = Math.floor(gap / 2);
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(shellSort(arr));
第二版:Knuth’s increment sequence

常见的、易生成的、优化 gap 的序列方法(来自 Algorithms (4th Edition) ,有些更快的方法但序列不容易生成,因为用到了比较深奥的数学公式):

function shellSort(arr) {
  const len = arr.length;
  let gap = 1;

  while (gap < len / 3) {
    gap = gap * 3 + 1;
  }
  while (gap > 0) {
    for (let i = gap; i < len; i++) {
      const temp = arr[i];
      let preIndex = i - gap;

      while (arr[preIndex] > temp) {
        arr[preIndex + gap] = arr[preIndex];
        preIndex -= gap;
      }
      arr[preIndex + gap] = temp;
    }
    gap = Math.floor(gap / 2);
  }

  return arr;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(shellSort(arr));

归并排序 Merge Sort(查看图解

简明解释

归并排序使用分而治之的思想,以折半的方式来递归/迭代排序元素,利用空间来换时间,做到了时间复杂度 O(n·log(n)) 的同时保持了稳定。

这让它在一些更考虑排序效率和稳定性,次考虑存储空间的场合非常适用(如数据库内排序,和堆排序相比,归并排序的稳定是优点)。并且归并排序非常适合于链表排序。

属性
  • 稳定 (在 O(n·log(n)) 时间复杂度的排序算法中,归并排序是唯一稳定的)
  • 时间复杂度 O(n·log(n))
  • 对于数组需要 Θ(n) 的额外空间 注意:归并排序需要额外的空间,这是它的不完美之处
  • 对于链表需要 O(log(n)) 的额外空间,所以归并排序非常适合列表的排序
    Does not require random access to data 因为这个特点,归并排序很适合用来排序列表
核心概念
  • 分而治之的思想
  • 空间换时间,并且稳定,保持稳定性这一点是它的亮点
  • 二分思想
第一版:基本实现

以迭代的方式来实现(但要注意防止函数调用过深导致 JavaScript 的运行栈溢出):

function mergeSort(arr) {
  const len = arr.length;

  if (len < 2) { return arr; }

  const mid = Math.floor(len / 2);
  const left = arr.slice(0, mid);
  const right = arr.slice(mid);

  return merge(mergeSort(left), mergeSort(right));
}

function merge(left, right) {
  const result = [];

  while (left.length > 0 && right.length > 0) {
    result.push(left[0] <= right[0] ? left.shift() : right.shift());
  }

  return result.concat(left, right);
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(mergeSort(arr));
第二版:空间优化

用 array.splice 取代 array.slice,减少一半的空间消耗。

function mergeSort2(arr) {
  const len = arr.length;

  if (len < 2) { return arr; }

  const mid = Math.floor(len / 2);
  const left = arr.splice(0, mid);
  const right = arr;

  return merge(mergeSort(left), mergeSort(right));
}

function merge(left, right) {
  const result = [];

  while (left.length > 0 && right.length > 0) {
    result.push(left[0] <= right[0] ? left.shift() : right.shift());
  }

  return result.concat(left, right);
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(mergeSort2(arr));

堆排序 Heap Sort(查看图解

简明解释

堆排序可以认为是选择排序的改进版,像选择排序一样将输入划分为已排序和待排序。

不一样的是堆排序利用堆这种近似完全二叉树的良好的数据结构来实现排序,本质上使用了二分的思想。

先将所有的数据堆化
然后移动 arr[0] 到数组末尾(已排序区域)
再重新堆化,依次这样循环来排序。
利用堆这种良好的数据结构,它在拥有良好的可预测性的同时(不管输入什么都是 O(n·log(n)) 时间复杂度),但它的缺点也有:即不稳定,而且 O(n·log(n)) 的平均效率决定了它的效率不如快速排序。适用于数据库内引擎排序(需要这样的可预测性性能)。

属性
  • 不稳定
  • O(n·log(n)) time
核心概念
  • 利用良好的数据结构——堆,来排序
  • 二分的思想
  • 选择排序的改进版,继承了"可预测性"(什么数据输入都为 O(n·log(n) time)
第一版:基本实现
function heapSort(arr) {
  let size = arr.length;

  // 初始化堆,i 从最后一个父节点开始调整,直到节点均调整完毕 
  for (let i = Math.floor(size / 2) - 1; i >= 0; i--) {
    heapify(arr, i, size);
  }
  // 堆排序:先将第一个元素和已拍好元素前一位作交换,再重新调整,直到排序完毕
  for (let i = size - 1; i > 0; i--) {
    swap(arr, 0, i);
    size -= 1;
    heapify(arr, 0, size);
  }

  return arr;
}

function heapify(arr, index, size) {
  let largest = index;
  let left = 2 * index + 1;
  let right = 2 * index + 2;

  if (left < size && arr[left] > arr[largest]) {
    largest = left;
  }
  if (right < size && arr[right] > arr[largest]) {
    largest = right;
  }
  if (largest !== index) {
    swap(arr, index, largest);
    heapify(arr, largest, size);
  }
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(heapSort(arr));

维基上给出的另一个方法

wikipedia 上给出的方法于第一版的区别在于维护堆性质时采用的方式不同,本质是一样的:

function heapSort(arr) {
  const size = arr.length;

  // 初始化 heap,i 从最后一个父节点开始调整,直到节点均调整完毕 
  for (let i = Math.floor(size / 2) - 1; i >= 0; i--) {
    heapify(i, size);
  }
  // 堆排序:先将第一个元素和已拍好元素前一位作交换,再重新调整,直到排序完毕
  for (let i = size - 1; i > 0; i--) {
    swap(arr, 0, i);
    heapify(0, i);
  }

  return arr;
}

function heapify(start, end) {
  // 建立父节点下标和子节点下标
  const dad = start;
  let son = dad * 2 + 1;

  if (son >= end) { return 0; }

  if (son + 1 < end && arr[son] < arr[son + 1]){
    son += 1;
  }
  if (arr[dad] <= arr[son]) {
    swap(arr, dad, son);
    heapify(son, end);
  }

  return 0;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(heapSort(arr));

快速排序 Quick Sort(查看图解

简明解释

从数列中挑出一个元素,称为"基准"(pivot),
重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任何一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。

属性
  • 不稳定
  • O(n²) time, 但是通常都是 O(n·log(n)) time (或者更快)
  • O(log(n)) extra space
  • When implemented well, it can be about two or three times faster than
    its main competitors, merge sort and heap sort
核心概念
  • 使用了分而治之的思想
第一版:基本实现
function quickSort(arr) {
  const pivot = arr[0];
  const left = [];
  const right = [];
  
  if (arr.length < 2) { return arr; }

  for (let i = 1, len = arr.length; i < len; i++) {
    arr[i] < pivot ? left.push(arr[i]) : right.push(arr[i]);
  }

  return quickSort(left).concat([pivot], quickSort(right));
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(quickSort(arr));
第二版:函数式编程

函数式编程:结构清晰,一目了然。

function quickSort2(arr) {
  const pivot = arr.shift();
  const left = [];
  const right = [];

  if (arr.length < 2) { return arr; }

  arr.forEach((element) => {
    element < pivot ? left.push(element) : right.push(element);
  });

  return quickSort2(left).concat([pivot], quickSort2(right));
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(quickSort2(arr));
第三版:in-place

等等,有没有觉得第一、二版中的代码虽然看起来简洁,但是却对空间消耗很大呢?

由此有了 in-place 版本:

function quickSort3(arr, left = 0, right = arr.length - 1) {
  if (left < right) {
    const pivot = partition(arr, left, right);

    quickSort3(arr, left, pivot - 1);
    quickSort3(arr, pivot + 1, right);
  }
  return arr;
}

function partition (arr, left ,right) {
  let pivot = left; // 以第一个元素为 pivot

  for (let i = left + 1; i <= right; i++) {
    if (arr[i] < arr[left]) { 
      swap(arr, i, pivot);
      pivot += 1;
    }
  }
  swap(arr, left, pivot); //将 pivot 值移至中间
  
  return pivot;
}

// test
const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(quickSort3(arr));
第四版:关于 pivot 的选取

这一版的亮点是 pivot 的选取,不再是简单的取 arr[0],而是:

const pivot = left + Math.ceil((right - left) * 0.5)
1
由此有了以下版本:

function quickSort4(arr, left = 0, right = arr.length - 1) {
  if (left < right) {
    // const pivot = left + Math.ceil((right - left) * 0.5);
    const pivot = Math.floor((right + left) / 2);
    const newPivot = partition(arr, pivot, left, right);

    quickSort4(arr, left, newPivot - 1);
    quickSort4(arr, newPivot + 1, right);
  }

  return arr;
}

function partition(arr, pivot, left, right) {
  const pivotValue = arr[pivot];
  let newPivot = left;

  swap(arr, pivot, right);
  for (let i = left; i < right; i++) {
    if (arr[i] < pivotValue) {
      swap(arr, i, newPivot);
      newPivot += 1;
    }
  }
  swap(arr, right, newPivot);

  return newPivot;
}

const arr = [91, 60, 96, 7, 35, 65, 10, 65, 9, 30, 20, 31, 77, 81, 24];
console.log(quickSort4(arr));
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值