关于程序员:什么样的问题应该使用动态规划

3次阅读

共计 8879 个字符,预计需要花费 23 分钟才能阅读完成。

说起动静布局,我不晓得你有没有这样的困扰,在把握了一些根底算法和数据结构之后,碰到一些较为简单的问题还是无从下手,面试时天然也是胆战心惊。如果我说动静布局是个玄幻的问题其实也不为过。究其原因,我感觉能够归因于这样两点:

  • 你对动静布局相干问题的套路和思维还没有齐全把握;
  • 你没有系统地总结过到底有哪些问题能够用动静布局解决。

知己知彼,你想把动静布局作为你的面试武器之一,就得足够理解它;而应答面试,总结、归类问题其实是个不错的抉择,这在咱们刷题的时候其实也能感觉得到。那么,咱们就针对以上两点,系统地谈一谈到底什么样的问题能够用动静布局来解。

一、动静布局是一种思维

动静布局算法,这种叫法我想你应该常常据说。嗯,从情理上讲这么叫我感觉也没错,首先动静布局它不是数据结构,这一点毋庸置疑,并且严格意义上来说它就是一种算法。但更加精确或者更加贴切的提法应该是说动静布局是一种思维。那算法和思维又有什么区别呢?

一般来说,咱们都会把算法和数据结构放一起来讲,这是因为它们之间密切相关,而算法也往往是在特定数据结构的根底之上对解题计划的一种谨严的总结。

比如说,在一个乱序数组的根底上进行排序,这里的数据结构指的是什么呢?很显然是数组,而算法则是所谓的排序。至于排序算法,你能够思考应用简略的冒泡排序或效率更高的疾速排序办法等等来解决问题。

没错,你应该也感觉到了,算法是一种简略的经验总结和套路。那什么是思维呢?相较于算法,思维更多的是领导你我来解决问题。

比如说,在解决一个简单问题的时候,咱们能够先将问题简化,先解决简略的问题,再解决难的问题,那么这就是一种领导解决问题的思维。另外,咱们常说的分治也是一种简略的思维,当然它在诸如归并排序或递归算法当中会经常被提及。

而动静布局就是这样一个领导咱们解决问题的思维:你须要利用曾经计算好的后果来推导你的计算,即大规模问题的后果是由小规模问题的后果运算得来的

总结一下:算法是一种经验总结,而思维则是用来领导咱们解决问题的。既然动静布局是一种思维,那它实际上就是一个比拟形象的概念了,也很难和理论的问题关联起来。所以说,弄清楚什么样的问题能够应用动静布局来解就显得非常重要了。

二、动静布局问题的特点

动静布局作为运筹学上的一种最优化解题办法,在算法问题上曾经失去广泛应用。接下来咱们就来看一下动归问题所具备的一些特点。

2.1 最优解问题

除非你碰到的问题是简略到找出一个数组中最大的值这样,对这种问题来说,你能够对数组进行排序,而后取数组头或尾部的元素,如果感觉麻烦,你也能够间接遍历失去最值。不然的话,你就得思考应用动静布局来解决这个问题了。这样的问题个别都会让你求最大子数组、求最长递增子数组、求最长递增子序列或求最长公共子串、子序列等等。

如果碰到求最值问题,咱们能够应用上面的套路来解决问题:

  • 优先思考应用贪婪算法的可能性;
  • 而后是暴力递归进行穷举,针对数据规模不大的状况;
  • 如果下面两种都不适宜,那么再抉择动静布局。

能够看到,求解动静布局的外围问题其实就是 穷举。当然了,动静布局问题也不会这么简略了事,咱们还须要思考待解决的问题是否存在重叠子问题、最优子结构等个性。

分明了动静布局算法的特点,接下来咱们就来看一下哪些问题适宜用动静布局思维来解题。

1. 乘积最大子数组

给你一个整数数组 numbers,找出数组中乘积最大的间断子数组(该子数组中至多蕴含一个数字),返回该子数组的乘积。


