作为一名对技术充满热情的学习者,我一直以来都深刻地体会到知识的广度和深度。在这个不断演变的数字时代,我远非专家,而是一位不断追求进步的旅行者。通过这篇博客,我想分享我在某个领域的学习经验,与大家共同探讨、共同成长。请大家以开放的心态阅读,相信你们也会在这段知识之旅中找到启示。
前言
学习快速排序,最重要的就是学会分治思想,对于pivot的选择有很多种,这让我们产生了如何使用更加合适的pivot时代码变得更加高效,下面我们就开始谈谈快速排序。
一、什么是快速排序
快速排序是一个高效的排序算法,由C.A.R. Hoare在1960年提出。因其平均时间复杂度为O(n log n)而被广泛使用,并且还具有原地排序(不需要额外大量的内存空间)的优点。这种算法的基本思想是分治策略,通过一个称为"pivot"(中枢或枢轴)的元素来将原数组分割成两个子数组。
快速排序的基本步骤包括:
- 选择pivot(枢轴元素):
从数组中选择一个元素作为pivot。选择方法有多种,例如选择第一个元素、最后一个元素、中间的元素,或者随机选择一个元素。选择不同的pivot可能会影响算法的性能。 - 分区操作:
重新排列数组,使得所有比pivot小的元素都排在pivot的前面,而所有比pivot大的元素都排在后面。这一步结束后,pivot就位于其最终排序后的位置。 - 递归排序子数组:
递归地在pivot左侧和右侧的子数组上重复前面的步骤,直到子数组的大小缩减到1或0,此时算法结束。
在最坏的情况下,例如当输入数组已经是排序状态或逆排序状态时,每次分区只缩减一个元素,这使得快速排序的时间复杂度退化为O(n^2)。然而,通过随机选择pivot,可以降低这种最坏情况发生的概率,使得快速排序在平均情况下展现出良好性能。
快速排序通常被认为在实际应用中比其他O(n log n)排序算法,如归并排序或堆排序,更快。因为其内部循环(inner
loop)可以有效地在多种现代架构上实现。尽管如此,在选择快速排序的实现时,还应考虑数据的特点以及实际性能表现。
二、三数取中
这里的数组是 {8, 3, 1, 7, 0, 10, 2}
,我们将使用“三数取中”方法来选择pivot,这在实践中是一个比较好的折衷方法。
在“三数取中”方法中,我们将比较数组的第一个元素、中间元素和最后一个元素,然后选择这三个数的中位数作为pivot。在我们的示例数组中,这将是 8
(第一个元素),7
(中间元素),和 2
(最后一个元素),所以pivot将是 7
。
以下是Java代码演示了如何对这个数组执行快速排序:
public class QuickSortExample { public static void quickSort(int[] arr, int low, int high) { if (low < high) { // 找到pivot所在位置 int pivotIndex = partition(arr, low, high); // 对pivot左边的数组递归排序 quickSort(arr, low, pivotIndex - 1); // 对pivot右边的数组递归排序 quickSort(arr, pivotIndex + 1, high); } } private static int partition(int[] arr, int low, int high) { // “三数取中”作为pivot int pivot = medianOfThree(arr, low, high); // 将pivot移到数组末尾 swap(arr, high, medianOfThreeIndex(arr, low, high)); int border = low - 1; // 边界 for (int i = low; i < high; i++) { // 所有小于pivot的元素移到左边 if (arr[i] < pivot) { border++; swap(arr, i, border); } } // 把pivot换回合适位置 swap(arr, border + 1, high); // 返回pivot位置 return border + 1; } private static int medianOfThree(int[] arr, int low, int high) { int middle = low + (high - low) / 2; int a = arr[low]; int b = arr[middle]; int c = arr[high]; if ((a - b) * (c - a) >= 0) return a; else if ((b - a) * (c - b) >= 0) return b; else return c; } private static int medianOfThreeIndex(int[] arr, int low, int high) { int middle = low + (high - low) / 2; int a = arr[low]; int b = arr[middle]; int c = arr[high]; if ((a - b) * (c - a) >= 0) return low; else if ((b - a) * (c - b) >= 0) return middle; else return high; } // 交换数组中的两个元素 private static void swap(int[] arr, int index1, int index2) { int temp = arr[index1]; arr[index1] = arr[index2]; arr[index2] = temp; } public static void main(String[] args) { int[] array = {8, 3, 1, 7, 0, 10, 2}; quickSort(array, 0, array.length - 1); for (int value : array) { System.out.print(value + " "); } } }
执行这段代码后,打印在控制台的应该是已经排序好的数组 0 1 2 3 7 8 10
。代码示例中使用了递归和分治的方法,将问题简化,在每一步将数组划分为小于pivot和大于pivot的两个部分,然后递归排序这两部分。
三、考研例题
题目:考虑以下未排序的数组:
15, 22, 13, 27, 12, 10, 20, 25
使用快速排序算法对上述数组进行排序。请手动执行快速排序的第一次分区操作,并给出以下分区后的数组状态以及枢轴的最终位置。
在此问题中,我们规定每次分区操作的枢轴选择数组的最后一个元素。
分步解答:
- 初始化数组和枢轴(pivot):
[15, 22, 13, 27, 12, 10, 20, 25] Pivot = 25
- 设置左指针
left
和右指针right
:
left = 0 (指向数组的第一个元素) right = 6 (指向枢轴的前一个元素)
- 执行分区操作,移动左右指针,并在必要时交换元素。我们按顺序遍历,直到
left
指针大于right
指针停止。
在第一个步骤中,遍历到left = 5
和right = 0
,由于12
小于25
,我们不需要对它们进行交换。此时,数组的状态仍然相同。
[15, 22, 13, 27, 12, 10, 20, 25] left -> ^ right -> ^
- 在第二次遍历中,
left
指针挪到值为15
的位置,而right
指针仍然指向15
。由于15
小于枢轴,left
继续向前移动,而right
不动。 - 继续进行,
left
指针移动到值为22
的位置。由于22
小于25
,left
继续向前移动。 - 当
left
指针达到值为27
时,该值大于枢轴值,right
仍然指向值为15
的位置。 - 移动
right
指针向左,找到第一个小于枢轴25
的元素(20
),然后交换left
指针和right
指针的值。
交换后的数组:
[15, 20, 13, 12, 10, 22, 27, 25] left -> ^ right -> ^
- 注意到现在
left
和right
都指向了22
。 - 因为
left
和right
相遇了,分区操作结束。根据快速排序算法的规则,枢轴值(25
)应该和left
(或者right
,因为它们在同一位置)指针的值交换位置。
最终数组的状态:
[15, 20, 13, 12, 10, 22, 25, 27] ^ pivot
在这个分区操作之后,枢轴值 25
的新索引位置是 6
。数组被分成两部分:索引 0
至 5
和索引 7
的元素。注意,现在枢轴值 25
正在其最终排列中的正确位置。递归地对左右两侧的子数组进行相同的分区操作,直到整个数组排序完毕。
请注意,这只是考试中可能遇到的快速排序手动执行的一种简单示例。在不同版本的快速排序中,枢轴的选择和分区逻辑可以有所不同,因此学生应该熟悉算法的不同变体。
四、Java面试题
在大型技术公司的Java面试中,关于快速排序的问题可能不会局限于基本的算法实现,而会涉及算法的优化、特殊情况处理、空间和时间复杂度分析等。以下是在Java面试中可能遇到的与快速排序相关的问题。
面试题:
- 在Java中实现快速排序算法。
- 快速排序的时间复杂度是多少?最好、平均和最坏情况下的时间复杂度分别是什么?
- 快速排序的空间复杂度是多少?
- 如何选择枢轴(pivot)以优化快速排序的性能?
- 快速排序算法如何避免退化为最坏情况的复杂度?
- 请解释快速排序不稳定性的含义。你能实现一个稳定的快速排序算法吗?
- 对已几乎有序的数组进行快速排序时,你会如何优化你的算法?
- 如果数组包含大量重复元素,你如何修改快速排序算法以提高效率?
- 请比较快速排序和归并排序,并讨论它们的优劣。
- 描述三向切分(3-way partitioning)快速排序的原理以及它解决的问题。
一个典型的快速排序面试题可能要求你实际编写代码并解释它。例如:
题目:给定一个整数数组,编写一个Java函数来实现快速排序,并对该数组进行原地(in-place)排序。
示例答案:
public class QuickSort { public void quickSort(int[] arr, int low, int high) { if (low < high) { // 分区操作,返回枢轴所在位置 int pivotIndex = partition(arr, low, high); // 递归对枢轴左侧的子数组进行快速排序 quickSort(arr, low, pivotIndex - 1); // 递归对枢轴右侧的子数组进行快速排序 quickSort(arr, pivotIndex + 1, high); } } private int partition(int[] arr, int low, int high) { int pivot = arr[high]; int i = low - 1; // 小于枢轴的元素的索引 for (int j = low; j < high; j++) { if (arr[j] <= pivot) { i++; // 如果当前元素小于或等于枢轴,则交换它和小于枢轴的元素的索引 swap(arr, i, j); } } // 将枢轴元素放到正确的位置 swap(arr, i + 1, high); return i + 1; } private void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } // 对外公开的快速排序接口 public void sort(int[] arr) { if (arr != null && arr.length > 1) { quickSort(arr, 0, arr.length - 1); } } // 驱动函数,用于测试 public static void main(String[] args) { QuickSort sorter = new QuickSort(); int[] array = {15, 3, 2, 1, 9, 5, 7, 8, 6}; sorter.sort(array); for (int num : array) { System.out.print(num + " "); } } }
记住这只是一个快速排序的基础实现。在实际的面试中,你可能需要讨论更多高级话题,如递归栈的空间复杂度、尾递归优化、转换为迭代版本等。此外,面试官可能会要求你写出不同枢轴选择策略的代码实例,如随机选择枢轴、中位数的中位数等。
五、思考
在快速排序的递归过程中,如何对左右子数组进行分区操作?
解答
在快速排序算法中,分区操作是核心步骤。在递归过程中,每一步的目的是将数组分成两个子数组,使得一个子数组中的所有元素都比枢轴(pivot)小,而另一个子数组中的所有元素都比枢轴大。以下是一个常见的分区步骤描述:
- 选择枢轴(pivot):
首先你需要选择一个枢轴。这可以是数组中的任何一个元素,比如最后一个元素、第一个元素、中间元素、或者是三者的中位数。 - 初始化指针:
接着,初始化两个指针,一个是left
,从数组的开始位置指起,另一个是right
,从枢轴前一个位置指起。 - 分区操作:接下来进行分区操作。比较
left
指针指向的元素和枢轴的值:
- 如果
left
指针指向的元素小于等于枢轴的值,则移动left
指针向右。 - 如果
right
指针指向的元素大于枢轴的值,则移动right
指针向左。 - 当
left
指针指向的元素大于枢轴的值,且right
指针指向的元素小于或等于枢轴的值时,交换这两个元素的位置,然后各自移动指针。
- 交换枢轴:
当left
指针和right
指针相遇时,所有在left
指针左侧的元素都应该比枢轴小,而右侧的元素都比枢轴大。此时,交换枢轴和left
指针指向的元素(或者right
指针指向的元素,这取决于具体实现),这样枢轴元素即被放置到了正确的位置,这个位置就是分区的划分点。 - 递归排序子数组:
最后,使用递归方法分别对枢轴左侧和右侧的子数组进行排序。递归的基本情况是当子数组只剩下一个元素或没有元素时,这表明该子数组已有序或者不需要排序。
以上描述的分区过程会在每次递归调用中不断重复,直到整个数组有序。这种方法称为“原地分区”(in-place
partitioning),因为它不需要额外存储空间来创建子数组。
总结
对快速排序的总结就到这里,我们通过面试题和考研中出现的例题,来带大家深刻的理解什么是快速排序,在各种场景下我们会遇到不同的题目,我们将要如何应对呢,这些都需要我们掌握和理解快排的算法逻辑,需要搭配大量的练习,希望同学们可以多练练。
感谢大家抽出宝贵的时间来阅读博主的博客,新人博主,感谢大家关注点赞,祝大家未来的学习工作生活一帆风顺,加油!!!