LeetCode 1994.好子集的数目(状压DP)

题目

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

质数、素数、合数

  • 质数(prime number)又称素数,有无限个。一个大于1的自然数,除了1和它本身外,不能被其他自然数整除,换句话说就是该数除了1和它本身以外不再有其他的因数;否则称为合数
  • 根据算术基本定理,每一个比1大的整数,要么本身是一个质数,要么可以写成一系列质数的乘积;而且如果不考虑这些质数在乘积中的顺序,那么写出来的形式是唯一的。最小的质数是2
  • 合数,数学用语,英文名为Composite number,指自然数中除了能被1和本身整除外,还能被其他的数整除(不包括0)的数。与之相对的是质数(因数只有1和它本身,如2,3,5,7,11,13等等,也称素数),而1既不属于质数也不属于合数。最小的合数是4
  • 素数就是质数

关于动态规划(dp)

相似性

可以用动态规划解决的题目,通常具有一定的相似性,这些相似性包括但不限于:

  • 通常该问题是一个求最优解的问题
  • 该问题一定具有最优子结构
  • 一般具有重叠子问题的性质
  • 可以抽象为一个状态,不同的状态之间可以转移

概念讲解:

  • 最优子结构:如果一个问题的最优解中包含了子问题的最优解,这个性质叫最优子结构(局部最优 -> 全局最优);求解动态规划问题的重点就是要描述这个问题的最优子结构;
  • 重叠子问题:就是字面上的意思,进一步解释就是在求解的过程中,碰到的子问题可能已经出现过了
  • 状态:一个问题可以抽象为一个状态,如何定义状态在动态规划中特别重要(因为定义状态就相当于描述了这个问题的最优子结构);

状态定义

所有的dp是解决多阶段决策最优化问题的一种思想方法

请注意多阶段这三个字:

如何定义状态是解决动态规划最重要的一步;

状态的定义也就决定了阶段的划分;状态表示了求解问题的某个阶段

在背包问题中,通过物品的件数 i i i和背包的容量 j j j来定义状态或者说是划分阶段;

动态规划一个重要的特性就是无后效性。无后效性就是指对于某个给定的阶段状态,它以前各阶段的状态无法直接影响它未来的发展,而只能通过当前的这个状态。换句话说影响当前阶段状态只可能是前一阶段的状态

可以看出如何定义状态是至关重要的,因为状态决定了阶段的划分,阶段的划分保证了无后效性

状压dp

简介

状压dp是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的

为了达到最优子结构和无后效性的效果,必须要定义好状态。但是有时候状态维度特别多,但是每个状态的决策又很少,这样开多维数组很可能会浪费,并且可能会爆空间。这时候考虑用状态压缩来做,比如每个状态的决策只有两个,但是状态的维度很多。

举例

下面用01背包来举例:

n n n件物品和一个容量为 v v v的背包。放入第 i i i件物品的占的空间为 C i C_i Ci,得到的价值是 W i W_i Wi;求解每种放法的背包价值;(这不是一个典型的动态规划问题,但是用动态规划的思想有助于讲解状压dp);

  1. 定义状态

因为要求每一种放法的背包价值,所以状态应该是这 n n n件物品的放与不放的情况。最容易想到的是开个 n n n维数组,第 i i i个维度的下标如果是 1 1 1的话代表放第 i i i件物品, 0 0 0的话代表不放第 i i i件物品;但是这样很容易造成空间浪费;仔细观察就会发现,每件物品有放与不放两种选择;假设有5件物品的时候,用1和0代表放和不放,如果这5件物品都不放的话,那就是00000;如果这5件物品都放的话,那就是11111

因此可以用二进制表示所有物品的放与不放的情况;二进制用十进制表示的话就只有 一个维度了。而且这一个维度能表示所有物品放与不放的情况,这个过程就叫做状态压缩 00000 − 11111 00000 - 11111 0000011111可以代表所有的情况,转化为十进制就是 0   ( 1 < < 5 − 1 ) 0~(1<<5 - 1) 0 (1<<51);

  1. 状态转移

放的状态只能从不放的状态转移过来,所以dp[10000]只能从dp[00000] + W[1] 转移过来;dp[11000]可以从dp[01000] + W[1]或者dp[10000] + W[2]转移过来

  1. 按一个方向求出该问题的解

该问题并不是一个典型的动态规划问题,要求的解并不是求一个最优值

import java.util.Scanner;

/**
 * 有 n 件物品和一个容量为 v 的背包。放入第 i 件物品的占的空间为 C_i ,得到的价值是 W_i ;求解每种放法的背包价值
 * 样例:
 * 4
 * 3 6
 * 2 5
 * 3 8
 * 4 9
 * 占用空间 价值
 */