示例 1:输出: [2,7,-2,4]
输入: 14
解释: 子数组 [2,7] 有最大乘积 14。示例 2:输出: [-5,0,3,-1]
输入: 3
解释: 后果不能为 15, 因为 [-5,3,-1] 不是子数组,是子序列。

首先,很显著这个题目当中蕴含一个“最”字,应用动静布局求解的概率就很大。这个问题的目标就是从数组中寻找一个最大的间断区间,确保这个区间的乘积最大。因为每个间断区间能够划分成两个更小的间断区间,而且大的间断区间的后果是两个小间断区间的乘积,因而这个问题还是求解满足条件的最大值,同样能够进行问题合成,而且属于求最值问题。同时,这个问题与求最大间断子序列和比拟类似,惟一的区别就是你须要在这个问题里思考正负号的问题,其它就雷同了。

对应实现代码:

class Solution {
public:
    int maxProduct(vector<int>& nums) {if(nums.empty()) return 0;

        int curMax = nums[0];
        int curMin = nums[0];
        int maxPro = nums[0];
        for(int i=1; i<nums.size(); i++){
            int temp = curMax;    // 因为 curMax 在下一行可能会被更新,所以保留下来
            curMax = max(max(curMax*nums[i], nums[i]), curMin*nums[i]);
            curMin = min(min(curMin*nums[i], nums[i]), temp*nums[i]);
            maxPro = max(curMax, maxPro);
        }
        return maxPro;
    }
};

2. 最长回文子串

问题:给定一个字符串 s,找到 s 中最长的回文子串。你能够假如 s 的最大长度为 1000。

示例 1:输出: "babad"
输入: "bab"


示例 2:输出: "cbbd"
输入: "bb"

【回文串】是一个正读和反读都一样的字符串,比方“level”或者“noon”等等就是回文串。这个问题仍然蕴含一个“最”字,同样因为求解的最长回文子串必定蕴含一个更短的回文子串,因而咱们仍然能够应用动静布局来求解这个问题。

对应实现代码:

class Solution {public boolean isPalindrome(String s, int b, int e){// 判断 s[b...e]是否为回文字符串
        int i = b, j = e;
        while(i <= j){if(s.charAt(i) != s.charAt(j)) return false;
            ++i;
            --j;
        }
        return true;
    }
    public String longestPalindrome(String s) {if(s.length() <=1){return s;}
        int l = 1, j = 0, ll = 1;
        for(int i = 1; i < s.length(); ++i){
             // 上面这个 if 语句就是用来维持循环不变式,即 ll 恒示意:以第 i 个字符为尾的最长回文子串的长度
             if(i - 1 - ll >= 0 && s.charAt(i) == s.charAt(i-1-ll)) ll += 2;
             else{while(true){// 从新确定以 i 为边界,最长的回文字串长度。确认范畴为从 ll+ 1 到 1
                     if(ll == 0||isPalindrome(s,i-ll,i)){
                         ++ll;
                         break;
                     }
                     --ll;
                 }
             }
             if(ll > l){// 更新最长回文子串信息
                l = ll;
                j = i;
            }
        }
        return s.substring(j-l+1, j+1);// 返回从 j -l+ 1 到 j 长度为 l 的子串
    }
}

3. 最长回升子序列

问题:给定一个无序的整数数组,找到其中最长回升子序列的长度。可能会有多种最长回升子序列的组合,你只须要输入对应的长度即可。

示例:输出: [10,9,2,5,3,7,66,18]
输入: 4
解释: 最长的回升子序列是 [2,3,7,66],它的长度是 4。

这个问题仍然是一个最优解问题,假如咱们要求一个长度为 5 的字符串中的回升自序列,咱们只须要晓得长度为 4 的字符串最长回升子序列是多长,就能够依据剩下的数字确定最初的后果。
对应实现代码:

