2850.将石头分散到网格图的最少移动次数

目标

给你一个大小为 3 * 3 ,下标从 0 开始的二维整数矩阵 grid ,分别表示每一个格子里石头的数目。网格图中总共恰好有 9 个石头,一个格子里可能会有 多个 石头。

每一次操作中,你可以将一个石头从它当前所在格子移动到一个至少有一条公共边的相邻格子。

请你返回每个格子恰好有一个石头的 最少移动次数 。

示例 1:

输入:grid = [[1,1,0],[1,1,1],[1,2,1]]
输出:3
解释:让每个格子都有一个石头的一个操作序列为:
1 - 将一个石头从格子 (2,1) 移动到 (2,2) 。
2 - 将一个石头从格子 (2,2) 移动到 (1,2) 。
3 - 将一个石头从格子 (1,2) 移动到 (0,2) 。
总共需要 3 次操作让每个格子都有一个石头。
让每个格子都有一个石头的最少操作次数为 3 。

示例 2:

输入:grid = [[1,3,0],[1,0,0],[1,0,3]]
输出:4
解释:让每个格子都有一个石头的一个操作序列为:
1 - 将一个石头从格子 (0,1) 移动到 (0,2) 。
2 - 将一个石头从格子 (0,1) 移动到 (1,1) 。
3 - 将一个石头从格子 (2,2) 移动到 (1,2) 。
4 - 将一个石头从格子 (2,2) 移动到 (2,1) 。
总共需要 4 次操作让每个格子都有一个石头。
让每个格子都有一个石头的最少操作次数为 4 。

说明:

  • grid.length == grid[i].length == 3
  • 0 <= grid[i][j] <= 9
  • grid 中元素之和为 9 。

思路

有一个3 * 3 的二维矩阵,有9个石头散落在其中,每次可以将石头移到相邻的格子里,问每个格子一块石头最少需要移动几次。

有多余石头的格子到没有石头格子移动的次数为其曼哈顿距离要想使移动次数最小,我们只需要从没有石头的格子向四个方向查找有多余石头的格子即可

并非是沿四个方向搜索,而是BFS找最短路径。 遍历四个方向,那么只能沿着该方向查找,而BFS则是由内层向外层查找,体会二者的不同。但这题使用BFS也无法保证得到的是最小移动次数,考虑下面的情况:

从0开始取最近的并不能保证得到最优解,比如下面这种情况:

3,2,0      3,1,1      2,1,1      2,1,1      2,1,1      1,1,1
0,1,0  ->  0,1,0  ->  1,1,0  ->  1,1,1  ->  1,1,1  ->  1,1,1
0,3,0      0,3,0      0,3,0      0,2,0      1,1,0      1,1,1
       1          1          2           1          4
左下角的应该从第一个元素取:

3,2,0      3,1,1      2,1,1      2,1,1      1,1,1      1,1,1
0,1,0  ->  0,1,0  ->  1,1,0  ->  1,1,1  ->  1,1,1  ->  1,1,1
0,3,0      0,3,0      0,3,0      0,2,0      1,2,0      1,1,1
       1          1          2           2          1

尽管这题使用BFS求解不了,但还是有一些收获的。BFS很容易错写成每次从队列取一个元素,然后判断该元素是否满足条件,不满足就将其邻接节点加入队列。当需要进行层次计数的时候就不对了,应该在每次循环的第一步记录队列中元素个数 k,本次处理中就循环判断这k个元素,在循环过程中判断是否满足条件,不满足的将其邻接节点加入队列,因为我们已经在前面计数了,因此这些邻接节点将在下一次循环中处理。

如果取最近的多余石头这种贪心策略不行的话,那么问题就不在于最短路径了。而应从整体上考虑从哪里移动到哪里才是最优的,可以尝试记忆化搜索解空间。我们可以很容易枚举出哪些格子没有石头,哪些格子石头多于1个,只需枚举它们的组合并取其曼哈顿距离之和最小值即可。

这里的核心问题是如何遍历这两个列表的组合,我想到的方法就是使用回溯算法,每向下递归一层就标记为已访问,而返回时再取消其标记。并且如果不保存重复子问题的话,执行会超时。这里的重复子问题是两组数据未访问元素相同,而已访问数据的组合不同。例如: [a,b,c,d,e,f,g] [h,i,j,k,l,m,n] 前面两个元素组合 (a, h) (b, i)(a, i) (b, h) 剩余的元素的组合情况完全相同。

最终使用状态压缩与回溯解出来了。如果不记录重复的子问题的话,dfs方法要调用3705927296次,而使用记忆化搜索只需调用12868次。

