从递归到记忆化递归再到dp

580 阅读2分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

介绍

今天做了一道题,觉得这个思想还挺有价值的,故写下这篇文记录一下。

题目是LC91,是一道很基础的题。

question.jpg

递归

看到这个题,一下能想到的方法就是递归搜索了。 因为这个题解码时只有两种情况,在某个状态截取1个字符或截取两个字符,所以递归做起来也不会很复杂。

class Solution {
    public int numDecodings(String s) {
        // 递归解法(超时)
        return dfs(s.toCharArray(), s.length(), 0);
    }

    public int dfs(char[] chars, int len, int index) {
        // 找到了一条解码方式
        if (index >= len) return 1; 
        // '0'没有解码方式(10或20可以在下面截取2个字符时截取得到,所以不用担心这样漏解)
        if (chars[index] == '0') return 0;

        // 截取1个字符时
        int res = dfs(chars, len, index + 1);
        // 截取2个字符时
        if (index < len - 1 && ((chars[index] - '0') * 10 + (chars[index+1] - '0') <= 26))
            res += dfs(chars, len, index + 2);

        return res;
    }
}

当然,这个方法毫无疑问会超时,但是这无疑是我们往后优化所必不可少的第一步。

记忆化递归

抓耳挠腮了半天,甚至动笔模拟了很久,会发现这个递归的方法存在很多不必要的重复计算。 在计算一个字符串编码方式时,这个普通的递归方法会多次重复计算它的子编码方式(即它的子字符串的编码方式)

如果不懂的话,看一下代码比较容易理解

class Solution {
    public int numDecodings(String s) {
        // 记忆化递归

        // 使用一个数组来存储计算结果就行了
        // 只需要存储s中从index开始到最后有多少种解码方式就行了
        int[] record = new int[s.length()];
        Arrays.fill(record, -1);

        return dfs(s.toCharArray(), s.length(), 0, record);
    }

    public int dfs(char[] chars, int len, int index, int[] record) {
        // 找到了一条解码方式
        if (index >= len) return 1; 
        // '0'没有解码方式(10或20可以在下面截取2个字符时截取得到,所以不用担心这样漏解)
        if (chars[index] == '0') return 0;
        // 如果已经计算过了,直接返回计算结果
        if (record[index] != -1) return record[index];

        // 截取1个字符时
        int res = dfs(chars, len, index + 1, record);
        // 截取2个字符时
        if (index < len - 1 && ((chars[index] - '0') * 10 + (chars[index+1] - '0') <= 26))
            res += dfs(chars, len, index + 2, record);
        
        record[index] = res; // 记录下index下有多少种解码方式
        return res;
    }
}

到这里,其实已经优化的很好了,但是还可以使用动态规划解决。

DP

DP的方法其实就是把记忆化递归的方式写成迭代的方式。 在记忆化递归中,记录数组record是在s中从后往前记录的,当然DP也可以从后往前写,但是从前往后比较符合我们人类的思维,所以我这里从前往后使用迭代的方式来做。

如果是一步步做下来的,其实可以很轻松地发现状态转移方程。 dp[i] = dp[i-1] + dp[i-2] 只是在处理边界条件和判断进入哪种状态时需要稍微注意一下。

class Solution {
    public int numDecodings(String s) {
        // dp解法
        // 比s长度大1,是为了添加一个哨兵
        int[] dp = new int[s.length() + 1];

        dp[0] = 1; // 哨兵,防止第一个字符就是'0'
        for (int i = 1; i <= s.length(); i++) {
            // 截取一个字符时
            // 只要不是'0'都可以接受
            if (s.charAt(i-1) != '0')
                dp[i] = dp[i-1];

            // 截取两个字符时
            // 除了要小于26以外,使用我这种方法判断的话还要避免第一位是0
            if (i >= 2 && s.charAt(i-2) != '0' 
                && ((s.charAt(i-2) - '0') * 10 + (s.charAt(i-1) - '0') <= 26))
                dp[i] += dp[i-2];
        }

        return dp[s.length()];
    }
}

是不是觉得dp的方式更加优雅简洁呢。

其实还有更加节省空间的方式,就是把dp数组换成只使用两个变量。因为这里在计算第i个位置的dp时只用到了i-1与i-2两个位置,我这里就不再写下去啦。

总结

其实DP和记忆化递归是一样的,只是采用了不同的写法。但是从头直接用动态规划的思想做的话可能会有点困难,所以想不出解法的时候不妨先从最普通的递归开始,一步一步进行优化。

扩展

有什么相关题目值得一做的我会补充在这里。