class Solution {public int lengthOfLIS(int[] nums) {if(nums.length == 0) return 0;
        int[] dp = new int[nums.length];
        int res = 0;
        Arrays.fill(dp, 1);
        for(int i = 0; i < nums.length; i++) {for(int j = 0; j < i; j++) {if(nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
            }
            res = Math.max(res, dp[i]);
        }
        return res;
    }
}

2.2 求可行性

如果有这样一个问题,让你判断是否存在一条总和为 x 的门路(如果找到了,就是 True;如果找不到,天然就是 False),或者让你判断是否找到一条合乎某种条件的门路,那么这类问题都能够演绎为求可行性问题,并且能够应用动静布局来解。

1. 凑零兑换问题

问题:给你 k 种面值的硬币,面值别离为 c1, c2 … ck,每种硬币的数量有限,再给一个总金额 amount,问你起码须要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1。

示例 1:输出: c1=1, c2=2, c3=5, c4=7, amount = 15
输入: 3
解释: 11 = 7 + 7 + 1。示例 2:输出: c1=3, amount =7
输入: -1
解释: 3 怎么也凑不到 7 这个值。

这个问题不言而喻,如果不可能凑出咱们须要的金额(即 amount),最初算法须要返回 -1,否则输入可能的硬币数量。这是一个典型的求可行性的动静布局问题。

对于示例代码:

class Solution {public int coinChange(int[] coins, int amount) {if(coins.length == 0)
            return -1;
        // 申明一个 amount+ 1 长度的数组 dp,代表各个价值的钱包,第 0 个钱包能够包容的总价值为 0,其它全副初始化为无穷大
        //dp[j]代表当钱包的总价值为 j 时,所须要的起码硬币的个数
        int[] dp = new int[amount+1];
        Arrays.fill(dp,1,dp.length,Integer.MAX_VALUE);
        for (int coin : coins) {for (int j = coin; j <= amount; j++) {if(dp[j-coin] != Integer.MAX_VALUE) {dp[j] = Math.min(dp[j], dp[j-coin]+1);
                }
            }
        }
        if(dp[amount] != Integer.MAX_VALUE)
            return dp[amount];
        return -1;
    }
}

2. 字符串交织组成问题

问题:给定三个字符串 s1, s2, s3, 验证 s3 是否是由 s1 和 s2 交织组成的。

示例 1:输出: s1="aabcc",s2 ="dbbca",s3="aadbbcbcac"
输入: true
解释: 能够交织组成。示例 2:输出: s1="aabcc",s2="dbbca",s3="aadbbbaccc"
输入: false
解释: 无奈交织组成。

这个问题略微有点简单,然而咱们仍然能够通过子问题的视角,首先求解 s1 中某个长度的子字符串是否由 s2 和 s3 的子字符串交织组成,直到求解整个 s1 的长度为止,也能够看成一个蕴含子问题的最值问题。
对应示例代码:

class Solution {public boolean isInterleave(String s1, String s2, String s3) {int length = s3.length();
        // 非凡状况解决
        if(s1.isEmpty() && s2.isEmpty() && s3.isEmpty()) return true;
        if(s1.isEmpty()) return s2.equals(s3);
        if(s2.isEmpty()) return s1.equals(s3);
        if(s1.length() + s2.length() != length) return false;
 
        int[][] dp = new int[s2.length()+1][s1.length()+1];
        // 边界赋值
        for(int i = 1;i < s1.length()+1;i++){if(s1.substring(0,i).equals(s3.substring(0,i))){dp[0][i] = 1;
            }
        }
        for(int i = 1;i < s2.length()+1;i++){if(s2.substring(0,i).equals(s3.substring(0,i))){dp[i][0] = 1;
            }
        }
        
        for(int i = 2;i <= length;i++){
            // 遍历 i 的所有组成(边界除外)for(int j = 1;j < i;j++){
                // 避免越界
                if(s1.length() >= j && i-j <= s2.length()){if(s1.charAt(j-1) == s3.charAt(i-1) && dp[i-j][j-1] == 1){dp[i-j][j] = 1;
                    }
                }
                // 避免越界
                if(s2.length() >= j && i-j <= s1.length()){if(s2.charAt(j-1) == s3.charAt(i-1) && dp[j-1][i-j] == 1){dp[j][i-j] = 1;
                    }
                }
            }
        }
        return dp[s2.length()][s1.length()]==1;
    }
}

