我明白了,leetcode91题

简介: 91题**解码方法**的难度属于中等,但其涉及到的知识并不少呢,斐波那契、备忘录剪枝、动态规划等等,除了题解之外,我也会深入浅出的讲解这些知识点,文章末尾我还会使用 正则 + 斐波那契的写法来解题,我们一起来看看。

leetcode 91题 解码方法

题目地址:https://leetcode-cn.com/problems/decode-ways/

题目

一条包含字母 A-Z 的消息通过以下映射进行了 编码

'A' -> 1
'B' -> 2
...
'Z' -> 26

解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,"11106" 可以映射为:

  • "AAJF" ,将消息分组为 (1 1 10 6)
  • "KJF" ,将消息分组为 (11 10 6)

注意,消息不能分组为  (1 11 06) ,因为 "06" 不能映射为 "F" ,这是由于 "6""06" 在映射中并不等价。

给你一个只含数字的 非空 字符串 s ,请计算并返回 解码 方法的 总数

题目数据保证答案肯定是一个 32 位 的整数。

示例 1:

输入: s = "12"
输出: 2
解释: 它可以解码为 "AB"(1 2)或者 "L"(12)。

示例 2:

输入: s = "226"
输出: 3
解释: 它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。

示例 3:

输入: s = "0"
输出: 0
解释: 没有字符映射到以 0 开头的数字。
含有 0 的有效映射是 'J' -> "10" 和 'T'-> "20" 。
由于没有字符,因此没有有效的方法对此进行解码,因为所有数字都需要映射。

示例 4:

输入: s = "06"
输出: 0
解释: "06" 不能映射到 "F" ,因为字符串含有前导 0(`"6"` 和 `"06"` 在映射中并不等价)。

提示:

  • 1 <= s.length <= 100
  • s 只包含数字,并且可能包含前导零。

要求

  • 统计能解码(匹配)到的个数。
  • 0 开头的数字不行。
  • 映射的结果在26个字母范围之内。
  • 这是一个斐波那契规律的字符串。

分析

开头为 0 直接返回 0。

0 是一个特殊的判断条件。

每一个匹配到的值必须小于等于26。

这是一个符合斐波那契数列规律的问题。

var numDecodings = function (s) {
  const n = s.length;
  // 如果 n === 0 或者 s[0] === '0',直接结束,我喜欢使用includes
  if (isZero(n) || isZero(s[0]) ) {
    return 0
  }
  
  // 使用一个表来存取匹配到的数量
  const dpTable = Array(n + 1).fill(0)
  // 斐波那契
  dpTable[0] = 1
  dpTable[1] = 1
  
  for (let i = 2; i <= n; i ++) {
    // 情况一:倒数一位不为0
    if (!isZero(s[i-1])) {
      dpTable[i] = dpTable[i] + dpTable[i - 1];
    }
    
    // 情况二:倒数两位 大于等于 10 并且小于等于 26
    const value =Number(dpTable[i - 2] + dpTable[i - 1])
    if (value >= 10 && value  <= 26) {
      dpTable[i] = dpTable[i] + dpTable[i - 2];
    }
  }
  
  return dpTable[n]
  
  function isZero(num) {
    return [0, '0'].includes(num)
  }
}

核心扩展

91题的题解使用到了动态规划,动态规划是很常见的算法设计技巧,同时这道题也使用到了斐波那契数列的规律,那我们就那斐波那契数列来举例吧。

层层递进的固定流程:递归的暴力解法 -> 带备忘录的递归解法 -> 非递归的动态规划解法

求斐波那契数列(递归的暴力解法)

function fibonacci (n) {
  if ([1, 2].includes(n)) {
    return 1
  }
  
  return fibonacci(n - 1) + fibonacci(n - 2)
}

把这种暴力递归的解法画一张图出来:

是不是有重复的计算?像这样的二叉树的节点总数是指数级的,所以所有子问题的个数为 (n^2) 噢,这么做会导致时间复杂度爆炸,因为时间复杂度也变成了 O(n^2) 。

求斐波那契数列 (带备忘录的递归解法)

let dp = [1, 1]
function fibonacci (n) {
  if (dp[n - 1]) {
    return dp[n - 1]
  }
  
  dp[n - 1] = fibonacci(n - 1) + fibonacci(n - 2);
  return dp[n - 1]
}

