LeetCode373查找和最小的K对数字:TopK问题:「小根堆 & 多路归并」 |「二分 & 滑动窗口」

前言

  • 大家好,我是新人简书博主:「 个人主页」主要分享程序员生活、编程技术、以及每日的LeetCode刷题记录,欢迎大家关注我,一起学习交流,谢谢!
    正在坚持每日更新LeetCode每日一题,发布的题解有些会参考其他大佬的思路(参考资料的链接会放在最下面),欢迎大家关注我 ~ ~ ~
    今天是坚持写题解的18天(haha,从21年圣诞节开始的),大家一起加油!

  • 每日一题:LeetCode:373.查找和最小的K对数字

    • 时间:2022-01-14
    • 力扣难度:Meduim
    • 个人难度:Meduim
    • 数据结构:数组、堆、优先队列
    • 算法:多路归并 、二分
    • Tips:本题属于非常高频的面试题,有可能直接手撕,或者是以考察大文件排序、海量数据排序等场景题的方式出现
LeetCode每日一题.jpg

2022-01-14:LeetCode:373.查找和最小的K对数字

1. 题目描述

  • 题目:原题链接

    • 给定两个以 升序排列 的整数数组 nums1 和 nums2 , 以及一个整数 k 。
    • 定义一对值 (u,v),其中第一个元素来自 nums1,第二个元素来自 nums2 。
    • 请找到和最小的 k 个数对 (u1,v1), (u2,v2) ... (uk,vk) 。
  • 输入输出规范

    • 输入:两个升序数组
    • 输出:K个数对(i, j)
  • 输入输出示例

    • 输入:nums1 = [1,7,11], nums2 = [2,4,6], k = 3
    • 输出:[1,2],[1,4],[1,6]

