「数据结构与算法」之 六种排序算法从理解到实现

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

前言

大家好,我是一知

学习这件事情,尤其是算法这种东西,光看是不太行的,很多时候觉得自己理解了,但是代码却可能不太容易写出来,所以还是得自己理解完并动手实现代码,而且即便是自己动手实现了,可能过时间长了,还是会忘记,所以还需要定期地总结和回顾。

本篇文章是个人近期对比较常见的6种排序算法的梳理总结,通过本篇文章你将了解到

  • 冒泡排序
  • 选择排序
  • 插入排序
  • 快速排序
  • 归并排序
  • 计数排序

这6种常见排序算法的基本思想实现思路、及代码实现

名词解释

时间复杂度:

时间复杂度用于描述算法运行的时间。我们把算法需要运算的次数用输入大小为n的函数来表示,记作 T ( n ) T(n) 。时间复杂度通常用 O ( f ( n ) ) O(f(n)) 来表示,有 T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) f ( n ) f(n) 为代码中基本操作重复执行的次数。

空间复杂度:

空间复杂度指算法在计算机内执行时所需存储空间的度量。记作 S ( n ) = O ( f ( n ) ) S(n)=O(f(n)) 。算法执行期间所需要的存储空间包括3个部分:

  • 算法程序所占的空间;
  • 输入的初始数据所占的存储空间;
  • 算法执行过程中所需要的额外空间。

排序算法稳定性:

假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,A1=A2,且A1在A2之前,而在排序后的序列中,A1仍在A2之前,则称这种排序算法是稳定的;否则称为不稳定的。

1. 冒泡排序

1.1 基本思想

我们知道水中的泡泡在往上冒的时候,会越来越大,冒泡排序,顾名思义就是一个让大的往上”冒“的排序过程。从无序序列中的首位遍历到末位的一次完整过程称为一次冒泡,通过判断相邻的元素(a, b)大小,比如左边的a比右边的b大,那么交换a和b的位置。每一次冒泡可以确定一个元素的位置,同时可以缩小无序序列的范围,然后再对无序序列进行冒泡,直到最终所有元素的位置都确定,即完成排序。

1.2 实现思路

  1. 遍历无序序列,遇到左边元素大于右边元素的,交换它们俩位置;
  2. 将整个无序序列遍历完一遍之后,能够将一个大数推到无序序列的最左边,然后缩小无序序列的右边界;
  3. 重复上述步骤1和2,直至无序序列只剩下一个元素,排序完成。

1.3 排序动画

冒泡排序.gif

1.4 代码实现

1.4.1 方法一

常规的单向冒泡

/**
 * 常规的单向遍历冒泡
 * @param {*} arr
 */
const bubbleSort = (arr) => {
	console.time('常规的单向遍历冒泡');

	const len = arr.length;

	for (let i = 0; i < len; i++) {
		let j = 0;
		// 无序序列的右端点
		const end = len - 1 - i;

		while (j < end) {
			if (arr[j] > arr[j + 1]) {
				let temp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = temp;
			}
			j++;
		}
	}

	console.timeEnd('常规的单向遍历冒泡');

	return arr;
};

// test case:
const arr = Array.from({ length: 50 }).map(() =>
	Math.floor(Math.random() * 100)
);
console.log(bubbleSort(arr));

复制代码

1.4.2 方法二

双端冒泡。

上面的方法一是从左到右冒泡,每次对无序序列只进行一次从小到大的冒泡,一次冒泡完,只缩小无序序列的右边界。而双端冒泡的不同之处在于,每次对无序序列进行冒泡过程中,从小到大冒一次,再从大到小冒一次,可以同时缩小无序序列的左右边界。

/**
 * 双端冒泡排序实现
 */
