5大排序算法&2大搜索&4大算法思想
完整源码地址,开源不易,你的star是我努力的动力!(gitee.com/lintaibai/T...)
介绍
介绍数据结构与算法中的的5大排序算法、2大搜索算法以及我们刷算法面试题常见的4大算法思想
包含以下内容:
- 冒泡排序
- 快速排序
- 插入排序
- 归并排序
- 选择排序
- 顺序搜索
- 二分搜素
- 分而治之
- 动态规划
- 贪心算法
- 回溯算法
5大排序
1.冒泡排序(常考)
认识
冒泡排序(Bubble Sort)是一种非常直观且简单的排序算法,主要用于对一组数据进行排序。它的名字来源于这样一个过程:在每一轮遍历中,最大的元素会"像气泡一样"逐渐上浮到数组的末端,故称为"冒泡排序"。
原理如下
- 比较相邻的元素。如果第一个比第二个大,就交换他们两个,如果不是相等的就跳过比下面的元素 ,这样依次的循环下去 直到所有的元素都比较完成才结束。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
思路
javascript
冒泡排序其实核心是对比里面的一层,就是谁大排后面
加一层外面的排序步骤,因为两个对比,所以最后一个不需要对比,对比次数就是数组长度减一
实际手写
plain
function bubbleSort(arr) {
const len = arr.length
if (len <= 1) return
for (let i = 0; i < len - 1; i++) {
for (let j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
const temp = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = temp
}
}
}
}
// 功能测试
const arr = [4, 3, 6, 2, 5, 7, 9, 8, 1]
bubbleSort(arr)
console.log(arr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
整个交换过程比较,分别对比
javascript
// 14 34 46 62 65 98
// 1 3 4 2 5 6 7 8 9
// 1 3 2 4 56789
优化写法
javascript
// 优化1 写法
function bubbleSort(arr) {
const len = arr.length
if (len <= 1) return
for (let i = 0; i < len - 1; i++) {
for (let j = 0; j < len - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
}
}
}
}
我们可以添加一个计算步数的看看我们的步数走了多少,这个过程可以看出我们部署总共走了36步
javascript
function bubbleSort(arr) {
const len = arr.length
let stepCount = 0; // 步数计数器
if (len <= 1) return
for (let i = 0; i < len - 1; i++) {
for (let j = 0; j < len - i - 1; j++) {
stepCount++; // 每次比较都算一步
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
}
}
}
console.log(stepCount,'步数');
return arr
}
// 输出
36 '步数'
优化逻辑-添加有序标志减少遍历次数
我们发现,虽然我们一直对比,但是后面其实我们已经排序好的并不需要太多的对比,所以我们可以添加一个有序标志
如果一轮遍历中没有发生任何交换,说明数组已经有序,可以提前结束排序
这个时候我们优化一下可以发现,步数已经缩减到了26步
原理
javascript
这个标志用来判断在某一轮遍历中是否发生了元素交换
如果一轮遍历中没有发生任何交换,说明数组已经有序,可以提前结束排序
例子:
假设数组是 [1, 2, 3, 4, 5],在第一轮遍历时:
比较1和2,不交换
比较2和3,不交换
比较3和4,不交换
比较4和5,不交换
整个过程中 isSorted 始终为true
因此排序提前结束,不需要进行后续的遍历
javascript
// 优化2 标志上一次移动的下标 有序标志 (isSorted)
function bubbleSort(arr) {
const len = arr.length;
let stepCount = 0; // 步数计数器
if (len <= 1) return
for (let i = 0; i < len - 1; i++) {
let isSorted = true // 有序标志
for (let j = 0; j < len - i - 1; j++) {
stepCount++; // 每次比较都算一步
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
isSorted = false
}
}
if (isSorted) {
console.log(isSorted, '交换结束===isSorted');
break
} // 如果没有发生交换,说明已经有序
}
console.log(stepCount,'步数');
return arr
}
// 输出 26 '步数'
优化逻辑-添加无序边界 (sortBorder) 减少每次遍历的比较次数
标记上一轮已经有序的位置 ,我们可以通过记录无序数列的边界位置
下一轮遍历时只需要比较到这个位置即可,因为后面的元素已经有序
javascript
例子:
假设数组是 [3, 4, 2, 1, 5]:
第一轮遍历:
3和4比较,不交换
4和2比较,交换,数组变为 [3, 2, 4, 1, 5],lastSwapIndex = 2
4和1比较,交换,数组变为 [3, 2, 1, 4, 5],lastSwapIndex = 3
4和5比较,不交换
第一轮结束后,sortBorder = 3,因为最后一次交换发生在索引3
此时我们知道,索引3之后的元素(4,5)已经有序
第二轮遍历:
只需要比较到索引3即可,不需要比较到最后
3和2比较,交换,数组变为 [2, 3, 1, 4, 5],lastSwapIndex = 1
3和1比较,交换,数组变为 [2, 1, 3, 4, 5],lastSwapIndex = 2
3和4比较,不交换
第二轮结束后,sortBorder = 2,因为最后一次交换发生在索引2
此时我们知道,索引2之后的元素(3,4,5)已经有序
这个时候输出我们发现,步数已经优化到了18步,也就是刚刚开始时候我们36步的一般,效率提升100%
javascript
// 优化3 标记上一轮已经有序的位置 无序边界 (sortBorder)
function bubbleSort(arr) {
let stepCount = 0; // 步数计数器
const len = arr.length;
if (len <= 1) return arr;
let sortBorder = len - 1; // 初始时无序边界为数组末尾
let lastSwapIndex = 0; // 记录最后一次交换的位置
for (let i = 0; i < len - 1; i++) {
let isSorted = true; // 有序标志
for (let j = 0; j < sortBorder; j++) {
stepCount++; // 每次比较都算一步
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
isSorted = false; // 发生了交换,说明还不是完全有序
lastSwapIndex = j; // 记录最后一次交换的位置
}
}
sortBorder = lastSwapIndex; // 更新无序边界,边界之后的元素已经有序
if (isSorted) {
console.log(isSorted, '交换结束===isSorted');
break; // 如果没有发生交换,说明已经有序
}
}
console.log(stepCount, '步数');
return arr;
}
// 测试代码
const testArr = [64, 34, 25, 12, 22, 11, 90];
console.log('原始数组:', testArr);
bubbleSort(testArr);
//输出 18 '步数'
添加上我们计算步数以及以及交换和对比的计数
javascript
// 优化3 标记上一轮已经有序的位置 无序边界 (sortBorder)
function bubbleSort(arr) {
let stepCount = 0; // 步数计数器
let compareCount = 0; // 比较次数计数器
let swapCount = 0; // 交换次数计数器
const len = arr.length;
if (len <= 1) return arr;
let sortBorder = len - 1; // 初始时无序边界为数组末尾
let lastSwapIndex = 0; // 记录最后一次交换的位置
for (let i = 0; i < len - 1; i++) {
let isSorted = true; // 有序标志
for (let j = 0; j < sortBorder; j++) {
stepCount++; // 每次比较都算一步
compareCount++; // 每次比较都计数
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
isSorted = false; // 发生了交换,说明还不是完全有序
lastSwapIndex = j; // 记录最后一次交换的位置
swapCount++; // 每次交换都计数
}
}
sortBorder = lastSwapIndex; // 更新无序边界,边界之后的元素已经有序
if (isSorted) {
console.log(isSorted, '交换结束isSorted');
break; // 如果没有发生交换,说明已经有序
}
}
console.log(stepCount, '步数');
console.log('比较次数:', compareCount);
console.log('交换次数:', swapCount);
return arr;
}
// 功能测试
const arr = [1, 4, 3, 6, 2, 5, 7, 9, 8]
bubbleSort(arr)
console.log(arr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
2.快速排序(Quick Sort常考)
认识
快速排序(Quick Sort)是一种经典的排序算法,广泛应用于实际工程中。主要特点是利用分治法 来排序,能够在大多数情况下实现 <font style="color:rgb(36, 41, 47);">O(n log n) 的时间复杂度。
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
plain
/**
* @description 快速排序
* @author hovinghuang
*/
/**
* 快速排序 (splice)
* @param arr
* @returns
*/
function quickSort1(arr: number[]): number[] {
const len = arr.length
if (len === 0) return arr
const midIndex = Math.floor(len / 2)
const midValue = arr.splice(midIndex, 1)[0]
const left: number[] = []
const right: number[] = []
// 注意: splice 会修改原数组,所以用 arr.length
for (let i = 0; i < arr.length; i++) {
const n = arr[i]
if (n < midValue) {
left.push(n)
} else {
right.push(n)
}
}
return quickSort1(left).concat([midValue], quickSort1(right))
}
/**
* 快速排序 (slice)
* @param arr
* @returns
*/
function quickSort2(arr: number[]): number[] {
const len = arr.length
if (len === 0) return arr
const midIndex = Math.floor(len / 2)
const midValue = arr.slice(midIndex, midIndex + 1)[0]
const left: number[] = []
const right: number[] = []
for (let i = 0; i < len; i++) {
if (i === midIndex) continue
const n = arr[i]
if (n < midValue) {
left.push(n)
} else {
right.push(n)
}
}
return quickSort2(left).concat([midValue], quickSort2(right))
}
// 功能测试
const testArr3 = [3, 2, 5, 1, 8, 7]
console.info('quickSort2:', quickSort2(testArr3))
基本概念
快速排序的核心思想是通过"分治法"将问题拆解为更小的子问题来递归解决。
- 分治法:快速排序每次通过选择一个元素作为基准(pivot),将数组分为两部分。左边部分的元素比基准小,右边部分的元素比基准大。然后,分别对左右两部分递归地进行排序。
- 选择基准元素:可以选择第一个元素、最后一个元素或中间元素作为基准,或者通过随机选择基准来避免最坏情况。
工作过程
- 选择基准元素(Pivot):从待排序的数组中选择一个元素作为基准(可以是第一个元素、最后一个元素,或通过其他方法选择)。
- 划分操作:将数组分为两部分。左边部分的元素都小于等于基准元素,右边部分的元素都大于基准元素。基准元素在划分过程中会被放到正确的位置。
- 递归排序:对基准元素左边和右边的子数组继续进行快速排序。
例子
假设我们要对以下数组进行排序:
plain
[6, 3, 8, 2, 7, 5, 1, 4]
- 选择基准元素 :假设选择第一个元素
6作为基准。 - 划分操作:将数组划分为左边部分(小于等于6)和右边部分(大于6)。
plain
左边:[3, 2, 5, 1, 4]
基准:[6]
右边:[8, 7]
- 对左右子数组递归进行快速排序:
- 左边
[3, 2, 5, 1, 4]选择3为基准,再进行划分。 - 右边
[8, 7]选择8为基准,再进行划分。
- 左边
最终,经过递归排序后,得到排序后的数组:
plain
[1, 2, 3, 4, 5, 6, 7, 8]
时间复杂度
- 最优情况 :当每次基准元素将数组均匀分割时,时间复杂度为
O(n log n),其中n是数组的长度。 - 平均情况 :在大多数实际情况下,快速排序的平均时间复杂度也是
O(n log n)。 - 最坏情况 :当选择的基准总是数组中的最大或最小元素(例如,数组已经排好序或完全逆序),导致每次分割只有一个元素时,时间复杂度退化为
O(n^2)。
快速排序的空间复杂度
快速排序的空间复杂度是 O(log n) ,这主要是由递归调用栈所占用的空间决定的。每次划分时,递归深度最多为 log n。
如果使用原地排序(即直接在原数组上修改,而不使用额外数组),空间复杂度可以降到 O(log n) 。但如果每次划分时创建新的数组,空间复杂度则是 O(n)。
优缺点
优点:
- 高效性 :在大多数情况下,快速排序的性能非常优越,时间复杂度为
O(n log n),适用于大规模数据排序。 - 原地排序:快速排序是一种原地排序算法,不需要额外的存储空间(除了递归栈)。
- 分治法:通过递归和分治法来处理复杂问题,适用于大规模数据集。
缺点:
-
最坏情况性能差 :当数据已经接近有序或是逆序时,快速排序的性能会退化为
O(n^2)。这个问题可以通过随机选择基准元素或使用三数取中的方法来减少。 -
不稳定排序:与冒泡排序、插入排序等稳定排序算法不同,快速排序会改变相等元素的顺序。
优化策略
- 基准选择优化 :
- 随机选择基准:通过随机选择基准元素,可以避免在已经排序或逆序的数组上出现最坏情况。
- 三数取中法:选择数组首、尾和中间三个元素的中位数作为基准,可以减少最坏情况的概率。
- 递归深度限制 :
- 当子数组的长度较小时(如小于10),可以切换为其他排序算法,如插入排序,因为插入排序对小规模数据的排序更高效。
- 尾递归优化 :
- 在递归过程中,可以选择递归较小的子数组,然后迭代处理较大的子数组,减少递归的深度。
快速排序核心
- 理解分治法:核心是分治法的思想。通过选择基准将问题划分成两个子问题,并通过递归解决它们。
- 实现多种方式:尝试实现快速排序的不同版本,例如使用递归实现、使用随机基准、三数取中法等。
- 分析时间复杂度:理解快速排序的时间复杂度分析,尤其是最坏情况和平均情况的区别,以及如何优化。
- 与其他排序算法比较:将快速排序与其他排序算法(如冒泡排序、选择排序、归并排序等)比较其优缺点和场景。
写法
基础写法
javascript
// 解法1 快速排序
function quickSort(arr) {
if (arr.length <= 1) {
return arr
}
// 选择基准元素,这里我们选择第一个元素作为基准
let pivot = arr[0];
let left = [];
let right = [];
for (let i = 1; i < arr.length; i++) {
if (arr[i] <= pivot) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
return [...quickSort(left), pivot,...quickSort(right)]
}
// 测试数据
let arr = [6, 3, 8, 2, 7, 5, 1, 4];
console.log("排序前:", arr);
let sortedArr = quickSort(arr);
console.log("排序后:", sortedArr);
整个过程
javascript
选择一个基准元素(pivot),这里选择数组的第一个元素
将小于等于基准元素的元素放到左数组
将大于基准元素的元素放到右数组
递归地对左数组和右数组进行同样的排序
最后将排序后的左数组、基准元素和排序后的右数组合并
执行过程:
对于数组 [6, 3, 8, 2, 7, 5, 1, 4]
第一次选择6为基准,分成 [3, 2, 5, 1, 4] 和 [8, 7]
然后对 [3, 2, 5, 1, 4] 选择3为基准,分成 [2, 1] 和 [5, 4]
对 [2, 1] 选择2为基准,分成 [1] 和 []
对 [5, 4] 选择5为基准,分成 [4] 和 []
对 [8, 7] 选择8为基准,分成 [7] 和 []
然后按照相同的方式递归处理其他子数组
输出:
排序前:[6, 3, 8, 2, 7, 5, 1, 4]
排序后:[1, 2, 3, 4, 5, 6, 7, 8]
优化-使用随机基准选择
添加注释以后我们更加容易看出添加基准以后,进行的步数大概平均下来在25步左右
javascript
// 写法优化1-使用随机基准选择
let stepCount = 0; // 全局步数计数器
console.log(`步骤 ${stepCount}: 进入`);
function quickSort(arr) {
if (arr.length <= 1) {
return arr
}
stepCount++; // 基准选择计数
// 选择基准元素,这里我们选择第一个元素作为基准
const randomIndex = Math.floor(Math.random() * arr.length);
[arr[0], arr[randomIndex]] = [arr[randomIndex], arr[0]]; // 交换到第一个位置
let pivot = arr[0];
let left = [];
let right = [];
for (let i = 1; i < arr.length; i++) {
stepCount++; // 循环迭代计数
console.log(`步骤 ${stepCount}: 检查元素 ${arr[i]}, 当前基准: ${pivot}`);
if (arr[i] <= pivot) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
stepCount++; // 递归调用计数
return [...quickSort(left), pivot,...quickSort(right)]
}
优化-使用三数取中法选择基准
这个时候我们优化下来,在14步左右
javascript
// 优化2-使用三数取中法选择基准(带步数计算)
let stepCount = 0; // 全局步数计数器
function quickSort(arr) {
if (arr.length <= 1) {
return arr
}
stepCount++; // 基准选择计数
// 选择基准元素,这里我们选择第一个元素作为基准
const mid = Math.floor(arr.length / 2);
// 比较三个数
if (arr[0] > arr[mid]) {
[arr[0], arr[mid]] = [arr[mid], arr[0]];
}
if (arr[0] > arr[arr.length - 1]) {
[arr[0], arr[arr.length - 1]] = [arr[arr.length - 1], arr[0]];
}
if (arr[mid] > arr[arr.length - 1]) {
[arr[mid], arr[arr.length - 1]] = [arr[arr.length - 1], arr[mid]];
}
let pivot = arr[mid];;
let left = [];
let right = [];
for (let i = 1; i < arr.length; i++) {
if (i === mid) continue; // 跳过基准元素
stepCount++; // 循环迭代计数
console.log(`步骤 ${stepCount}: 检查元素 ${arr[i]}, 当前基准: ${pivot}`);
if (arr[i] <= pivot) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
stepCount++; // 递归调用计数
return [...quickSort(left), pivot,...quickSort(right)]
}
优化3-使用原地分区
这里一定得去看看原理分区的概念才能理解这个原地分区,其实就是不借助于额外存储进行位置的不断交换
javascript
// 优化3-使用原地分区
let stepCount = 0; // 全局步数计数器
function quickSort(arr, left = 0, right = arr.length - 1) {
if (left < right) {
stepCount++; // 递归调用计数
const mid = Math.floor((left + right) / 2);
// 比较三个数
if (arr[left] > arr[mid]) {
[arr[left], arr[mid]] = [arr[mid], arr[left]];
}
if (arr[left] > arr[right]) {
[arr[left], arr[right]] = [arr[right], arr[left]];
}
if (arr[mid] > arr[right]) {
[arr[mid], arr[right]] = [arr[right], arr[mid]];
}
[arr[mid], arr[right]] = [arr[right], arr[mid]];
const pivot = arr[right];
let i = left - 1;
for (let j = left; j < right; j++) {
stepCount++; // 循环迭代计数
if (arr[j] <= pivot) {
i++;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
[arr[mid], arr[right]] = [arr[right], arr[mid]];
const partitionIndex = i + 1;
quickSort(arr, left, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, right);
}
return arr;
}
// 测试数据
let arr = [6, 3, 8, 2, 7, 5, 1, 4];
console.log("排序前:", arr);
let sortedArr = quickSort(arr);
console.log("排序后:", sortedArr);
console.log("总步数:", stepCount);
优化4-添加小数组插入排序优化
这个时候我们又能感觉到步数明显确实加快了
javascript
// 优化4-添加小数组插入排序优化
let stepCount = 0; // 全局步数计数器
function quickSort(arr, left = 0, right = arr.length - 1) {
// 对小数组使用插入排序
if (right - left < 10) {
insertionSort(arr, left, right);
stepCount++;
return arr;
}
if (left < right) {
stepCount++; // 递归调用计数
const mid = Math.floor((left + right) / 2);
// 比较三个数
if (arr[left] > arr[mid]) {
[arr[left], arr[mid]] = [arr[mid], arr[left]];
}
if (arr[left] > arr[right]) {
[arr[left], arr[right]] = [arr[right], arr[left]];
}
if (arr[mid] > arr[right]) {
[arr[mid], arr[right]] = [arr[right], arr[mid]];
}
[arr[mid], arr[right]] = [arr[right], arr[mid]];
const pivot = arr[right];
let i = left - 1;
for (let j = left; j < right; j++) {
stepCount++; // 循环迭代计数
if (arr[j] <= pivot) {
i++;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
[arr[i + 1], arr[right]] = [arr[right], arr[i + 1]];
const partitionIndex = i + 1;
quickSort(arr, left, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, right);
}
return arr;
}
// 辅助函数:插入排序(带步数计算)
function insertionSort(arr, left, right) {
for (let i = left + 1; i <= right; i++) {
const key = arr[i];
let j = i - 1;
while (j >= left && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
}
优化5-尾递归优化的原理
优化前(普通递归):
plain
复制 插入 新文件
factorial(3)
→ 3 * factorial(2)
→ 3 * (2 * factorial(1))
→ 3 * (2 * 1)
→ 3 * 2
→ 6
优化后(尾递归):
plain
复制 插入 新文件
factorial(3, 1)
→ factorial(2, 3)
→ factorial(1, 6)
→ 6
这里我们进行一下尾递归优化的写法,核心就是下面的代码
优点:避免递归深度过大,从而引发栈溢出
传统的快速排序
javascript
quickSort(arr, left, partitionIndex - 1); // 递归处理左子数组
quickSort(arr, partitionIndex + 1, right); // 递归处理右子数组
尾递归
javascript
// 先处理较小的子数组,减少递归深度
if (partitionIndex - left < right - partitionIndex) {
quickSort(arr, left, partitionIndex - 1);
left = partitionIndex + 1;
} else {
quickSort(arr, partitionIndex + 1, right);
right = partitionIndex - 1;
}
尾递归优化完整
javascript
// 优化5 - 尾递归优化
let stepCount = 0; // 全局步数计数器
function quickSort(arr, left = 0, right = arr.length - 1) {
// 对小数组使用插入排序
if (right - left < 10) {
insertionSort(arr, left, right);
stepCount++;
return arr;
}
while (left < right) {
stepCount++; // 递归调用计数
const mid = Math.floor((left + right) / 2);
// 比较三个数
if (arr[left] > arr[mid]) {
[arr[left], arr[mid]] = [arr[mid], arr[left]];
}
if (arr[left] > arr[right]) {
[arr[left], arr[right]] = [arr[right], arr[left]];
}
if (arr[mid] > arr[right]) {
[arr[mid], arr[right]] = [arr[right], arr[mid]];
}
// 将基准放到right位置
[arr[mid], arr[right]] = [arr[right], arr[mid]];
const pivot = arr[right];
let i = left - 1;
for (let j = left; j < right; j++) {
stepCount++; // 循环迭代计数
if (arr[j] <= pivot) {
i++;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
[arr[i + 1], arr[right]] = [arr[right], arr[i + 1]];
//
const partitionIndex = i + 1;
// 先处理较小的子数组,减少递归深度
if (partitionIndex - left < right - partitionIndex) {
quickSort(arr, left, partitionIndex - 1);
left = partitionIndex + 1;
} else {
quickSort(arr, partitionIndex + 1, right);
right = partitionIndex - 1;
}
}
return arr;
}
// 辅助函数:插入排序(带步数计算)
function insertionSort(arr, left, right) {
stepCount++;
console.log(`步骤 ${stepCount}: 进入insertionSort, 范围: [${left}, ${right}]`);
for (let i = left + 1; i <= right; i++) {
stepCount++;
console.log(`步骤 ${stepCount}: 处理元素arr[${i}](${arr[i]})`);
const key = arr[i];
let j = i - 1;
stepCount++;
while (j >= left && arr[j] > key) {
stepCount++;
console.log(`步骤 ${stepCount}: 移动arr[${j}]到arr[${j + 1}]`);
arr[j + 1] = arr[j];
j--;
}
stepCount++;
arr[j + 1] = key;
console.log(`步骤 ${stepCount}: 将${key}放到位置${j + 1},数组变为:`, arr);
}
stepCount++;
console.log(`步骤 ${stepCount}: 插入排序完成`);
return arr;
}
3.插入排序(Insertion Sort)
认识
插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入,类似于我们整理手中的扑克牌 ,一次只处理一个元素,每次都将当前元素插入到已排序部分的正确位置。
plain
function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
const temp = arr[i];
let j = i;
while (j > 0) {
if (arr[j - 1] > temp) {
arr[j] = arr[j - 1];
} else {
break;
}
j--;
}
arr[j] = temp;
}
}
// 功能测试
const arr = [4, 3, 6, 2, 5, 7, 9, 8, 1]
insertionSort(arr)
console.log(arr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
核心思想
将数组分为两个部分:
已排序部分:初始时只包含第一个元素
未排序部分:包含其余所有元素
算法通过从未排序部分取出一个元素,并将其插入到已排序部分的适当位置,从而不断扩大已排序部分,直到整个数组排序完成。
时间复杂度
- 最坏时间复杂度:O(n²) - 当数组是逆序时
- 平均时间复杂度:O(n²)
- 最好时间复杂度:O(n) - 当数组已经有序时
空间复杂度
- O(1) - 只需要常数级别的额外空间
优缺点
优点
- 实现简单,容易理解
- 对于小规模数据或基本有序的数据效率较高
- 是稳定排序算法(相等元素的相对顺序不变)
- 是原地排序算法,不需要额外空间
缺点
- 对于大规模数据效率较低
- 最坏情况下时间复杂度为O(n²)
适用场景
- 数据规模较小
- 数据基本有序
- 需要稳定排序的场景
- 内存受限的场景
4.归并排序(Merge Sort)
认识
归并排序(Merge Sort)是一种基于分治法(Divide and Conquer)的高效排序算法。它将数组分成两半,分别对两半进行排序,然后将排序好的两半合并成一个有序数组。归并排序是稳定排序算法,时间复杂度为O(n log n)。
大致实现如下
plain
function mergeSort(arr) {
if(arr.length === 1) return arr
let mid = Math.floor(arr.length / 2)
let left = arr.slice(0, mid)
let right = arr.slice(mid)
return merge(mergeSort(left), mergeSort(right))
}
function merge(a, b) {
let res = []
while (a.length && b.length) {
if (a[0] < b[0]) {
res.push(a[0])
a.shift()
} else {
res.push(b[0])
b.shift()
}
}
if(a.length){
res = res.concat(a)
} else {
res = res.concat(b)
}
return res
}
// 功能测试
const arr = [4, 3, 6, 2, 5, 7, 9, 8, 1]
console.log(mergeSort(arr)) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
原理
归并排序的核心思想是"分而治之",主要包含两个步骤:
- 分:将数组不断二分,直到每个子数组只有一个元素(单个元素自然是有序的)
- 治:将有序的子数组两两合并,直到合并成一个完整的有序数组
主要就是分为两步
- 分割:将待排序的线性表不断地切分成若干个子表,直到每个子表只包含一个元素,这时,可以认为只包含一个元素的子表是有序表。
- 归并:将子表两两合并,每合并一次,就会产生一个新的且更长的有序表,重复这一步骤,直到最后只剩下一个子表,这个子表就是排好序的线性表。
算法步骤
- 分解阶段:
- 将数组从中间分成两个子数组
- 递归地对每个子数组进行分解,直到每个子数组只有一个元素
- 合并阶段:
- 将两个有序的子数组合并成一个有序数组
- 比较两个子数组的元素,按顺序取出较小的元素放入结果数组
- 如果其中一个子数组已经全部取出,则将另一个子数组的剩余元素直接复制到结果数组
时间复杂度分析
- 最好时间复杂度:O(n log n)
- 平均时间复杂度:O(n log n)
- 最坏时间复杂度:O(n log n)
- 空间复杂度:O(n) - 需要额外的空间来存储合并的数组
优缺点分析
优点
- 时间复杂度稳定:在各种情况下都是O(n log n)
- 稳定排序:相等元素的相对顺序保持不变
- 适合大规模数据:对于大数据集效率较高
- 可并行化:分解阶段可以并行处理
缺点
- 空间复杂度高:需要O(n)的额外空间
- 小规模数据性能不如插入排序:对于小数组,插入排序可能更高效
- 不是原地排序:需要额外的存储空间
JavaScript实现
基础实现
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>归并排序</title>
</head>
<body>
<script>
// 解法1 归并排序
const merge=(left,right)=>{
let result = [];
let leftIndex = 0;
let rightIndex = 0;
while (leftIndex < left.length && rightIndex < right.length) {
if(left[leftIndex]<right[rightIndex]){
result.push(left[leftIndex])
leftIndex++;
}else{
result.push(right[rightIndex])
rightIndex++;
}
}
return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex));
};
const mergeSort=(arr)=> {
// 数组长度小于等于1,则已经有序
if(arr.length<=1){
return arr;
}
// 分解阶段:将数组分成两半
const middle=Math.floor(arr.length / 2);
const left =arr.slice(0,middle);
const right =arr.slice(middle);
return merge(mergeSort(left),mergeSort(right))
}
// 示例使用
const arr = [38, 27, 43, 3, 9, 82, 10];
console.log("原始数组:", arr);
console.log("排序后数组:", mergeSort(arr));
</script>
</body>
</html>
优化1-对小数组使用插入排序
归并排序的方式比较适合于大数组,这种时候小数组我们可以额外使用插入排序进行优化
javascript
当数组长度小于某个阈值(如10-20)时,使用插入排序而不是继续递归归并排序
插入排序在小数组上表现通常比归并排序更好
设置阈值以后
javascript
// 2 优化-小数组使用插入排序
const INSERTION_SORT_THRESHOLD = 10;
const insertionSort = (arr) => {
for (let i = 1; i < arr.length; i++) {
let key = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
};
const merge=(left,right)=>{
let result = [];
let leftIndex = 0;
let rightIndex = 0;
while (leftIndex < left.length && rightIndex < right.length) {
if(left[leftIndex]<right[rightIndex]){
result.push(left[leftIndex])
leftIndex++;
}else{
result.push(right[rightIndex])
rightIndex++;
}
}
return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex));
};
const mergeSort=(arr)=> {
// 数组长度小于等于1,则已经有序
if(arr.length<=INSERTION_SORT_THRESHOLD){
return insertionSort([...arr]);
}
// 分解阶段:将数组分成两半
const middle=Math.floor(arr.length / 2);
const left =arr.slice(0,middle);
const right =arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
}
优化合并过程
检查左右数组是否已经有序,避免不必要的比较
javascript
// 3-优化合并过程
// 检查左右数组是否已经有序,避免不必要的比较
// 这里感觉其实就是各种排序之间的特性进行优化
const INSERTION_SORT_THRESHOLD = 10;
const insertionSort = (arr) => {
for (let i = 1; i < arr.length; i++) {
let key = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
};
const merge=(left,right)=>{
// 检查是否可以直接合并
if (left[left.length - 1] <= right[0]) {
return left.concat(right);
}
if (right[right.length - 1] <= left[0]) {
return right.concat(left);
}
let result = [];
let leftIndex = 0;
let rightIndex = 0;
while (leftIndex < left.length && rightIndex < right.length) {
if(left[leftIndex]<right[rightIndex]){
result.push(left[leftIndex])
leftIndex++;
}else{
result.push(right[rightIndex])
rightIndex++;
}
}
return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex));
};
const mergeSort=(arr)=> {
// 数组长度小于等于1,则已经有序
if(arr.length<=INSERTION_SORT_THRESHOLD){
return insertionSort([...arr]);
}
// 分解阶段:将数组分成两半
const middle=Math.floor(arr.length / 2);
const left =arr.slice(0,middle);
const right =arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
}
优化-预先分配结果数组空间
javascript
// 预先分配结果数组空间
const INSERTION_SORT_THRESHOLD = 10;
const insertionSort = (arr) => {
for (let i = 1; i < arr.length; i++) {
let key = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
};
const merge=(left,right)=>{
// 检查是否可以直接合并
if (left[left.length - 1] <= right[0]) {
return left.concat(right);
}
if (right[right.length - 1] <= left[0]) {
return right.concat(left);
}
// 预先分配结果数组空间
const result = new Array(left.length + right.length);
let leftIndex = 0, rightIndex = 0, resultIndex = 0;
while (leftIndex < left.length && rightIndex < right.length) {
result[resultIndex++] = left[leftIndex] < right[rightIndex]
? left[leftIndex++]
: right[rightIndex++];
}
// 处理剩余元素
while (leftIndex < left.length) {
result[resultIndex++] = left[leftIndex++];
}
while (rightIndex < right.length) {
result[resultIndex++] = right[rightIndex++];
}
return result;
};
const mergeSort=(arr)=> {
// 数组长度小于等于1,则已经有序
if(arr.length<=INSERTION_SORT_THRESHOLD){
return insertionSort([...arr]);
}
// 分解阶段:将数组分成两半
const middle=Math.floor(arr.length / 2);
const left =arr.slice(0,middle);
const right =arr.slice(middle);
return merge(mergeSort(left), mergeSort(right));
}
优化5-避免不必要的数组复制-使用索引而不是slice来减少内存分配
javascript
const mergeSort = (arr, start = 0, end = arr.length) => {
// 数组长度小于阈值,使用插入排序
if (end - start <= INSERTION_SORT_THRESHOLD) {
return insertionSort(arr.slice(start, end));
}
const middle = Math.floor((start + end) / 2);
const left = mergeSort(arr, start, middle);
const right = mergeSort(arr, middle, end);
return merge(left, right);
};
优化-使用迭代而非递归实现
javascript
const INSERTION_SORT_THRESHOLD = 10;
const insertionSort = (arr) => {
for (let i = 1; i < arr.length; i++) {
let key = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
};
const merge = (arr, temp, left, mid, right) => {
// 检查是否已经有序
if (arr[mid - 1] <= arr[mid]) {
return;
}
// 合并两个有序区间
let i = left, j = mid, k = left;
while (i < mid && j < right) {
temp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++];
}
while (i < mid) {
temp[k++] = arr[i++];
}
while (j < right) {
temp[k++] = arr[j++];
}
// 将合并结果复制回原数组
for (let idx = left; idx < right; idx++) {
arr[idx] = temp[idx];
}
};
const mergeSort = (arr) => {
const n = arr.length;
const temp = new Array(n);
// 使用插入排序处理小数组
for (let i = 0; i < n; i += INSERTION_SORT_THRESHOLD) {
const end = Math.min(i + INSERTION_SORT_THRESHOLD, n);
insertionSort(arr, i, end);
}
// 自底向上的归并排序
for (let size = INSERTION_SORT_THRESHOLD; size < n; size *= 2) {
for (let left = 0; left < n - size; left += 2 * size) {
const mid = left + size;
const right = Math.min(left + 2 * size, n);
merge(arr, temp, left, mid, right);
}
}
return arr;
};
最终版本
javascript
const INSERTION_SORT_THRESHOLD = 10;
const insertionSort = (arr, start = 0, end = arr.length) => {
for (let i = start + 1; i < end; i++) {
let key = arr[i];
let j = i - 1;
while (j >= start && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
};
const merge = (left, right) => {
// 检查是否可以直接合并
if (left[left.length - 1] <= right[0]) {
return left.concat(right);
}
if (right[right.length - 1] <= left[0]) {
return right.concat(left);
}
// 预先分配结果数组空间
const result = new Array(left.length + right.length);
let leftIndex = 0, rightIndex = 0, resultIndex = 0;
while (leftIndex < left.length && rightIndex < right.length) {
result[resultIndex++] = left[leftIndex] < right[rightIndex]
? left[leftIndex++]
: right[rightIndex++];
}
// 处理剩余元素
while (leftIndex < left.length) {
result[resultIndex++] = left[leftIndex++];
}
while (rightIndex < right.length) {
result[resultIndex++] = right[rightIndex++];
}
return result;
};
const mergeSort = (arr) => {
// 数组长度小于阈值,使用插入排序
if (arr.length <= INSERTION_SORT_THRESHOLD) {
return insertionSort([...arr]);
}
// 分解阶段:将数组分成两半
const middle = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, middle));
const right = mergeSort(arr.slice(middle));
return merge(left, right);
};
5.选择排序(Selection Sort)
认识
选择排序基本思想是
- 首先在未排序的数列中找到最小(or最大)元素,然后将其存放到数列的起始位置。
- 接着,再从剩余未排序的元素中继续寻找最小(or最大)元素,然后放到已排序序列的末尾。
- 以此类推,直到所有元素均排序完毕。
分类:属于选择排序类,是原地排序算法
稳定性:不稳定排序算法(相等元素的相对位置可能改变)
时间复杂度:
- 最坏情况:O(n²)
- 最好情况:O(n²)
- 平均情况:O(n²)
空间复杂度:
O(1),只需要常数级别的额外空间
特点:
交换次数少,每次只交换一次
比较次数多,无论数组初始状态如何
不适合大规模数据排序
选择排序的原理
选择排序的工作过程可以分为以下步骤:
- 将数组分为已排序区和未排序区,初始时已排序区为空
- 在未排序区中找到最小(或最大)的元素
- 将这个元素与未排序区的第一个元素交换位置
- 将已排序区的范围扩大一个元素
- 重复步骤2-4,直到整个数组排序完成
适用场景
选择排序适用于以下场景:
- 小规模数据排序
- 内存受限的环境(空间复杂度低)
- 对交换操作有较高成本的情况(交换次数少)
- 对算法简单性要求高的情况
选择排序的优缺点
优点:
- 实现简单,容易理解
- 空间复杂度低,只需要O(1)的额外空间
- 交换次数少,每次排序只交换一次
- 不受数据初始顺序影响,时间复杂度始终是O(n²)
缺点:
- 时间复杂度高,不适合大规模数据
- 不稳定排序,相等元素的相对位置可能改变
- 即使数组已经有序,仍然需要进行O(n²)的比较
实现
plain
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>选择排序</title>
</head>
<body>
<script>
// 解法1 选择排序
const optimizedSelectionSort = (arr) => {
const n = arr.length;
// 外层循环控制排序轮数
for(let i=0;i<n-1;i++){
// 假设当前未排序区的第一个元素是最小的
let minIndex = i;
// 内层循环在未排序区找最小元素的索引
for(let j=i+1;j<n;j++){
if(arr[j]<arr[minIndex]){
minIndex=j;
}
}
// 如果找到的最小元素不是当前位置,则交换
if(minIndex!==i){
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
}
return arr;
};
// 示例使用
const arr = [38, 27, 43, 3, 9, 82, 10];
console.log("原始数组:", arr);
console.log("排序后数组:", optimizedSelectionSort(arr));
</script>
</body>
</html>
优化-双向选择排序,减少比较次数
javascript
// 1 优化 -双向选择排序,减少比较次数
const optimizedSelectionSort = (arr) => {
const n = arr.length;
let left = 0;
let right = n - 1;
while(left < right){
let minIndex = left;
let maxIndex = left;
// 优化内层循环,减少比较次数
for (let i = left + 1; i <= right; i++) {
// 一次比较同时更新最小和最大值
if (arr[i] < arr[minIndex]) {
minIndex = i;
} else if (arr[i] > arr[maxIndex]) {
maxIndex = i;
}
}
// 交换最小值到左端
if (minIndex !== left) {
[arr[left], arr[minIndex]] = [arr[minIndex], arr[left]];
// 如果最大值原本在left位置,已经被交换到minIndex位置
if (maxIndex === left) {
maxIndex = minIndex;
}
}
// 交换最大值到右端
if (maxIndex !== right) {
[arr[right], arr[maxIndex]] = [arr[maxIndex], arr[right]];
}
left++;
right--;
return arr;
}
};
添加提前终止条件
在排序的过程之中,有时候我们会遇到后面已经排序的情况,就可以避免重复比对,这个时候可以提前添加终止条件
javascript
// 2 添加提前终止条件
function optimizedSelectionSort(arr) {
const n = arr.length;
let left = 0;
let right = n - 1;
while (left < right) {
let minIndex = left;
let maxIndex = left;
let isSorted = true; // 添加标记,假设已排序
for (let i = left + 1; i <= right; i++) {
// 如果发现逆序对,标记为未排序
if (arr[i] < arr[i - 1]) {
isSorted = false;
}
if (arr[i] < arr[minIndex]) {
minIndex = i;
} else if (arr[i] > arr[maxIndex]) {
maxIndex = i;
}
}
// 如果已经有序,提前终止
if (isSorted) {
break;
}
// 交换最小值到左端
if (minIndex !== left) {
[arr[left], arr[minIndex]] = [arr[minIndex], arr[left]];
if (maxIndex === left) {
maxIndex = minIndex;
}
}
// 交换最大值到右端
if (maxIndex !== right) {
[arr[right], arr[maxIndex]] = [arr[maxIndex], arr[right]];
}
left++;
right--;
}
return arr;
}