目录

题目描述

1444. 切披萨的方案数

题意分析

给定一个 $m \times n$ 的字符矩阵表示披萨,字符 A 表示这一格有苹果,. 表示没有。要把披萨分给 $k$ 个人,因此需要切 $k-1$ 刀,最终得到 $k$ 块,且每一块都必须至少包含一个苹果。求切法总数,结果对 $10^9+7$ 取模。

切割方式受到严格限制,这是全题最重要的约束信号:每一刀要么沿水平方向、要么沿垂直方向,且必须贯穿当前这块披萨;切完之后,把上面那部分或左边那部分分给一个人拿走,自己继续持有剩下的部分接着切。

这条规则带来一个决定性的结构性质:无论切了多少刀,我留在手里的那块披萨永远是原矩阵的一个右下角子矩形。因为每一刀只会砍掉顶部若干行或左侧若干列,右边界和下边界从始至终不动。于是「当前状态」只需要两个数就能完整描述——剩余部分的左上角坐标。

取模要求提示答案规模会很大,说明这是计数问题而非枚举问题,必须用递推而不是真的把方案列出来。数据规模上 $m$、$n$ 与 $k$ 都不大,允许一个状态数为三者乘积、转移代价为 $O(m+n)$ 的算法。

边界方面要注意:最后剩在自己手里的那一块也是 $k$ 块中的一块,同样必须含苹果,不能只检查被切走的部分;另外整个披萨可能一个苹果都没有,此时答案为 $0$。

解法:后缀苹果数 + 记忆化搜索

核心思路

每一刀只会切走当前披萨的上部或左部,因此手中继续切的部分始终是一个右下角子矩形。状态不需要记录四条边,只要记录它的左上角坐标 (row, col)

为了 $O(1)$ 判断切出的区域是否有苹果,预处理二维后缀和:apples[row][col] 表示从 (row, col) 到右下角的苹果数。递推式为

\[apples[row][col] = apples[row+1][col] + apples[row][col+1] - apples[row+1][col+1] + value(row,col)\]

定义 dfs(row, col, pieces):把左上角为 (row, col) 的右下角子矩形切成 pieces 块,且每块都有苹果的方案数。

  • 横切到 nextRow:切走的上部含苹果,当且仅当 apples[row][col] - apples[nextRow][col] > 0;剩余状态为 dfs(nextRow, col, pieces - 1)
  • 竖切到 nextCol:切走的左部含苹果,当且仅当 apples[row][col] - apples[row][nextCol] > 0;剩余状态为 dfs(row, nextCol, pieces - 1)

边界是:剩余苹果数少于 pieces 时必然无解;pieces == 1 时,只要当前区域有苹果就有一种方案。用记忆化缓存三元状态,避免不同切割路径重复计算同一子问题。

正确性来自按「第一刀」分类:任意合法方案都有唯一的第一刀方向和位置,转移枚举了全部可能;被切走部分由后缀和保证含苹果,剩余部分由递归继续保证,所以不会计入非法方案,也不会重复或遗漏。

解题步骤

  • 从右下向左上计算 apples,额外补一行一列零值哨兵以统一边界。
  • 建立三维缓存 memo[row][col][pieces],用 -1 表示尚未计算,因为合法方案数本身可能为 0
  • 进入搜索后,若 apples[row][col] < pieces,返回 0;若 pieces == 1,返回 1
  • 枚举所有横切位置,只有切走的上部含苹果时才递归到下方子矩形。
  • 枚举所有竖切位置,只有切走的左部含苹果时才递归到右方子矩形。
  • 将所有子状态方案数相加并对 $10^9+7$ 取模,写入缓存后返回。

["A..", "AAA", "..."]k = 3:第一刀横切掉首行后,第二刀有两种合法竖切位置;第一刀竖切掉首列后,第二刀还有一种合法竖切位置,共 3 种。

代码实现

import java.util.Arrays;

class Solution {
    private static final int MOD = 1_000_000_007;
    private int rows;
    private int cols;
    private int[][] apples;
    private int[][][] memo;

    public int ways(String[] pizza, int k) {
        rows = pizza.length;
        cols = pizza[0].length();
        apples = new int[rows + 1][cols + 1];

        for (int row = rows - 1; row >= 0; row--) {
            for (int col = cols - 1; col >= 0; col--) {
                apples[row][col] = apples[row + 1][col] + apples[row][col + 1]
                        - apples[row + 1][col + 1]
                        + (pizza[row].charAt(col) == 'A' ? 1 : 0);
            }
        }

        memo = new int[rows][cols][k + 1];
        for (int row = 0; row < rows; row++) {
            for (int col = 0; col < cols; col++) {
                Arrays.fill(memo[row][col], -1);
            }
        }
        return dfs(0, 0, k);
    }

