【刷穿 LeetCode】526. 优美的排列 : 详解两种状态压缩 DP 思路

简介: 【刷穿 LeetCode】526. 优美的排列 : 详解两种状态压缩 DP 思路

网络异常,图片无法展示
|


题目描述



这是 LeetCode 上的 526. 优美的排列 ,难度为 中等


Tag : 「位运算」、「状压 DP」、「动态规划」


假设有从 11NNNN 个整数,如果从这 NN 个数字中成功构造出一个数组,使得数组的第 ii 位 (1 <= i <= N1<=i<=N) 满足如下两个条件中的一个,我们就称这个数组为一个优美的排列。


条件:


  • ii 位的数字能被 ii 整除
  • ii 能被第 ii 位上的数字整除


现在给定一个整数 NN,请问可以构造多少个优美的排列?


示例1:


输入: 2
输出: 2
解释: 
第 1 个优美的排列是 [1, 2]:
  第 1 个位置(i=1)上的数字是1,1能被 i(i=1)整除
  第 2 个位置(i=2)上的数字是2,2能被 i(i=2)整除
第 2 个优美的排列是 [2, 1]:
  第 1 个位置(i=1)上的数字是2,2能被 i(i=1)整除
  第 2 个位置(i=2)上的数字是1,i(i=2)能被 1 整除
复制代码


说明:


  • N 是一个正整数,并且不会超过15。


状态压缩 DP



利用数据范围不超过 1515,我们可以使用「状态压缩 DP」进行求解。


使用一个二进制数表示当前哪些数已被选,哪些数未被选,目的是为了可以使用位运算进行加速。


我们可以通过一个具体的样例,来感受下「状态压缩」是什么意思:


例如 (000...0101)_2(000...0101)2 代表值为 11 和值为 33 的数字已经被使用了,而值为 22 的节点尚未被使用。


然后再来看看使用「状态压缩」的话,一些基本的操作该如何进行:


假设变量 statestate 存放了「当前数的使用情况」,当我们需要检查值为 kk 的数是否被使用时,可以使用位运算 a = (state >> k) & 1,来获取 statestate 中第 kk 位的二进制表示,如果 a11 代表值为 kk 的数字已被使用,如果为 00 则未被访问。


定义 f[i][state]f[i][state] 为考虑前 ii 个数,且当前选择方案为 statestate 的所有方案数量。


一个显然的初始化条件为 f[0][0] = 1f[0][0]=1,代表当我们不考虑任何数(i = 0i=0)的情况下,一个数都不被选择(state = 0state=0)为一种合法方案。


不失一般性的考虑 f[i][state]f[i][state] 该如何转移,由于本题是求方案数,我们的转移方程必须做到「不重不漏」。


我们可以通过枚举当前位置 ii 是选哪个数,假设位置 ii 所选数值为 kk,首先 kk 值需要同时满足如下两个条件:


  • statestate 中的第 kk 位为 11
  • 要么 kk 能被 ii 整除,要么 ii 能被 kk 整除。


那么根据状态定义,位置 ii 选了数值 kk,通过位运算我们可以直接得出决策位置 ii 之前的状态是什么:state \& (\lnot (1 << (k - 1)))state&(¬(1<<(k1))),代表将 statestate 的二进制表示中的第 kk 位置 00


最终的 f[i][state]f[i][state] 为当前位置 ii 选择的是所有合法的 kk 值的方案数之和:


f[i][state] = \sum_{k = 1}^{n} f[i - 1][state \& (\lnot (1 << (k - 1)))]f[i][state]=k=1nf[i1][state&(¬(1<<(k1)))]


一些细节:由于给定的数值范围为 [1,n][1,n],但实现上为了方便,我们使用 statestate 从右往左的第 00 位表示数值 11 选择情况,第 11 位表示数值 22 的选择情况 ... 即对选择数值 kk 做一个 -11 的偏移。


代码:


class Solution {
    public int countArrangement(int n) {
        int mask = 1 << n;
        int[][] f = new int[n + 1][mask];
        f[0][0] = 1;
        for (int i = 1; i <= n; i++) {
            // 枚举所有的状态
            for (int state = 0; state < mask; state++) {
                // 枚举位置 i(最后一位)选的数值是 k
                for (int k = 1; k <= n; k++) {
                    // 首先 k 在 state 中必须是 1
                    if (((state >> (k - 1)) & 1) == 0) continue;
                    // 数值 k 和位置 i 之间满足任一整除关系
                    if (k % i != 0 && i % k != 0) continue;
                    // state & (~(1 << (k - 1))) 代表将 state 中数值 k 的位置置零
                    f[i][state] += f[i - 1][state & (~(1 << (k - 1)))];
                }
            }
        }
        return f[n][mask - 1];
    }
}
复制代码


  • 时间复杂度:共有 n * 2^nn2n 的状态需要被转移,每次转移复杂度为 O(n)O(n),整体复杂度为 O(n^2 * 2^n)O(n22n)
  • 空间复杂度:O(n * 2^n)O(n2n)


状态压缩 DP(优化)



通过对朴素的状压 DP 的分析,我们发现,在决策第 ii 位的时候,理论上我们应该使用的数字数量也应该为 ii 个。


但这一点在朴素状压 DP 中并没有体现,这就导致了我们在决策第 ii 位的时候,仍然需要对所有的 statestate 进行计算检查(即使是那些二进制表示中 11 的出现次数不为 ii 个的状态)。


