请问如何将记忆化搜索解法转成动态规划写法

来源:1-5 大厂面试为什么总考算法?

Potter520

2023-06-30

比如这个题目:2741. 特别的排列

图片描述

这其实就是全排列问题,画图我能看到重叠子问题,然后利用记忆化搜索,写出如下算法。

/**
 * 记忆化搜索
 * @param {*} nums
 */
var specialPerm = function (nums) {
  let n = nums.length;
  let visited = Array(n).fill(false);
  let meno = Array(n)
    .fill(-1)
    .map(() => Array((1 << n) - 1).fill(-1));
  let MOD = 1e9 + 7;

  //! 从深度0至depth,获得的特殊排序个数。prevPos:前一个元素索引, mask: 表示已选择的数的索引二进制值
  function dfs(depth, prevPos, mask) {
    if (depth == n) {
      return 1;
    }

    //! 已选择的数
    if (meno[prevPos][mask] !== -1) {
      return meno[prevPos][mask];
    }

    let res = 0;
    for (let i = 0; i < n; i++) {
      if (!visited[i]) {
        //! mask=0 表示没有选择任何元素,也就是第一个开始元素,所以直接递归下去
        if (mask == 0) {
          visited[i] = true;
          res += dfs(depth + 1, i, mask | (1 << i));
          visited[i] = false;
        } else if (nums[i] % nums[prevPos] == 0 || nums[prevPos] % nums[i] == 0) {
          visited[i] = true;
          res += dfs(depth + 1, i, mask | (1 << i));
          visited[i] = false;
        }
        res %= MOD;
      }
    }

    meno[prevPos][mask] = res;

    return res;
  }

  return dfs(0, 0, 0);
};

提交可以通过,但是要改成动态规划写法,我想不清base case、dp的清楚定义、状态转移。我尝试画图了也没有搞清楚,疑问一:请问老师有什么方法能帮助我想清楚这些东西吗?也翻看了别人的dp解法,还是没有看懂。其实之前也有总结过不少动态规划的题目,但是要做这种转换还是想不清。对于我这种状态有什么建议呢?
疑问二:记忆化搜索算法转动态规划,有没有什么技巧。比如:什么对应什么,以及如何改,要遵循什么规则等等。

写回答

2回答

liuyubobobo

2023-07-15

首先,在你的代码中,visited 这个数组是不必要的。因为 visited 的信息就在 mask 中。mask 的第 i 位是 0 表示这个元素没有被选过,是 1 表示选过,所以我先简单优化了一下你现在的代码,扔掉了 visited。注意原先对 if(!visited[i]) 的判断变成了 

if ((mask & (1 << i)) == 0)

即 mask 的第 i 位是 0。

(其实,递归函数中的 deoth 参数也可以扔掉,因为 depth 的信息也在 mask 中,即 mask 中 1 的数量。但是因为我对 js 不熟,不确定是不是有更直接的方式获得一个数字的二进制表示中 1 的数量,且 depth 作为函数参数传递在我看来可以接受,就保留了。)


另外,我将 meno 的拼写修改成了 memo,memo 是备忘录的意思,也是记忆化搜索这种方式的英文 memoization 的前四个字母。不过我也习惯把这个数组叫 dp。


var specialPerm = function (nums) {
  
  let n = nums.length;
  let memo = Array(n)
    .fill(-1)
    .map(() => Array((1 << n) - 1).fill(-1));
  let MOD = 1e9 + 7;
  
  //! 从深度0至depth,获得的特殊排序个数。prevPos:前一个元素索引, mask: 表示已选择的数的索引二进制值
  function dfs(depth, prevPos, mask) {
    
    if (depth == n) {
      return 1;
    }
    
    //! 已选择的数
    if (memo[prevPos][mask] !== -1) {
      return memo[prevPos][mask];
    }
    
    //!!!!!!!!!!
    // 注意这段代码
    let res = 0;
    for (let i = 0; i < n; i++) {
      if ((mask & (1 << i)) == 0) {
        //! mask=0 表示没有选择任何元素,也就是第一个开始元素,所以直接递归下去
        if (mask == 0) {
          res += dfs(depth + 1, i, mask | (1 << i));
        } 
        else if (nums[i] % nums[prevPos] == 0 || nums[prevPos] % nums[i] == 0) {
          res += dfs(depth + 1, i, mask | (1 << i));
        }
        res %= MOD;
      }
    }
    
    memo[prevPos][mask] = res;
    // !!!!!!!!!!
    
    return res;
  }
  return dfs(0, 0, 0);
};


