C++ 分治 快速排序优化 三指针快排 力扣 面试题 17.14. 最小K个数 题解 每日一题

文章目录


题目描述

题目链接:力扣 面试题 17.14. 最小K个数

题目描述:

示例:

输入: arr = [1,3,5,7,2,4,6,8], k = 4

输出: [1,2,3,4](输出顺序不唯一,如[2,1,3,4]也可)
提示:

0 <= len(arr) <= 100000

0 <= k <= min(100000, len(arr))

为什么这道题值得我们花上几分钟的时间弄明白?

这道题看似是"排序的简化版",实则藏着3个关键学习点,吃透它能帮你少走很多弯路:

  1. 打破"全排序"思维定式

    很多人第一反应是"把数组排好序,再取前k个"------但排序要O(n log n)时间,而这道题用"快速选择"只要O(n)!它教会我们:解决问题不必"面面俱到",找到核心目标(只要前k小)并精准发力,效率会大幅提升

  2. 衔接"快速选择"的实战应用

    如果你看过我的上一篇 第K个最大元素 的博客,会发现这道题是它的"反向孪生题"------前者找"第k大",后者找"前k小",按照上两篇博客的思路可以直接秒杀,核心逻辑都是"三指针分区+剪枝递归",通过这道题,能帮你更加巩固掌握"快速选择"在不同场景下的调整技巧,第一次看我的博客的朋友建议在我的主页中将我的前两篇博客看下,我的前两篇博客非常细致的将快排的原理进行了剖析,毫不夸张的说当你看完前两篇博客回来看这这道题或者类似的题目完全可以直接秒杀!

  3. 覆盖工程中的边界场景

    题目里的"k=0""k等于数组长度""空数组"等边界条件,正是实际开发中容易踩坑的地方。比如统计"销量最低的10款商品"时,若商品总数不足10款,直接返回全部即可------这道题的边界处理,能帮你培养代码的健壮性。

几种解法的分析

这道题有比较多的解法,我们来一一分析方法的优劣:

全排序法:直观但低效的"基础解"

核心逻辑

先对整个数组执行升序全量排序(如快排、归并排序等),排序完成后直接截取前K个元素------本质是用"全排序的完整开销"换"结果的直观性",无需额外思考筛选逻辑。

复杂度分析

  • 时间复杂度:O(n log n)(主流排序算法的时间开销,n为数组长度,排序过程需遍历+分治处理,层级为log n);
  • 空间复杂度:O(log n)~O(n)(快排需O(log n)递归栈空间,归并排序需O(n)临时数组空间,取决于排序算法)。

优劣势

  • 优势:逻辑简单,代码易写(调用内置排序函数即可),无需理解复杂数据结构或算法思想;
  • 劣势:存在"无效计算"------只需前K个元素,却要排序整个数组,当n极大时(如n=1e5)效率明显不足。

堆选择法:稳定且灵活的"实用解"

核心逻辑

利用最大堆的"优先级筛选特性",动态维护"当前最小的K个元素":

  1. 堆初始化:遍历数组,若堆大小<K,直接将元素入堆;
  2. 筛选逻辑:若堆大小=K,且当前元素<堆顶(堆顶是"候选最小K个"中的最大值),则弹出堆顶、将当前元素入堆(替换掉"候选中最大的元素",确保堆内始终是更小的元素);
  3. 结果输出:遍历结束后,堆内所有元素即为"最小的K个元素"。

复杂度分析

  • 时间复杂度:O(n log K)(遍历数组n次,每次入堆/出堆的时间取决于堆的高度log K,K远小于n时log K可视为常数);
  • 空间复杂度:O(K)(堆的大小始终固定为K,仅存储K个候选元素)。

优劣势

  • 优势:性能稳定(无最坏情况风险),支持动态数据流(如实时统计"最近1小时最小K个请求延迟",无需存储全量数据);
  • 劣势:空间开销随K增长(当K接近n时,空间复杂度接近O(n),不如快速选择法的原地操作)。

