JavaScript 数据结构与算法之美 - 十大经典排序算法

1. 前言

算法为王。javascript

想学好前端,先练好内功,内功不行,就算招式练的再花哨,终究成不了高手;只有内功深厚者,前端之路才会走得更远。html

笔者写的 JavaScript 数据结构与算法之美 系列用的语言是 JavaScript ,旨在入门数据结构与算法和方便之后复习。前端

文中包含了 十大经典排序算法 的思想、代码实现、一些例子、复杂度分析、动画、还有算法可视化工具。java

这应该是目前最全的 JavaScript 十大经典排序算法 的讲解了吧。git

2. 如何分析一个排序算法

复杂度分析是整个算法学习的精髓。github

  • 时间复杂度: 一个算法执行所耗费的时间。
  • 空间复杂度: 运行完一个程序所需内存的大小。

时间和空间复杂度的详解,请看 JavaScript 数据结构与算法之美 - 时间和空间复杂度算法

学习排序算法,咱们除了学习它的算法原理、代码实现以外,更重要的是要学会如何评价、分析一个排序算法。shell

分析一个排序算法,要从 执行效率内存消耗稳定性 三方面入手。api

2.1 执行效率

1. 最好状况、最坏状况、平均状况时间复杂度数组

咱们在分析排序算法的时间复杂度时,要分别给出最好状况、最坏状况、平均状况下的时间复杂度。 除此以外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。

2. 时间复杂度的系数、常数 、低阶

咱们知道,时间复杂度反应的是数据规模 n 很大的时候的一个增加趋势,因此它表示的时候会忽略系数、常数、低阶。

可是实际的软件开发中,咱们排序的多是 10 个、100 个、1000 个这样规模很小的数据,因此,在对同一阶时间复杂度的排序算法性能对比的时候,咱们就要把系数、常数、低阶也考虑进来。

3. 比较次数和交换(或移动)次数

这一节和下一节讲的都是基于比较的排序算法。基于比较的排序算法的执行过程,会涉及两种操做,一种是元素比较大小,另外一种是元素交换或移动。

因此,若是咱们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。

2.2 内存消耗

也就是看空间复杂度。

还须要知道以下术语:

  • 内排序:全部排序操做都在内存中完成;
  • 外排序:因为数据太大,所以把数据放在磁盘中,而排序经过磁盘和内存的数据传输才能进行;
  • 原地排序:原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。

2.3 稳定性

  • 稳定:若是待排序的序列中存在值相等的元素,通过排序以后,相等元素之间原有的前后顺序不变。 好比: a 本来在 b 前面,而 a = b,排序以后,a 仍然在 b 的前面;
  • 不稳定:若是待排序的序列中存在值相等的元素,通过排序以后,相等元素之间原有的前后顺序改变。 好比:a 本来在 b 的前面,而 a = b,排序以后, a 在 b 的后面;

3. 十大经典排序算法

3.1 冒泡排序(Bubble Sort)

冒泡

思想

  • 冒泡排序只会操做相邻的两个数据。
  • 每次冒泡操做都会对相邻的两个元素进行比较,看是否知足大小关系要求。若是不知足就让它俩互换。
  • 一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工做。

特色

  • 优势:排序算法的基础,简单实用易于理解。
  • 缺点:比较次数多,效率较低。

实现

// 冒泡排序(未优化)
const bubbleSort = arr => {
	console.time('改进前冒泡排序耗时');
	const length = arr.length;
	if (length <= 1) return;
	// i < length - 1 是由于外层只须要 length-1 次就排好了,第 length 次比较是多余的。
	for (let i = 0; i < length - 1; i++) {
		// j < length - i - 1 是由于内层的 length-i-1 到 length-1 的位置已经排好了,不须要再比较一次。
		for (let j = 0; j < length - i - 1; j++) {
			if (arr[j] > arr[j + 1]) {
				const temp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = temp;
			}
		}
	}
	console.log('改进前 arr :', arr);
	console.timeEnd('改进前冒泡排序耗时');
};
复制代码

优化:当某次冒泡操做已经没有数据交换时,说明已经达到彻底有序,不用再继续执行后续的冒泡操做。

// 冒泡排序(已优化)
const bubbleSort2 = arr => {
	console.time('改进后冒泡排序耗时');
	const length = arr.length;
	if (length <= 1) return;
	// i < length - 1 是由于外层只须要 length-1 次就排好了,第 length 次比较是多余的。
	for (let i = 0; i < length - 1; i++) {
		let hasChange = false; // 提早退出冒泡循环的标志位
		// j < length - i - 1 是由于内层的 length-i-1 到 length-1 的位置已经排好了,不须要再比较一次。
		for (let j = 0; j < length - i - 1; j++) {
			if (arr[j] > arr[j + 1]) {
				const temp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = temp;
				hasChange = true; // 表示有数据交换
			}
		}

		if (!hasChange) break; // 若是 false 说明全部元素已经到位,没有数据交换,提早退出
	}
	console.log('改进后 arr :', arr);
	console.timeEnd('改进后冒泡排序耗时');
};
复制代码

测试

// 测试
const arr = [7, 8, 4, 5, 6, 3, 2, 1];
bubbleSort(arr);
// 改进前 arr : [1, 2, 3, 4, 5, 6, 7, 8]
// 改进前冒泡排序耗时: 0.43798828125ms

const arr2 = [7, 8, 4, 5, 6, 3, 2, 1];
bubbleSort2(arr2);
// 改进后 arr : [1, 2, 3, 4, 5, 6, 7, 8]
// 改进后冒泡排序耗时: 0.318115234375ms
复制代码