==========


下面说你的问题。我先把你的这个代码写成 dp,然后再来回答一些"所谓"的“基本原则"(


var specialPerm = function (nums) {
    
    let n = nums.length;
    let memo = Array(n)
        .fill(-1)
        .map(() => Array((1 << n) - 1).fill(-1));
    let MOD = 1e9 + 7;
    
    // A!!! 
    // 对应上面递归算法的
    // if (depth == n) return 1;
    // 也就是不管 prevPos 的值,只要 mask == (1 << n) - 1,结果都是 1
    for(let prevPos = 0; prevPos < n; prevPos ++) memo[prevPos][(1 << n) - 1] = 1;
    
    // B!!!
    // 逆序遍历 mask
    for(let mask = (1 << n) - 2; mask >= 0; mask --){
        // 遍历 prevPos
        for(let prevPos = 0; prevPos < n; prevPos ++){
            
            // C!!!
            // 注意,下面这段代码,和上面我标识 // !!!!!!!!! 的代码完全相同
            // 区别只要在上面是递归调用 dfs(i, mask | (1 << i)) 的地方
            // 下面的代码直接调用 memo[i][mask | (1 << i)]
            let res = 0;
            for(let i = 0; i < n; i ++){
                if ((mask & (1 << i)) == 0){
                    if (mask == 0) {
                        res += memo[i][mask | (1 << i)];
                    }
                    else if (nums[i] % nums[prevPos] == 0 || nums[prevPos] % nums[i] == 0) {
                        res += memo[i][mask | (1 << i)];
                    }
                    res %= MOD;
                }
            }
            memo[prevPos][mask] = res;
            // !!!!!!!!!!
        }
    }
    return memo[0][0];
};


下面看一些基本原则:


1)

递归调用分两部分:递归到底的情况和递归过程。

递归到底的情况对应上面代码的 A!!!,这部分应该很好理解;

递归过程对应上面代码的 B!!! 和 C!!!


2)

记忆化搜索的递归过程,本质就是状态转移。所以,上面 C!!! 的代码逻辑是完全相同的。


3)

最重要的区别是,对于递归过程,我们不需要关心使用什么“顺序”去访问这些状态,但是在做 dp 的时候,我们需要关心这个状态的顺序。也就是上面 B!!! 代码做的事情(两重循环)


具体就是在这个例子里,我们逆序遍历 mask 是重要的,顺序遍历 mask 是不可以的。因为你可以看状态转移,我们为了计算 memo[prevPos][mask],需要访问 memo[i][mask | (1 << i)] 的值,而 mask | (1 << i) 的值肯定比 mask 大。所以,我们为了计算更小的 mask 对应的状态,必须先计算完 更大的 mask 对应的状态。

(在这个例子里,对 prevPos 的遍历没有顺序要求,我是从小到大遍历,也可以从大到小遍历。)


4)

想清楚这三件事儿,任何记忆搜索的代码都可以修改成动态规划。再总结一遍,这三件事儿是:

  1. 基本状态是什么(对应递归的终止条件)

  2. 状态转移是什么(对应递归过程的逻辑)

  3. 按照什么顺序计算这些状态(在递归的时候基本不用考虑这件事儿)

