【算法训练-排序算法 二】【快速排序】数组中的第K个最大元素、最小的K个数

简介: 【算法训练-排序算法 二】【快速排序】数组中的第K个最大元素、最小的K个数

废话不多说,喊一句号子鼓励自己:程序员永不失业,程序员走向架构!本篇Blog的主题是【寻找第K大】,使用【数组】这个基本的数据结构来实现,这个高频题的站点是:CodeTop,筛选条件为:目标公司+最近一年+出现频率排序,由高到低的去牛客TOP101去找,只有两个地方都出现过才做这道题(CodeTop本身汇聚了LeetCode的来源),确保刷的题都是高频要面试考的题。

名曲目标题后,附上题目链接,后期可以依据解题思路反复快速练习,题目按照题干的基本数据结构分类,且每个分类的第一篇必定是对基础数据结构的介绍

数组中的第K个最大元素【MID】

一道中等难度使用快排可以解决的题

题干

输入:
[1,3,5,2,2],5,3
返回值:
2
输入:
[10,10,9,9,8,7,5,6,4,3,4,2],12,3
返回值:
9
说明:
去重后的第3大是8,但本题要求包含重复的元素,不用去重,所以输出9

解题思路

使用快速排序的思路来解决,快速排序(Quick Sort)是一种基于分治思想的排序算法,它通过将数组分成较小和较大的两部分,并分别对这两部分进行排序,最终将整个数组排序。快速排序是一种高效的排序算法,通常在平均情况下具有较快的执行速度。

下面是快速排序的基本思想和步骤:

  1. [划分]选择基准元素(Pivot): 从数组中选择一个元素作为基准元素。
  2. [划分]划分(Partition): 将数组分成两部分,使得基准元素左边的元素都小于等于基准元素,右边的元素都大于基准元素。这一步骤通常称为“划分”。
  3. [解决]递归排序: 递归地对基准元素左边和右边的子数组进行排序。也就是说,对小于基准元素的子数组和大于基准元素的子数组分别执行快速排序。
  4. [合并]合并: 由于子数组都是在原数组中进行排序,所以最终整个数组也就被排序了。

这些步骤使得较大问题被分解成较小的子问题,这些子问题又能通过递归地应用快速排序来解决。在最好情况下,每次划分都能将数组均匀分成两半,这使得算法的时间复杂度为O(n log n)。

然而,需要注意的是,快速排序的性能高度依赖于基准元素的选择。最坏情况下,如果每次划分都使数组分成极不平衡的两部分,算法的时间复杂度可能会退化到O(n^2)。为了应对这种情况,通常可以选择合适的基准元素,如随机选择或者采用三数取中等方法。

总之,快速排序是一种常用且高效的排序算法,尤其适用于大规模数据的排序。

代码实现

给出代码实现基本档案

基本数据结构数组

辅助数据结构

算法快速排序(分治算法)、二分查找

技巧双指针

import java.util.*;
public class Solution {
    /**
     * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
     *
     *
     * @param a int整型一维数组
     * @param n int整型
     * @param K int整型
     * @return int整型
     */
    public int findKth (int[] a, int n, int K) {
        return quikSort(a, 0, n - 1, K);
    }
    public int quikSort(int[] a, int start, int end, int K) {
        // 找到基准元素的位置,这个位置是第 pivot+1(因为数组下标从0开始) 大的元素
        int pivot = partion(a, start, end);
        if (K == pivot + 1 ) {
            // 正好命中
            return a[pivot]  ;
        } else if (K < pivot + 1) {
            // K小于基准位置,说明它是更大的数,在基准位置左边搜索
            return quikSort(a, start, pivot - 1, K);
        } else {
            // K大于基准位置,说明它是更小的数,在基准位置右边搜索,
            return quikSort(a, pivot + 1, end, K );
        }
    }
    // 获取一个基准元素,倒排,所以大于基准元素的数在左,小于基准元素的数在右
    private int partion(int[] a, int start, int end) {
        int pivotValue = a[start];
        int i = start, j = end;
        while (i != j) {
            // (a[j]只有大于基准元素才停止,因为大的放基准元素左边
            while (a[j] <= pivotValue && i < j) {
                // 一定要j先走,因为j先走ij就会在j的位置交汇,而基准元素的左边一定要比自己大,所以为了保证后续基准元素交换正确,一定要保证交汇位置元素值大于等于基准元素,所以要优先满足j的条件(即找到大于基准元素的值并停下),如果i先走,在靠近交互点时,i会越过大于基准元素的值与j交汇。
                j--;
            }
            // (a[i]只有小于基准元素才停止,因为大的放基准元素左边
            while (a[i] >= pivotValue && i < j) {
                i++;
            }
            if (i < j) {
                swap(a, i, j);
            }
        }
        // 全部交换完后,基准元素交换
        swap(a, start, j);
        return j;
    }
    // 辅助函数,交换数组元素
    private void swap(int[] a, int i, int j) {
        int temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}

复杂度分析

快速排序的时间复杂度和空间复杂度如下:

时间复杂度:

  • 平均情况: 在平均情况下,快速排序的时间复杂度为O(n log n),其中n是待排序数组的长度。这是因为每次划分都能将数组大致均匀地分成两部分,导致递归的深度大约为log n,而每次划分的过程需要O(n)的时间。
  • 最坏情况: 在最坏情况下,如果每次划分都导致一个极不平衡的分割(例如每次选取的基准元素都是当前子数组的最大或最小元素),那么快速排序的时间复杂度可能退化到O(n^2)。这是因为需要执行n次划分,每次划分都需要O(n)的时间。为了避免最坏情况,通常采用随机选择基准元素或者三数取中法来减少极端情况的发生。
  • 最好情况: 快速排序的最好情况时间复杂度为O(n log n),与平均情况相同。这种情况发生在每次划分都能将数组准确地分成相等的两部分时。

空间复杂度:

快速排序的空间复杂度主要取决于递归调用的深度和每次划分所使用的额外空间。

  • 递归调用的深度: 在递归调用中,每次只需要保存一个基准元素的索引和部分数组的边界信息。因此,递归调用的深度为O(log n)
  • 每次划分所使用的额外空间: 每次划分需要O(1)的额外空间来存储基准元素和进行交换。

综合考虑,快速排序的空间复杂度为O(log n)。这是因为虽然递归调用的深度为O(log n),但在每层递归中所需的额外空间是常数级别的。这使得快速排序在空间上比某些其他排序算法(如归并排序)更加节省。

最小的K个数(库存管理)

另一道类似的题目

题干

解题思路

同上的快排思路,只不过结果要处理一下,截取前k个数

代码实现

给出代码实现基本档案

基本数据结构数组

辅助数据结构

算法快速排序(分治算法)、二分查找

技巧双指针

import java.util.*;
public class Solution {
    public ArrayList<Integer> GetLeastNumbers_Solution(int [] input, int k) {
        // 1 入参判断,不满足条件返回空集合
        if (input.length == 0 || input.length < k) {
            return new  ArrayList<Integer>();
        }
        // 2 对数组进行快速排序
        quikSort(input, 0, input.length - 1);
        // 3 截断返回前k个最小的数
        int[] resultArray = Arrays.copyOf(input, k);
        ArrayList<Integer> result = new  ArrayList<Integer>();
        for (int value : resultArray) {
            result.add(value);
        }
        return result;
    }
    // 快速排序方法
    private void quikSort(int [] input, int left, int right) {
        int pivot;
        if (left < right) {
            pivot = partion(input, left, right);
            quikSort(input, left, pivot - 1);
            quikSort(input, pivot + 1, right);
        }
    }
    // 分治寻找基准值
    private int partion(int [] input, int left, int right) {
        // 1 随机打乱当前数组,均匀获取基准值下标
        int pivotIndex = new Random().nextInt(right - left + 1) + left;
        swap(input, left, pivotIndex);
        // 2 定义双指针
        int i = left;
        int j = right;
        int pivot = input[left];
        // 3 指针对撞交换
        while (i < j) {
            // 3-1 从右侧找到比基准值小的
            while (i < j && input[j] >= pivot ) {
                j--;
            }
            // 3-2 从左侧找到比基准值大的
            while (i < j && input[i] <= pivot ) {
                i++;
            }
            // 3-3 交换ij
            if (i < j) {
                swap(input, i, j);
            }
        }
        // 4 交换基准值与目标值
        swap(input, left, j);
        // 5 返回基准值索引下标
        return j;
    }
    private void swap(int [] input, int i, int j) {
        int temp = input[i];
        input[i] = input[j];
        input[j] = temp;
    }
}

复杂度分析

快速排序的时间复杂度和空间复杂度如下:

时间复杂度:

  • 平均情况: 在平均情况下,快速排序的时间复杂度为O(n log n),其中n是待排序数组的长度。这是因为每次划分都能将数组大致均匀地分成两部分,导致递归的深度大约为log n,而每次划分的过程需要O(n)的时间。
  • 最坏情况: 在最坏情况下,如果每次划分都导致一个极不平衡的分割(例如每次选取的基准元素都是当前子数组的最大或最小元素),那么快速排序的时间复杂度可能退化到O(n^2)。这是因为需要执行n次划分,每次划分都需要O(n)的时间。为了避免最坏情况,通常采用随机选择基准元素或者三数取中法来减少极端情况的发生。
  • 最好情况: 快速排序的最好情况时间复杂度为O(n log n),与平均情况相同。这种情况发生在每次划分都能将数组准确地分成相等的两部分时。

空间复杂度:

快速排序的空间复杂度主要取决于递归调用的深度和每次划分所使用的额外空间。