因此我们可以换个思路进行枚举(使用新的状态定义并优化转移方程)。


定义 f[state]f[state] 为当前选择数值情况为 statestate 时的所有方案的数量。


这样仍然有 f[0] = 1f[0]=1 的初始化条件,最终答案为 f[(1 << n) - 1]f[(1<<n)1]


不失一般性考虑 f[state]f[state] 如何计算:


从当前状态 statestate 进行出发,检查 statestate 中的每一位 11 作为最后一个被选择的数值,这样仍然可以确保方案数「不重不漏」的被统计,同时由于我们「从小到大」对 statestate 进行枚举,因此计算 f[state]f[state] 所依赖的其他状态值必然都已经被计算完成。


同样的,我们仍然需要确保 statestate 中的那一位作为最后一个的 11 需要与所放的位置成整除关系。


因此我们需要一个计算 statestate11 的个数的方法,这里使用 lowbitlowbit 实现即可。


最终的 f[state]f[state] 为当前位置选择的是所有合法值的方案数之和:


f[state] = \sum_{i = 0}^{n}f[state \& ( \lnot (1 << i))]f[state]=i=0nf[state&(¬(1<<i))]


代码:


class Solution {
    int getCnt(int x) {
        int ans = 0;
        while (x != 0) {
            x -= (x & -x); // lowbit
            ans++;
        }
        return ans;
    }
    public int countArrangement(int n) {
        int mask = 1 << n;
        int[] f = new int[mask];
        f[0] = 1;
        // 枚举所有的状态
        for (int state = 1; state < mask; state++) {
            // 计算 state 有多少个 1(也就是当前排序长度为多少)
            int cnt = getCnt(state);
            // 枚举最后一位数值为多少
            for (int i = 0; i < n; i++) {
                // 数值在 state 中必须是 1
                if (((state >> i) & 1) == 0) continue;
                // 数值(i + 1)和位置(cnt)之间满足任一整除关系
                if ((i + 1) % cnt != 0 && cnt % (i + 1) != 0) continue;
                // state & (~(1 << i)) 代表将 state 中所选数值的位置置零
                f[state] += f[state & (~(1 << i))];
            }
        }
        return f[mask - 1];
    }
}
复制代码


  • 时间复杂度:共有 2^n2n 的状态需要被转移,每次转移复杂度为 O(n)O(n),整体复杂度为 O(n * 2^n)O(n2n)
  • 空间复杂度:O(2^n)O(2n)


总结



不难发现,其实两种状态压缩 DP 的思路其实是完全一样的。


只不过在朴素状压 DP 中我们是显式的枚举了考虑每一种长度的情况(存在维度 ii),而在状压 DP(优化)中利用则 statestate 中的 11 的个数中蕴含的长度信息。


最后



这是我们「刷穿 LeetCode」系列文章的第 No.525 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先将所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

相关文章
|
6月前
|
测试技术
leetcode-1592:重新排列单词间的空格
leetcode-1592:重新排列单词间的空格
44 0
|
1月前
|
算法 C++ 容器
Leetcode第三十一题(下一个排列)
这篇文章介绍了LeetCode第31题“下一个排列”的C++解决方案,该算法通过原地修改数组来找到下一个字典序更大的排列,如果不存在则重排为字典序最小的排列。
28 0
Leetcode第三十一题(下一个排列)
|
5月前
|
存储 算法 数据挖掘
python 数学+减治、下一个排列法、DFS回溯法实现:第 k 个排列【LeetCode 题目 60】
python 数学+减治、下一个排列法、DFS回溯法实现:第 k 个排列【LeetCode 题目 60】
|
3月前
|
算法 Java
LeetCode初级算法题:环形链表+排列硬币+合并两个有序数组java解法
LeetCode初级算法题:环形链表+排列硬币+合并两个有序数组java解法
54 0
|
6月前
|
存储 算法 安全
【刷题】 leetcode 面试题 01.06 字符串压缩
来看效果: 非常好!!!过啦!!!
63 5
【刷题】 leetcode 面试题 01.06 字符串压缩
|
6月前
力扣337.打家劫舍3(树形dp)
力扣337.打家劫舍3(树形dp)
|
5月前
|
算法
【经典LeetCode算法题目专栏分类】【第8期】滑动窗口:最小覆盖子串、字符串排列、找所有字母异位词、 最长无重复子串
【经典LeetCode算法题目专栏分类】【第8期】滑动窗口:最小覆盖子串、字符串排列、找所有字母异位词、 最长无重复子串
|
5月前
|
算法
【经典LeetCode算法题目专栏分类】【第2期】组合与排列问题系列
【经典LeetCode算法题目专栏分类】【第2期】组合与排列问题系列
|
5月前
|
存储 算法 数据挖掘
LeetCode 题目 31:下一个排列【python】
LeetCode 题目 31:下一个排列【python】
|
5月前
dp表,哈希表,力扣5.最长回文子串力扣1745.分割回文串IV力扣132.分割回文串II优先级队列(堆)是什么下面是手动实现小根堆力扣1046.最后一块石头的重量
dp表,哈希表,力扣5.最长回文子串力扣1745.分割回文串IV力扣132.分割回文串II优先级队列(堆)是什么下面是手动实现小根堆力扣1046.最后一块石头的重量