「状态压缩」利用位运算,把若干个状态「压缩」到一个整数里,方便查找、比较和复制(参数传递)。
可以状态压缩,是基于这样一个事实:一个布尔数组可以与一个整数一一对应。
一个长度小于等于 32 的布尔数组可以与一个 int 类型的整数一一对应,可以表示 种状态; 一个长度小于等于 64 的布尔数组可以与一个 long 类型的整数一一对应,可以表示 种状态。
今天要和大家分享的是「状态压缩」的两道问题。我再准备一段时间,再和大家分享「动态规划」里「状压 dp」相关的问题,这两道问题是一个热身。
例 1:「力扣」第 1371 题:每个元音包含偶数次的最长子字符串
给你一个字符串
s,请你返回满足以下条件的最长子字符串的长度:每个元音字母,即 'a','e','i','o','u' ,在子字符串中都恰好出现了偶数次。
示例 1:
输入:s = "eleetminicoworoep"
输出:13
解释:最长子字符串是 "leetminicowor" ,它包含 e,i,o 各 2 个,以及 0 个 a,u 。
示例 2:
输入:s = "leetcodeisgreat"
输出:5
解释:最长子字符串是 "leetc" ,其中包含 2 个 e 。
示例 3:
输入:s = "bcbcbc"
输出:6
解释:这个示例中,字符串 "bcbcbc" 本身就是最长的,因为所有的元音 a,e,i,o,u 都出现了 0 次。
提示:
1 <= s.length <= 5 x 10^5;s 只包含小写英文字母。
算法思想
前缀和 -> 区间和(对于异或也是类似的道理)、哈希表、动态规划、状态压缩。
思路分析:
-
「出现两次」联想到异或和两次抵消;
-
「子字符串」表示连续,连续的问题通常想到「滑动窗口」或者是「前缀和」,这里的和也指异或和;子串中
a、e、i、o、u只出现偶数次,等价于:在这个子串里异或和为 ; -
由于要记录「最长的」符合要求的子串的长度,于是只需要记录第一次出现的「前缀异或和」,以后再次出现的相同的「异或前缀和」的时候,将下标相减(注意考虑边界情况)。
因此把所有的「前缀异或和」信息保存在一个哈希表里,由于这里所有的前缀异或和状态有限(),用数组或者哈希表均可;
-
定义成「异或前缀和」是因为中间遍历的那些元音字符相同的,在异或运算下都抵消了,这是符合题目的要求的:「中间遍历的那些字符出现偶数次元音字符」;
-
这个哈希表的
key是前缀异或和对应的整数cur,value是当前遍历到的下标,初始化的时候赋值为一个特殊值,表示当前的前缀异或和没有出现; -
cur记录了遍历到当前下标i的a、e、i、o、u的情况,是一个「前缀和」的概念,并且是「异或前缀和」; -
把前缀异或和的信息表示在一个二进制只有 5 位的整数
cur里,方便以后查找; -
把状态信息返回在一个整数里面的操作叫做「状态压缩」,状态压缩的好处是,便于查找和比对,不好的地方是调试相对困难。
以下两种写法一样:
参考代码 1:
Java 代码:
import java.util.Arrays;
public class Solution {
public int findTheLongestSubstring(String s) {
// dp 定义:状态为 i 的前缀异或和第 1 次出现的
int[] dp = new int[32];
// -1 表示未赋值
Arrays.fill(dp, -1);
// 前缀异或和
int bitMap = 0;
dp[bitMap] = 0;
int res = 0;
int len = s.length();
char[] charArray = s.toCharArray();
for (int i = 0; i < len; i++) {
char c = charArray[i];
if (c == 'a') {
bitMap ^= 1;
}
if (c == 'e') {
bitMap ^= (1 << 1);
}
if (c == 'i') {
bitMap ^= (1 << 2);
}
if (c == 'o') {
bitMap ^= (1 << 3);
}
if (c == 'u') {
bitMap ^= (1 << 4);
}
// 先记录信息,然后再计算长度的时候,就需要 + 1
if (dp[bitMap] >= 0) {
res = Math.max(res, i - dp[bitMap] + 1);
} else {
dp[bitMap] = i + 1;
}
}
return res;
}
}
参考代码 2:
Java 代码:
import java.util.Arrays;
public class Solution2 {
public int findTheLongestSubstring(String s) {
int[] dp = new int[32];
Arrays.fill(dp, -1);
int bitMap = 0;
dp[bitMap] = 0;
int res = 0;
int index = 0;
char[] chars = s.toCharArray();
for (char c : chars) {
index++;
if (c == 'a') {
bitMap ^= 1;
}
if (c == 'e') {
bitMap ^= (1 << 1);
}
if (c == 'i') {
bitMap ^= (1 << 2);
}
if (c == 'o') {
bitMap ^= (1 << 3);
}
if (c == 'u') {
bitMap ^= (1 << 4);
}
if (dp[bitMap] >= 0) {
// 由于此时 index 已经 ++,因此是 index - dp[bitMap]
res = Math.max(res, index - dp[bitMap]);
} else {
dp[bitMap] = index;
}
}
return res;
}
}
复杂度分析:
-
时间复杂度:,这里 是输入数组的长度,遍历一次得到结果;
-
空间复杂度:,状态数组的长度是 。
例 2:「力扣」第 1457 题: 二叉树中的伪回文路径
给你一棵二叉树,每个节点的值为 1 到 9 。我们称二叉树中的一条路径是 「伪回文」的,当它满足:路径经过的所有节点值的排列中,存在一个回文序列。
请你返回从根到叶子节点的所有路径中 伪回文 路径的数目。
示例 1:
输入:root = [2,3,1,3,1,null,1]
输出:2
解释:上图为给定的二叉树。总共有 3 条从根到叶子的路径:红色路径 [2,3,3] ,绿色路径 [2,1,1] 和路径 [2,3,1] 。
在这些路径中,只有红色和绿色的路径是伪回文路径,因为红色路径 [2,3,3] 存在回文排列 [3,2,3] ,绿色路径 [2,1,1] 存在回文排列 [1,2,1] 。
示例 2:
输入:root = [2,1,1,1,3,null,null,null,null,null,1]
输出:1
解释:上图为给定二叉树。总共有 3 条从根到叶子的路径:绿色路径 [2,1,1] ,路径 [2,1,3,1] 和路径 [2,1] 。
这些路径中只有绿色路径是伪回文路径,因为 [2,1,1] 存在回文排列 [1,2,1] 。
示例 3:
输入:root = [9]
输出:1
提示:
- 给定二叉树的节点数目在
1到10^5之间。 - 节点值在
1到9之间。
思路分析
- 伪回文的意思是:要么出现的字母都能两两配对,要么除了两两配对的字符以外,还有一个字符是「落单」的;
- 题目说的是从根结点到叶子结点的一个路径,因此我们使用「先序遍历」;
- 异或有这样的特点:异或两次以后为 ;
- 状态压缩的好处是:易于复制,像这道问题,Java 中参数的传递是「值传递」,可以方便地作为参数传递下去;
(status & (status - 1))是位运算的一个性质,需要记住的,表示把二进制表示下最低位的 变成 。
参考代码 1:
Java 代码:
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
private int count = 0;
public int pseudoPalindromicPaths(TreeNode root) {
dfs(root, 0);
return count;
}
private void dfs(TreeNode node, int status) {
if (node == null) {
return;
}
status ^= (1 << node.val);
if (node.left == null && node.right == null) {
if (status == 0 || (status & (status - 1)) == 0) {
count++;
}
return;
}
if (node.left != null) {
dfs(node.left, status);
}
if (node.right != null) {
dfs(node.right, status);
}
}
}
复杂度分析:
- 时间复杂度:,这里 是二叉树的结点的个数;
- 空间复杂度:,这里需要的空间是递归树的高度。