Leecode刷题 215. 数组中的第K个最大元素(快速排序法)

题目:

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

输入: [3,2,1,5,6,4], k = 2
输出: 5

来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/kth-largest-element-in-an-array
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

思路:

先补充一下基本的:
深究递归和迭代的区别、优缺点及实例对比

下面这篇总结排序比较全面:
几种排序的总结(包括含有重复数字的快速排序)

思路1 快速排序法

在这里插入图片描述

快速排序算法详解(原理、实现和时间复杂度)
【算法】排序算法之快速排序(知乎文章代码不是很好,不要参考。)
文章1:

快速排序的操作是这样的:首先从数列的右边开始往左边找,我们设这个下标为 i,也就是进行减减操作(i–),找到第 1 个比基准数小的值,让它与基准值交换;接着从左边开始往右边找,设这个下标为 j,然后执行加加操作(j++),找到第 1 个比基准数大的值,让它与基准值交换;然后继续寻找,直到 i 与 j 相遇时结束,最后基准值所在的位置即 k 的位置,也就是说 k 左边的值均比 k 上的值小,而 k 右边的值都比 k 上的值大。

文章2:这个知乎文章代码不是很好,不要参考。

void swap(int *x, int *y) {
    int t = *x;
    *x = *y;
    *y = t;
}
void quick_sort_recursive(int arr[], int start, int end) {
    if (start >= end)
        return;
    int mid = arr[end];
    int left = start, right = end - 1;
    while (left < right) {
        while (arr[left] < mid && left < right)//因为是让最后一个数作为基准,所以从左边先开始动
            left++;
        while (arr[right] >= mid && left < right)
            right--;
        swap(&arr[left], &arr[right]);
    }
    if (arr[left] >= arr[end])//这里其实是与基准值也就是最后一个值进行一次比较,确保中间的值为mid,因为之前让最后一个数作为基准,所以是没有参与比较的
        swap(&arr[left], &arr[end]);
    else
        left++;//为了指向mid,?????还是不太理解这个为什么
    
    //下面这两句是用迭代的方法:左右分别进行快速排序
    if (left) {//只要left值大于0就说明左边需要排序
        quick_sort_recursive(arr, start, left - 1);//
    }
    //右边一定需要排序
    quick_sort_recursive(arr, left + 1, end);
}
void quick_sort(int arr[], int len) {
    quick_sort_recursive(arr, 0, len - 1);
}

疑问

这两种方法的结果是一样的,只是可能第k个值左右两侧的值,在第一次排序之后,顺序不一样。

第二种的代码,目前看起来有点难度,有几个点不太懂怎么推出来的,可以验证是对的,但是为什么需要再思考一下。

emmm,第二种的代码的疑问,我大概分析是这样的,,就是一般的都是把这个基准值,最后移到了我们人为分界处,但是这里貌似并没有,他只是在最后left等于right的时候,因为此时left和right左边的都是小于基准值的,右边都是大于基准值的,而他们共同指向的这个值,不确定是不是比基准值大,如果比基准值大,就交换顺序,如果比基准值小,就将left加1,这样就以left为分界线,左边都是小于基准值的,右边都是大于或者等于基准值的。

(应该是这么一种情况,某次交换完之后,left以及他之后的所有值都是小于基准值的,这样才让他一直靠近到等于right之后,停止循环,但是此时right指向的,应该是上次循环,与left交换的那个值啊,应该是大于等于基准值的,按理说还是执行不到else的这句啊)

疑问解答

上面的疑问带了一些分析,其实上面的分析是对的,经过大佬的确认,确实,这个知乎文章里面的代码,确实执行不到else那一句,首先 上面那个while的执行条件就是left<right 如果循环结束 那么必然有leftright 也就意味着arr[left]arr[right]恒成立 下面那个else确实应该不会执行

所以啊,上面那个知乎文章代码不是很好,不要参考。

其他问题解答:

疑问2:如果出现序列中有重复的值怎么办?
几种排序的总结(包括含有重复数字的快速排序)
这部分看下面有关取等号和重复元素的部分

