🔥快速排序:分治思想的完美演绎,从原理到实战全解析!
🔥为了更好的让大家理解算法这里推荐一个算法可视化的网站https://staying.fun/zh/features/algorithm-visualize
复制文章中JavaScript代码示例到这个网站上就可以看到可视化算法运算的过程了!大家快点来试试吧!!!!
一、算法原理:分治思想的核心体现
快速排序是一种基于分治思想的高效排序算法,其核心思想是通过选择一个 “枢纽元”(pivot)将数组分成两部分,左边的元素都小于等于枢纽元,右边的元素都大于等于枢纽元,然后递归地对左右两部分进行排序,最终实现整个数组的有序排列。
1.1 算法步骤
选择枢纽元:通常选择数组的最后一个元素,也可以优化为三数取中法(取左、中、右三个元素的中间值)。
分区操作:使用双指针法将数组划分为左右两部分。
递归排序:对左右子数组重复上述步骤,直到子数组长度为 1。💡 类比场景:整理书架时,先选一本书作为基准,将比它薄的放左边,厚的放右边,再分别整理左右两堆。
二、JavaScript 代码实现与注释
2.1 原地分区(空间复杂度 O (log n))
原地分区是快速排序优化空间复杂度的关键,在原数组上直接操作,减少额外空间开销。
function partition(arr, left, right) {
// 选择最右侧元素作为基准值pivot
const pivot = arr[right];
// i为小于pivot的元素的边界,初始时指向最左侧
let i = left;
// 遍历除了pivot外的所有元素
for (let j = left; j < right; j++) {
// 如果当前元素小于或等于pivot
if (arr[j] <= pivot) {
// 交换arr[i]和arr[j],并将i右移一位,保持i左侧的元素都小于等于pivot
[arr[i], arr[j]] = [arr[j], arr[i]];
i++;
}
}
// 最后将pivot(arr[right])与arr[i]交换,使得pivot位于正确的位置上
[arr[i], arr[right]] = [arr[right], arr[i]];
// 返回pivot的最终位置索引
return i;
}
function quickSort(arr, left = 0, right = arr.length - 1) {
// 如果左指针小于右指针,说明还有未排序的区间
if (left < right) {
// 调用分区函数,返回pivot的索引,完成一次分区
const pivotIndex = partition(arr, left, right);
// 对pivot左边的子数组进行快速排序
quickSort(arr, left, pivotIndex - 1);
// 对pivot右边的子数组进行快速排序
quickSort(arr, pivotIndex + 1, right);
}
// 返回排序后的数组,实际上由于是原地排序,此处return并非必要
return arr;
}
// 测试
const unsortedArray = [3, 6, 8, 10, 1, 2, 1];
console.log("Unsorted:", unsortedArray);
const sortedArray = quickSort(unsortedArray);
console.log("Sorted:", sortedArray);
2.2 非原地分区(空间复杂度 O (n))
非原地分区实现相对直观,通过创建新数组来存储分区结果。
function quickSortNonInPlace(arr) {
// 递归结束条件:数组长度小于2,说明已经有序
if (arr.length < 2) return arr;
// 选择数组的第一个元素作为基准值pivot
const pivot = arr[0];
// 定义left数组存储小于pivot的元素
const left = [];
// 定义right数组存储大于pivot的元素
const right = [];
// 遍历数组,从第二个元素开始
for (let i = 1; i < arr.length; i++) {
// 如果当前元素小于pivot,放入left数组
if (arr[i] < pivot) {
left.push(arr[i]);
} else {
// 否则放入right数组
right.push(arr[i]);
}
}
// 递归地对left和right数组进行快速排序,并将结果与pivot合并
return [...quickSortNonInPlace(left), pivot, ...quickSortNonInPlace(right)];
}
// 测试
const unsortedArray2 = [3, 6, 8, 10, 1, 2, 1];
console.log("Unsorted:", unsortedArray2);
const sortedArray2 = quickSortNonInPlace(unsortedArray2);
console.log("Sorted:", sortedArray2);
通过上述两种实现方式,可以清晰看到快速排序在不同分区策略下的代码逻辑和性能特点。原地分区在空间利用上更高效,适合处理大规模数据;非原地分区则更易于理解和实现,在对空间复杂度要求不高时是不错的选择。
三、算法复杂度与特性
时间复杂度
平均情况:O (n log n)。平均而言,快速排序通过不断地将数组对半分割,递归深度为 log n,每层操作次数为 n,所以平均时间复杂度为 O (n log n) 。例如,对一个包含 1000 个元素的数组进行排序,平均情况下,其操作次数量级在 1000 * log 1000 左右。
最坏情况:O (n²)。当每次选择的枢纽元都是最大或最小元素时,比如数组已经有序,每次划分只能减少一个元素,快速排序就退化为冒泡排序,时间复杂度变为 O (n²)。例如,对已经有序的数组 [1, 2, 3, 4, 5] 进行排序,若每次选择第一个元素作为枢纽元,就会出现这种最坏情况。
最好情况:O (n log n)。理想情况下,每次选择的枢纽元都能将数组均匀分成两半,递归深度为 log n,每层处理 n 个元素,时间复杂度为 O (n log n)。
空间复杂度:O (log n)。主要是递归调用栈的空间开销。在最好和平均情况下,递归树深度为 log n,所以空间复杂度为 O (log n);最坏情况下,递归深度为 n,空间复杂度为 O (n) 。例如,对于一个长度为 100 的数组,在平均情况下,递归栈空间占用约 log 100 量级;而在最坏情况,可能达到 100。
稳定性:不稳定排序。在排序过程中,相同元素的相对顺序可能会改变。例如,数组 [3, 3, 1],以第一个 3 为枢纽元进行分区时,两个 3 的相对顺序可能发生变化。
适用场景:适用于大数据量的内部排序,因为平均时间复杂度低且不需要额外空间(原地分区时)。在实际应用中,如数据库索引构建、文件系统排序、编译器优化等场景,快速排序都能发挥其高效的特性。
四、关键优化点
三数取中法:选取数组的左、中、右三个位置的元素,将这三个元素排序后取中间值作为枢纽元。例如,对于数组[5, 9, 2, 7, 1]
,左元素是 5,中间元素(索引为数组长度整除 2,这里是 9),右元素是 1 ,排序后[1, 5, 9]
,取中间值 5 作为枢纽元。这样能避免选择到最大或最小值作为枢纽元,减少出现最坏情况(时间复杂度为 O (n²))的概率。
function getPivot(arr, left, right) {
let mid = Math.floor((left + right) / 2);
if ((arr[left] <= arr[mid] && arr[mid] <= arr[right]) || (arr[right] <= arr[mid] && arr[mid] <= arr[left])) {
return mid;
} else if ((arr[mid] <= arr[left] && arr[left] <= arr[right]) || (arr[right] <= arr[left] && arr[left] <= arr[mid])) {
return left;
} else {
return right;
}
}
function partitionWithMedian(arr, left, right) {
let pivotIndex = getPivot(arr, left, right);
let pivot = arr[pivotIndex];
[arr[pivotIndex], arr[right]] = [arr[right], arr[pivotIndex]];
let i = left;
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;
}
function optimizedQuickSort(arr, left = 0, right = arr.length - 1) {
if (left < right) {
let pivotIndex = partitionWithMedian(arr, left, right);
optimizedQuickSort(arr, left, pivotIndex - 1);
optimizedQuickSort(arr, pivotIndex + 1, right);
}
return arr;
}
// 测试
const unsortedArray3 = [3, 6, 8, 10, 1, 2, 1];
console.log("Unsorted:", unsortedArray3);
const sortedArray3 = optimizedQuickSort(unsortedArray3);
console.log("Sorted:", sortedArray3);
小数组优化:当子数组长度小于阈值(如 3)时,改用插入排序。因为在小数组上,插入排序的常数时间开销较小,性能比快速排序更优。比如,对于数组[3, 2, 1]
,使用插入排序可能只需几次比较和交换就能完成排序,而快速排序的递归调用开销相对较大。
function insertionSort(arr, left, right) {
for (let i = left + 1; i <= right; i++) {
let temp = arr[i];
let j = i - 1;
while (j >= left && arr[j] > temp) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = temp;
}
return arr;
}
function quickSortWithInsertion(arr, left = 0, right = arr.length - 1) {
if (right - left <= 3) {
return insertionSort(arr, left, right);
}
if (left < right) {
let pivotIndex = partition(arr, left, right);
quickSortWithInsertion(arr, left, pivotIndex - 1);
quickSortWithInsertion(arr, pivotIndex + 1, right);
}
return arr;
}
// 测试
const unsortedArray4 = [3, 6, 8, 10, 1, 2, 1];
console.log("Unsorted:", unsortedArray4);
const sortedArray4 = quickSortWithInsertion(unsortedArray4);
console.log("Sorted:", sortedArray4);
处理重复元素:在分区时将等于枢纽元的元素均匀分布到左右两侧。可以使用三路快排,将数组分为小于、等于、大于枢纽元的三个部分,避免重复元素集中在一侧导致的不平衡划分。例如,对于数组[3, 3, 1, 4, 3]
,使用三路快排能将等于 3 的元素均匀分布,减少递归深度,提高效率。
function threeWayPartition(arr, left, right) {
let pivot = arr[right];
let lt = left; // 小于pivot的元素的索引
let gt = right; // 大于pivot的元素的索引
let i = left;
while (i <= gt) {
if (arr[i] < pivot) {
[arr[i], arr[lt]] = [arr[lt], arr[i]];
lt++;
i++;
} else if (arr[i] > pivot) {
[arr[i], arr[gt]] = [arr[gt], arr[i]];
gt--;
} else {
i++;
}
}
return [lt, gt];
}
function threeWayQuickSort(arr, left = 0, right = arr.length - 1) {
if (left < right) {
let [lt, gt] = threeWayPartition(arr, left, right);
threeWayQuickSort(arr, left, lt - 1);
threeWayQuickSort(arr, gt + 1, right);
}
return arr;
}
// 测试
const unsortedArray5 = [3, 6, 3, 10, 1, 3, 1];
console.log("Unsorted:", unsortedArray5);
const sortedArray5 = threeWayQuickSort(unsortedArray5);
console.log("Sorted:", sortedArray5);
五、典型应用场景
大数据量排序:当数据量巨大,超出内存容量时,快速排序的原地排序特性(原地分区实现)使其优势凸显。它无需额外的大量内存来存储中间结果,直接在原数组上操作 。比如在处理大规模的日志文件排序时,日志记录数量可能达到千万甚至亿级,快速排序可以高效地对这些数据进行排序,减少磁盘 I/O 操作和内存开销。
内部排序:在数据库索引构建中,快速排序用于对索引数据进行排序,以提高数据检索的效率。例如,在关系型数据库中,对某个表按照某个字段建立索引时,需要对该字段的数据进行排序,快速排序能快速完成这一任务,使得后续的查询操作可以利用索引快速定位数据,提升查询性能。在文件系统目录排序中,快速排序可以按照文件名、文件大小、修改时间等属性对文件和目录进行排序,方便用户管理和查找文件 。
算法竞赛:在算法竞赛中,快速排序是解决排序问题的首选算法之一。由于其平均时间复杂度低,代码实现相对简洁,在面对各种排序相关的题目时,能够快速编写代码并高效地处理数据 。例如,在给定一组学生成绩,要求按照成绩从高到低排序并输出前几名学生信息的竞赛题目中,快速排序就能发挥其优势,快速准确地完成排序任务。
六、避坑指南
在实现快速排序时,有几个常见的陷阱需要注意,以确保代码的正确性和高效性。
数组越界:在分区操作中,双指针需要在正确的范围内移动,避免访问到数组边界之外的元素。在partition
函数中,指针i
和j
的移动必须保证在数组的有效索引范围内,否则会导致数组越界错误 。比如在使用双指针进行分区时,如果没有正确判断指针是否越界,当j
指针移动到小于 0 或者i
指针移动到大于数组长度时,就会出现数组越界异常。
基准选择:避免选择已排序数组的端点作为枢纽元,否则可能导致最坏情况的时间复杂度。如果总是选择数组的第一个或最后一个元素作为枢纽元,当数组已经有序时,每次分区都只能将数组分成一个元素和其余元素两部分,快速排序就会退化为冒泡排序,时间复杂度变为 O (n²) 。比如对于已经有序的数组[1, 2, 3, 4, 5]
,若每次都选择第一个元素 1 作为枢纽元,就会出现这种最坏情况。
递归深度:在处理大规模数据或极端情况(如完全逆序数组)时,递归调用可能导致栈溢出。对于非常大的数组,如果递归深度过深,超过了系统栈的限制,就会引发栈溢出错误 。例如,对一个包含 10 万个元素的完全逆序数组进行排序,递归深度可能会达到 10 万,很容易导致栈溢出。可以考虑改用迭代实现,或者设置递归深度限制,避免这种情况的发生。
七、总结
快速排序是一种高效的排序算法,通过分治思想和双指针技巧实现平均 O (n log n) 的时间复杂度。掌握其核心原理和优化策略,能够在实际开发中应对各种排序需求。你在哪些场景用过快速排序?欢迎在评论区交流!👇# 排序算法 #快速排序 #JavaScript #算法可视化