12.动态规划:状压DP问题和位运算

参考:

0x3f:从集合论到位运算,常见位运算技巧分类总结!https://leetcode.cn/circle/discuss/CaOJ45/

状态压缩DP详细讲解 https://zhuanlan.zhihu.com/p/599427567

【动态规划学习】状压/子集 DP https://leetcode.cn/circle/article/CD6iai/

位运算和状态压缩DP

集合与位运算

0x3f:从集合论到位运算,常见位运算技巧分类总结!https://leetcode.cn/circle/discuss/CaOJ45/

集合可以用二进制表示,二进制从低到高i 位为 1 表示 i 在集合中,为 0 表示 i 不在集合中。例如集合 {0,2,3} 可以用二进制数 1101 表示;反过来,二进制数 1101 就对应着集合 {0,2,3}

利用位运算「并行计算」的特点,我们可以高效地做一些和集合有关的运算。按照常见的应用场景,可以分为以下四类:

  1. 集合与集合
  2. 集合与元素
  3. 遍历集合
  4. 枚举集合

一、集合与集合

其中 & 表示按位与,∣ 表示按位或,⊕ 表示按位异或,∼ 表示按位取反。

其中「对称差」指仅在其中一个集合的元素。

术语集合位运算举例举例
交集A ∩ Ba & b{0,2,3} ∩ {0,1,2}= {0,2}1101 & 0111= 0101
并集A ∪ Ba ∣ b{0,2,3}∪ {0,1,2}= {0,1,2,3}1101 ∣ 0111= 1111
对称差A Δ Ba ⊕ b{0,2,3} Δ {0,1,2}= {1,3}1101 ⊕ 0111= 1010
A ∖ Ba & ∼b{0,2,3} ∖ {1,2}= {0,3}1101 & 1001= 1001
差(子集)A ∖ B (B⊆A)a ⊕ b{0,2,3} ∖ {0,2}= {3}1101 ⊕ 0101= 1000
包含于A ⊆ Ba & b = a
a ∣ b = b
{0,2} ⊆ {0,2,3}0101 & 1101=0101
0101 ∣ 1101=1101

二、集合与元素

通常会用到移位运算。

其中 << 表示左移,>> 表示右移。

注:左移 i 位相当于乘 2^i,右移 i 位相当于除 2^i

术语集合位运算举例举例
空集0
单元素集合{i}1 << i{2}1 << 2
全集U={0,1,2,⋯n−1}(1 << n)−1{0,1,2,3}(1 << 4)−1
补集 ∁ u S ∁_uS uS=U∖S∼s 或者
((1 << n)−1)⊕s
属于i ∈ S(s >> i) & 1=12∈{0,2,3}(1101 >> 2) & 1=1
不属于i ∉ S(s >> i) & 1=01∉{0,2,3}(1101 >> 1) & 1=0
添加元素S ∪ {i}s ∣ (1 << i){0,3}∪{2}1001 ∣ (1 << 2)
删除元素S ∖ {i}s & ∼(1 << i){0,2,3}∖{2}1101&∼(1 << 2)
删除元素(一定在集合中)S ∖ {i}(i∈S)s ⊕ (1 << i){0,2,3}∖{2}1101⊕(1 << 2)
删除最小元素s & (s−1)见下
      s = 101100
    s-1 = 101011 // 最低位的 1 变成 0,同时 1 右边的 0 都取反,变成 1
s&(s-1) = 101000

此外,某些数字可以借助标准库提供的函数算出:

术语Java
集合大小(元素个数)Integer.bitcount(s)
二进制长度(减一得到集合中的最大元素)32-Integer.numberOfLeadingZeros(s)
集合中的最小元素Integer.numberOfTrailingZeros(s)

特别地,只包含最小元素的子集,即二进制最低 1 及其后面的 0,也叫 lowbit,可以用 s & -s 算出。举例说明:

     s = 101100
    ~s = 010011
(~s)+1 = 010100 // 根据补码的定义,这就是 -s   最低 1 左侧取反,右侧不变
s & -s = 000100 // lowbit

三、遍历集合

设元素范围从 0 到 n−1,挨个判断元素是否在集合 s 中:

for (int i = 0; i < n; i++) {
    if (((s >> i) & 1) == 1) { // i 在 s 中
        // 处理 i 的逻辑
    }
}

四、枚举集合

设元素范围从 0 到 n−1,从空集 ∅ 枚举到全集 U:

for (int s = 0; s < (1 << n); s++) {
    // 处理 s 的逻辑
}

设集合为 s,从大到小枚举 s 的所有非空子集 sub:

for (int sub = s; sub > 0; sub = (sub - 1) & s) {
    // 处理 sub 的逻辑
}

如果要从大到小枚举 s 的所有子集 sub(从 s 枚举到空集 ∅),可以这样写:

int sub = s;
do {
    // 处理 sub 的逻辑
    sub = (sub - 1) & s;
} while (sub != s);

注:还可以枚举全集 U 的所有大小恰好为 k 的子集,这一技巧叫做 Gosper’s Hack https://www.bilibili.com/video/BV1na41137jv/

// 枚举全集 U 的所有大小恰好为 k 的子集
for(int s = 0; s < (1 << n); s++){
    if(Integer.bitCount(s) == k){
        // 处理 sub 的逻辑
    }
}

状态压缩DP与最短哈密顿(Hamilton)路径问题

状态压缩DP:

https://zhuanlan.zhihu.com/p/599427567

在讲状压dp之前,我们应该清楚dp是解决多阶段决策最优化问题的一种思想方法,即利用各个阶段之间的关系,逐个求解,最终求得全局最优解。

我们通常需要确认原问题与子问题、动态规划状态、边界状态、状态转移方程

动态规划多阶段一个重要的特性就是无后效性,即“未来与过去无关”。无后效性就是对于某个给定的阶段状态,它以前各阶段的状态无法直接影响它未来的发展。换句话说,当前的状态是此前历史的一个完整总结,此前的历史只能通过当前的状态去影响过程未来的演变。

对于动态规划,如何定义状态是至关重要的,因为状态决定了阶段的划分,阶段的划分保证了无后效性。

状态压缩DP其实是一种暴力的算法,因为它需要遍历每个状态,而每个状态是多个事件的集合,也就是以集合为状态,一个集合就是一个状态。集合问题一般是指数复杂度的NP问题,所以状态压缩DP的复杂度仍然是指数的,只能用于小规模问题的求解。

为了方便地同时表示一个状态的多个事件,状态一般用二进制数来表示。一个数就能表示一个状态,通常一个状态数据就是一个一串0和1组成的二进制数,每一位二进制数只有两种状态,比如说硬币的正反两面,10枚硬币的结果就可以用10位二进制数完全表示出来,每一个10位二进制数就表示了其中一种结果。

使用二进制数表示状态不仅缩小了数据存储空间,还能利用二进制数的位运算很方便地进行状态转移


【动态规划学习】状压/子集 DP https://leetcode.cn/circle/article/CD6iai/

1、状态压缩DP 的一个小技巧,一般应用在集合问题中(状压 DP 又叫 子集 DP(DP on Subsets))。当 DP 状态集合 时,把集合的组合或排列用一个 二进制整数 表示,这个二进制整数的 0/1 组合表示集合的一个 子集,从而把对 DP 状态 的处理转换为二进制的位操作,让代码变得简洁易写(注:相对集合操作而言),同时提高算法效率。从二进制操作简化集合处理的角度看,状态压缩 也是一种 DP 优化方法。

DP 优化的方法有很多,其中 状态压缩 是对 DP 状态表示 的优化~

2. 状压DP 经典问题 —— 最短哈密顿(Hamilton)路径问题

状态压缩 DP 常常用 Hamilton(旅行商)问题作为引子。

哈密尔顿回路

  • 1859年,数学家哈密尔顿(Hamilton)提出了一个叫做“周游世界”的游戏:在一个正十二面体的20个顶点上,依次标注了伦敦、巴黎、莫斯科等世界上著名的大城市。要求游戏者从某个城市出发,把所有的城市都走过一次,且仅走过一次,然后回到出发点。这类问题就是图论中著名的“哈密尔顿问题”。