const bubbleSort = (arr) => {
	console.time('双端冒泡排序耗时');

	let low = 0;
	let high = arr.length - 1;
	let temp;

	// 6, 8, 35, 28, 32, 38, 42
	//    ↑               ↑
	//   low	    high
	// 每经历一次while循环,左右两边都进行一轮遍历交换,就能将low和high间的无序范围缩小2
	while (low < high) {
		// 从低到高遍历,每经历完一轮for循环能确定一个大的数字
		for (let i = low; i < high; i++) {
			// 如果左边比右边大,则进行交换
			if (arr[i] > arr[i + 1]) {
				temp = arr[i];
				arr[i] = arr[i + 1];
				arr[i + 1] = temp;
			}
		}
		high--;

		// 再从高到低遍历,每经历完一轮for循环能确定一个小的数字
		for (let j = high; j > low; j--) {
			if (arr[j] < arr[j - 1]) {
				temp = arr[j];
				arr[j] = arr[j - 1];
				arr[j - 1] = temp;
			}
		}
		low++;
	}

	console.timeEnd('双端冒泡排序耗时');
        
        return arr;
};

// test case:
const arr = Array.from({ length: 50 }).map(() =>
	Math.floor(Math.random() * 100)
);
console.log(bubbleSort(arr));
复制代码

1.4.3 区别

从算法时间复杂度和空间复杂度上来看,两种都是一样的,经个人测试,数组长度不大时,两种方法的实际耗时基本相差不大,而在数组长度较大时,双端冒泡实际耗时比常规的单向冒泡要少一些。

  1. 数组长度为100时:

image.png 2. 数组长度为100000时: image.png

1.5 算法复杂度

时间复杂度:( n 2 n^2 );

空间复杂度:使用常数空间存储若干临时变量,空间复杂度为O(1)。

1.6 排序算法稳定性

我们只在左边比右边大时才会进行交换,所以两个相同的元素在排序后的相对位置不会发生变化,是稳定的

2. 选择排序

2.1 基本思想

从无序序列中找到最小的一个数,将其放到有序序列中,重复这一过程,直到无序序列仅剩一个(最大的数),排序完成。

2.2 实现思路

  1. 初始的有序序列为空,无序序列为整个待排序数组;
  2. 遍历无序序列,找到其中一个最小的数,通过位置交换的方式将其放到左侧的有序序列的末位,然后缩小无序序列的起始边界;
  3. 重复步骤2,直到无序序列仅剩一个数,这个数已是最大数,并且已自动排在了最后一位,此时排序完成。

2.3 排序动画

选择排序.gif

2.4 代码实现

/**
 * 选择排序
 * 每次都从无序序列中找到一个最小(或最大)的数,放到有序的序列中
 */
const selectionSort = (arr) => {
	console.time('选择排序耗时');

	const len = arr.length;
	let minIndex;
	// 无序序列的起始索引是0
	let i = 0;
	let temp;

	while (i < len - 1) {
		minIndex = i;
		let j = i;
		// 从无序序列中找到最小的数的索引
		while (j < len) {
			if (arr[j] < arr[minIndex]) {
				minIndex = j;
			}
			j++;
		}
		// 将上面从无序序列中找到的最小的数放到左侧的有序序列中
		temp = arr[i];
		arr[i] = arr[minIndex];
		arr[minIndex] = temp;
		// 将无序序列的起始索引右移一位
		i++;
	}
	console.timeEnd('选择排序耗时');

	return arr;
};

// test case:
const arr = Array.from({ length: 50 }).map(() =>
	Math.floor(Math.random() * 100)
);
console.log(selectionSort(arr));
复制代码

2.5 算法复杂度

时间复杂度:O( n 2 n^2 );

空间复杂度:O(1)。

2.6 排序算法稳定性

如果一个元素A比当前元素B1小,而这个A出现在一个和当前元素相等的B2元素后面,那么交换后稳定性就被破坏了。举个例子,序列4,9,4,2,7,在第一遍选择过程中,第1个元素4会和2交换,那么原序列中两个4的相对前后顺序就被破坏了,所以选择排序是一个不稳定的排序算法。

3. 插入排序

3.1 基本思想

