Bootstrap

每日算法一练:剑指offer——动态规划(2)

1. 解密数字

现有一串神秘的密文 ciphertext,经调查,密文的特点和规则如下:

密文由非负整数组成
数字 0-25 分别对应字母 a-z
请根据上述规则将密文 ciphertext 解密为字母,并返回共有多少种解密结果。

示例 1:

输入: ciphertext = 216612
输出: 6
解释: 216612 解密后有 6 种不同的形式,分别是 "cbggbc","vggbc","vggm","cbggm","cqgbc" 和 "cqgm" 

提示:

0 <= ciphertext < 231

        这个问题可以通过动态规划来解决。我们需要找出一种方法,将一个给定的数字 ciphertext 转化为对应的翻译方式,具体翻译规则是:

  1. 每个数字(1-9)可以被单独翻译。
  2. 由两个数字组成的两位数(10-25)也可以翻译为一个字符。

动态规划解题思路

  1. 定义状态
    dp[i] 表示到数字的第 i 位时,所有可能的翻译方法数。我们需要计算 dp[n],其中 nciphertext 的位数。

  2. 转移方程

    • 对于每一位数字 x[i],如果它与前一位数字 x[i-1] 组成的两位数在 [10, 25] 范围内,则可以将这两位数作为一个字符翻译。因此,状态转移公式为:

      dp[i]=dp[i−1]+dp[i−2]dp[i] = dp[i-1] + dp[i-2]

      其中:

      • dp[i-1] 表示当前位数字单独翻译的情况。
      • dp[i-2] 表示当前位数字与前一位数字组成的两位数翻译的情况(如果符合条件)。
    • 如果 x[i-1]x[i] 组成的两位数不在 [10, 25] 范围内,则:

      dp[i]=dp[i−1]dp[i] = dp[i-1]
  3. 初始化

    • dp[0] = 1,表示“无数字”的翻译方法数。
    • dp[1] = 1,表示仅有一个数字时的翻译方法数。
  4. 最后的结果
    结果是 dp[n],即数字 ciphertext 的翻译方案数。

代码实现

方法一:字符串遍历

class Solution {
    public int crackNumber(int ciphertext) {
        String s = String.valueOf(ciphertext); // 将数字转换为字符串
        int a = 1, b = 1; // 初始状态 dp[0] = 1, dp[1] = 1
        for(int i = 2; i <= s.length(); i++) {
            String tmp = s.substring(i - 2, i); // 获取当前位和前一位的两位数
            int c = (tmp.compareTo("10") >= 0 && tmp.compareTo("25") <= 0) ? a + b : a; // 判断是否在可翻译的范围内
            b = a; // 更新 dp[i-1]
            a = c; // 更新 dp[i]
        }
        return a; // 返回 dp[n]
    }
}

方法二:数字求余

        为了节省空间,我们可以直接利用数字的除法和取余操作来获取每一位数字。这个方法通过从右到左进行动态规划,避免了字符串的使用。

class Solution {
    public int crackNumber(int ciphertext) {
        int a = 1, b = 1, x, y = ciphertext % 10; // 获取个位数字
        while (ciphertext > 9) {
            ciphertext /= 10; // 获取当前数字的十位
            x = ciphertext % 10;
            int tmp = 10 * x + y; // 计算当前数字和前一位数字组成的两位数
            int c = (tmp >= 10 && tmp <= 25) ? a + b : a; // 判断两位数是否在 10 到 25 之间
            b = a; // 更新 dp[i-1]
            a = c; // 更新 dp[i]
            y = x; // 更新当前位数字
        }
        return a; // 返回 dp[n]
    }
}

时间复杂度与空间复杂度

  1. 时间复杂度

    • 时间复杂度为 O(N),其中 Nciphertext 的位数。每次循环进行常数时间的操作,因此总体时间复杂度是线性的。
  2. 空间复杂度

    • 方法一中,空间复杂度为 O(N),因为我们需要一个字符串来存储 ciphertext
    • 方法二中,空间复杂度为 O(1),因为只使用了常数空间来存储变量。

总结

        通过动态规划,我们能够有效地计算出给定数字的所有翻译方式。两种方法分别是字符串遍历和数字求余,通过优化空间使用,第二种方法可以将空间复杂度从 O(N) 降至 O(1)

2. 珠宝的最高价值

        现有一个记作二维矩阵 frame 的珠宝架,其中 frame[i][j] 为该位置珠宝的价值。拿取珠宝的规则为:

  • 只能从架子的左上角开始拿珠宝
  • 每次可以移动到右侧或下侧的相邻位置
  • 到达珠宝架子的右下角时,停止拿取

注意:珠宝的价值都是大于 0 的。除非这个架子上没有任何珠宝,比如 frame = [[0]]

示例 1:

输入: frame = [[1,3,1],[1,5,1],[4,2,1]]
输出: 12
解释: 路径 1→3→5→2→1 可以拿到最高价值的珠宝

提示:

  • 0 < frame.length <= 200
  • 0 < frame[0].length <= 200