这种带备忘录的递归解法是针对暴力递归的解法做的优化,优化的操作其实就是对那颗指数级的二叉树进行剪枝,剪枝会减少重复的树节点(减少了重复的计算)。

这种带备忘录的递归解法也画一张图出来:

红色实线部分圈着的是被剪枝的节点,是不是剪掉了所有的重复树节点?没错是的,所有重复的子问题都被干掉了,时间复杂度骤减。

没错,它时间复杂度也从O(n^2)降低到了O(n),如果用空间去换时间的话,它的时间复杂度甚至还会从O(n) 降 O(1)。

惊叹算法设计的神奇。从O(n^2)到O(n)再到O(1),速度一下子就上去了。
假设这个 n 为 1000,O(1000^2)、O(1000)、O(1) 这三者相比之下,可以体会到时间复杂度的提升是 质的飞升

求斐波那契数列 (非递归的动态规划解法)

function fibonacci (n) {
  let dp = Array(n).fill(0);
  dp[0] = dp[1] = 1;

  for (let i = 2; i < n; i ++) {
    dp[i] = dp[i-1] + dp[i - 2]
  }

  return dp[n - 1];
}

无论是递归的暴力解法还是带备忘录的递归解法,实际上本质都是自顶向下(从上到下),看上面两张图不难发现这个规律。

使用动态规划的方式,你可以看到我们是自底向上(从下往上),也就是从最小的问题逐渐往上推,先求fibonacci(2)再求fibonacci(3)...最后fibonacci(20)。

递归是自顶向下,从上向下的延申,逐层分解问题的规模,直到触及到底部(小问题得以解决),然后再逐层的向上返回答案,最后求出最大的那个问题的解。
动态规划一般都是非递归的,是自底向上的循环迭代,先解决最小规模的问题,再用小问题的答案求解除最大规模问题的解。

动态规划

从求斐波那契数列的非递归动态规划解法中,我可以分析到三个要素,这三个要素也是动态规划的显著特征。

  • 状态转移方程:例如 dp[0] = dp[1] = 1;dp[i] = dp[i-1] + dp[i-2];
  • 暴力解法:想到了这个状态转移方程后,就能写出这个暴力解的算法,比如使用 DP Table 和 for循环 来实现求斐波那契数列的算法 。
  • 独立最优子结构:子问题必须相互独立,最大规模问题的解是由多个子问题的最优解计算得来的。

画一下这三个特征的图:

也许这张图不是很明显,那么可以换另一个更生动的方式来说明,比如leetcode 中 322.零钱兑换

leetcode 322题 零钱兑换

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1

示例 2:

输入:coins = [2], amount = 3
输出:-1

示例 3:

输入: coins = [1], amount = 0
输出: 0

示例 4:

输入: coins = [1], amount = 1
输出: 1

示例 5:

输入: coins = [1], amount = 2
输出: 2

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 231 - 1
  • 0 <= amount <= 104

要求

  • 有三种面额的金币:1元、2元、5元
  • 用这三种面额的金币组合出指定的总额
  • 这三种面额的金币支持重复组合
  • 最少使用几枚可以组合出指定的总额

分析

总额为13,那么最少需要四张:5 + 5 + 2 + 1

总额为0 时,直接返回 0

每一次的解都在 coins中,下一次的因依赖与上一次的解,比如 第一次的解为5,下一次的因就是 13 - 5,那么剩下的解就是 从 8 中计算得出。

可以遍历coins,如果下一次的因小于 coins中某个金币面额,那么就continue一下,切换下一个面额。

累积计数,求最小值。

递归的写法

这种写法性能贼差嘞,递归中套循环,循环中有递归,复杂度是 O(n^count),也就是n的每一轮循环的总次数的那么多次方噢,爆炸,如果coins中有五个元素,那么时间复杂度就是O(n^5),爆炸了爆炸了。

var coinChange = function(coins, amount) {
    if ([0, '0'].includes(amount)) {
        return 0
    }

    let temp = Number.MAX_VALUE
    for(let coin of coins) {
        if (amount - coin < 0) continue;

        const result = coinChange(coins, amount - coin);

        if (result === -1) continue;

        temp = Math.min(temp, result + 1);
    }

    return temp === Number.MAX_VALUE ? -1 : temp
};

