力扣:473. 火柴拼正方形 - 的思考过程

474 阅读2分钟

473. 火柴拼正方形 - 力扣(LeetCode) (leetcode-cn.com)

题目:

是否能利用给定数组中的所有数拼出一个正方形。

思考过程:

看题目的数据范围就知道是用状态压缩。当然,这类题目很多时候都可以用dfs + 剪枝技巧 来做。包括我自己以前都是喜欢一股脑地上dfs,但是这次专门挑战下就用状态压缩dp。原因有两个:1)处于锻炼自身技能的目的 2)dfs经常要搭配一些剪枝技巧,有时候并不是很容易找出来剪枝技巧的思路。

这道题属于十分经典的状态压缩类的(分组)问题。典型的套路就是:将原问题拆解为子问题的解。还有一道比较类似的可以参考:1681. 最小不兼容性 - 力扣(LeetCode) (leetcode-cn.com)

在这里的情况就是将数组中所有数分到4组,看是否每一组的和都相等。所以我们可以提前计算出数组总和,然后除以4就能求出边长,也即每个分组的和

递推公式如下:

f[k][mask]=f[k1][masksubset],其中valid[subset]=true,表示数组中的某个子集subset代表的数之和是否等于正方形的边长f[k][mask] \quad |= f[k-1][mask \oplus subset],其中valid[subset] = true,表示数组中的某个 \\子集subset代表的数之和是否等于正方形的边长

顺便说一下两个位运算的经典作用:

1.求所有子集

int subset = mask;
while (subset > 0) {
	subset = (subset - 1) & mask;
}

也可以:
for (int subset = mask; subset > 0; subset = (subset - 1) & mask) {

}

2.将选定的数置0

mask = mask ^ subset;
将二进制表示的mask 中的subset 位置 置为0

我们可以先预处理valid数组,看哪些数字组合可以放到一组中,然后再利用上面的递推式枚举所有状态,最后答案是:

f[k][(1<<nums.length)1]f[k][(1 << nums.length) - 1]