计数排序法:数值受限场景的"最优解"

核心逻辑

基于"元素数值范围有限"的前提,用计数统计直接生成结果,属于"非比较类排序"的延伸:

  1. 确定范围:遍历数组找到最小值min和最大值max,确定数值区间[min, max];
  2. 统计次数:用"计数数组"记录每个数值在原数组中的出现次数;
  3. 生成结果:从最小数值开始,依次取出元素,直到取出总数达到K,这些元素即为"最小的K个元素"。

复杂度分析

  • 时间复杂度:O(n + m)(n为数组长度,m为数值范围大小(max - min + 1),统计次数需O(n),生成结果需O(m));
  • 空间复杂度:O(m)(需额外存储"计数数组",大小为数值范围m)。

优劣势

  • 优势:时间开销极低(接近O(n)),无比较操作,对CPU缓存友好;
  • 劣势:强依赖"数值范围小"的前提(若m远大于n,如元素为1~1e9,计数数组会溢出或占用极大内存)。

快速选择法:通用场景的"最优解"

核心逻辑

基于"快排分区思想"的剪枝优化,无需全量排序,仅聚焦"前K小元素"所在区间:

  1. 分区操作:随机选基准值,用三指针将数组划分为三部分------左区间(<基准)、中间区间(=基准)、右区间(>基准);
  2. 区间判断:计算左区间、中间区间长度:
    • 若K≤左区间长度:"最小K个"在左区间,递归处理左区间;
    • 若K≤左区间+中间区间长度:"最小K个"已包含左区间+部分中间区间,无需继续递归;
    • 若K更大:调整K值(K=K-左区间长度-中间区间长度),递归处理右区间;
  3. 结果获取:递归结束后,数组前K个元素即为"最小的K个元素"(无需关注内部顺序)。

复杂度分析

  • 时间复杂度:平均O(n)(每次仅递归处理一个子区间,子区间长度平均减半,总操作数为n + n/2 + n/4 + ... ≈ 2n),最坏O(n²)(极端分区失衡,如有序数组固定选边界为基准,但随机基准可将此概率降至极低);
  • 空间复杂度:O(log n)(递归栈开销,平均递归深度为log n,最坏O(n))。

优劣势

  • 优势:时间最优(平均O(n)),原地操作(无额外数组开销),无数值范围限制,是通用场景下的最优选择;
  • 劣势:实现需理解"分区+剪枝"逻辑,对新手有一定难度;最坏情况存在性能风险(需通过随机基准规避)。
解法 核心逻辑关键词 平均时间复杂度 空间复杂度 核心优势 核心局限 适用场景
全排序法 全量排序+截取前K O(n log n) O(log n) 逻辑直观、代码简单 无效计算多、效率低 小规模数组、快速写可行解
堆选择法 最大堆+动态筛选 O(n log K) O(K) 性能稳定、支持动态数据流 空间随K增长 K远小于n、动态数据流
计数排序法 数值统计+顺序生成 O(n + m) O(m) 时间开销极低 依赖数值范围有限 元素数值范围小的场景
快速选择法 分区剪枝+聚焦目标区间 O(n) O(log n) 时间最优、原地操作、通用 实现有难度、最坏风险低 大规模数组、无数值限制的通用场景

我们这篇博客着重理解快速选择法

快速选择法算法原理

核心思路还是"快速选择",但这次的目标从"找某一个元素"变成了"找某一批元素"。关键在于:我们不需要把整个数组排好序,只要通过"分区"让"前k小的元素"乖乖待在数组最前面,后面的元素是什么样,完全不用管

第一步:理解"分区"的核心作用

和三指针快排一样,我们随机选一个基准值mark,把数组分成三部分:

  • 左区间[begin, left]:所有元素 小于 mark
  • 中间区间[left+1, right-1]:所有元素 等于 mark
  • 右区间[right, end]:所有元素 大于 mark

