0. 算法复杂度

排序算法

时间复杂度(平均)

空间复杂度

稳定性

冒泡排序

O(n2)

O(1)

稳定

快速排序

O(nlogn)

O(1)

不稳定

简单插入排序

O(n2)

O(1)

稳定

shell 排序

O(n1.3)

O(1)

不稳定

简单选择排序

O(n2)

O(1)

不稳定

堆排序

O(nlogn)

O(1)

不稳定

二路归并排序

O(nlogn)

O(n)

稳定

计数排序

O(n + k)

O(n + k)

稳定

桶排序

O(n + k)

O(n + k)

稳定

基数排序

O(n * k)

O(n + k)

稳定

TIP: 以下算法的说明与实现,均以升序排列为例。

1. 冒泡排序

基本思想:

  1. 从数组第一个元素开始,重复比较前后相邻的 2 个数组元素,如果前面一个元素大于后面一个元素,则交换 2 个元素的位置;
  2. 经过步骤 1 描述的一轮循环后,无序数列中的最大值将被放置在无序数列的末尾(此时,该值已有序)。至此,无序数列长度减 1;
  3. 对无序数列重复步骤 1、2,直至整个数列有序(无序数列长度为 0)。

复杂度分析:

  • 时间复杂度:

循环最内层语句的执行次数为:(n - 1)*(n - 1 - i)。保留最高指数,得到时间复杂度为:O(n2)。

  • 空间复杂度:

该算法中,对数组的操作都是就地操作(in-place),没有用到额外的存储空间,因此空间复杂度为:O(1)。

  • 稳定性

在对相邻数据进行比较时,只有在 arr[i] > arr[j](i > j) 的情况下,才进行交换位置的操作,这样就可以保证相等的两个元素在完成比较后,依旧保留原本的前后相对位置。因此,冒泡排序是稳定的。

代码实现:

// 冒泡排序(升序)
function bubbleSort(arr) {
	for (let i = 0; i < arr.length - 1; i++) {
		for (let j = 0; j < arr.length - 1 - i; j++) {
			if (arr[j] > arr[j + 1]) {
				[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
			}
		}
	}
	return arr;
}

bubbleSort([3, 1, 6, 5, 7]);

优化: 可以使用一个变量来表示上一轮冒泡是否交换过数据,如果没有,就表示数列以及有序了,可以跳过后面的冒泡操作,直接返回了!

2. 快速排序

基本思想:

  1. 选择数列中的一个元素(在这选择待比较数列第一个元素)作为基准(pivot);
  2. 把小于基准值的元素放到基准的左边,把大于基准值的元素放到基准的右边,与基准值大小相等的元素放置在左边还是右边都可以,这一操作称为分区(partition)。至此,可以得到左边和右边的 2 个子数列;
  3. 对得到的子数列重复进行步骤 1、2,直至每个分区操作的数列都只剩下一个元素,这时候,整个数列就有序了。

复杂度分析:

  • 时间复杂度:

对应一个长度为 n 的数组,需要进行 log2n 次分区操作;完成每次分区操作,需要对每个数据都进行比较操作,总的要进行 n 次比较。因此,快速排序的时间复杂度为:O(nlog2n)。

  • 空间复杂度:

对数组元素的操作是就地操作,不会消耗额外的存储空间。所以,快速排序的空间复杂度为:O(1)。

  • 稳定性:

对于数组 [2,5,5,3,1,7],使用快速排序来排序后,2 个 5 的前后相对位置变了。因此,快速排序是不稳定的。

代码实现:

function exchange(arr, i, j) {
	[arr[i], arr[j]] = [arr[j], arr[i]];
}

function partition(arr, left, right) {
	let pivot = arr[left];
	let pivotIndex = left;
	if (left >= right) {
		return;
	}
	while (left < right) {
		while (arr[right] > pivot && left < right) {
			right--;
		}
		while (arr[left] <= pivot && left < right) {
			left++;
		}
		if (left < right) {
			exchange(arr, left, right);
		}
	}
	// left === right
	exchange(arr, pivotIndex, left);
	return left;
}

function quickSort(arr, left, right) {
	left = typeof left !== 'number' || Number.isNaN(left) ? 0 : left;
	right = typeof right !== 'number' || Number.isNaN(right) ? arr.length - 1 : right;
	if (left < right) {
		let pivotIndex = partition(arr, left, right);
		quickSort(arr, left, pivotIndex - 1);
		quickSort(arr, pivotIndex + 1, right);
	}
	return arr;
}

quickSort([3, 1, 2]);

3. 简单插入排序

基本思想:

  1. 默认地,将数组中的第一个元素作为有序数组, 剩下的元素作为无序数组;
  2. 从后往前遍历有序数组,找到合适的位置,插入无序数组的首个元素。至此,有序数组中增加一个元素,无序数组中减少一个元素;
  3. 重复进行步骤 2,直至无序数组中的全部元素都插入到了有序数组中。

复杂度分析:

  • 时间复杂度:

算法中出现了嵌套 2 层的循环。因此,简单插入排序的时间复杂度为:O(n2)。

  • 空间复杂度:

算法中对数组是就地操作的,没有消耗额外的空间。因此,简单插入排序的空间复杂度为:O(1)。

  • 稳定性:

遍历有序数组的过程中,是将比待插入数组元素大的元素向后移动,否则就将待插入元素插至比较元素之后(这样的话,满足:比较元素 <= 待插入元素),这样的话,可以保证相同元素之间前后相对顺序。因此,插入排序是稳定的。

代码实现:

function insertionSort(arr) {
	// 遍历无序数列
	for (let i = 1; i < arr.length; i++) {
		// 待插入的一个无序数据
		let current = arr[i];
		// 默认第一个数据为有序的
		let j = i - 1;
		// 从后往前遍历有序数列
		while (j >= 0 && current < arr[j]) {
			// 将有序数列中大于无序数据的部分从后往前依次向后移动
			arr[j + 1] = arr[j];
			j--;
		}
		arr[j + 1] = current;
	}
	return arr;
}

insertionSort([3, 1, 6, 5, 7]);

4. shell 排序(缩小增量排序)

基本思想:

  1. 选择一个增量序列t1,t2,…,tk,其中 ti > tj,tk = 1;
  2. 按增量序列个数 k,对序列进行 k 趟排序;
  3. 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

复杂度分析:

  • 时间复杂度:

与增量序列的选择有关,太复杂了,本人是推不出,就记一个 O(n1.3) 好了。

  • 空间复杂度:

在这个算法中,对数组的操作都是就地完成的,不会消耗额外的存储空间。因此,shell 排序的空间复杂度为:O(1)。

  • 稳定性:

不稳定

代码实现:

function shellSort(arr) {
	for (let gap = Math.floor(arr.length / 2); gap > 0; gap = Math.floor(gap / 2)) {
		for (let i = gap; i < arr.length; i++) {
			let current = arr[i];
			let j = i;
			while (j - gap >= 0 && arr[j - gap] > arr[j]) {
				arr[j - gap + 1] = arr[j - gap];
				j = j - gap;
			}
			arr[j] = current;
		}
	}
	return arr;
}

shellSort([3, 1, 6, 5, 7]);

5. 简单选择排序

基本思想:

  1. 初始时,认为:数组中有序数列为空,无序数列为整个数组。
  2. 继续遍历无序数列,找出其中的最小值,并把它与有序数列的末尾后面的一个元素交换位置;
  3. 重复进行步骤 2,经过 n - 1 轮的循环后,整个数组就有序了。
    换成一句话概况: 选择未排序的数列中的最小值,放在未排序数列的首位。

复杂度分析:

  • 时间复杂度:

2 层嵌套的循环。因此,选择排序的时间复杂度为:O(n2)。

  • 空间复杂度:

数组是就地操作的。因此,选择排序的空间复杂度为:O(1)。

  • 稳定性:

对于数组 [5, 8, 5, 2, 9],经过选择排序进行排序之后,2 个 5 的前后相对顺序就被破坏了。因此,选择排序是不稳定的。

代码实现:

function selectionSort(arr) {
	for (let i = 0; i < arr.length - 1; i++) {
		let minIndex = i;
		for (let j = i + 1; j < arr.length; j++) {
			if (arr[j] < arr[minIndex]) {
				minIndex = j;
			}
		}
		[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
	}
	return arr;
}

selectionSort([3, 1, 6, 5, 7, 6]);

6. 堆排序

基本思想:

  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) 调整为新堆;
  4. 然后再次将 R[1] 与无序区最后一个元素交换,得到新的无序区 (R1,R2….Rn-2) 和新的有序区 (Rn-1,Rn) 。不断重复此过程直到有序区的元素个数为 n-1 ,则整个排序过程完成。

复杂度分析:

  • 时间复杂度:

O(nlog2n)

  • 空间复杂度:

数组是就地操作的。因此,选择排序的空间复杂度为:O(1)。

  • 稳定性:

不稳定

代码实现:

function exchange(arr, i, j) {
	[arr[i], arr[j]] = [arr[j], arr[i]];
}

function shiftDown(arr, i, length) {
	for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
		if (j + 1 < length && arr[j] < arr[j + 1]) {
			j++;
		}
		if (arr[j] > arr[i]) {
			exchange(arr, i, j);
			i = j;
		} else {
			break;
		}
	}
}