public class KnapsackStateCompressionDP {
    public static void main(String[] args) {
        int INF = 1 << 15;
        Scanner sc = new Scanner(System.in);
        // 物品数量
        int n = sc.nextInt();
        // 价值
        int[] W = new int[n];
        // 空间
        int[] C = new int[n];
        for (int i = 0; i < n; i++) {
            C[i] = sc.nextInt();
            W[i] = sc.nextInt();
        }
        int[] dp = new int[INF +10];
        int[] dp1 = new int[INF + 10];
        for (int i = 0; i < (1 << n); i++) {
            for (int j = 0; j < n; j++) {
                // i 放法中 没放 第 j 件物品
                if ((i & (1 << j)) == 0) {
                    int temp = i | (1 << j);
                    // temp 放法的价值
                    dp[temp] = dp[i] + W[j];
                    // temp 放法占用的空间
                    dp1[temp] = dp1[i] + C[j];
                }
            }
        }
        for (int i = 0; i < (1 << n); i++) {
            // 方案 i
            myPrint(i);
            System.out.print("\t");
            // 方案 i 的价值
            System.out.print(dp[i]);
            System.out.print("\t");
            // 方案 i 的占用空间
            System.out.print(dp1[i]);
            System.out.println();
        }
    }
    public static void myPrint(int num) {
        int k = 0;
        StringBuilder sb = new StringBuilder();
        if (num == 0) {
            sb.append("0");
        }
        for (; (1 << k) <= num; k++) {
            if ((num & (1 << k)) != 0) {
                sb.append("1");
            } else {
                sb.append("0");
            }
        }
        System.out.print(sb.reverse().toString());
    }
}

状压dp的特点一般是规模比较小,n一般小于15。而且一般只有两种决策

状态压缩基础

  • 元素c插入集合A:A |= (1 << c)

  • A删除c:A &= ~(1 << c)

  • A置空:A=0

  • 并集:A | B

  • 交集:A & B

  • 判断A是否是B的子集:(A & B) == A

  • 全集:(1 << n) - 1

  • 补集:((1 << n) - 1) ^ A

  • 获取最低位的1:x & -x

  • 最低位的1变为0:n = n & (n - 1)

  • 判断是否是2的幂,也就是去除最低位的1之后为0:A & (A - 1) == 0

  • 获取最高位的1

    int v = x & -x;
    while (v != x) {
        x -= v;
        v = x & -x;
    }
    return v;
    
  • 枚举A的子集

    for (int sub = A & (A - 1); sub != A; sub = (sub - 1) & A) {
        
    }
    

本题思路

题目规定数组中的元素不超过 30,因此可以将 [1, 30] 中的整数分成如下三类:

  • 1:对于任意一个好子集而言,添加任意数目的 1,得到的新子集仍然是好子集;

  • 2,3,5,6,7,10,11,13,14,15,17,19,21,22,23,26,29,30:这些数均不包含平方因子,因此每个数在好子集中至多出现一次

  • 4,8,9,12,16,18,20,24,25,27,28:这些数包含平方因子,因此一定不能在好子集中出现

可以通过硬编码的方式把 [1, 30] 中的整数按照上述分类,也可以先预处理出所有 [1, 30] 中质数 2,3,5,7,11,13,17,19,23,29,再通过试除的方式动态分类。

由于每个质因数只能出现一次,并且 [1, 30] 中一共有 10 个质数,因此可以用一个长度为 10 的二进制数 mask 表示这些质因数的使用情况,其中 mask 的第 i 位为 1 当且仅当第 i个质数已经被使用过(状态压缩)。

状态定义

定义f[i][mask] 表示当只选择 [2,i] 范围内的数,并且选择的数的质因数使用情况为 mask 时的方案数。

状态转移

如果 i 本身包含平方因子,那么无法选择 i,相当于在 [2, i-1] 范围内选择,状态转移方程为:
f [ i ] [ m a s k ] = f [ i − 1 ] [ m a s k ] f[i][mask]=f[i-1][mask] f[i][mask]=f[i1][mask]
如果 i 本身不包含平方因子,记其包含的质因子的二进制表示为 subset(同样可以通过试除的方法得到),那么状态转移方程为:
f [ i ] [ m a s k ] = f [ i − 1 ] [ m a s k ] + f [ i − 1 ] [ m a s k   ∧ s u b s e t ] × f r e q [ i ] f[i][mask]=f[i-1][mask]+f[i-1][mask^{\,\wedge}subset] \times freq[i] f[i][mask]=f[i1][mask]+f[i1][masksubset]×freq[i]
其中:

  • freq[i] 表示数组 numsi 出现的次数;

  • mask^subset 表示从二进制表示 mask 中去除所有在 subset 中出现的 1,使用按位异或运算实现。这里需要保证 subsetmask 的子集,可以使用按位与运算来判断

动态规划的边界条件为:
f [ 1 ] [ 0 ] = 2 f r e q [ 1 ] f[1][0]=2^{freq[1]} f[1][0]=2freq[1]