插入排序的过程类似于我们打扑克时,摸牌并将摸到的牌按单张牌的大小插入到它应该在的相应位置,比如我们当前手里有的牌是3、4、6、9、10、J,那么如果我们此时摸到一张8,我们应该把它插入到6和9的中间,并将9、10、J依次往后移一位。在插入排序过程中,我们会从无序序列中选取一个数target,然后从后往前扫描此时的有序序列,找到第一个比target小或者等于target的数,然后将target插入到这个数的后面;然后再从无序序列中选取一个数,并重复这一过程,最终完成排序。

3.2 实现思路

  1. 无序序列的起始索引i从 1 开始,因为第 0 个可以认为已经处于有序序列中;
  2. 选取右侧的无序序列中的第一个数作为要插入的数target
  3. 在左侧的有序序列中从后往前扫描,找到第一个比target小或者等于target的数,将target插入到这个数的后面,并将有序序列中target被插入的位置后面的数依次往后移动一位,然后将无序序列的左边界后移一位;
  4. 重复步骤2和3,完成排序。

3.3 排序动画

插入排序.gif

3.4 代码实现

3.4.1 方法一

/**
 * 插入排序
 */
const insertionSort = (arr) => {
	console.time('插入排序耗时');

	const len = arr.length;

	// i为无序序列的起始索引,从1开始即可,因为第0个可以认为已经处于有序序列中
	for (let i = 1; i < len; i++) {
		// 要插入的数
		const target = arr[i];
		// scanIndex为扫描指针,初始值为有序序列的末位索引
		let scanIndex = i - 1;

		// 终止条件为:找到第一个比target小或者等于target的数或者扫描到有序序列左端点
		while (scanIndex >= 0 && arr[scanIndex] > target) {
			// 扫描过程中,将比target大的数依次往后移一位
			arr[scanIndex + 1] = arr[scanIndex];
			// 扫描指针往前移一位
			scanIndex--;
		}
		// 将target插入到对应位置
		arr[scanIndex + 1] = target;
	}

	console.timeEnd('插入排序耗时');

	return arr;
};

// test case:
const arr = Array.from({ length: 100 }).map(() =>
	Math.floor(Math.random() * 1000)
);
console.log(insertionSort(arr));
复制代码

3.4.2 方法二

思路与方法一基本一致,不同之处在于查找第一个比target小或者等于target的数时使用二分查找。

/**
 * 插入排序
 */
const insertionSort = (arr) => {
	console.time('使用二分查找的插入排序耗时');

	const len = arr.length;

	// i为无序序列的起始索引,从1开始即可,因为第0个可以认为已经处于有序序列中
	for (let i = 1; i < len; i++) {
		// 要插入的数
		const target = arr[i];
		// 二分查找指针
		let left = 0;
		let right = i - 1;
		let mid;

		// while循环结束后,left对应的索引就是我们要找的结果(第一个比target小的数或者有序序列左端点)
		while (left <= right) {
                        // 二分查找
			mid = left + Math.floor((right - left) / 2);
			if (arr[mid] <= target) {
				left = mid + 1;
			} else {
				right = mid - 1;
			}
		}

		for (let j = i - 1; j >= left; j--) {
			// 将比target大的数依次往后移一位
			arr[j + 1] = arr[j];
		}

		// 将target插入到对应位置
		arr[left] = target;
	}

	console.timeEnd('使用二分查找的插入排序耗时');

	return arr;
};

// test case:
const arr = Array.from({ length: 100 }).map(() =>
	Math.floor(Math.random() * 1000)
);
console.log(insertionSort(arr));
复制代码

3.4.3 区别

从算法时间复杂度和空间复杂度上来看,两种都是一样的,经个人测试,数组长度不大时,两种方法的实际耗时基本相差不大,而数组长度较大时,使用二分查找的插入排序实际耗时比常规插入排序要少一些。具体的测试截图就不再放了,大家有兴趣的话可以自己试一下,本文末尾也会放出代码地址。

3.5 算法复杂度

时间复杂度:O( n 2 n^2 );

空间复杂度:O(1)。

3.6 排序算法稳定性

我们每次都是选取无序序列中的第一位,所以排序后相同元素的相对位置不会发生变化,是稳定的。