旅行商问题 (Traveling Salesman Problem,TSP),又叫货郎担问题,它是图论中一个经典的组合优化问题。

  • 经典的TSP可以描述为:一个商品推销员要去若干个城市推销商品,该推销员从一个城市出发,需要经过所有城市一次并且仅一次之后后,回到出发城市。问他应如何选择在城市之间的行程路线,以使他走过的总路程最短。
  • 从图论的角度来看,该问题其实就是在一个赋权的无向图中,去找一个哈密尔顿回路,并且使得该回路的总权值最小。

最短 Hamilton 路径 - AcWing

问题描述:给定一个有权无向图,包括 n n n 个点,标记 0 ∼ n − 1 0∼n−1 0n1,以及连接 n n n 个点的边,求从起点 0 0 0 到终点 n − 1 n−1 n1 的最短路径。要求必须经过所有点,而且只经过一次。 1 ≤ n ≤ 20 1≤n≤20 1n20

先尝试暴力解法,枚举 n 个点的全排列。共有 n! 个全排列,一个全排列就是一条路径,计算每个全排列的路径长度,需要做 n 次加法。在所有路径中找最短路径,总的时间复杂度为 O ( n × n ! ) O(n×n!) O(n×n!)

时间复杂度分析:10! ≈ 3x10^6,当 n = 14时,14! > 10! × 10^4


使用DP求解Hamilton问题

如果用状态压缩 DP 求解,能把时间复杂度降低到 O ( n 2 × 2 n ) O(n^2\times2^n) O(n2×2n)

状态定义: 设 S 为图的一个子集,用 dp[S][j]表示集合 S 内最短的Hamilton问题,即从起点 0 出发,经过 S 中的所有点,到达终点 j 的最短路径 (集合 S 中包含 点) 。然后根据 DP 的思路,让 S 从最小的子集逐步扩展到整个图,最后得到的 dp[N][n-1] 就是答案,N 表示包含图上所有点的集合。

状态转移: 如何求 dp[S][j]? 可以从小问题 S - j 递推到大问题 S

其中,S - j表示从集合 S 中去掉 ,即不包含 j 点的集合

  • 如何从 S - j递推到 S? 设为 S - j 中的一个点,把 0~ j 的路径分为两部分: 0->1... ->k(k +1) ->...-> j。以 k 为变量,枚举 s-j 中的所有点,找出最短路,状态转移方程为:
  • d p [ S ] [ j ] = m i n ( d p [ S − j ] [ k ] + d i s t ( k , j ) ) , k ∈ ( S − j ) dp[S][j] = min({dp[S - j][k] + dist(k, j)}),k∈(S-j) dp[S][j]=min(dp[Sj][k]+dist(k,j)),k(Sj)

集合 S 初始时只包含起点 0,然后逐步将图中的点包含进来,直到最后包含所有点。

#include<bits/stdc++.h>
using namespace std;
int n, dp[1 << 20][21];
int dist[21][21];
int main(){
    // 初始化为最大值
    memset(dp, 0x3f, sizeof(dp));
    cin >> n;
    // 输入图
    for (int i = 0; i < n; i ++)    
        for (int j = 0; j < n; j ++)
            // 输入点之间的距离
            cin >> dist[i][j];
    
    // 开始:集合中只有点 0,起点和终点都是 0
    dp[1][0] = 0;
    // 从小集合扩展到大集合,集合用 S 的二进制表示(遍历集合)         
    for (int S = 1; S < (1 << n); S ++) 
        // 枚举点 j
        for (int j = 0; j < n; j ++)
            // (1) 这个判断与下面的 (2) 同时起作用
            if ((S >> j) & 1)
                // 枚举到达 j 的点 k,k 属于集合 S - j
                for (int k = 0; k < n; k ++)
                    // (2) k 属于集合 S - j,S - j 用 (1) 保证
                    if ((S ^ (1 << j)) >> k & 1)
                    // 把 (1) 和 (2) 写在一起更容易理解,但是效率低一些
                    // if (((S >> j) & 1) && ((S ^ (1 << j)) >> k & 1))
                        dp[S][j] = min(dp[S][j], dp[S ^ (1 << j)][k] + dist[k][j]);
    // 输出:路径包含了所有的点,终点是 n - 1
    cout << dp[(1 << n) - 1][n - 1];
    return 0;
}

位运算与状压DP题单

1、集合论与位运算:

2、奇偶性判断:

按位异或:①当x是偶数时,x+1 = x ^ 1;②当x是奇数时,x-1 = x^1

按位与:①当x时偶数时,x & 1 = 0;②当x是奇数时,x & 1 = 1

3、 常见位运算题目

**异或运算本质:消除所有出现次数为偶数的元素。**出现次数为偶数次的数最终都会被异或掉变成0。最后只保留出现次数为奇数次的数。

按位或:


然后是一些状态压缩 DP。这类题目通常和排列/子集有关,可以先从暴力回溯开始思考,再过渡到记忆化搜索和递推。

更多题目,可以在题库中同时选上「动态规划」和「位运算」这两个标签:链接

子集状压DP练习题

1494. 并行课程 II

难度困难188

给你一个整数 n 表示某所大学里课程的数目,编号为 1n ,数组 relations 中, relations[i] = [xi, yi] 表示一个先修课的关系,也就是课程 xi 必须在课程 yi 之前上。同时你还有一个整数 k

在一个学期中,你 最多 可以同时上 k 门课,前提是这些课的先修课在之前的学期里已经上过了。

请你返回上完所有课最少需要多少个学期。题目保证一定存在一种上完所有课的方式。

示例 1:

img

输入:n = 4, relations = [[2,1],[3,1],[1,4]], k = 2
输出:3 
解释:上图展示了题目输入的图。在第一个学期中,我们可以上课程 2 和课程 3 。然后第二个学期上课程 1 ,第三个学期上课程 4 。

示例 2:

img

输入:n = 5, relations = [[2,1],[3,1],[4,1],[1,5]], k = 2
输出:4 
解释:上图展示了题目输入的图。一个最优方案是:第一学期上课程 2 和 3,第二学期上课程 4 ,第三学期上课程 1 ,第四学期上课程 5 。

示例 3:

输入:n = 11, relations = [], k = 2
输出:6

提示:

  • 1 <= n <= 15
  • 1 <= k <= n
  • 0 <= relations.length <= n * (n-1) / 2
  • relations[i].length == 2
  • 1 <= xi, yi <= n
  • xi != yi
  • 所有先修关系都是不同的,也就是说 relations[i] != relations[j]
  • 题目输入的图是个有向无环图。

题解:https://leetcode.cn/problems/parallel-courses-ii/solution/zi-ji-zhuang-ya-dpcong-ji-yi-hua-sou-suo-oxwd/

一、记忆化搜索:

代码实现时,由于先学课程 1,再学课程 2,或者先学课程 2,再学课程 1,都会递归到「学完课程 1 和 2」的状态上。一叶知秋,整个递归中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化。

从 i1 出发,求为 1 的位的子集,开始这些为 1 的位全是 1,之后每次减去 1,再与 i1做与运算,得到的 1 所在的位仍然在最初的 i1 所在的位的集合中,只是某些位变为了 0,由于每次只减去 1,所以肯定可以遍历 11111…111 ~ 00000…000 的所有状态,即得到为 1 的位的子集。

