数组排序
数组排序是编程中的常见操作,以下是常见的排序方法及其分类:
冒泡排序
冒泡排序是一种简单的交换排序算法 ,通过重复比较相邻元素并交换 (如果顺序错误)来排序数组。其核心思想是每一轮排序将当前未排序部分的最大值(或最小值)"冒泡"到正确位置。
1. 算法步骤
以升序排序为例:
- 比较相邻元素 :从数组的第一个元素开始,依次比较相邻的两个元素(
arr[j]
和arr[j+1]
)。 - 交换(如果逆序) :如果前一个元素比后一个元素大(
arr[j] > arr[j+1]
),则交换它们的位置。 - 重复遍历:每一轮遍历会将当前未排序部分的最大值"冒泡"到数组末尾。
- 缩小范围:每一轮排序后,未排序部分的长度减1(因为最大值已归位)。
- 终止条件:当某一轮未发生任何交换时,说明数组已完全有序,提前终止。
示例(升序排序):
初始数组:[5, 3, 8, 4, 2]
第1轮:
- 比较 5和3 → 交换 → [3, 5, 8, 4, 2]
- 比较 5和8 → 不交换 → [3, 5, 8, 4, 2]
- 比较 8和4 → 交换 → [3, 5, 4, 8, 2]
- 比较 8和2 → 交换 → [3, 5, 4, 2, 8] (最大值8已归位)
第2轮:
- 比较 3和5 → 不交换 → [3, 5, 4, 2, 8]
- 比较 5和4 → 交换 → [3, 4, 5, 2, 8]
- 比较 5和2 → 交换 → [3, 4, 2, 5, 8] (次大值5已归位)
第3轮:
- 比较 3和4 → 不交换 → [3, 4, 2, 5, 8]
- 比较 4和2 → 交换 → [3, 2, 4, 5, 8] (4已归位)
第4轮:
- 比较 3和2 → 交换 → [2, 3, 4, 5, 8] (3已归位,数组完全有序)
2. JS代码实现
基础版本(未优化)
ini
function bubbleSort(arr) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) {
// 每轮将最大的元素放到末尾
// 缩小范围:每一轮排序后,未排序部分的长度减1(因为最大值已归位)
for (let j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 交换相邻元素
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}
// 测试
const array = [64, 34, 25, 12, 22, 11, 90];
console.log(bubbleSort(array)); // [11, 12, 22, 25, 34, 64, 90]
优化版本(提前终止)
ini
function bubbleSortOptimized(arr) {
const n = arr.length;
let swapped;
for (let i = 0; i < n - 1; i++) {
swapped = false;
for (let j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
swapped = true; // 标记本轮发生交换
}
}
// 如果本轮未交换,说明数组已有序
if (!swapped) break;
}
return arr;
}
// 测试
const array = [1, 2, 3, 4, 5]; // 已经有序的数组
console.log(bubbleSortOptimized(array)); // 提前终止,仅需一轮比较
3. 时间复杂度分析
- 最坏情况 (数组完全逆序):需进行
n(n-1)/2
次比较和交换,时间复杂度为 O(n²)。 - 最好情况 (数组已经有序):只需进行
n-1
次比较(无交换),时间复杂度为 O(n)。 - 平均情况:O(n²)。
4.空间复杂度分析
- O(1)(原地排序,仅需常数级额外空间用于交换)
5. 特点总结
- 优点:代码简单,易于实现,适合小规模数据或教学。
- 缺点:效率低(O(n²)),不适合大规模数据。
- 稳定性:稳定(相等元素不会交换相对位置)。
选择排序
1. 基本思想
选择排序是一种原地比较排序算法 ,其核心思想是每次从未排序部分选择最小(或最大)元素,放到已排序部分的末尾。通过不断缩小未排序范围,最终完成整个数组的排序。
2. 算法步骤
- 外层循环 :控制当前需要填入最小元素的位置(从
0
到n-2
)。 - 内层循环:在未排序部分中查找最小元素的索引。
- 交换操作:将找到的最小元素与当前外层循环位置的元素交换。
3. 时间复杂度分析
- 最坏情况 :无论数组是否有序,每次都需要遍历剩余未排序部分,比较次数为
n(n-1)/2
,时间复杂度为 O(n²)。 - 最好情况 :即使数组已经有序,仍需完整比较,时间复杂度仍为 O(n²)。
- 平均情况:O(n²)。
4. 空间复杂度
- O(1)(原地排序,仅需常数级额外空间用于交换)
5. JavaScript 实现
ini
function selectionSort(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 array = [64, 25, 12, 22, 11];
console.log(selectionSort(array)); // [11, 12, 22, 25, 64]
6. 过程演示(以 [29, 10, 14, 37, 13]
为例)
-
第一轮:
- 未排序部分:
[29, 10, 14, 37, 13]
- 找到最小值
10
(索引 1),与29
交换 →[10, 29, 14, 37, 13]
- 未排序部分:
-
第二轮:
- 未排序部分:
[29, 14, 37, 13]
- 找到最小值
13
(索引 4),与29
交换 →[10, 13, 14, 37, 29]
- 未排序部分:
-
第三轮:
- 未排序部分:
[14, 37, 29]
- 最小值
14
已在正确位置,无需交换。
- 未排序部分:
-
第四轮:
- 未排序部分:
[37, 29]
- 找到最小值
29
,与37
交换 →[10, 13, 14, 29, 37]
- 未排序部分:
7. 特点总结
- 优点 :
- 简单直观,交换次数少(最多
n-1
次交换)。 - 适合小规模数据或对内存写入敏感的场景(如闪存存储)。
- 简单直观,交换次数少(最多
- 缺点 :
- 时间复杂度始终为 O(n²),效率低。
- 不稳定(例如对
[5, 5, 2]
排序时,第一个5
会与2
交换,破坏原有顺序)。
插入排序
1. 基本思想
插入排序是一种简单直观的排序算法,其核心思想是:
- 将数组分为"已排序"和"未排序"两部分,逐个将未排序部分的元素插入到已排序部分的正确位置。
- 类似于整理扑克牌时,将新牌插入到手中已排序的牌中的过程。
2. 算法步骤
- 初始化 :
- 假设第一个元素(
arr[0]
)是已排序部分,其余为未排序部分。
- 假设第一个元素(
- 遍历未排序部分 :
- 从
i = 1
到n-1
,依次取出arr[i]
作为待插入元素。
- 从
- 插入到正确位置 :
- 在已排序部分(
arr[0..i-1]
)中从后向前扫描 ,找到arr[i]
的正确位置。 - 若已排序元素大于
arr[i]
,则将该元素后移一位,直到找到插入点。
- 在已排序部分(
- 插入元素 :
- 将
arr[i]
放入正确位置,保持已排序部分始终有序。
- 将
3. 时间复杂度分析
- 最坏情况 (数组完全逆序):每次插入需移动所有已排序元素,比较次数为
n(n-1)/2
,时间复杂度为 O(n²)。 - 最好情况 (数组已有序):每次仅需比较一次,时间复杂度为 O(n)。
- 平均情况:O(n²)。
4. 空间复杂度
- O(1)(原地排序,仅需常数级额外空间用于临时存储待插入元素)。
5. JavaScript 实现
基础版本(升序)
ini
function insertionSort(arr){
const n = arr.length;
for (let i = 1; i < n; i++) {
const current = arr[i]; // 当前待插入元素
let j = i - 1; // 从已排序部分的末尾开始比较
// 在已排序部分中寻找插入位置
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j]; // 元素后移
j--;
}
arr[j + 1] = current; // 插入到正确位置
}
return arr;
}
// 测试
const array = [12, 11, 13, 5, 6];
console.log(insertionSort(array)); // [5, 6, 11, 12, 13]
降序版本
ini
function insertionSortDesc(arr){
const n = arr.length;
for (let i = 1; i < n; i++) {
const current = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] < current) { // 仅修改比较符号
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}
// 测试
console.log(insertionSortDesc([12, 11, 13, 5, 6])); // [13, 12, 11, 6, 5]
6. 过程演示(以 [12, 11, 13, 5, 6]
为例)
- 初始状态 :
- 已排序部分:
[12]
,未排序部分:[11, 13, 5, 6]
- 已排序部分:
- 第一轮(i=1) :
- 取出
11
,与12
比较 →12
后移 → 插入11
→[11, 12, 13, 5, 6]
- 取出
- 第二轮(i=2) :
- 取出
13
,比12
大,无需移动 →[11, 12, 13, 5, 6]
- 取出
- 第三轮(i=3) :
- 取出
5
,依次与13
、12
、11
比较并后移 → 插入5
→[5, 11, 12, 13, 6]
- 取出
- 第四轮(i=4) :
- 取出
6
,依次与13
、12
、11
比较并后移 → 插入6
→[5, 6, 11, 12, 13]
- 取出
7. 特点总结
- 优点 :
- 简单易实现,适合小规模或基本有序的数据。
- 稳定排序(相等元素不会交换顺序)。
- 实际运行效率优于冒泡排序和选择排序(交换次数更少)。
- 缺点 :
- 大规模数据效率低(O(n²))。
希尔排序
1. 基本思想
希尔排序是插入排序的改进版 ,通过将数组分组并对每组进行插入排序,逐步缩小分组间隔(gap),最终实现整体有序。
- 核心思想 :
- 先让数组中距离较远的元素 基本有序,再逐步调整为局部有序,最后用插入排序完成精细化排序。
- 通过减少元素的移动次数,提升插入排序的效率。
2. 算法步骤
- 选择间隔序列(gap sequence) :
- 常见序列:希尔原始序列(
gap = n/2, n/4, ..., 1
),或更优的序列如 Knuth 序列(gap = (3^k - 1)/2
)。
- 常见序列:希尔原始序列(
- 分组插入排序 :
- 对每个
gap
,将数组分为gap
个子序列,分别进行插入排序。
- 对每个
- 逐步缩小
gap
:- 重复上述过程,直到
gap = 1
(即最后一次为标准的插入排序)。
- 重复上述过程,直到
3. 时间复杂度分析
- 最坏情况 :取决于间隔序列的选择,一般为 O(n²)(如使用希尔原始序列)。
- 最优情况 :若数组已部分有序,可接近 O(n log n)(如使用 Sedgewick 序列)。
- 平均情况 :通常介于 O(n log n) 和 O(n²) 之间。
4. 空间复杂度
- O(1)(原地排序,仅需常数级额外空间)。
5. JavaScript 实现
基础版本(希尔原始序列:gap = n/2, n/4, ..., 1)
ini
function shellSort(arr){
const n = arr.length;
let gap = Math.floor(n / 2); // 初始间隔
while (gap > 0) {
// 对每个子序列进行插入排序
for (let i = gap; i < n; i++) {
const temp = arr[i]; // 当前待插入元素
let j = i;
// 在子序列中向前比较并移动元素
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp; // 插入到正确位置
}
gap = Math.floor(gap / 2); // 缩小间隔
}
return arr;
}
// 测试
const array = [12, 34, 54, 2, 3, 9, 8, 7, 1];
console.log(shellSort(array)); // [1, 2, 3, 7, 8, 9, 12, 34, 54]
优化版本(Knuth 序列:gap = (3^k - 1)/2)
ini
function shellSortKnuth(arr){
const n = arr.length;
let gap = 1;
// 计算最大初始间隔(Knuth 序列)
while (gap < n / 3) {
gap = gap * 3 + 1; // 1, 4, 13, 40, 121, ...
}
while (gap > 0) {
for (let i = gap; i < n; i++) {
const temp = arr[i];
let j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
gap = Math.floor((gap - 1) / 3); // 缩小间隔
}
return arr;
}
// 测试
console.log(shellSortKnuth([23, 10, 49, 2, 17, 5])); // [2, 5, 10, 17, 23, 49]
6. 过程演示(以 [12, 34, 54, 2, 3]
为例)
- 初始 gap = 2 :
- 子序列 1(索引 0, 2, 4):
[12, 54, 3]
→ 插入排序后[3, 12, 54]
- 子序列 2(索引 1, 3):
[34, 2]
→ 插入排序后[2, 34]
- 数组变为
[3, 2, 12, 34, 54]
- 子序列 1(索引 0, 2, 4):
- gap = 1 (标准插入排序):
- 对
[3, 2, 12, 34, 54]
排序 →[2, 3, 12, 34, 54]
- 对
7. 特点总结
- 优点 :
- 比简单插入排序高效(尤其是中等规模数据)。
- 原地排序,空间复杂度低。
- 缺点 :
- 时间复杂度依赖间隔序列的选择。
- 不稳定排序(相同元素可能因跨间隔交换而改变顺序)。
归并排序
1. 基本思想
归并排序是一种分治算法(Divide and Conquer),其核心思想是:
- 分解:将数组递归地分成两半,直到每个子数组只有一个元素(自然有序)。
- 合并:将两个已排序的子数组合并成一个有序数组,直到最终完成整个数组的排序。
2. 算法步骤
- 分割阶段 :
- 找到数组的中间位置
mid
,将数组分为左右两部分left
和right
。
- 找到数组的中间位置
- 递归排序 :
- 对
left
和right
分别递归调用归并排序。
- 对
- 合并阶段 :
- 创建一个临时数组,按顺序从
left
和right
中选取较小的元素放入,直到其中一个子数组被完全合并。 - 将剩余元素直接拼接到临时数组的末尾。
- 创建一个临时数组,按顺序从
3. 时间复杂度分析
- 分割阶段 :每次将数组分成两半,共需 O(log n) 层递归。
- 合并阶段:每层需要遍历所有元素(O(n))。
- 总时间复杂度 :O(n log n)(最优、最坏、平均情况均相同)。
4. 空间复杂度
- O(n):合并时需要临时数组存储结果(非原地排序)。
5. JavaScript 实现
递归版本(标准实现)
scss
function mergeSort(arr){
// 递归终止条件:数组长度为1时直接返回
if (arr.length <= 1) return arr;
// 分割数组
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid)); // 递归排序左半部分
const right = mergeSort(arr.slice(mid)); // 递归排序右半部分
// 合并两个有序数组
return merge(left, right);
}
function merge(left, right) {
const result = [];
let i = 0, j = 0;
// 比较两个子数组的元素,按顺序合并
while (i < left.length && j < right.length) {
if (left[i] < right[j]) {
result.push(left[i++]);
} else {
result.push(right[j++]);
}
}
// 将剩余元素拼接到结果数组
return result.concat(left.slice(i)).concat(right.slice(j));
}
// 测试
const array = [38, 27, 43, 3, 9, 82, 10];
console.log(mergeSort(array)); // [3, 9, 10, 27, 38, 43, 82]
优化版本(避免频繁切片)
sql
function mergeSortOptimized(arr, start = 0, end = arr.length - 1) {
if (start >= end) return [arr[start]]; // 单元素数组直接返回
const mid = Math.floor((start + end) / 2);
const left = mergeSortOptimized(arr, start, mid);
const right = mergeSortOptimized(arr, mid + 1, end);
return merge(left, right);
}
// merge函数同上
6. 过程演示(以 [38, 27, 43, 3, 9, 82, 10]
为例)
- 分割 :
- 第一次分割:
[38, 27, 43, 3]
和[9, 82, 10]
- 递归分割左半部分:
[38, 27]
和[43, 3]
→ 继续分割为单元素数组。 - 递归分割右半部分:
[9]
和[82, 10]
→[82]
和[10]
。
- 第一次分割:
- 合并 :
- 合并
[38]
和[27]
→[27, 38]
- 合并
[43]
和[3]
→[3, 43]
- 合并
[27, 38]
和[3, 43]
→[3, 27, 38, 43]
- 合并
[82]
和[10]
→[10, 82]
- 合并
[9]
和[10, 82]
→[9, 10, 82]
- 最终合并
[3, 27, 38, 43]
和[9, 10, 82]
→[3, 9, 10, 27, 38, 43, 82]
- 合并
7. 特点总结
- 优点 :
- 时间复杂度稳定为 O(n log n),适合大规模数据。
- 稳定排序(合并时保留相等元素的原始顺序)。
- 缺点 :
- 需要 O(n) 额外空间(非原地排序)。
- 递归调用可能引发栈溢出(极深递归时)
快速排序
1. 基本思想
快速排序是一种分治算法(Divide and Conquer),其核心思想是:
- 选择基准(Pivot):从数组中选择一个元素作为基准值。
- 分区(Partition) :将数组分为两部分,使得:
- 左侧所有元素 ≤ 基准值,
- 右侧所有元素 ≥ 基准值。
- 递归排序:对左右子数组递归调用快速排序。
2. 算法步骤
- 基准选择:通常选择第一个、最后一个或随机元素作为基准(以下实现选择最后一个元素)。
- 分区操作 :
- 使用双指针(
i
和j
),i
指向小于基准的区域的末尾。 - 遍历数组,将小于基准的元素交换到
i
的位置,并移动i
。
- 使用双指针(
- 放置基准 :将基准值放到
i
的最终位置,此时基准已处于正确位置。 - 递归调用:对基准左侧和右侧的子数组重复上述过程。
3. 时间复杂度分析
- 最优情况 :每次分区均匀,递归树高度为 log n,时间复杂度为 O(n log n)。
- 最坏情况 :每次分区极度不均(如数组已有序且选择首尾为基准),时间复杂度为 O(n²)。
- 平均情况:O(n log n)。
4. 空间复杂度
- O(log n):递归调用栈的深度(最坏情况下 O(n))。
5. JavaScript 实现
基础版本(Lomuto 分区方案)
scss
function quickSort(arr, left = 0, right = arr.length - 1) {
if (left < right) {
const pivotIndex = partition(arr, left, right); // 获取基准位置
quickSort(arr, left, pivotIndex - 1); // 递归排序左子数组
quickSort(arr, pivotIndex + 1, right); // 递归排序右子数组
}
return arr;
}
function partition(arr, left, right) {
const pivot = arr[right]; // 选择最后一个元素作为基准
let i = left; // i 指向小于基准的区域的末尾
for (let j = left; j < right; j++) {
if (arr[j] < pivot) {
[arr[i], arr[j]] = [arr[j], arr[i]]; // 交换小于基准的元素
i++;
}
}
[arr[i], arr[right]] = [arr[right], arr[i]]; // 将基准放到正确位置
return i; // 返回基准索引
}
// 测试
const array = [10, 80, 30, 90, 40, 50, 70];
console.log(quickSort(array)); // [10, 30, 40, 50, 70, 80, 90]
优化版本(Hoare 分区方案 + 随机基准)
scss
function quickSortOptimized(arr, left = 0, right = arr.length - 1) {
if (left < right) {
const pivotIndex = hoarePartition(arr, left, right);
quickSortOptimized(arr, left, pivotIndex); // 注意边界与 Lomuto 不同
quickSortOptimized(arr, pivotIndex + 1, right);
}
return arr;
}
function hoarePartition(arr, left, right) {
const randomIndex = Math.floor(Math.random() * (right - left + 1)) + left;
[arr[left], arr[randomIndex]] = [arr[randomIndex], arr[left]]; // 随机选择基准
const pivot = arr[left];
let i = left - 1, j = right + 1;
while (true) {
do { i++; } while (arr[i] < pivot);
do { j--; } while (arr[j] > pivot);
if (i >= j) return j;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
// 测试
console.log(quickSortOptimized([3, 0, 2, 5, -1, 4, 1])); // [-1, 0, 1, 2, 3, 4, 5]
6. 过程演示(以 [10, 80, 30, 90, 40, 50, 70]
为例)
- 初始调用 :
quickSort(arr, 0, 6)
,基准pivot = 70
(最后一个元素)。 - 分区过程 :
i = 0
,遍历j
从0
到5
:10 < 70
→ 交换arr[0]
与自身,i++
→i = 1
80 > 70
→ 跳过30 < 70
→ 交换arr[1]
和arr[2]
→[10, 30, 80, 90, 40, 50, 70]
,i = 2
- 类似处理剩余元素 → 最终
i = 4
- 交换
arr[4]
和pivot
→[10, 30, 40, 50, 70, 80, 90]
,返回pivotIndex = 4
。
- 递归排序 :
- 左子数组
[10, 30, 40, 50]
,右子数组[80, 90]
。
- 左子数组
7. 特点总结
- 优点 :
- 平均情况下效率极高(O(n log n)),实际运行速度快于归并排序和堆排序。
- 原地排序(空间复杂度低)。
- 缺点 :
- 不稳定排序(分区时可能改变相等元素的顺序)。
- 最坏情况下退化为 O(n²)(可通过随机化基准避免)。