疑问3:快速排序开始
快速排序先从左边扫描和先从右边扫描的区别
选取最左边为基准,应该从右边先开始扫描,选取右边为基准,应该从左边开始。
先从左边和先从右边扫,不一样的原因是,while循环条件触发,left = right的触发,left和right,两种情况下对应不同的值。
另外这篇文章也解释了一下,如果选取左边为基准,那不先从右边开始,要怎么做?
还有,为什么 采用 ++j 而不是 j++ 。

  • 先从右边扫描最后停留的位置肯定是小于基准数的,因为右指针是找比基准数小的数,先找到比基准数小的数,左指针和右指针相遇的情况只能是右指针停止,然后左指针向左扫描直到和右指针相遇,所以相遇的位置肯定是比基准数小的,然后这个数和基准数交换,这个数在基准数左边并小于基准数符合快排规则
  • 先从左边扫描最后停留的位置肯定是大于基准数的,因为左指针是找比基准数大的数,先停在比基准数大的数,左指针和右指针相遇的情况只能是左指针停止,然后右指针向左扫描直到和左指针相遇,所以相遇的位置肯定是比基准数大的,然后这个数和基准数交换,这个数在基准数左边但大于基准数不符合快排规则

应用到本题

我们可以用快速排序来解决这个问题,先对原数组排序,再返回倒数第 k 个位置,这样平均时间复杂度是 O(nlogn),但其实我们可以做的更快。
我们对数组 a[l⋯r] 做快速排序的过程是(参考《算法导论》):

  • 分解: 将数组 a[l⋯r] 「划分」成两个子数组 a[l⋯q−1]、a[q+1⋯r],使得 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。其中,计算下标 q 也是「划分」过程的一部分。
  • 解决: 通过递归调用快速排序,对子数组 a[l⋯q−1] 和 a[q+1⋯r] 进行排序。
  • 合并: 因为子数组都是原址排序的,所以不需要进行合并操作,a[l⋯r] 已经有序。
  • 上文中提到的 「划分」 过程是:从子数组 a[l⋯r] 中选择任意一个元素 x 作为主元,调整子数组的元素使得左边的元素都小于等于它,右边的元素都大于等于它,x 的最终位置就是 q。

由此可以发现每次经过「划分」操作后,我们一定可以确定一个元素的最终位置,即 x 的最终位置为 q,并且保证 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。所以只要某次划分的 q 为倒数第 k 个下标的时候,我们就已经找到了答案。 我们只关心这一点,至于 a[l⋯q−1] 和 a[q+1⋯r] 是否是有序的,我们不关心。
因此我们可以改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 q 正好就是我们需要的下标,就直接返回 a[q];否则,如果 q 比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。 这就是「快速选择」算法。

我们知道快速排序的性能和「划分」出的子数组的长度密切相关。直观地理解如果每次规模为 n 的问题我们都划分成 1 和 n−1,每次递归的时候又向n−1 的集合中递归,这种情况是最坏的,时间代价是 O(n ^ 2) . 我们可以引入随机化来加速这个过程,它的时间代价的期望是 O(n), 证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。

  • 时间复杂度:O(n),如上文所述,证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。
  • 空间复杂度:O(log n),递归使用栈空间的空间代价的期望为 O(logn)。

官方解法:

官方代码没有注释,比较难受,所以这里把自己的理解加进去。
漫画:什么是快速排序?(完整版)

1 快速排序法——这并不是一种常见的代码,所以最开始有点费解

注意,j的循环条件,最开始是l,不是1,一定要分清楚!!!!!!!!!
可以使用例子: 6 1 2 7 9 3 4 5 10 8 进行演练。

inline int partition(int* a, int l, int r) {//l代表left,r代表right,此处的r为数组可以取到的最右侧的下标,l则是能取到的最小的下标,比如0
    int x = a[r], i = l - 1;//i = l-1是为了后面使用++i是先加一之后再进行取值,所以为了防止最左边的数取不到,将其先减一
    //另外基准值选择了右侧的值,所以从最左边开始
    for (int j = l; j < r; ++j) {//i可以说是从left开始,而j是从l,不是1!!,然后到right-1(right是数组最右侧的值)
    //千万要注意l和1!!!!!!
        if (a[j] <= x) {//因为j是从l开始,i是从l-1,然后++开始,所以,如果最开始就符合if的条件判断,那么每次后面的交换语句其实都是不起作用的,都是自己和自己交换。只有某一次,a[j] > x了,而没有执行++i,但++j了,这时候再往后,每次都会将这个>x的值与后面<=x的值交换顺序,这样就保证这个大于基准值的值一直再往后移动。
            int t = a[++i];
            a[i] = a[j], a[j] = t;
        }
    }
    //因为最后j只取到right-1,所以没有将最后的基准值调换顺序,那么此时i指向的是比基准值大的数的前一个数,也就是比i小的一个数,所以需要i+1之后,与最后一个值交换,就能得到了。
    int t = a[i + 1];
    a[i + 1] = a[r], a[r] = t;
    return i + 1;
}

