Bootstrap

每日算法一练:剑指offer——图

1. 字母迷宫

        字母迷宫游戏初始界面记作 m x n 二维字符串数组 grid,请判断玩家是否能在 grid 中找到目标单词 target
        注意:寻找单词时 必须 按照字母顺序,通过水平或垂直方向相邻的单元格内的字母构成,同时,同一个单元格内的字母 不允许被重复使用 

示例 1:

输入:grid = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], target = "ABCCED"
输出:true

示例 2:

输入:grid = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], target = "SEE"
输出:true

示例 3:

输入:grid = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], target = "ABCB"
输出:false

提示:

m == grid.length
n = grid[i].length
1 <= m, n <= 6
1 <= target.length <= 15
grid 和 target 仅由大小写英文字母组成

解题思路解析

核心思想

        这是一道典型的回溯问题,使用深度优先搜索(DFS)+ 剪枝可以高效解决。解题流程主要分为以下几个步骤:

  1. 深度优先搜索 (DFS)

    • 将问题转化为矩阵中每个单元格作为起点,从四个方向递归搜索是否可以匹配目标字符串 target
    • 递归过程模拟“暴力法”搜索,沿路径尝试匹配字符串的每个字符。
  2. 剪枝

    • 遇到显然无法完成匹配的路径时提前终止递归(如字符不匹配或超出矩阵范围)。
    • 剪枝操作避免了无效的重复计算,提高了效率。

具体步骤

  1. 遍历矩阵 grid 的每个单元格,尝试作为匹配的起点。
  2. 对每个起点使用深度优先搜索:
    • 若当前单元格匹配目标字符串的某字符,递归检查四个方向是否能够继续匹配。
    • 在递归时使用标记法避免重复访问当前路径中的元素。
    • 若匹配到 target 的最后一个字符,则返回成功。
  3. 若所有起点均无法匹配目标字符串,则返回 false

DFS 的参数与递推关系

  • 递归参数
    • i, j:当前搜索到的矩阵元素的行列索引。
    • k:目标字符串 target 当前匹配到的字符索引。
  • 终止条件
    • 失败:当前索引越界,当前元素与目标字符不匹配,或当前元素已被访问过。
    • 成功:已匹配到 target 的最后一个字符。
  • 递推
    • 标记当前元素为已访问(临时修改其值)。
    • 递归搜索上下左右四个方向。
    • 恢复当前元素的初始值。

代码详解(Java)

class Solution {
    public boolean wordPuzzle(char[][] grid, String target) {
        // 将目标字符串转化为字符数组
        char[] words = target.toCharArray();
        // 遍历每个矩阵单元格作为起点
        for (int i = 0; i < grid.length; i++) {
            for (int j = 0; j < grid[0].length; j++) {
                // 从当前单元格启动 DFS,若找到路径,返回 true
                if (dfs(grid, words, i, j, 0)) return true;
            }
        }
        return false; // 遍历所有起点后仍未找到路径
    }

    boolean dfs(char[][] grid, char[] target, int i, int j, int k) {
        // 越界检查或当前元素不匹配,返回 false
        if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] != target[k]) {
            return false;
        }
        // 如果匹配到目标字符串的最后一个字符,返回 true
        if (k == target.length - 1) return true;

        // 临时标记当前单元格为已访问
        char temp = grid[i][j];
        grid[i][j] = '\0';

        // 递归检查四个方向是否能匹配后续字符
        boolean res = dfs(grid, target, i + 1, j, k + 1) || // 下
                      dfs(grid, target, i - 1, j, k + 1) || // 上
                      dfs(grid, target, i, j + 1, k + 1) || // 右
                      dfs(grid, target, i, j - 1, k + 1);   // 左

        // 恢复当前单元格的值
        grid[i][j] = temp;

        return res; // 返回结果
    }
}

复杂度分析

  1. 时间复杂度

    • 最差情况下,矩阵中每个单元格都作为起点,且每个字符递归搜索 3 个方向,复杂度为 O(3K⋅MN)O(3^K \cdot MN)。
    • KK 为目标字符串 target 的长度,M,NM, N 为矩阵的行列大小。
  2. 空间复杂度

    • 递归深度最大为字符串长度 KK,因此空间复杂度为 O(K)O(K)。

