dp算法编程:暴力dfs->记忆化搜索->动态规划

前言

算法,其实可以理解成一种特殊的思维方式。
最近做题时候遇到了几道比较典型的动态规划题目,逐步分析解题思路,发现可以拆分成暴力->记忆化搜索->动态规划的递进过程,现结合案例进行总结。
复习下卡子哥的动态规划五部曲:
(1)确定dp数组(dp table)以及下标的含义
(2)确定递推公式
(3)dp数组如何初始化
(4)确定遍历顺序
(5)举例推导dp数组
比较难的是想出递推公式(状态转移方程) B站有视频讲这部分的,举例打家劫舍I问题:
https://www.bilibili.com/video/BV1Xj411K7oF/?vd_source=ae5000205105da3b7f7459f2b3559aac
补充:自顶向下–记忆化搜索 自底向上–递推 可以将dfs的搜索翻译成递推:
在这里插入图片描述


一、力扣823:带因子的二叉树(树形dp)

题目传送门:https://leetcode.cn/problems/binary-trees-with-factors/description/
参考题解: https://leetcode.cn/problems/binary-trees-with-factors/solutions/1/cong-ji-yi-hua-sou-suo-dao-di-tui-jiao-n-nbk6/
题目可以先抽象成树去理解,或者直接不考虑树结构,单纯涉及递归/动态规划。
(1)首先拆分子问题,递归思考,暴力dfs复杂度可能会达到指数级别:
-----------选取每一个元素作为根节点,向下递归更小的因子作为子树的根,直到给定列表里面找不到更小因子的元素,时间复杂度o(n^2),输入1e5个元素会超时。 (感觉1e4还是可以一试)
(2)然后发现节点状态出现重复计算的部分,利用数组或者哈希表改成记忆化搜索:
-----------元素计算重复的部分包括以每个数字为根能获得的数量,比如12 = 26,6=23,这个过程2就是公共的,可以利用数组存储,数组初始化为-1,用于判断缓存结果是否存在,时间复杂度为状态个数乘以每个状态的复杂度,本题可以达到o(n)。
(3)然后把记搜过程翻译成递归的递推公式,若状态只和前几个状态有关,空间可以优化o(1)
动态规划的时间复杂度 = 状态数每个状态的复杂度。
dfs(val)= 1 (当x
x>val时)
dfs(val) = 1+dfs(x)*dfs(x) (当x^2为val时)
dfs(val) = 1+dfs(x)*dfs(val/x)2 (当 val%x为0 && x!=val/x 时)
转换成dp的递推公式:
dp([i] = 1 (当x
x>val时)
dp[i] += dp[j]*dp[j] (当x^2为val时)
dp[i] += dp[j] *dp[map.get(val/arr[j])] *2 (当 val%x==0 && x!=val/x 时)

下面是注释版题解:

class Solution {
    public int numFactoredBinaryTrees(int[] arr) {
        final long MOD = (long) 1e9 + 7;
        //经过排序剪枝,后续递归只需要找到比当前数小的下标
        Arrays.sort(arr);
        int n = arr.length;
        //哈希表存储数字和下标的映射,支持o(1)复杂度判断x和val/x
        Map<Integer, Integer> map= new HashMap<>(n);
        for (int i = 0; i < n; i++) {
            map.put(arr[i], i);
        }
        //记录最终的结果总数
        long ans = 0;
        //(1)定义dp[i] 表示以元素arr[i]作为根节点能构造出树的个数
        long[] dp = new long[n]; 
        //(4)外层正序遍历数组中的所有元素,作为根节点
        for (int i = 0; i < n; i++) {
            int val = arr[i];
            dp[i] = 1; //(3) dp数组初始化赋值为1,表示根节点的1个值
            //(4)内层正序遍历比根节点更小的元素作为因子,构建子树
            for (int j = 0; j < i; ++j) {
            	//(2)递推公式 三部分处理
                int x = arr[j]; //选择一个因子,x<=sqrt(val)即可
                if ((long) x * x > val) { // 防止乘法溢出
                    break;
                }
                // x == val/x 时候 翻转一致 直接相乘
                if (x * x == val) {
                    dp[i] += dp[j] * dp[j];
                    break;
                }
                // x != val/x 时候 翻转答案对称*2
                if (val % x == 0 && map.containsKey(val / x)) {
                    dp[i] += dp[j] * dp[map.get(val / x)] * 2;
                }
            }
            //(5)累加每个节点作为根节点构建得到的树的个数 举例推导12=2*6=3*4 
            ans += dp[i];
        }
        //因为中途不会溢出,最后取模也可以
        return (int) (ans % MOD);
    }
}

类似还有个数位dp也可以顺便对比看下:
https://blog.csdn.net/m0_52711790/article/details/128893183
其他树形dp问题可以参考以下链接(缝合怪版):
543. 二叉树的直径 https://leetcode.cn/problems/diameter-of-binary-tree/solution/shi-pin-che-di-zhang-wo-zhi-jing-dpcong-taqma/
337. 打家劫舍 III https://leetcode.cn/problems/house-robber-iii/solution/shi-pin-ru-he-si-kao-shu-xing-dppythonja-a7t1/
968. 监控二叉树 https://leetcode.cn/problems/binary-tree-cameras/solution/shi-pin-ru-he-si-kao-shu-xing-dpgai-chen-uqsf/

二、力扣638:大礼包 (背包问题)

题目传送门:https://leetcode.cn/problems/shopping-offers/description/
(两眼一黑,看标题感觉要被直接N+3送走了!)
题目不能用贪心,物品有两重因素绑定,即成本和数量约束,尽可能用礼包或许不是最优解,背包问题的局部最优是推不出全局最优的。
错误解题思路(贪心)
递归寻找大礼包,找不到大礼包则用retail补足
判断大礼包的方式:
1)是否单个大礼包超出购买需求,则直接删除大礼包O(n^2)
2)计算单个大礼包相对detail的节省,计算最大节省O(n^2)
每次递归中只选择唯一一个大礼包。贪心解不了,只能一个一个试。
做这道题可以顺便复习一下01背包和完全背包问题,它们是选或不选问题的代表。
参考题解https://leetcode.cn/problems/shopping-offers/solutions/1064231/wei-rao-li-lun-kan-qi-lai-xiang-bei-bao-l4trt/;https://leetcode.cn/problems/shopping-offers/solutions/1063610/gong-shui-san-xie-yi-ti-shuang-jie-zhuan-qgk1/;https://leetcode.cn/problems/shopping-offers/solutions/1063608/java-dfshe-ji-yi-hua-sou-suo-by-la-fe-th-4h8n/