  • 递归调用的深度: 在递归调用中,每次只需要保存一个基准元素的索引和部分数组的边界信息。因此,递归调用的深度为O(log n)
  • 每次划分所使用的额外空间: 每次划分需要O(1)的额外空间来存储基准元素和进行交换。

综合考虑,快速排序的空间复杂度为O(log n)。这是因为虽然递归调用的深度为O(log n),但在每层递归中所需的额外空间是常数级别的。这使得快速排序在空间上比某些其他排序算法(如归并排序)更加节省。

拓展知识:分治算法

分治法是一种解决问题的算法设计范式,它将一个问题分解成多个相似的子问题,然后解决这些子问题,并将它们的解合并以得出原始问题的解。分治法的核心思想是将大问题分解成更小的、相似的子问题,通过解决子问题来解决原始问题。

分治法通常包含三个步骤:分解(Divide)、解决(Conquer)、合并(Combine)。

  1. 分解(Divide): 将原始问题划分为更小、相似的子问题。这一步骤通常是递归地进行的,即将问题逐步分解为更小规模的子问题。
  2. 解决(Conquer): 递归地解决子问题。当子问题足够小,可以直接求解时,就停止分解,转而解决这些子问题。
  3. 合并(Combine): 将子问题的解合并以得出原始问题的解。这是分治法的关键步骤,将各个子问题的解整合起来形成更大问题的解。

分治法通常用于解决一些可以被分解成相似子问题的问题,如排序、搜索、求解最短路径等。典型的分治算法包括归并排序快速排序。以下是一个分治法的示例:

归并排序:

  1. 分解(Divide): 将数组分成两半,分别对这两半进行排序。
  2. 解决(Conquer): 对分解得到的子数组递归地进行排序,直到子数组长度足够小。
  3. 合并(Combine): 将排好序的子数组合并,得到完整的有序数组。

分治法的优点在于它可以将问题分解成独立的子问题,每个子问题的求解都相对简单。这使得算法设计和理解变得更加清晰。然而,分治法有时会在子问题的合并阶段引入额外的开销,因此在设计分治算法时需要权衡分解和合并的成本。

相关文章
|
12天前
|
存储 算法 Java
解析HashSet的工作原理,揭示Set如何利用哈希算法和equals()方法确保元素唯一性,并通过示例代码展示了其“无重复”特性的具体应用
在Java中,Set接口以其独特的“无重复”特性脱颖而出。本文通过解析HashSet的工作原理,揭示Set如何利用哈希算法和equals()方法确保元素唯一性,并通过示例代码展示了其“无重复”特性的具体应用。
30 3
|
20天前
|
存储 机器学习/深度学习 算法
蓝桥杯练习题(三):Python组之算法训练提高综合五十题
蓝桥杯Python编程练习题的集合,涵盖了从基础到提高的多个算法题目及其解答。
36 3
蓝桥杯练习题(三):Python组之算法训练提高综合五十题
|
20天前
|
机器学习/深度学习 存储 缓存
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
文章主要介绍了排序算法的分类、时间复杂度的概念和计算方法,以及常见的时间复杂度级别,并简单提及了空间复杂度。
18 1
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
|
20天前
|
算法 程序员 索引
数据结构与算法学习七:栈、数组模拟栈、单链表模拟栈、栈应用实例 实现 综合计算器
栈的基本概念、应用场景以及如何使用数组和单链表模拟栈,并展示了如何利用栈和中缀表达式实现一个综合计算器。
18 1
数据结构与算法学习七:栈、数组模拟栈、单链表模拟栈、栈应用实例 实现 综合计算器
|
13天前
|
存储 算法 Java
Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性
Java Set因其“无重复”特性在集合框架中独树一帜。本文解析了Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性,并提供了最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的hashCode()与equals()方法。
29 4
|
25天前
|
机器学习/深度学习 算法 决策智能
【机器学习】揭秘深度学习优化算法:加速训练与提升性能
【机器学习】揭秘深度学习优化算法:加速训练与提升性能
|
20天前
|
算法 搜索推荐 Shell
数据结构与算法学习十二:希尔排序、快速排序(递归、好理解)、归并排序(递归、难理解)
这篇文章介绍了希尔排序、快速排序和归并排序三种排序算法的基本概念、实现思路、代码实现及其测试结果。
14 1
|
20天前
|
搜索推荐 算法
数据结构与算法学习十四:常用排序算法总结和对比
关于常用排序算法的总结和对比,包括稳定性、内排序、外排序、时间复杂度和空间复杂度等术语的解释。
14 0
数据结构与算法学习十四:常用排序算法总结和对比
|
20天前
|
存储 算法 定位技术
数据结构与算法学习二、稀疏数组与队列,数组模拟队列,模拟环形队列
这篇文章主要介绍了稀疏数组和队列的概念、应用实例以及如何使用数组模拟队列和环形队列的实现方法。
19 0
数据结构与算法学习二、稀疏数组与队列,数组模拟队列,模拟环形队列
|
20天前
|
机器学习/深度学习 搜索推荐 算法
探索数据结构:初入算法之经典排序算法
探索数据结构:初入算法之经典排序算法