12 | 排序(下):如何用快排思想在O(n)内查找第K大元素?

参考资料:

1、
2、
3、动画演示效果

一、前言

上一节我讲了冒泡排序插入排序选择排序这三种排序算法,它们的时间复杂度都是 O(n2),比较高,适合小规模数据的排序。今天,我讲两种时间复杂度为 O(nlogn) 的排序算法,归并排序快速排序。这两种排序算法适合大规模的数据排序,比上一节讲的那三种排序算法要更常用

二、归并排序的原理

2.1、分治思想

归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
在这里插入图片描述
分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的分治是一种解决问题的处理思想,递归是一种编程技巧。

在这里插入图片描述

在这里插入图片描述

2.2、如何用递归代码来实现归并排序?


import java.util.Arrays;

public class MergeSort {
    public static void main(String[] args) {
//        int arr[] = SortTestHelper.getRandomArray(15, -1, 1);
        int arr[] = {12,5,6,10,2};
        System.out.println("归并排序前:"+Arrays.toString(arr));
        mergeSort(arr, 0, arr.length-1);
        System.out.println("归并排序后:"+Arrays.toString(arr));
    }

    /**
     * 递归使用归并排序,对arr[l...r]的范围进行排序(前闭区间,后闭区间)
     * @param arr 待排序数组
     * @param left 数组左
     * @param right
     */
    private static void mergeSort(int[] arr, int left, int right) {
        //对于递归,要处理递归到底的判断,这里就是left>=right。
        if( left >= right)//left = 0    right      = 4  不管原数组的个数是奇数还是偶数,最大索引传进,进行多次递归最终都是left = right =0
                          //left = 0    right(mid) = 2
                          //left = 0    right(mid) = 1
                          //left = 0    right(mid) = 1/2  0
            return;//终止了当前方法

        int mid = (left+right)/2;
        mergeSort(arr, left, mid);
        mergeSort(arr, mid+1, right);//mid = 0   right = 1
        merge(arr, left, mid, right); //将左右两部分,利用临时数组进行归并   left = 0 , mid = 0 ,right = 1
    }

    /**
     * 将arr[l...mid]和arr[mid+1...r]两部分进行归并
     * @param arr
     * @param left
     * @param mid
     * @param right
     * i:临时数组左边比较的元素下标;j:临时数组右边比较的元素的下标;k:原数组将要放置的元素下标
     */
    private static void merge(int[] arr, int left, int mid, int right) {//  left = 0 , mid = 0, right = 1
        int[] aux = new int[right-left+1]; //临时辅助数组   right 1 - left  0 + 1 = [2]

        for(int i=left; i<=right; i++)
            //将以左边索引开始,右边索引结束的值
            // {0,1,2,3,4,5}
            // 第一次是将0位置的值赋值给0位置,第二次是将1位置的值赋值到1位置的值。
            // 相当于将原数组赋值了一份值到aux[]临时数组
            aux[i-left] = arr[i]; /*减去的left是原数组相对于临时数组的偏移量*/

        int i=left, j=mid+1;
        for(int k=left; k<=right; k++) {
            if(i > mid) { //检查左下标是否越界
                arr[k] = aux[j-left];
                j++;
            } else if(j > right) { //检查右下标是否越界
                arr[k] = aux[i-left];
                i++;
            } else if(aux[i-left] <= aux[j-left]) {
                arr[k] = aux[i-left];
                i++;
            } else {
                arr[k] = aux[j-left];
                j++;
            }


        }


    }


}


2.3、自己实现的代码


import java.util.Arrays;

public class SortTest {
    public static void main(String[] args) {
        //主要分为两种情况,一种是偶数,一种是奇数。
        // 1、问题一,如果是奇数,那么中间这个数应该是放在前面的一组里,还是后面的一组里。
        //马士兵的说是排在前面一组
        //2、问题二,如果是偶数的情况下,排序是有问题的
        //3、问题三,要考虑稳定排序
        int[] arr = {3, 4,  7, 1, 2, 6};//7  0 + 6
//                   0, 1, 2, 3, 4, 5, 7
//        merge_sort(arr, 7);
//        merge(arr, 3, 3, 3);
//        merge4(arr, 1, 3, 3);
        sort(arr, 0, arr.length - 1);
    }

