堆排序和快排与归并排序

目录

    • 快速排序
        • 算法思想
        • 算法的优化
        • 代码实现
    • 归并排序
        • 算法思想
        • 算法优化
        • 代码实现
    • 堆排序
        • 算法思想
        • 代码实现

本文主要介绍了三个排序算法的思想原理和Java代码实现

快速排序

快排序的一个优点是其原地排序的特性,通过反复的交换元素,直接在数组中进行操作,只需要分配较少的内存来用于中间计算。
最佳情况:T(n) = O(nlogn) 最差情况:T(n) = O(n2) 平均情况:T(n) = O(nlogn)

算法思想

快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。
通过划分成多个子部分进行排序,这个子部分的任务就是根据一个“基准(pivot)元素”对数组进行划分。

步骤1:选择一个基准元素。从数组中选择一个元素,把它作为基准元素。

步骤2:根据这个基准元素重新排列数组。选择了基准元素p之后,下一个任务就是对数组中的元素进行排列,使数组中p之前的所有元素都小于p,在p之后的所有元素都大于p。例如,根据上面的输入数组,下面是一种合理的重新排列元素的方式:
例如对左边的数组进行一次快排:
堆排序和快排与归并排序_第1张图片
这样一次排序过后的时间复杂度是O(n);但是这样排完序后数组还不是有序的,接下来才是快排的核心步骤:
对于已经划分好的区域: 左边的小于基准元素的A区域+基准值+右边大于基准值的B区域;
下一步对于左边的A区域和右边的B区域进行一个递归调用,即先进行划分区域,在进行递归调用,这点和归并排序恰好相反;
如上面的例子对于小于3的部分进行一次递归调用后,其选择基准值如果是1则变成了1,2,此时对于2无法再次细分则递归结束,而对于右边的若以6为基准值,结果变为4,5 和7,8;两个部分,然后还要对于两边进行一个二次递归,因为此时还可以细分;在进行这一次后结果就排好了;

再次梳理一下快排的详细步骤:
1、选择基准值:此处需要选出一个用于划分的基准值;
如何选择,可以直接选择子数组第一个,也可以随机选择;
所以此处可以采用一种可递归调用的函数,需要通过接收待排子数组选出一个下标值返回;

基准元素本身处于正确的位置,意味着它在排序之后的输入数组中也是处于相同的位置(所有小于它的元素在它之前,所有大于它的元素在它之后)。并且,这种划分把排序问题分割成两个更小的子问题:对小于基准元素的元素进行排序(它们很方便地在自己的子数组中原地排序)以及对大于基准元素的元素进行排序(也是在它们自己的子数组中原地排序)。

2、划分数组;对于基准值和数组,需要对于该数组进行一个划分,将分出的大于基准值和小于基准值的分开;

常见的一种方式是使用一个双指针的操作方式:
(有一种是加入左右指针的方式,我这里介绍前后指针)对于待分的数组首尾加上一个前后指针,随后对于指针指向的元素进行一个比较,其中一个指针用于维护小于基准值的边界,而另一个指针用于去控制需要交换的元素;以一个例子说明
堆排序和快排与归并排序_第2张图片
当指针均指向8时,此时8>3,所以j需要向后移动,而i不动。
堆排序和快排与归并排序_第3张图片
当遇到2时,因为2<3,所以“8”与“2”进行交换,同时把i的值加1,这样i就位于“2”和“8”之间,
当遇到5时,5>3,所以j向后移动一位;
当遇到1时,此时又需要进行一次交换,只要把“1”与大于基准的第一个元素(“8”)进行交换并把i的值加1;
堆排序和快排与归并排序_第4张图片
这样当遍历完成后,得到的i指向的就是两个区域的划分切点;将基准元素与这个i指针前的一个元素进行一个交换即可;

这个函数需要进行的操作是进行子数组的划分;当然也只需要返回一个基准位置即可;

还有一种不常见的,如果不在意空间可以选用这种稍好理解的方式:对输入数组A进行一遍扫描,并把它的非基准元素逐个复制到一个相同长度的新数组B中,小于基准p的元素复制到数组B的前面,大于基准p的元素复制到数组B的后面。在处理完了所有的非基准元素之后,就可以把基准元素复制到数组B中剩下的那个位置。

3、最后是递归的操作
需要做到的是一个通过前面的信息得到了划分的基准值和子数组,对于子数组又需要重新调用前面两个方法;

算法的复杂度受基准值的影响;

算法的优化

1、基准值的随机化选择
快排在它的递归调用之外所完成的主要工作发生在(1)基准值和(2)划分子程序中。我们假设前者的运行时间是Θ (n)。如果使用了中位元素作为基准元素,就可以实现输入数组的完美划分,每个递归调用最多对不超过n/2个元素的子数组进行操作。这样可以减少递归的次数;
选择子数组的第一个元素作为基准元素只耗时O(1),但可能导致QuickSort的运行时间高达Θ (n2)。选择中位元素作为基准元素可以保证总体运行时间为Θ (n log n),但这样会在选择基准元素时消耗的时间太多(如果仍然是线性时间)。有一种简单和轻量级的方法用于选择一个基准元素,使其能够实现数组划分的大致平衡,该思路的关键是使用随机化。