分析

  • 第一,冒泡排序是原地排序算法吗 ?

冒泡的过程只涉及相邻数据的交换操做,只须要常量级的临时空间,因此它的空间复杂度为 O(1),是一个原地排序算法。

  • 第二,冒泡排序是稳定的排序算法吗 ?

在冒泡排序中,只有交换才能够改变两个元素的先后顺序。 为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,咱们不作交换,相同大小的数据在排序先后不会改变顺序。 因此冒泡排序是稳定的排序算法。

  • 第三,冒泡排序的时间复杂度是多少 ?

最佳状况:T(n) = O(n),当数据已是正序时。 最差状况:T(n) = O(n2),当数据是反序时。 平均状况:T(n) = O(n2)。

动画

冒泡排序动画

冒泡排序动画

3.2 插入排序(Insertion Sort)

插入排序又为分为 直接插入排序 和优化后的 拆半插入排序希尔排序,咱们一般说的插入排序是指直接插入排序。

1、直接插入

思想

通常人打扑克牌,整理牌的时候,都是按牌的大小(从小到大或者从大到小)整理牌的,那每摸一张新牌,就扫描本身的牌,把新牌插入到相应的位置。

插入排序的工做原理:经过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

步骤

  • 从第一个元素开始,该元素能够认为已经被排序;
  • 取出下一个元素,在已经排序的元素序列中从后向前扫描;
  • 若是该元素(已排序)大于新元素,将该元素移到下一位置;
  • 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置;
  • 将新元素插入到该位置后;
  • 重复步骤 2 ~ 5。

实现

// 插入排序
const insertionSort = array => {
	const len = array.length;
	if (len <= 1) return

	let preIndex, current;
	for (let i = 1; i < len; i++) {
		preIndex = i - 1; //待比较元素的下标
		current = array[i]; //当前元素
		while (preIndex >= 0 && array[preIndex] > current) {
			//前置条件之一: 待比较元素比当前元素大
			array[preIndex + 1] = array[preIndex]; //将待比较元素后移一位
			preIndex--; //游标前移一位
		}
		if (preIndex + 1 != i) {
			//避免同一个元素赋值给自身
			array[preIndex + 1] = current; //将当前元素插入预留空位
			console.log('array :', array);
		}
	}
	return array;
};
复制代码

测试

// 测试
const array = [5, 4, 3, 2, 1];
console.log("原始 array :", array);
insertionSort(array);
// 原始 array:    [5, 4, 3, 2, 1]
// array:  		 [4, 5, 3, 2, 1]
// array:  		 [3, 4, 5, 2, 1]
// array: 		 [2, 3, 4, 5, 1]
// array:  		 [1, 2, 3, 4, 5]
复制代码

分析

  • 第一,插入排序是原地排序算法吗 ?

插入排序算法的运行并不须要额外的存储空间,因此空间复杂度是 O(1),因此,这是一个原地排序算法。

  • 第二,插入排序是稳定的排序算法吗 ?

在插入排序中,对于值相同的元素,咱们能够选择将后面出现的元素,插入到前面出现元素的后面,这样就能够保持原有的先后顺序不变,因此插入排序是稳定的排序算法。

  • 第三,插入排序的时间复杂度是多少 ?

最佳状况:T(n) = O(n),当数据已是正序时。 最差状况:T(n) = O(n2),当数据是反序时。 平均状况:T(n) = O(n2)。

动画

insertion-sort.gif

2、拆半插入

插入排序也有一种优化算法,叫作拆半插入

思想

折半插入排序是直接插入排序的升级版,鉴于插入排序第一部分为已排好序的数组,咱们没必要按顺序依次寻找插入点,只需比较它们的中间值与待插入元素的大小便可。

步骤

  • 取 0 ~ i-1 的中间点 ( m = (i-1) >> 1 ),array[i] 与 array[m] 进行比较,若 array[i] < array[m],则说明待插入的元素 array[i] 应该处于数组的 0 ~ m 索引之间;反之,则说明它应该处于数组的 m ~ i-1 索引之间。
  • 重复步骤 1,每次缩小一半的查找范围,直至找到插入的位置。
  • 将数组中插入位置以后的元素所有后移一位。
  • 在指定位置插入第 i 个元素。

注:x >> 1 是位运算中的右移运算,表示右移一位,等同于 x 除以 2 再取整,即 x >> 1 == Math.floor(x/2) 。

// 折半插入排序
const binaryInsertionSort = array => {
	const len = array.length;
	if (len <= 1) return;

	let current, i, j, low, high, m;
	for (i = 1; i < len; i++) {
		low = 0;
		high = i - 1;
		current = array[i];

		while (low <= high) {
			//步骤 1 & 2 : 折半查找
			m = (low + high) >> 1; // 注: x>>1 是位运算中的右移运算, 表示右移一位, 等同于 x 除以 2 再取整, 即 x>>1 == Math.floor(x/2) .
			if (array[i] >= array[m]) {
				//值相同时, 切换到高半区,保证稳定性
				low = m + 1; //插入点在高半区
			} else {
				high = m - 1; //插入点在低半区
			}
		}
		for (j = i; j > low; j--) {
			//步骤 3: 插入位置以后的元素所有后移一位
			array[j] = array[j - 1];
			console.log('array2 :', JSON.parse(JSON.stringify(array)));
		}
		array[low] = current; //步骤 4: 插入该元素
	}
	console.log('array2 :', JSON.parse(JSON.stringify(array)));
	return array;
};
复制代码

测试