4. 快速排序

4.1 基本思想

从待排序数组中选取一个基准数,将比它小的数都放到它左边,比它大的放到它右边,此时它将一个无序序列划分成了两个子序列,然后使用递归方式对左右两个子序列进行同样的处理。递归的终点是要处理的子序列中只有1或0个数,则认为该子序列是有序的,最终整个序列将是有序的。这是一种典型的分治思想。

4.2 实现思路

  1. 从序列中选取一个基准数pivot,序列中任意一个数都是可以作为基准数的,为了方便,我们选取第一个;
  2. 遍历无序序列中的数,如果比基准数小,就放到基准数的左边,比它大就放到它的右边,与它相等就不动,因为放两边都是可以的;
  3. 在上述遍历过程中,我们首先从序列的右边开始往左边找,我们设这个下标为j,如果arr[j]大于或者等于基准数,就执行j--操作,直到找到第 1 个比基准数小的值;接着从左边开始往右边找,设这个下标为 i,如果arr[i]小于或者等于基准数,就执行i++操作,直到找到第 1 个比基准数大的值,然后将arr[i]arr[j]交换位置;然后继续重复前面的从右往左的查找过程,直到 ij 重合时结束。而此时基准值还在第一位的位置,所以需要将arr[i]与基准值交换一下位置,此时就达到了基准数左边的数都小于等于它、基准数右边的数都大于等于它的效果了。
  4. 然后使用递归对上述左右两个子序列进行相同的处理;
  5. 当要处理的子序列中数的个数为 1 或者 0 时,认为该子序列是有序的,最终整个序列就将是序的。

4.3 排序动画

快速排序.gif

4.4 代码实现

/**
 * 交换数组中的某两项
 */
const swap = (arr, i, j) => {
	const temp = arr[j];
	arr[j] = arr[i];
	arr[i] = temp;
};

const quickSort = (arr, left, right) => {
	// 如果left >= right,说明该序列中没有元素或者只有一个元素,不需要排序
	if (left >= right) return;

	// 取序列中第一个数作为基准数
	const pivot = arr[left];
	let i = left;
	let j = right;

	// i等于j时,while终止
	while (i < j) {
		// 这个while终止,说明遇到了小于基准数的,就停止
		while (i < j && arr[j] >= pivot) {
			j--;
		}
		// 然后从左到右,找到第一个大于基准数的
		while (i < j && arr[i] <= pivot) {
			i++;
		}
                // 交换这两个数的位置
		swap(arr, i, j);
	}

        // 将基准数换到中间来
	swap(arr, i, left);
        // 再递归地对左右两个子序列进行相同的排序处理
	quickSort(arr, left, i - 1);
	quickSort(arr, i + 1, right);
};

// test case:
const arr = Array.from({ length: 100 }).map(() =>
	Math.floor(Math.random() * 1000)
);
quickSort(arr, 0, arr.length - 1);
console.log(arr);
复制代码

4.5 算法复杂度

时间复杂度:O( n l o g n nlogn );

空间复杂度:使用递归,每次递归中只使用了常数空间,故空间复杂度为递归的深度,为O( l o g n logn )。

4.6 排序算法稳定性

不稳定。

5. 归并排序

5.1 基本思想

将一个待排序序列分成两个子序列 (分),再使用归并排序分别对这两个子序列排序 (治),将排好序的两个子序列合并成一个有序序列 (合),也是运用了分治思想。

5.2 排序动画

归并排序.gif

5.3 实现思路

5.3.1 递归法(自顶向下)

  1. 将待排序序列从中间位置一分为二,分成两个子序列;
  2. 再递归对这两个子序列分别进行归并排序,递归的终点是子序列中只有一个数,则认为该序列有序;
  3. 将有序的子序列两两合并得到一个更大的有序序列;合并过程如下:
    • 创建一个结果数组用于保存合并结果;
    • 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
    • 比较两个指针所指向的元素,选择相对小的元素放入结果数组,并移动指针到下一位置;
    • 重复上一个步骤直到某一指针到达序列尾;
    • 然后将另一序列剩下的所有元素直接复制到合并序列尾。