总结

  • 本算法利用了回溯思想,通过递归模拟暴力搜索并结合剪枝优化,适用于类似路径搜索的问题。
  • 使用矩阵单元格的临时修改(标记法)避免了额外的访问记录数据结构,简化了代码逻辑并提升了效率。

2. 衣厨整理

        家居整理师将待整理衣橱划分为 m x n 的二维矩阵 grid,其中 grid[i][j] 代表一个需要整理的格子。整理师自 grid[0][0] 开始 逐行逐列 地整理每个格子。

        整理规则为:在整理过程中,可以选择 向右移动一格 或 向下移动一格,但不能移动到衣柜之外。同时,不需要整理 digit(i) + digit(j) > cnt 的格子,其中 digit(x) 表示数字 x 的各数位之和。

请返回整理师 总共需要整理多少个格子

示例 1:

输入:m = 4, n = 7, cnt = 5
输出:18

提示:

  • 1 <= n, m <= 100
  • 0 <= cnt <= 20

前置工作:数位和计算

  1. 利用 x % 10 提取个位数,x / 10 实现高位右移,可以高效地求出任意数字的数位和。
  2. 对于连续数字,可以采用增量计算避免重复求和:
    • (x+1) % 10 == 0(进位):数位和减少 8。
    • 否则:数位和加 1。
  3. 数位和增量的公式使得在大规模矩阵中快速更新成为可能。

DFS 方法

算法思路

  • 递归搜索:从起点 (0,0) 出发,优先向右和向下探索,沿路径记录所有可达解。
  • 剪枝优化
    • 若当前坐标越界或数位和超出限制,立即返回。
    • 若当前格子已访问过,也不再重复探索。
  • 回溯操作:每次递归返回当前路径的总可达解数。

优点

  • 实现简单,适合处理连通区域的深度探索。

代码补充

以下是对代码中逻辑的分步解释:

public int dfs(int i, int j, int si, int sj) {
    // 剪枝条件:越界、数位和超出限制、已访问
    if (i >= m || j >= n || cnt < si + sj || visited[i][j]) return 0;

    // 标记当前格子已访问
    visited[i][j] = true;

    // 递归计算当前格子及其右、下方向的总解
    return 1 
        + dfs(i + 1, j, (i + 1) % 10 != 0 ? si + 1 : si - 8, sj) 
        + dfs(i, j + 1, si, (j + 1) % 10 != 0 ? sj + 1 : sj - 8);
}

BFS 方法

算法思路

  • 队列辅助:采用队列保存当前所有待探索的节点,每次从队首取出当前节点进行处理,并将其相邻的未访问节点加入队列。
  • 终止条件:队列为空,表明所有可能的路径均已探索完毕。

优点

  • 自然支持“逐层平推”的搜索方式,更适合多层嵌套结构的遍历。

代码补充

以下是 BFS 的关键代码及逻辑说明:

Queue<int[]> queue = new LinkedList<>();
queue.add(new int[] { 0, 0, 0, 0 }); // 起点入队

while (!queue.isEmpty()) {
    int[] x = queue.poll();
    int i = x[0], j = x[1], si = x[2], sj = x[3];

    // 剪枝条件:越界、数位和超出限制、已访问
    if (i >= m || j >= n || cnt < si + sj || visited[i][j]) continue;

    // 标记当前格子已访问,更新解的数量
    visited[i][j] = true;
    res++;

    // 将右、下方向的格子加入队列
    queue.add(new int[] { i + 1, j, (i + 1) % 10 != 0 ? si + 1 : si - 8, sj });
    queue.add(new int[] { i, j + 1, si, (j + 1) % 10 != 0 ? sj + 1 : sj - 8 });
}

复杂度分析

  1. 时间复杂度:两种方法均为 O(M×N)),最坏情况下机器人遍历整个矩阵。
  2. 空间复杂度
    • DFS:递归栈的深度在最坏情况下为 O(M+N)O(M + N),辅助矩阵占用 O(M×N)。
    • BFS:队列大小与矩阵中未访问格子的数量成正比,最坏情况为 O(M×N)。

方法对比