const array2 = [5, 4, 3, 2, 1];
console.log('原始 array2:', array2);
binaryInsertionSort(array2);
// 原始 array2:  [5, 4, 3, 2, 1]
// array2 :     [5, 5, 3, 2, 1]
// array2 :     [4, 5, 5, 2, 1]
// array2 :     [4, 4, 5, 2, 1]
// array2 :     [3, 4, 5, 5, 1]
// array2 :     [3, 4, 4, 5, 1]
// array2 :     [3, 3, 4, 5, 1]
// array2 :     [2, 3, 4, 5, 5]
// array2 :     [2, 3, 4, 4, 5]
// array2 :     [2, 3, 3, 4, 5]
// array2 :     [2, 2, 3, 4, 5]
// array2 :     [1, 2, 3, 4, 5]
复制代码

注意:和直接插入排序相似,折半插入排序每次交换的是相邻的且值为不一样的元素,它并不会改变值相同的元素之间的顺序,所以它是稳定的。

3、希尔排序

希尔排序是一个平均时间复杂度为 O(n log n) 的算法,会在下一个章节和 归并排序、快速排序、堆排序 一块儿讲,本文就不展开了。

3.3 选择排序(Selection Sort)

思路

选择排序算法的实现思路有点相似插入排序,也分已排序区间和未排序区间。可是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

步骤

  1. 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
  2. 再从剩余未排序元素中继续寻找最小(大)元素,而后放到已排序序列的末尾。
  3. 重复第二步,直到全部元素均排序完毕。

实现

const selectionSort = array => {
	const len = array.length;
	let minIndex, temp;
	for (let i = 0; i < len - 1; i++) {
		minIndex = i;
		for (let j = i + 1; j < len; j++) {
			if (array[j] < array[minIndex]) {
				// 寻找最小的数
				minIndex = j; // 将最小数的索引保存
			}
		}
		temp = array[i];
		array[i] = array[minIndex];
		array[minIndex] = temp;
		console.log('array: ', array);
	}
	return array;
};
复制代码

测试

// 测试
const array = [5, 4, 3, 2, 1];
console.log('原始array:', array);
selectionSort(array);
// 原始 array:  [5, 4, 3, 2, 1]
// array:  		 [1, 4, 3, 2, 5]
// array:  		 [1, 2, 3, 4, 5]
// array: 		 [1, 2, 3, 4, 5]
// array:  		 [1, 2, 3, 4, 5]
复制代码

分析

  • 第一,选择排序是原地排序算法吗 ?

选择排序空间复杂度为 O(1),是一种原地排序算法。

  • 第二,选择排序是稳定的排序算法吗 ?

选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。因此,选择排序是一种不稳定的排序算法。

  • 第三,选择排序的时间复杂度是多少 ?

不管是正序仍是逆序,选择排序都会遍历 n2 / 2 次来排序,因此,最佳、最差和平均的复杂度是同样的。 最佳状况:T(n) = O(n2)。 最差状况:T(n) = O(n2)。 平均状况:T(n) = O(n2)。

动画

selection-sort.gif

3.4 归并排序(Merge Sort)

思想

排序一个数组,咱们先把数组从中间分红先后两部分,而后对先后两部分分别排序,再将排好序的两部分合并在一块儿,这样整个数组就都有序了。

归并排序采用的是分治思想

分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

merge-sort-example.png

注:x >> 1 是位运算中的右移运算,表示右移一位,等同于 x 除以 2 再取整,即 x >> 1 === Math.floor(x / 2) 。

实现

const mergeSort = arr => {
	//采用自上而下的递归方法
	const len = arr.length;
	if (len < 2) {
		return arr;
	}
	// length >> 1 和 Math.floor(len / 2) 等价
	let middle = Math.floor(len / 2),
		left = arr.slice(0, middle),
		right = arr.slice(middle); // 拆分为两个子数组
	return merge(mergeSort(left), mergeSort(right));
};

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

	while (left.length && right.length) {
		// 注意: 判断的条件是小于或等于,若是只是小于,那么排序将不稳定.
		if (left[0] <= right[0]) {
			result.push(left.shift());
		} else {
			result.push(right.shift());
		}
	}

	while (left.length) result.push(left.shift());

	while (right.length) result.push(right.shift());

	return result;
};
复制代码

测试

// 测试
const arr = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.time('归并排序耗时');
console.log('arr :', mergeSort(arr));
console.timeEnd('归并排序耗时');
// arr : [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
// 归并排序耗时: 0.739990234375ms
复制代码

分析

  • 第一,归并排序是原地排序算法吗 ?

这是由于归并排序的合并函数,在合并两个有序数组为一个有序数组时,须要借助额外的存储空间。 实际上,尽管每次合并操做都须要申请额外的内存空间,但在合并完成以后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,因此空间复杂度是 O(n)。 因此,归并排序不是原地排序算法。

  • 第二,归并排序是稳定的排序算法吗 ?

merge 方法里面的 left[0] <= right[0] ,保证了值相同的元素,在合并先后的前后顺序不变。归并排序是稳定的排序方法。

  • 第三,归并排序的时间复杂度是多少 ?

从效率上看,归并排序可算是排序算法中的佼佼者。假设数组长度为 n,那么拆分数组共需 logn 步,又每步都是一个普通的合并子数组的过程,时间复杂度为 O(n),故其综合时间复杂度为 O(n log n)。

最佳状况:T(n) = O(n log n)。 最差状况:T(n) = O(n log n)。 平均状况:T(n) = O(n log n)。

动画

merge-sort.gif

3.5 快速排序 (Quick Sort)

快速排序的特色就是快,并且效率高!它是处理大数据最快的排序算法之一。