class Solution {
    // 定义dfs(i)表示上完集合 i 中的课程,最少需要多少个学期
    // 考虑枚举 i 的大小不超过 k 的非空子集 j,作为一个学期内需要学完的课程
    //          这里 j 中所有元素的先修课必须在 i 的补集中
    // 用一个学期上完 j 中的课程,则剩余课程为i/j ,继续递归计算 dfs(i/j),所有情况取最小值
    // 递归边界 dfs(空集) = 0
    // 递归入口 dfs(全集)
    int[] pre1, memo;
    int k, u;
    public int minNumberOfSemesters(int n, int[][] relations, int k) {
        this.k = k;
        pre1 = new int[n];
        for(int[] r : relations){
            // r[1] 的先修课程集合,下标改从 0 开始
            pre1[r[1] - 1] |= 1 << (r[0] - 1);
        }
        u = (1 << n) - 1; // 全集
        memo = new int[1 << n];
        Arrays.fill(memo, -1); // -1表示还没计算过
        return dfs(u);
    }

    public int dfs(int i){
        if(i == 0) return 0; // 空集
        if(memo[i] != -1) return memo[i]; // 之前计算过了
        int i1 = 0, ci = u ^ i; // i1 是当前可以学习的课程集合,ci 是 i 的补集(已经学过的课程)
        for(int j = 0; j < pre1.length; j++){
            // pre1[j] 在 i 的补集中,可以学(否则这学期一定不能学)
            if(((i >> j) & 1) == 1 && (pre1[j] | ci) == ci)
                i1 |= 1 << j;
        }
        if(Integer.bitCount(i1) <= k){ // 如果个数小于k,则可以全部学习,不用再枚举子集
            return memo[i] = dfs(i ^ i1) + 1; // dfs(i) = dfs(i \ i1) + 1
        }
        // 可以学的课程超过k个,需要枚举大小为 k 的子集
        int res = Integer.MAX_VALUE;
    
        for(int j = i1; j > 0; j = (j-1) & i1){ // 枚举 i1 的子集 j
            if(Integer.bitCount(j) == k)
                res = Math.min(res, dfs(i ^ j) + 1);
        }
        return memo[i] = res;
    }
}

记忆化搜索转递推

class Solution {
    public int minNumberOfSemesters(int n, int[][] relations, int k) {
        int[] pre1 = new int[n];
        for(int[] r : relations)
            // r[1] 的先修课程集合,下标改从 0 开始
            pre1[r[1] - 1] |= 1 << (r[0] - 1);
        int u = (1 << n) - 1; // 全集
        // 定义f(i)表示上完集合 i 中的课程,最少需要多少个学期
        int[] f = new int[1 << n];
        f[0] = 0;
        for(int i = 1; i < (1 << n); i++){
            int i1 = 0, ci = u ^ i; // i1 是当前可以学习的课程集合,ci 是 i 的补集(已经学过的课程)
            for(int j = 0; j < n; j++)
                // pre1[j] 在 i 的补集中,可以学(否则这学期一定不能学)
                if(((i >> j) & 1) == 1 && (pre1[j] | ci) == ci)
                    i1 |= 1 << j;
            if(Integer.bitCount(i1) <= k){ // 如果个数小于k,则可以全部学习,不用再枚举子集
                f[i] = f[i ^ i1] + 1;
                continue;
            }
            f[i] = Integer.MAX_VALUE;
            for(int j = i1; j > 0; j = (j-1) & i1){ // 枚举 i1 的子集 j
                if(Integer.bitCount(j) == k)
                    f[i] = Math.min(f[i], f[i ^ j] + 1);
            }
        }
        return f[u];
    }
}

2741. 特别的排列

难度中等12

给你一个下标从 0 开始的整数数组 nums ,它包含 n互不相同 的正整数。如果 nums 的一个排列满足以下条件,我们称它是一个特别的排列:

  • 对于 0 <= i < n - 1 的下标 i ,要么 nums[i] % nums[i+1] == 0 ,要么 nums[i+1] % nums[i] == 0

请你返回特别排列的总数目,由于答案可能很大,请将它对 109 + 7 取余 后返回。

示例 1:

输入:nums = [2,3,6]
输出:2
解释:[3,6,2] 和 [2,6,3] 是 nums 两个特别的排列。

示例 2:

输入:nums = [1,4,3]
输出:2
解释:[3,1,4] 和 [4,1,3] 是 nums 两个特别的排列。

提示:

  • 2 <= nums.length <= 14
  • 1 <= nums[i] <= 109

题解:https://leetcode.cn/problems/special-permutations/solution/zhuang-ya-dp-by-endlesscheng-4jkr/

关键点:

1、为什么可以这个东西可以用记忆化搜索进行优化?

  • 【先选 2 再选 1 然后递归到 4】和【先选 1 再选 2 然后递归到4】都会递归到dfs(*,4),参数相同,是一个重复的子问题,可以用记忆化搜索解决 O(n!) -> O(2^n)

2、状态压缩DP = ①排列型的回溯、②记忆化搜索=>递推、③集合=>位运算

记忆化搜索

class Solution {
    // 定义dfs(i, j) 表示当前可以选的下标集合为 i, 上一个选的数的下标是j,
    // 转移:从i中选一个下标k
    // 如果 nums[i] % nums[j] == 0 or nums[j] % nums[i] == 0 
    // 则 dfs(i, j) += sum(dfs(i\{k}, k) for k in i)
    // 递归边界:dfs(空集【0】, j) = 1 // 递归到i是空集,说明找到了一个特别的排列
    // 递归入口:dfs(U\{j}, j)
    // 答案: sum(dfs(U\{j}, j) for j in range(n))
    // 时间复杂度 = O(状态个数) * O(单个状态的计算时间) <- 【动态规划的时间复杂度】
    // 			= O(n * 2^n) * O(n)
    private static final int MOD = (int) 1e9 + 7;
    int n;
    int[][] cache;
    int[] nums;
    public int specialPerm(int[] nums) {
        n = nums.length;
        this.nums = nums;
        // cache[i][j] : i是集合的所有情况 2^i个,j表示上一次选的数 n个
        cache = new int[1 << n][n];
        for(int i = 0; i < (1 << n); i++)
            Arrays.fill(cache[i], -1);
        int ans = 0;
        int u = (1 << n) - 1; // 全集
        for(int i = 0; i < n; i++){ // 初始状态下每个数都可以选
            ans = (ans + dfs(u ^ (1 << i), i)) % MOD;
        }
        return ans % MOD;
    }

    public int dfs(int i, int j){
        if(i == 0) return 1;
        if(cache[i][j] >= 0) return cache[i][j];
        int res = 0;
        // 遍历集合
        for(int k = 0; k < n; k++){
            // 判断元素k是否在集合i中(是否可以选)
            if(((i >> k) & 1) == 1){
                if(nums[j] % nums[k] == 0 || nums[k] % nums[j] == 0){ // 题目要求
                    res = (res + dfs(i ^ (1 << k), k)) % MOD;
                }
            }
        }
        return cache[i][j] = res % MOD;
    }
}

转成递推

class Solution {
    private static final int MOD = (int) 1e9 + 7;
    public int specialPerm(int[] nums) {
        int n = nums.length;
        // 定义f[i][j] 表示当前可以选的下标集合为 i, 上一个选的数的下标是j,
        int[][] f = new int[1 << n][n];
        for(int i = 0; i < n; i++)
            f[0][i] = 1;
        // 递归dfs(i,j) ,递推就得循环计算i和j
        // i 从小到大遍历(遍历所有状态集合)
        for(int i = 0; i < (1 << n); i++){
            // 遍历每个元素
            for(int j = 0; j < n; j++){ 
                for(int k = 0; k < n; k++){
                    if(((i >> k) & 1) == 1 && (nums[j] % nums[k] == 0 || nums[k] % nums[j] == 0))
                        f[i][j] = (f[i][j] + f[i ^ (1 << k)][k]) % MOD;
                }
            }
        }
        int ans = 0;
        for(int i = 0; i < n; i++){
            ans = (ans + f[((1<<n)-1)^(1<<i)][i]) % MOD;
        }
        return ans;
    }
}

996. 正方形数组的数目(相似)

难度困难109

给定一个非负整数数组 A,如果该数组每对相邻元素之和是一个完全平方数,则称这一数组为正方形数组。

返回 A 的正方形排列的数目。两个排列 A1A2 不同的充要条件是存在某个索引 i,使得 A1[i] != A2[i]。