    public static void sort(int[] arr, int left, int right) {
        if (left == right) return;//这里应该是>=,不知是什么原理?
        //分成两半,中间值:如果是奇数,取值是前半段+中间值。这个公式适用于索引不管是0开头还是1开头。
        // 注意:该公式特点是如果有小数,是直接省去的
        //分成两半
        int mid = (left + right) / 2;
        //左边排序
        sort(arr, left, mid);
        //右边排序
        sort(arr, mid + 1, right);//这有两个功能,一是当递归到终止条件时(剩余两个数),可以跳过这一步。二是可以递归分解后半段

        //每次合并的时候,它的前一次合并的数组和下一次合并的数组是怎么连接?
        merge4(arr, left, mid + 1, right);
    }

    public static void merge3(int[] arr, int leftPoint, int rightPoint, int boundPoint) {
        int min = arr.length / 2;

        //前半段的开头索引        int[] arr = {3, 4, 5, 1, 2,6};
        int head1 = 0;
        //后半段的开头索引
        int head2 = min + 1;
        //新数组的开始索引
        int a = 0;

        int[] arr3 = new int[arr.length];

        //自己的实现方式一
        //假设两个数组(前后两部分)都没有遍历完的情况时,执行具体的操作
        while (head1 <= min && head2 < arr.length) {
            arr3[a++] = arr[head1] <= arr[head2] ? arr[head1++] : arr[head2++];
        }
        //将上面的if()代码优化,可以写在下面。
        //如果head2遍历完,head1没有遍历完的情况。执行以下操作
        while (head1 <= min) arr3[a++] = arr[head1++];
        //同理把下一个if()代码优化
        //如果head1遍历完,head2没有遍历完的情况。执行以下操作
        while (head2 < arr.length) arr3[a++] = arr[head2++];

        System.out.println(Arrays.toString(arr3));
    }


    /**
     * 以上方法不够灵活,可以指定前段开始指针、后段开始指针、最后指针。
     * 这样就灵活多了。
     * leftPoint     前段开始指针
     * rightPoint    后段开始指针
     * boundPoint   最后指针
     * 需要考虑边界问题:1、leftPoint == rightPoint 。2、leftPoint < rightPoint 时,不执行。
     * 此代码现在有问题:问题二
     */
    public static void merge4(int[] arr, int leftPoint, int rightPoint, int boundPoint) {
        int min = rightPoint - 1;

        //前半段的开头索引
        int head1 = leftPoint;
        //后半段的开头索引
        int head2 = rightPoint;
        //新数组的开始索引
        int a = 0;


        int[] arr3 = new int[rightPoint - leftPoint + 1];

        //自己的实现方式一
        //假设两个数组(前后两部分)都没有遍历完的情况时,执行具体的操作
        while (head1 <= min && head2 <= boundPoint) {

            arr3[a++] = arr[head1] <= arr[head2] ? arr[head1++] : arr[head2++];
        }
        //将上面的if()代码优化,可以写在下面。
        //如果head2遍历完,head1没有遍历完的情况。执行以下操作
        while (head1 <= min) arr3[a++] = arr[head1++];
        //同理把下一个if()代码优化
        //如果head1遍历完,head2没有遍历完的情况。执行以下操作
        while (head2 <= boundPoint) arr3[a++] = arr[head2++];


        for (int i = 0; i < arr3.length; i++) {
            arr[leftPoint + i] = arr3[i];
        }

    }

//归并排序
public class TestMergesort {
    public static void main(String[] args) {
        int[] arr = new int[]{23, 10, 9, 29, 1, 20, 2, 2};

        mergesort(arr, 0, arr.length - 1);
        System.out.println(Arrays.toString(arr));
    }

