题目信息
- 平台:LeetCode
- 题目:2333. Minimum Sum Square Difference
- 难度:Medium
- 题目链接:最小差值平方和
题目描述
给定两个长度为 nums1 和 nums2。你可以对 nums1 中的任意元素执行恰好 nums2 中的任意元素执行恰好
请你返回让
注意:操作后的元素可以是负数,不要求保持非负。
数据范围:
初步思路
这道题是一道非常经典的贪心问题,可以从数学性质转化、核心贪心策略以及两种具体实现来拆解。
1. 操作等价性:合并 和
设
- 对
nums1[i]加,与对 nums2[i]减的效果完全一样,都是让两者的差值绝对值向 靠近 ; - 对
nums1[i]减,与对 nums2[i]加,同样都能让差值远离 ,只会让答案变大; - 因此**存在一个最优解,其中每次操作都用来让某个
减小 **,没有任何一次操作是在把某个数推得更远; - 所以两个数组的操作预算可以完全等价地互换,**
与 可以直接合并为一个总操作次数 **。
为什么「恰好
次」不影响这个结论?
如果所有差值都已削到(即 ),剩下的操作可以全部浪费在同一个元素上反复加 再减 ,答案不变。
所以我们只需关心至多使用次有效操作即可,这也是后面代码里那句 if (sum_d <= k) return 0;的由来。
这一步转化把「两个数组分别操作」简化成了「单个差值数组削峰」,是后面所有算法的基础。
2. 贪心策略:为什么优先减去最大的差值?
观察数值从
显然,**
换句话说,我们的目标是:「削峰填谷」——把数组中的所有大数尽量削平,让它们变得尽可能小且均匀。
如果所有差值之和
解法选择
针对这种「把所有数削平」的问题,通常有两种最优解。
解法 1:计数/桶模拟(最推荐,简单且快)
注意到题目数据范围
- 用一个频数数组
cnt[v]记录差值为的元素个数; - 从最大值
开始从大到小往下模拟: - 如果剩余的
:说明能把这 个数全部削低 (变成 ),于是 , ; - 如果
:说明只能削低其中的 个数, 个数变成 ,剩下 个保持为 ,此时 用完,退出循环。
- 如果剩余的
时间复杂度:
解法 2:二分答案(通用解法,面试官最喜欢的扩展)
如果题目把数据范围扩大到
- 二分最终所有差值的**「上限」
**(即所有大于 的数都被削到了 ); check(T):计算把所有大于的差值削到 需要多少次操作 。若操作数 ,说明上限 可以达到(甚至可能更小); - 找到最小可达的
之后,如果还有剩余的 ,由于 是下限,我们只能把一部分刚好等于 的数进一步减成 。
时间复杂度:
还有第三种思路:大顶堆批量削减(本题不推荐)
用大顶堆维护差值、每次取出最大值减
可行的改法是按层批量削减——把堆里的元素组织成 (差值, 出现次数),整层一起下沉、合并同值条目,从而避免逐次操作。
不过这条路有两个限制:
- 复杂度并不占优。设不同差值的个数为
,其复杂度约为 ,相比桶计数法的 和二分答案法的 都没有优势,纯粹是多绕了一层; - 实现易错。批量合并时若把已弹出的条目丢弃,会导致元素凭空消失、
的消耗算错,是这道题最容易写错的地方。
因此本文只给出前两种实现。面试时如果被问到「还有别的思路吗」,可以提堆的批量削减思路并说明上述限制——它证明的是你知道怎么分析取舍,而不是必须写出代码。
算法分析
- 核心思想:把两个数组的操作等价合并为对差值数组的「削峰」,优先减小最大的差值
- 技巧要点:用频次数组
模拟削峰;或二分出最大差值上限,再把剩余操作分配给等于上限的元素 - 时间复杂度:桶计数法
;二分答案法 - 空间复杂度:桶计数法
;二分答案法
代码实现
解法一:桶计数法
class Solution {
public:
long long minSumSquareDiff(vector<int>& nums1, vector<int>& nums2, int k1, int k2) {
int n = nums1.size();
long long k = (long long)k1 + k2;
const int M = 100000;
vector<int> cnt(M + 1, 0);
int max_d = 0;
long long sum_d = 0;
for (int i = 0; i < n; ++i) {
int d = abs(nums1[i] - nums2[i]);
cnt[d]++;
max_d = max(max_d, d);
sum_d += d;
}
// 操作数足以把所有差值清零
if (sum_d <= k) return 0;
// 从大到小削峰
for (int v = max_d; v > 0; --v) {
if (cnt[v] == 0) continue;
if (k >= cnt[v]) {
// 这一层的全部削到 v-1
k -= cnt[v];
cnt[v - 1] += cnt[v];
cnt[v] = 0;
} else {
// 只削 k 个就停
cnt[v] -= k;
cnt[v - 1] += k;
k = 0;
break;
}
}
long long ans = 0;
for (int v = 1; v <= max_d; ++v) {
if (cnt[v] > 0) ans += (long long)cnt[v] * v * v;
}
return ans;
}
};class Solution:
def minSumSquareDiff(self, nums1: List[int], nums2: List[int], k1: int, k2: int) -> int:
k = k1 + k2
cnt = [0] * 100001
max_d = 0
sum_d = 0
for a, b in zip(nums1, nums2):
d = abs(a - b)
cnt[d] += 1
max_d = max(max_d, d)
sum_d += d
# 操作数足以把所有差值清零
if sum_d <= k:
return 0
# 从大到小削峰
for v in range(max_d, 0, -1):
if cnt[v] == 0:
continue
if k >= cnt[v]:
# 这一层的全部削到 v-1
k -= cnt[v]
cnt[v - 1] += cnt[v]
cnt[v] = 0
else:
# 只削 k 个就停
cnt[v] -= k
cnt[v - 1] += k
k = 0
break
ans = 0
for v in range(1, max_d + 1):
if cnt[v] > 0:
ans += cnt[v] * v * v
return ansclass Solution {
public long long minSumSquareDiff(int[] nums1, int[] nums2, int k1, int k2) {
final int M = 100000;
int[] cnt = new int[M + 1];
long long k = (long long) k1 + k2;
int maxD = 0;
long long sumD = 0;
for (int i = 0; i < nums1.length; i++) {
int d = Math.abs(nums1[i] - nums2[i]);
cnt[d]++;
maxD = Math.max(maxD, d);
sumD += d;
}
// 操作数足以把所有差值清零
if (sumD <= k) return 0L;
// 从大到小削峰
for (int v = maxD; v > 0; v--) {
if (cnt[v] == 0) continue;
if (k >= cnt[v]) {
// 这一层的全部削到 v-1
k -= cnt[v];
cnt[v - 1] += cnt[v];
cnt[v] = 0;
} else {
// 只削 k 个就停
cnt[v] -= k;
cnt[v - 1] += k;
k = 0;
break;
}
}
long long ans = 0;
for (int v = 1; v <= maxD; v++) {
if (cnt[v] > 0) ans += (long long) cnt[v] * v * v;
}
return ans;
}
}func minSumSquareDiff(nums1 []int, nums2 []int, k1 int, k2 int) int64 {
const M = 100000
cnt := make([]int, M+1)
k := int64(k1) + int64(k2)
maxD := 0
var sumD int64
for i := range nums1 {
d := nums1[i] - nums2[i]
if d < 0 {
d = -d
}
cnt[d]++
if d > maxD {
maxD = d
}
sumD += int64(d)
}
// 操作数足以把所有差值清零
if sumD <= k {
return 0
}
// 从大到小削峰
for v := maxD; v > 0; v-- {
if cnt[v] == 0 {
continue
}
if k >= int64(cnt[v]) {
// 这一层的全部削到 v-1
k -= int64(cnt[v])
cnt[v-1] += cnt[v]
cnt[v] = 0
} else {
// 只削 k 个就停
cnt[v] -= int(k)
cnt[v-1] += int(k)
k = 0
break
}
}
var ans int64
for v := 1; v <= maxD; v++ {
if cnt[v] > 0 {
ans += int64(cnt[v]) * int64(v) * int64(v)
}
}
return ans
}impl Solution {
pub fn min_sum_square_diff(nums1: Vec<i32>, nums2: Vec<i32>, k1: i64, k2: i64) -> i64 {
const M: usize = 100_000;
let mut cnt = vec![0i64; M + 1];
let mut k: i64 = k1 + k2;
let mut max_d: usize = 0;
let mut sum_d: i64 = 0;
for i in 0..nums1.len() {
let d = (nums1[i] - nums2[i]).abs();
cnt[d as usize] += 1;
if d as usize > max_d {
max_d = d as usize;
}
sum_d += i64::from(d);
}
// 操作数足以把所有差值清零
if sum_d <= k {
return 0;
}
// 从大到小削峰
for v in (1..=max_d).rev() {
if cnt[v] == 0 {
continue;
}
if k >= cnt[v] {
// 这一层的全部削到 v-1
k -= cnt[v];
cnt[v - 1] += cnt[v];
cnt[v] = 0;
} else {
// 只削 k 个就停
cnt[v] -= k;
cnt[v - 1] += k;
k = 0;
break;
}
}
let mut ans: i64 = 0;
for v in 1..=max_d {
if cnt[v] > 0 {
ans += cnt[v] * (v as i64) * (v as i64);
}
}
ans
}
}解法二:二分答案法
下面这组是通用版实现,数据范围很大时桶数组开不下,就用二分。
class Solution {
public:
long long minSumSquareDiff(vector<int>& nums1, vector<int>& nums2, int k1, int k2) {
int n = nums1.size();
long long k = (long long)k1 + k2;
vector<int> d(n);
int max_val = 0;
long long total = 0;
for (int i = 0; i < n; ++i) {
d[i] = abs(nums1[i] - nums2[i]);
max_val = max(max_val, d[i]);
total += d[i];
}
if (total <= k) return 0;
// 判断能否把所有大于 mid 的差值削到 mid
auto check = [&](int mid) -> bool {
long long need = 0;
for (int x : d) {
if (x > mid) need += x - mid;
}
return need <= k;
};
// 二分最小可达上限
int left = 0, right = max_val;
while (left < right) {
int mid = left + (right - left) / 2;
if (check(mid)) right = mid;
else left = mid + 1;
}
int limit = left;
// 先削到 limit,再用剩余操作把等于 limit 的继续削低
for (int x : d) {
if (x > limit) k -= (x - limit);
}
long long ans = 0;
for (int x : d) {
int cur = min(x, limit);
if (cur == limit && k > 0 && cur > 0) {
cur--;
k--;
}
ans += 1LL * cur * cur;
}
return ans;
}
};class Solution:
def minSumSquareDiff(self, nums1: List[int], nums2: List[int], k1: int, k2: int) -> int:
k = k1 + k2
d = [abs(a - b) for a, b in zip(nums1, nums2)]
max_val = max(d)
if sum(d) <= k:
return 0
# 判断能否把所有大于 mid 的差值削到 mid
def check(mid: int) -> bool:
need = 0
for x in d:
if x > mid:
need += x - mid
return need <= k
# 二分最小可达上限
left, right = 0, max_val
while left < right:
mid = (left + right) // 2
if check(mid):
right = mid
else:
left = mid + 1
limit = left
# 先削到 limit,再用剩余操作把等于 limit 的继续削低
for x in d:
if x > limit:
k -= x - limit
ans = 0
for x in d:
cur = min(x, limit)
if cur == limit and k > 0 and cur > 0:
cur -= 1
k -= 1
ans += cur * cur
return ansclass Solution {
public long long minSumSquareDiff(int[] nums1, int[] nums2, int k1, int k2) {
long long k = (long long) k1 + k2;
int n = nums1.length;
int[] d = new int[n];
int maxVal = 0;
long long total = 0;
for (int i = 0; i < n; i++) {
d[i] = Math.abs(nums1[i] - nums2[i]);
maxVal = Math.max(maxVal, d[i]);
total += d[i];
}
if (total <= k) return 0L;
// 二分最小可达上限
int left = 0, right = maxVal;
while (left < right) {
int mid = left + (right - left) / 2;
long long need = 0;
for (int x : d) {
if (x > mid) need += x - mid;
}
if (need <= k) right = mid;
else left = mid + 1;
}
int limit = left;
// 先削到 limit,再用剩余操作把等于 limit 的继续削低
for (int x : d) {
if (x > limit) k -= (x - limit);
}
long long ans = 0;
for (int x : d) {
int cur = Math.min(x, limit);
if (cur == limit && k > 0 && cur > 0) {
cur--;
k--;
}
ans += 1L * cur * cur;
}
return ans;
}
}func minSumSquareDiff(nums1 []int, nums2 []int, k1 int, k2 int) int64 {
k := int64(k1) + int64(k2)
n := len(nums1)
d := make([]int, n)
maxVal := 0
var total int64
for i := 0; i < n; i++ {
v := nums1[i] - nums2[i]
if v < 0 {
v = -v
}
d[i] = v
if v > maxVal {
maxVal = v
}
total += int64(v)
}
if total <= k {
return 0
}
// 二分最小可达上限
left, right := 0, maxVal
for left < right {
mid := left + (right-left)/2
var need int64
for _, x := range d {
if x > mid {
need += int64(x - mid)
}
}
if need <= k {
right = mid
} else {
left = mid + 1
}
}
limit := left
// 先削到 limit,再用剩余操作把等于 limit 的继续削低
for _, x := range d {
if x > limit {
k -= int64(x - limit)
}
}
var ans int64
for _, x := range d {
cur := x
if cur > limit {
cur = limit
}
if cur == limit && k > 0 && cur > 0 {
cur--
k--
}
ans += int64(cur) * int64(cur)
}
return ans
}impl Solution {
pub fn min_sum_square_diff(nums1: Vec<i32>, nums2: Vec<i32>, k1: i64, k2: i64) -> i64 {
let mut k: i64 = k1 + k2;
let d: Vec<i64> = nums1
.iter()
.zip(nums2.iter())
.map(|(a, b)| i64::from((a - b).abs()))
.collect();
let max_val = *d.iter().max().unwrap_or(&0) as i64;
let total: i64 = d.iter().sum();
if total <= k {
return 0;
}
// 二分最小可达上限
let (mut left, mut right) = (0i64, max_val);
while left < right {
let mid = left + (right - left) / 2;
let need: i64 = d.iter().filter(|x| **x > mid).map(|x| x - mid).sum();
if need <= k {
right = mid;
} else {
left = mid + 1;
}
}
let limit = left;
// 先削到 limit,再用剩余操作把等于 limit 的继续削低
for x in &d {
if *x > limit {
k -= x - limit;
}
}
let mut ans: i64 = 0;
for x in &d {
let mut cur = (*x).min(limit);
if cur == limit && k > 0 && cur > 0 {
cur -= 1;
k -= 1;
}
ans += cur * cur;
}
ans
}
}测试用例
| 输入 | 输出 | 说明 |
|---|---|---|
nums1 = [1,2,3,4], nums2 = [2,10,20,19], k1 = 0, k2 = 0 |
579 |
官方样例 1: |
nums1 = [1,4,10,12], nums2 = [5,8,6,9], k1 = 1, k2 = 1 |
43 |
官方样例 2: |
nums1 = [5,2], nums2 = [4,3], k1 = 3, k2 = 1 |
0 |
边界: |
nums1 = [1], nums2 = [6], k1 = 5, k2 = 1 |
0 |
边界: |
两种解法、五种语言的实现均已在本地对上述用例验证通过,输出完全一致。
容易踩坑的细节
- 整数溢出
最大为 ,虽然还没超过 int上限(),但已经非常接近。不同语言的 int 宽度并不统一,用 64 位类型存 更稳妥; - 真正会溢出的是平方和:
最大可达 ,远超 int上限,必须使用 64 位整数; - C++ 的乘运算是左结合的,写
cnt[v] * v * v会先算cnt[v] * v,这一步就已经溢出了。要写(long long)cnt[v] * v * v,让第一个参与乘法的数就是 64 位类型。
- 剩余操作的处理
- 在二分求出上限
limit后,剩下的只能分给恰好等于 limit的元素使其变成limit - 1,不能给已经小于limit的元素; - 同时要注意
limit为时不能再往下削,否则差值会变成负数。
- 在二分求出上限
- 桶计数法的边界
cnt数组要开到(差值最大就是 ),否则 cnt[v-1] += cnt[v]在时会越界; - 削峰循环条件是
v > 0,的元素无法再削减。
总结与反思
- 先做等价转化,再谈算法。把「对两个数组分别加减
、 次」合并成「对差值数组操作 次」,是本题最关键的一步转化,跳过它容易陷入错误的分类讨论。 导出了贪心。差值从 降一带来的收益随 线性增长,因此「优先削最大的」永远最优——这是整个思路的理论支点。 - 数据范围决定选型。
时桶计数法 明显更优;一旦到 量级,桶开不下,才需要二分答案的 。面试时把两者都讲出来更能体现对「复杂度依赖值域」的理解。 - 易错点:整数溢出。平方和
可达 ,远超 int上限,累加必须用 64 位类型;同时注意 C++ 乘法的左结合陷阱。