示例 1:

输入:[1,17,8]
输出:2
解释:
[1,8,17] 和 [17,8,1] 都是有效的排列。

示例 2:

输入:[2,2,2]
输出:1

提示:

  1. 1 <= A.length <= 12
  2. 0 <= A[i] <= 1e9
class Solution {
    int[] nums;
    int n;
    int[][] cache;
    public int numSquarefulPerms(int[] nums) {
        this.nums = nums;
        n = nums.length;
        int u = (1 << n) - 1;
        cache = new int[1 << n][n];
        for(int i = 0; i < (1 << n); i++)
            Arrays.fill(cache[i], -1);
        int res = 0;
        for(int i = 0; i < n; i++){
            res += dfs(u ^ (1 << i), i);
        }
        // 去重 : dp 算出来的结果有很多重复的,需要去重,这里用的是乘法原理去重,
	    // 例如1,1,2,2,2,3中全排列去重,两个1交换位置会多算一次(共2次),
        //      三个2交换位置会多算5次(共6次),最后结果除以每个重复数次数的阶乘。
        Map<Integer, Integer> map = new HashMap<>();
        for(int num : nums) map.put(num, map.getOrDefault(num, 0) + 1);
        for(Map.Entry<Integer, Integer> entry : map.entrySet()){
            res /= getFactorial(entry.getValue());
        }
        return res;
    }

    // 定义dfs(i, j) 表示当前可以选的下标集合为 i, 上一个选的数的下标是j,
    public int dfs(int i, int j){
        if(i == 0) return 1;
        if(cache[i][j] >= 0) return cache[i][j];
        int res = 0;
        for(int k = 0; k < n; k++){
            if(((i >> k) & 1) == 1 && isSqrt(nums[j] + nums[k])){
                res += dfs(i ^ (1 << k), k);
            }
        }
        return cache[i][j] = res;
    }

    public boolean isSqrt(int x){
        int i = (int)Math.sqrt(x);
        return i * i == x;
    }

    public int getFactorial(int x){
        int cnt = 1;
        for(int i = 1; i <= x; i++){
            cnt *= i;
        }
        return cnt;
    }
}

记忆化搜索转递推

class Solution {
    public int numSquarefulPerms(int[] nums) {
        int n = nums.length;
        int[][] f = new int[1 << n][n];
        for(int i = 0; i < n; i++)
            f[0][i] = 1;

        for(int i = 0; i < (1 << n); i++){
            for(int j = 0; j < n; j++){
                for(int k = 0; k < n; k++){
                    if(((i >> k) & 1) == 1 && isSqrt(nums[j] + nums[k]))
                        f[i][j] += f[i ^ (1 << k)][k];
                }
            }
        }
        int res = 0;
        for(int i = 0; i < n; i++) res += f[((1<<n)-1) ^ (1<<i)][i];
        Map<Integer, Integer> map = new HashMap<>();
        for(int num : nums) map.put(num, map.getOrDefault(num, 0) + 1);
        for(Map.Entry<Integer, Integer> entry : map.entrySet()){
            res /= getFactorial(entry.getValue());
        }
        return res;
    }

    public boolean isSqrt(int x){
        int i = (int)Math.sqrt(x);
        return i * i == x;
    }

    public int getFactorial(int x){
        int cnt = 1;
        for(int i = 1; i <= x; i++){
            cnt *= i;
        }
        return cnt;
    }
}

1125. 最小的必要团队

难度困难177

作为项目经理,你规划了一份需求的技能清单 req_skills,并打算从备选人员名单 people 中选出些人组成一个「必要团队」( 编号为 i 的备选人员 people[i] 含有一份该备选人员掌握的技能列表)。

所谓「必要团队」,就是在这个团队中,对于所需求的技能列表 req_skills 中列出的每项技能,团队中至少有一名成员已经掌握。可以用每个人的编号来表示团队中的成员:

  • 例如,团队 team = [0, 1, 3] 表示掌握技能分别为 people[0]people[1],和 people[3] 的备选人员。

请你返回 任一 规模最小的必要团队,团队成员用人员编号表示。你可以按 任意顺序 返回答案,题目数据保证答案存在。

示例 1:

输入:req_skills = ["java","nodejs","reactjs"], people = [["java"],["nodejs"],["nodejs","reactjs"]]
输出:[0,2]

示例 2:

输入:req_skills = ["algorithms","math","java","reactjs","csharp","aws"], people = [["algorithms","math","java"],["algorithms","math","reactjs"],["java","csharp","aws"],["reactjs","csharp"],["csharp","math"],["aws","java"]]
输出:[1,2]

提示:

  • 1 <= req_skills.length <= 16
  • 1 <= req_skills[i].length <= 16
  • req_skills[i] 由小写英文字母组成
  • req_skills 中的所有字符串 互不相同
  • 1 <= people.length <= 60
  • 0 <= people[i].length <= 16
  • 1 <= people[i][j].length <= 16
  • people[i][j] 由小写英文字母组成
  • people[i] 中的所有字符串 互不相同
  • people[i] 中的每个技能是 req_skills 中的技能
  • 题目数据保证「必要团队」一定存在

题解:https://leetcode.cn/problems/smallest-sufficient-team/solution/zhuang-ya-0-1-bei-bao-cha-biao-fa-vs-shu-qode/

class Solution {
    // 把people堪称物品(集合),reqskills看成背包容量(目标集合),本题就是集合版的0-1背包问题
    // 状态压缩:为了方便计算,把reqskill的每个字符串映射到下标上,然后把每个people[i]映射转换成数字集合,再压缩成二进制数
    // 本题用到的位运算技巧:
    //     1.将元素x变为集合{x} : 1 << x
    //     2.判断元素x是否在集合A中 : ((A >> x) & 1) == 1
    //     3.计算两个集合 A,B 的并集 A : A | B
    //     4.A\B在集合A中去掉集合B的元素 : A & ~B
    //     5.全集U : (1 << n) - 1
    private long all;
    private int[] mask; // mask[i] 记录 people[i] 拥有的技能
    private long[][] cache;
    public int[] smallestSufficientTeam(String[] req_skills, List<List<String>> people) {
        Map<String, Integer> sid = new HashMap<>();
        int m = req_skills.length;
        for(int i = 0; i < m; i++)
            sid.put(req_skills[i], i); // 将技能字符串映射到下标
        int n = people.size();
        mask = new int[n];
        for(int i = 0; i < n; i++){ // 把每个 people[i] 压缩成一个二进制数 mask[i]
            for(String s : people.get(i)){
                mask[i] |= (1 << sid.get(s));
            }
        }
        int u = 1 << m; // 需要的技能 全集
        cache = new long[n][u];
        for(int i = 0; i < n; i++){
            Arrays.fill(cache[i], -1);
        }
        all = (1L << n) - 1;
        long res = dfs(n-1, u-1);
        int[] ans = new int[Long.bitCount(res)];
        for(int i = 0, j = 0; i < n; i++){
            if(((res >> i) & 1) == 1)
                ans[j++] = i; // 所有在res中的下标
        }
        return ans;
    }

    // 定义dfs(i, j) 表示从前i个集合中选一些集合,并集包含j,至少需要选择的集合个数
    // 转移:
    //      不选第i个集合:dfs(i, j) = dfs(i-1, j)
    //      选第i个集合: dfs(i, j) = dfs(i-1, j \ people[i]) + 1
    //  两者取最小值
    // 递归边界: people集合i < 0 返回全集 ; 需要的技能集合j = 空集,返回空集
    // 递归入口:dfs(people集合, 需要的技能集合)
    public long dfs(int i, int j){
        if(j == 0) return 0; // 背包已装满
        if(i < 0) return all; // 没法装满背包,返回全集,这样下面比较集合大小会取更小的
        if(cache[i][j] >= 0) return cache[i][j];
        long res1 = dfs(i-1, j); // 不选mask[i]
        long res2 = dfs(i-1, j & ~mask[i]) | (1L << i); // 选 mask[i]
        return cache[i][j] = Long.bitCount(res1) < Long.bitCount(res2) ? res1 : res2;
    }
}

