LeetCode中等难度题目「373. 查找和最小的 K 对数字」,这道题核心考察优先队列(小顶堆)的应用,同时需要兼顾去重和边界处理,很多同学容易在堆调整和去重逻辑上踩坑,今天就结合代码一步步讲明白。
一、题目核心信息
先明确题目要求,避免理解偏差:
-
给定两个非递减顺序排列的整数数组 nums1 和 nums2,以及一个整数 k;
-
定义“数对”(u, v),u来自nums1,v来自nums2;
-
需要找出和最小的 k 个数对,返回这k个数对组成的二维数组。
关键提示:数组是非递减的,这是解题的重要突破口;k可能小于所有可能数对的数量(此时返回所有数对中最小的k个),也可能大于(此时返回所有数对)。
二、解题思路分析
拿到题目,首先想到的是“暴力解法”——枚举所有可能的数对,计算它们的和,排序后取前k个。但这种方法的时间复杂度是 O(m*n log(m*n))(m、n分别是nums1、nums2的长度),当数组长度较大时(比如m、n都是1e4),会直接超时,所以必须寻找更高效的解法。
结合数组“非递减”的特性,我们可以用 小顶堆(优先队列) 来优化:
-
初始时,将最小的可能数对(nums1[0], nums2[0])加入堆中,同时用一个集合记录已加入堆的数对下标(i,j),避免重复加入;
-
每次从堆顶取出和最小的数对,加入结果集;
-
取出堆顶数对(i,j)后,将其“下一个可能的最小数对”(i, j+1)和(i+1, j)加入堆中(前提是下标未越界且未被访问过);
-
重复步骤2-3,直到取出k个数对,或堆为空(所有数对都已取出)。
为什么这样可行?因为数组是非递减的,(i,j)的下一个最小和,必然是从(i,j+1)或(i+1,j)中产生——比如nums1[i] ≤ nums1[i+1],nums2[j] ≤ nums2[j+1],所以(i,j)之后,最小的和一定是这两个数对之一,无需枚举其他无关数对,大大减少了计算量。
时间复杂度优化为 O(k log k):每次堆的插入和弹出操作都是 O(log k),总共执行k次,效率远高于暴力解法。
三、完整代码及逐行解析
先贴出完整可运行代码(TypeScript),再逐行拆解关键逻辑,重点讲解堆调整和去重细节:
function kSmallestPairs(nums1: number[], nums2: number[], k: number): number[][] {
const m = nums1.length;
const n = nums2.length;
const heap: [number, number, number][] = []; // 小顶堆,存储 [和, nums1下标, nums2下标]
const visited = new Set<string>(); // 记录已加入堆的下标对,避免重复
const res: number[][] = []; // 存储结果
// 封装push函数:将下标(i,j)对应的数对加入堆(需判断边界和去重)
function push(i: number, j: number) {
if (i >= m || j >= n) return; // 下标越界,直接返回
const key = `${i},${j}`; // 用字符串标记下标对,方便存入Set
if (visited.has(key)) return; // 已访问过,避免重复加入
visited.add(key); // 标记为已访问
heap.push([nums1[i] + nums2[j], i, j]); // 加入堆
// 堆的上浮调整(维护小顶堆特性)
let cur = heap.length - 1; // 当前插入元素的下标
while (cur > 0) {
const parent = (cur - 1) >> 1; // 父节点下标(等价于Math.floor((cur-1)/2))
if (heap[parent][0] <= heap[cur][0]) break; // 父节点更小,满足小顶堆,退出
// 父节点大于当前节点,交换两者
[heap[parent], heap[cur]] = [heap[cur], heap[parent]];
cur = parent; // 继续向上调整
}
}
push(0, 0); // 初始加入最小的数对下标(0,0)
// 循环取出堆顶元素,直到拿到k个结果或堆为空
while (heap.length && res.length < k) {
const top = heap[0]; // 堆顶是当前和最小的元素
const last = heap.pop()!; // 取出堆尾元素
if (heap.length > 0) {
heap[0] = last; // 将堆尾元素放到堆顶,准备下沉调整
// 堆的下沉调整(维护小顶堆特性)
let cur = 0;
const len = heap.length;
while (true) {
let left = cur * 2 + 1; // 左子节点下标
let right = cur * 2 + 2; // 右子节点下标
let minIdx = cur; // 记录当前最小元素的下标
// 比较左子节点和当前最小元素,更新minIdx
if (left < len && heap[left][0] < heap[minIdx][0]) minIdx = left;
// 比较右子节点和当前最小元素,更新minIdx
if (right < len && heap[right][0] < heap[minIdx][0]) minIdx = right;
if (minIdx === cur) break; // 没有比当前节点更小的子节点,退出
// 交换当前节点和最小子节点
[heap[cur], heap[minIdx]] = [heap[minIdx], heap[cur]];
cur = minIdx; // 继续向下调整
}
}
// 将堆顶元素对应的数对加入结果集
const [sum, i, j] = top;
res.push([nums1[i], nums2[j]]);
// 加入下一个可能的最小数对(i,j+1)和(i+1,j)
push(i, j + 1);
push(i + 1, j);
}
return res;
}
关键细节拆解(避坑重点)
1. 堆的存储结构
堆中存储的是 [和, nums1下标, nums2下标],而不是直接存储数对(u,v)。这样做的目的是,方便后续取出下标后,快速找到“下一个可能的数对”(i,j+1)和(i+1,j),同时通过和的大小维护小顶堆。
2. 去重逻辑(visited集合)
为什么需要去重?比如(i+1,j)和(i,j+1)可能会指向同一个下标对(比如i=1,j=0和i=0,j=1,后续可能都会衍生出(1,1)),如果不去重,会导致同一个数对多次加入堆中,浪费空间和计算资源。
这里用 ${i},${j} 作为key存入Set,既简洁又能唯一标识一个下标对,避免重复。
3. 堆的调整(上浮+下沉)
这是小顶堆的核心,也是最容易出错的地方,代码中已经标注了关键步骤,再补充2个易错点:
-
上浮调整:插入元素后,从当前位置向上和父节点比较,只要当前元素比父节点小,就交换,直到父节点更小或到达堆顶;
-
下沉调整:取出堆顶后,将堆尾元素放到堆顶,然后向下和左右子节点比较,找到最小的子节点交换,直到没有更小的子节点或到达堆底;
-
注意:下沉调整时,必须先判断左右子节点是否越界(left < len、right < len),否则会报错。
4. 边界处理
有两个边界需要注意:
-
下标越界:push函数中,先判断i >= m或j >= n,避免访问数组不存在的元素;
-
k大于所有数对数量:当堆为空时,说明所有数对都已取出,此时即使res.length < k,也需要退出循环,返回已有的所有数对。
四、总结与优化方向
核心总结
这道题的核心是“利用非递减数组的特性,用小顶堆筛选最小和数对”,避免暴力枚举。关键在于:
-
堆的维护(上浮+下沉),确保堆顶始终是当前最小和;
-
去重逻辑,避免重复加入同一个数对;
-
边界处理,防止下标越界和k超出数对总数的情况。