重学数据结构和算法(五)之归并排序、快速排序
2021/9/19 17:06:33
本文主要是介绍重学数据结构和算法(五)之归并排序、快速排序,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
目录- 归并排序(Merge Sort)
- 归并排序的原理:分治法
- 如何用递归代码来实现归并排序
- 快速排序(Quicksort)
- 代码实现快速排序
- O(n) 时间复杂度内求无序数组中的第 K 大元素
最近学习了极客时间的《数据结构与算法之美》很有收获,记录总结一下。
欢迎学习老师的专栏:数据结构与算法之美
代码地址:https://github.com/peiniwan/Arithmetic
归并排序(Merge Sort)
冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是 O(n2),比较高,适合小规模数据的排序。归并排序和快速排序的时间复杂度为 O(nlogn) 。这两种排序算法适合大规模的数据排序
稳定,但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法。
这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。
归并排序的原理:分治法
归并排序和快速排序都用到了分治思想,非常巧妙。我们可以借鉴这个思想,来解决非排序的问题,比如:如何在 O(n) 的时间复杂度内查找一个无序数组中的第 K 大元素?
如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
分治法的基本思想是:将原问题分解为若干个规模更小但结构与原问题相似的子问题。递归地解这些子问题,然后将这些子问题的解组合为原问题的解。
分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。
如何用递归代码来实现归并排序
写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。所以,要想写出归并排序的代码,我们先写出归并排序的递推公式。
递推公式: merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r)) 终止条件: p >= r 不用再继续分解
merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。我们将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 (p+r)/2。当下标从 p 到 q 和从 q+1 到 r 这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。
public class MergeSort { public void mergeSort(int[] a, int left, int right) { if (left < right) { int middle = (left + right) / 2; mergeSort(a, left, middle);//左边归并排序,使得左子序列有序 mergeSort(a, middle + 1, right);//右边归并排序,使得右子序列有序 merge(a, left, middle, right);//将两个有序子数组合并操作 } } private void merge(int[] a, int left, int middle, int right) {//left0,mid0,right1 //在排序前,先建好一个长度等于原数组长度的临时数组,避免递归中频繁开辟空间 int[] tmpArray = new int[a.length]; int rightStart = middle + 1;//右序列指针 int leftStart = left;//左序列指针 int temp = left;//临时数组指针 //比较两个小数组相应下标位置的数组大小,小的先放进新数组 while (left <= middle && rightStart <= right) { if (a[left] <= a[rightStart]) { //相当于tmpArray[third]=a[left];third++;left++三步合一步 tmpArray[temp++] = a[left++]; } else { tmpArray[temp++] = a[rightStart++]; } } //如果左边还有数据需要拷贝,把左边数组剩下的拷贝到新数组 while (left <= middle) { tmpArray[temp++] = a[left++]; } //如果右边还有数据...... while (rightStart <= right) { tmpArray[temp++] = a[rightStart++]; } //将temp中的元素全部拷贝到原数组中 while (leftStart <= right) { a[leftStart] = tmpArray[leftStart++]; } } public static void main(String[] args) { MergeSort mergeSort = new MergeSort(); int[] a = new int[]{9, 7, 6, 5, 4, 3, 2, 1}; mergeSort.mergeSort(a, 0, a.length - 1); for (int n : a) { System.out.print(" " + n); } } }
快速排序(Quicksort)
快排是一种原地、不稳定的排序算法。
快排利用的也是分治思想。乍看起来,它有点像归并排序,但是思路其实完全不一样。我们待会会讲两者的区别。现在,我们先来看下快排的核心思想。
基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。利用分治法可将快速排序的分为三步:
- 在数据集之中,选择一个元素作为”基准”(pivot)。
- 所有小于”基准”的元素,都移到”基准”的左边;所有大于”基准”的元素,都移到”基准”的右边。这个操作称为分区 (partition) 操作,分区操作结束后,基准元素所处的位置就是最终排序后它的位置。
- 对”基准”左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。
快速排序和归并排序对比
归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
代码实现快速排序
public class QuickSort { public void quick(int[] a) { if (a.length > 0) { quickSort(a, 0, a.length - 1); } } /** * 快速排序 * @param a * @param low 低位 * @param high 高位 */ private void quickSort(int[] a, int low, int high) { if (low < high) { int middle = getMiddle(a, low, high); //递归排比第一个基数小的数和大的数 quickSort(a, 0, middle - 1); quickSort(a, middle + 1, high); } } /** * * @param a * @param low * @param high * @return */ private int getMiddle(int[] a, int low, int high) { int temp = a[low];//基准元素 while (low < high) {//第二次3,9 while (low < high && a[high] >= temp) { high--; } a[low] = a[high];//将比基数小的数放到基数前面///用个数字想一下就明白了 while (low < high && a[low] <= temp) { low++; } a[high] = a[low];//将比基数大的数放到基数后面 } a[low] = temp;//插入到排序后正确的位置,low就是基数应该在的位置 return low; } }
O(n) 时间复杂度内求无序数组中的第 K 大元素
快排核心思想就是分治和分区,我们可以利用分区的思想,来解答开篇的问题:O(n) 时间复杂度内求无序数组中的第 K 大元素。比如,4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4。
我们选择数组区间 A[0…n-1] 的最后一个元素 A[n-1] 作为 pivot,对数组 A[0…n-1] 原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。
如果 p+1=K,那 A[p] 就是要求解的元素;如果 K>p+1, 说明第 K 大元素出现在 A[p+1…n-1] 区间,我们再按照上面的思路递归地在 A[p+1…n-1] 这个区间内查找。同理,如果 K<p+1,那我们就在 A[0…p-1] 区间查找。
int kthLargest = leetCode.findKthLargest(new int[]{4, 2, 5, 12, 3}, 3); //倒数 class Solution { public int findKthLargest(int[] nums, int k) { int len = nums.length; int left = 0, right = len - 1; int pivot = 0; while (len - k != (pivot = partition(nums, left, right))) { //4所在的问题就是2,那就找到了 //第k大应该在第K位,找每个数字应该在的位置,正好第0个4就是第K位,就找到了 if (pivot < len - k) {//在后面 left = pivot + 1; right = len - 1; } else { left = 0; right = pivot - 1; } } return nums[pivot]; } private int partition(int[] nums, int left, int right) { int pivot = nums[left]; while (left < right) { while (left < right && nums[right] >= pivot) right--; nums[left] = nums[right]; while (left < right && nums[left] <= pivot) left++; nums[right] = nums[left]; } nums[left] = pivot; return left; } }
为什么上述解决思路的时间复杂度是 O(n)?
第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1。
如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)。
这篇关于重学数据结构和算法(五)之归并排序、快速排序的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-23Springboot应用的多环境打包入门
- 2024-11-23Springboot应用的生产发布入门教程
- 2024-11-23Python编程入门指南
- 2024-11-23Java创业入门:从零开始的编程之旅
- 2024-11-23Java创业入门:新手必读的Java编程与创业指南
- 2024-11-23Java对接阿里云智能语音服务入门详解
- 2024-11-23Java对接阿里云智能语音服务入门教程
- 2024-11-23JAVA对接阿里云智能语音服务入门教程
- 2024-11-23Java副业入门:初学者的简单教程
- 2024-11-23JAVA副业入门:初学者的实战指南