🎉1986. 完成任务的最少工作时间段

难度中等89

你被安排了 n 个任务。任务需要花费的时间用长度为 n 的整数数组 tasks 表示,第 i 个任务需要花费 tasks[i] 小时完成。一个 工作时间段 中,你可以 至多 连续工作 sessionTime 个小时,然后休息一会儿。

你需要按照如下条件完成给定任务:

  • 如果你在某一个时间段开始一个任务,你需要在 同一个 时间段完成它。
  • 完成一个任务后,你可以 立马 开始一个新的任务。
  • 你可以按 任意顺序 完成任务。

给你 taskssessionTime ,请你按照上述要求,返回完成所有任务所需要的 最少 数目的 工作时间段

测试数据保证 sessionTime 大于等于 tasks[i] 中的 最大值

示例 1:

输入:tasks = [1,2,3], sessionTime = 3
输出:2
解释:你可以在两个工作时间段内完成所有任务。
- 第一个工作时间段:完成第一和第二个任务,花费 1 + 2 = 3 小时。
- 第二个工作时间段:完成第三个任务,花费 3 小时。

示例 2:

输入:tasks = [3,1,3,1,1], sessionTime = 8
输出:2
解释:你可以在两个工作时间段内完成所有任务。
- 第一个工作时间段:完成除了最后一个任务以外的所有任务,花费 3 + 1 + 3 + 1 = 8 小时。
- 第二个工作时间段,完成最后一个任务,花费 1 小时。

示例 3:

输入:tasks = [1,2,3,4,5], sessionTime = 15
输出:1
解释:你可以在一个工作时间段以内完成所有任务。

提示:

  • n == tasks.length
  • 1 <= n <= 14
  • 1 <= tasks[i] <= 10
  • max(tasks[i]) <= sessionTime <= 15
class Solution {
    public int minSessions(int[] tasks, int sessionTime) {
        int n = tasks.length;
        // 预处理一个工作时间段能完成的任务子集
        boolean[] works = new boolean[1 << n];
        for(int i = 1; i < (1 << n); i++){
            // 查看该子集代表的工作能否在一个sessiontime中完成
            works[i] = calculate(i, tasks, sessionTime);
        }
        //dp[i] 当前task的选择状态为 集合i 时所需的最小时间段个数
        // 转移方程
        // dp[i] =(dp[i xor j] + (sums[j] <= sessionTime)) (j是i的子集, 预先计算出每个状态的时间总和sums进行转移)
        int[] dp = new int[1 << n];
        Arrays.fill(dp, Integer.MAX_VALUE / 2);
        dp[0] = 0;
        // 枚举所有子集sub
        for(int sub = 0; sub < (1 << n); sub++){
            // 枚举sub的子集j dp[sub] = mn(dp[sub^j]+values[j] == true)
            for(int j = sub; j != 0; j = (j-1) & sub){
                // 如果子集中的任务能在一个工作时间段完成,并且sub^j的子集任务存在
                if(works[j] && dp[sub ^ j] != Integer.MAX_VALUE / 2){
                    dp[sub] = Math.min(dp[sub], dp[sub ^ j] + 1);
                }
            }
        }
        return dp[(1 << n) - 1];
    }

    public boolean calculate(int sub, int[] tasks, int sessionTime){
        for(int i = 0; i < tasks.length; i++){
            if(((sub >> i) & 1) == 1){
                sessionTime -= tasks[i];
                if(sessionTime < 0) return false;
            }
        }
        return true;
    }
}

1681. 最小不兼容性

难度困难72

给你一个整数数组 nums 和一个整数 k 。你需要将这个数组划分到 k 个相同大小的子集中,使得同一个子集里面没有两个相同的元素。

一个子集的 不兼容性 是该子集里面最大值和最小值的差。

请你返回将数组分成 k 个子集后,各子集 不兼容性最小值 ,如果无法分成分成 k 个子集,返回 -1

子集的定义是数组中一些数字的集合,对数字顺序没有要求。

示例 1:

输入:nums = [1,2,1,4], k = 2
输出:4
解释:最优的分配是 [1,2] 和 [1,4] 。
不兼容性和为 (2-1) + (4-1) = 4 。
注意到 [1,1] 和 [2,4] 可以得到更小的和,但是第一个集合有 2 个相同的元素,所以不可行。

示例 2:

输入:nums = [6,3,8,1,3,1,2,2], k = 4
输出:6
解释:最优的子集分配为 [1,2],[2,3],[6,8] 和 [1,3] 。
不兼容性和为 (2-1) + (3-2) + (8-6) + (3-1) = 6 。

示例 3:

输入:nums = [5,3,3,6,3,3], k = 3
输出:-1
解释:没办法将这些数字分配到 3 个子集且满足每个子集里没有相同数字。

提示:

  • 1 <= k <= nums.length <= 16
  • nums.length 能被 k 整除。
  • 1 <= nums[i] <= nums.length

DP

class Solution {
    public int minimumIncompatibility(int[] nums, int k) {
        int n = nums.length;
        //预处理所有符合条件的子集,计算其不兼容性
        int[] values = new int[1 << n];
        Arrays.fill(values, -1);
        for(int sub = 0; sub < (1 << n); sub++){
            if(Integer.bitCount(sub) == (n / k))
                values[sub] = calculate(nums, sub);
        }
        //dp[i] 表示选择集合i时的最小不兼容和
        int[] dp = new int[1 << n];
        //dp[i] = mn(dp[i^sub]+values[sub])
        Arrays.fill(dp, Integer.MAX_VALUE / 2);
        dp[0] = 0;
        for(int sub = 0; sub < (1 << n); sub++){
            // 判断sub是否有(n/k)倍数的子集
            if(Integer.bitCount(sub) % (n/k) != 0) continue;
            //枚举i的子集
            for(int j = sub; j != 0; j = (j-1) & sub){
                if(values[j] != -1 && dp[sub ^ j] != Integer.MAX_VALUE / 2)
                    dp[sub] = Math.min(dp[sub], dp[sub ^ j] + values[j]);
            }
        }
        return dp[(1 << n) - 1] == Integer.MAX_VALUE / 2 ? -1 : dp[(1 << n) - 1];
    }

    public int calculate(int[] nums, int sub){
        int n = nums.length;
        int[] tmp = new int[n+1];
        int mn = 17, mx = -1;
        for(int i = 0; i < n; i++){
            if(((sub >> i) & 1) == 1){
                // 不能包含重复元素
                if(tmp[nums[i]] != 0) return -1;
                tmp[nums[i]] += 1;
                mx = Math.max(mx, nums[i]);
                mn = Math.min(mn, nums[i]);
            }
        }
        return mx - mn;
    }
}

2305. 公平分发饼干

难度中等67

给你一个整数数组 cookies ,其中 cookies[i] 表示在第 i 个零食包中的饼干数量。另给你一个整数 k 表示等待分发零食包的孩子数量,所有 零食包都需要分发。在同一个零食包中的所有饼干都必须分发给同一个孩子,不能分开。

分发的 不公平程度 定义为单个孩子在分发过程中能够获得饼干的最大总数。

返回所有分发的最小不公平程度。

示例 1:

输入:cookies = [8,15,10,20,8], k = 2
输出:31
解释:一种最优方案是 [8,15,8] 和 [10,20] 。
- 第 1 个孩子分到 [8,15,8] ,总计 8 + 15 + 8 = 31 块饼干。
- 第 2 个孩子分到 [10,20] ,总计 10 + 20 = 30 块饼干。
分发的不公平程度为 max(31,30) = 31 。
可以证明不存在不公平程度小于 31 的分发方案。

示例 2:

输入:cookies = [6,1,3,2,2,4,1,2], k = 3
输出:7
解释:一种最优方案是 [6,1]、[3,2,2] 和 [4,1,2] 。
- 第 1 个孩子分到 [6,1] ,总计 6 + 1 = 7 块饼干。 
- 第 2 个孩子分到 [3,2,2] ,总计 3 + 2 + 2 = 7 块饼干。
- 第 3 个孩子分到 [4,1,2] ,总计 4 + 1 + 2 = 7 块饼干。
分发的不公平程度为 max(7,7,7) = 7 。
可以证明不存在不公平程度小于 7 的分发方案。

提示:

  • 2 <= cookies.length <= 8
  • 1 <= cookies[i] <= 105
  • 2 <= k <= cookies.length
class Solution {
    int[] cookies;
    int[][] cache;
    int n, k;
    public int distributeCookies(int[] cookies, int k) {
        n = cookies.length;
        this.k = k;
        this.cookies = cookies;
        cache = new int[k][1 << n];
        for(int i = 0; i < k; i++)
            Arrays.fill(cache[i], -1);
        return dfs(0, (1 << n) - 1);
    }

/*
1、 定义dfs(i, j)表示将集合j中的饼干分给第i个及其后面所有孩子获得的最小不公平程度
2、 枚举第i个孩子获得的饼干集合sub,并计算出在此情况下他手上持有的饼干数量tmp。
3、 与对应的dfs(i + 1, j &~ sub)取最大值,表示在此情况下的单人持有的最大饼干数量
4、 之后继续与当前记录的最大饼干数量取最小值,表示该情况下能获得的最小不公平数量
    即res = min(res, max(tmp, dfs(i + 1, j &~ sub)));
5、 注意递归出口, 假如i是最后一个人的下一个,那么如果j != 0,表示还有饼干未被分配,这是不合法的,所以返
    回一个较大值INT_MAX来直接舍弃这种情况。
*/
    public int dfs(int i, int j){
        if(i == k){
            if(j != 0) return Integer.MAX_VALUE; // 还有饼干未被分配,这是不合法的
            return 0;
        }
        if(cache[i][j] >= 0) return cache[i][j];
        int res = Integer.MAX_VALUE;
        for(int sub = j; sub > 0; sub = (sub - 1) & j){ // 枚举第i个孩子获得的饼干集合sub
            int tmp = 0; // 计算持有的饼干数量tmp
            for(int k = n-1; k >= 0; k--){
                if(((sub >> k) & 1) == 1){
                    tmp += cookies[k];
                }
            }
            res = Math.min(res, Math.max(tmp, dfs(i+1, j & (~sub))));
        }
        return cache[i][j] = res;
    }

}

1723. 完成所有工作的最短时间

难度困难316

​ 给你一个整数数组 jobs ,其中 jobs[i] 是完成第 i 项工作要花费的时间。

请你将这些工作分配给 k 位工人。所有工作都应该分配给工人,且每项工作只能分配给一位工人。工人的 工作时间 是完成分配给他们的所有工作花费时间的总和。请你设计一套最佳的工作分配方案,使工人的 最大工作时间 得以 最小化

返回分配方案中尽可能 最小最大工作时间

示例 1:

输入:jobs = [3,2,3], k = 3
输出:3
解释:给每位工人分配一项工作,最大工作时间是 3 。

示例 2:

输入:jobs = [1,2,4,7,8], k = 2
输出:11
解释:按下述方式分配工作:
1 号工人:1、2、8(工作时间 = 1 + 2 + 8 = 11)
2 号工人:4、7(工作时间 = 4 + 7 = 11)
最大工作时间是 11 。

提示:

  • 1 <= k <= jobs.length <= 12
  • 1 <= jobs[i] <= 107

二分图匹配模型的状压DP

状压动态规划(State Compression Dynamic Programming)是一种优化动态规划算法的技巧,常用于求解具有状态指数级别增长的问题。而二分图匹配是一类经典的组合优化问题,可以用状压DP来解决。

下面是几个常见的二分图匹配模型的状压DP题目类型:

  1. 最大独立集问题:给定一个二分图,要求找到一个最大的不含有任何公共边的顶点集合。可以将每个状态表示为一个位掩码,位掩码中第 i 位表示第 i 个顶点是否被选择。然后使用状压DP求解最大独立集的大小。
  2. 最小顶点覆盖问题:给定一个二分图,要求找到一个最小的顶点集合,使得每条边至少有一个端点在集合中。可以将每个状态表示为一个位掩码,位掩码中第 i 位表示第 i 个顶点是否被选择。然后使用状压DP求解最小顶点覆盖的大小。
  3. 最大权匹配问题:给定一个带权二分图,要求找到一个最大权的匹配,使得匹配的边权和最大。可以将每个状态表示为一个位掩码,位掩码中第 i 位表示第 i 个顶点是否被选择。然后使用状压DP求解最大权匹配的权值和。
  4. 最小权完美匹配问题:给定一个带权二分图,要求找到一个权和最小的完美匹配,即每个顶点都被匹配到,并且不存在其他完美匹配使得权和更小。可以将每个状态表示为一个位掩码,位掩码中第 i 位表示第 i 个顶点是否被选择。然后使用状压DP求解最小权完美匹配的权值和。

1879. 两个数组最小的异或值之和

难度困难36

给你两个整数数组 nums1nums2 ,它们长度都为 n

两个数组的 异或值之和(nums1[0] XOR nums2[0]) + (nums1[1] XOR nums2[1]) + ... + (nums1[n - 1] XOR nums2[n - 1])下标从 0 开始)。

  • 比方说,[1,2,3][3,2,1]异或值之和 等于 (1 XOR 3) + (2 XOR 2) + (3 XOR 1) = 2 + 0 + 2 = 4

请你将 nums2 中的元素重新排列,使得 异或值之和 最小

请你返回重新排列之后的 异或值之和

示例 1:

输入:nums1 = [1,2], nums2 = [2,3]
输出:2
解释:将 nums2 重新排列得到 [3,2] 。
异或值之和为 (1 XOR 3) + (2 XOR 2) = 2 + 0 = 2 。

示例 2:

输入:nums1 = [1,0,3], nums2 = [5,3,4]
输出:8
解释:将 nums2 重新排列得到 [5,4,3] 。
异或值之和为 (1 XOR 5) + (0 XOR 4) + (3 XOR 3) = 4 + 4 + 0 = 8 。

提示:

  • n == nums1.length
  • n == nums2.length
  • 1 <= n <= 14
  • 0 <= nums1[i], nums2[i] <= 107

https://leetcode.cn/problems/minimum-xor-sum-of-two-arrays/solution/python-zhuang-tai-ya-suo-ji-yi-hua-sou-s-ih5w/

状态压缩 + 记忆化搜索

依次固定 nums1 中的待异或的元素,用 i 表示

搜索 nums2 中还未使用过的元素,方法是用 mask 表示,如果 mask 的第 j 位是 0 ,那 nums2[j] 未被使用过,把它与 nums1[i] 异或,然后继续 dfs

class Solution {
    int[][] cache;
    int[] nums1, nums2;
    public int minimumXORSum(int[] nums1, int[] nums2) {
        this.nums1 = nums1;
        this.nums2 = nums2;
        int n = nums1.length;
        cache = new int[n][1 << n];
        for(int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1);
        return dfs(0, 0);
    }
	// 定义dfs(i,mask)表示,当前已经匹配了i个数,匹配的数在集合mask中,得到的最小异或值之和
    public int dfs(int i, int mask){
        if(i == nums2.length)
            return 0; // 递归终点: i == len(num),说明num1和nums2中的每个数都异或过了
        if(cache[i][mask] >= 0) return cache[i][mask];
        int res = Integer.MAX_VALUE;
        for(int j = 0; j < nums2.length; j++){
            if(((1 << j) & mask) == 0){ // 如果 nums2[j] 未被使用过
                res = Math.min(res, (nums1[i] ^ nums2[j]) + dfs(i+1, mask | (1 << j)));
            }
        }
        return cache[i][mask] = res;
    }
}

状压DP

数据范围1-14优先考虑状压。