官网题解也是类似的思路,只不过遍历组合的方式不同,它是固定一个列表不变,另一个进行全排列。//todo 有空再研究一下官网题解吧

代码

/**
 * @date 2024-07-20 15:55
 */
public class MinimumMoves2850 {

    public int minimumMoves_v2(int[][] grid) {
        List<int[]> zeros = new ArrayList<>();
        List<int[]> more = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                if (grid[i][j] == 0) {
                    zeros.add(new int[]{i, j});
                } else if (grid[i][j] > 1) {
                    for (int k = 0; k < grid[i][j] - 1; k++) {
                        more.add(new int[]{i, j});
                    }
                }
            }
        }
        int k = zeros.size();
        int res = Integer.MAX_VALUE;
        int[][] mem = new int[255][255];

        for (int i = 0; i < k; i++) {
            // 状态压缩
            int zerosVisited = 0x000000ff;
            zerosVisited ^= 1 << i;
            int[] zero = zeros.get(i);
            for (int j = 0; j < k; j++) {
                int moreVisited = 0x000000ff;
                moreVisited ^= 1 << j;
                int[] m = more.get(j);
                int distance = Math.abs(zero[0] - m[0]) + Math.abs(zero[1] - m[1]);
                res = Math.min(res, distance + dfs_v2(zeros, more, zerosVisited, moreVisited, 1, mem));
            }
        }
        return res;
    }

    public int dfs_v2(List<int[]> zeros, List<int[]> more, int zerosVisited, int moreVisited, int level, int[][] mem) {
        if (level == zeros.size()) {
            return 0;
        }
        int k = zeros.size();
        int res = Integer.MAX_VALUE;
        for (int i = 0; i < k; i++) {
            if (((zerosVisited >> i) & 1) == 0) {
                continue;
            }
            zerosVisited ^= 1 << i;
            int[] zero = zeros.get(i);
            for (int j = 0; j < k; j++) {
                if (((moreVisited >> j) & 1) == 0) {
                    continue;
                }
                moreVisited ^= 1 << j;
                int[] m = more.get(j);
                int distance = Math.abs(zero[0] - m[0]) + Math.abs(zero[1] - m[1]);
                if (mem[zerosVisited][moreVisited] == 0) {
                    // 重复的子问题是两边剩余的元素均相同
                    mem[zerosVisited][moreVisited] = dfs_v2(zeros, more, zerosVisited, moreVisited, level + 1, mem);
                }
                res = Math.min(res, distance + mem[zerosVisited][moreVisited]);
                // 回溯
                moreVisited ^= 1 << j;
            }
            zerosVisited ^= 1 << i;
        }
        return res;
    }

}

性能

494.目标和

目标

给你一个非负整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

输入:nums = [1], target = 1
输出:1

说明:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

思路

有一个数组,可以在数组元素前加上正负号来组成表达式,问表达式等于target的数目。

如果当前元素为正则累加,否则相减,递归直到所有元素都已列入表达式,如果累加结果等于target则返回1,否则返回0。

//todo 改为递推,或记忆化搜索

代码

/**
 * @date 2024-06-30 20:07
 */
public class FindTargetSumWays494 {
    public int findTargetSumWays(int[] nums, int target) {
        return dfs(nums, 1, nums[0], target) + dfs(nums, 1, -nums[0], target);
    }

    public int dfs(int[] nums, int i, int res, int target) {
        if (i == nums.length) {
            return res - target == 0 ? 1 : 0;
        }
        return dfs(nums, i + 1, res + nums[i], target) + dfs(nums, i + 1, res - nums[i], target);
    }

}

性能

2741.特别的排列

目标

给你一个下标从 0 开始的整数数组 nums ,它包含 n 个 互不相同 的正整数。如果 nums 的一个排列满足以下条件,我们称它是一个特别的排列:

  • 对于 0 <= i < n - 1 的下标 i ,要么 nums[i] % nums[i+1] == 0 ,要么 nums[i+1] % nums[i] == 0 。

请你返回特别排列的总数目,由于答案可能很大,请将它对 109 + 7 取余 后返回。

示例 1:

输入:nums = [2,3,6]
输出:2
解释:[3,6,2] 和 [2,6,3] 是 nums 两个特别的排列。

示例 2:

输入:nums = [1,4,3]
输出:2
解释:[3,1,4] 和 [4,1,3] 是 nums 两个特别的排列。

说明:

  • 2 <= nums.length <= 14
  • 1 <= nums[i] <= 10^9

思路

有一个互不相同的正整数数组,问使得相邻元素可以被整除(对于相邻元素a % b == 0 || b % a == 0)的排列有多少种。

