冒泡排序

冒泡排序重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。

这个算法的名字由来是因为越小/大的元素会经由交换慢慢“浮”到数列的顶端。

冒泡排序的思路

  1. 从头开始比较每一对相邻元素,如果不符合升序/降序的要求,就交换它们的位置
  2. 经历过几轮比较,第几大/小的元素一定在倒数第几个
  3. 忽略已经找到的元素,重复执行比较交换,直到全部元素有序

图示
冒泡排序

实现

1
2
3
4
5
6
7
8
9
10
function bubbleSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const len = arr.length;
for (let i = 0; i < len - 1; i++) {
for (let j = 0; j < len - 1 - i; j++) {
if (comparator(arr[j], arr[j + 1]) > 0) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
}

优化1

如果在一轮比较中没有数据交换,意味着整个数组已经有序,可以直接结束排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function bubbleSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const len = arr.length;
let sorted; // 用来标识是否已经排序成功
for (let i = 0; i < len - 1; i++) {
sorted = true;
for (let j = 0; j < len - 1 - i; j++) {
if (comparator(arr[j], arr[j + 1]) > 0) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
sorted = false;
}
}
if (sorted) {
break;
}
}
}

优化2

在排序过程中数组分为两个区域:一个是末尾的有序区,一个是前面的无序区。

真正的有序区不是已经找到的元素组成,而是由最后一次执行交换的下标到数组末尾。

每次比较仅比较到有序区,以及判断每一次是否有序可以减少比较次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function bubbleSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const len = arr.length;
let lastExchangeIndex = 0,
sortedBorder = len - 1;
for (let i = 0; i < len - 1; i++) {
lastExchangeIndex = 0;
for (let j = 0; j < sortedBorder; j++) {
if (comparator(arr[j], arr[j + 1]) > 0) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
lastExchangeIndex = j;
}
}
if (lastExchangeIndex === 0) {
break;
}
sortedBorder = lastExchangeIndex;
}
}

选择排序

选择排序是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小/大元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小/大元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

选择排序思路

  1. 从序列中找出最大/小的元素,和起始位置的元素进行交换
  2. 和冒泡排序一样,每一轮都可以决定一个数据的位置
  3. 忽略已经找到的元素,重复执行直到全部有序

图示

选择排序

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function selectionSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const len = arr.length;
let minIndex;
for (let i = 0; i < len; i++) {
minIndex = i;
for (let j = i + 1; j < len; j++) {
if (comparator(arr[minIndex], arr[j]) > 0) {
minIndex = j;
}
}
if (minIndex !== i) {
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
}
}

插入排序

插入排序的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序思路

  1. 插入排序将序列分为2个部分
    1. 头部是已经排好序的
    2. 尾部是待排序的
  2. 从头开始扫描每一个元素,并将其放入头部合适的位置,使头部数据保持有序,类似于扑克牌

图示

插入排序

实现

1
2
3
4
5
6
7
8
function insertionSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const len = arr.length;
for (let i = 1; i < len; i++) {
for (let j = i; j > 0 && comparator(arr[j], arr[j - 1]) < 0; j--) {
[arr[j], arr[j - 1]] = [arr[j - 1], arr[j]];
}
}
}

优化

将新数据插入过程的交换换为挪动,能减少交换次数提高效率,也更加符合扑克牌中的插入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function insertionSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const len = arr.length;
let curValue: T,
curIndex: number;
for (let i = 1; i < len; i++) {
curValue = arr[i];
curIndex = i;
for (let j = i; j > 0 && comparator(arr[j], arr[j - 1]) < 0; j--) {
[arr[j], arr[j - 1]] = [arr[j - 1], arr[j]];
curIndex = j - 1;
}
arr[curIndex] = curValue;
}
}

性能

插入排序的性能取决于输入元素的初始顺序,数组越接近有序排序越快。

将数据的交换变为挪动,可以大幅提高插入排序的速度,这样可以减少一半访问数组的次数。

时间复杂度:

  • 最坏 O(n2n^2)
  • 最好 O(n)
  • 平均 O(n2n^2)

希尔排序

对于大规模乱序数组插入排序很慢,因为插入排序只会交换相邻的元素,元素只能一点一点地从数组的一端移动到另一端。

希尔排序基于插入排序,简单的改进了一下,交换不相邻的元素以对数组的局部进行排序,最终将局部有序的数组排序。

希尔排序的思想是使数组中任意间隔为h的元素都是有序的,这样的数组称为 h有序数组

在进行排序时,h很大就能很容易的移动元素,也为实现更小的h创造便利,直到 h为1,数组就能够排序。

递增序列: h所取的值组成的序列,例如:h 从 N 通过 h/3 变为1。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function shellSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const len = arr.length;
let h = 1;
// 采用 1/2(3^k - 1) 序列
while (h < Math.floor(len / 3)) {
h = h * 3 + 1;
}
while (h >= 1) {
for (let i = h; i < len; i++) {
for (let j = i; j >= h && comparator(arr[j], arr[j - h]) < 0; j -= h) {
[arr[j], arr[j - h]] = [arr[j - h], arr[j]];
}
}
h = Math.floor(h / 3);
}
}

性能

希尔排序比插入排序要快的多,并且数组越大,优势越大。

希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序开始时子数组短,排序之后子数组部分有序,这两种情况都适合插入排序。

子数组部分有序的程度取决于递增序列的选择,递增序列的选择是一个数学问题。