思想

  • 先找到一个基准点(通常指数组的中部),而后数组被该基准点分为两部分,依次与该基准点数据比较,若是比它小,放左边;反之,放右边。
  • 左右分别用一个空数组去存储比较后的数据。
  • 最后递归执行上述操做,直到数组长度 <= 1;

特色:快速,经常使用。

缺点:须要另外声明两个数组,浪费了内存空间资源。

实现

方法一:

const quickSort1 = arr => {
	if (arr.length <= 1) {
		return arr;
	}
	//取基准点
	const midIndex = Math.floor(arr.length / 2);
	//取基准点的值,splice(index,1) 则返回的是含有被删除的元素的数组。
	const valArr = arr.splice(midIndex, 1);
	const midIndexVal = valArr[0];
	const left = []; //存放比基准点小的数组
	const right = []; //存放比基准点大的数组
	//遍历数组,进行判断分配
	for (let i = 0; i < arr.length; i++) {
		if (arr[i] < midIndexVal) {
			left.push(arr[i]); //比基准点小的放在左边数组
		} else {
			right.push(arr[i]); //比基准点大的放在右边数组
		}
	}
	//递归执行以上操做,对左右两个数组进行操做,直到数组长度为 <= 1
	return quickSort1(left).concat(midIndexVal, quickSort1(right));
};
const array2 = [5, 4, 3, 2, 1];
console.log('quickSort1 ', quickSort1(array2));
// quickSort1: [1, 2, 3, 4, 5]
复制代码

方法二:

// 快速排序
const quickSort = (arr, left, right) => {
	let len = arr.length,
		partitionIndex;
	left = typeof left != 'number' ? 0 : left;
	right = typeof right != 'number' ? len - 1 : right;

	if (left < right) {
		partitionIndex = partition(arr, left, right);
		quickSort(arr, left, partitionIndex - 1);
		quickSort(arr, partitionIndex + 1, right);
	}
	return arr;
};

const partition = (arr, left, right) => {
	//分区操做
	let pivot = left, //设定基准值(pivot)
		index = pivot + 1;
	for (let i = index; i <= right; i++) {
		if (arr[i] < arr[pivot]) {
			swap(arr, i, index);
			index++;
		}
	}
	swap(arr, pivot, index - 1);
	return index - 1;
};

const swap = (arr, i, j) => {
	let temp = arr[i];
	arr[i] = arr[j];
	arr[j] = temp;
};
复制代码

测试

// 测试
const array = [5, 4, 3, 2, 1];
console.log('原始array:', array);
const newArr = quickSort(array);
console.log('newArr:', newArr);
// 原始 array:  [5, 4, 3, 2, 1]
// newArr:   [1, 4, 3, 2, 5]
复制代码

分析

  • 第一,快速排序是原地排序算法吗 ?

由于 partition() 函数进行分区时,不须要不少额外的内存空间,因此快排是原地排序算法。

  • 第二,快速排序是稳定的排序算法吗 ?

和选择排序类似,快速排序每次交换的元素都有可能不是相邻的,所以它有可能打破原来值为相同的元素之间的顺序。所以,快速排序并不稳定

  • 第三,快速排序的时间复杂度是多少 ?

极端的例子:若是数组中的数据原来已是有序的了,好比 1,3,5,6,8。若是咱们每次选择最后一个元素做为 pivot,那每次分区获得的两个区间都是不均等的。咱们须要进行大约 n 次分区操做,才能完成快排的整个过程。每次分区咱们平均要扫描大约 n / 2 个元素,这种状况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2)。

最佳状况:T(n) = O(n log n)。 最差状况:T(n) = O(n2)。 平均状况:T(n) = O(n log n)。

动画

quick-sort.gif

解答开篇问题

快排和归并用的都是分治思想,递推公式和递归代码也很是类似,那它们的区别在哪里呢 ?

快速排序与归并排序

能够发现:

  • 归并排序的处理过程是由下而上的,先处理子问题,而后再合并。
  • 而快排正好相反,它的处理过程是由上而下的,先分区,而后再处理子问题。
  • 归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,可是它是非原地排序算法。
  • 归并之因此是非原地排序算法,主要缘由是合并函数没法在原地执行。
  • 快速排序经过设计巧妙的原地分区函数,能够实现原地排序,解决了归并排序占用太多内存的问题。

3.6 希尔排序(Shell Sort)

思想

  • 先将整个待排序的记录序列分割成为若干子序列。
  • 分别进行直接插入排序。
  • 待整个序列中的记录基本有序时,再对全体记录进行依次直接插入排序。

过程

  1. 举个易于理解的例子:[35, 33, 42, 10, 14, 19, 27, 44],咱们采起间隔 4。建立一个位于 4 个位置间隔的全部值的虚拟子列表。下面这些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。

栗子

  1. 咱们比较每一个子列表中的值,并在原始数组中交换它们(若是须要)。完成此步骤后,新数组应以下所示。

栗子

  1. 而后,咱们采用 2 的间隔,这个间隙产生两个子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。

栗子

  1. 咱们比较并交换原始数组中的值(若是须要)。完成此步骤后,数组变成:[14, 10, 27, 19, 35, 33, 42, 44],图以下所示,10 与 19 的位置互换一下。

image.png

  1. 最后,咱们使用值间隔 1 对数组的其他部分进行排序,Shell sort 使用插入排序对数组进行排序。

栗子

实现