排列数的计算需要使用dfs,但如果不保存重复子问题的话会超时。

难点在于是否将保存的结果计入,例如 [2,6,3],虽然dfs 2 -> 6 -> 36 -> 2 -> 3有重复的子问题3,但是后者不符合题目条件。

// todo

代码

性能

139.单词拆分

目标

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true。

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet" 和 "code" 拼接成。

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。注意,你可以重复使用字典中的单词。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

说明:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • s 和 wordDict[i] 仅由小写英文字母组成
  • wordDict 中的所有字符串 互不相同

思路

已知一个字符串列表 wordDict 和一个字符串 s,问能否用列表中的元素拼成该字符串,列表中的元素可以重复使用。

很明显需要使用动态规划来求解,假设当前列表元素 word 的长度为 l,子字符串 sub 的长度为 i,如果 sub.substring(0, i-l) 能由字典中的词拼成并且 word.equals(sub.substring(i-l, l)) 那么 sub 也能由字典中的词拼成。

代码

/**
 * @date 2024-06-23 19:58
 */
public class WordBreak139 {
    public boolean wordBreak(String s, List<String> wordDict) {
        int n = s.length();
        boolean[] dp = new boolean[n + 1];
        dp[0] = true;
        for (int i = 1; i <= n; i++) {
            for (String word : wordDict) {
                int length = word.length();
                if (length <= i && dp[i - length] && word.equals(s.substring(i - length, i))) {
                    dp[i] = true;
                }
            }
        }
        return dp[n];
    }

    public boolean wordBreak_v1(String s, List<String> wordDict) {
        int n = s.length();
        char[] mem = new char[n + 1];
        Arrays.fill(mem, '2');
        return dfs(s, 0, wordDict, mem) == '1';
    }

    public char dfs(String s, int i, List<String> wordDict, char[] mem) {
        int n = s.length();
        if (i == n) {
            return '1';
        }
        if (mem[i] != '2') {
            return mem[i];
        }
        for (String word : wordDict) {
            if (s.startsWith(word, i) && '1' == dfs(s, i + word.length(), wordDict, mem)) {
                return mem[i] = '1';
            }
        }
        return mem[i] = '0';
    }
}

性能

最快的解法是使用记忆化搜索,可以剪枝缩小搜索范围。

2713.矩阵中严格递增的单元格数

目标

给你一个下标从 1 开始、大小为 m x n 的整数矩阵 mat,你可以选择任一单元格作为 起始单元格 。

从起始单元格出发,你可以移动到 同一行或同一列 中的任何其他单元格,但前提是目标单元格的值 严格大于 当前单元格的值。

你可以多次重复这一过程,从一个单元格移动到另一个单元格,直到无法再进行任何移动。

请你找出从某个单元开始访问矩阵所能访问的 单元格的最大数量 。

返回一个表示可访问单元格最大数量的整数。

示例 1:

输入:mat = [[3,1],[3,4]]
输出:2
解释:上图展示了从第 1 行、第 2 列的单元格开始,可以访问 2 个单元格。可以证明,无论从哪个单元格开始,最多只能访问 2 个单元格,因此答案是 2 。

示例 2:

输入:mat = [[1,1],[1,1]]
输出:1
解释:由于目标单元格必须严格大于当前单元格,在本示例中只能访问 1 个单元格。 

示例 3:

输入:mat = [[3,1,6],[-9,5,7]]
输出:4
解释:上图展示了从第 2 行、第 1 列的单元格开始,可以访问 4 个单元格。可以证明,无论从哪个单元格开始,最多只能访问 4 个单元格,因此答案是 4 。  

说明:

  • m == mat.length
  • n == mat[i].length
  • 1 <= m, n <= 10^5
  • 1 <= m * n <= 10^5
  • -10^5 <= mat[i][j] <= 10^5

思路

有一个二维矩阵,我们可以从任意元素出发到达同行或同列的任意严格大于该元素值的位置,问我们最多能访问到多少单元格。

最直接的想法就是建立一个有向无环图,然后求最大路径长度。但是建图的过程需要循环mn(m+n)次,针对每个元素判断其同行同列上严格大于的元素。显然会超时。

于是考虑使用记忆化搜索,结果测试用例 558/566 超时,这个二维数组只有一行,有 100000列,从 1~100000,我在本地测试的时候报栈溢出。

我想要将其转为迭代的形式,但是时间紧迫,简单起见对一行或一列的情况做了特殊处理,排序后去重,最后勉强通过了。

官网题解使用的是动态规划,有时间详细看一下。//todo

代码