https://leetcode.cn/problems/minimum-xor-sum-of-two-arrays/solution/1879-liang-ge-shu-zu-zui-xiao-de-yi-huo-gats9/

class Solution {
    // 两个数组的最小异或值之和取决于两个数组的其中 n−1 对整数的异或值之和与剩余一对整数的异或值之和,
    // 因此可以使用动态规划计算两个数组的最小异或值之和。
    public int minimumXORSum(int[] nums1, int[] nums2) {
        int n = nums1.length;
        int[] dp = new int[1 << n];
        // 二进制整数 i 表示数组 nums2 的前缀包含的数字的下标集合,将二进制整数 i 中的 1 的个数记为 count,
        // 则 dp[i] 表示数组 nums1 的前 count 个整数与
        //              数组 nums2 的特定 count 个整数(这些整数的下标集合由i表示)的最小异或值之和。
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = 0; // i=0 时,前缀为空,异或值之和一定为0
        for(int i = 1; i < (1 << n); i++){ // i 表示数组nums2的特定排列的前 count 个整数的集合。
            //则数组 nums 2的该特定排列的前 prevCount 个整数的集合是 i - 2^j
            int prevCount = Integer.bitCount(i) - 1;
            for(int j = 0; j < n; j++){
                if(((1 << j) & i) != 0){
                    dp[i] = Math.min(dp[i], dp[i - (1 << j)] + (nums1[prevCount] ^ nums2[j]));
                }
            }
        }
        return dp[(1 << n) - 1];
    }
}

1595. 连通两组点的最小成本

难度困难86

给你两组点,其中第一组中有 size1 个点,第二组中有 size2 个点,且 size1 >= size2

任意两点间的连接成本 cost 由大小为 size1 x size2 矩阵给出,其中 cost[i][j] 是第一组中的点 i 和第二组中的点 j 的连接成本。**如果两个组中的每个点都与另一组中的一个或多个点连接,则称这两组点是连通的。**换言之,第一组中的每个点必须至少与第二组中的一个点连接,且第二组中的每个点必须至少与第一组中的一个点连接。

返回连通两组点所需的最小成本。

示例 1:

img

输入:cost = [[15, 96], [36, 2]]
输出:17
解释:连通两组点的最佳方法是:
1--A
2--B
总成本为 17 。

示例 2:

img

输入:cost = [[1, 3, 5], [4, 1, 1], [1, 5, 3]]
输出:4
解释:连通两组点的最佳方法是:
1--A
2--B
2--C
3--A
最小成本为 4 。
请注意,虽然有多个点连接到第一组中的点 2 和第二组中的点 A ,但由于题目并不限制连接点的数目,所以只需要关心最低总成本。

示例 3:

输入:cost = [[2, 5, 1], [3, 4, 7], [8, 1, 2], [6, 2, 4], [3, 8, 8]]
输出:10

提示:

  • size1 == cost.length
  • size2 == cost[i].length
  • 1 <= size1, size2 <= 12
  • size1 >= size2
  • 0 <= cost[i][j] <= 100

状压DP(记忆化搜索 ==> 动态规划)

https://leetcode.cn/problems/minimum-cost-to-connect-two-groups-of-points/solution/jiao-ni-yi-bu-bu-si-kao-dong-tai-gui-hua-djxq/

class Solution {
    int n, m;
    int[][] cache;
    List<List<Integer>> cost;
    public int connectTwoGroups(List<List<Integer>> cost) {
        this.cost = cost;
        n = cost.size(); m = cost.get(0).size();
        cache = new int[n][1 << m];
        for(int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1);
        return dfs(n-1, 0);
    }
    // 定义dfs(i, mask) 表示 在第一组中还有0-i个点需要连接,连接的第二组点在集合mask中,所需要的最小成本
    // 转移:枚举第一组第i个点连接第二组中的任意一个点
    // 递归边界: dfs(0, mask) ,此时mask中还为0的点可以连接任意第一组的点,选最小的成本
    // 递归入口: dfs(全集,0)
    public int dfs(int i, int mask){
        if(i < 0){
            if(mask == (1 << m) - 1)
                return 0; // 第二组的点都选过了,没有额外成本
            int ans = 0; // 寻找第二组中不为1的点,可以连接第一组任意点,选成本最小的点
            for(int j = 0; j < m; j++){
                if(((mask >> j) & 1) == 1) continue;
                int min = Integer.MAX_VALUE;
                for(int k = 0; k < n; k++){
                    min = Math.min(min, cost.get(k).get(j));
                }
                ans += min;
            }
            return ans;
        }
        if(cache[i][mask] >= 0) return cache[i][mask];
        int ans = Integer.MAX_VALUE;
        // 枚举第i位选第二组的哪个
        for(int p = 0; p < m; p++){
            // 将第一组中的i和第二组中的p连接起来,代价为cost.get(i).get(p)
            ans = Math.min(ans, cost.get(i).get(p) + dfs(i-1, mask | (1 << p)));
        }
        return cache[i][mask] = ans;
    }
}

优化:这里有重复寻找第二组中每个点成本最小的连接方式,可以进行预处理

class Solution {
    int n, m;
    int[][] cache;
    int[] mincost;
    List<List<Integer>> cost;
    public int connectTwoGroups(List<List<Integer>> cost) {
        this.cost = cost;
        n = cost.size(); m = cost.get(0).size();
        // 预处理寻找第二组中每个点的最小连接成本
        mincost = new int[m];
        Arrays.fill(mincost, Integer.MAX_VALUE);
        for(int i = 0; i < m; i++){
            for(List<Integer> c : cost){
                mincost[i] = Math.min(mincost[i], c.get(i));
            }
        }
        cache = new int[n][1 << m];
        for(int i = 0; i < n; i++)
            Arrays.fill(cache[i], -1);
        return dfs(n-1, 0);
    }
    // 定义dfs(i, mask) 表示 在第一组中还有0-i个点需要连接,连接的第二组点在集合mask中,所需要的最小成本
    // 转移:枚举第一组第i个点连接第二组中的任意一个点
    // 递归边界: dfs(0, mask) ,此时mask中还为0的点可以连接任意第一组的点,选最小的成本
    // 递归入口: dfs(全集,0)
    public int dfs(int i, int mask){
        if(i < 0){
            if(mask == (1 << m) - 1)
                return 0; // 第二组的点都选过了,没有额外成本
            int ans = 0; // 寻找第二组中不为1的点,可以连接第一组任意点,选成本最小的点
            for(int j = 0; j < m; j++){
                if(((mask >> j) & 1) == 1) continue;
                ans += mincost[j];
            }
            return ans;
        }
        if(cache[i][mask] >= 0) return cache[i][mask];
        int ans = Integer.MAX_VALUE;
        // 枚举第i位选第二组的哪个
        for(int p = 0; p < m; p++){
            // 将第一组中的i和第二组中的p连接起来,代价为cost.get(i).get(p)
            ans = Math.min(ans, cost.get(i).get(p) + dfs(i-1, mask | (1 << p)));
        }
        return cache[i][mask] = ans;
    }
}

问: 能不能枚举第二组的点,去连接第一组的点?

答: 也可以,但这样做的时间复杂度是 O(nm2^n),相比 O(nm2^m)更慢。注意本题 n >= m。

记忆化搜索转递推

