归并排序和快速排序

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;
        }
//如果不使用快排,应该加上递归结束条件
     //if(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);  //这一步交换是因为相等的情况下会出现lt++后不等于i
                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/

posted @ 2020-06-28 17:36  jingyi_up  阅读(379)  评论(0编辑  收藏  举报