2.3 求总数

除了求最值与可行性之外,求计划总数也是比拟常见的一类动静布局问题。比如说给定一个数据结构和限定条件,让你计算出一个计划的所有可能的门路,那么这种问题就属于求计划总数的问题。

1. 硬币组合问题

问题:英国的英镑硬币有 1p, 2p, 5p, 10p, 20p, 50p, £1 (100p), 和 £2 (200p)。比方咱们能够用以下形式来组成 2 英镑:1×£1 + 1×50p + 2×20p + 1×5p + 1×2p + 3×1p。问题是一共有多少种形式能够组成 n 英镑? 留神不能有反复,比方 1 英镑 +2 个 50P 和 50P+50P+1 英镑是一样的。

示例 1:输出: 2
输入: 73682 

这个问题实质还是求满足条件的组合,只不过这里不需要求出具体的值或者说组合,只须要计算出组合的数量即可。

public class Main {public static void main(String[] args) throws Exception {Scanner sc = new Scanner(System.in);
        while (sc.hasNext()) {int n = sc.nextInt();
            int coin[] = { 1, 5, 10, 20, 50, 100};
            
            // dp[i][j]示意用前 i 种硬币凑成 j 元的组合数
            long[][] dp = new long[7][n + 1];
            
            for (int i = 1; i <= n; i++) {dp[0][i] = 0; // 用 0 种硬币凑成 i 元的组合数为 0
            }
            
            for (int i = 0; i <= 6; i++) {dp[i][0] = 1; // 用 i 种硬币凑成 0 元的组合数为 1, 所有硬币均为 0 个即可
            }
            
            for (int i = 1; i <= 6; i++) {for (int j = 1; j <= n; j++) {dp[i][j] = 0;
                    for (int k = 0; k <= j / coin[i - 1]; k++) {dp[i][j] += dp[i - 1][j - k * coin[i - 1]];
                    }
                }
            }
            
            System.out.print(dp[6][n]);
        }
        sc.close();}
}

2. 门路布局问题

问题:一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下或者向右挪动一步。机器人试图达到网格的右下角,共有多少门路?

示例 1:输出: 2 2
输入: 2


示例 1:输出: 3 3
输入: 6

这个问题还是一个求满足条件的组合数量的问题,只不过这里的组合变成了门路的组合。咱们能够先求出长宽更小的网格中的所有门路,而后再在一个更大的网格内求解更多的组合。这和硬币组合的问题相比没有什么本质区别。

这里有一个法则或者说景象须要强调,那就是求计划总数的动静布局问题个别都指的是求“一个”计划的所有具体模式。如果是求“所有”计划的具体模式,那这种必定不是动静布局问题,而是应用传统递归来遍历出所有计划的具体模式。

为什么这么说呢?因为你须要把所有状况枚举进去,大多状况下基本就没有重叠子问题给你优化。即使有,你也只能应用备忘录对遍历进行一个简略减速。但实质上,这类问题不是动静布局问题。

对应示例代码:

package com.qst.Tesst;

import java.util.Scanner;

public class Test12 {public static void main(String[] args) {Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {int x = scanner.nextInt();
            int y = scanner.nextInt();

            // 设置门路
            long[][] path = new long[x + 1][y + 1];
            // 设置领导数量
            int n = scanner.nextInt();

            // 领导地位
            for (int i = 0; i < n; i++) {int a = scanner.nextInt();
                int b = scanner.nextInt();
                path[a][b] = -1;
            }

            for (int i = 0; i <= x; i++) {path[i][0] = 1;
            }
            for (int j = 0; j <= y; j++) {path[0][j] = 1;
            }

            for (int i = 1; i <= x; i++) {for (int j = 1; j <= y; j++) {if (path[i][j] == -1) {path[i][j] = 0;
                    } else {path[i][j] = path[i - 1][j] + path[i][j - 1];
                    }

                }

            }
            System.out.println(path[x][y]);
        }
    }
}

