全栈开发知识库

100.查找和最小的 K 对数字

题目

给定两个以 非递减顺序排列 的整数数组 nums1 和 nums2 , 以及一个整数 k 。

定义一对值 (u,v),其中第一个元素来自 nums1,第二个元素来自 nums2 。

请找到和最小的 k 个数对 (u1,v1), (u2,v2) ... (uk,vk) 。

示例 1:

  • 输入: nums1 = [1,7,11], nums2 = [2,4,6], k = 3
  • 输出: [[1,2],[1,4],[1,6]] 解释: 返回序列中的前 3 对数: [1,2],[1,4],[1,6],[7,2],[7,4],[11,2],[7,6],[11,4],[11,6]

示例 2:

  • 输入: nums1 = [1,1,2], nums2 = [1,2,3], k = 2
  • 输出: [[1,1],[1,1]] 解释: 返回序列中的前 2 对数: [1,1],[1,1],[1,2],[2,1],[1,2],[2,2],[1,3],[1,3],[2,3]

思路

最直接的暴力方法

假设:

nums1 = [1, 7, 11]
nums2 = [2, 4, 6]

我们可以把所有组合都枚举出来:

(1,2) → 3
(1,4) → 5
(1,6) → 7
(7,2) → 9
(7,4) → 11
(7,6) → 13
(11,2) → 13
(11,4) → 15
(11,6) → 17

然后排序,取前 K 个。

如果两个数组长度分别为 m 和 n:

  • 一共有 m * n 个组合
  • 排序需要 O(mn log(mn))

显然不够优秀。

我们真正需要思考的是:

两个数组本身已经有序,能不能利用这个性质?

答案是可以。

假设:

nums1 = [1, 7, 11]
nums2 = [2, 4, 6]

我们可以把它们看成三条有序的「链」:

以 1 开头:
[1,2] → 3       [1,4] → 5       [1,6] → 7
以 7 开头:
[7,2] → 9       [7,4] → 11       [7,6] → 13
以11开头: 
[11,2] → 13       [11,4] → 15       [11,6] → 17

注意,因为两个数组都是从小到大排列的,所以每一行都是递增的。

现在问题就变成了:

从多个已经排好序的序列中,找出最小的K个元素。

对于 nums1 中的每一个元素 a,它和 nums2 中的所有元素配对,这些配对的和是随着 nums2 下标增加而递增的(因为 nums2 本身升序)。

这其实就是非常经典的:

多路归并(K-way Merge)

而多路归并最经典的数据结构就是:

最小堆

那我们需要不需要把上面例子中所有的9个数字都计算出来? 其实: 不需要,因为每一排本身已经是从小到大排列的。

比如第一排:
3 -> 5 -> 7,所以一开始我们只需要关注3就可以,因为5和7一定都排在3后面 第二排: 9 -> 11 -> 13,我们只需要先关注9。 第三排: 13 -> 15 -> 17,我们只需要先关注13.

所以一开始,我们只需要三个数字(每一排的第一个数):

3
9
13

我们现在手里有:

3
9
13

谁最小? 3最小,所以第一个答案就是[1, 2], 我们把3拿走之后怎么办?
把第一排往后移动一步,第一排原来是3 -> 5 -> 7,我们拿走了3,那么第一排现在应该看: 5。 于是现在我们关注:

5
9
13

谁最小? 5,所以第二个答案就是[1, 4],再把第一排向后移动。

整个过程

第一排:3 → 5 → 7
第二排:9 → 11 → 13
第三排:13 → 15 → 17

我们开始每次只看每一排的第一个数:

3
9
13

然后找这几个当前数字里面最小的那个。

找到了之后: 把这一排往后移动一个,然后继续找最小的。

但是假设有10000个数组,我们每次都需要再10000个数字里面找最小的。

如果找很多次,又会变慢,所以我们需要一个东西:

自动帮我们维护当前最小值。
这就是PriorityQueue,也就是小顶堆。

那么PriorityQueue里面到底放什么?

我们不能只放:

3
9
13

因为我们不仅需要知道: 当前最小的和是多少。
还需要知道: 这个最小的和来自哪两个数字?
例如: 3其实是来自nums1[0]和nums2[0]的和。

所以我们在PriorityQueue里面存的是: [i, j],也就是两个数组的下标。

也就是说: [i, j]表示的是nums1[i] 和 nums2[j]。

这就是我们要写PriorityQueue的Comparator的地方:

val pq = PriorityQueue<IntArray> { a, b ->
    (nums1[a[0]] + nums2[a[1]])
        .compareTo(nums1[b[0]] + nums2[b[1]])
}

也就是告诉PriorityQueue:

我这里面放的是int数组[i, j],你不要直接比较[i, j],你要计算nums1[i] = nums2[j],谁的和小,谁就优先。

代码实现

class Solution {
    fun kSmallestPairs(
        nums1: IntArray,
        nums2: IntArray,
        k: Int
    ): List<List<Int>> {

        val result = mutableListOf<List<Int>>()

        if (nums1.isEmpty() || nums2.isEmpty()) {
            return result
        }

        // 小顶堆
        val pq = PriorityQueue<IntArray> { a, b ->
            (nums1[a[0]] + nums2[a[1]])
                .compareTo(nums1[b[0]] + nums2[b[1]])
        }

        // 每一排先放第一个
        val count = minOf(nums1.size, k)

        for (i in 0 until count) {
            pq.offer(intArrayOf(i, 0))
        }

        // 找 k 个
        repeat(k) {

            if (pq.isEmpty()) {
                return@repeat
            }

            // 拿当前最小的
            val cur = pq.poll()

            val i = cur[0]
            val j = cur[1]

            // 加入答案
            result.add(
                listOf(nums1[i], nums2[j])
            )

            // 这一排还有下一个
            if (j + 1 < nums2.size) {
                pq.offer(
                    intArrayOf(i, j + 1)
                )
            }
        }

        return result
    }
}

PriorityQueue介绍

PriorityQueue是Java/Kotlin一个非常重要的数据结构。

一个自动帮你维护最值的容器。

最常见的用途就是:

  • 找最小值 -> 小顶堆
  • 找最大值 -> 大顶堆
  • Top K
  • K路合并
  • 贪心算法
  • BFS等

PriorityQueue其实就是一个队列。

普通队列是: 先进先出 FIFO 但是PriorityQueue不一样,它是:

谁的优先级最高,谁先出来。

例如:

val pq = PriorityQueue<Int>()

pq.offer(5)
pq.offer(2)
pq.offer(8)
pq.offer(1)

虽然加入的顺序是: 5, 2, 8, 1 但是: pq.poll(),会得到1,再poll()是2,再poll是5,最后是8.

所以PriorityQueue会按照优先级取元素,默认是最小的元素优先。

最重要的三个方法:
- offer(): 加入元素 - peek(): 查看堆顶,但是不删除 - poll(): 取出对顶,同时删除它。

那最大堆怎么办?

因为PriorityQueue默认是小顶堆,如果想要大顶堆,那就需要告诉PriorityQueue:

数字越大,优先级越高

最简单的写法是:

val pq = PriorityQueue<Int>(compareByDescending { it })

另一种写法就是: Comparator: 自定义优先级规则

val pq = PriorityQueue<Int> { a, b ->
    b.compareTo(a)
}

复杂度

  • 时间复杂度: O(k log(min(m, n)))
  • 空间复杂度: O(min(m, n))(堆的大小)


  • 邮箱 :charon.chui@gmail.com
  • Good Luck!
本页由 Markdown 自动渲染 · 共 628 篇笔记