时间复杂度:使用$ 1/2(3^k - 1)$序列达不到平方级别,在最坏的情况下比较次数和N3/2N^{3/2}成正比

归并排序

归并排序是采用分治法的一个非常典型的应用。先让每个子序列有序,将已有序的子序列合并,得到完全有序的序列。

归并排序思路

  1. 将当前序列平均分为2个子序列
  2. 将2个子序列排序
  3. 将排序完成的2个子序列合并为一个有序序列

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function mergeSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
const aux = new Array<T>(arr.length);

// 将 [lo, hi] 之间的数据排序
const sort = (lo: number, hi: number) => {
if (hi <= lo) { return; }
const mid = lo + Math.floor((hi - lo) / 2);
sort(lo, mid);
sort(mid + 1, hi);
merge(lo, mid, hi);
}

// 将 [lo, mid] [mid+1, hi] 之间的数据归并
const merge = (lo: number, mid: number, hi: number) => {
// i 为左半边下标, j 为右半边下标
// i 的取值范围[lo, mid] j 的取值范围[mid+1, hi],超出即为该半边的数据已经合并完
let i = lo, j = mid + 1;
for (let k = lo; k <= hi; k++) {
aux[k] = arr[k];
}
for (let k = lo; k <= hi; k++) {
if (i > mid) {
arr[k] = aux[j++];
} else if (j > hi) {
arr[k] = aux[i++];
} else if (comparator(aux[j], aux[i]) < 0) {
arr[k] = aux[j++];
} else {
arr[k] = aux[i++];
}
}
}
sort(0, arr.length - 1);
}

性能

归并排序是一种渐进最优的基于比较排序的算法。

时间复杂度:由于总是平均分割,最好、最坏、平均时间复杂度都是 O(nlgn)

空间复杂度:递归 + 辅助数组 O(lgn + n) —> O(n)

快速排序

快速排序是一种分治的排序算法,类似于归并排序。

  • 归并排序是将数组分成两个子数组分别排序,然后将有序的子数组归并以将整个数组排序

  • 快速排序是当两个子数组都有序时整个数组自然就排序了

在实现上的区别就是:前一种递归调用发生在处理数组之前,后一种递归调用发生在处理数组之后。

快速排序的思想:

不同于归并排序将数组等分,快排则是从数列中挑出一个元素(称为 pivot )将数组切分为两部分,一部分是小于该基准的元素,另一部分是大于该基准的元素。切分之后,pivot 就位于这两部分元素的中间。

以选择数组第一个元素为基准为例,进行切分:

递归地在另外两部分进行切分,则当所有切分完毕之后,子数组有序了,整个数组也有序了。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function quickSort<T>(arr: T[], comparator: Comparator = defaultComparator) {
// 将 [lo, hi] 之间的数组排序
const sort = (lo: number, hi: number) => {
if (hi <= lo) { return; }
const pivot = partition(lo, hi);
sort( lo, pivot - 1,);
sort( pivot + 1, hi,);
}

// 将 [lo, hi] 之间以 arr[lo] 为基准点进行切分,并返回基准点最终的下标
const partition = (lo: number, hi: number) => {
let i = lo, j = hi + 1;
const v = arr[lo];
while (true) {
while (comparator(arr[++i], v) < 0) if (i == hi) break;
while (comparator(v, arr[--j]) < 0) if (j == lo) break;
if (i >= j) break;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
[arr[lo], arr[j]] = [arr[j], arr[lo]];
return j;
}

sort(arr, 0, arr.length - 1);
}

性能

快速排序由冒泡排序演变而来,由于使用了分治法,所以原本需要比较N轮,只要比较 logN 轮。

快速排序的效率依赖于数组切分的效果,也就是取决于pivot的值。

最好的切分效果是每次都对半切分,最差的情况每次选中的都是最小/大的元素,每次只会确定一个元素位置,退化成冒泡排序。

时间复杂度:

  • 最好:O(nlgn)
  • 最坏:O(n2n^2)
  • 平均:O(nlgn)

空间复杂度:O(lgn)

快速排序对于数据随机的大数组效率很高,保持随机性的两种方式:

  • 排序前打乱数组
  • partition 随机选择一个 pivot

对于小数组,快速排序比插入排序慢,一般的做法是在排序小数组时使用插入排序。

sort 方法进行改进:

1
2
3
4
5
6
7
8
const sort = (lo: number, hi: number) => {
if (hi <= lo + M) {
insertionSort(arr, comparator);
}
const pivot = partition(lo, hi);
sort( lo, pivot - 1,);
sort( pivot + 1, hi,);
}

堆排序

堆排序指的是利用堆这种数据结构来辅助完成数组的排序。

堆排序的思路非常简单,首先在线性的时间内建一个堆,接着利用堆删除元素的思路进行排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function heapSort<T>(arr: T[], comparator: Comparator<T> = defaultComparator) {
MaxHeap.buildHeap(arr);
for (let i = arr.length - 1; i > 0; i--) {
[arr[i], arr[0]] = [arr[0], arr[i]];
siftDown(arr, 0, i, comparator);
}
}

function siftDown<T>(arr: T[], k: number, N = arr.length, comparator: Comparator<T> = defaultComparator) {
let j;
while ((j = (k << 1) + 1) < N) {
if (j < N - 1 && comparator(arr[j + 1], arr[j]) > 0) {
j++;
}
if (!(comparator(arr[j], arr[k]) > 0)) {
return;
}
[arr[k], arr[j]] = [arr[j], arr[k]];
k = j;
}
}