三、如何确认动静布局问题

从后面我所说来看,如果你碰到了求最值、求可行性或者是求计划总数的问题的话,那么这个问题就八九不离十了,你根本能够确定它就须要应用动静布局来解。然而,也有一些个别情况须要留神:

3.1 数据不可排序

假如咱们有一个无序数列,心愿求出这个数列中最大的两个数字之和。很多初学者刚刚学完动静布局会走火入魔到看到最优化问题就想用动静布局来求解,事实上,这个问题不是简略做一个排序或者做一个遍历就能够求解进去的。对于这种问题,咱们应该先考虑一下能不能通过排序来简化问题,如果不能,才极有可能是动静布局问题。

最小的 k 个数

问题:输出整数数组 arr,找出其中最小的 k 个数。例如,输出 4、5、1、6、2、7、3、8 这 8 个数字,则最小的 4 个数字是 1、2、3、4。

示例 1:输出:arr = [3,2,1], k = 2
输入:[1,2] 或者 [2,1]


示例 2:输出:arr = [0,1,2,1], k = 1
输入:[0]

咱们发现尽管这个问题也是求“最”值,但其实只有通过排序就能解决,所以咱们应该用排序、堆等算法或者数据结构就能够解决,而不应该用动静布局。

对应的示例代码:

public class Solution {public ArrayList<Integer> GetLeastNumbers_Solution(int [] input, int k) {
                int t;
        boolean flag;
        ArrayList result = new ArrayList();
        if(k>input.length){return result;}
        for(int i =0;i<input.length;i++){
            flag = true;
            for(int j = 0; j < input.length-i;j++)
                if(j<input.length-i-1){if(input[j] > input[j+1]) {t = input[j];
                        input[j] = input[j+1];
                        input[j+1] = t;
                        flag = false;
                    }
                }
            if(flag)break;
        }
        for(int i = 0; i < k;i++){result.add(input[i]);
        }
        return  result;
    }
}

3.2 数据不可替换

还有一类问题,能够归类到咱们总结的几类问题里去,然而不存在动静布局要求的重叠子问题(比方经典的八皇后问题),那么这类问题就无奈通过动静布局求解。

全排列

问题:给定一个没有反复数字的序列,返回其所有可能的全排列。


示例:输出: [1,2,3]
输入:
[[1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]

这个问题尽管是求组合,但没有重叠子问题,更不存在最优化的要求,因而能够应用回溯办法解决。

对应的示例代码:

public class Main {public static void main(String[] args) {perm(new int[]{1,2,3},new Stack<>());
    }
    public static void perm(int[] array, Stack<Integer> stack) {if(array.length <= 0) {
            // 进入了叶子节点,输入栈中内容
            System.out.println(stack);
        } else {for (int i = 0; i < array.length; i++) {
                //tmepArray 是一个长期数组,用于就是 Ri
                //eg:1,2,3 的全排列,先取出 1,那么这时 tempArray 中就是 2,3
                int[] tempArray = new int[array.length-1];
                System.arraycopy(array,0,tempArray,0,i);
                System.arraycopy(array,i+1,tempArray,i,array.length-i-1);
                stack.push(array[i]);
                perm(tempArray,stack);
                stack.pop();}
        }
    }
}

总结一下,哪些问题能够应用动静布局呢,通常含有上面状况的个别都能够应用动静布局来解决:

  • 求最优解问题(最大值和最小值);
  • 求可行性(True 或 False);
  • 求计划总数;
  • 数据结构不可排序(Unsortable);
  • 算法不可应用替换(Non-swappable)。

如果面试题目呈现这些特色,那么在 90% 的状况下你都能断言它就是一个动归问题。除此之外,还须要思考这个问题是否蕴含重叠子问题与最优子结构,在这个根底之上你就能够 99% 断言它是否为动归问题,并且也趁势找到了大抵的解题思路。

正文完
 0