2056.棋盘上有效移动组合的数目

目标

有一个 8 x 8 的棋盘,它包含 n 个棋子(棋子包括车,后和象三种)。给你一个长度为 n 的字符串数组 pieces ,其中 pieces[i] 表示第 i 个棋子的类型(车,后或象)。除此以外,还给你一个长度为 n 的二维整数数组 positions ,其中 positions[i] = [ri, ci] 表示第 i 个棋子现在在棋盘上的位置为 (ri, ci) ,棋盘下标从 1 开始。

棋盘上每个棋子都可以移动 至多一次 。每个棋子的移动中,首先选择移动的 方向 ,然后选择 移动的步数 ,同时你要确保移动过程中棋子不能移到棋盘以外的地方。棋子需按照以下规则移动:

  • 车可以 水平或者竖直 从 (r, c) 沿着方向 (r+1, c),(r-1, c),(r, c+1) 或者 (r, c-1) 移动。
  • 后可以 水平竖直或者斜对角 从 (r, c) 沿着方向 (r+1, c),(r-1, c),(r, c+1),(r, c-1),(r+1, c+1),(r+1, c-1),(r-1, c+1),(r-1, c-1) 移动。
  • 象可以 斜对角 从 (r, c) 沿着方向 (r+1, c+1),(r+1, c-1),(r-1, c+1),(r-1, c-1) 移动。

移动组合 包含所有棋子的 移动 。每一秒,每个棋子都沿着它们选择的方向往前移动 一步 ,直到它们到达目标位置。所有棋子从时刻 0 开始移动。如果在某个时刻,两个或者更多棋子占据了同一个格子,那么这个移动组合 不有效 。

请你返回 有效 移动组合的数目。

注意:

  • 初始时,不会有两个棋子 在 同一个位置。
  • 有可能在一个移动组合中,有棋子不移动。
  • 如果两个棋子 直接相邻 且两个棋子下一秒要互相占据对方的位置,可以将它们在同一秒内 交换位置。

示例 1:

输入:pieces = ["rook"], positions = [[1,1]]
输出:15
解释:上图展示了棋子所有可能的移动。

示例 2:

输入:pieces = ["queen"], positions = [[1,1]]
输出:22
解释:上图展示了棋子所有可能的移动。

示例 3:

输入:pieces = ["bishop"], positions = [[4,3]]
输出:12
解释:上图展示了棋子所有可能的移动。

示例 4:

输入:pieces = ["rook","rook"], positions = [[1,1],[8,8]]
输出:223
解释:每个车有 15 种移动,所以总共有 15 * 15 = 225 种移动组合。
但是,有两个是不有效的移动组合:
- 将两个车都移动到 (8, 1) ,会导致它们在同一个格子相遇。
- 将两个车都移动到 (1, 8) ,会导致它们在同一个格子相遇。
所以,总共有 225 - 2 = 223 种有效移动组合。
注意,有两种有效的移动组合,分别是一个车在 (1, 8) ,另一个车在 (8, 1) 。
即使棋盘状态是相同的,这两个移动组合被视为不同的,因为每个棋子移动操作是不相同的。

示例 5:

输入:pieces = ["queen","bishop"], positions = [[5,7],[3,4]]
输出:281
解释:总共有 12 * 24 = 288 种移动组合。
但是,有一些不有效的移动组合:
- 如果后停在 (6, 7) ,它会阻挡象到达 (6, 7) 或者 (7, 8) 。
- 如果后停在 (5, 6) ,它会阻挡象到达 (5, 6) ,(6, 7) 或者 (7, 8) 。
- 如果象停在 (5, 2) ,它会阻挡后到达 (5, 2) 或者 (5, 1) 。
在 288 个移动组合当中,281 个是有效的。

说明:

  • n == pieces.length
  • n == positions.length
  • 1 <= n <= 4
  • pieces 只包含字符串 "rook" ,"queen" 和 "bishop" 。
  • 棋盘上最多只有一个后。
  • 1 <= ri, ci <= 8
  • 每一个 positions[i] 互不相同。

思路

有一个 8 x 8 的棋盘,行列编号从 1 ~ 8。棋盘上有 n 个棋子,pieces[i] 表示棋子 i 的类型,rook 表示车,queen 表示皇后,bishop 表示象,positions[i] = [ri, ci] 表示棋子 i 所在行编号为 ri,所在列编号为 ci。棋盘上的每个棋子都可以移动 至多 一步,不同的棋子移动方式不同,参考国际象棋。车走竖线或横线,象走斜线,皇后走竖线、横线或斜线,不能跳过其它棋子。

特别注意,我们求的是 有效移动 组合,重点在 移动 而不是最终状态,比如示例4。

棋盘大小固定,考虑车移动或者不移动,它最多有 15 种移动的可能,包括不移动或者移动到相应方向上的其它格子共 1 + 2 * 7 种。由于棋盘是偶数没有中间格子,所以象有 8 + 6 种。同理皇后可能的移动方式最多有 28 种,包括不移动或者移动相应方向上的其它格子 1 + 2 * 7 + 7 + 6 种,如果它位于一条 8 格的对角线上,除去自身还剩 7 格,它所在的另一对角线剩余 6 个格子。

题目描述里面非常让人迷惑的一点是既允许相邻棋子同时交换位置,而示例5又说棋子会阻挡其它棋子通行。这个题的难点在于如果按照棋盘状态去枚举的话,当两个棋子相遇的时候不知道是否应该穿越,如果穿越的话就会移动另一个棋子,如何去重?我们必须将移动的步数考虑进去,如果两个棋子相遇时都没有到达目标位置那么它们可以穿过。否则,某个棋子提前停下,另一个棋子行进到相同的位置发生碰撞,不符合题目条件。

参考题解思路写出来了。

代码