class Solution {
    // 在记忆化搜索中,存在一个状态 i < 0, 因此f数组整体右移
    //      令f[0][x]表示状态 i < 0,最后返回结果f[n][(1 << m) - 1]
    public int connectTwoGroups(List<List<Integer>> cost) {
        int n = cost.size(), m = cost.get(0).size();
        // 预处理寻找第二组中每个点的最小连接成本
        int[] mincost = new int[m];
        Arrays.fill(mincost, Integer.MAX_VALUE);
        for(int i = 0; i < m; i++){
            for(List<Integer> c : cost){
                mincost[i] = Math.min(mincost[i], c.get(i));
            }
        }
        int[][] f = new int[n+1][1 << m];
        for(int i = 0; i < (1 << m); i++)
            for(int j = 0; j < m; j++)
                if((i >> j & 1) == 1) // 第二组的点 k 未连接
                    f[0][i] += mincost[j]; // 去第一组找个成本最小的点连接
    
        for(int i = 0; i < n; i++)
            for(int j = 0; j < (1 << m); j++){
                int res = Integer.MAX_VALUE;
                for(int k = 0; k < m; k++) // 第一组的点 i 与第二组的点 k
                    res = Math.min(res, f[i][j & ~(1 << k)] + cost.get(i).get(k));
                f[i+1][j] = res;
            }
        return f[n][(1 << m) - 1];
    }
}

2172. 数组的最大与和

难度困难51

给你一个长度为 n 的整数数组 nums 和一个整数 numSlots ,满足2 * numSlots >= n 。总共有 numSlots 个篮子,编号为 1numSlots

你需要把所有 n 个整数分到这些篮子中,且每个篮子 至多 有 2 个整数。一种分配方案的 与和 定义为每个数与它所在篮子编号的 按位与运算 结果之和。

  • 比方说,将数字 [1, 3] 放入篮子 *1* 中,[4, 6] 放入篮子 *2* 中,这个方案的与和为 (1 AND ***1\***) + (3 AND ***1\***) + (4 AND ***2***) + (6 AND ***2***) = 1 + 1 + 0 + 2 = 4

请你返回将 nums 中所有数放入 numSlots 个篮子中的最大与和。

示例 1:

输入:nums = [1,2,3,4,5,6], numSlots = 3
输出:9
解释:一个可行的方案是 [1, 4] 放入篮子 1 中,[2, 6] 放入篮子 2 中,[3, 5] 放入篮子 3 中。
最大与和为 (1 AND 1) + (4 AND 1) + (2 AND 2) + (6 AND 2) + (3 AND 3) + (5 AND 3) = 1 + 0 + 2 + 2 + 3 + 1 = 9 。

示例 2:

输入:nums = [1,3,10,4,7,1], numSlots = 9
输出:24
解释:一个可行的方案是 [1, 1] 放入篮子 1 中,[3] 放入篮子 3 中,[4] 放入篮子 4 中,[7] 放入篮子 7 中,[10] 放入篮子 9 中。
最大与和为 (1 AND 1) + (1 AND 1) + (3 AND 3) + (4 AND 4) + (7 AND 7) + (10 AND 9) = 1 + 1 + 3 + 4 + 7 + 8 = 24 。
注意,篮子 2 ,5 ,6 和 8 是空的,这是允许的。

提示:

  • n == nums.length
  • 1 <= numSlots <= 9
  • 1 <= n <= 2 * numSlots
  • 1 <= nums[i] <= 15

题解:状态压缩DP + 记忆化搜索

class Solution {
    int numSlots;
    int[] nums;
    int[][] cache;
    public int maximumANDSum(int[] nums, int numSlots) {
        int n = nums.length;
        this.nums = nums;
        this.numSlots = numSlots;
        cache = new int[n][1 << (2*numSlots)];
        for(int i = 0; i < n; i++) 
            Arrays.fill(cache[i], -1);
        return dfs(n-1, 0);
    }
    
    // 定义dfs(i, mask) 表示还有0-i没有放,已经放的篮子集合为mask, 由于每个篮子至多放两个,mask集合个数为 2 *numSlots
    // 转移:枚举i放mask中为0的篮子
    // 递归边界:dfs(< 0, mask) = 0表示所有整数都放到篮子里了
    // 递归入口:dfs(len(nums), 0)
    public int dfs(int i, int mask){
        if(i < 0) return 0;
        if(cache[i][mask] >= 0) return cache[i][mask];
        int res = 0;
        // 枚举第i位数放在哪一个篮子里
        for(int j = 0; j < 2 * numSlots; j++){
            if(((mask >> j) & 1) == 1) continue;
            res = Math.max(res, dfs(i-1, mask | (1 << j)) + ((j / 2 + 1) & nums[i]));
        }
        return cache[i][mask] = res;
    }
}

记忆化搜索转递推

class Solution {
    public int maximumANDSum(int[] nums, int numSlots) {
        int n = nums.length;
        int[][] f = new int[n+1][1 << (2*numSlots)];
        for(int i = 0; i < n; i++){
            for(int j = 0; j < (1 << 2*numSlots); j++){
                for(int k = 0; k < 2 * numSlots; k++){
                    if(((j >> k) & 1) == 0){
                        f[i+1][j] = Math.max(f[i+1][j], f[i][j | (1 << k)] + ((k/2+1) & nums[i]));
                    }
                }
            }
        }
        // 最后答案为f[n][*]的最大值,因为f[i][mask]表示还有0-i没有放,已经放的篮子集合为mask,得到的最大与和
        int res = 0;
        for(int i = 0; i < (1 << 2*numSlots); i++)
            res = Math.max(res, f[n][i]);
        return res;
    }
}

位运算练习题

78. 子集

难度中等2063

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入:nums = [0]
输出:[[],[0]]

提示:

  • 1 <= nums.length <= 10
  • -10 <= nums[i] <= 10
  • nums 中的所有元素 互不相同

题解:假设nums=[1,2,3,4],二进制的0可以写成0000,代表一个数也不取,1=0001表示去第一个数也就是[1],2=0010,表示取第二个数[2],3=0011表示取1和2位[1,2],4=0100表示[3]…15=1111表示[1,2,3,4]

class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        int n = nums.length;
        // 枚举集合(从空集到全集)
        for(int s = 0; s < (1 << n); s++){
            List<Integer> sub = new ArrayList<>();
            // 遍历集合
            for(int j = 0; j < n; j++){
                if(((s >> j) & 1) == 1) 
                    sub.add(nums[j]);
            }
            res.add(sub);
        }
        return res;
    }
}

77. 组合

难度中等1402

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

示例 2:

输入:n = 1, k = 1
输出:[[1]]

提示:

  • 1 <= n <= 20
  • 1 <= k <= n

方法二:Gosper’s Hack枚举所有大小为k的子集

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> res = new ArrayList<>();
        // Gosper's Hack
        for(int s = 0; s < (1 << n); s++){
            if(Integer.bitCount(s) == k){
                List<Integer> sub = new ArrayList<>();
                // 遍历集合
                for(int i = 0; i < n; i++){
                    if(((s >> i) & 1) == 1)
                        sub.add(i+1);
                }
                res.add(sub);
            }
        }
        return res;
    }
}

46. 全排列

难度中等2578

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例 1:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3:

输入:nums = [1]
输出:[[1]]

提示:

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • nums 中的所有整数 互不相同

回溯 + visit数组解法

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    List<Integer> cur = new ArrayList<>();
    boolean[] visit;
    public List<List<Integer>> permute(int[] nums) {
        visit = new boolean[nums.length];
        dfs(0, nums);
        return res;
    }

    public void dfs(int i, int[] nums){
        if(i == nums.length){
            res.add(new ArrayList<>(cur));
            return;
        }
        for(int k = 0; k < nums.length; k++){
            if(visit[k] == false){
                visit[k] = true;
                cur.add(nums[k]);
                dfs(i+1, nums);
                cur.remove(cur.size()-1);
                visit[k] = false;
            }
        }
    }

}

使用二进制枚举代替visit数组

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    List<Integer> cur = new ArrayList<>();
    int mask;
    public List<List<Integer>> permute(int[] nums) {
        mask = 0;
        dfs(0, nums);
        return res;
    }

    public void dfs(int i, int[] nums){
        if(i == nums.length){
            res.add(new ArrayList<>(cur));
            return;
        }
        for(int k = 0; k < nums.length; k++){
            if(((mask >> k) & 1) == 0){ // 没访问过
                mask |= (1 << k);
                cur.add(nums[k]);
                dfs(i+1, nums);
                cur.remove(cur.size()-1);
                mask ^= (1 << k);
            }
        }
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值