因为 3 是做递归的时候不用考虑的,所以如果你真的理解记忆化搜索,面对一个可以 dp 的问题,写出记忆化搜索的代码,是比写出 dp 代码更容易的,这是非常正常的事情。 因为在一些情况下,这个状态的访问顺序可以很复杂,不一定是正序或者是逆序,有可能是一个状态图的拓扑序。使用递归其实是简化了这个过程,而非复杂化了这个过程。


5)

更神奇的是,在很多时候,记忆化搜索是比 dp 效率高的(比如我测试这个问题就是如此)。这是因为记忆化搜索只会计算为了计算出最终结果所需要的状态,有可能会省略一些状态的计算。而 dp 一定遍历了所有的状态。

当然,dp 也有更具有优势的时候。比如如果所有状态都需要计算的话,非递归的代码通常性能更好。更重要的是,dp 的方式提供了算法优化的空间,比如我在课程中介绍的滚动数组的方式。更进阶的情况包括把整个 dp 表组织成其他数据结构(比如线段树)以快速查询某些信息。但这些都属于很进阶的内容了,面试 200% 不会涉及。


6)

综上,如果你愿意,当然可以尝试把每一个记忆化搜索的代码改成 dp,但其实对于现代计算机来说,这并非必须,也不一定更优。


继续加油!:)


0
3
甲骨文_0001
回复
liuyubobobo
老师,我把我的问题贴在回答区了,看看哈:)
2023-07-26
共3条回复

甲骨文_0001

2023-07-26

var specialPerm = function(nums) {
    let resArr = [] // 先求出所有全排列
    permute(nums, 0, [], resArr)
    let cnt = 0; // 符合条件的结果计数
    for( let row = 0; row < resArr.length; row ++ ){
        // 对第row行个子数组考察
        let bSpecial = true;
        // 接着对每行按列来判断,如果有一个下标处不符合条件,直接break,同时设bSpecial=false;
        for( let i = 0; i < resArr[row].length - 1; i ++ ){
            if( !(resArr[row][i] % resArr[row][i+1] == 0 || resArr[row][i+1] % resArr[row][i] == 0) ){
                bSpecial = false
                break;
            }
        } // for i
        if(bSpecial){ // 如果某行每列都判断下来了,都符合条件,计数加1
            cnt++;
        }
    } // for row
    return cnt;
};

function permute( nums, mask, tmp, resArr ){
    if( tmp.length >= nums.length ){
        resArr.push([...tmp])
        return;
    }

    for( let i = 0; i < nums.length; i ++ ){
        // let ss = mask.toString(2)
        if( (mask & (1 << i)) == 0 ){
            tmp.push(nums[i])
            permute( nums, mask | (1 << i), tmp, resArr )
            tmp.pop()
        }
    } // for i
}

老师,这道题目我自己的方法是首先求出全排列,然后按照 

对于 0 <= i < n - 1 的下标 i ,要么 nums[i] % nums[i+1] == 0 ,要么 nums[i+1] % nums[i] == 0

这个逻辑来对一个排列进行判断,因为上述同学的递归 dfs(depth, prevPos, mask) 我一下想不出来,需要消化,然后我的方法在我自己测试的时候,(如下截图)对同一组用例: [24,3,27,54,9,90,30,60,20,100],在我的chrome上跑是4个计数,然后判题系统是抛错了,不晓得这个错是怎么回事。。。。。

//img.mukewang.com/szimg/64c11fe10828921911100366.jpg

//img.mukewang.com/szimg/64c11fe1090ae9db22421246.jpg


0
1
liuyubobobo
我估计是超 leetcode 设定的内存限制了。你可以试一下不要先生成所有的排列,再判断。在生成排列的过程中,就可以判断该排列的正确性。另外,这个问题的数据规模这样暴力做应该还是会超时的。全排列的规模是远远比指数级别高的(n! 是远远大于 2^n 的)。继续加油!:)
2023-07-28
共1条回复

玩转算法面试-- Leetcode真题分门别类讲解

课程配套大量BAT面试真题,高频算法题解析,强化训练

7410 学习 · 1150 问题

查看课程