举个例子:数组[1,3,5,7,2,4,6,8],若选mark=4,分区后会变成[1,3,2,4,5,7,6,8]------左区间[1,3,2](<4)、中间[4](=4)、右区间[5,7,6,8](>4)。

第二步:判断"前k小"藏在哪里

分区后,我们不用管右区间(因为里面的元素都比mark大,肯定不是"前k小"的一部分),只需看左区间和中间区间的长度:

  • 设左区间长度为a = left - begin + 1(小于mark的元素个数);
  • 中间区间长度为b = right - left - 1(等于mark的元素个数)。

然后分三种情况判断:

  1. k <= a:前k小的元素全在左区间里(比如k=3,左区间有3个元素,刚好够),只需递归处理左区间;
  2. k <= a + b:前k小的元素=左区间全部 + 中间区间的一部分(比如k=4,左区间3个+中间1个,刚好4个),此时不用再递归了------因为左+中间的元素已经是前k小,且都在数组前面;
  3. k > a + b:前k小的元素=左区间全部 + 中间区间全部 + 右区间的一部分(比如k=5,左3+中间1=4,还缺1个,需从右区间找),此时调整k值(k = k - a - b),递归处理右区间。

第三步:递归结束后,直接"切"出前k个

当递归停止时,数组的前k个元素就是我们要找的"最小k个数"------不管它们内部顺序如何,只要满足"比后面的元素小"就行。比如上面的例子,最终数组前4个元素是[1,2,3,4],直接返回这部分即可。

代码实现

cpp 复制代码
class Solution {
public:
    // 递归函数:在[begin, end]区间内调整,让前k小的元素落到区间最前面
    // 注意:这个函数没有返回值!它的作用是"原地修改数组"
    void SmaK(vector<int>& arr,int begin,int end, int k)
    {
        // 递归终止条件:
        // 1. 区间长度为0(begin >= end);
        // 2. k无效(k<=0,或k比当前区间元素还多,不用调整)
        if (begin >= end || k <= 0 || k > end - begin + 1) {
            return; 
        }

        // 1. 随机选基准值,三指针分区(<mark, =mark, >mark)
        int index = (rand() % (end - begin + 1)) + begin;
        int mark = arr[index];
        int left = begin - 1, right = end + 1, i = begin;
        while(i < right)
        {
            if(arr[i] < mark)           // 归入左区间,left右移+遍历指针右移
                swap(arr[++left],arr[i++]);
            else if(arr[i] == mark)     // 归入中间区间,直接遍历下一个
                i++;
            else                        // 归入右区间,right左移(交换来的元素未判断,i不右移)
                swap(arr[--right],arr[i]);  
        }

        // 2. 计算左区间和中间区间长度,判断前k小的位置
        int a = left - begin + 1;      // 左区间长度(<mark的元素个数)
        int b = right - left - 1;      // 中间区间长度(=mark的元素个数)

        if(k <= a) {
            // 情况1:前k小全在左区间,递归处理左区间
            SmaK(arr,begin,left,k);
        } else if(k <= a + b) {
            // 情况2:前k小=左区间全部+中间区间部分,不用再调整,直接返回
            return;
        } else {
            // 情况3:前k小还需要右区间的部分元素,调整k后递归右区间
            SmaK(arr,right,end,k - a - b);
        }
    }

    vector<int> smallestK(vector<int>& arr, int k) {
        // 边界处理1:空数组或k<=0,返回空数组
        if (arr.empty() || k <= 0) {
            return {};
        }
        // 边界处理2:k >= 数组长度,返回整个数组
        if (k >= arr.size()) {
            return arr;
        }

        // 初始化随机种子(避免固定基准导致的最坏情况)
        srand(time(nullptr));
        // 调用SmaK原地调整数组,让前k小的元素落到数组最前面
        SmaK(arr,0,arr.size()-1,k);
        // 直接截取数组前k个元素返回(不用管后面的元素)
        return {arr.begin(), arr.begin() + k};
    }
};

细节分析

函数返回值