1.暴力dfs

遍历每个大礼包,每次决策(每层)选择用或者不用,直到礼包里面的物品的数量总和大于所需要的总数时,停止递归。 res = Math.min(res,dfs(left)+itemPrice)

class Solution {
    public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
        return dfs(price,special,needs);
    }

    public int dfs(List<Integer> price, List<List<Integer>> special, List<Integer> needs){
        int res = 0;
        for(int i=0;i<needs.size();i++){
            res+=price.get(i)*needs.get(i);
        }
        
        for(List<Integer> item:special){
            List<Integer> clone = new ArrayList<>(needs);
            boolean flag = true;
            for(int j=0;j<needs.size();j++){
                if(needs.get(j)-item.get(j)<0){
                    flag = false;
                    break;
                }
                clone.set(j,needs.get(j)-item.get(j));
            }
            if(flag){
                res = Math.min(res,dfs(price,special,clone)+item.get(item.size()-1));
            }
        }
        return res;
    }
}

运行结果
在这里插入图片描述

2.记忆化搜索

由于礼包可能不止一种,不同种类递归的时候有可能出现重复计算的状态。对于相同的need数组,花费的最小成本一定是确定的,所以加几行代码,记录一下每次need数组对应的结果,可以用hash缓存,能够减少时间复杂度。

class Solution {
    public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
        //记搜修改
        HashMap<List<Integer>,Integer> map = new HashMap<>();
        return dfs(price,special,needs,map);
    }

    public int dfs(List<Integer> price, List<List<Integer>> special, List<Integer> needs,HashMap<List<Integer>,Integer> map){
        //记搜修改
        if(map.containsKey(needs)) return map.get(needs);
        int res = 0;
        for(int i=0;i<needs.size();i++){
            res+=price.get(i)*needs.get(i);
        }
        
        for(List<Integer> item:special){
            List<Integer> clone = new ArrayList<>(needs);
            boolean flag = true;
            for(int j=0;j<needs.size();j++){
                if(needs.get(j)-item.get(j)<0){
                    flag = false;
                    break;
                }
                clone.set(j,needs.get(j)-item.get(j));
            }
            if(flag){
                res = Math.min(res,dfs(price,special,clone,map)+item.get(item.size()-1));
            }
        }
        //记搜修改
        map.put(needs,res);
        return res;
    }
}

运行结果
在这里插入图片描述

3.动态规划

理论上来说,这道题用记忆化搜索更好理解,但实际上也可以看成是完全背包问题:
有点难想,并且用到了一些压缩技巧,参考题解https://leetcode.cn/problems/shopping-offers/solutions/1063610/gong-shui-san-xie-yi-ti-shuang-jie-zhuan-qgk1/

class Solution {
    public int shoppingOffers(List<Integer> price, List<List<Integer>> special, List<Integer> needs) {
        int n = price.size();
        int[] g = new int[n + 1];
        g[0] = 1;
        for (int i = 1; i <= n; i++) {
            g[i] = g[i - 1] * (needs.get(i - 1) + 1);
        }
        int mask = g[n];
        int[] dp = new int[mask];
        int[] cnt = new int[n];
        for (int state = 1; state < mask; state++) {
            dp[state] = 0x3f3f3f3f;
            Arrays.fill(cnt, 0);
            for (int i = 0; i < n; i++) {
                cnt[i] = state % g[i + 1] / g[i];
            }
            for (int i = 0; i < n; i++) {
                if (cnt[i] > 0) dp[state] = Math.min(dp[state], dp[state - g[i]] + price.get(i));
            }
            //知识点: break/continue只支持跳出当前一轮循环,out for可以跳出多重循环
            out:for (List<Integer> x : special) {
                int cur = state;
                for (int i = 0; i < n; i++) {
                    if (cnt[i] < x.get(i)) continue out;
                    cur -= x.get(i) * g[i];
                }
                dp[state] = Math.min(dp[state], dp[cur] + x.get(n));
            }
        }
        return dp[mask - 1];
    }
}

总结

文章分析了dp算法的通用分析过程,以后做暴力递归题目超时的时候,可以尝试根据这种思路,转换成记忆化搜索或者推导动态规划算法的状态转移方程进行dp算法求解。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值