作者:LeetCode-Solution
链接:https://leetcode.cn/problems/kth-largest-element-in-an-array/solution/shu-zu-zhong-de-di-kge-zui-da-yuan-su-by-leetcode-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1.2 更常规的快速排序算法(运行超时,后面分析算法的错误,牵扯到等号)

inline int partition(int* nums, int start, int end)
{
    //更换快速排序算法,换成更通用的:
    if(start > end)
        return;
    int left = start,right = end;//排序指针
    int base = nums[start];//使用最左侧的为基准
    while(left < right)
    {
        while(nums[right] > base && right > left)//左边为基准值,所以从右边先开始
            right--;
        while(nums[left] < base && right > left)//
            left++;
        if(left >= right)
            break;
        else
        {
            int temp = nums[left];
            nums[left] = nums[right];
            nums[right] = temp; 
        }
    } 
    nums[left] = base;
    nums[end] = nums[left];
    return left;
}

1.3修改上面超时代码

关于取等号与重复元素(使用不好就会超时)
inline int partition(int* nums, int start, int end)
{
    //更换快速排序算法,换成更通用的:
    if(start > end)
        return;
    int left = start,right = end;//排序指针
    int base = nums[start];//使用最左侧的为基准
    while(left < right)
    {
        while(nums[right] > base && right > left)//注意,不加等号
            right--;
        if(left < right)
        {
            int temp = nums[left];
            nums[left] = nums[right];
            nums[right] = temp; 
            left++;//因为换到了left处,所以让他加1,跳过这个重复值
        }
        while(nums[left] < base && right > left)//不能加等号
            left++;
        if(left < right)
        {
            int temp = nums[left];
            nums[left] = nums[right];
            nums[right] = temp; 
            right--;
        }
        if(left >= right)
            break;  
    } 
    //加上这两句就是错误的
    // nums[left] = base;
    // nums[end] = nums[left];
    return left;
}

while条件上加不加等号=都是一样的效果,这就有点让我不解
我自己创建,填入测试用例,发现不管是带不带等号都会输出相同的结果。
在这里插入图片描述
输出的结果时10 7 7 6 6 4 ,只能说,他是按照快速排序了,而且重复元素确实排对了,但是对与这个题目来说需要进一步优化,不然输出的第几大就变成了排序之后倒数第几个元素了。这个问题其实是,此处力扣的题目描述的也不严谨,可能就是为了找到这个排序之后的第几个元素,先解决加不加等号的问题。
在这里插入图片描述

下面对这个重复元素的算法进行分析。

关于包含重复元素的快速排序
这里首先用上面文章中错误的例子说一下:
一般我们写快排的时候都是如下写的:

            while (pivot < nums[high] && low < high) 
            while (pivot > nums[low] && low < high)  

也就是说不带“=”,这时的快速排序就会有重复元素覆盖掉未重复元素,从而导致错误。
而如果加入“=”,程序在运行这一行代码时,就会跳过重复元素,而不会中断while循环交换元素,自然不会出现错误。
上面这段话,其实应该不对,经过验证,上面这篇文章简单通过加等号就企图解决这个有重复元素的问题,而经过我的演练,(可以使用例子 6 1 3 7 4 6 2 3 7 4 10 )发现是不可行的!!!!

对于有重复元素的,会执行超时,原因下面这篇文章有分析,实际上我们也可以考虑一下,如果不加等号,那么遇到相等的元素的时候,就会停下,然后进行left和right的指向的值的交换,但是由于换完之后,left和right值是没有变换的对吧,所以,这时候,比如我们是right指向的值发现了一个相同的,然后与left进行交换了,但是left和right的值都没有变换,那么下一次left进行循环的时候,left又检测到了一个相同的,又与right进行交换,就陷入了无限循环中,一直卡着,所以运行超时。
简言之:就是重复值与基准值是相同的,会卡住。
所以需要 每次交换之后,都要更改left或者right的值,这样其实left和right就交错开了。