2. 方法一:多路归并 & 小根堆

  • 思路:优先队列中维护K个数对

    • 本题是TopK类型问题,可以参考LC215数组的第K大个元素即求出一个序列中的K大或K小个元素,与普通的TopK问题不同的是本题是两个升序数组,然后求解的是K小个数对
    • 首先,可以想到最基础的方法是,将两个数组可以组成的数对全部枚举出来,并对这些元素进行排序,最终取出K小个元素对应的数对即可,时间复杂度取决于排序算法的复杂度,一般为O(m*nlog(m*n))
    • 这种方法属于暴力枚举,复杂度较高,所以需要进行优化,实际上,对于TopK问题,我们完全不需要对整个序列进行排序,而是只关心TopK个元素
    • 所以,我们只需要维护K个元素,并取出其中的最值,然后每次添加进来一个新元素,继续取出最值,这些最值组成的集合就是最终的TopK个元素
    • 这种思想类似于堆结构,即大根堆小根堆,Java中提供了基于堆思想的PriorityQueue优先队列结构,无需我们手动构建堆
  • 堆的常规解题方式:以K小为例

    • 方式一:结果为堆中元素
      • 对于单个序列的TopK问题,首先将序列的前 k 个元素添加到大根堆(降序)中
      • 然后遍历剩下的 n - k 个元素,逐个判断其与堆顶元素的大小,当前元素小时,取出堆顶,加入当前元素(堆调整)
      • 最终堆中剩余的 k 个元素就是TopK
    • 方式二:结果为堆中每次取出的元素
      • 对于多个序列的TopK问题,如本题是有两个独立的数组,需要先对各个子序列排序
      • 接着同样在小根堆(升序)中维护对应序列个数个元素,然后每次取出堆顶元素,并加入当前堆顶元素(最小值)所在序列的下一个值,一共进行 k 次
      • 取出的元素组成的集合( k 个)就是TopK,这种方式也成为多路归并,常见于大文件排序、海量数据排序等场景(面试热点)
  • 本题的解题步骤

    • 本题对应第二种方式的情况,但由于本题求解的数对,所以与普通的解法有一点区别
    • 由于本题给出的两个数组都是升序数组,可以发现,数对(num1[0], nums2[0])是最小的数对,且对于 nums1 中的一个元素,其与 nums2 中每个元素组成的数对序列也是升序的,反之亦然
    • 因此,当求解过程中确定了 (num1[i], nums2[j]) 为一个 TopK后,下一个TopK应该是从堆中已有元素和 (num1[i+1], nums2[j])、(num1[i], nums2[j+1]) 中产生
    • 首先我们将 k 个元素放入小根堆中,为了避免后续查找TopK时加入元素重复的问题,初始时以其中一个数组为基础,加入(0,0), (1,0), ... , (k-1, 0)这些元素,当取出一个元素 (i, j) 后,新加入的元素为(i, j + 1)
    • 取出的元素组成的就是TopK集合
  • 题解

    public List<List<Integer>> kSmallestPairs(int[] nums1, int[] nums2, int k) {
        if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) return null;
        List<List<Integer>> smallestPairs = new ArrayList<>();
        int n = nums1.length;
        int m = nums2.length;
        PriorityQueue<int[]> queue = new PriorityQueue<>(k, (a, b) -> (nums1[a[0]] + nums2[a[1]]) - (nums1[b[0]] + nums2[b[1]]));
        // 1. 维护 K 个元素到堆中 : (i, 0)
        for (int i = 0; i < Math.min(n, k); i++) {
            queue.add(new int[]{i, 0});
        }
        // 2. 取出堆顶元素并加入新元素
        while (k > 0 && !queue.isEmpty()) {
            int[] pairs = queue.poll();
            List<Integer> list = new ArrayList<>();
            list.add(nums1[pairs[0]]);
            list.add(nums2[pairs[1]]);
            smallestPairs.add(list);
            if(pairs[1] + 1 < m) queue.add(new int[]{pairs[0], pairs[1] + 1});
            k--;
        }
        return smallestPairs;
    }
    
  • 复杂度分析:n 和 m 分别是两个数组的大小,k 是要求的数对个数

    • 时间复杂度:O(k*log(min(n,k))),初始堆O(min(k,n)),堆调整O(log(min(n,k)))
    • 空间复杂度:O(min(n,k)

3. 方法二:二分 & 滑动窗口

  • 思路:通过二分确定序列前 k 个数对与后面的分界点的值

    • TopK问题也可以通过二分的思路来解决,因为数对序列可以等效为类似坐标轴的概念,一定存在一个分界点,将前 k 个元素与后面的序列分开
    • 数对的最小值为nums1[0] + nums2[0],最大值为nums1[n-1] + nums2[m-1]
    • 可以将最小值最大值作为左右起点,可通过二分查找,注意查找的条件不是找到满足大小关系的目标值,而是找到某个确保前面有 k 个,或者算上自身有 k 个数对元素的分界值divideNum
    • 二分法中,计算小于 mid 值的数对元素个数可以通过滑动窗口的方式,计算元素个数的复杂度为O(m+n),整个二分过程的复杂度为O((m+n)*log(Max(nums)-Min(nums)))
    • 找到分界值后,就可以遍历两个有序数组,将大小小于分界值的数对加入到结果集中,如果此时不足 k 个元素,则考虑将等于分界值的部分数对加入到结果集中,因为一共要加入 k 个数对,复杂度为O(k)
    • 注意:本题输出的顺序优先输出小索引的nums1数组,所以对于等于分界值的情况要注意查找的顺序
  • 题解:直接模拟

    // 方法二:二分 & 滑动窗口
    public List<List<Integer>> kSmallestPairs2(int[] nums1, int[] nums2, int k) {
        if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) return null;
        List<List<Integer>> smallestPairs = new ArrayList<>();
        int n = nums1.length;
        int m = nums2.length;
    
        // 二分查找第 k 小的数对和的大小
        int left = nums1[0] + nums2[0];
        int right = nums1[n - 1] + nums2[m - 1];
        while (left < right) {
            int mid = left + ((right - left) >> 1);
            long count = 0; // mid之前的元素的个数
            int start = 0;
            int end = m - 1;
            // 双指针查找当前比 mid 小的元素个数,用来确定二分的方向
            while (start < n && end >= 0) {
                if(count >= k) break;
                if (nums1[start] + nums2[end] > mid) {
                    end--;
                } else {
                    count += end + 1;
                    start++;
                }
            }
            // mid前的元素超过k个,向左二分,没超过向右
            if (count < k) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
    
        // 分界点的值
        int divideNum = left;
        // 找到小于分界点的值的数对,并添加到TopK中
    
        for (int num1 : nums1) {
            for (int num2 : nums2) {
                if( k > 0 && num1 + num2 < divideNum) {
                    List<Integer> list = new ArrayList<>();
                    list.add(num1);
                    list.add(num2);
                    smallestPairs.add(list);
                    k--;
                }else break;
            }
        }
    
        // 找到等于分界点的值的数对
        int index = m - 1;
        for (int i = 0; i < n && k > 0; i++) {
            // 找到第一个不大于分界点值的数对
            while (index >= 0 && nums1[i] + nums2[index] > divideNum) {
                index--;
            }
            for (int j = i; j >= 0; j--) {
                if(k > 0 && nums1[j] + nums2[index] == divideNum) {
                    List<Integer> list = new ArrayList<>();
                    list.add(nums1[j]);
                    list.add(nums2[index]);
                    smallestPairs.add(list);
                    k--;
                }else break;
            }
        }
        return smallestPairs;
    }
    
  • 复杂度分析:n 和 m 分别是两个数组的大小,k 是要求的数对个数

    • 时间复杂度:O(k+(m+n)*log(Max(nums)-Min(nums)))
    • 空间复杂度:O(1)

最后

如果本文有所帮助的话,欢迎大家可以给个三连「点赞」&「收藏」&「关注」 ~ ~ ~
也希望大家有空的时候光临我的其他平台,上面会更新Java面经、八股文、刷题记录等等,欢迎大家光临交流,谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容