const shellSort = arr => {
	let len = arr.length,
		temp,
		gap = 1;
	console.time('希尔排序耗时');
	while (gap < len / 3) {
		//动态定义间隔序列
		gap = gap * 3 + 1;
	}
	for (gap; gap > 0; gap = Math.floor(gap / 3)) {
		for (let i = gap; i < len; i++) {
			temp = arr[i];
			let j = i - gap;
			for (; j >= 0 && arr[j] > temp; j -= gap) {
				arr[j + gap] = arr[j];
			}
			arr[j + gap] = temp;
			console.log('arr :', arr);
		}
	}
	console.timeEnd('希尔排序耗时');
	return arr;
};
复制代码

测试

// 测试
const array = [35, 33, 42, 10, 14, 19, 27, 44];
console.log('原始array:', array);
const newArr = shellSort(array);
console.log('newArr:', newArr);
// 原始 array:   [35, 33, 42, 10, 14, 19, 27, 44]
// arr :   [14, 33, 42, 10, 35, 19, 27, 44]
// arr :   [14, 19, 42, 10, 35, 33, 27, 44]
// arr :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr :   [10, 14, 19, 27, 35, 33, 42, 44]
// arr :   [10, 14, 19, 27, 35, 33, 42, 44]
// arr :   [10, 14, 19, 27, 33, 35, 42, 44]
// arr :   [10, 14, 19, 27, 33, 35, 42, 44]
// arr :   [10, 14, 19, 27, 33, 35, 42, 44]
// 希尔排序耗时: 3.592041015625ms
// newArr:   [10, 14, 19, 27, 33, 35, 42, 44]
复制代码

分析

  • 第一,希尔排序是原地排序算法吗 ?

希尔排序过程当中,只涉及相邻数据的交换操做,只须要常量级的临时空间,空间复杂度为 O(1) 。因此,希尔排序是原地排序算法。

  • 第二,希尔排序是稳定的排序算法吗 ?

咱们知道,单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在屡次不一样的插入排序过程当中,相同的元素可能在各自的插入排序中移动,可能致使相同元素相对顺序发生变化。 所以,希尔排序不稳定

  • 第三,希尔排序的时间复杂度是多少 ?

最佳状况:T(n) = O(n log n)。 最差状况:T(n) = O(n log2 n)。 平均状况:T(n) = O(n log2 n)。

动画

shell-sort.gif

3.7 堆排序(Heap Sort)

堆的定义

堆实际上是一种特殊的树。只要知足这两点,它就是一个堆。

  • 堆是一个彻底二叉树。 彻底二叉树:除了最后一层,其余层的节点个数都是满的,最后一层的节点都靠左排列。
  • 堆中每个节点的值都必须大于等于(或小于等于)其子树中每一个节点的值。 也能够说:堆中每一个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。

对于每一个节点的值都大于等于子树中每一个节点值的堆,咱们叫做大顶堆。 对于每一个节点的值都小于等于子树中每一个节点值的堆,咱们叫做小顶堆

区分堆、大顶堆、小顶堆

其中图 1 和 图 2 是大顶堆,图 3 是小顶堆,图 4 不是堆。除此以外,从图中还能够看出来,对于同一组数据,咱们能够构建多种不一样形态的堆。

思想

  1. 将初始待排序关键字序列 (R1, R2 .... Rn) 构建成大顶堆,此堆为初始的无序区;
  2. 将堆顶元素 R[1] 与最后一个元素 R[n] 交换,此时获得新的无序区 (R1, R2, ..... Rn-1) 和新的有序区 (Rn) ,且知足 R[1, 2 ... n-1] <= R[n]。
  3. 因为交换后新的堆顶 R[1] 可能违反堆的性质,所以须要对当前无序区 (R1, R2 ...... Rn-1) 调整为新堆,而后再次将 R[1] 与无序区最后一个元素交换,获得新的无序区 (R1, R2 .... Rn-2) 和新的有序区 (Rn-1, Rn)。不断重复此过程,直到有序区的元素个数为 n - 1,则整个排序过程完成。

实现

// 堆排序
const heapSort = array => {
	console.time('堆排序耗时');
	// 初始化大顶堆,从第一个非叶子结点开始
	for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) {
		heapify(array, i, array.length);
	}
	// 排序,每一次 for 循环找出一个当前最大值,数组长度减一
	for (let i = Math.floor(array.length - 1); i > 0; i--) {
		// 根节点与最后一个节点交换
		swap(array, 0, i);
		// 从根节点开始调整,而且最后一个结点已经为当前最大值,不须要再参与比较,因此第三个参数为 i,即比较到最后一个结点前一个便可
		heapify(array, 0, i);
	}
	console.timeEnd('堆排序耗时');
	return array;
};

// 交换两个节点
const swap = (array, i, j) => {
	let temp = array[i];
	array[i] = array[j];
	array[j] = temp;
};

// 将 i 结点如下的堆整理为大顶堆,注意这一步实现的基础其实是:
// 假设结点 i 如下的子堆已是一个大顶堆,heapify 函数实现的
// 功能是其实是:找到 结点 i 在包括结点 i 的堆中的正确位置。
// 后面将写一个 for 循环,从第一个非叶子结点开始,对每个非叶子结点
// 都执行 heapify 操做,因此就知足告终点 i 如下的子堆已是一大顶堆
const heapify = (array, i, length) => {
	let temp = array[i]; // 当前父节点
	// j < length 的目的是对结点 i 如下的结点所有作顺序调整
	for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
		temp = array[i]; // 将 array[i] 取出,整个过程至关于找到 array[i] 应处于的位置
		if (j + 1 < length && array[j] < array[j + 1]) {
			j++; // 找到两个孩子中较大的一个,再与父节点比较
		}
		if (temp < array[j]) {
			swap(array, i, j); // 若是父节点小于子节点:交换;不然跳出
			i = j; // 交换后,temp 的下标变为 j
		} else {
			break;
		}
	}
};
复制代码

