|
| 1 | +### 题目描述 |
| 2 | + |
| 3 | +这是 LeetCode 上的 **[373. 查找和最小的K对数字](https://leetcode-cn.com/problems/find-k-pairs-with-smallest-sums/solution/gong-shui-san-xie-duo-lu-gui-bing-yun-yo-pgw5/)** ,难度为 **中等**。 |
| 4 | + |
| 5 | +Tag : 「优先队列」、「二分」、「多路归并」 |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +给定两个以升序排列的整数数组 `nums1` 和 `nums2` , 以及一个整数 `k` 。 |
| 10 | + |
| 11 | +定义一对值 $(u,v),ドル其中第一个元素来自 `nums1`,第二个元素来自 `nums2`。 |
| 12 | + |
| 13 | +请找到和最小的 `k` 个数对 $(u_1,v_1), (u_2,v_2) ... (u_k,v_k)$ 。 |
| 14 | + |
| 15 | +示例 1: |
| 16 | +``` |
| 17 | +输入: nums1 = [1,7,11], nums2 = [2,4,6], k = 3 |
| 18 | + |
| 19 | +输出: [1,2],[1,4],[1,6] |
| 20 | + |
| 21 | +解释: 返回序列中的前 3 对数: |
| 22 | + [1,2],[1,4],[1,6],[7,2],[7,4],[11,2],[7,6],[11,4],[11,6] |
| 23 | +``` |
| 24 | +示例 2: |
| 25 | +``` |
| 26 | +输入: nums1 = [1,1,2], nums2 = [1,2,3], k = 2 |
| 27 | + |
| 28 | +输出: [1,1],[1,1] |
| 29 | + |
| 30 | +解释: 返回序列中的前 2 对数: |
| 31 | + [1,1],[1,1],[1,2],[2,1],[1,2],[2,2],[1,3],[1,3],[2,3] |
| 32 | +``` |
| 33 | +示例 3: |
| 34 | +``` |
| 35 | +输入: nums1 = [1,2], nums2 = [3], k = 3 |
| 36 | + |
| 37 | +输出: [1,3],[2,3] |
| 38 | + |
| 39 | +解释: 也可能序列中所有的数对都被返回:[1,3],[2,3] |
| 40 | +``` |
| 41 | + |
| 42 | +提示: |
| 43 | +* 1ドル <= nums1.length, nums2.length <= 10^4$ |
| 44 | +* $-10^9 <= nums1[i], nums2[i] <= 10^9$ |
| 45 | +* $nums1, nums2 均为升序排列$ |
| 46 | +* 1ドル <= k <= 1000$ |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +### 基本分析 |
| 51 | + |
| 52 | +这道题和 [(题解) 786. 第 K 个最小的素数分数](https://leetcode-cn.com/problems/k-th-smallest-prime-fraction/solution/gong-shui-san-xie-yi-ti-shuang-jie-you-x-8ymk/) 几乎是一模一样,先做哪一道都是一样的,难度上没有区别 🤣 |
| 53 | + |
| 54 | +最常规的做法是使用「多路归并」,还不熟悉「多路归并」的同学,建议先学习前置🧀:[多路归并入门](https://mp.weixin.qq.com/s?__biz=MzU4NDE3MTEyMA==&mid=2247490029&idx=1&sn=bba9ddff88d247db310406ee418d5a15&chksm=fd9cb2f2caeb3be4b1f84962677337dcb5884374e5b6b80340834eaff79298d11151da2dd5f7&token=252055586&lang=zh_CN#rd),里面讲述了如何从「朴素优先队列」往「多路归并」进行转换。 |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +### 多路归并 |
| 59 | + |
| 60 | +令 $nums1$ 的长度为 $n,ドル$nums2$ 的长度为 $m,ドル所有的点对数量为 $n * m$。 |
| 61 | + |
| 62 | +其中每个 $nums1[i]$ 参与所组成的点序列为: |
| 63 | + |
| 64 | +$$ |
| 65 | +[(nums1[0], nums2[0]), (nums1[0], nums2[1]), ..., (nums1[0], nums2[m - 1])]\\ |
| 66 | +[(nums1[1], nums2[0]), (nums1[1], nums2[1]), ..., (nums1[1], nums2[m - 1])]\\ |
| 67 | +...\\ |
| 68 | +[(nums1[n - 1], nums2[0]), (nums1[n - 1], nums2[1]), ..., (nums1[n - 1], nums2[m - 1])]\\ |
| 69 | +$$ |
| 70 | + |
| 71 | +由于 $nums1$ 和 $nums2$ 均已按升序排序,因此每个 $nums1[i]$ 参与构成的点序列也为升序排序,这引导我们使用「多路归并」来进行求解。 |
| 72 | + |
| 73 | +具体的,起始我们将这 $n$ 个序列的首位元素(点对)以二元组 $(i, j)$ 放入优先队列(小根堆),其中 $i$ 为该点对中 $nums1[i]$ 的下标,$j$ 为该点对中 $nums2[j]$ 的下标,这步操作的复杂度为 $O(n\log{n})$。这里也可以得出一个小优化是:我们始终确保 $nums1$ 为两数组中长度较少的那个,然后通过标识位来记录是否发生过交换,确保答案的点顺序的正确性。 |
| 74 | + |
| 75 | +每次从优先队列(堆)中取出堆顶元素(含义为当前未被加入到答案的所有点对中的最小值),加入答案,并将该点对所在序列的下一位(如果有)加入优先队列中。 |
| 76 | + |
| 77 | +举个 🌰,首次取出的二元组为 $(0, 0),ドル即点对 $(nums1[0], nums2[0]),ドル取完后将序列的下一位点对 $(nums1[0], nums2[1])$ 以二元组 $(0, 1)$ 形式放入优先队列。 |
| 78 | + |
| 79 | +可通过「反证法」证明,每次这样的「取当前,放入下一位」的操作,可以确保当前未被加入答案的所有点对的最小值必然在优先队列(堆)中,即前 $k$ 个出堆的元素必然是所有点对的前 $k$ 小的值。 |
| 80 | + |
| 81 | +**代码(感谢 [@Benhao](/u/himymben/) 同学提供的其他语言版本):** |
| 82 | +```Java |
| 83 | +class Solution { |
| 84 | + boolean flag = true; |
| 85 | + public List<List<Integer>> kSmallestPairs(int[] nums1, int[] nums2, int k) { |
| 86 | + List<List<Integer>> ans = new ArrayList<>(); |
| 87 | + int n = nums1.length, m = nums2.length; |
| 88 | + if (n > m && !(flag = false)) return kSmallestPairs(nums2, nums1, k); |
| 89 | + PriorityQueue<int[]> q = new PriorityQueue<>((a,b)->(nums1[a[0]]+nums2[a[1]])-(nums1[b[0]]+nums2[b[1]])); |
| 90 | + for (int i = 0; i < Math.min(n, k); i++) q.add(new int[]{i, 0}); |
| 91 | + while (ans.size() < k && !q.isEmpty()) { |
| 92 | + int[] poll = q.poll(); |
| 93 | + int a = poll[0], b = poll[1]; |
| 94 | + ans.add(new ArrayList<>(){{ |
| 95 | + add(flag ? nums1[a] : nums2[b]); |
| 96 | + add(flag ? nums2[b] : nums1[a]); |
| 97 | + }}); |
| 98 | + if (b + 1 < m) q.add(new int[]{a, b + 1}); |
| 99 | + } |
| 100 | + return ans; |
| 101 | + } |
| 102 | +} |
| 103 | +``` |
| 104 | +- |
| 105 | +```Python3 |
| 106 | +class Solution: |
| 107 | + def kSmallestPairs(self, nums1: List[int], nums2: List[int], k: int) -> List[List[int]]: |
| 108 | + flag, ans = (n := len(nums1)) > (m := len(nums2)), [] |
| 109 | + if flag: |
| 110 | + n, m, nums1, nums2 = m, n, nums2, nums1 |
| 111 | + pq = [] |
| 112 | + for i in range(min(n, k)): |
| 113 | + heapq.heappush(pq, (nums1[i] + nums2[0], i, 0)) |
| 114 | + while len(ans) < k and pq: |
| 115 | + _, a, b = heapq.heappop(pq) |
| 116 | + ans.append([nums2[b], nums1[a]] if flag else [nums1[a], nums2[b]]) |
| 117 | + if b + 1 < m: |
| 118 | + heapq.heappush(pq, (nums1[a] + nums2[b + 1], a, b + 1)) |
| 119 | + return ans |
| 120 | +``` |
| 121 | +- |
| 122 | +```Golang |
| 123 | +func kSmallestPairs(nums1 []int, nums2 []int, k int) [][]int { |
| 124 | + n, m, ans := len(nums1), len(nums2), [][]int{} |
| 125 | + flag := n > m |
| 126 | + if flag { |
| 127 | + n, m, nums1, nums2 = m, n, nums2, nums1 |
| 128 | + } |
| 129 | + if n > k { |
| 130 | + n = k |
| 131 | + } |
| 132 | + pq := make(hp, n) |
| 133 | + for i := 0; i < n; i++ { |
| 134 | + pq[i] = []int{nums1[i] + nums2[0], i, 0} |
| 135 | + } |
| 136 | + heap.Init(&pq) |
| 137 | + for pq.Len() > 0 && len(ans) < k { |
| 138 | + poll := heap.Pop(&pq).([]int) |
| 139 | + a, b := poll[1], poll[2] |
| 140 | + if flag{ |
| 141 | + ans = append(ans, []int{nums2[b], nums1[a]}) |
| 142 | + }else{ |
| 143 | + ans = append(ans, []int{nums1[a], nums2[b]}) |
| 144 | + } |
| 145 | + if b < m - 1 { |
| 146 | + heap.Push(&pq, []int{nums1[a] + nums2[b + 1], a, b + 1}) |
| 147 | + } |
| 148 | + } |
| 149 | + return ans |
| 150 | +} |
| 151 | +// 最小堆模板 |
| 152 | +type hp [][]int |
| 153 | +func (h hp) Len() int { return len(h) } |
| 154 | +func (h hp) Less(i, j int) bool { return h[i][0] < h[j][0] } |
| 155 | +func (h hp) Swap(i, j int) { h[i], h[j] = h[j], h[i] } |
| 156 | +func (h *hp) Push(v interface{}) { *h = append(*h, v.([]int)) } |
| 157 | +func (h *hp) Pop() interface{} { a := *h; v := a[len(a)-1]; *h = a[:len(a)-1]; return v } |
| 158 | +``` |
| 159 | +* 时间复杂度:令 $M$ 为 $n$、$m$ 和 $k$ 三者中的最小值,复杂度为 $O(M + k) * \log{M})$ |
| 160 | +* 空间复杂度:$O(M)$ |
| 161 | + |
| 162 | +--- |
| 163 | + |
| 164 | +### 二分 |
| 165 | + |
| 166 | +我们还能够使用多次「二分」来做。 |
| 167 | + |
| 168 | +假设我们将所有「数对和」按照升序排序,两端的值分别为 $l = nums1[0] + nums2[0]$ 和 $r = nums1[n - 1] + nums2[m - 1]$。 |
| 169 | + |
| 170 | +因此我们可以在值域 $[l, r]$ 上进行二分,找到第一个满足「点对和小于等于 $x$ 的,且数量超过 $k$ 的值 $x$」。 |
| 171 | + |
| 172 | +之所以能够二分,是因为 $x$ 所在的点对和数轴上具有二段性: |
| 173 | + |
| 174 | +* 点对和小于 $x$ 的点对数量少于 $k$ 个; |
| 175 | +* 点对和大于等于 $x$ 的点对数量大于等于 $k$ 个。 |
| 176 | + |
| 177 | +判定小于等于 $x$ 的点对数量是否大于等于 $k$ 个这一步可直接使用循环来做,由于二分是从中间值开始,这一步不会出现跑满两层循环的情况。 |
| 178 | + |
| 179 | +当二分出第 $k$ 小的值为 $x$ 后,由于存在不同点对的点对和值相等,我们需要先将所有点对和小于等于 $x$ 的值加入答案,然后酌情把值等于 $x$ 的点对加入答案,知道满足答案数量为 $k$。 |
| 180 | + |
| 181 | +找值为 $x$ 的所有点对这一步,可以通过枚举 $nums1[i],ドル然后在 $nums2$ 上二分目标值 $x - nums1[i]$ 的左右端点来做。 |
| 182 | + |
| 183 | +最后,在所有处理过程中,我们都可以利用答案数组的大小与 $k$ 的关系做剪枝。 |
| 184 | + |
| 185 | +代码: |
| 186 | +```Java |
| 187 | +class Solution { |
| 188 | + int[] nums1, nums2; |
| 189 | + int n, m; |
| 190 | + public List<List<Integer>> kSmallestPairs(int[] n1, int[] n2, int k) { |
| 191 | + nums1 = n1; nums2 = n2; |
| 192 | + n = nums1.length; m = nums2.length; |
| 193 | + List<List<Integer>> ans = new ArrayList<>(); |
| 194 | + int l = nums1[0] + nums2[0], r = nums1[n - 1] + nums2[m - 1]; |
| 195 | + while (l < r) { |
| 196 | + int mid = l + r >> 1; |
| 197 | + if (check(mid, k)) r = mid; |
| 198 | + else l = mid + 1; |
| 199 | + } |
| 200 | + int x = r; |
| 201 | + for (int i = 0; i < n; i++) { |
| 202 | + for (int j = 0; j < m; j++) { |
| 203 | + if (nums1[i] + nums2[j] < x) { |
| 204 | + List<Integer> temp = new ArrayList<>(); |
| 205 | + temp.add(nums1[i]); temp.add(nums2[j]); |
| 206 | + ans.add(temp); |
| 207 | + } else break; |
| 208 | + } |
| 209 | + } |
| 210 | + for (int i = 0; i < n && ans.size() < k; i++) { |
| 211 | + int a = nums1[i], b = x - a; |
| 212 | + int c = -1, d = -1; |
| 213 | + l = 0; r = m - 1; |
| 214 | + while (l < r) { |
| 215 | + int mid = l + r >> 1; |
| 216 | + if (nums2[mid] >= b) r = mid; |
| 217 | + else l = mid + 1; |
| 218 | + } |
| 219 | + if (nums2[r] != b) continue; |
| 220 | + c = r; |
| 221 | + l = 0; r = m - 1; |
| 222 | + while (l < r) { |
| 223 | + int mid = l + r + 1 >> 1; |
| 224 | + if (nums2[mid] <= b) l = mid; |
| 225 | + else r = mid - 1; |
| 226 | + } |
| 227 | + d = r; |
| 228 | + for (int p = c; p <= d && ans.size() < k; p++) { |
| 229 | + List<Integer> temp = new ArrayList<>(); |
| 230 | + temp.add(a); temp.add(b); |
| 231 | + ans.add(temp); |
| 232 | + } |
| 233 | + } |
| 234 | + return ans; |
| 235 | + } |
| 236 | + boolean check(int x, int k) { |
| 237 | + int ans = 0; |
| 238 | + for (int i = 0; i < n && ans < k; i++) { |
| 239 | + for (int j = 0; j < m && ans < k; j++) { |
| 240 | + if (nums1[i] + nums2[j] <= x) ans++; |
| 241 | + else break; |
| 242 | + } |
| 243 | + } |
| 244 | + return ans >= k; |
| 245 | + } |
| 246 | +} |
| 247 | +``` |
| 248 | +* 时间复杂度:假设点对和的值域大小范围为 $M,ドル第一次二分的复杂度为 $O((n * m) * \log{M})$;统计点对和值小于目标值 $x$ 的复杂度为 $O(n * m)$;统计所有点对和等于目标值的复杂度为 $O(\max(n * \log{m}, k))$(整个处理过程中利用了大小关系做了剪枝,大多循环都不会跑满,实际计算量会比理论分析的要低) |
| 249 | +* 空间复杂度:$O(k)$ |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +### 最后 |
| 254 | + |
| 255 | +这是我们「刷穿 LeetCode」系列文章的第 `No.373` 篇,系列开始于 2021年01月01日,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。 |
| 256 | + |
| 257 | +在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。 |
| 258 | + |
| 259 | +为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:https://github.com/SharingSource/LogicStack-LeetCode 。 |
| 260 | + |
| 261 | +在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。 |
| 262 | + |
0 commit comments