LeetCode 1681. 最小不兼容性
题目描述


题意分析
将全部元素分成
k个组,每组恰好有m=n/k个元素,同组不能出现相同数值。每组的费用是最大值减最小值,求总费用最小值,无法分组时返回-1。分组不要求连续,但每个数组位置必须且只能使用一次;题目保证n能被k整除。
解法:状态压缩 DP
核心思路
[!blue]
用已使用下标集合表示状态,一次加入一个完整组。mask的第i位表示下标i是否已使用,dp[mask]保存搜索中把这些元素组成若干合法完整组的最小费用。组数等于已用元素数除以m,无需另加一维;未用元素及后续可选组只由mask决定,因此相同状态只保留最小费用即可。先预处理每个子集的
cost[sub]。只有子集包含恰好m个下标、且对应数值互不重复时,它才能成为一组,费用为最大值减最小值;其他子集记为-1。掩码区分的是不同位置,即使两个位置值相等也有不同位,组内是否同值必须另外用seen判断。对可达状态,剩余下标是
remain = all ^ mask。枚举其中的合法子集sub,执行dp[mask | sub] = min(dp[mask | sub], dp[mask] + cost[sub])。新组与已用元素不重叠,又恰好包含m项,转移后仍是由若干完整组组成的合法状态。分组没有先后区别,可以强制下一组包含剩余的最小下标。任意完整划分中都恰好有一组包含这个下标,把它先处理不会改变总费用;剩余部分继续采用同样规则,所以这项限制只消除组顺序的重复,不会漏掉最优划分。
每次加入非空子集都会令新掩码大于旧掩码,因此按掩码递增处理时,所有转移来源都已经计算完成。到达
all后恰好使用全部n个元素,每组又固定为m项,也就恰好完成了k组。
解题步骤
- 统计每个数值的次数;同值元素每组最多放一个,若出现次数超过
k,立即返回-1。- 枚举所有掩码,筛选大小为
m且数值不重复的子集,计算其费用。- 初始化
dp[0]=0,其余为不可达标记;按掩码递增扫描,跳过不可达状态和全集状态。- 找到
remain的最低有效位,用sub=(sub-1)&remain依次枚举非空子集,只用包含该位且cost[sub]合法的组进行转移。dp[all]仍不可达则返回-1,否则返回这个最小总费用。
k=n时每组只有一个元素,费用都为 0;k=1时只能使用整个数组作为一组,重复值会使它不合法。题目数值范围为1..n,所以按值计数和判重的数组都分配n+1个位置。
代码实现
class Solution {
public int minimumIncompatibility(int[] nums, int k) {
int n = nums.length;
int m = n / k;
int all = (1 << n) - 1;
int[] frequency = new int[n + 1];
for (int value : nums) {
// 每组最多容纳一个同值元素,出现超过组数必然无解。
if (++frequency[value] > k) {
return -1;
}
}
// 预处理每个候选小组的代价,-1 表示大小不符或组内有重复值。
int[] cost = new int[1 << n];
Arrays.fill(cost, -1);
for (int mask = 0; mask <= all; mask++) {
if (Integer.bitCount(mask) != m) {
continue;
}
int min = Integer.MAX_VALUE;
int max = Integer.MIN_VALUE;
boolean ok = true;
// 掩码区分下标,这里另外按数值判定组内重复。
boolean[] seen = new boolean[n + 1];
for (int i = 0; i < n; i++) {
if (((mask >> i) & 1) == 0) {
continue;
}
int v = nums[i];
if (seen[v]) {
ok = false;
break;
}
seen[v] = true;
min = Math.min(min, v);
max = Math.max(max, v);
}
if (ok) {
cost[mask] = max - min;
}
}
// 未能组成完整小组的状态保持不可达。
int inf = Integer.MAX_VALUE / 4;
int[] dp = new int[1 << n];
Arrays.fill(dp, inf);
dp[0] = 0;
for (int mask = 0; mask <= all; mask++) {
if (dp[mask] == inf) {
continue;
}
if (mask == all) {
continue;
}
int remain = all ^ mask;
// 固定下一组必须包含的下标,避免重复考虑组的处理顺序。
int first = 0;
while (((remain >> first) & 1) == 0) {
first++;
}
int must = 1 << first;
for (int sub = remain; sub > 0; sub = (sub - 1) & remain) {
if ((sub & must) == 0) {
continue;
}
if (cost[sub] == -1) {
continue;
}
int nxt = mask | sub;
// 加入一个完整合法组,未来状态只依赖新的已用下标集合。
int cand = dp[mask] + cost[sub];
if (cand < dp[nxt]) {
dp[nxt] = cand;
}
}
}
if (dp[all] == inf) {
return -1;
}
return dp[all];
}
}
import "math/bits"
func minimumIncompatibility(nums []int, k int) int {
n := len(nums)
m := n / k
all := (1 << n) - 1
frequency := make([]int, n+1)
for _, value := range nums {
frequency[value]++
// 每组最多容纳一个同值元素,出现超过组数必然无解。
if frequency[value] > k {
return -1
}
}
// 预处理每个候选小组的代价,-1 表示大小不符或组内有重复值。
cost := make([]int, 1<<n)
for i := 0; i < len(cost); i++ {
cost[i] = -1
}
for mask := 0; mask <= all; mask++ {
if bits.OnesCount(uint(mask)) != m {
continue
}
minVal := int(^uint(0) >> 1)
maxVal := -minVal
// 掩码区分下标,这里另外按数值判定组内重复。
seen := make([]bool, n+1)
ok := true
for i := 0; i < n; i++ {
if (mask>>i)&1 == 0 {
continue
}
v := nums[i]
if seen[v] {
ok = false
break
}
seen[v] = true
if v < minVal {
minVal = v
}
if v > maxVal {
maxVal = v
}
}
if ok {
cost[mask] = maxVal - minVal
}
}
// 未能组成完整小组的状态保持不可达。
inf := int(^uint(0)>>1) / 4
dp := make([]int, 1<<n)
for i := 0; i < len(dp); i++ {
dp[i] = inf
}
dp[0] = 0
for mask := 0; mask <= all; mask++ {
if dp[mask] == inf || mask == all {
continue
}
remain := all ^ mask
// 固定下一组必须包含的下标,避免重复考虑组的处理顺序。
first := 0
for ((remain >> first) & 1) == 0 {
first++
}
must := 1 << first
for sub := remain; sub > 0; sub = (sub - 1) & remain {
if (sub & must) == 0 {
continue
}
if cost[sub] == -1 {
continue
}
nxt := mask | sub
// 加入一个完整合法组,未来状态只依赖新的已用下标集合。
cand := dp[mask] + cost[sub]
if cand < dp[nxt] {
dp[nxt] = cand
}
}
}
if dp[all] == inf {
return -1
}
return dp[all]
}
复杂度分析
- 时间复杂度:$O(n·2^n+3^n)$。预处理每个子集最多检查 $n$ 个位置;转移时,每个位置可属于已用集合、当前新组、仍未用集合三种情况,所有状态与子集组合总数以 $3^n$ 为上界。
- 空间复杂度:$O(2^n)$,保存合法组费用和状态表。
关键点总结
[!green]
- 掩码按下标表示使用情况,合法性按数值判重。
- 每次转移一个完整组,状态无需保存半组信息。
- 固定下一组必含的下标只消除顺序对称,不改变最小值。
易错点总结
[!yellow]
- 把非法 cost 初始化为零:会接受大小错误或含重复值的组。
- 子集循环包含零且继续递推:零的下一个子集会回到全集,造成循环。
- 按倒序只扫描一次状态:刚更新的较大状态没有机会继续扩展。
- 判重数组只开 n 项:数值可以为 n,需要 n+1 个位置。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 698. 划分为k个相等的子集 | 中等 | 同样把所有元素分组,本题固定每组人数、禁止组内重复并最小化极差和,而非要求各组和相同。 |
| 1799. N 次操作后的最大分数和 | 困难 | 同样可预处理合法小组并用位掩码DP合并不相交组,原题固定每组两项并按操作次序加权gcd计分。 |