/**
 * @date 2024-06-19 16:28
 */
public class MaxIncreasingCells2713 {

    public int maxIncreasingCells(int[][] mat) {
        int res = 0;
        int m = mat.length;
        int n = mat[0].length;
        if (m == 1) {
            res = n;
            Arrays.sort(mat[0]);
            for (int i = 1; i < n; i++) {
                if (mat[0][i] == mat[0][i - 1]) {
                    res--;
                }
            }
            return res;
        } else if (n == 1) {
            res = m;
            Arrays.sort(mat, (a, b) -> a[0] - b[0]);
            for (int i = 1; i < m; i++) {
                if (mat[i][0] == mat[i - 1][0]) {
                    res--;
                }
            }
            return res;
        }

        int l = m * n;
        // 将二维坐标映射到一维,dp记录的是从该点为起点的能移动的最大次数
        int[] dp = new int[l];
        Arrays.fill(dp, -1);
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                res = Math.max(res, move(mat, mat[i][j], i * n + j, i, j, dp));
            }
        }
        return res;
    }

    public int move(int[][] mat, int curVal, int next, int i, int j, int[] dp) {
        int m = mat.length;
        int n = mat[0].length;
        if (dp[next] > -1) {
            return dp[next];
        } else if (dp[next] == -2) {
            return 1;
        }
        boolean noNext = true;
        for (int k = 0; k < n; k++) {
            if (mat[i][k] > curVal) {
                noNext = false;
                dp[next] = Math.max(dp[next], move(mat, mat[i][k], i * n + k, i, k, dp) + 1);
            }
        }
        for (int k = 0; k < m; k++) {
            if (mat[k][j] > curVal) {
                noNext = false;
                dp[next] = Math.max(dp[next], move(mat, mat[k][j], k * n + j, k, j, dp) + 1);
            }
        }
        if (noNext) {
            dp[next] = -2;
            return 1;
        }

        return dp[next];
    }

}

性能

2786.访问数组中的位置使分数最大

目标

给你一个下标从 0 开始的整数数组 nums 和一个正整数 x 。

你 一开始 在数组的位置 0 处,你可以按照下述规则访问数组中的其他位置:

  • 如果你当前在位置 i ,那么你可以移动到满足 i < j 的 任意 位置 j 。
  • 对于你访问的位置 i ,你可以获得分数 nums[i] 。
  • 如果你从位置 i 移动到位置 j 且 nums[i] 和 nums[j] 的 奇偶性 不同,那么你将失去分数 x 。

请你返回你能得到的 最大 得分之和。

注意 ,你一开始的分数为 nums[0] 。

示例 1:

输入:nums = [2,3,6,1,9,2], x = 5
输出:13
解释:我们可以按顺序访问数组中的位置:0 -> 2 -> 3 -> 4 。
对应位置的值为 2 ,6 ,1 和 9 。因为 6 和 1 的奇偶性不同,所以下标从 2 -> 3 让你失去 x = 5 分。
总得分为:2 + 6 + 1 + 9 - 5 = 13 。

示例 2:

输入:nums = [2,4,6,8], x = 3
输出:20
解释:数组中的所有元素奇偶性都一样,所以我们可以将每个元素都访问一次,而且不会失去任何分数。
总得分为:2 + 4 + 6 + 8 = 20 。

说明:

  • 2 <= nums.length <= 10^5
  • 1 <= nums[i], x <= 10^6

思路

给定一个数组 nums 与 正整数 x,从下标 0 开始,允许从任意位置 i 开始向后访问位置 j,如果nums[i]nums[j] 的奇偶性相同,则可以获得 nums[j] 分,否则获得 nums[j] - x 分。求能够获得的分数总和的最大值。

刚开始就想到要从后向前,自底向上动态规划,如果当前的奇偶性与与后面的奇偶性相同就累加,否则就将后面的值减去x。接着又想到并不是要每一个节点都要访问,如果节点没有访问奇偶性和谁比较呢?并且后面的得分取决于前一个元素的奇偶性,考虑到昨天的题 子序列最大优雅度,觉得可能方向又错了。

于是就尝试贪心算法,从下标0开始,执行while循环,如果后面的元素奇偶性与之相同,直接累加。对于奇偶性不同的,我们可以考虑累加或者跳过。这样问题就变成了从这个新位置开始向后能获取的最大分数。注意新的位置奇偶性发生了变化。

这么一想问题又变成记忆化搜索了,于是就可以转换为递推/动态规划问题。

// todo 转换为动态规划的写法

代码

/**
 * @date 2024-06-14 8:43
 */
public class MaxScore2786 {