一开始的代码如下:

   public boolean makesquare(int[] matchsticks) {
        long sum = 0;
        for (int i = 0; i < matchsticks.length; i++) {
            sum += matchsticks[i];
        }
        if (sum % 4 != 0) {
            return false;
        }
        int sidelen = (int) (sum / 4);
        boolean[][] valid = new boolean[4][1 << matchsticks.length];
        for (int i = 0; i < (1 << matchsticks.length); i++) {
            long tmpSum = 0L;
            for (int j = 0; j < matchsticks.length; j++) {
                if (((1 << j) & i) != 0) {
                    tmpSum += matchsticks[j];
                }
            }
            if (tmpSum == sidelen) {
                valid[0][i] = true;
            }

        for (int i = 1; i < 4; i++) {
            for (int j = 0; j < (1 << matchsticks.length); j++) {
                for (int subset = j; subset > 0; subset = (subset - 1) & j) {

                    if (valid[0][subset]) {
                        valid[i][j] |= valid[i - 1][j ^ subset];
                    }
                }
            }
        }

        return valid[3][(1 << matchsticks.length) - 1];
    }

image-20211129224342302.png

2300多ms通过你敢信。。。?

当然本着学习的态度,这显然是无法接受的。

于是我在原来的代码上加了很多输出,帮助自己寻找“重复”的计算,类似于:

   public boolean makesquare(int[] matchsticks) {
        long sum = 0;
        for (int i = 0; i < matchsticks.length; i++) {
            sum += matchsticks[i];
        }
        if (sum % 4 != 0) {
            return false;
        }
        int sidelen = (int) (sum / 4);
        boolean[][] valid = new boolean[4][1 << matchsticks.length];
        for (int i = 0; i < (1 << matchsticks.length); i++) {
            long tmpSum = 0L;
            for (int j = 0; j < matchsticks.length; j++) {
                if (((1 << j) & i) != 0) {
                    tmpSum += matchsticks[j];
                }
            }
            if (tmpSum == sidelen) {
                valid[0][i] = true;
            }
        }

        for (int i = 1; i < 4; i++) {
            for (int j = 0; j < (1 << matchsticks.length); j++) {
                for (int subset = j; subset > 0; subset = (subset - 1) & j) {

                    if (valid[0][subset]) {
                        valid[i][j] |= valid[i - 1][j ^ subset]

                    }
                }
            }
        }

//        for (int i = 0; i < 4; i++) {
//            for (int j = 0; j < (1 << matchsticks.length); j++) {
////                StringJoiner stringJoiner = new StringJoiner(",");
////                for (int k = 0; k < matchsticks.length; k++) {
////                    if (((1 << k) & j) != 0) {
////                        stringJoiner.add(matchsticks[k] + "");
////                    }
////                }
////                System.out.println(stringJoiner.toString() + " " + valid[i][j]);
//                System.out.println(translate(matchsticks, j) + " " + valid[i][j]);
//            }
//            System.out.println("***************");
//        }
        return valid[3][(1 << matchsticks.length) - 1];
    }
    
        private String translate(int[] matchsticks, int chosen) {

        StringJoiner stringJoiner = new StringJoiner(",");
        for (int k = 0; k < matchsticks.length; k++) {
            if (((1 << k) & chosen) != 0) {
                stringJoiner.add(matchsticks[k] + "");
            }
        }
        //System.out.println(stringJoiner.toString() + " ");
        return stringJoiner.toString() + " ";
    }

终于,在观察了半天打印出来的输出之后,我发现:其实我一直迷惑了自己,双层for循环其实是“不重不漏”的,没有地方重复计算了,每一步都是“合理”的,比如,当要求f[3][mask]的时候,就需要知道f[2][mask ^ subset]的各种情况。

真正耗时的是由很多计算其实是不需要的,所以只需要加上判断:

if (sidesum[j] != (i + 1) * sidelen) {
    continue;
}

就能够提减少运算。

它的含义是在当前k=i的情况下,只有那些sidesum[j] == (i + 1) * sidelen才有可能 为true

完整代码如下:

public boolean makesquare(int[] matchsticks) {
    long sum = 0;
    for (int i = 0; i < matchsticks.length; i++) {
        sum += matchsticks[i];
    }
    if (sum % 4 != 0) {
        return false;
    }
    int sidelen = (int) (sum / 4);
    boolean[][] valid = new boolean[4][1 << matchsticks.length];
    long[] sidesum = new long[1 << matchsticks.length];

    for (int i = 0; i < (1 << matchsticks.length); i++) {
        long tmpSum = 0L;
        for (int j = 0; j < matchsticks.length; j++) {
            if (((1 << j) & i) != 0) {
                tmpSum += matchsticks[j];
            }
        }
        sidesum[i] = tmpSum;
        if (tmpSum == sidelen) {
            valid[0][i] = true;
        }
    }

    //int count = 0;
    for (int i = 1; i < 4; i++) {
        for (int j = 0; j < (1 << matchsticks.length); j++) {
            if (sidesum[j] != (i + 1) * sidelen) {
                continue;
            }
            //count++;
            for (int subset = j; subset > 0; subset = (subset - 1) & j) {
                if (valid[0][subset]) {
                    valid[i][j] = valid[i - 1][j ^ subset];
                }
                if (valid[i][j]) {
                    break;
                }
            }
        }
    }

    //System.out.println(count);
    return valid[3][(1 << matchsticks.length) - 1];
}

另外,其实可以把双层循环改成一层就够了,如下:

public boolean makesquare(int[] matchsticks) {
    long sum = 0;
    for (int i = 0; i < matchsticks.length; i++) {
        sum += matchsticks[i];
    }
    if (sum % 4 != 0) {
        return false;
    }
    int sidelen = (int) (sum / 4);
    boolean[] valid = new boolean[1 << matchsticks.length];
    long[] sidesum = new long[1 << matchsticks.length];

    for (int i = 0; i < (1 << matchsticks.length); i++) {
        long tmpSum = 0L;
        for (int j = 0; j < matchsticks.length; j++) {
            if (((1 << j) & i) != 0) {
                tmpSum += matchsticks[j];
            }
        }
        sidesum[i] = tmpSum;
        if (tmpSum == sidelen) {
            valid[i] = true;
        }
    }


    //int count = 0;
    for (int j = 0; j < (1 << matchsticks.length); j++) {
        if (sidesum[j] % sidelen == 0) {
           // if (sidesum[j] / sidelen > 1) {
             //   count++;
          //  }

            for (int subset = j; subset > 0; subset = (subset - 1) & j) {

                if (valid[subset]) {
                    valid[j] |= valid[j ^ subset];
                }
                if (valid[j]) {
                    break;
                }
            }
        }
    }
    //System.out.println(count);
    return valid[(1 << matchsticks.length) - 1];
}

上面的两个代码我加了count来对比是否计算量一致,最后发现是一致的。

最终提交的时间:

image-20211129225933699.png

其实跟dfs+剪枝比还是不快的,但是这种套路应用性好一点,毕竟有些剪枝技巧实在是不容易看出来。