[特殊字符]算法详解——快速排序:分治思想的完美演绎,从原理到实战全解析!

🔥快速排序:分治思想的完美演绎,从原理到实战全解析!

🔥为了更好的让大家理解算法这里推荐一个算法可视化的网站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函数中,指针ij的移动必须保证在数组的有效索引范围内,否则会导致数组越界错误 。比如在使用双指针进行分区时,如果没有正确判断指针是否越界,当j指针移动到小于 0 或者i指针移动到大于数组长度时,就会出现数组越界异常。

基准选择:避免选择已排序数组的端点作为枢纽元,否则可能导致最坏情况的时间复杂度。如果总是选择数组的第一个或最后一个元素作为枢纽元,当数组已经有序时,每次分区都只能将数组分成一个元素和其余元素两部分,快速排序就会退化为冒泡排序,时间复杂度变为 O (n²) 。比如对于已经有序的数组[1, 2, 3, 4, 5],若每次都选择第一个元素 1 作为枢纽元,就会出现这种最坏情况。

递归深度:在处理大规模数据或极端情况(如完全逆序数组)时,递归调用可能导致栈溢出。对于非常大的数组,如果递归深度过深,超过了系统栈的限制,就会引发栈溢出错误 。例如,对一个包含 10 万个元素的完全逆序数组进行排序,递归深度可能会达到 10 万,很容易导致栈溢出。可以考虑改用迭代实现,或者设置递归深度限制,避免这种情况的发生。

七、总结

快速排序是一种高效的排序算法,通过分治思想和双指针技巧实现平均 O (n log n) 的时间复杂度。掌握其核心原理和优化策略,能够在实际开发中应对各种排序需求。你在哪些场景用过快速排序?欢迎在评论区交流!👇# 排序算法 #快速排序 #JavaScript #算法可视化

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

PGFA

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值