该题可以通过动态规划来求解,关键是利用状态转移方程来递推每个位置的最大珠宝价值。

解题思路:

        我们有一个二维棋盘,每个格子里有珠宝,我们需要从左上角 (0, 0) 出发,按照规则(每次只能向右或向下移动)走到右下角 (m-1, n-1),目标是获得的珠宝最大总值。通过动态规划来逐步计算每个位置可能的最大珠宝总值。

动态规划解析:

  1. 状态定义:
    dp[i][j] 表示从起点 (0, 0) 到达位置 (i, j) 时所能收获的珠宝最大累计价值。

  2. 转移方程:

    • 初始位置 dp[0][0] = frame[0][0],即起始位置的珠宝价值。
    • 如果 i = 0j > 0,只能从左边到达,dp[0][j] = dp[0][j-1] + frame[0][j]
    • 如果 i > 0j = 0,只能从上边到达,dp[i][0] = dp[i-1][0] + frame[i][0]
    • 对于其他位置 (i, j),可以从左边 (dp[i][j-1]) 或从上边 (dp[i-1][j]) 走到,选取两者中的较大值:
      dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + frame[i][j]
  3. 空间优化:
    由于 dp[i][j] 只依赖于 dp[i-1][j]dp[i][j-1],可以在原数组 frame 上进行修改,不需要额外的二维数组来存储 dp,因此空间复杂度可以降为 O(1)

代码实现:

class Solution {
    public int jewelleryValue(int[][] frame) {
        int m = frame.length, n = frame[0].length;
        
        // 初始化第一行
        for (int j = 1; j < n; j++) {
            frame[0][j] += frame[0][j - 1];
        }
        
        // 初始化第一列
        for (int i = 1; i < m; i++) {
            frame[i][0] += frame[i - 1][0];
        }
        
        // 动态规划计算剩余部分
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                frame[i][j] += Math.max(frame[i][j - 1], frame[i - 1][j]);
            }
        }
        
        // 返回右下角的珠宝最大值
        return frame[m - 1][n - 1];
    }
}

代码解释:

  1. 初始化第一行和第一列:
    第一行每个元素只能从左边过来,所以我们把每个元素加上它左边的值;第一列每个元素只能从上面过来,所以加上它上面格子的值。

  2. 递推计算其他格子:
    从第二行第二列开始,对于每个格子,我们取它上边和左边的最大值,加上当前格子的珠宝值,得到从起点到该格子的最大珠宝值。

  3. 返回结果:
    最终右下角的值就是从左上角到右下角的最大珠宝价值。

时间复杂度和空间复杂度:

  • 时间复杂度:
    时间复杂度为 O(M * N),其中 MN 分别是矩阵的行数和列数,因为我们需要遍历整个矩阵。

  • 空间复杂度:
    空间复杂度为 O(1),因为我们是在原矩阵上进行修改,没有额外使用空间。

这个解法高效且简单,适用于大多数二维矩阵的动态规划问题。

3. 统计结果概率

        你选择掷出 num 个色子,请返回所有点数总和的概率。

        你需要用一个浮点数数组返回答案,其中第 i 个元素代表这 num 个骰子所能掷出的点数集合中第 i 小的那个的概率。

示例 1:

输入:num = 3
输出:[0.00463,0.01389,0.02778,0.04630,0.06944,0.09722,0.11574,0.12500,0.12500,0.11574,0.09722,0.06944,0.04630,0.02778,0.01389,0.00463]

示例 2:

输入:num = 5
输出:[0.00013,0.00064,0.00193,0.00450,0.00900,0.01620,0.02636,0.03922,0.05401,0.06944,0.08372,0.09452,0.10031,0.10031,0.09452,0.08372,0.06944,0.05401,0.03922,0.02636,0.01620,0.00900,0.00450,0.00193,0.00064,0.00013]

提示:

  • 1 <= num <= 11

方法一:暴力法

        暴力法是通过遍历所有的点数组合来统计每种点数和的出现概率。给定 n 个骰子,每个骰子的点数范围为 1 到 6,因此骰子的所有组合一共有 6^n 种。对于每一种组合,计算其点数和,并统计每种点数和出现的次数。

  • 这种方法的时间复杂度为 O(6^n),随着 n 增大,计算量急剧增加,因此当 n 较大时,暴力法的效率会显得十分低下。特别是题目中 n 的范围是 1 到 11,因此暴力法不适用于较大的 n

方法二:动态规划

        在动态规划方法中,设 f(n, x) 表示使用 n 个骰子得到点数和 x 的概率。通过递推关系可以求解每种点数和的概率。

状态转移:

  • 当已知 n-1 个骰子的解 f(n-1) 时,加入第 n 个骰子后,可以通过已知的概率计算 f(n,x)
  • 具体来说,点数和为 x 的概率可以通过考虑骰子的每个点数(从 1 到 6)来计算,依次累加前 n-1 个骰子中对应的点数和的概率。

递推公式如下:

f(n,x)=∑i=16f(n−1,x−i)×16f(n, x) = \sum_{i=1}^{6} f(n-1, x-i) \times \frac{1}{6}