方法优点缺点
DFS实现简单,适合递归思维的场景遇到大规模数据时递归栈溢出风险
BFS每层搜索有序,不易遗漏或重复访问实现稍复杂,占用额外队列空间

总结

这两种方法针对机器人运动范围问题提供了两种视角的解决方案:

  • 若矩阵规模较小或路径连通性较强,DFS 更加直观;
  • 若矩阵规模较大或需要层次化搜索,BFS 的性能和可靠性更高。

3.  螺旋遍历二维数组

        给定一个二维数组 array,请返回「螺旋遍历」该数组的结果。

        螺旋遍历:从左上角开始,按照 向右向下向左向上 的顺序 依次 提取元素,然后再进入内部一层重复相同的步骤,直到提取完所有元素。

示例 1:

输入:array = [[1,2,3],[8,9,4],[7,6,5]]
输出:[1,2,3,4,5,6,7,8,9]

示例 2:

输入:array  = [[1,2,3,4],[12,13,14,5],[11,16,15,6],[10,9,8,7]]
输出:[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]

解题思路

  1. 边界初始化
    定义矩阵的四个边界:

    • l (left):左边界,初始为 0。
    • r (right):右边界,初始为矩阵的列数减一。
    • t (top):上边界,初始为 0。
    • b (bottom):下边界,初始为矩阵的行数减一。
  2. 遍历方向 模拟顺时针遍历矩阵的过程,按照如下顺序循环:

    • 从左到右:固定行 t,从 l 遍历到 r
    • 从上到下:固定列 r,从 t+1 遍历到 b
    • 从右到左(如果还有剩余行):固定行 b,从 r-1 遍历到 l
    • 从下到上(如果还有剩余列):固定列 l,从 b-1 遍历到 t+1
  3. 边界收缩 每次打印后,更新相应的边界:

    • 左边界 l 向右收缩:l++
    • 右边界 r 向左收缩:r--
    • 上边界 t 向下收缩:t++
    • 下边界 b 向上收缩:b--
  4. 终止条件 当上下边界 (t > b) 或左右边界 (l > r) 交错时,停止打印。

Java 实现

以下是 Java 实现的逐步解析:

class Solution {
    public int[] spiralArray(int[][] array) {
        // 处理空矩阵的情况
        if (array.length == 0) return new int[0];

        // 初始化边界
        int l = 0, r = array[0].length - 1; // 左边界和右边界
        int t = 0, b = array.length - 1;    // 上边界和下边界
        int x = 0; // 结果数组的索引
        int[] res = new int[(r + 1) * (b + 1)]; // 初始化结果数组

        // 循环打印矩阵
        while (true) {
            // 从左到右
            for (int i = l; i <= r; i++) res[x++] = array[t][i];
            if (++t > b) break; // 收缩上边界,判断是否越界

            // 从上到下
            for (int i = t; i <= b; i++) res[x++] = array[i][r];
            if (l > --r) break; // 收缩右边界,判断是否越界

            // 从右到左
            for (int i = r; i >= l; i--) res[x++] = array[b][i];
            if (t > --b) break; // 收缩下边界,判断是否越界

            // 从下到上
            for (int i = b; i >= t; i--) res[x++] = array[i][l];
            if (++l > r) break; // 收缩左边界,判断是否越界
        }

        // 返回结果数组
        return res;
    }
}

复杂度分析

  1. 时间复杂度

    • 每个元素都被访问一次,总共有 M × N 个元素。
    • 时间复杂度为 O(M × N)
  2. 空间复杂度

    • 只使用了四个整型变量(l, r, t, b)来表示边界,额外空间复杂度为 O(1)

测试示例

示例 1

输入:

int[][] array = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};

输出:

[1, 2, 3, 6, 9, 8, 7, 4, 5]

示例 2

输入:

int[][] array = {{1, 2}, {3, 4}};

输出:

[1, 2, 4, 3]

小结

  1. 该解法利用了模拟法,通过控制边界逐步收缩来实现顺时针打印矩阵。
  2. 在写代码时,注意边界条件的判断(例如数组越界问题)。
  3. 本解法具有较高的时间和空间效率,适用于各种大小的矩阵。
;