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

代码

性能

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;
    }

}

性能

216.组合总和 III

目标

找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

示例 1:

输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。

示例 2:

输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]
解释:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
没有其他符合的组合了。

示例 3:

输入: k = 4, n = 1
输出: []
解释: 不存在有效的组合。
在[1,9]范围内使用4个不同的数字,我们可以得到的最小和是1+2+3+4 = 10,因为10 > 1,没有有效的组合。

说明:

  • 2 <= k <= 9
  • 1 <= n <= 60

思路

从1~9中选k个,使它们的和是n。暴力求解需要 C9k 次遍历,可以使用回溯算法成批地考察具有特定前缀的所有候选解,一旦发现与目标解不合,需要撤销当前选择返回上一层进行下一个可能的尝试。dfs只是回溯算法的一环。关于回溯算法具体可参考《数据结构与算法分析》第314页。

代码

/**
 * @date 2024-04-21 20:44
 */
public class CombinationSum216 {

    public List<List<Integer>> combinationSum3(int k, int n) {
        List<List<Integer>> res = new ArrayList<>();
        Deque<Integer> q = new ArrayDeque<>();
        for (int i = 1; i <= 9; i++) {
            dfs(k - 1, i, n, q, res);
        }
        return res;
    }

    public void dfs(int k, int root, int target, Deque<Integer> q, List<List<Integer>> res) {
        if (k < 0 || target < 0) {
            return;
        }
        if (target == root && k == 0) {
            q.offer(root);
            res.add(new ArrayList<>(q));
            q.pollLast();
            return;
        }
        q.offer(root);
        for (int i = root + 1; i <= 9; i++) {
            dfs(k - 1, i, target - root, q, res);
        }
        q.pollLast();
    }
}

性能

39.组合总和

目标

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

示例 1:

输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。

示例 2:

输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:

输入: candidates = [2], target = 1
输出: []

提示:

  • 1 <= candidates.length <= 30
  • 2 <= candidates[i] <= 40
  • candidates 的所有元素 互不相同
  • 1 <= target <= 40

思路

一看到这道题就想到要用动态规划,但是昨天看了回溯算法的视频,所以就试图使用dfs去写。

先从target开始,循环减去可选数字,然后递归。想法是好的,但是这种集合嵌套集合的操作一会就把我搞晕了,向下传递什么,返回什么?有机会再想想吧。

还是用动态规划吧,难点在于去重。刚开始甚至写了hash函数,但是它不能处理2, 5(2 3)4(2 2), 3的情况,dp[2] + dp[5] 与 dp[4] + dp[3] 得到的组合是相同的 [2, 2, 3]

这让我想到了518.零钱兑换II,这两道题本质是一样的。那个只让返回组合数,这个需要返回具体的组合。

去重的精髓就在于不能提前初始化dp,只能在第一次访问到候选值的时候初始化。

代码

/**
 * @date 2024-04-20 10:20
 */
public class CombinationSum39 {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>>[] dp = new List[target + 1];
        for (int i = 0; i <= target; i++) {
            dp[i] = new ArrayList<>();
        }
        for (int candidate : candidates) {
            if (candidate <= target) {
                List<Integer> list = new ArrayList<>();
                list.add(candidate);
                dp[candidate].add(list);
            }
            for (int i = candidate; i <= target; i++) {
                for (List<Integer> lj : dp[i - candidate]) {
                    List<Integer> tmp = new ArrayList<>();
                    tmp.add(candidate);
                    tmp.addAll(lj);
                    dp[i].add(tmp);
                }
            }
        }
        return dp[target];
    }
}

性能