5.3.2 代码实现一

这种实现需要开辟left和right数组的内存空间,且排序后的是一个新数组,非原数组;

/**
* 合并两个子序列
*/
const merge = (left, right) => {
   const result = [];

   while (left.length && right.length) {
   	// 因为left和right都是有序的,比较两个数组中谁的头部数字小,就先放进结果数组,保证result是从小到大的顺序
   	if (left[0] < right[0]) {
   		result.push(left.shift());
   	} else {
   		result.push(right.shift());
   	}
   }

   // 当一个数组已经空了,就把另一个数组依次push进result中
   while (left.length) {
   	result.push(left.shift());
   }

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

   return result;
};

const mergeSort = (arr) => {
   const len = arr.length;
   // 如果仅有1项或者0项,无需排序
   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));
};

// test case:
const arr = Array.from({ length: 30 }).map(() =>
   Math.floor(Math.random() * 1000)
);
console.log(mergeSort(arr));
复制代码

5.3.3 代码实现二

这种实现使用指针替代了开辟left和right数组内存空间,且在原数组上进行排序。

/**
 * 合并两个子序列
 * 子序列是数组中的[left, right]区间内以mid为界被分开的两个子序列
 */
const merge = (arr, left, mid, right) => {
	let i = left;
	let j = mid + 1;
	// 用于临时存放合并好的结果
	const result = [];

	// 判断两个子序列的头部元素,哪个更小,就优先放进result数组
	while (i <= mid && j <= right) {
		if (arr[i] <= arr[j]) {
			result.push(arr[i++]);
		} else {
			result.push(arr[j++]);
		}
	}

	// 将剩下的一个子序列中的剩余元素依次放进result数组
	while (i <= mid) {
		result.push(arr[i++]);
	}
	while (j <= right) {
		result.push(arr[j++]);
	}

	// 将result中保存的元素依次复制到arr中的对应位置
	// 此处我们从后往前复制
	while (right >= left) {
		arr[right] = result[right - left];
		right--;
	}
};

const sort = (arr, left, right) => {
	// 如果子序列仅有1项或者0项,无需排序
	if (right <= left) return;

	const mid = left + Math.floor((right - left) / 2);

	// 使两个子数组有序,
	sort(arr, left, mid);
	sort(arr, mid + 1, right);
	// 再将这两个有序的子序列合并成一个大的有序序列
	merge(arr, left, mid, right);
};

const mergeSort = (arr) => {
	sort(arr, 0, arr.length - 1);
};

// test case:
const arr = Array.from({ length: 30 }).map(() =>
	Math.floor(Math.random() * 1000)
);
mergeSort(arr);
console.log(arr);
复制代码

5.3.4 迭代法(自底向上)

  1. 将序列每相邻两个数字进行归并操作,形成 c e i l ( n / 2 ) ceil(n/2) 个序列,排序后每个序列包含两个(或一个)元素;
  2. 若此时序列数大于1个则将上述序列再次归并,形成 c e i l ( n / 4 ) ceil(n/4) 个序列,每个序列包含四个(或三个)元素;
  3. 重复步骤2,直到所有元素排序完毕,即序列数为1。

5.3.5 代码实现

该实现中的merge过程及代码与上述递归法中第二种实现相同,并且也是在原数组上排序。

/**
 * 合并两个子序列
 * 子序列是数组中的[left, right]区间内以mid为界被分开的两个子序列
 */
const merge = (arr, left, mid, right) => {
	let i = left;
	let j = mid + 1;
	// 用于临时存放合并好的结果
	const result = [];

	// 判断两个子序列的头部元素,哪个更小,就优先放进result数组
	while (i <= mid && j <= right) {
		if (arr[i] <= arr[j]) {
			result.push(arr[i++]);
		} else {
			result.push(arr[j++]);
		}
	}

	// 将剩下的一个子序列中的剩余元素依次放进result数组
	while (i <= mid) {
		result.push(arr[i++]);
	}
	while (j <= right) {
		result.push(arr[j++]);
	}

	// 将result中保存的元素依次复制到arr中的对应位置
	// 此处我们从后往前复制
	while (right >= left) {
		arr[right] = result[right - left];
		right--;
	}
};