因为无法通过快速的选出一个中位数,所以选择使用随机的方式来提高效率,随机选择基准元素往往比固定第一个更好;随机化的QuickSort中需要非常好的运气才会选到中位元素(n分之一的概率),但是选中一个近似中位元素却不需要太多的运气;它接近于50的概率;

另外一种选择基准值的方式,快 速 三 向 切 分。(J. Bently,D. McIlroy) 用 将重复元素放置于子数组两端的方式实现一个信息量最优的排序算法。使用两个索引 p 和 q,使得a[lo…p-1] 和 a[q+1…hi] 的 元 素 都 和 a[lo]相等。使用另外两个索引 i 和 j,使得 a[p…i-1]小于 a[lo],a[j+i…q] 大于 a[lo]。在内循环中加入代码,在 a[i] 和 v 相当时将其与 a[p] 交换(并将 p 加 1),在 a[j] 和 v 相等且 a[i] 和a[j] 尚未和 v 进行比较之前将其与 a[q] 交换。添加在切分循环结束后将和 v 相等的元素交换到正确位置的代码

2、对于小数组的排序方式切换
对于小数组,插入排序比快速排序效率更高,因此如果是小数组可以尝试切换插入排序方式;

代码实现
 public int[] quickSort(int[] nums,int left,int right) {
        // 0个或1个元素的子数组
        if(right<=left){
            return nums;
        }
        //找到基准值移到子数组最左边;
        int pivot= new Random().nextInt(right-left+1)+left;
        swap(nums,left,pivot);

        //新一次的切分点
        int n = partition(nums,left,right);
        // 对第一部分进行递归操作
        quickSort(nums, left, n -1);
        // 对第二部分进行递归操作   
        quickSort(nums, n+1,right);
        return nums;
    }
    //基础快排,用于找到每次分组的切割点
    private int partition(int[] nums,int left,int right) {
        int pivot = nums[left];
        int i = left+1;
        for(int j = left+1;j<=right;j++){
            if(nums[j]<pivot){
                swap(nums,i,j);
                i++;
            }
        }
        swap(nums,left,i-1);
        return i-1;
    }

    //用于交换数据
    private  void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }

归并排序

归并排序是一种经典的分治思想算法,分治算法设计范式是一种通用的解决问题的方法,在许多不同的领域有着大量的应用。它的基本思路就是把原始问题分解为多个更小的子问题,并以递归的方式解决子问题,最终通过组合子问题的解决方案得到原始问题的答案。

归并排序和快速排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序;
最佳情况:T(n) = O(n) 最差情况:T(n) = O(nlogn) 平均情况:T(n) = O(nlogn)

算法思想

归并排序MergeSort以更小的输入数组调用自身。把一个排序问题分解为多个更小的排序问题的最简单方法就是把输入数组对半分开,并分别以递归的方式对数组的前半部分和后半部分进行排序。

步骤1:要对子数组 a[left,…,right] 进行排序,先将它分为 a[left,…,mid] 和 a[mid+1,…,right] 两部分;
步骤2:递归调用方法将上面数组的继续划分;每一组需要执行一个子程序,用于排序;
步骤3:合并所有子数组的结果,得到最终的排序结果;
涉及到递归调用看起来略为抽象;
举个例子就是
如果对于 数组 [5,3,4,2,1]进行排序;要经过一下几步;
1、拆分为 [5,3]和 [4,2,1],
2、左边,继续拆分为[5],[3],
此时长度无法拆分,开始合并合并结果为[3,5];
3、右边[4,2,1]拆分为[4],[2,1]再加上拆分合并后为[1,2,4];
4、两部分在合并 变为 [1,2,3,4,5];

合并的算法:用一个临时数组来存储两个有序数组的合并结果即可;

算法优化

归并排序可以处理数百万甚至更大规模的数组,这是插入排序或者选择排序做不到的。归并排序的
主要缺点是辅助数组所使用的额外空间和 N 的大小成正比。
1、对于小数组的排序依然可以优化为插入排序;因为递归会使小规模问题中方法的调用过于频繁,所以改进对它们的处理方法就能改进整个算法。
2、归并的方式本质是判断两个有序数组的合并,因此对于这个也可以进行更好的优化,比如我们可以添加一个判断条件,如果 前一个数组的末尾 小于等于后一个数组的开头,我们就认为数组已经是有序,并跳过合并方法。
3、实时上对于某些数组,可以定义间隔值来直接进行归并操作;即不需要区划分,而采用自底向上的归并方式;如以2为间隔,每两个数划为一组,排序后与后面的组合并,变成4个,再反复递归得到结果;