按一个方向求出该问题的解

最终的答案即为所有 f[30][..] 中除了 f[30][0] 以外的项的总和。

class Solution {
    static final int[] PRIMES = {2,3,5,7,11,13,17,19,23,29};
    static final int NUM_MAX = 30;
    static final int MOD = 1000000007;
    static final int MASK = 1 << PRIMES.length;
    public int numberOfGoodSubsets(int[] nums) {
        int[] freq = new int[NUM_MAX + 1];
        for (int num : nums) {
            freq[num]++;
        }

        int[][] f = new int[NUM_MAX + 1][MASK];
        // 边界条件
        f[1][0] = 1;
        for (int i = 0; i < freq[1]; i++) {
            f[1][0] = f[1][0] * 2 % MOD;
        }
        for (int i = 2; i <= NUM_MAX; i++) {
            for (int s = 0; s < MASK; s++) {
                f[i][s] = f[i - 1][s];
            }
            // nums 中不包括 i 这个数
            if (freq[i] == 0) {
                continue ;
            }
            // 检查 i 的每个质因数是否均不超过 1 个
            int subset = 0, x = i;
            boolean check = true;
            for (int j = 0; j < PRIMES.length; j++) {
                int prime = PRIMES[j];
                if (x % (prime * prime) == 0) {
                    check = false;
                    break;
                }
                // i 包含的质因子的二进制表示为 subset
                if (x % prime == 0) {
                    subset |= (1 << j);
                }
            }
            if (!check) {
                continue ;
            }
            // 动态规划
            // check = true 表示数字 i 可以放入集合中,且只能放入一个
            for (int mask = 0; mask < MASK; mask++) {
                if ((mask & subset) == subset) {
                    f[i][mask] = (int) ((f[i - 1][mask] + ((long)f[i-1][mask ^ subset]) * freq[i]) % MOD);                   }
            }
        }
        int ans = 0;
        for (int i = 1; i < MASK; i++) {
            ans = (ans + f[30][i]) % MOD;
        }
        return ans;
    }
}

空间优化

注意到 f[i][mask]只会从 f[i−1][..] 转移而来,并且 f[i−1][..] 中的下标总是小于 mask,因此可以使用类似 0−1 背包的空间优化方法,在遍历 mask 时从 2 10 − 1 2^{10}-1 2101 到 1 逆序遍历,这样就只需要使用一个长度为 2 10 2^{10} 210 的一维数组做状态转移了

class Solution {
    static final int[] PRIMES = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29};
    static final int NUM_MAX = 30;
    static final int MOD = 1000000007;
	static final int MASK = 1 << PRIMES.length;
    public int numberOfGoodSubsets(int[] nums) {
        int[] freq = new int[NUM_MAX + 1];
        for (int num : nums) {
            ++freq[num];
        }

        int[] f = new int[MASK];
        f[0] = 1;
        for (int i = 0; i < freq[1]; ++i) {
            f[0] = f[0] * 2 % MOD;
        }
        
        for (int i = 2; i <= NUM_MAX; ++i) {
            if (freq[i] == 0) {
                continue;
            }
            
            // 检查 i 的每个质因数是否均不超过 1 个
            int subset = 0, x = i;
            boolean check = true;
            for (int j = 0; j < PRIMES.length; ++j) {
                int prime = PRIMES[j];
                if (x % (prime * prime) == 0) {
                    check = false;
                    break;
                }
                if (x % prime == 0) {
                    subset |= (1 << j);
                }
            }
            if (!check) {
                continue;
            }

            // 动态规划
            for (int mask = MASK - 1; mask > 0; --mask) {
                if ((mask & subset) == subset) {
                    f[mask] = (int) ((f[mask] + ((long) f[mask ^ subset]) * freq[i]) % MOD);
                }
            }
        }

        int ans = 0;
        for (int mask = 1; mask < MASK; ++mask) {
            ans = (ans + f[mask]) % MOD;
        }        
        return ans;
    }
}
  • 时间复杂度: O ( n + C × 2 π ( C ) ) O(n + C \times 2^{\pi(C)}) O(n+C×2π(C))。其中 n n n 是数组 n u m s nums nums 的长度, C C C n u m s nums nums 元素的最大值,在本题中 C=30 π ( x ) \pi(x) π(x) 表示 ≤ x \leq x x 的质数的个数。

    • 一共需要考虑 O ( C ) O(C) O(C) 个数,每个数需要 O ( 2 π ( C ) ) O(2^{\pi(C)}) O(2π(C)) 的时间计算动态规划;
    • 在初始时还需要遍历一遍所有的数,时间复杂度为 O ( n ) O(n) O(n)
  • 空间复杂度: O ( 2 π ( C ) ) O(2^{\pi(C)}) O(2π(C)),即为动态规划需要使用的空间。

Reference

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

xylitolz

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值