测试

const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = heapSort(array);
console.log('newArr:', newArr);
// 原始 array:  [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗时: 0.15087890625ms
// newArr:   [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]
复制代码

分析

  • 第一,堆排序是原地排序算法吗 ?

整个堆排序的过程,都只须要极个别临时存储空间,因此堆排序原地排序算法。

  • 第二,堆排序是稳定的排序算法吗 ?

由于在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操做,因此就有可能改变值相同数据的原始相对顺序。 因此,堆排序是不稳定的排序算法。

  • 第三,堆排序的时间复杂度是多少 ?

堆排序包括建堆和排序两个操做,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),因此,堆排序总体的时间复杂度是 O(nlogn)。

最佳状况:T(n) = O(n log n)。 最差状况:T(n) = O(n log n)。 平均状况:T(n) = O(n log n)。

动画

heap-sort.gif

heap-sort2.gif

3.8 桶排序(Bucket Sort)

桶排序是计数排序的升级版,也采用了分治思想

思想

  • 将要排序的数据分到有限数量的几个有序的桶里。
  • 每一个桶里的数据再单独进行排序(通常用插入排序或者快速排序)。
  • 桶内排完序以后,再把每一个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

好比:

桶排序利用了函数的映射关系,高效与否的关键就在于这个映射函数的肯定。

为了使桶排序更加高效,咱们须要作到这两点:

  • 在额外空间充足的状况下,尽可能增大桶的数量。
  • 使用的映射函数可以将输入的 N 个数据均匀的分配到 K 个桶中。

桶排序的核心:就在于怎么把元素平均分配到每一个桶里,合理的分配将大大提升排序的效率。

实现

// 桶排序
const bucketSort = (array, bucketSize) => {
  if (array.length === 0) {
    return array;
  }

  console.time('桶排序耗时');
  let i = 0;
  let minValue = array[0];
  let maxValue = array[0];
  for (i = 1; i < array.length; i++) {
    if (array[i] < minValue) {
      minValue = array[i]; //输入数据的最小值
    } else if (array[i] > maxValue) {
      maxValue = array[i]; //输入数据的最大值
    }
  }

  //桶的初始化
  const DEFAULT_BUCKET_SIZE = 5; //设置桶的默认数量为 5
  bucketSize = bucketSize || DEFAULT_BUCKET_SIZE;
  const bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
  const buckets = new Array(bucketCount);
  for (i = 0; i < buckets.length; i++) {
    buckets[i] = [];
  }

  //利用映射函数将数据分配到各个桶中
  for (i = 0; i < array.length; i++) {
    buckets[Math.floor((array[i] - minValue) / bucketSize)].push(array[i]);
  }

  array.length = 0;
  for (i = 0; i < buckets.length; i++) {
    quickSort(buckets[i]); //对每一个桶进行排序,这里使用了快速排序
    for (var j = 0; j < buckets[i].length; j++) {
      array.push(buckets[i][j]);
    }
  }
  console.timeEnd('桶排序耗时');

  return array;
};

// 快速排序
const quickSort = (arr, left, right) => {
	let len = arr.length,
		partitionIndex;
	left = typeof left != 'number' ? 0 : left;
	right = typeof right != 'number' ? len - 1 : right;

	if (left < right) {
		partitionIndex = partition(arr, left, right);
		quickSort(arr, left, partitionIndex - 1);
		quickSort(arr, partitionIndex + 1, right);
	}
	return arr;
};

const partition = (arr, left, right) => {
	//分区操做
	let pivot = left, //设定基准值(pivot)
		index = pivot + 1;
	for (let i = index; i <= right; i++) {
		if (arr[i] < arr[pivot]) {
			swap(arr, i, index);
			index++;
		}
	}
	swap(arr, pivot, index - 1);
	return index - 1;
};

const swap = (arr, i, j) => {
	let temp = arr[i];
	arr[i] = arr[j];
	arr[j] = temp;
};
复制代码

测试

const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = bucketSort(array);
console.log('newArr:', newArr);
// 原始 array:  [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗时: 0.133056640625ms
// newArr:   [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]
复制代码

分析

  • 第一,桶排序是原地排序算法吗 ?

由于桶排序的空间复杂度,也即内存消耗为 O(n),因此不是原地排序算法。

  • 第二,桶排序是稳定的排序算法吗 ?

取决于每一个桶的排序方式,好比:快排就不稳定,归并就稳定。

  • 第三,桶排序的时间复杂度是多少 ?

由于桶内部的排序能够有多种方法,是会对桶排序的时间复杂度产生很重大的影响。因此,桶排序的时间复杂度能够是多种状况的。

总的来讲 最佳状况:当输入的数据能够均匀的分配到每个桶中。 最差状况:当输入的数据被分配到了同一个桶中。

如下是桶的内部排序快速排序的状况:

若是要排序的数据有 n 个,咱们把它们均匀地划分到 m 个桶内,每一个桶里就有 k =n / m 个元素。每一个桶内部使用快速排序,时间复杂度为 O(k * logk)。 m 个桶排序的时间复杂度就是 O(m * k * logk),由于 k = n / m,因此整个桶排序的时间复杂度就是 O(n*log(n/m))。 当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个很是小的常量,这个时候桶排序的时间复杂度接近 O(n)。

最佳状况:T(n) = O(n)。当输入的数据能够均匀的分配到每个桶中。

最差状况:T(n) = O(nlogn)。当输入的数据被分配到了同一个桶中。

平均状况:T(n) = O(n)。

桶排序最好状况下使用线性时间 O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,由于其它部分的时间复杂度都为 O(n)。 很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。

