LeetCode 2542. 最大子序列的分数
题目描述
题意分析
给两个等长数组
nums1与nums2,要从下标集合里挑出恰好k个位置。分数定义为「这k个位置在nums1上的取值之和」乘以「这k个位置在nums2上的取值的最小值」。求分数的最大值。第一件要看清的事:题目虽然叫「子序列」,但下标的先后顺序完全不影响分数——求和与求最小值都是与顺序无关的运算。所以这道题实质上是「选一个大小为
k的下标子集」,而不是真正意义上的子序列问题。这一点很重要,它意味着我们可以任意重排下标,不会破坏任何约束。第二件事:两个数组通过同一个下标绑定在一起,选中一个位置就同时拿走它的
nums1值和nums2值,不能拆开取。这是唯一的耦合来源。第三件事:目标函数是两个因子的乘积,而这两个因子的性质完全不同。左边是可加的——多选一个位置就多加一份,越多越好;右边是取极值的,多选一个位置只可能让最小值变小或不变,越多越糟。两个因子朝相反方向拉扯,正是本题的核心张力。
数据规模上,两个数组长度可达 $10^5$,元素值可达 $10^5$。所以和最大约 $10^{10}$,再乘上最小值可达 $10^{15}$——必须用 64 位整型,返回值类型本身也是长整型。同时这个规模排除了任何 $O(n^2)$ 的做法。
边界包括:
k等于数组长度(只有一种选法);k = 1(答案是所有位置各自乘积的最大值);nums2中存在大量相同值;以及所有nums1值都相同。
解法:按 nums2 降序枚举最小值 + 小顶堆维护 nums1 前 k 大
核心思路
暴力做法是枚举所有大小为
k的下标子集,$\binom{n}{k}$ 量级,完全不可行。稍好一点的想法是「优先选nums1大的位置」,但立刻会被反例打脸——nums1最大的那几个位置可能对应着极小的nums2值,把整体乘积拖垮。反过来「优先选nums2大的位置」同样不对,因为那些位置的nums1可能都很小。两个因子必须一起考虑。瓶颈在于「最小值」这个因子是被整个选择集合共同决定的,无法在逐个挑选时局部判断。破局的办法是把它从未知量变成枚举量。
关键观察:设最终选中的
k个位置里,nums2最小的那个位置是p。一旦固定了p,右因子就锁死为nums2[p],而左因子的最优选法立刻变得显然——在所有满足nums2[i] >= nums2[p]的位置中,挑nums1最大的k个(其中必须包含p本身)。因为右因子已经固定,剩下的唯一目标就是把左边的和做到最大。于是问题被拆成
n个独立的子问题:对每个可能的p,求「nums2不小于nums2[p]的那些位置中,nums1的前k大之和」,再乘以nums2[p],取所有结果的最大值。直接对每个
p重新扫一遍是 $O(n^2)$。让它变成线性对数的办法是按nums2从大到小遍历:这样当处理到位置p时,之前已经处理过的位置恰好就是「nums2不小于nums2[p]」的那些,它们构成一个只增不减的候选池。于是「前k大之和」可以增量维护——用一个小顶堆装当前选中的k个nums1值,堆顶是其中最小的那个;每来一个新值就压进去,若堆的大小超过k就弹掉堆顶(当前最小者),同时同步维护堆内元素之和。不变量是:遍历到第
t个位置时,堆中恰好是前t个位置(按nums2降序)里nums1最大的那 $\min(t, k)$ 个,sum是它们的和。每压入一个新元素并弹出当前最小者,仍然保持「留下的是最大的k个」,不变量得以维持。只有当堆的大小恰好等于
k时才更新答案,此时sum * nums2[i]是一个合法且在「以i为最小值位置」这一约束下最优的分数。注意此刻nums2[i]一定是堆中所有位置里nums2的最小值——因为它们都在i之前或就是i,而遍历是按nums2降序的。遍历完成后,所有可能的最小值位置都被枚举过,答案就是其中的最大者。
解题步骤
- 构造下标数组并按
nums2降序排序。排序的对象是下标而不是数值本身,因为两个数组靠下标绑定,必须整体搬动。降序是关键:它保证「已处理集合」始终等于「nums2不小于当前值的集合」。- 准备一个小顶堆和一个 64 位的和累加器。选小顶堆而非大顶堆,是因为要淘汰的永远是当前最小的
nums1值——只有小顶堆能 $O(1)$ 拿到它。用错方向会把最大的那个扔掉,结果反而更差。- 按排好的顺序逐个处理下标
i:把nums1[i]压入堆,同时把它加进sum。压入必须无条件进行,因为要先让它参与竞争,再决定谁被淘汰。- 若堆的大小超过
k,弹出堆顶并从sum中减去它。这一步把候选池重新压回k个。弹出与减法必须成对出现,任何一边漏掉都会让sum与堆内容脱节。- 若堆的大小恰好等于
k,用sum * nums2[i]更新答案。判定用「等于」而不是「不小于」也可以(大小永远不会超过k),但更重要的是它必须写在弹出之后——弹出之前堆里可能有k + 1个元素,sum不是合法的前k大之和。- 乘法两侧都要转成 64 位再相乘。
sum本身已是 64 位,nums2[i]是 32 位,在多数语言里会自动提升;但若把sum写成 32 位,乘积会在 $10^{15}$ 量级上悄无声息地溢出。- 返回累计的最大值。初始值取 0 是安全的,因为所有元素都是正整数,分数不可能为负。
以
nums1 = [1, 3, 3, 2]、nums2 = [2, 1, 3, 4]、k = 3走一遍,预期答案 12。按
nums2降序排下标:nums2[3] = 4、nums2[2] = 3、nums2[0] = 2、nums2[1] = 1,所以顺序是下标 3、2、0、1。处理下标 3:压入
nums1[3] = 2,sum = 2,堆是{2},大小 1 小于 3,不更新答案。处理下标 2:压入
nums1[2] = 3,sum = 5,堆是{2, 3},大小 2,仍不更新。处理下标 0:压入
nums1[0] = 1,sum = 6,堆是{1, 2, 3},大小恰好 3。此时nums2[0] = 2就是这三个位置里nums2的最小值,分数为 $6 \times 2 = 12$,答案更新为 12。处理下标 1:压入
nums1[1] = 3,sum = 9,堆变成四个元素{1, 2, 3, 3},超过k,弹出堆顶 1,sum = 8,堆是{2, 3, 3}。此时nums2[1] = 1,分数为 $8 \times 1 = 8$,小于 12,答案不变。返回 12,对应选择下标 0、2、3:
nums1之和为 $1 + 3 + 2 = 6$,nums2最小值为 $\min(2, 3, 4) = 2$,分数 12,与预期一致。再看
k = 1的用例nums1 = [4, 2, 3, 1, 1]、nums2 = [7, 5, 10, 9, 6],预期 30。按nums2降序的下标顺序是 2(10)、3(9)、0(7)、4(6)、1(5)。处理下标 2 时堆里只有nums1[2] = 3,sum = 3,大小已等于 1,分数 $3 \times 10 = 30$,答案 30。之后每一步都会先压入再弹出,堆里始终只留当前最大的一个nums1值:处理下标 3 时留下 3、分数 $3 \times 9 = 27$;处理下标 0 时 4 挤掉 3、分数 $4 \times 7 = 28$;处理下标 4 时仍是 4、分数 24;处理下标 1 时仍是 4、分数 20。最大值 30,正确。若把小顶堆错写成大顶堆,第一个例子在处理下标 1 时会弹掉 3 而不是 1,
sum变成 6,虽然本例答案不受影响,但在nums1 = [1, 100]、k = 1这类数据上会立刻把最大的 100 扔掉,答案严重偏小。
代码实现
class Solution {
public long maxScore(int[] nums1, int[] nums2, int k) {
int n = nums1.length;
// 排序的是下标,因为两个数组靠下标绑定,必须整体搬动。
Integer[] idx = new Integer[n];
for (int i = 0; i < n; i++) {
idx[i] = i;
}
Arrays.sort(idx, (a, b) -> nums2[b] - nums2[a]);
// 小顶堆:堆顶是当前选中的 nums1 值里最小的那个,也是最该被淘汰的。
PriorityQueue<Integer> heap = new PriorityQueue<>();
long sum = 0;
long answer = 0;
for (int t = 0; t < n; t++) {
int i = idx[t];
heap.offer(nums1[i]);
sum += nums1[i];
if (heap.size() > k) {
sum -= heap.poll();
}
// 此刻 nums2[i] 正是堆内所有位置中 nums2 的最小值。
if (heap.size() == k) {
answer = Math.max(answer, sum * nums2[i]);
}
}
return answer;
}
}
func maxScore(nums1 []int, nums2 []int, k int) int64 {
n := len(nums1)
// 排序的是下标,因为两个数组靠下标绑定,必须整体搬动。
idx := make([]int, n)
for i := range idx {
idx[i] = i
}
sort.Slice(idx, func(a, b int) bool {
return nums2[idx[a]] > nums2[idx[b]]
})
// 小顶堆:堆顶是当前选中的 nums1 值里最小的那个,也是最该被淘汰的。
minHeap := make([]int, 0, k+1)
push := func(x int) {
minHeap = append(minHeap, x)
i := len(minHeap) - 1
for i > 0 {
p := (i - 1) / 2
if minHeap[p] <= minHeap[i] {
break
}
minHeap[p], minHeap[i] = minHeap[i], minHeap[p]
i = p
}
}
pop := func() int {
top := minHeap[0]
last := len(minHeap) - 1
minHeap[0] = minHeap[last]
minHeap = minHeap[:last]
i := 0
for {
l, r := i*2+1, i*2+2
if l >= len(minHeap) {
break
}
best := l
if r < len(minHeap) && minHeap[r] < minHeap[l] {
best = r
}
if minHeap[i] <= minHeap[best] {
break
}
minHeap[i], minHeap[best] = minHeap[best], minHeap[i]
i = best
}
return top
}
sum := int64(0)
answer := int64(0)
for _, i := range idx {
push(nums1[i])
sum += int64(nums1[i])
if len(minHeap) > k {
sum -= int64(pop())
}
// 此刻 nums2[i] 正是堆内所有位置中 nums2 的最小值。
if len(minHeap) == k {
if cand := sum * int64(nums2[i]); cand > answer {
answer = cand
}
}
}
return answer
}
复杂度分析
- 时间复杂度:$O(n \log n)$。按
nums2降序排序下标是 $O(n \log n)$;随后单趟遍历中每个元素至多入堆一次、出堆一次,每次堆操作是 $O(\log k)$,合计 $O(n \log k)$。由于 $k \le n$,整体由排序主导,是 $O(n \log n)$。- 空间复杂度:$O(n)$。下标数组占 $O(n)$,堆最多装 $k + 1$ 个元素占 $O(k)$,排序的额外开销在 Java 中因对象数组走归并排序而是 $O(n)$、在 Go 中是 $O(\log n)$ 的递归栈。
关键点总结
- 目标函数含有「极值因子」时,标准破法是把极值从未知量变成枚举量:枚举谁是那个最小值,剩下的部分就退化成一个无耦合的简单最优化问题。这条思路可以直接迁移到「和乘以最小值」「和乘以最大值」「区间长度乘以最小值」等一大类题目。
- 枚举顺序要与「候选池单调扩张」对齐。按
nums2降序遍历,使得「已处理集合」恰好等于「合法候选集合」,从而把每次 $O(n)$ 的重新筛选压成 $O(\log k)$ 的增量维护。- 维护「前
k大之和」的标准工具是小顶堆:堆顶是候选中的最小者,也就是下一个该被淘汰的。想清楚「谁该被淘汰」再选堆的方向,比死记「求前 k 大用小顶堆」更可靠。- 堆的内容与累加器必须严格同步:入堆即加、出堆即减,两个动作成对出现。一旦脱节,后续所有分数都是错的且难以定位。
- 排序时要搬动下标而非数值,因为两个数组通过下标绑定。凡是「多个数组按位置配对」的题,第一反应就该是对下标排序或先打包成结构体。
- 数值范围要在动手前估算:本题和最大 $10^{10}$、乘积最大 $10^{15}$,必须全程 64 位。返回值类型是长整型这一点,本身就是题目给出的溢出提示。
- 面试视角:先说明「顺序无关,本质是选子集」,再指出两个因子方向相反、无法单独贪心,然后抛出「枚举最小值」这一步——这是整道题的题眼,说出来基本就通过了。接着讲降序遍历如何让候选池单调扩张,最后给出小顶堆的增量维护。面试官常追问「为什么只在堆满
k个时才更新答案」,答案是不足k个时选择不合法;再追问「nums2有重复值会不会出问题」,回答是不会——相同nums2值的位置内部顺序任意,因为它们对应的右因子相同,而左因子只会随候选池扩张而不减。
解法对比
朴素枚举子集:正确但完全不可行。它的价值仅在于说明「两个因子必须联合考虑」,可以作为讲解的起点,但绝不能作为提交答案。
只按
nums1或只按nums2单向贪心:写起来最短,但都是错的。前者会选中nums2极小的位置拖垮右因子,后者会选中nums1极小的位置拖垮左因子。面试中若脱口而出这类做法,最好紧跟一句反例说明自己知道它为什么错。排序 + 小顶堆:$O(n \log n)$ 时间、$O(n)$ 空间,是本题的标准解,也是面试官期待的答案。它把一个二元耦合的最优化问题拆成「枚举一维、增量维护另一维」,结构清晰且常数很小。
排序 + 前缀式的其他维护方式:有人会想把堆换成「排序后取前
k大」的静态预处理,但候选池是随遍历动态扩张的,静态方案需要对每个前缀重新求前k大,反而更慢。也有人尝试用平衡树或有序集合替代堆,复杂度相同但常数更大、代码更长,除非还需要中位数之类的额外查询,否则没有必要。面试怎么答:开口先讲「枚举最小值」的思路而不是直接背代码,因为这一步才是考点;讲完思路再说「候选池随降序遍历单调扩张,所以用小顶堆增量维护前
k大之和」;最后主动补一句溢出风险和 64 位类型。整套讲下来大约两分钟,比先写代码再解释更容易让人跟上。
易错点总结
- 错误写法:用大顶堆,弹出堆顶时扔掉的是最大值。用例
nums1 = [1, 100]、nums2 = [1, 1]、k = 1→ 处理第二个位置时把 100 弹出、留下 1,答案算成 1,正确答案是 100。- 错误写法:
sum用 32 位整型。用例:$10^5$ 个值为 $10^5$ 的元素、k = 10^5→ 和达到 $10^{10}$,32 位下溢出成负数,乘积随之为负,答案完全错误。- 错误写法:乘法写成
(int) sum * nums2[i]或两个 32 位数直接相乘。用例nums1与nums2各取 $10^5$ 量级、k较大 → 乘积达 $10^{15}$,在 32 位下截断,返回一个看似合理的小数字。- 错误写法:按
nums2升序排序。用例nums1 = [1, 3, 3, 2]、nums2 = [2, 1, 3, 4]、k = 3→ 处理到某个位置时,已处理集合是「nums2不大于当前值」的那些,右因子不再等于nums2[i],分数计算的前提被破坏,答案与 12 不符。- 错误写法:把答案更新写在弹出堆顶之前。用例
nums1 = [1, 3, 3, 2]、nums2 = [2, 1, 3, 4]、k = 3→ 处理下标 1 时堆里有 4 个元素、sum = 9,算出 $9 \times 1 = 9$,这个分数对应选了 4 个位置,不合法。- 错误写法:弹出堆顶时忘记从
sum中减去。用例nums1 = [1, 3, 3, 2]、nums2 = [2, 1, 3, 4]、k = 3→sum一路累加成 9 而堆里只有 3 个元素,后续所有分数都被高估。- 错误写法:排序数值本身而不是下标。用例
nums1 = [1, 3, 3, 2]、nums2 = [2, 1, 3, 4]、k = 3→ 单独对nums2排序后两个数组的配对关系被打乱,nums1[i]与nums2[i]不再属于同一个位置,结果毫无意义。- 错误写法:比较器写成
nums2[a] - nums2[b]却期望降序。用例:任意数据 → 得到的是升序,与上面「按升序排序」的后果相同,答案偏小。- 错误写法:只在遍历到第
k个元素时更新一次答案,之后不再更新。用例nums1 = [4, 2, 3, 1, 1]、nums2 = [7, 5, 10, 9, 6]、k = 1→ 只算了第一个位置的 30 就停手,虽然本例恰好正确,但换成最优解出现在后面的数据(比如后半段有很大的nums1值)就会漏掉更优分数。- 错误写法:把答案初始化成一个负的哨兵值并在堆不足
k时也更新。用例nums1 = [1, 2]、nums2 = [3, 4]、k = 2→ 堆只有 1 个元素时就用sum * nums2[i]更新,得到一个用 1 个位置算出的分数,违反「恰好选k个」的约束。- 错误写法:认为「
nums1前k大」可以先整体排序一次性确定。用例nums1 = [5, 1, 1]、nums2 = [1, 9, 9]、k = 2→ 全局前 2 大是 5 和 1(下标 0 与 1),分数 $6 \times 1 = 6$;而正确答案是选下标 1、2,分数 $2 \times 9 = 18$。候选池必须随最小值的枚举动态变化。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 215. 数组中的第K个最大元素 | 中等 | 只求单个第 K 大而非前 K 之和,可用大小为 K 的小顶堆,也可用快速选择 |
| 703. 数据流中的第 K 大元素 | 简单 | 同样用小顶堆维护「当前前 K 大」,但数据在线到达,没有排序这一预处理步骤 |
| 347. 前 K 个高频元素 | 中等 | 排序键是频次而非原值,考点在于统计与选取的衔接,无二元乘积的耦合 |
| 786. 第 K 个最小的质数分数 | 中等 | 同样是两数组配对成的目标值,但要按值排名而非求最大,需用堆或二分答案 |
| 632. 最小区间 | 困难 | 目标同样含极值因子,用堆管一端、变量管另一端,是「枚举一维」思路的另一形态 |
| 1675. 数组的最小偏移量 | 困难 | 极差作为目标,靠归一化把操作变单向后用堆逐步压低最大值 |
| 1648. 销售价值减少的颜色球 | 中等 | 堆的贪心因操作次数过大而失效,改用二分水平线批量结算,是本题堆解法的反例 |
| 2462. 雇佣 K 位工人的总代价 | 中等 | 同样要在动态候选池里取最优 K 个,但候选来自双端窗口,需要两个堆配合指针 |