归并排序和快速排序
Posted jing-yi
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了归并排序和快速排序相关的知识,希望对你有一定的参考价值。
1、归并排序
基本思路:借助额外空间,合并两个有序数组,得到更长的有序数组。例如:「力扣」第 88 题:合并两个有序数组。
算法思想:分而治之(分治思想)。「分而治之」思想的形象理解是「曹冲称象」、MapReduce,在一定情况下可以并行化。
public class Solution { // 归并排序 /** * 列表大小等于或小于该大小,将优先于 mergeSort 使用插入排序 */ private static final int INSERTION_SORT_THRESHOLD = 7; public int[] sortArray(int[] nums) { int len = nums.length; int[] temp = new int[len]; mergeSort(nums, 0, len - 1, temp); return nums; } /** * 对数组 nums 的子区间 [left, right] 进行归并排序 * * @param nums * @param left * @param right * @param temp 用于合并两个有序数组的辅助数组,全局使用一份,避免多次创建和销毁 */ private void mergeSort(int[] nums, int left, int right, int[] temp) { // 小区间使用插入排序 if (right - left <= INSERTION_SORT_THRESHOLD) { insertionSort(nums, left, right); return; } int mid = left + (right - left) / 2; // Java 里有更优的写法,在 left 和 right 都是大整数时,即使溢出,结论依然正确 // int mid = (left + right) >>> 1; mergeSort(nums, left, mid, temp); mergeSort(nums, mid + 1, right, temp); // 如果数组的这个子区间本身有序,无需合并 if (nums[mid] <= nums[mid + 1]) { return; } mergeOfTwoSortedArray(nums, left, mid, right, temp); } /** * 对数组 arr 的子区间 [left, right] 使用插入排序 * * @param arr 给定数组 * @param left 左边界,能取到 * @param right 右边界,能取到 */ private void insertionSort(int[] arr, int left, int right) { for (int i = left + 1; i <= right; i++) { int temp = arr[i]; int j = i; while (j > left && arr[j - 1] > temp) { arr[j] = arr[j - 1]; j--; } arr[j] = temp; } } /** * 合并两个有序数组:先把值复制到临时数组,再合并回去 * * @param nums * @param left * @param mid [left, mid] 有序,[mid + 1, right] 有序 * @param right * @param temp 全局使用的临时数组 */ private void mergeOfTwoSortedArray(int[] nums, int left, int mid, int right, int[] temp) { System.arraycopy(nums, left, temp, left, right + 1 - left); int i = left; int j = mid + 1; for (int k = left; k <= right; k++) { if (i == mid + 1) { nums[k] = temp[j]; j++; } else if (j == right + 1) { nums[k] = temp[i]; i++; } else if (temp[i] <= temp[j]) { // 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前) nums[k] = temp[i]; i++; } else { // temp[i] > temp[j] nums[k] = temp[j]; j++; } } } }
优化 1:在「小区间」里转向使用「插入排序」,Java 源码里面也有类似这种操作,「小区间」的长度是个超参数,需要测试决定,我这里参考了 JDK 源码;
优化 2: 在「两个数组」本身就是有序的情况下,无需合并;
优化 3:全程使用一份临时数组进行「合并两个有序数组」的操作,避免创建临时数组和销毁的消耗,避免计算下标偏移量。
注意:实现归并排序的时候,要特别注意,不要把这个算法实现成非稳定排序,区别就在 <= 和 < ,已在代码中注明。
「归并排序」比「快速排序」好的一点是,它借助了额外空间,可以实现「稳定排序」,Java 里对于「对象数组」的排序任务,就是使用归并排序(的升级版 TimSort,在这里就不多做介绍)。
复杂度分析:
时间复杂度:O(N log N)O(NlogN),这里 NN 是数组的长度;
空间复杂度:O(N)O(N),辅助数组与输入数组规模相当。
「归并排序」也有「原地归并排序」和「不使用递归」的归并排序,但是我个人觉得不常用,编码、调试都有一定难度。递归、分治处理问题的思想在基础算法领域是非常常见的,建议多练习编写「归并排序」学习递归思想,了解递归的细节,熟悉分治的思想。
经典问题:
《剑指 Offer》第 51 题:数组中的逆序对,照着归并排序的思路就能写出来。
「力扣」第 315 题:计算右侧小于当前元素的个数,它们是一个问题。
未优化版归并排序:
public static void main(String[] args) { int[] arrays = {9, 2, 5, 1, 3, 2, 9, 5, 2, 1, 8}; sort(arrays, 0, arrays.length - 1); for (Integer i : arrays){ System.out.print(i + "-"); } } private static void sort(int[] array, int left,int right){ if(left == right){ return; } int mid = (left + right) / 2; sort(array,left,mid); sort(array,mid+1,right); merge(array,left,mid+1,right); } private static void merge(int[] arrays,int L,int M,int R){ //左边的数组的大小 int[] leftArray = new int[M - L]; //右边的数组大小 int[] rightArray = new int[R - M + 1]; //往这两个数组填充数据 for (int i = L; i < M; i++) { leftArray[i - L] = arrays[i]; } for (int i = M; i <= R; i++) { rightArray[i - M] = arrays[i]; } int i = 0, j = 0; // arrays数组的第一个元素 int k = L; //比较这两个数组的值,哪个小,就往数组上放 while (i < leftArray.length && j < rightArray.length) { //谁比较小,谁将元素放入大数组中,移动指针,继续比较下一个 if (leftArray[i] < rightArray[j]) { arrays[k] = leftArray[i]; i++; k++; } else { arrays[k] = rightArray[j]; j++; k++; } } //如果左边的数组还没比较完,右边的数都已经完了,那么将左边的数抄到大数组中(剩下的都是大数字) while (i < leftArray.length) { arrays[k] = leftArray[i]; i++; k++; } //如果右边的数组还没比较完,左边的数都已经完了,那么将右边的数抄到大数组中(剩下的都是大数字) while (j < rightArray.length) { arrays[k] = rightArray[j]; k++; j++; } }
2、快速排序
基本思路:快速排序每一次都排定一个元素(这个元素呆在了它最终应该呆的位置),然后递归地去排它左边的部分和右边的部分,依次进行下去,直到数组有序;
算法思想:分而治之(分治思想),与「归并排序」不同,「快速排序」在「分」这件事情上不想「归并排序」无脑地一分为二,而是采用了 partition 的方法(书上,和网上都有介绍,就不展开了),因此就没有「合」的过程。
实现细节(注意事项):(针对特殊测试用例:顺序数组或者逆序数组)一定要随机化选择切分元素(pivot),否则在输入数组是有序数组或者是逆序数组的时候,快速排序会变得非常慢(等同于冒泡排序或者「选择排序」);
以下是针对特殊测试用例(有很多重复元素的输入数组)有 3 种版本的***:
版本 1:基本***:把等于切分元素的所有元素分到了数组的同一侧,可能会造成递归树倾斜;
版本 2:双指针***:把等于切分元素的所有元素等概率地分到了数组的两侧,避免了递归树倾斜,递归树相对平衡;
版本 3:三指针***:把等于切分元素的所有元素挤到了数组的中间,在有很多元素和切分元素相等的情况下,递归区间大大减少。
这里有一个经验的总结:之所以***有这些优化,起因都是来自「递归树」的高度。关于「树」的算法的优化,绝大部分都是在和树的「高度」较劲。类似的通过减少树高度、使得树更平衡的数据结构还有「二叉搜索树」优化成「AVL 树」或者「红黑树」、「并查集」的「按秩合并」与「路径压缩」。
写对「快速排序」的技巧:保持「循环不变量」,即定义的变量在循环开始前、循环过程中、循环结束以后,都保持不变的性质,这个性质是人为根据问题特点定义的。
「循环不变量」的内容在《算法导论》这本书里有介绍。我个人觉得非常有用。「循环不变量」是证明算法有效性的基础,更是写对代码的保证,遵守循环不变量,是不是该写等于号,先交换还是先 ++ ,就会特别清楚,绝对不会写错,我在编码的时候,会将遵守的「循环不变量」作为注释写在代码中。
快速排序丢失了稳定性,如果需要稳定的快速排序,需要具体定义比较函数,这个过程叫「稳定化」,在这里就不展开了。
使用「快速排序」解决的经典问题(非常重要):
TopK 问题:「力扣」第 215 题:数组中的第 K 个最大元素;
荷兰国旗问题:「力扣」第 75 题:颜色分类。
版本1:
import java.util.Random; public class Solution { // 快速排序 1:基本快速排序 /** * 列表大小等于或小于该大小,将优先于 quickSort 使用插入排序 */ private static final int INSERTION_SORT_THRESHOLD = 7; private static final Random RANDOM = new Random(); public int[] sortArray(int[] nums) { int len = nums.length; quickSort(nums, 0, len - 1); return nums; } private void quickSort(int[] nums, int left, int right) { // 小区间使用插入排序 if (right - left <= INSERTION_SORT_THRESHOLD) { insertionSort(nums, left, right); return; } int pIndex = partition(nums, left, right); quickSort(nums, left, pIndex - 1); quickSort(nums, pIndex + 1, right); } /** * 对数组 nums 的子区间 [left, right] 使用插入排序 * * @param nums 给定数组 * @param left 左边界,能取到 * @param right 右边界,能取到 */ private void insertionSort(int[] nums, int left, int right) { for (int i = left + 1; i <= right; i++) { int temp = nums[i]; int j = i; while (j > left && nums[j - 1] > temp) { nums[j] = nums[j - 1]; j--; } nums[j] = temp; } } private int partition(int[] nums, int left, int right) { int randomIndex = RANDOM.nextInt(right - left + 1) + left; swap(nums, left, randomIndex); // 基准值 int pivot = nums[left]; int lt = left; // 循环不变量: // all in [left + 1, lt] < pivot // all in [lt + 1, i) >= pivot for (int i = left + 1; i <= right; i++) { if (nums[i] < pivot) { lt++; swap(nums, i, lt); } } swap(nums, left, lt); return lt; } private void swap(int[] nums, int index1, int index2) { int temp = nums[index1]; nums[index1] = nums[index2]; nums[index2] = temp; } }
版本2:
import java.util.Random; public class Solution { // 快速排序 2:双指针(指针对撞)快速排序 /** * 列表大小等于或小于该大小,将优先于 quickSort 使用插入排序 */ private static final int INSERTION_SORT_THRESHOLD = 7; private static final Random RANDOM = new Random(); public int[] sortArray(int[] nums) { int len = nums.length; quickSort(nums, 0, len - 1); return nums; } private void quickSort(int[] nums, int left, int right) { // 小区间使用插入排序 if (right - left <= INSERTION_SORT_THRESHOLD) { insertionSort(nums, left, right); return; } int pIndex = partition(nums, left, right); quickSort(nums, left, pIndex - 1); quickSort(nums, pIndex + 1, right); } /** * 对数组 nums 的子区间 [left, right] 使用插入排序 * * @param nums 给定数组 * @param left 左边界,能取到 * @param right 右边界,能取到 */ private void insertionSort(int[] nums, int left, int right) { for (int i = left + 1; i <= right; i++) { int temp = nums[i]; int j = i; while (j > left && nums[j - 1] > temp) { nums[j] = nums[j - 1]; j--; } nums[j] = temp; } } private int partition(int[] nums, int left, int right) { int randomIndex = left + RANDOM.nextInt(right - left + 1); swap(nums, randomIndex, left); int pivot = nums[left]; int lt = left + 1; int gt = right; // 循环不变量: // all in [left + 1, lt) <= pivot // all in (gt, right] >= pivot while (true) { while (lt <= right && nums[lt] < pivot) { lt++; } while (gt > left && nums[gt] > pivot) { gt--; } if (lt >= gt) { break; } // 细节:相等的元素通过交换,等概率分到数组的两边 swap(nums, lt, gt); lt++; gt--; } swap(nums, left, gt); return gt; } private void swap(int[] nums, int index1, int index2) { int temp = nums[index1]; nums[index1] = nums[index2]; nums[index2] = temp; } }
版本3:
import java.util.Random; public class Solution { // 快速排序 3:三指针快速排序 /** * 列表大小等于或小于该大小,将优先于 quickSort 使用插入排序 */ private static final int INSERTION_SORT_THRESHOLD = 7; private static final Random RANDOM = new Random(); public int[] sortArray(int[] nums) { int len = nums.length; quickSort(nums, 0, len - 1); return nums; } private void quickSort(int[] nums, int left, int right) { // 小区间使用插入排序 if (right - left <= INSERTION_SORT_THRESHOLD) { insertionSort(nums, left, right); return; } int randomIndex = left + RANDOM.nextInt(right - left + 1); swap(nums, randomIndex, left); // 循环不变量: // all in [left + 1, lt] < pivot // all in [lt + 1, i) = pivot // all in [gt, right] > pivot int pivot = nums[left]; int lt = left; int gt = right + 1; int i = left + 1; while (i < gt) { if (nums[i] < pivot) { lt++; swap(nums, i, lt); i++; } else if (nums[i] == pivot) { i++; } else { gt--; swap(nums, i, gt); } } swap(nums, left, lt); // 注意这里,大大减少了两侧分治的区间 quickSort(nums, left, lt - 1); quickSort(nums, gt, right); } /** * 对数组 nums 的子区间 [left, right] 使用插入排序 * * @param nums 给定数组 * @param left 左边界,能取到 * @param right 右边界,能取到 */ private void insertionSort(int[] nums, int left, int right) { for (int i = left + 1; i <= right; i++) { int temp = nums[i]; int j = i; while (j > left && nums[j - 1] > temp) { nums[j] = nums[j - 1]; j--; } nums[j] = temp; } } private void swap(int[] nums, int index1, int index2) { int temp = nums[index1]; nums[index1] = nums[index2]; nums[index2] = temp; } }
参考文章:https://leetcode-cn.com/problems/sort-an-array/solution/fu-xi-ji-chu-pai-xu-suan-fa-java-by-liweiwei1419/
以上是关于归并排序和快速排序的主要内容,如果未能解决你的问题,请参考以下文章