const mergeSort = (arr) => {
	const len = arr.length;
	if (len < 2) return;

	// 子序列元素个数,为1时,子序列仅有1个元素,如[3]和[5]合并,以此类推
	let k = 1;

	while (k < len) {
		// 两个完整子序列(完整:子序列元素个数为k)合并完后的元素个数
		const count = 2 * k;
		let i;
		// i < len - count是为了保证在这个for循环中的合并都是完整子序列合并
		for (i = 0; i < len - count; i += count) {
			merge(arr, i, i + k - 1, i + count - 1);
		}

		// 最后可能存在一个子序列,则不需要合并,因为每个子序列已是有序的;
		// 最后如果存在两个子序列(i + k < len 的情况),两个子序列可能不全是完整子序列,比如[3,5,8,16]和[2,7],
		// 如果按照上面的for循环中right传入i + count - 1的话,将会超出arr的长度,故单独处理
		if (i + k < len) {
			merge(arr, i, i + k - 1, len - 1);
		}
		k *= 2;
	}
};

// test case:
const arr = Array.from({ length: 90 }).map(() =>
	Math.floor(Math.random() * 1000)
);
mergeSort(arr);
console.log(arr);
复制代码

5.4 算法复杂度

时间复杂度:与原数组的初始状态无关,不管元素在什么情况下,都要执行这些步骤,最好和最坏的时间复杂度都是O( n l o g n nlogn );

空间复杂度:使用递归法时,merge过程临时的结果数组result和递归时压入栈的数据占用的空间:n + logn;迭代法时,只有merge过程临时的结果数组result占用空间:n,所以归并排序的空间复杂度为 O( n n )。

5.5 算法稳定性

关键在于merge方法中,我们设定了arr[i] <= arr[j],所以当两个元素相同时,我们会优先把位于左边的arr[i]放入结果数组,它们的相对位置没有发生变化,因此归并排序是一种稳定的排序算法。

6. 计数排序

6.1 基本思想

利用数组索引是有序的这个特点,使用一个计数数组count,将待排序数组中的元素arr[i]对应为数组的索引(桶),count[arr[i]]对应为这个数在待排序数组中出现的次数,然后依次读取计数数组生成一个有序数组,即为排序结果。这种算法使用值的大小作为数组索引,因此更适用于待排序数组中的元素取值范围不大的场景。

6.2 实现思路

  1. 创建一个计数数组count;
  2. 遍历待排序数组,将数组中值为a的元素出现的次数存入计数数组的第a项count[a]
  3. 创建一个结果数组,遍历计数数组,count[i]大于 0 则往结果数组中push一个i,同时执行count[i]--,如果count[i]不存在计数或者等于 0,则i++,然后再进行相同的处理;
  4. 计数数组遍历结束,返回结果数组。

6.3 排序动画

计数排序.gif

6.4 代码实现

const countSort = (arr) => {
	let len = arr.length;
	const count = [];

	// 计数
	for (let i = 0; i < len; i++) {
		count[arr[i]] = (count[arr[i]] || 0) + 1;
	}

	len = count.length;
	const result = [];
	for (let i = 0; i < len; i++) {
		while (count[i]) {
			result.push(i);
			count[i]--;
		}
	}

	return result;
};

// test case:
const arr = Array.from({ length: 300 }).map(() =>
	Math.floor(Math.random() * 100)
);

console.log(countSort(arr));
复制代码

6.5 算法复杂度

时间复杂度:O( n + k n+k ),n为数组长度,k取决于数组元素的最大值;

空间复杂度:count的长度取决于数组元素的最大值,空间复杂度为O( k k )。

6.6 排序算法稳定性

稳定。

本文出现的代码都可以在这里找到。

猜你喜欢

转载自juejin.im/post/7082995404190515214
今日推荐