如图:

带备忘录的递归写法

这种写法对原来的递归树进行了剪枝操作,极大的减少了重复的运算。复杂度是O(n*count),重复的问题都不会继续运算,时间复杂度是O(n)。

var coinChange = function(coins, amount) {

    const memory = Array(amount).fill(-10);

    return calcCoinChange(coins, amount)

    function calcCoinChange(coins, amount) {
        if ([0, '0'].includes(amount)) {
            return 0
        }
        
        const curIndex = amount - 1
        if (memory[curIndex] !== -10) {   
            return memory[curIndex];
        }

        let temp = Number.MAX_VALUE
        for (let coin of coins) {
            if(amount - coin < 0) continue;

            const result = calcCoinChange(coins, amount - coin);

            if (result === -1) continue;
            temp = Math.min(temp, result + 1);
        }

        memory[curIndex] = temp === Number.MAX_VALUE ? -1 : temp;
        return memory[curIndex]
    }
};

如图:

动态规划的写法

这种写法采用双重for循环加dp数组,也是自底向上的,动态规划是一种分阶段 求解 决策 的数学思想。下面这种写法的时间复杂度也是O(n)。

var coinChange = function(coins, amount) {

    if ([0, '0'].includes(amount)) return 0

    const dp = Array(amount + 1).fill(amount + 1)
    dp[0] = 0

    for (let i = 1; i <= amount; i ++)
        for (const coin of coins)
            if (coin <= i) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1)
            }

    return dp[amount] > amount ? -1 : dp[amount]
}

另类题解

通过正则表达式和求斐波那契数列的方式来实现91题


/**
 * @param {string} s
 * @return {number}
 */
var numDecodings = function(s) {
    if(s[0] == '0') return 0;
    if(/(30)|(40)|(50)|(60)|(70)|(80)|(90)|(00)/.test(s)) return 0;
    list = s.match(/(1|2)*(1[03456789])|(1|2)*(2[03456])|(1|2)+/g);
    if(list) {
        let solve = 1, minus = 0;
        for(let i=0; i<list.length; i++) {
            minus = /0$/.test(list[i]) ? 2 : 0;
            solve *= fb(list[i].length - minus);
        }
        return solve
    } else return 1;

    function fb(index) {
        if(index <= 1) return 1;
        var num1 = 1, num2 = 1, ans = 2;
        for(let i=2; i<index; i++) {
            num1 = num2;
            num2 = ans;
            ans = num1 + num2;
        }
        return ans;
    }
};
目录
相关文章
|
5天前
|
前端开发 JavaScript
【面试题】这道面试题真的很变态吗?
【面试题】这道面试题真的很变态吗?
|
5天前
|
算法 程序员
「程序员必须掌握的算法」双指针「上篇」
「程序员必须掌握的算法」双指针「上篇」
|
6月前
LeetCode刷题之 存在重复元素(题目分析➕源代码)
LeetCode刷题之 存在重复元素(题目分析➕源代码)
|
7月前
|
算法 C++ 索引
代码随想录刷题-数组
代码随想录刷题-数组
32 0
|
12月前
|
算法 C++
【每日算法Day 103】老题新做,几乎不会有人想到的解法,它来了
【每日算法Day 103】老题新做,几乎不会有人想到的解法,它来了
leetcode14(弄懂了一个知识点)
这个题有一点细节,所以就记录一下(可能不一定准确)
57 0
|
C语言
【leetcode】学了栈和队列却觉得无用武之地?试试这几道题目吧!
【leetcode】学了栈和队列却觉得无用武之地?试试这几道题目吧!
72 0
|
存储 安全 小程序
软件测试面试题及答案,这些可以白嫖的题目确定不收藏?
对于软件测试培训人员来说,除了掌握好专业的理论知识和技术,最重要的面试准备也是少不了的,毕竟面试可是大家正式进入软件行业的拦路虎,所以,在正式面试前,相关的软件测试面试题真题以及答案也一定要背一背!
125 0
|
数据安全/隐私保护 Python
一眼就解密题解
一眼就解密题解
144 0
一眼就解密题解