function heapSort(arr) {
	// 从下至上,从右至左将数据初始化为大顶堆
	for (let i = Math.floor(arr.length / 2 - 1); i >= 0; i--) {
		// i 为非叶子节点
		shiftDown(arr, i, arr.length);
	}

	// 将栈顶元素与未排序数列最后一个数据交换,交换后需要将剩下数据组成的数调整为大顶堆
	for (let i = arr.length - 1; i > 0; i--) {
		exchange(arr, 0, i);
		shiftDown(arr, 0, i);
	}

	return arr;
}

heapSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2]);

7. 二路归并排序

基本思想:

  1. 将一个长度为 n 的数组分割为 2 个长度为 n / 2 的数组;
  2. 对 2 个数组分别递归地调用归并排序;
  3. 将排好序的 2 个数组合并为 1 个数组。

复杂度分析:

  • 时间复杂度:

数列的归并树高度为 log2n,每层总的需要进行约 n 次比较。因此,归并排序的时间复杂度为 O(nlog2n)。

  • 空间复杂度:

代码执行过程中,需要一个长度为 n 的数组来存储排序结果。因此,归并排序的空间复杂度为:O(n)。

  • 稳定性:

合并排序好的 2 个数组的时候,判断条件 left[0] <= right[0] 为 true 时,说明 left 数组和 right 数组的首元素相同,这时候 选择先将 left 数组的首元素移入结果数组中, 就能保证这 2 个相同元素的前后相对位置与原来的一致。因此,归并排序是稳定的。

代码实现:

function merge(left, right) {
	let result = [];
	while (left.length > 0 && right.length > 0) {
		if (left[0] <= right[0]) {
			result.push(left.shift());
		} else {
			result.push(right.shift());
		}
	}
	return result.concat(left, right);
}

function mergeSort(arr) {
	if (arr.length <= 1) {
		return arr;
	}
	let middleIndex = Math.floor(arr.length / 2);
	return merge(mergeSort(arr.slice(0, middleIndex)), mergeSort(arr.slice(middleIndex)));
}

mergeSort([3, 1, 6, 5, 7]);

8. 计数排序

基本思想:

  1. 找到数组中的最大的元素,记为 maxValue;
  2. 创建一个长度为 maxValue + 1 的数据,用作后续的计数数组 countingArr;
  3. 遍历数组 arr,累计 countArr[arr[i]] 的值;
  4. 遍历 countingArr,反向填充得到结果数组 result。

当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法

复杂度分析:

将数组的最大值记为 k

  • 时间复杂度:

n + (n + k) => O(n + k)

  • 空间复杂度:

算法需要创建一个长度为 n + k 的计数数组 和一个长度为 n 的结果数组。因此,空间复杂度为 O(n + k)。

  • 稳定性:

该算法不会改变原数组的元素的位置。因此,计数排序是稳定的。

代码实现:

function countingSort(arr, maxValue) {
	maxValue = maxValue || Math.max(...arr);
	let countingArr = new Array(maxValue + 1);
	let result = [];
	for (let i = 0; i < arr.length; i++) {
		if (!countingArr[arr[i]]) {
			// 初始化键值对应的计数值
			countingArr[arr[i]] = 0;
		}
		// 键值对应的数据出现,计数值加一
		countingArr[arr[i]]++;
	}
	for (let i = 0; i < countingArr.length; i++) {
		while (countingArr[i] > 0) {
			result.push(i);
			countingArr[i]--;
		}
	}
	return result;
}

countingSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2], 9);

9. 桶排序

基本思想:

  1. 将数据分配到桶中;
  2. 对桶中的数据进行排序;
  3. 将排序好的数据合并到结果数组中。

复杂度分析:

  • 时间复杂度:

O(n + k)

  • 空间复杂度:

O(n + k)

  • 稳定性:

稳定,理由同计数排序。

代码实现:

function insertionSort(arr) {
	// 遍历无序数列
	for (let i = 1; i < arr.length; i++) {
		// 待插入的一个无序数据
		let current = arr[i];
		// 默认第一个数据为有序的
		let j = i - 1;
		// 从后往前遍历有序数列
		while (j >= 0 && current < arr[j]) {
			// 将有序数列中大于无序数据的部分从后往前依次向后移动
			arr[j + 1] = arr[j];
			j--;
		}
		arr[j + 1] = current;
	}
	return arr;
}

function bucketSort(arr, size) {
	let bucketSize = size || 5;
	let result = [];
	if (arr.length <= 1) {
		return arr;
	}
	let minValue = arr[0];
	let maxValue = arr[0];
	// 找到数组中的最大值和最小值
	for (let i = 1; i < arr.length; i++) {
		if (minValue > arr[i]) {
			minValue = arr[i];
		} else if (maxValue < arr[i]) {
			maxValue = arr[i];
		}
	}

	let bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
	let buckets = new Array(bucketCount);
	// 初始化桶
	for (let i = 0; i < bucketCount; i++) {
		buckets[i] = [];
	}
	// 将数据分配到桶中
	for (let i = 0; i < arr.length; i++) {
		let bucketIndex = Math.floor((arr[i] - minValue) / bucketSize);
		buckets[bucketIndex].push(arr[i]);
	}
	// 使用插入排序,对桶内数据进行排序
	for (let i = 0; i < buckets.length; i++) {
		insertionSort(buckets[i]);
		// 将排序完成的数据放进 result 数组中
		result = [...result, ...buckets[i]];
	}
	return result;
}

bucketSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2]);

10. 基数排序

基本思想:

  1. 取得数组中的最大数,并取得位数;
  2. arr为原始数组,从最低位开始取每个位组成 radix 数组;
  3. 对 radix 进行计数排序(利用计数排序适用于小范围数的特点);

复杂度分析:

  • 时间复杂度:

O(n * k)

  • 空间复杂度:

O(n + k)

  • 稳定性:

稳定,理由同计数排序。

代码实现:

let counter = [];
function radixSort(arr, maxDigit) {
	let mod = 10;
	let dev = 1;
	for (let i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
		for (let j = 0; j < arr.length; j++) {
			let bucket = parseInt((arr[j] % mod) / dev);
			if (counter[bucket] == null) {
				counter[bucket] = [];
			}
			counter[bucket].push(arr[j]);
		}
		let pos = 0;
		for (let j = 0; j < counter.length; j++) {
			let value = null;
			if (counter[j] != null) {
				while ((value = counter[j].shift()) != null) {
					arr[pos++] = value;
				}
			}
		}
	}
	return arr;
}

radixSort([4, 6, 8, 5, 9, 1, 2, 5, 3, 2], 1);