/**
 * @date 2024-12-04 14:24
 */
public class CountCombinations2056 {

    public static class Move {
        public int x;
        public int y;
        public int dx;
        public int dy;
        public int step;

        public Move(int x, int y, int dx, int dy, int step) {
            this.x = x;
            this.y = y;
            this.dx = dx;
            this.dy = dy;
            this.step = step;
        }
    }

    public int[][] rookDir = new int[][]{{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    public int[][] bishopDir = new int[][]{{-1, -1}, {1, 1}, {1, -1}, {-1, 1}};
    public int[][] queenDir = new int[][]{{-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {1, 1}, {1, -1}, {-1, 1}};

    /**
     * 预处理每个棋子可行的移动方案、起点、方向、步数
     */
    public int countCombinations(String[] pieces, int[][] positions) {
        int n = pieces.length;
        Move[][] moves = new Move[n][];
        for (int i = 0; i < n; i++) {
            moves[i] = generateAllValidMove(pieces[i], positions[i]);
        }
        Move[] curMove = new Move[n];
        return dfs(0, n, curMove, moves);
    }

    /**
     * 回溯,先从棋子0中选一个可行的移动方案,然后进入下一层,
     * 从棋子1中选一个可行的移动方案,判断当前方案与前面所选的移动方案是否存在某一时刻使得两个棋子占据同一个格子
     * 如果不存在就进入下一层,否则枚举另一个方案。
     * i 表示枚举第 i 个棋子的合法移动方案
     * curMove 表示前面棋子选择的移动方案,用于下一层比较移动方案是否合法
     * moves 表示所有棋子的移动方案
     */
    public int dfs(int i, int n, Move[] curMove, Move[][] moves) {
        if (i == n) {
            return 1;
        }
        int cnt = 0;
        here:
        for (Move move : moves[i]) {
            for (int j = 0; j < i; j++) {
                if (!valid(move, curMove[j])) {
                    continue here;
                }
            }
            curMove[i] = move;
            cnt += dfs(i + 1, n, curMove, moves);
        }
        return cnt;
    }

    public boolean valid(Move move, Move curMove) {
        int x1 = move.x;
        int y1 = move.y;
        int x2 = curMove.x;
        int y2 = curMove.y;
        // 步数小的会提前停下来,如果它们坐标相同说明存在在某一时刻使得两个棋子移动到同一个位置上,不合法
        for (int i = 0; i < Math.max(move.step, curMove.step); i++) {
            if (i < move.step) {
                x1 += move.dx;
                y1 += move.dy;
            }
            if (i < curMove.step) {
                x2 += curMove.dx;
                y2 += curMove.dy;
            }
            if (x1 == x2 && y1 == y2) {
                return false;
            }
        }
        return true;
    }

    /**
     * 生成该棋子所有可能的移动方案
     * 起点,方向,步数
     */
    public Move[] generateAllValidMove(String pieces, int[] positions) {
        int[][] directions = null;
        switch (pieces) {
            case "rook":
                directions = rookDir;
                break;
            case "bishop":
                directions = bishopDir;
                break;
            case "queen":
                directions = queenDir;
                break;
            default:
        }
        if (directions == null) {
            return null;
        }
        List<Move> moves = new ArrayList<>();
        int x = positions[0];
        int y = positions[1];
        // 将不移动的情况加入集合
        moves.add(new Move(x, y, 0, 0, 0));
        // 遍历该棋子允许的行进方向,
        for (int[] dir : directions) {
            int dx = dir[0];
            int dy = dir[1];
            int step = 0;
            x = positions[0] + dx;
            y = positions[1] + dy;
            while (x > 0 && x <= 8 && y > 0 && y <= 8) {
                moves.add(new Move(positions[0], positions[1], dx, dy, ++step));
                x += dx;
                y += dy;
            }
        }
        return moves.toArray(new Move[0]);
    }

}

性能

52.N皇后II

目标

n 皇后问题 研究的是如何将 n 个皇后放置在 n × n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回 n 皇后问题 不同的解决方案的数量。

示例 1:

输入:n = 4
输出:2
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

输入:n = 1
输出:1

说明:

1 <= n <= 9

思路

将 n 个皇后放在 n x n 的棋盘上,使它们不在同一行、不在同一列并且不在同一斜线上。返回不同的解决方案数量。与 51_N皇后 相比,本题无需返回具体的布局。

关键点:

  • 每行只能放一个皇后,枚举当前行的每一列,如果存在合法的位置则进入下一行
  • 如何判断是否在一条斜线上,对于坐标 (i, j),(a, b),如果 i + j == a + b || i - j == a - b,那么它们在一条斜线上
  • 使用数组保存已经存在皇后的斜线,由于 i - j 可能出现负数,我们将其右移 n,使用 i - j + n,最大值为 n - 1 + n,数组初始化为 2 * n,i + j 最大值为 n - 1 + n - 1 初始化为 2 * n - 1
  • 从第 0 行开始,如果能够进入第 1 行,说明第 0 行存在皇后。同理如果能够进入第 2 行,说明前两行存在皇后,总共 2 个皇后。因此结束条件为 row == n

代码


/**
 * @date 2024-12-01 17:58
 */
public class TotalNQueens52 {

    public int res;
    public int n;
    public boolean[] colIndexSet;
    public boolean[] leftDiagonalSet;
    public boolean[] rightDiagonalSet;

    public int totalNQueens(int n) {
        this.n = n;
        colIndexSet = new boolean[n];
        leftDiagonalSet = new boolean[2 * n];
        rightDiagonalSet = new boolean[2 * n - 1];
        backTracing(0);
        return res;
    }

    public void backTracing(int row) {
        if (row == n) {
            res++;
            return;
        }
        for (int col = 0; col < n; col++) {
            if (colIndexSet[col] || leftDiagonalSet[row - col + n] || rightDiagonalSet[row + col]) {
                continue;
            }
            colIndexSet[col] = true;
            leftDiagonalSet[row - col + n] = true;
            rightDiagonalSet[row + col] = true;
            backTracing(row + 1);
            colIndexSet[col] = false;
            leftDiagonalSet[row - col + n] = false;
            rightDiagonalSet[row + col] = false;
        }
    }

}

性能

51.N皇后

目标

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

示例 1:

输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

输入:n = 1
输出:[["Q"]]

说明:

  • 1 <= n <= 9

思路

将 n 个皇后放在 n x n 的棋盘上,使它们不在同一行、不在同一列并且不在同一斜线上。返回所有不同的解决方案,使用 Q 表示皇后,. 表示空位。

枚举棋盘的每个格子,当该格子放置皇后时,其所在行、列、斜线的格子都不能有皇后。使用回溯算法,如果格子遍历完皇后数量刚好为 n 个则计数。

假设 (i, j) 位置上放置了皇后,那么所有 (i, *) (*, j) 以及所有满足 a - b == i - j || i + j == a + b(a, b) 都不能再放置皇后。

使用整数的二进制位来表示列是否允许放置皇后,从左到右对应二进制的低位到高位:

  • 由于逻辑与、或没有逆运算,所以必须以参数的方式传递,否则无法恢复现场
  • 如果我们在第 0 行的第 i 列放置了皇后,那么第 1 行的第 i - 1, i, i + 1 列无法放置皇后,使用位运算的角度来看就是对 1 << i, 1 << (i + 1), 1 << (i - 1) 相与,即 1 << i, (1 << i) << 1, (1 << i) >> 1。令 c = 1 << i,再考虑当前行与前面行的限制,下一行 不能放置皇后的列为:colIndexSet | c,(leftDiagonalSet | c) << 1,(rightDiagonalSet | c) >> 1),其中 colIndexSet 的 bit 1 表示无法放置皇后的列;leftDiagonalSet 的 bit 1 表示受 斜线限制,无法放置皇后的列;rightDiagonalSet 的 bit 1 表示受 斜线限制,无法放置皇后的列;c 表示将当前行放置皇后的列对应的 bit 位 置 1 所代表的数字
  • valid = ((1 << n) - 1) & ~(colIndexSet | leftDiagonalSet | rightDiagonalSet) 表示合法的位置
  • c = valid & -valid 得到 bit 1 的最低位表示的数字
  • Integer.bitCount(c - 1) 表示列下标
  • valid ^= c 将当前处理过的最低位 bit 1 置 0

当然也可以使用数组来记录不可放置皇后的列信息,差别不大,见 52_N皇后II

代码


/**
 * @date 2024-12-01 16:45
 */
public class SolveNQueens51 {

    public static class SolveNQueens {
        public List<List<String>> res;
        public int n;
        public int[] queens;

        public List<List<String>> solveNQueens(int n) {
            this.n = n;
            queens = new int[n];
            res = new ArrayList<>();
            backTracing(0, 0, 0, 0);
            return res;
        }

        public void backTracing(int row, int colIndexSet, int leftDiagonalSet, int rightDiagonalSet) {
            if (row == n) {
                List<String> solution = new ArrayList<>(n);
                char[] chars = new char[n];
                Arrays.fill(chars, '.');
                for (int i = 0; i < n; i++) {
                    chars[queens[i]] = 'Q';
                    solution.add(new String(chars));
                    chars[queens[i]] = '.';
                }
                res.add(solution);
                return;
            }
            int valid = ((1 << n) - 1) & ~(colIndexSet | leftDiagonalSet | rightDiagonalSet);
            while (valid > 0){
                int c = valid & -valid;
                queens[row] = Integer.bitCount(c - 1);
                backTracing(row + 1, colIndexSet | c,
                        (leftDiagonalSet | c) << 1,
                        (rightDiagonalSet | c) >> 1);
                valid ^= c;
            }
        }

    }

    public List<List<String>> res;
    public Set<Integer> rowIndexSet;
    public Set<Integer> colIndexSet;
    public Set<Integer> leftDiagonalSet;
    public Set<Integer> rightDiagonalSet;
    public ArrayList<int[]> indexList;
    public int end;
    public int n;

    public List<List<String>> solveNQueens(int n) {
        res = new ArrayList<>(n);
        rowIndexSet = new HashSet<>(n);
        colIndexSet = new HashSet<>(n);
        leftDiagonalSet = new HashSet<>(n);
        rightDiagonalSet = new HashSet<>(n);
        indexList = new ArrayList<>(n);
        end = n * n;
        this.n = n;
        for (int i = 0; i < n; i++) {
            backTracing(i);
        }
        return res;
    }

    public void backTracing(int index) {
        int row = index / n;
        int col = index % n;
        int leftDiagonal = row - col;
        int rightDiagonal = row + col;
        rowIndexSet.add(row);
        colIndexSet.add(col);
        leftDiagonalSet.add(leftDiagonal);
        rightDiagonalSet.add(rightDiagonal);
        indexList.add(new int[]{row, col});
        if (indexList.size() == n) {
            StringBuilder sb = new StringBuilder();
            List<String> solution = new ArrayList<>(n);
            for (int[] cor : indexList) {
                for (int i = 0; i < n; i++) {
                    if (i == cor[1]) {
                        sb.append('Q');
                    } else {
                        sb.append('.');
                    }
                }
                solution.add(sb.toString());
                sb.setLength(0);
            }
            res.add(solution);
        }
        for (int i = index + 1; i < end; i++) {
            int r = i / n;
            int c = i % n;
            int ld = r - c;
            int rd = r + c;
            if (rowIndexSet.contains(r) || colIndexSet.contains(c)
                    || leftDiagonalSet.contains(ld)
                    || rightDiagonalSet.contains(rd)) {
                continue;
            }
            backTracing(i);
        }
        rowIndexSet.remove(row);
        colIndexSet.remove(col);
        leftDiagonalSet.remove(leftDiagonal);
        rightDiagonalSet.remove(rightDiagonal);
        indexList.remove(indexList.size() - 1);
    }

}

性能

3251.单调数组对的数目II

目标

给你一个长度为 n 的 正 整数数组 nums 。

如果两个 非负 整数数组 (arr1, arr2) 满足以下条件,我们称它们是 单调 数组对:

  • 两个数组的长度都是 n 。
  • arr1 是单调 非递减 的,换句话说 arr1[0] <= arr1[1] <= ... <= arr1[n - 1] 。
  • arr2 是单调 非递增 的,换句话说 arr2[0] >= arr2[1] >= ... >= arr2[n - 1] 。
  • 对于所有的 0 <= i <= n - 1 都有 arr1[i] + arr2[i] == nums[i] 。

请你返回所有 单调 数组对的数目。

由于答案可能很大,请你将它对 109 + 7 取余 后返回。

示例 1:

输入:nums = [2,3,2]
输出:4
解释:
单调数组对包括:
([0, 1, 1], [2, 2, 1])
([0, 1, 2], [2, 2, 0])
([0, 2, 2], [2, 1, 0])
([1, 2, 2], [1, 1, 0])

示例 2:

输入:nums = [5,5,5,5]
输出:126

说明:

  • 1 <= n == nums.length <= 2000
  • 1 <= nums[i] <= 1000

思路

有一个长度为 n 正整数数组 nums,可以将其拆成两个数组 arr1 arr2,使之满足 arr1[i] + arr2[i] == nums[i]。问 有多少种拆分方法使得 arr1 非递减 且 arr2 非递增。

与昨天的 3250.单调数组对的数目I 相比,nums[i] 的最大值从 50 变成了 1000。时间复杂度大概为 O(n*m^2),mnums[i] 的最大值,如果还沿用昨天的解法就会超时。

先将昨天的题目改写为动态规划,定义 dp[i][j] 表示最后一个元素为 j,长度为 i + 1 的满足条件的 arr1 个数。由于 arr1 是非递减的,如果最后一个元素为 arr1[i] = j 那么倒数第二个元素arr1[i - 1] <= j。同时我们还要考虑到 arr2 非递增,即 arr2[i - 1] >= arr2[i]nums[i - 1] - arr1[i - 1] >= nums[i] - arr1[i]arr1[i - 1] <= nums[i - 1] - nums[i] + arr1[i]。综上,arr1[i - 1] <= Math.min(j, nums[i - 1] - nums[i] + j)

经过上面的分析,dp[i][j] = Σdp[i - 1][k],其中 k ∈ [0, Math.min(j, nums[i - 1] - nums[i] + j)]。这样写会超时,针对每个 j,我们会进行多次重复的计算。

d = nums[i - 1] - nums[i],当 d >= 0 时,上界为 j,否则上界为 j + d

考虑 nums[i - 1] < nums[i],即 d < 0

  • arr1[i - 1] = j 时,令arr2[i - 1] = nums[i - 1] - j = a
  • arr1[i] = j 时,arr2[i] = nums[i] - arr1[i] = nums[i - 1] - d - j = a - dd < 0

也就是说,当 arr1[i] 的取值与上一层一样时,arr2[i] 比上一层的值大了 |d|。为了使第 iarr2 非递增,那么 arr1 的取值只能从 |d| 开始。

它们之间的约束关系是这样的,当 nums[i] 变大,arr1i 层取 j 时,arr2 的第 i 层比上一层增大了 |d|,这时我们必须舍弃 [0, |d|) 的取值,因为它必定大于上一层 arr2 的最大值。然后考虑第 i 层的 arr1[|d|, nums[i]] 的情况,由于第 i 层的 arr2 相比第 i - 1 层增大了 |d|,因此需要减小第 i - 1 层的 arr1,使第 i - 1 层的 arr2 增大。所以第 i 层的 j 对应第 i - 1 层的 j - |d|

dp[i][j] 的取值类似前缀和,只不过有约束条件,并不是所有值都合法。考虑简单的情况 nums[0] == nums[1] && i == 1,

  • 当 j == 0 时,dp[1][0] = dp[0][0] = 1
  • 当 j == 1 时,上一层(i == 0) arr1 可以取 0、1,dp[1][1] = dp[1][0] + dp[0][1] = 2
  • 当 j == 2 时,上一层(i == 0) arr1 可以取 0、1、2,dp[1][2] = dp[1][1] + dp[0][2] = 3

因此我们有 dp[i][j] = (dp[i][j - 1] + dp[i - 1][j - d])

代码


/**
 * @date 2024-11-29 9:39
 */
public class CountOfPairs3251 {

    public static int MOD = 1000000007;

    public int countOfPairs(int[] nums) {
        int res = 0;
        int n = nums.length;
        int[][] dp = new int[n][1001];
        for (int i = 0; i <= nums[0]; i++) {
            dp[0][i] = 1;
        }
        for (int i = 1; i < n; i++) {
            int d = Math.max(nums[i] - nums[i - 1], 0);
            for (int j = d; j <= nums[i]; j++) {
                if (j == 0) {
                    dp[i][j] = dp[i - 1][0] % MOD;
                } else {
                    dp[i][j] = (dp[i][j - 1] + dp[i - 1][j - d]) % MOD;
                }
            }
        }
        for (int i : dp[n - 1]) {
            res = (res + i) % MOD;
        }
        return res;
    }

}

性能

3250.单调数组对的数目I

目标

给你一个长度为 n 的 正 整数数组 nums 。

如果两个 非负 整数数组 (arr1, arr2) 满足以下条件,我们称它们是 单调 数组对:

  • 两个数组的长度都是 n 。
  • arr1 是单调 非递减 的,换句话说 arr1[0] <= arr1[1] <= ... <= arr1[n - 1] 。
  • arr2 是单调 非递增 的,换句话说 arr2[0] >= arr2[1] >= ... >= arr2[n - 1] 。
  • 对于所有的 0 <= i <= n - 1 都有 arr1[i] + arr2[i] == nums[i] 。

请你返回所有 单调 数组对的数目。

由于答案可能很大,请你将它对 10^9 + 7 取余 后返回。

示例 1:

输入:nums = [2,3,2]
输出:4
解释:
单调数组对包括:
([0, 1, 1], [2, 2, 1])
([0, 1, 2], [2, 2, 0])
([0, 2, 2], [2, 1, 0])
([1, 2, 2], [1, 1, 0])

示例 2:

输入:nums = [5,5,5,5]
输出:126

说明:

  • 1 <= n == nums.length <= 2000
  • 1 <= nums[i] <= 50

提示:

  • Let dp[i][s] is the number of monotonic pairs of length i with the arr1[i - 1] = s.
  • If arr1[i - 1] = s, arr2[i - 1] = nums[i - 1] - s.
  • Check if the state in recurrence is valid.

思路

有一个长度为 n 的正整数数组 nums,可以将其拆成两个数组 arr1 arr2,使之满足 arr1[i] + arr2[i] == nums[i]。问 有多少种拆分方法使得 arr1 非递减 且 arr2 非递增。

显然 arr1 确定之后,arr2 也就确定了。考虑枚举 arr1,判断 arr1 是否非递减, 以及arr2 是否非递增。可以使用记忆化搜索,对于位置 iarr1arr2 需要满足下面的条件:

  • arr1[i] >= arr1[i - 1]
  • arr2[i] = nums[i] - arr1[i] <= arr2[i - 1] = nums[i - 1] - arr1[i - 1],即 arr1[i] >= nums[i] - nums[i - 1] + arr1[i - 1]

也就是 nums[i] >= arr1[i] >= Math.max(nums[i] - nums[i - 1] + arr1[i - 1], arr1[i - 1])

代码


/**
 * @date 2024-11-28 10:36
 */
public class CountOfPairs3250 {

    public static int MOD = 1000000007;

    public int countOfPairs(int[] nums) {
        int res = 0;
        int n = nums.length;
        int[][] mem = new int[n + 1][51];
        for (int[] arr : mem) {
            Arrays.fill(arr, -1);
        }
        for (int i = 0; i <= nums[0]; i++) {
            res = (res + dfs(nums, 1, i, mem)) % MOD;
        }
        return res;
    }

    public int dfs(int[] nums, int i, int prev, int[][] mem) {
        int n = nums.length;
        if (i == n) {
            return 1;
        }
        int lowerBound = Math.max(prev, nums[i] - nums[i - 1] + prev);
        int next = i + 1;
        int res = 0;
        for (int j = lowerBound; j <= nums[i]; j++) {
            if (mem[next][j] == -1) {
                mem[next][j] = dfs(nums, next, j, mem) % MOD;
            }
            res = (res + mem[next][j]) % MOD;
        }
        return res;
    }

}

性能

632.最小区间

目标

你有 k 个 非递减排列 的整数列表。找到一个 最小 区间,使得 k 个列表中的每个列表至少有一个数包含在其中。

我们定义如果 b-a < d-c 或者在 b-a == d-c 时 a < c,则区间 [a,b] 比 [c,d] 小。

示例 1:

输入:nums = [[4,10,15,24,26], [0,9,12,20], [5,18,22,30]]
输出:[20,24]
解释: 
列表 1:[4, 10, 15, 24, 26],24 在区间 [20,24] 中。
列表 2:[0, 9, 12, 20],20 在区间 [20,24] 中。
列表 3:[5, 18, 22, 30],22 在区间 [20,24] 中。

示例 2:

输入:nums = [[1,2,3],[1,2,3],[1,2,3]]
输出:[1,1]

说明:

  • nums.length == k
  • 1 <= k <= 3500
  • 1 <= nums[i].length <= 50
  • -10^5 <= nums[i][j] <= 10^5
  • nums[i] 按非递减顺序排列

思路

k 个非递减排列的整数列表,找一个最小区间,使得 k 个列表中每个列表至少有一个元素包含其中。所谓最小区间指区间长度最小,如果长度相同,则起点小的区间更小。

可以将元素标记属于哪个列表,然后从小到大排序,使用滑动窗口,找到最小的窗口。

当元素移入/移出窗口,如何判断是否应该从集合中增加/删除列表种类?

  • 可以记录每个列表元素的最大下标,如果移出的元素等于该下标则说明窗口内不包含该列表中的元素。元素移入窗口时可以根据最大下标是否小于左边界来判断是否应该增加列表种类,考虑到开始时左边界为 0,应该将数组初始化为 -1
  • 也可以记录每个列表在窗口元素的个数,如果从 0 变为 1 则增加种类,如果从 1 变为 0 则减少。

代码


/**
 * @date 2024-11-24 15:14
 */
public class SmallestRange632 {

    /**
     * 优化代码逻辑
     */
    public int[] smallestRange_v1(List<List<Integer>> nums) {
        List<int[]> list = new ArrayList<>();
        for (int i = 0; i < nums.size(); i++) {
            for (Integer num : nums.get(i)) {
                list.add(new int[]{num, i});
            }
        }
        int[] res = new int[]{0, 1000000};
        list.sort((a, b) -> a[0] - b[0]);
        int n = list.size(), k = nums.size();
        int l = 0;
        int[] typeMaxIndex = new int[k];
        Arrays.fill(typeMaxIndex, -1);
        int types = 0;
        for (int r = 0; r < n; r++) {
            int[] e = list.get(r);
            int right = e[0];
            int type = e[1];
            int lastTypeMaxIndex = typeMaxIndex[type];
            typeMaxIndex[type] = r;
            if (lastTypeMaxIndex < l){
                types++;
            }
            while (types == k) {
                int[] left = list.get(l);
                int lType = left[1];
                if (typeMaxIndex[lType] == l++) {
                    types--;
                    if (right - left[0] < res[1] - res[0]) {
                        res[0] = left[0];
                        res[1] = right;
                    }
                }
            }
        }
        return res;
    }

    public int[] smallestRange(List<List<Integer>> nums) {
        List<int[]> list = new ArrayList<>();
        for (int i = 0; i < nums.size(); i++) {
            for (Integer num : nums.get(i)) {
                list.add(new int[]{num, i});
            }
        }
        int[] res = new int[]{0, 1000000};
        list.sort((a, b) -> a[0] - b[0]);
        int n = list.size(), k = nums.size();
        int l = 0;
        int[] typeMaxIndex = new int[k];
        Set<Integer> set = new HashSet<>();
        for (int r = 0; r < n; r++) {
            int[] e = list.get(r);
            int right = e[0];
            int type = e[1];
            typeMaxIndex[type] = r;
            set.add(type);
            while (set.size() == k) {
                int[] left = list.get(l);
                int lType = left[1];
                if (typeMaxIndex[lType] == l++) {
                    set.remove(lType);
                }
            }
            if (l >= 1 && !set.contains(list.get(l - 1)[1]) && set.size() == k - 1) {
                int left = list.get(l - 1)[0];
                if (right - left < res[1] - res[0]) {
                    res[0] = left;
                    res[1] = right;
                }
            }
        }
        return res;
    }
}

性能

3244.新增道路查询后的最短距离II

目标

给你一个整数 n 和一个二维整数数组 queries。

有 n 个城市,编号从 0 到 n - 1。初始时,每个城市 i 都有一条单向道路通往城市 i + 1( 0 <= i < n - 1)。

queries[i] = [ui, vi] 表示新建一条从城市 ui 到城市 vi 的单向道路。每次查询后,你需要找到从城市 0 到城市 n - 1 的最短路径的长度。

所有查询中不会存在两个查询都满足 queries[i][0] < queries[j][0] < queries[i][1] < queries[j][1]

返回一个数组 answer,对于范围 [0, queries.length - 1] 中的每个 i,answer[i] 是处理完前 i + 1 个查询后,从城市 0 到城市 n - 1 的最短路径的长度。

示例 1:

输入: n = 5, queries = [[2, 4], [0, 2], [0, 4]]
输出: [3, 2, 1]
解释:
新增一条从 2 到 4 的道路后,从 0 到 4 的最短路径长度为 3。
新增一条从 0 到 2 的道路后,从 0 到 4 的最短路径长度为 2。
新增一条从 0 到 4 的道路后,从 0 到 4 的最短路径长度为 1。

示例 2:

输入: n = 4, queries = [[0, 3], [0, 2]]
输出: [1, 1]
解释:
新增一条从 0 到 3 的道路后,从 0 到 3 的最短路径长度为 1。
新增一条从 0 到 2 的道路后,从 0 到 3 的最短路径长度仍为 1。

说明:

  • 3 <= n <= 10^5
  • 1 <= queries.length <= 10^5
  • queries[i].length == 2
  • 0 <= queries[i][0] < queries[i][1] < n
  • 1 < queries[i][1] - queries[i][0]
  • 查询中不存在重复的道路。
  • 不存在两个查询都满足 i != j 且 queries[i][0] < queries[j][0] < queries[i][1] < queries[j][1]

思路

n 个城市,刚开始每一个城市 i 有一条单项道路通向城市 i + 1,有一个二维数组 queriesqueries[i] 表示增加一条从 queries[i][0]queries[i][1] 的单项道路,返回 answer 数组,answer[i] 表示增加了 queries[i] 之后从城市 0 到城市 n - 1 的最短路径。增加的路径保证不会出现相交的情况。

比昨天的题 3243.新增道路查询后的最短距离I 多了一个条件,新增的路径不会交叉。昨天首先考虑的就是今天这个思路,因为起点与终点是固定的,可以通过查询区间的关系来减小最短路径。当时考虑的差分数组解决不了区间包含的关系。

定义区间数组 intervalinterval[i] = j, 表示 i -> j 有直达道路。如果发现查询的路径 [l, r] 已经被包含,直接略过。否则,循环记录区间 (l, r) 内的路径数,保存下一跳的城市 next = interval[l],将 interval[l] 置为 r,l = next 直到 l >= r。注意最后一跳到 r 是没有计数的,相当于减去了将前面多余的步数,与直达的效果一样。 interval[l] = r 是第一次进入循环必须进行的操作,幸运的是它并不影响我们标记其它区间内的元素,当有更大的查询路径时,直接在 l 处就跳过了。

核心点是如何表示区间,如何判断区间是否重合,如何针对大区间减少的路径计数。

代码


/**
 * @date 2024-11-20 10:10
 */
public class ShortestDistanceAfterQueries3244 {

    public int[] shortestDistanceAfterQueries(int n, int[][] queries) {
        int ql = queries.length;
        int[] res = new int[ql];
        int[] interval = new int[n - 1];
        Arrays.setAll(interval, i -> i + 1);
        int shortestPath = n - 1;
        for (int i = 0; i < ql; i++) {
            int l = queries[i][0];
            int r = queries[i][1];
            while (interval[l] < r) {
                int next = interval[l];
                interval[l] = r;
                l = next;
                shortestPath--;
            }
            res[i] = shortestPath;
        }
        return res;
    }

}

性能

3261.统计满足K约束的子字符串数量II

目标

给你一个 二进制 字符串 s 和一个整数 k。

另给你一个二维整数数组 queries ,其中 queries[i] = [li, ri] 。

如果一个 二进制字符串 满足以下任一条件,则认为该字符串满足 k 约束:

  • 字符串中 0 的数量最多为 k。
  • 字符串中 1 的数量最多为 k。

返回一个整数数组 answer ,其中 answer[i] 表示 s[li..ri] 中满足 k 约束 的 子字符串 的数量。

示例 1:

输入:s = "0001111", k = 2, queries = [[0,6]]
输出:[26]
解释:
对于查询 [0, 6], s[0..6] = "0001111" 的所有子字符串中,除 s[0..5] = "000111" 和 s[0..6] = "0001111" 外,其余子字符串都满足 k 约束。

示例 2:

输入:s = "010101", k = 1, queries = [[0,5],[1,4],[2,3]]
输出:[15,9,3]
解释:
s 的所有子字符串中,长度大于 3 的子字符串都不满足 k 约束。

说明:

  • 1 <= s.length <= 10^5
  • s[i] 是 '0' 或 '1'
  • 1 <= k <= s.length
  • 1 <= queries.length <= 10^5
  • queries[i] == [li, ri]
  • 0 <= li <= ri < s.length
  • 所有查询互不相同

思路

定义二进制字符串满足 k 约束的条件是字符串中 0 的个数 或者 1 的个数都不超过 k。求给定字符串满足 k 约束的子字符串数量。子字符串 是字符串中 连续非空 字符序列。

这道题与昨天的 3258.统计满足K约束的子字符串数量I 相比多了一个查询数组,并且字符串的长度也来到了 10^5,返回结果是 long[],暴力枚举会超时。

滑动窗口的时间复杂度为 O(n),不可能对每一次查询都重新滑动一遍。显然需要在滑动的过程中记录下查询的结果。每次滑动我们可以得到一个区间 [l, r],这个区间的所有子数组是合法的。

使用一维数组记录这个区间,使用下标与值的映射,我们有两种选择:

  • 记录的左端点的最大右端点,即 left[l] = r
  • 记录的右端点的最小左端点,即 right[r] = l

对于查询区间 [ql, qr],它与我们已知的合法区间存在两种关系,被包含或者部分相交。

  • 如果是被包含的关系,那么查询区间的所有子数组均合法,子数组个数为 (qr - ql + 1) * (qr - ql + 2) / 2
  • 如果是相交的关系,说明 ql < r[ql, r] 的所有子数组是合法的,剩下的 [r + 1, qr] 的合法子数组如何求?可以在滑动过程中记录以每个元素为终点的合法子数组个数,以前缀和的形式保存。

这里区间的划分与结果集的构成是非常有讲究的。前缀和记录的是以元素为 终点 的合法子数组,如果我们在滑动的过程中根据查询区间终点匹配当前元素,即 qr == r,那么可能的情况为 ql >= l 查询区间被完全包含。如果 ql < l 则查询区间与合法区间相交。如果这时使用前缀和计算 [ql, l],使用公式计算 [l, r] 就错了。因为后面区间使用公式计算的子数组并不包括以前面区间内的元素为起点的子数组,并且前缀和记录的子数组的起点可能在查询的左边界之外。而反过来前面使用公式计算,后面使用前缀和计算,被减去的那部分子数组个数中就包含了以 前面区间所有元素 为终点的子数组,也就是前面使用公式计算的子数组个数。我们不用担心后面通过前缀和计算的子数组的起点超出左边界,如果超出的话,一定是被包含的关系。

核心点在于想清楚这两部分集合的关系, [i, j] 的所有子数组包括了以 b ∈ [i, j] 为终点,a ∈ [i, b] 为起点的子数组。而使用前缀和相减计算的是所有以 c ∈ [m, n] 为终点的合法子数组,起点可以不在该区间,但是不会超出 ql

代码


/**
 * @date 2024-11-13 0:25
 */
public class CountKConstraintSubstrings3261 {

    public long[] countKConstraintSubstrings_v1(String s, int k, int[][] queries) {
        int n = s.length();
        char[] chars = s.toCharArray();
        int[] cnt = new int[2];
        long[] res = new long[queries.length];
        long[] prefix = new long[n + 1];
        int[] right = new int[n];
        Arrays.fill(right, n);
        int l = 0;
        for (int i = 0; i < n; i++) {
            cnt[chars[i] - '0']++;
            while (l <= i && cnt[0] > k && cnt[1] > k) {
                right[l] = i;
                cnt[chars[l++] - '0']--;
            }
            prefix[i + 1] += prefix[i] + i - l + 1;
        }
        for (int i = 0; i < queries.length; i++) {
            int ql = queries[i][0];
            int qr = queries[i][1];
            int r = Math.min(right[ql], qr + 1);
            res[i] = (r - ql + 1L) * (r - ql) / 2 + prefix[qr + 1] - prefix[r];

        }
        return res;
    }

}

性能

1547.切棍子的最小成本

目标

有一根长度为 n 个单位的木棍,棍上从 0 到 n 标记了若干位置。例如,长度为 6 的棍子可以标记如下:

给你一个整数数组 cuts ,其中 cuts[i] 表示你需要将棍子切开的位置。

你可以按顺序完成切割,也可以根据需要更改切割的顺序。

每次切割的成本都是当前要切割的棍子的长度,切棍子的总成本是历次切割成本的总和。对棍子进行切割将会把一根木棍分成两根较小的木棍(这两根木棍的长度和就是切割前木棍的长度)。请参阅第一个示例以获得更直观的解释。

返回切棍子的 最小总成本 。

示例 1:

输入:n = 7, cuts = [1,3,4,5]
输出:16
解释:按 [1, 3, 4, 5] 的顺序切割的情况如下所示:
第一次切割长度为 7 的棍子,成本为 7 。第二次切割长度为 6 的棍子(即第一次切割得到的第二根棍子),第三次切割为长度 4 的棍子,最后切割长度为 3 的棍子。总成本为 7 + 6 + 4 + 3 = 20 。
而将切割顺序重新排列为 [3, 5, 1, 4] 后,总成本 = 16(如示例图中 7 + 4 + 3 + 2 = 16)。

示例 2:

输入:n = 9, cuts = [5,6,1,4,2]
输出:22
解释:如果按给定的顺序切割,则总成本为 25 。总成本 <= 25 的切割顺序很多,例如,[4, 6, 5, 2, 1] 的总成本 = 22,是所有可能方案中成本最小的。

说明:

  • 2 <= n <= 10^6
  • 1 <= cuts.length <= min(n - 1, 100)
  • 1 <= cuts[i] <= n - 1
  • cuts 数组中的所有整数都 互不相同

提示:

  • Build a dp array where dp[i][j] is the minimum cost to achieve all the cuts between i and j.
  • When you try to get the minimum cost between i and j, try all possible cuts k between them, dp[i][j] = min(dp[i][k] + dp[k][j]) + (j - i) for all possible cuts k between them.

思路

有一个长度为 n 的木棍,刻度从 0 ~ n,有一个整数数组 cutscuts[i] 表示需要在刻度 i 处进行切割,切割的成本为该刻度所在棍子的长度,求切割棍子的最小成本。

许多算法书上引入动态规划经常举的一个例子是钢条切割问题。已知特定长度钢条的价值,问怎样切可以使价值最大。而本题是给出必须要切的点,问按照什么顺序切成本最小。

定义 dp[i][j] 表示完成 (i, j) 之间所有切割点所需要的最小成本,dp[0][n] 就是答案。状态转移方程为 dp[i][j] = min(dp[i][k] + dp[k][j]) + j - i)。根据定义 dp 数组应该初始化为 0,因为无法切割时成本为 0。

但是由于切割点的范围太大 2 ~ 10^6,如果直接定义的话会超出内存限制。可以先将切割点排序,定义 ij 为切点的下标,切点个数 m 最大为 100,时间复杂度为 O(m^3)

考虑到切点本身不包含木棍的两个端点 0n,我们定义端点 endpoint 数组,将这两个端点加进来,dp[0][m - 1] 即为所求。状态转移方程为 dp[i][j] = min(dp[i][k] + dp[k][j]) + endpoint[j] - endpoint[i]

特别需要注意的是 dp 数组的遍历的顺序。当我们计算 dp[i][j] 时需要已经计算出 dp[k][j],枚举起点 i 应该倒序,因为 k > i,同理还需要计算出 dp[i][k],枚举终点 j 应该正序,因为 k > y。枚举 k 正序倒序都可以。

枚举 i,j 的先后顺序也是可以交换的。

代码


/**
 * @date 2024-11-11 10:07
 */
public class MinCost1547 {