    public long maxScore(int[] nums, int x) {
        int n = nums.length;
        long[][] mem = new long[n + 1][2];
        for (int i = 0; i < mem.length; i++) {
            mem[i] = new long[]{Integer.MIN_VALUE, Integer.MIN_VALUE};
        }
        long res = nums[0];
        int flag = nums[0] % 2;
        int i = 1;
        while (i < n && nums[i] % 2 == flag) {
            res += nums[i];
            i++;
        }
        res += Math.max(0, maxScore(nums, x, i, flag, mem));
        return res;
    }

    public long maxScore(int[] nums, int x, int start, int preFlag, long[][] mem) {
        int n = nums.length;
        if (start >= n) {
            return 0;
        }
        // 如果选择该节点
        int flag = nums[start] % 2;
        long select = nums[start];
        if (preFlag != flag) {
            select -= x;
        }
        int i = start + 1;
        while (i < n && nums[i] % 2 == flag) {
            select += nums[i];
            i++;
        }
        if (mem[i][flag] == Integer.MIN_VALUE) {
            mem[i][flag] = maxScore(nums, x, i, flag, mem);
        }
        select += Math.max(0, mem[i][flag]);
        // 如果跳过该节点
        if (mem[start + 1][preFlag] == Integer.MIN_VALUE) {
            mem[start + 1][preFlag] = maxScore(nums, x, start + 1, preFlag, mem);
        }
        return Math.max(select, mem[start + 1][preFlag]);
    }

}

性能

3040.相同分数的最大操作数目II

目标

给你一个整数数组 nums ,如果 nums 至少 包含 2 个元素,你可以执行以下操作中的 任意 一个:

  • 选择 nums 中最前面两个元素并且删除它们。
  • 选择 nums 中最后两个元素并且删除它们。
  • 选择 nums 中第一个和最后一个元素并且删除它们。

一次操作的 分数 是被删除元素的和。

在确保 所有操作分数相同 的前提下,请你求出 最多 能进行多少次操作。

请你返回按照上述要求 最多 可以进行的操作次数。

示例 1:

输入:nums = [3,2,1,2,3,4]
输出:3
解释:我们执行以下操作:
- 删除前两个元素,分数为 3 + 2 = 5 ,nums = [1,2,3,4] 。
- 删除第一个元素和最后一个元素,分数为 1 + 4 = 5 ,nums = [2,3] 。
- 删除第一个元素和最后一个元素,分数为 2 + 3 = 5 ,nums = [] 。
由于 nums 为空,我们无法继续进行任何操作。

示例 2:

输入:nums = [3,2,6,1,4]
输出:2
解释:我们执行以下操作:
- 删除前两个元素,分数为 3 + 2 = 5 ,nums = [6,1,4] 。
- 删除最后两个元素,分数为 1 + 4 = 5 ,nums = [6] 。
至多进行 2 次操作。

说明:

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

思路

相同分数的最大操作数目I 增加了两种操作,可以删除最后两个元素或者一前一后两个元素。

我的思路是使用回溯算法,为了防止环的形成,使用自定义hash函数 (long) start << 16 | end; 记录已经搜索过的区间,并存入哈希表。

勉强通过了,看了官网题解,说是要用记忆搜索。网友还给出了递推的解法。//todo

代码

/**
 * @date 2024-06-08 20:03
 */
public class MaxOperations3040 {
    private Set<Long> set;

    public int maxOperations(int[] nums) {
        int res = 0;
        int n = nums.length;
        set = new HashSet<>();
        set.add(n - 1L);
        res = dfs(nums, 2, n - 1, nums[0] + nums[1], 1);
        res = Math.max(res, dfs(nums, 0, n - 3, nums[n - 2] + nums[n - 1], 1));
        res = Math.max(res, dfs(nums, 1, n - 2, nums[0] + nums[n - 1], 1));
        return res;
    }

    public int dfs(int[] nums, int start, int end, int target, int ops) {
        int res = ops;
        long key = (long) start << 16 | end;
        if (set.contains(key) || start >= end || res == nums.length / 2) {
            return res;
        }
        set.add(key);
        if (start < nums.length - 1 && nums[start] + nums[start + 1] == target) {
            res = dfs(nums, start + 2, end, target, ops + 1);
        }
        if (end >= 1 && nums[end] + nums[end - 1] == target) {
            res = Math.max(res, dfs(nums, start, end - 2, target, ops + 1));
        }
        if (end >= 0 && start < nums.length && nums[start] + nums[end] == target) {
            res = Math.max(res, dfs(nums, start + 1, end - 1, target, ops + 1));
        }
        return res;
    }

}

性能