在最开始我的SmaK函数是由返回值的,但是在完全写完我发现返回值完全是多余的,下面我们一起讨论下为什么SmaK函数是void类型,没有返回值,是怎么拿到前k小的元素的呢?

关键在于 "原地修改"

  1. SmaK的作用不是"返回元素",而是"调整数组结构"------通过分区和递归,把所有"前k小的元素"都移到数组的前k个位置;
  2. SmaK执行完后,数组的前k个元素已经满足"都是最小的k个"(顺序不影响),此时直接用迭代器arr.begin()arr.begin()+k截取这部分,就能得到答案;
  3. 这种方式比"函数返回子数组"更高效------避免了额外的数组拷贝,空间复杂度更低(只有递归栈的O(log n))。

举个直观的例子:

原数组[3,1,4,2,5],k=3。SmaK执行后,数组可能变成[1,2,3,4,5](也可能是[2,1,3,5,4])------不管前3个元素顺序如何,直接截取[1,2,3](或[2,1,3])就是正确答案。

为什么优先选"快速选择"?

  • 时间上:平均O(n)比O(n log k)和O(n log n)都快,尤其当n很大时(比如n=1e5),优势更明显;
  • 空间上:原地操作,不需要额外存储堆或新数组,更节省内存;
  • 实现上:复用"三指针分区"逻辑,和"第K个最大元素"代码差异小,容易掌握。

避坑指南:这些细节别踩雷!

  1. 随机种子必须初始化

    忘记写srand(time(nullptr))会导致基准值固定,遇到有序数组时时间复杂度退化为O(n²)------比如数组[1,2,3,4,5],固定选最后一个元素当基准,每次分区都失衡。

  2. 递归终止条件要全面
    begin >= end(区间空)、k <=0(无效k)、k > end - begin +1(k比当前区间元素多),这三个条件缺一不可,否则会出现数组越界或无效递归。

  3. 截取数组时别越界

    最终返回{arr.begin(), arr.begin() + k}时,要确保k <= arr.size()------不过我们在smallestK函数开头已经做了边界处理(k >= arr.size()时直接返回整个数组),这里不会出问题。

下题预告

下一篇我们将聚焦力扣912. 排序数组 ,我们通过这几天我相信大家对快排已经是胸有成竹了!之后开启下一个主流排序:归并排序------这道题是掌握归并排序最经典的"实战载体",吃透它能帮你打通"分治思想"到"稳定排序"的关键链路。

Doro又又又带着小花🌸来啦!🌸奖励🌸看到这里的你!如果这篇「最小K个数」的博客帮你搞懂了"不排序找前k小"的巧思,别忘了点赞支持呀!把它收藏起来,以后复习"快速选择"时翻出来,就能快速回忆起核心逻辑~关注我,我会持续更新算法系列的博客,有什么好的想法我们讨论,对算法感兴趣的朋友可以去看下我的算法专辑里面有更多有意思的算法等着你!

相关推荐
ALex_zry3 小时前
C++中使用gRPC over Unix Domain Sockets的高性能进程间通信技术解析
开发语言·c++·unix
学学学无无止境3 小时前
将有序数组转换为二叉搜索树-力扣
leetcode
sun༒3 小时前
递归经典例题
java·算法
小年糕是糕手3 小时前
【C语言】函数栈帧的创建和销毁
java·c语言·开发语言·数据结构·c++·链表
Lear3 小时前
【数组】代码随想录 44.开发商购买土地
算法
CoovallyAIHub3 小时前
OmniNWM:突破自动驾驶世界模型三大瓶颈,全景多模态仿真新标杆(附代码地址)
深度学习·算法·计算机视觉
努力努力再努力wz3 小时前
【Linux进阶系列】:信号(下)
java·linux·运维·服务器·开发语言·数据结构·c++
zzzsde4 小时前
【C++】stack和queue:使用&&OJ题&&模拟实现
开发语言·c++
TU^4 小时前
C语言习题~day27
c语言·数据结构·算法