作为一名有着十年开发经验的程序员,我深知排序算法在计算机科学中的基础地位。排序算法不仅是算法学习的"Hello World",更是面试中的常客和实际开发中的必备技能。今天,我将用最通俗易懂的方式,带大家深入理解六大经典比较排序算法。
排序算法看似简单,但其中蕴含着丰富的计算机科学思想。从时间复杂度O(n²)的简单排序到O(n log n)的高效算法,每一种排序方法都有其独特的应用场景和优化空间。在实际工作中,根据数据规模、数据特点和性能要求选择合适的排序算法,是程序员必备的能力。
在深入每个算法之前,我们先整体了解这六大比较排序算法的基本特性:
| 算法名称 | 时间复杂度 | 空间复杂度 | 稳定性 | 核心思想 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(1) | 稳定 | 相邻元素比较交换,大的元素逐渐"冒泡"到最后 |
| 选择排序 | O(n²) | O(1) | 不稳定 | 每次选择最小元素放到已排序部分的末尾 |
| 插入排序 | O(n²) | O(1) | 稳定 | 将未排序元素插入到已排序部分的适当位置 |
| 希尔排序 | O(n^1.3) | O(1) | 不稳定 | 改进的插入排序,通过分组减少元素移动次数 |
| 归并排序 | O(n log n) | O(n) | 稳定 | 分治法,将数组分成两半分别排序后合并 |
| 快速排序 | O(n log n) | O(log n) | 不稳定 | 选取基准元素,将数组分成小于和大于基准的两部分 |
在实际应用中,选择排序算法需要考虑以下几个因素:
冒泡排序是最容易理解的排序算法之一。它的核心思想就像汽水中的气泡,较轻的元素会逐渐"浮"到数组的顶端。具体实现是通过多次遍历数组,每次比较相邻的两个元素,如果顺序不对就交换它们。
javascript复制function bubbleSort(arr) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) {
let 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;
}
冒泡排序的时间复杂度在最坏和平均情况下都是O(n²),最好情况下(数组已有序)是O(n)。空间复杂度为O(1),因为它是原地排序。
优化点:
注意:虽然冒泡排序简单易懂,但在实际开发中几乎不会使用,因为它的性能较差。但在某些特定场景下,如数据基本有序时,经过优化的冒泡排序可能比其他复杂算法表现更好。
选择排序的核心思想是"选择":每次从未排序部分选择最小(或最大)的元素,放到已排序部分的末尾。这种算法比冒泡排序的交换次数少,通常性能稍好。
javascript复制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;
}
选择排序的时间复杂度始终是O(n²),无论数据是否有序。它进行O(n)次交换,比冒泡排序的O(n²)次交换要好。空间复杂度为O(1)。
适用场景:
插入排序的工作方式类似于整理扑克牌。它将数组分为已排序和未排序两部分,每次从未排序部分取出一个元素,插入到已排序部分的正确位置。
javascript复制function insertionSort(arr) {
const n = arr.length;
for (let i = 1; i < n; i++) {
const 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;
}
插入排序在最好情况下(数组已有序)时间复杂度为O(n),平均和最坏情况下为O(n²)。对于小规模或基本有序的数据,插入排序非常高效。
希尔排序是插入排序的改进版本,通过引入间隔序列,让元素可以一次移动多位,从而减少总的移动次数。
javascript复制function shellSort(arr) {
const n = arr.length;
for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
for (let i = gap; i < n; i++) {
const temp = arr[i];
let j = i;
while (j - gap >= 0 && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
return arr;
}
归并排序采用分治策略,将数组分成两半,分别排序后再合并。它的核心是merge操作,将两个有序数组合并成一个有序数组。
javascript复制function mergeSort(arr) {
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]);
i++;
} else {
result.push(right[j]);
j++;
}
}
return result.concat(left.slice(i)).concat(right.slice(j));
}
归并排序的时间复杂度始终是O(n log n),空间复杂度为O(n)。它是稳定的排序算法,适合以下场景:
快速排序也采用分治策略,但它先进行划分(partition)操作,再进行递归。选择一个基准元素,将数组分成小于基准和大于基准的两部分,然后对两部分递归排序。
javascript复制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[left];
let i = left;
for (let j = left + 1; j <= right; j++) {
if (arr[j] < pivot) {
i++;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
[arr[left], arr[i]] = [arr[i], arr[left]];
return i;
}
快速排序的平均时间复杂度为O(n log n),最坏情况下(如数组已有序)为O(n²)。通过合理选择基准(如三数取中法)可以避免最坏情况。
优化技巧:
问题1:颜色分类(LeetCode 75)
这是一个典型的可以使用改进的快速排序partition思想解决的问题。我们可以使用三指针法,一次遍历完成排序。
javascript复制function sortColors(nums) {
let low = 0, high = nums.length - 1;
let i = 0;
while (i <= high) {
if (nums[i] === 0) {
[nums[i], nums[low]] = [nums[low], nums[i]];
low++;
i++;
} else if (nums[i] === 2) {
[nums[i], nums[high]] = [nums[high], nums[i]];
high--;
} else {
i++;
}
}
}
问题2:数组中的第K个最大元素(LeetCode 215)
这个问题可以使用快速选择算法,它是快速排序的变种,平均时间复杂度为O(n)。
javascript复制function findKthLargest(nums, k) {
const n = nums.length;
const target = n - k;
let left = 0, right = n - 1;
while (left <= right) {
const pivotIndex = partition(nums, left, right);
if (pivotIndex === target) {
return nums[pivotIndex];
} else if (pivotIndex < target) {
left = pivotIndex + 1;
} else {
right = pivotIndex - 1;
}
}
}
在实际工程中选择排序算法时,需要考虑以下因素:
当发现排序性能不符合预期时,可以检查以下几点:
编写排序算法时需要注意的边界条件:
现代JavaScript引擎(如V8)的Array.prototype.sort()通常使用混合排序策略:
生产环境的排序函数需要考虑更多因素:
javascript复制function productionSort(arr, compareFn = (a, b) => a - b) {
if (!Array.isArray(arr)) {
throw new TypeError('Input must be an array');
}
// 对于小数组使用插入排序
if (arr.length <= 10) {
return insertionSort(arr, compareFn);
}
// 对于大数组使用快速排序
return quickSort(arr, compareFn);
}
function insertionSort(arr, compareFn) {
const n = arr.length;
for (let i = 1; i < n; i++) {
const key = arr[i];
let j = i - 1;
while (j >= 0 && compareFn(arr[j], key) > 0) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
}
function quickSort(arr, compareFn, left = 0, right = arr.length - 1) {
if (left < right) {
const pivotIndex = partition(arr, compareFn, left, right);
quickSort(arr, compareFn, left, pivotIndex - 1);
quickSort(arr, compareFn, pivotIndex + 1, right);
}
return arr;
}
function partition(arr, compareFn, left, right) {
const pivot = arr[left];
let i = left;
for (let j = left + 1; j <= right; j++) {
if (compareFn(arr[j], pivot) < 0) {
i++;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
[arr[left], arr[i]] = [arr[i], arr[left]];
return i;
}
为确保排序函数的正确性,需要设计全面的测试用例:
javascript复制function testSortAlgorithm(sortFn) {
// 测试用例
const testCases = [
{ input: [], expected: [] },
{ input: [1], expected: [1] },
{ input: [2, 1], expected: [1, 2] },
{ input: [3, 1, 2], expected: [1, 2, 3] },
{ input: [1, 2, 3, 4, 5], expected: [1, 2, 3, 4, 5] },
{ input: [5, 4, 3, 2, 1], expected: [1, 2, 3, 4, 5] },
{ input: [3, 1, 4, 1, 5, 9, 2, 6, 5], expected: [1, 1, 2, 3, 4, 5, 5, 6, 9] },
{ input: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], expected: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] },
{ input: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1], expected: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }
];
testCases.forEach(({ input, expected }, index) => {
const result = sortFn([...input]);
const passed = JSON.stringify(result) === JSON.stringify(expected);
console.log(`Test case ${index + 1}: ${passed ? 'PASSED' : 'FAILED'}`);
if (!passed) {
console.log(` Input: ${input}`);
console.log(` Expected: ${expected}`);
console.log(` Received: ${result}`);
}
});
// 性能测试
const largeArray = Array.from({ length: 10000 }, () => Math.floor(Math.random() * 10000));
console.time('Sort performance');
sortFn([...largeArray]);
console.timeEnd('Sort performance');
}
// 测试我们的productionSort函数
testSortAlgorithm(productionSort);
虽然本文重点是比较排序,但值得一提的是非比较排序(如计数排序、桶排序、基数排序)可以在特定条件下突破O(n log n)的时间复杂度下限:
现代计算机多核普及,利用并行计算可以显著提高排序速度:
当数据量太大无法全部装入内存时,需要使用外部排序:
javascript复制// 表格排序示例
function sortTable(columnIndex, ascending = true) {
const table = document.getElementById('data-table');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((rowA, rowB) => {
const cellA = rowA.cells[columnIndex].textContent;
const cellB = rowB.cells[columnIndex].textContent;
// 简单实现:假设内容是数字
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
if (!isNaN(numA) && !isNaN(numB)) {
return ascending ? numA - numB : numB - numA;
}
// 如果是文本,按字符串比较
return ascending
? cellA.localeCompare(cellB)
: cellB.localeCompare(cellA);
});
// 清空并重新添加排序后的行
tbody.innerHTML = '';
rows.forEach(row => tbody.appendChild(row));
}
排序算法不仅仅是实用的工具,它们还体现了许多重要的计算机科学思想:
理解这些底层思想,比单纯记忆算法实现更为重要。它们可以帮助我们解决更广泛的计算机科学问题,设计更高效的算法和系统。