代码实现
public int[] mergeSort(int[] nums) {
        doSort(nums,0,nums.length - 1);
        return nums;
    }
    public void doSort(int[] nums, int left, int right) {
        if (left < right) {
        int mid = left+(right-left) / 2;
            doSort(nums,left,mid);
            doSort(nums,mid+1,right);
            merge(nums,left,mid,right);
        }
    }

    private void merge(int[] nums, int left, int mid, int right) {
        int i = left;
        int j = mid+1;
        int len = right-left+1;
        int[] temp = new int[len];
        int k = 0;
        //合并数组
        while (i <= mid && j <= right) {
            if (nums[i]<nums[j]) {
                temp[k++] = nums[i++];
            } else {
                temp[k++] = nums[j++];
            }
        }
        //如果左右子数组没有合并完成
        while (i <= mid) {
            temp[k++] = nums[i++];
        }

        while (j <= right) {
            temp[k++] = nums[j++];
        }
        
        System.arraycopy(temp, 0, nums, left, len);

    }

堆排序

在某些应用场景中,排序的输入量可能非常巨大,甚至可以认为输入是无限的。
解决这个问题的一种方法是将输入排序然后从中找出 K 个最大的元素,但我们已经说明输入将会非常庞大。这种方式对于输入流无解;因而采用另一种方法,将每个新的输入和已知的M 个最大元素比较,但除非 M 较小,否则这种比较的代价会非常高昂。这种方式也被称为优先队列;
优先队列需要实现高效的插入与删除操作来保证效率;常见实现方式是无序数组(直接插入,删除时遍历最大值)、有序数组(插入时保证顺序,取值时直接删)、还有就是堆;

二叉堆是优先队列的一种,相较于其他两种,其优势在于可以同时拥有logN级的插入和删除效率在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素。相应地,这些位置的元素又至少要大于等于数组中的另两个元素;维护了一颗二叉排序树;
堆排序则时借用堆这种结构对于整个数组的数据进行一个排序,通过维护一个堆来实现原地修改数组达到排序手段;即O(1)空间复杂度;
最佳情况:T(n) = O(nlogn) 最差情况:T(n) = O(nlogn) 平均情况:T(n) = O(nlogn)

算法思想

一般可用一个长度为N+1的数组来表示一个大小为N的堆。堆的元素存在于下标1到N中。当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。当某个结点的优先级下降(例如,将根结点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序。

插入元素。我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置。
删除最大元素。我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。

堆排序可以分为两个阶段。在堆的构造阶段中,我们将原始数组重新组织安排进一个堆中;然
后在下沉排序阶段,我们从堆中按递减顺序取出所有元素并得到排序结果。

构造子堆:
从右至左用函数构造子堆。数组的每个位置都已经是一个子堆的根结点了,对于这些子堆也适用。如果一个结点的两个子结点都已经是堆了,那么在该结点上调用 sink() 可以将它们变成一个堆。这个过
程会递归地建立起堆的秩序。开始时我们只需要扫描数组中的一半元素,因为我们可以跳过大小为
1 的子堆。最后我们在位置 1 上调用 sink() 方法,扫描结束。在排序的第一阶段,堆的构造方法
和我们的想象有所不同,因为我们的目标是构造一个堆有序的数组并使最大元素位于数组的开头(次
大的元素在附近)而非构造函数结束的末尾

排序阶段:
堆排序的主要工作都是在第二阶段完成的。将堆中的最大元素删除,然后放入堆缩小后数组中空出的位置。

大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底。在下沉中总是直接提升较大的子结点直至到达堆底,然后再使元素上浮到正确的位置。这个想法几乎可以将比较次数减少一半——接近了归并排序所需的比较次数(随机数组)。这种方法需要额外的空间,因此在实际应用中只有当比较操作代价较高时才有用;

代码实现
  public static int[] heapSort(int[] arr){
        int len = arr.length;
        //初始化堆,构造一个最大堆,用于进行后续工作,如果堆小了效率较低;
        for(int i = (len/2 - 1);i >= 0;i--){
            heapAdjust(arr,i,len);
        }
        //将堆顶的元素和后面的元素交换,并重新调整堆,即移除一个堆顶元素尝试对新进的元素进行排序;
        for(int i = len - 1;i > 0;i--){
            swap(arr, i,0);
            heapAdjust(arr,0,i);
        }
        return arr;
    }

    public static void heapAdjust(int[] arr,int index,int length){
        int max = index;
        //完全二叉树的左子节点等于父节点下标*2,右子节点等于父节点下标*2+1
        int leftChild = 2*index;
        int rightChild = 2*index + 1;
        //与左右子树比较,尝试向二叉树下方下沉,或者说向数组后面移动;
        if(length > leftChild && arr[max] < arr[leftChild]){
            max = leftChild;
        }
        if(length > rightChild && arr[max] < arr[rightChild]){
            max = rightChild;
        }
        if(max != index){
            swap(arr,index,max);
            heapAdjust(arr,max,length);
        }

    }
    private static void swap(int[] arr, int index,int j) {
        int temp = arr[index];
        arr[index] = arr[j];
        arr[j] = temp;
    }

你可能感兴趣的:(java,排序算法,快速排序,分治算法,堆排序)