「力扣」上状态压缩的两道入门问题

407 阅读6分钟

「状态压缩」利用位运算,把若干个状态「压缩」到一个整数里,方便查找、比较和复制(参数传递)。

可以状态压缩,是基于这样一个事实:一个布尔数组可以与一个整数一一对应。

一个长度小于等于 32 的布尔数组可以与一个 int 类型的整数一一对应,可以表示 2322^{32} 种状态; 一个长度小于等于 64 的布尔数组可以与一个 long 类型的整数一一对应,可以表示 2642^{64} 种状态。


今天要和大家分享的是「状态压缩」的两道问题。我再准备一段时间,再和大家分享「动态规划」里「状压 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 只包含小写英文字母

算法思想

前缀和 -> 区间和(对于异或也是类似的道理)、哈希表、动态规划、状态压缩。

思路分析:

  1. 「出现两次」联想到异或和两次抵消;

  2. 「子字符串」表示连续,连续的问题通常想到「滑动窗口」或者是「前缀和」,这里的和也指异或和;子串中 aeiou 只出现偶数次,等价于:在这个子串里异或和为 00

  3. 由于要记录「最长的」符合要求的子串的长度,于是只需要记录第一次出现的「前缀异或和」,以后再次出现的相同的「异或前缀和」的时候,将下标相减(注意考虑边界情况)。

    因此把所有的「前缀异或和」信息保存在一个哈希表里,由于这里所有的前缀异或和状态有限(25=322^5 = 32),用数组或者哈希表均可;

  4. 定义成「异或前缀和」是因为中间遍历的那些元音字符相同的,在异或运算下都抵消了,这是符合题目的要求的:「中间遍历的那些字符出现偶数次元音字符」;

  5. 这个哈希表的 key 是前缀异或和对应的整数 curvalue 是当前遍历到的下标,初始化的时候赋值为一个特殊值,表示当前的前缀异或和没有出现;

  6. cur 记录了遍历到当前下标 iaeiou 的情况,是一个「前缀和」的概念,并且是「异或前缀和」;

  7. 把前缀异或和的信息表示在一个二进制只有 5 位的整数 cur 里,方便以后查找;

  8. 把状态信息返回在一个整数里面的操作叫做「状态压缩」,状态压缩的好处是,便于查找和比对,不好的地方是调试相对困难。

以下两种写法一样:

参考代码 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;
    }
}

复杂度分析

  • 时间复杂度:O(N)O(N),这里 NN 是输入数组的长度,遍历一次得到结果;

  • 空间复杂度:O(N)O(N),状态数组的长度是 NN

  • 参考资料

例 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

提示:

  • 给定二叉树的节点数目在 110^5 之间。
  • 节点值在 19 之间。

思路分析

  • 伪回文的意思是:要么出现的字母都能两两配对,要么除了两两配对的字符以外,还有一个字符是「落单」的;
  • 题目说的是从根结点到叶子结点的一个路径,因此我们使用「先序遍历」;
  • 异或有这样的特点:异或两次以后为 00
  • 状态压缩的好处是:易于复制,像这道问题,Java 中参数的传递是「值传递」,可以方便地作为参数传递下去;
  • (status & (status - 1)) 是位运算的一个性质,需要记住的,表示把二进制表示下最低位的 11 变成 00

参考代码 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);
        }
    }
}

复杂度分析

  • 时间复杂度:O(N)O(N),这里 NN 是二叉树的结点的个数;
  • 空间复杂度:O(logN)O(\log N),这里需要的空间是递归树的高度。