适用场景

  • 桶排序比较适合用在外部排序中。
  • 外部排序就是数据存储在外部磁盘且数据量大,但内存有限,没法将整个数据所有加载到内存中。

动画

bocket-sort.gif

3.9 计数排序(Counting Sort)

思想

  • 找出待排序的数组中最大和最小的元素。
  • 统计数组中每一个值为 i 的元素出现的次数,存入新数组 countArr 的第 i 项。
  • 对全部的计数累加(从 countArr 中的第一个元素开始,每一项和前一项相加)。
  • 反向填充目标数组:将每一个元素 i 放在新数组的第 countArr[i] 项,每放一个元素就将 countArr[i] 减去 1 。

关键在于理解最后反向填充时的操做。

使用条件

  • 只能用在数据范围不大的场景中,若数据范围 k 比要排序的数据 n 大不少,就不适合用计数排序。
  • 计数排序只能给非负整数排序,其余类型须要在不改变相对大小状况下,转换为非负整数。
  • 好比若是考试成绩精确到小数后一位,就须要将全部分数乘以 10,转换为整数。

实现

方法一:

const countingSort = array => {
	let len = array.length,
		result = [],
		countArr = [],
		min = (max = array[0]);
	console.time('计数排序耗时');
	for (let i = 0; i < len; i++) {
		// 获取最小,最大 值
		min = min <= array[i] ? min : array[i];
		max = max >= array[i] ? max : array[i];
		countArr[array[i]] = countArr[array[i]] ? countArr[array[i]] + 1 : 1;
	}
	console.log('countArr :', countArr);
	// 从最小值 -> 最大值,将计数逐项相加
	for (let j = min; j < max; j++) {
		countArr[j + 1] = (countArr[j + 1] || 0) + (countArr[j] || 0);
	}
	console.log('countArr 2:', countArr);
	// countArr 中,下标为 array 数值,数据为 array 数值出现次数;反向填充数据进入 result 数据
	for (let k = len - 1; k >= 0; k--) {
		// result[位置] = array 数据
		result[countArr[array[k]] - 1] = array[k];
		// 减小 countArr 数组中保存的计数
		countArr[array[k]]--;
		// console.log("array[k]:", array[k], 'countArr[array[k]] :', countArr[array[k]],)
		console.log('result:', result);
	}
	console.timeEnd('计数排序耗时');
	return result;
};
复制代码

测试

const array = [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2];
console.log('原始 array: ', array);
const newArr = countingSort(array);
console.log('newArr: ', newArr);
// 原始 array:  [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2]
// 计数排序耗时: 5.6708984375ms
// newArr:   [1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 4, 4, 6, 7, 7, 8, 8, 9, 9]
复制代码

测试结果

方法二:

const countingSort2 = (arr, maxValue) => {
	console.time('计数排序耗时');
	maxValue = maxValue || arr.length;
	let bucket = new Array(maxValue + 1),
		sortedIndex = 0;
	(arrLen = arr.length), (bucketLen = maxValue + 1);

	for (let i = 0; i < arrLen; i++) {
		if (!bucket[arr[i]]) {
			bucket[arr[i]] = 0;
		}
		bucket[arr[i]]++;
	}

	for (let j = 0; j < bucketLen; j++) {
		while (bucket[j] > 0) {
			arr[sortedIndex++] = j;
			bucket[j]--;
		}
	}
	console.timeEnd('计数排序耗时');
	return arr;
};
复制代码

测试

const array2 = [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2];
console.log('原始 array2: ', array2);
const newArr2 = countingSort2(array2, 21);
console.log('newArr2: ', newArr2);
// 原始 array:  [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2]
// 计数排序耗时: 0.043212890625ms
// newArr:   [1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 4, 4, 6, 7, 7, 8, 8, 9, 9]
复制代码

例子

能够认为,计数排序实际上是桶排序的一种特殊状况

当要排序的 n 个数据,所处的范围并不大的时候,好比最大值是 k,咱们就能够把数据划分红 k 个桶。每一个桶内的数据值都是相同的,省掉了桶内排序的时间。

咱们都经历太高考,高考查分数系统你还记得吗?咱们查分数的时候,系统会显示咱们的成绩以及所在省的排名。若是你所在的省有 50 万考生,如何经过成绩快速排序得出名次呢?

  • 考生的满分是 900 分,最小是 0 分,这个数据的范围很小,因此咱们能够分红 901 个桶,对应分数从 0 分到 900 分。
  • 根据考生的成绩,咱们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,因此并不须要再进行排序。
  • 咱们只须要依次扫描每一个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。
  • 由于只涉及扫描遍历操做,因此时间复杂度是 O(n)。

分析

  • 第一,计数排序是原地排序算法吗 ?

由于计数排序的空间复杂度为 O(k),k 桶的个数,因此不是原地排序算法。

  • 第二,计数排序是稳定的排序算法吗 ?

计数排序不改变相同元素之间本来相对的顺序,所以它是稳定的排序算法。

  • 第三,计数排序的时间复杂度是多少 ?

最佳状况:T(n) = O(n + k) 最差状况:T(n) = O(n + k) 平均状况:T(n) = O(n + k) k 是待排序列最大值。

动画

counting-sort.gif

3.10 基数排序(Radix Sort)

思想

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不一样的数字,而后按每一个位数分别比较。

例子

假设咱们有 10 万个手机号码,但愿将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢 ?

这个问题里有这样的规律:假设要比较两个手机号码 a,b 的大小,若是在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了。因此是基于来比较的。

桶排序、计数排序能派上用场吗 ?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢 ? 有,就是基数排序。