代码就是上面写的。

快速排序遇到数组内含相同元素时的解法

这篇文章写了一下,取等号的实验。在这里插入图片描述
有趣算法之快速排序之元素重复与否
这篇也应证了这种写法的正确。

一定注意,while条件上不加等号=,上面这两个都加了,我觉得可能是因为例子不够全面,如果使用我自己的例子 6 1 3 7 4 6 2 3 7 8 4 10,如果加了等号最后得到的结果就是 4 1 3 3 4 6 2 6 7 7 10,很显然是错误的。

等号是有影响的,而且是必须加的。
在这里插入图片描述

标准快排:

第一种:

void kuaipai(int *arr,int begin,int end){
    if(begin<end){
        int i=begin,j=end,mid = arr[begin],c;
        while(i<j){
            while(i<j&&arr[j]>=mid)j--;
            while(i<j&&arr[i]<=mid)i++;
            c=arr[i];arr[j]=arr[i];arr[i]=c;
        }
        c=arr[begin];arr[begin]=arr[i];arr[i]=c;
        kuaipai(arr,begin,i-1);
        kuaipai(arr,i+1,end);
    }
}

第二种:

void kuaipai(int *arr,int begin,int end){
    if(begin<end){
        int i=begin,j=end,mid = arr[begin],c;
        while(i<j){
            while(i<j&&arr[j]>=mid)j--;
            	arr[i]=arr[j]
            while(i<j&&arr[i]<=mid)i++;
            	arr[j]=arr[i]
            c=arr[i];arr[j]=arr[i];arr[i]=c;
        }
        arr[i]=mid;
        kuaipai(arr,begin,i-1);
        kuaipai(arr,i+1,end);
    }
}

如果等号不加就会来回死循环。

另外,如果百度,还可以查到其他的方法,比如三分法。

2 随机

因为上面的随机函数每次都是以最后一个数作为基准的(官方解法),多次调用不具有随机性,所以我们选择先随机产生一个i,让这个下标的值与最后一个值进行交换,然后以最后一个值作为基准进行快速排序
接下来是随机产生一个i值,与最后一个值交换顺序,然后进行排序

inline int randomPartition(int* a, int l, int r) {
    int i = rand() % (r - l + 1) + l; 产生一个l到r的随机值
    int t = a[i];
    a[i] = a[r], a[r] = t;
    return partition(a, l, r);
}


作者:LeetCode-Solution
链接:https://leetcode.cn/problems/kth-largest-element-in-an-array/solution/shu-zu-zhong-de-di-kge-zui-da-yuan-su-by-leetcode-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
rand()用法详解

1、rand()不需要参数,它会返回一个从0到最大随机数的任意整数,最大随机数的大小通常是固定的一个大整数。
2、如果你要产生0~99这100个整数中的一个随机整数,可以表达为:int num = rand() % 100;
这样,num的值就是一个0~99中的一个随机数了。
3、如果要产生1~100,则是这样:int num = rand() % 100 + 1;
4、总结来说,可以表示为:int num = rand() % n +a;
其中的a是起始值,n-1+a是终止值,n是整数的范围。
5、一般性:rand() % (b-a+1)+ a ; 就表示 a~b 之间的一个随机整数。
由于随机数范围RAND_MAX(win下为32767)与编译器平台有关,如果我们需要更大范围的随机数,可以直接想乘等办法.
(int)round(1.0rand()/RAND_MAX(b-a+1)+a)

3 随机选择

然后就是上层的快速选择算法,随机排序一次,然后看返回的值是不是等于要求的index,如果不是就依据返回的值选择区间下一个。

int quickSelect(int* a, int l, int r, int index) {
    int q = randomPartition(a, l, r);
    if (q == index) {
        return a[q];
    } else {
        return q < index ? quickSelect(a, q + 1, r, index)
                         : quickSelect(a, l, q - 1, index);
    }
}

作者:LeetCode-Solution
链接:https://leetcode.cn/problems/kth-largest-element-in-an-array/solution/shu-zu-zhong-de-di-kge-zui-da-yuan-su-by-leetcode-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

4 调用

最后就是最上层的,调用函数。至此完成了算法的编写。

int findKthLargest(int* nums, int numsSize, int k) {
    srand(time(0));
    return quickSelect(nums, 0, numsSize - 1, numsSize - k);
}