    /**
     * 1、时间复杂度:(递归的时间复杂度求法:是根据递推公式和终止条件进行的求的)
     *          最好时间复杂度:nlogn
     *          最坏时间复杂度:nlogn
     *          平均时间复杂度:nlogn     因为执行效率和数据的有序度是没有关系的,所以都是一样的。nlogn
     *                  说明:合并两个子数组的执行效率是O(n)
     *
     * 2、空间消耗:每次合并的时候,会申请一个数组(大小小于原数组)。因为每次申请完就会释放,最大申请就是原数组的大小。
     *              所以最后空间复杂度是:O(n)
     *
     * 3、是否是稳定排序:主要看合并的时候,如果相同的数据(前半段和后半段),先将前半段放在了前面。
     *              所以,是稳定的排序。
     *
     * @param arr
     * @param start
     * @param end
     */
    public static void mergesort(int[] arr, int start, int end) {
        if (start >= end) return;

        int mid = (start + end) / 2;

        mergesort(arr, start, mid);
        mergesort(arr, mid + 1, end);
        merge(arr, start, mid, end);
    }

    public static void merge(int[] arr, int start, int mid, int end) {
        int[] ints = new int[end - start + 1];

        for (int i = start; i <= end; i++) {
            ints[i - start] = arr[i];//注意这里的start mid end 可能是某一段的索引
        }

        int i = start;//arr 中前半段第一个数
        int j = mid + 1;//arr 中后半段第一个数
        for (int a = start; a <= end; a++) {
            if (i > mid) {
                arr[a] = ints[j - start];
                j++;
            } else if (j > end) {
                arr[a] = ints[i - start];
                i++;
            } else if (ints[i - start] > ints[j - start]) {
                arr[a] = ints[j - start];
                j++;
            } else {
                arr[a] = ints[i - start];
                i++;
            }
        }


    }


}

2.4、第一,归并排序是稳定的排序算法吗?

主要看merge()方法里,如果前半段和后半段的数相等时,是将前一个数进行放在前面。所以是稳定排序。

2.5、第二,归并排序的时间复杂度是多少?(即递归代码的时间复杂度分析)

求解 a问题,可以分为求解 b问题的时间和 c问题的时间。k为 b问题和 c问题合并所需的时间
T(a)= T(b)+ T(c)+ K

归并排序的时间复杂度的计算公式就是:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2*T(n/2) + n; n>1

通过下面代码直观的看一下,n是数据的总量(总个数)
T(n) = 2T(n/2) + n
= 2
(2T(n/4) + n/2) + n = 4T(n/4) + 2n
= 4
(2T(n/8) + n/4) + 2n = 8T(n/8) + 3n
= 8*(2T(n/16) + n/8) + 3n = 16T(n/16) + 4n

= 2^k * T(n/2^k) + k * n

当 T(n/2 ^ k)=T(1) 时,也就是 n/2^k=1,我们得到 k=log2n 。(因为n/2 ^k=1是最后一次分解,也就是终止条件。
得到 T(n)=Cn+nlog2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。

归并排序的执行效率与要排序的**原始数组的有序程度无关,**所以其时间复杂度是非常稳定的。最好时间复杂度、最坏时间复杂度、平均时间复杂度都是 O(nlogn)。

说明:log 省略底数,在计算机中默认底数是2

2.6、第三,归并排序的空间复杂度是多少?

实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。

三、快速排序的原理

3.1、快排的思想

如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。
我们遍历 p 到 r 之间的数据,**将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。**经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
在这里插入图片描述

根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
在这里插入图片描述
程序执行时的问题:将左半部分递归完成后,代码是怎么跳到右半部分的?(方法压栈后,退栈的条件是什么)

3.2、快排的代码


import java.util.Arrays;

public class QuilkeySortTest {


    public static void main(String[] args) {
        int[] arr = {6, 11, 3, 7, 8};
//        int[] arr = {9, 7, 8, 3, 2, 1};
        quick_sort(arr, arr.length);
        System.out.println(Arrays.toString(arr));
    }

    // 快速排序,A是数组,n表示数组的大小
    public static void quick_sort(int[] arr, int n) {
        quick_sort_c(arr, 0, n - 1);
    }

    // 快速排序递归函数,p,r为下标
    public static void quick_sort_c(int[] arr, int p, int r) {
        if (p >= r) return;
        int q = partition(arr, p, r);// 获取分区点
        quick_sort_c(arr, p, q - 1);
        quick_sort_c(arr, q + 1, r);

    }
    //分区函数
    public static int partition(int[] arr, int p, int r) {
        int pivot = arr[r];//该数组最后一个值
        int i = p;//第一个索引


		//循环条件:当 j 遍历到最后一个值(pivot)时,跳出循环。
        for (int j = p; j <= r - 1; j++) {
            if (arr[j] < pivot) {
                int temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
                i += 1;
            }
        }
//当 j 指针指向最后一个数时,跳出上面的循环,执行最后一次交换
        int temp2 = arr[i];
        arr[i] = arr[r];//这里不能用 pivot的原因是:最后temp2赋值给arr[r]才赋值到数组里,不然是赋值不到的。
        arr[r] = temp2;

        return i;
    }
}

自己的实现

//快排
public class TestQuicksort {
    public static void main(String[] args) {
        int[] arr = new int[]{23, 10, 9, 29, 1, 20, 2, 2};
//        int[] arr = {1, 4, 3, 6, 8, 2, 5, 7};
        quick(arr, 0, arr.length - 1);

        System.out.println(Arrays.toString(arr));
    }

    public static void quick(int[] arr, int start, int end) {
        if (start >= end) return;

        int q = quicksort(arr, start, end);

        quick(arr, start, q - 1);
        quick(arr, q + 1, end);
    }

    /**
     * 1、时间复杂度:
     *          最坏时间复杂度: n2
     *          最好时间复杂度: nlogn
     *          平均时间复杂度: 
     * 2、空间消耗:只用到了常量级的空间,所以是原地排序
     *
     * 3、是否是稳定排序:不是。例如:9、11、11、8、7、10
     *
     * @param arr
     * @param start
     * @param end
     * @return
     */
    public static int quicksort(int[] arr, int start, int end) {
        int i = start;

        for (int j = start; j < end; j++) {
            if (arr[j] < arr[end]) {
                swap(arr, i, j);
                i++;
            }
        }

        swap(arr, i, end);
        return i;

    }

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

3.3、快排代码的自己理解的图解

快排代码的自己理解的图解

3.5、第一,快速排序是稳定的排序算法吗?

快速排序并不是一个稳定的排序算法。

3.6、第二,快速排序的时间复杂度是多少?(即递归代码的时间复杂度分析)

最好情况时间复杂度(分区极其均衡):O(nlogn)
最坏情况时间复杂度(分区极其不均衡):O(n2)
那快排的平均情况时间复杂度是多少呢?
求解递归的时间复杂度两种方式:
1、递推公式
2、递归树

T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = T(n/10) + T(9*n/10) + n; n>1

3.7、第三,快速排序的空间复杂度是多少?

快速排序并是一个原地排序算法。

3.4、问题收集

1、执行partition函数的时候,指针 i 和 指针 j 指向第一个值时,是否可以省去他们交换的执行?影响性能大吗?因为交换前后都是一样的。
2、分区的时候,取pivot 值(随机取其一个值)是否合理?会不会造成前半段个数一直很多或者后半段个数一直很多?这样是否有影响?

四、解答开篇问题

在一个数组中,找第K大元素(类似排名名次),比如:4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4。
用快排排好序后,根据索引来确定第K大元素。



public class STest {
    public static void main(String[] args) {
        int[]arr={8,6,5,7};
        kthSmallest(arr,4);
    }

    public static int kthSmallest(int[] arr, int k) {
        if (arr == null || arr.length < k) {
            return -1;
        }

        int partition = partition(arr, 0, arr.length - 1);
        while (partition + 1 != k) {  //这一步
            if (partition + 1 < k) {
                partition = partition(arr, partition + 1, arr.length - 1);
            } else {
                partition = partition(arr, 0, partition - 1);
            }
        }

        return arr[partition];
    }

    private static int partition(int[] arr, int p, int r) {
        int pivot = arr[r];

        int i = p;
        for (int j = p; j < r; j++) {
            // 这里要是 <= ,不然会出现死循环,比如查找数组 [1,1,2] 的第二小的元素
            if (arr[j] <= pivot) {
                swap(arr, i, j);
                i++;
            }
        }

        swap(arr, i, r);

        return i;
    }

    private static void swap(int[] arr, int i, int j) {
        if (i == j) {
            return;
        }

        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值