    private int dfs(int row, int col, int pieces) {
        if (apples[row][col] < pieces) {
            return 0;
        }
        if (pieces == 1) {
            return 1;
        }
        if (memo[row][col][pieces] != -1) {
            return memo[row][col][pieces];
        }

        int ans = 0;
        for (int nextRow = row + 1; nextRow < rows; nextRow++) {
            if (apples[row][col] - apples[nextRow][col] > 0) {
                ans = (ans + dfs(nextRow, col, pieces - 1)) % MOD;
            }
        }
        for (int nextCol = col + 1; nextCol < cols; nextCol++) {
            if (apples[row][col] - apples[row][nextCol] > 0) {
                ans = (ans + dfs(row, nextCol, pieces - 1)) % MOD;
            }
        }
        memo[row][col][pieces] = ans;
        return ans;
    }
}
func ways(pizza []string, k int) int {
    const mod = 1_000_000_007
    rows, cols := len(pizza), len(pizza[0])

    apples := make([][]int, rows+1)
    for row := range apples {
        apples[row] = make([]int, cols+1)
    }
    for row := rows - 1; row >= 0; row-- {
        for col := cols - 1; col >= 0; col-- {
            apples[row][col] = apples[row+1][col] + apples[row][col+1] -
                apples[row+1][col+1]
            if pizza[row][col] == 'A' {
                apples[row][col]++
            }
        }
    }

    memo := make([][][]int, rows)
    for row := range memo {
        memo[row] = make([][]int, cols)
        for col := range memo[row] {
            memo[row][col] = make([]int, k+1)
            for pieces := range memo[row][col] {
                memo[row][col][pieces] = -1
            }
        }
    }

    var dfs func(int, int, int) int
    dfs = func(row, col, pieces int) int {
        if apples[row][col] < pieces {
            return 0
        }
        if pieces == 1 {
            return 1
        }
        if memo[row][col][pieces] != -1 {
            return memo[row][col][pieces]
        }

        ans := 0
        for nextRow := row + 1; nextRow < rows; nextRow++ {
            if apples[row][col]-apples[nextRow][col] > 0 {
                ans = (ans + dfs(nextRow, col, pieces-1)) % mod
            }
        }
        for nextCol := col + 1; nextCol < cols; nextCol++ {
            if apples[row][col]-apples[row][nextCol] > 0 {
                ans = (ans + dfs(row, nextCol, pieces-1)) % mod
            }
        }
        memo[row][col][pieces] = ans
        return ans
    }

    return dfs(0, 0, k)
}

复杂度分析

  • 时间复杂度:$O(kmn(m+n))$。共有 $O(kmn)$ 个状态,每个状态最多枚举 $m-1$ 个横切位置和 $n-1$ 个竖切位置;后缀和预处理的 $O(mn)$ 被主项覆盖。
  • 空间复杂度:$O(kmn)$。三维缓存占主导;后缀和为 $O(mn)$,递归深度最多为 $k$。

关键点总结

  • 「每刀切走上部或左部」保证剩余区域固定右下角,使状态从四条边压缩为左上角两个坐标。
  • 后缀和把「切走部分是否含苹果」降为一次减法,是转移能高效枚举的前提。
  • 状态必须包含剩余块数;相同矩形在需要切成不同块数时答案不同。
  • 按第一刀分类可直接证明转移不重不漏,递归负责验证最后留下的部分。
  • apples[row][col] < pieces 提前剪掉苹果数不足的状态。

易错点总结

  • 只检查切走的部分,未验证最后留下的部分;递归边界必须保证当前区域也含苹果。
  • 把横切判据误写成「剩余部分有苹果」;真正要先检查的是被交出去的上部,即两个后缀和之差。
  • 允许 nextRow == rowsnextCol == cols,会把空区域留给后续的人。
  • 0 表示缓存未计算,导致答案确实为 0 的状态被反复搜索。
  • 后缀和漏减重叠区域 apples[row+1][col+1],会重复计算右下区域的苹果。
  • 累加方案数时不及时取模,容易在其他语言或更大约束下溢出。

相似题目

题目 难度 考察点
304. 二维区域和检索 - 矩阵不可变 中等 只练二维前缀和的构建与容斥查询,是本题的预处理部分
312. 戳气球 困难 同为区间划分计数型 dp,但要枚举最后操作的那个位置
363. 矩形区域不超过 K 的最大数值和 困难 同用二维前缀和,目标从计数换成带约束的最值
410. 分割数组的最大值 困难 一维分段问题,求最小化最大段和而非方案数
1043. 分隔数组以得到最大和 中等 一维分段 dp,段长有上限,转移只需枚举上一刀
1314. 矩阵区域和 中等 二维前缀和的直接应用,重点在边界裁剪