使用条件

  • 要求数据能够分割独立的来比较;
  • 位之间由递进关系,若是 a 数据的高位比 b 数据大,那么剩下的地位就不用比较了;
  • 每一位的数据范围不能太大,要能够用线性排序,不然基数排序的时间复杂度没法作到 O(n)。

方案

按照优先从高位或低位来排序有两种实现方案:

  • MSD:由高位为基底,先按 k1 排序分组,同一组中记录, 关键码 k1 相等,再对各组按 k2 排序分红子组, 以后,对后面的关键码继续这样的排序分组,直到按最次位关键码 kd 对各子组排序后,再将各组链接起来,便获得一个有序序列。MSD 方式适用于位数多的序列。
  • LSD:由低位为基底,先从 kd 开始排序,再对 kd - 1 进行排序,依次重复,直到对 k1 排序后便获得一个有序序列。LSD 方式适用于位数少的序列。

实现

/** * name: 基数排序 * @param array 待排序数组 * @param max 最大位数 */
const radixSort = (array, max) => {
	console.time('计数排序耗时');
	const buckets = [];
	let unit = 10,
		base = 1;
	for (let i = 0; i < max; i++, base *= 10, unit *= 10) {
		for (let j = 0; j < array.length; j++) {
			let index = ~~((array[j] % unit) / base); //依次过滤出个位,十位等等数字
			if (buckets[index] == null) {
				buckets[index] = []; //初始化桶
			}
			buckets[index].push(array[j]); //往不一样桶里添加数据
		}
		let pos = 0,
			value;
		for (let j = 0, length = buckets.length; j < length; j++) {
			if (buckets[j] != null) {
				while ((value = buckets[j].shift()) != null) {
					array[pos++] = value; //将不一样桶里数据挨个捞出来,为下一轮高位排序作准备,因为靠近桶底的元素排名靠前,所以从桶底先捞
				}
			}
		}
	}
	console.timeEnd('计数排序耗时');
	return array;
};
复制代码

测试

const array = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.log('原始array:', array);
const newArr = radixSort(array, 2);
console.log('newArr:', newArr);
// 原始 array:  [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]
// 堆排序耗时: 0.064208984375ms
// newArr:   [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
复制代码

分析

  • 第一,基数排序是原地排序算法吗 ?

由于计数排序的空间复杂度为 O(n + k),因此不是原地排序算法。

  • 第二,基数排序是稳定的排序算法吗 ?

基数排序不改变相同元素之间的相对顺序,所以它是稳定的排序算法。

  • 第三,基数排序的时间复杂度是多少 ?

最佳状况:T(n) = O(n * k) 最差状况:T(n) = O(n * k) 平均状况:T(n) = O(n * k) 其中,k 是待排序列最大值。

动画

LSD 基数排序动图演示:

radixSort.gif

4. 复杂度对比

十大经典排序算法的 时间复杂度与空间复杂度 比较。

名称 平均 最好 最坏 空间 稳定性 排序方式
冒泡排序 O(n2) O(n) O(n2) O(1) Yes In-place
插入排序 O(n2) O(n) O(n2) O(1) Yes In-place
选择排序 O(n2) O(n2) O(n2) O(1) No In-place
归并排序 O(n log n) O(n log n) O(n log n) O(n) Yes Out-place
快速排序 O(n log n) O(n log n) O(n2) O(logn) No In-place
希尔排序 O(n log n) O(n log2 n) O(n log2 n) O(1) No In-place
堆排序 O(n log n) O(n log n) O(n log n) O(1) No In-place
桶排序 O(n + k) O(n + k) O(n2) O(n + k) Yes Out-place
计数排序 O(n + k) O(n + k) O(n + k) O(k) Yes Out-place
基数排序 O(n * k) O(n * k) O(n * k) O(n + k) Yes Out-place

名词解释:

  • n:数据规模;
  • k:桶的个数;
  • In-place: 占用常数内存,不占用额外内存;
  • Out-place: 占用额外内存。

5. 算法可视化工具

  • 算法可视化工具 algorithm-visualizer 算法可视化工具 algorithm-visualizer 是一个交互式的在线平台,能够从代码中可视化算法,还能够看到代码执行的过程。旨在经过交互式可视化的执行来揭示算法背后的机制。 效果以下图:

    算法可视化工具

  • 算法可视化动画网站 visualgo.net/en 效果以下图:

    quick-sort.gif

  • 算法可视化动画网站 www.ee.ryerson.ca 效果以下图:

    insert-sort.gif

  • illustrated-algorithms 变量和操做的可视化表示加强了控制流和实际源代码。您能够快速前进和后退执行,以密切观察算法的工做方式。 效果以下图:

    binary-search.gif

6. 系列文章

JavaScript 数据结构与算法之美 系列文章,暂时写了以下的 11 篇文章,后续还有想写的内容,再补充。

所写的内容只是数据结构与算法内容的冰山一角,若是你还想学更多的内容,推荐学习王争老师的 数据结构与算法之美

从时间和空间复杂度、基础数据结构到排序算法,文章的内容有必定的关联性,因此阅读时推荐按顺序来阅读,效果更佳。

若是有错误或者不严谨的地方,请务必给予指正,以避免误人子弟,十分感谢。

7. 最后

文中全部的代码及测试事例都已经放到个人 GitHub 上了。

笔者为了写好这系列的文章,花费了大量的业余时间,边学边写,边写边修改,先后历时差很少 2 个月,入门级的文章总算是写完了。

若是你以为有用或者喜欢,就点收藏,顺便点个赞吧,你的支持是我最大的鼓励 !

相关文章
相关标签/搜索