解决越界问题:

        在上述递推公式中,存在越界的风险(即 x-i 小于零)。为了解决这个问题,我们可以将状态转移的方向调整为“正向”,即从 f(n-1, x) 转移到 f(n, x)。这样可以避免访问无效的状态。

动态规划优化:
  1. 空间优化:由于 f(n, x) 只依赖于 f(n-1, x+1)f(n-1, x+6),因此可以使用两个一维数组交替存储结果,减少空间复杂度。
  2. 时间复杂度:时间复杂度为 O(n^2),因为每个状态的转移涉及到最多 6 次操作,且总共有 n 次状态转移。

代码实现:

class Solution {
    public double[] statisticsProbability(int num) {
        // 初始化 dp 数组,代表 1 个骰子时的概率
        double[] dp = new double[6];
        Arrays.fill(dp, 1.0 / 6.0);
        
        // 动态规划计算概率
        for (int i = 2; i <= num; i++) {
            double[] tmp = new double[5 * i + 1];
            for (int j = 0; j < dp.length; j++) {
                for (int k = 0; k < 6; k++) {
                    tmp[j + k] += dp[j] / 6.0;
                }
            }
            dp = tmp;
        }
        
        return dp;
    }
}

复杂度分析:

  • 时间复杂度O(n^2)。每一轮状态转移有多次内循环操作,具体的复杂度取决于骰子的数量 n
  • 空间复杂度O(n)。由于使用了两个一维数组交替存储结果,空间复杂度被优化为线性级别。

总结:

  • 暴力法适用于 n 较小的情况,但对于 n 较大的情况效率低下。
  • 动态规划方法则适合大规模数据,通过优化空间和时间复杂度,使得问题能够在较大范围内有效解决。

4. 破冰游戏

        社团共有 num 位成员参与破冰游戏,编号为 0 ~ num-1。成员们按照编号顺序围绕圆桌而坐。社长抽取一个数字 target,从 0 号成员起开始计数,排在第 target 位的成员离开圆桌,且成员离开后从下一个成员开始计数。请返回游戏结束时最后一位成员的编号。

示例 1:

输入:num = 7, target = 4
输出:1

示例 2:

输入:num = 12, target = 5
输出:0

提示:

  • 1 <= num <= 10^5
  • 1 <= target <= 10^6

        该问题是著名的 约瑟夫环 问题,其核心是求解一个环形数组中最终剩下的数字。这个问题通常使用动态规划或递推的方法来优化计算,避免暴力法的高时间复杂度。

问题描述:

        给定 n 个人和一个目标 m,从第一个人开始按顺序每次删除第 m 个人,直到最后只剩一个人,返回最后剩下的人的编号(编号从 0 开始)。

解题思路:

  1. 模拟法: 直接模拟每轮删除过程,可以将每个数字视作一个环中的节点。每次删除第 m 个节点,直到只剩一个节点。但由于这个方法需要频繁遍历链表,时间复杂度高,无法满足较大的 nm 范围。

  2. 动态规划法: 该问题可以通过递推的方式来求解,利用已知小规模问题的解推导出大规模问题的解。设 f(n) 为长度为 n 的数字环,删除第 m 个数字后的解。

    递推关系式为:

    f(n)=(f(n−1)+m)%nf(n) = (f(n-1) + m) \% n
    • f(1) 为 0,因为当环中只有一个元素时,剩下的数字必然是 0。
    • f(1) 推算到 f(n) 即可得到最终解。
  3. 状态转移:

    • 状态定义:dp[i] 表示 i 个数字时,最后剩下的数字。
    • 递推方程:dp[i] = (dp[i-1] + m) % i
    • 初始条件:dp[1] = 0
  4. 优化空间复杂度:

    • 由于 dp[i] 仅依赖于 dp[i-1],可以使用一个变量进行递推,从而将空间复杂度优化到 O(1)

代码实现:

class Solution {
    public int iceBreakingGame(int num, int target) {
        int x = 0;
        for (int i = 2; i <= num; i++) {
            x = (x + target) % i;
        }
        return x;
    }
}

复杂度分析:

  • 时间复杂度O(n)。通过迭代从 i=2i=n,每次状态转移只需要 O(1) 时间。
  • 空间复杂度O(1)。只用了一个额外的变量 x 来存储当前的结果,避免了使用额外的数组。

例子:

假设 n = 5m = 3,我们从 f(1) = 0 开始,通过递推逐步得到 f(2), f(3), f(4), f(5) 的值:

  • f(1) = 0 (初始状态)
  • f(2) = (f(1) + 3) % 2 = (0 + 3) % 2 = 1
  • f(3) = (f(2) + 3) % 3 = (1 + 3) % 3 = 1
  • f(4) = (f(3) + 3) % 4 = (1 + 3) % 4 = 0
  • f(5) = (f(4) + 3) % 5 = (0 + 3) % 5 = 3

最终结果为 f(5) = 3,表示最后剩下的数字是 3。

这种方法有效避免了暴力法的高时间复杂度,并且能够高效地解决大范围的输入问题。

;