    public int minCost(int n, int[] cuts) {
        Arrays.sort(cuts);
        int m = cuts.length;
        int[] endpoint = new int[m + 2];
        System.arraycopy(cuts, 0, endpoint, 1, m);
        endpoint[m + 1] = n;

        m = endpoint.length;
        int[][] dp = new int[m][m];

        for (int i = m - 3; i >= 0; i--) {
            for (int j = i + 2; j < m; j++) {
                int min = Integer.MAX_VALUE;
                for (int k = i + 1; k < j; k++) {
                    min = Math.min(dp[i][k] + dp[k][j], min);
                }
                dp[i][j] = min + endpoint[j] - endpoint[i];
            }
        }
        return dp[0][m - 1];
    }

}

性能

3235.判断矩形的两个角落是否可达

目标

给你两个正整数 xCorner 和 yCorner 和一个二维整数数组 circles ,其中 circles[i] = [xi, yi, ri] 表示一个圆心在 (xi, yi) 半径为 ri 的圆。

坐标平面内有一个左下角在原点,右上角在 (xCorner, yCorner) 的矩形。你需要判断是否存在一条从左下角到右上角的路径满足:路径 完全 在矩形内部,不会 触碰或者经过 任何 圆的内部和边界,同时 只 在起点和终点接触到矩形。

如果存在这样的路径,请你返回 true ,否则返回 false 。

示例 1:

输入:X = 3, Y = 4, circles = [[2,1,1]]
输出:true
解释:
黑色曲线表示一条从 (0, 0) 到 (3, 4) 的路径。

示例 2:

输入:X = 3, Y = 3, circles = [[1,1,2]]
输出:false
解释:
不存在从 (0, 0) 到 (3, 3) 的路径。

示例 3:

输入:X = 3, Y = 3, circles = [[2,1,1],[1,2,1]]
输出:false
解释:
不存在从 (0, 0) 到 (3, 3) 的路径。

示例 4:

输入:X = 4, Y = 4, circles = [[5,5,1]]
输出:true
解释:

说明:

  • 3 <= xCorner, yCorner <= 10^9
  • 1 <= circles.length <= 1000
  • circles[i].length == 3
  • 1 <= xi, yi, ri <= 10^9

思路

有一个以原点为左下顶点, [xCorner, yCorner] 为右上顶点的矩形,还有一些圆 circlescircles[i, j, r] 表示圆的圆心在 (i, j) 半径为 r。问是否存在一条从原点到 [xCorner, yCorner] 的路径,满足路径在矩形内部(不与矩形边界重合),且不触碰或经过任何园的内部与边界。

评论说这是史上分数最高的题目,周赛全球也没几个人做出来,直接放弃了。

代码

性能