LeetCode 1681. 最小不兼容性
题目描述
题意分析
把
nums分成k个大小完全相同的子集,每个子集的不兼容性定义为它的最大值减最小值,要求所有子集不兼容性之和最小;分不出来就返回 -1。「大小完全相同」这条约束把每组的容量钉死为 $m = n / k$,
n能被k整除是题目保证的。这意味着分组不是自由切割,而是把n个元素恰好摊进k个m元格子。无解只有一个来源:同一个子集里不能出现重复元素(否则最大最小之差的定义仍然成立,但题目明确要求子集内元素互不相同)。所以若某个数值出现的次数超过
k,它无论怎么分都会有两个落进同一组,直接无解。决定做法的是数据规模:
n最多 16,元素值也不超过n。16 这个数字几乎是「状态压缩」的代名词——$2^{16} = 65536$ 个子集状态完全可以枚举,而 $16!$ 的排列数则不可接受。元素值上界很小这一点,则让「子集内是否有重复」可以用一个小数组直接判定。还要注意的是分组之间没有顺序:把元素分成 ${A, B}$ 和分成 ${B, A}$ 是同一种方案。如果不消除这种对称性,搜索空间会被放大 $k!$ 倍。
边界包括:
k == 1(整个数组是一组,答案是全局极差,但若有重复元素则无解);k == n(每组一个元素,不兼容性全为 0);以及某个值出现次数超过k的无解情形。
解法:状态压缩 DP
核心思路
暴力做法是枚举所有分组方案:把
n个元素划分成k个大小为m的无序组,方案数是 $\dfrac{n!}{(m!)^k \cdot k!}$,n = 16、k = 2时约 6435,看着不大;但k = 4、m = 4时已经是 262 万,k = 8时更糟,而且每个方案还要计算代价。真正的问题是这种枚举没有复用性——不同方案之间共享的前缀被反复重算。观察点在于:当我们已经组好了若干完整的组,接下来怎么组,只取决于「还剩哪些元素没用」,与已用元素被怎样分组无关。这就是无后效性,也是把指数枚举换成 DP 的依据。而「还剩哪些元素」在
n ≤ 16时正好可以用一个 16 位整数表示。于是定义状态:
dp[mask]表示已经选取mask中标记的这些元素、并把它们恰好分成若干个满员小组时,这些小组的不兼容性之和的最小值。mask中 1 的个数必然是m的倍数,其他mask不可达。初始状态dp[0] = 0(还没组任何小组,代价为 0),答案是dp[all],all是全 1 掩码;若它仍是无穷大,说明无解。转移是「一次补齐一个完整小组」:从
dp[mask]出发,在剩余元素remain = all ^ mask中挑一个大小为m的子集sub作为新的一组,转移到dp[mask | sub],代价加上cost[sub]。
cost[sub]是预处理量,含义是「把sub中的元素放在同一组时的不兼容性」。只有当sub恰好有m个元素且其中的数值互不相同时它才有定义,否则标记为非法。把它预处理出来,是因为同一个sub会在无数条转移路径上被用到,重复计算是纯浪费。消除组间顺序的对称性靠一个小技巧:强制新的一组必须包含
remain中最低位的那个元素。任何一种分组方案里,含有「当前剩余的最小下标元素」的那一组是唯一的,把它钉死为「下一个要组的组」,就让每种方案在 DP 中只被计数一次。这一步既是正确性无关的优化,也把枚举量从 $3^n$ 砍掉一半以上。
解题步骤
- 先统计数值频次。若任一数值出现超过
k次,k个组无法各自最多放一个,直接返回 -1。- 算出每组容量
m = n / k与全集掩码all = (1 << n) - 1。后续所有判断都围绕这两个量展开。- 预处理
cost数组,长度 $2^n$,初值全置 -1 表示非法。遍历每个mask,先用位计数过滤掉大小不等于m的;再遍历其中的每一位,用一个按数值索引的布尔数组检测重复,一旦发现重复立即标记非法并跳出;同时统计最大值与最小值。合法则写入max - min。数值上界不超过n ≤ 16,所以布尔数组开到 21 已经绰绰有余。- 初始化
dp数组,长度 $2^n$,全部置为一个足够大的哨兵值,dp[0] = 0。哨兵取Integer.MAX_VALUE / 4而不是MAX_VALUE,是为了给后续的dp[mask] + cost[sub]留出加法空间,避免溢出成负数后被误当作更优解。- 正序遍历所有
mask做转移。正序保证在处理mask时,所有能到达它的更小状态都已经算完——因为mask | sub严格大于mask(sub与mask无交集且非空)。遇到dp[mask]仍是哨兵值就跳过,不可达状态不能继续扩展,否则会把哨兵值当成真实代价传播出去。- 找出
remain的最低位must。这是对称性消除的落点:接下来枚举的子集必须包含它。- 用
sub = remain; sub > 0; sub = (sub - 1) & remain枚举remain的所有非空子集。这是子集枚举的标准写法,(sub - 1) & remain能不重不漏地给出下一个子集。枚举体内先跳过不含must的、再跳过cost为 -1 的(既排除了大小不对,也排除了含重复值的),然后用dp[mask] + cost[sub]更新dp[mask | sub]。- 最后检查
dp[all]:等于哨兵值说明全集不可达,返回 -1;否则返回它。以
nums = [1, 2, 1, 4]、k = 2走一遍,预期答案 4。n = 4,m = 2,all = 0b1111。预处理阶段,只有大小为 2 的掩码有资格。下标 0、2 上的值都是 1,所以掩码
0b0101含重复,非法。其余大小为 2 的掩码:0b0011(值 1、2)代价 1;0b1001(值 1、4)代价 3;0b0110(值 2、1)代价 1;0b1010(值 2、4)代价 2;0b1100(值 1、4)代价 3。DP 从
dp[0] = 0开始。remain = 0b1111,最低位是第 0 位,must = 0b0001。含must且大小为 2 的合法子集有0b0011(代价 1)、0b1001(代价 3);0b0101因含重复被跳过。于是dp[0b0011] = 1、dp[0b1001] = 3。处理
mask = 0b0011时remain = 0b1100,最低位是第 2 位,must = 0b0100。含它的合法二元子集只有0b1100,代价 3,于是dp[0b1111] = 1 + 3 = 4。处理mask = 0b1001时remain = 0b0110,must = 0b0010,合法子集是0b0110,代价 1,于是dp[0b1111]尝试更新为 $3 + 1 = 4$,与已有值相同不变。最终
dp[0b1111] = 4,对应分组{1, 2}与{1, 4},不兼容性 $1 + 3 = 4$。再看无解用例
nums = [1, 1, 1, 1]、k = 2。所有大小为 2 的掩码都含两个 1,cost全为 -1,从dp[0]出发一个转移都做不出来,dp[all]保持哨兵值,返回 -1。
代码实现
import java.util.Arrays;
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 \cdot 2^n + 3^n)$。预处理部分对每个掩码扫一遍所有位,是 $O(n \cdot 2^n)$;DP 部分对每个 mask枚举remain的全部子集,所有(mask, sub)组合的总数是 $\sum_{mask} 2^{remain } = 3^n$,这是子集枚举的经典结论。 n = 16时 $3^{16} \approx 4.3 \times 10^7$,加上「必含最低位」的剪枝实际只有一半左右,可以通过。- 空间复杂度:$O(2^n)$,
cost与dp两个数组各占 $2^n$ 个整数,n = 16时约 65536 项,内存压力很小。预处理中的seen数组是常数大小。
关键点总结
n ≤ 16加「划分成若干组」几乎是状态压缩 DP 的固定搭配。看到这个规模就应该先想「用二进制表示已用元素集合」,而不是先想搜索或贪心。- 同一数值出现超过
k次时必然无解,因为k个组每组最多容纳一个;代码先做这个必要条件检查,合法子集预处理仍负责保证每个具体组内无重复。- 状态定义的关键是找到无后效性的切口:本题的切口是「已用元素集合」,因为未来的选择只依赖剩下哪些元素,不依赖已用元素的分组方式。能说清这一点,状态就定对了一半。
- 把「一次转移补齐一整个组」而不是「一次转移放一个元素」,能让状态里不必额外记录「当前组已放几个」,维度更低。这是划分类状压题的通用取舍。
- 强制新组包含剩余最低位是消除组间顺序对称性的标准手法。它不改变最优值,却能把等价方案压成一条路径,既提速又避免重复统计。同类技巧在 698、473 里也反复出现。
- 预处理把「子集合法性 + 代价」一次算清,让 DP 主循环只剩查表和取最小值。哪些量该预处理,判断标准是「它是否会在多条转移路径上被重复计算」。
- 哨兵值要留出加法余量。用
MAX_VALUE做无穷大时,dp[mask] + cost[sub]会溢出成负数并被误判为更优,这是状压 DP 里最隐蔽的一类错误。- 面试视角:先说清「暴力枚举划分方案的数量」,再指出无后效性并给出状态定义,然后讲转移和子集枚举写法,最后补上对称性消除与哨兵细节。面试官常追问「为什么复杂度是 $3^n$」,要能给出「每个元素在
mask内、在sub内、都不在,共三种归属」的组合论证;再追问「能不能改成按元素逐个放」,答案是可以,但状态要加一维记录当前组已放个数,转移更琐碎。
易错点总结
- 错误写法:哨兵值取
Integer.MAX_VALUE。用例nums = [1, 1, 1, 1]、k = 2→ 虽然本例转移不会发生,但只要有任何一条从不可达状态出发的转移被执行,MAX_VALUE + cost立刻溢出成负数并被当作最优解写入dp,最终返回一个负的不兼容性。- 错误写法:不跳过
dp[mask] == inf的不可达状态。用例nums = [1, 2, 1, 4]、k = 2→ 从dp[0b0101](含重复值、本不可达)继续扩展,哨兵值一路传播,配合前一条的溢出会直接给出错误答案。- 错误写法:省略「新组必须包含
remain最低位」的限制。用例nums = [1, 2, 1, 4]、k = 2→ 结果仍然正确,但每种分组被按k!种顺序重复枚举,n = 16、k = 8时运行时间成倍增长,大用例超时。- 错误写法:子集枚举写成
for (int sub = remain; sub >= 0; sub = (sub - 1) & remain)。用例:任意输入 →sub减到 0 后(0 - 1) & remain又回到remain,条件sub >= 0恒成立,程序陷入死循环。- 错误写法:判重时用「下标是否重复」而不是「数值是否重复」。用例
nums = [1, 1, 1, 1]、k = 2→ 每个掩码里的下标天然互不相同,所有子集都被判为合法,返回 0,正确答案是 -1。- 错误写法:
seen数组按元素值开成new boolean[n]。用例nums = [1, 2, 3, 4]、k = 2→ 值 4 作为下标访问长度为 4 的数组,越界抛异常;数值上界是n,数组长度至少要n + 1。- 错误写法:
cost数组初值设为 0 而不是 -1。用例nums = [1, 1, 1, 1]、k = 2→ 含重复值的子集代价被当成 0,DP 顺利走到全集,返回 0,正确答案是 -1。- 错误写法:转移时不校验
sub的大小是否为m。用例nums = [1, 2, 1, 4]、k = 2→ 若cost又恰好对非m大小的子集有值,会拼出大小不等的分组,答案偏小且不满足「每组大小相同」。- 错误写法:
mask倒序遍历。用例nums = [1, 2, 1, 4]、k = 2→ 处理0b1111时它的前驱0b0011还未被更新,dp[all]拿不到任何有效转移,返回 -1,正确答案是 4。- 错误写法:把答案写成
dp[all]但忘记判断它是否仍是哨兵值。用例nums = [1, 1, 1, 1]、k = 2→ 返回一个巨大的哨兵数字而不是 -1。- 错误写法:通过频次检查后直接返回 0。频次不超过
k只说明可以避免组内重复,不能给出最小代价;nums = [1,2,1,4]、k = 2通过检查,但最小不兼容性是 4 而不是 0。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 78. 子集 | 中等 | 用二进制枚举子集的入门形态,只需构造不需优化,是本题预处理环节的基础 |
| 416. 分割等和子集 | 中等 | 只分两组且只关心和,可用容量维度的 01 背包,无需压缩到集合状态 |
| 473. 火柴拼正方形 | 中等 | 固定分四组且要求组内和相等,可用回溯加剪枝,也可用同样的状压 DP 骨架 |
| 698. 划分为k个相等的子集 | 中等 | 与本题结构最接近,只是评价标准从极差换成「和是否相等」,可用同一套掩码 DP |
| 368. 最大整除子集 | 中等 | 同样在子集上做最优化,但元素规模大,只能靠排序后的线性 DP 而非状态压缩 |
| 847. 访问所有节点的最短路径 | 困难 | 状态是「已访问集合 + 当前位置」,展示状压如何与图上 BFS 结合 |
| 51. N 皇后 | 困难 | 用位运算维护列与两条对角线的占用,是位掩码用于剪枝而非用于 DP 状态 |
| 1526. 形成目标数组的子数组最少增加次数 | 困难 | 同为「最小操作次数」但结构完全不同,靠差分与贪心一遍扫描解决 |