作者:LeetCode-Solution
链接:https://leetcode.cn/problems/kth-largest-element-in-an-array/solution/shu-zu-zhong-de-di-kge-zui-da-yuan-su-by-leetcode-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
srand(time(0)) ;用法详解

计算机没有办法产生真正的随机数的,是用算法模拟,所以你只调用rand,每次出来的东西是一样的。设置一个种子后,根据种子的不同,就可以产生不同的数了。而怎么保证种子的不同呢?最简单的办法当然是用永远在向前的时间。

srand(time(0)) ;//先设置种子
rand();//然后产生随机数

Srand是种下随机种子数,你每回种下的种子不一样,用Rand得到的随机数就不一样。为了每回种下一个不一样的种子,所以就选用Time(0),Time(0)是得到当前时时间值(因为每时每刻时间是不一样的了)。

srand(time(0)) ;就是给这个算法一个启动种子,也就是算法的随机种子数,有这个数以后才可以产生随机数,用1970.1.1至今的秒数,初始化随机数种子。

补充:inline用法详解

inline用法详解

内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的
执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收
获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,
消耗更多的内存空间。

C语言的inline

由于不需要严格将各个值按照大小顺序放置到不同的位置当中,那么对整个数组进行排序就有一点多余,只需要知道从大到小的各个值的个数就可以了,所以需要通过一个HASH表来保存这些值,然后从数组当中的最大值开始,每次都进行–,往下找就可以了,每次找到一个元素,就获取到这个元素的个数,添加到总RANKING当中去,进行判断是否大于K就好了

public int findKthLargest(int[] nums, int k) {
        //必须要通过maps保存各个元素的个数
        HashMap<Integer, Integer> maps = new HashMap<>();
        //也必须要获取到数组当中的最大值,
        int max=nums[0];
        for (int num : nums) {
            max=Math.max(max,num);
            Integer counts = maps.getOrDefault(num, 0);
            maps.put(num,counts+1);
        }
        for (int num : nums) {
            if (num==max){
                int rank=1;
                //如果num的个数已经大于K了,那么直接返回K,如果不是大于K,就接着往下找
                if (maps.get(num)>=k)
                    return max;
                while(rank<k){
                    //开始往下找
                    if (maps.containsKey(--num)){
                        rank+=maps.get(num);
                        if (rank>=k)
                            return num;
                    }
                    
                }
            }
        }
        
        return -1;

    }

对于LeetCode刷题,我可以提供一些指导和建议。LeetCode是一个非常受欢迎的在线编程平台,提供了大量的编程题目,涵盖各种算法和数据结构的知识点。 要有效地刷LeetCode题目,以下是一些建议: 1. 熟悉常见的数据结构和算法:在开始刷题之前,你需要对常见的数据结构(如数组、链表、栈、队列、树等)和算法(如排序、查找、递归、动态规划等)有一定的了解。 2. 刷题顺序:可以按照题目的难度或者类型进行刷题。刚开始可以选择一些简单的题目,逐渐提升到中等和困难难度的题目。另外,可以按照题目类型进行分类刷题,比如数组、链表、树等。 3. 题目分析:在开始解题之前,仔细阅读题目,并理解问题的要求。可以考虑一些边界情况和特殊情况。 4. 设计合适的解决方案:根据题目要求,设计出符合要求的解决方案。可以先在纸上画图或者写伪代码,再实现代码。 5. 编写高质量的代码:编写代码时,注意代码的可读性、可维护性和效率。可以考虑使用适当的数据结构和算法来优化代码。 6. 调试和测试:编写完代码后,进行调试和测试,确保代码能够正确地解决问题。 7. 多解法比较:对于一道题目,可以尝试不同的解法,比较它们的优劣,并分析其时间复杂度和空间复杂度。 8. 学习他人的解法:在刷题过程中,可以参考他人的解题思路和代码,并学习其中的优秀之处。 9. 刷题计划:可以制定一个刷题计划,每天或每周刷一定数量的题目,并坚持下去。 10. 总结和复习:在刷题过程中,及时总结自己的思考和解题过程,对于一些常见的算法和技巧进行复习和加深理解。 希望以上的建议对你有所帮助。祝你在LeetCode刷题中取得好成绩!如果你有其他问题,也欢迎继续提问。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值