【数据结构与算法】动态规划初步

232 阅读3分钟

动态规划,简称DP(Dynamic Programming),是求解最优化问题的一种常用策略,如找零钱,硬币个数最少,最大连续子序列和等。

通常的使用套路(一步一步优化)

1.暴力递归(自顶向下,出现了重叠 子问题)

2.记忆化搜索(自顶向下,缓存)

3.递推(自底向上)

举例:

假设有25分、20分、5分、1分的硬币,现要找给客户41分的零钱,如何办到硬币个数最少?

如果用贪心策略,每次都取最大的硬币,将会得到1枚25分、3枚5分、1枚1分,共5枚,而实际上最优解只需要2枚20分和1枚1分,共3枚,因此贪心策略得到的往往不是最优解。用动态规划则能得到最优解,动态规划很适合解决这种最优化的问题。

假设dp(41) = 凑到41分需要的最少硬币个数

dp(n) = 凑到n分需要的最少硬币个数。

如果第1次选择了25分的硬币,那么dp(n)=dp(n-25)+1

如果第1次选择了20分的硬币,那么dp(n)=dp(n-20)+1

如果第1次选择了5分的硬币,那么dp(n)=dp(n-5)+1

如果第1次选择了1分的硬币,那么dp(n)=dp(n-1)+1

4种情况都有可能,取一个最小的,所以dp(n) = min{dp(n-25),dp(n-20),dp(n-5),dp(n-1)}+1

动态规划考虑了所有的情况,所以能求最优解,贪心只看眼前的局部利益,这就是贪心的问题所在。

1.暴力递归

function coins(n){
        if(n < 1) return Number.MAX_VALUE;
        if(n == 25 || n == 20 || n == 5 || n == 1) return 1;
        return Math.min(coins(n - 25), coins(n - 20), coins(n - 5), coins(n - 1)) + 1; 
        
}

类似于斐波那契数列的递归版,会有大量的重复计算,时间复杂度较高。

2.记忆化搜索

function coins(n) {           
 //dp[n]代表凑够n分需要的最少硬币个数            
  if (n < 1) return -1;//排除不合理的情况           
     let dp = new Array(n + 1).fill(0);//长度是n+1,才能放到dp[n],初始化均为0          
  // dp[25] = dp[20] = dp[5] = dp[1] = 1; 这样写不好,n可能是小于20的,dp[20]会越界在其他语言中。            
     let faces = [1, 5, 20, 25];            
     for (let i = 0; i < faces.length; i++) {//这样写更安全,不越界  
              
        if (n < faces[i]) break;                
        dp[faces[i]] = 1;           
      }           
     return change(n, dp)           
 function change(n, dp) {               
     if (n < 1) return Number.MAX_VALUE;//为递归基服务               
     if (dp[n] == 0) { // 没有缓存过,需要计算                   
     let min1 = Math.min(change(n - 25, dp), change(n - 20, dp));                   
     let min2 = Math.min(change(n - 5, dp), change(n - 1, dp));                   
     dp[n] = Math.min(min1, min2) + 1;                
     }               
     return dp[n];   //缓存过,直接取缓存的数据               
  }       
}

记忆化搜索,虽节省了重复计算,但还是有一定的栈空间开销。

3.递推

function coins(n) {          
   if (n < 1) return -1;         
   let dp = new Array(n + 1).fill(0);          
   for (let i = 1; i <= n; i++) {               
       let min = dp[i - 1];            
       if (i >= 5) min = Math.min(dp[i - 5], min);          
       if (i >= 20) min = Math.min(dp[i - 20], min);         
       if (i >= 25) min = Math.min(dp[i - 25], min);          
       dp[i] = min + 1;//巧妙地利用数组初始化值0,刚好实现i = 1,5,20,25时是1    
    }         
   return dp[n]; 
}

时间复杂度、空间复杂度O(n)。

思考题:求出找零钱的具体方案,即都有哪些硬币。

function coins(n) {       
     if (n < 1) return -1;         
     //dp[n]代表凑够n分需要的最少硬币个数     
     let dp = new Array(n + 1).fill(0);       
     // faces[n] 代表凑够n分钱的最后选择的那枚硬币的面值     
     let faces = new Array(n + 1).fill(0)        
     for (let i = 1; i <= n; i++) {         
       let min = Number.MAX_VALUE;           
       if (i >= 1 && dp[i - 1] < min) {          
          min = dp[i - 1];               
          faces[i] = 1;            
        }             
     if (i >= 5 && dp[i - 5] < min) {           
         min = dp[i - 5];                  
         faces[i] = 5;              
      }              
      if (i >= 20 && dp[i - 20] < min) {    
         min = dp[i - 20];           
         faces[i] = 20;            
       }             
      if (i >= 25 && dp[i - 25] < min) {        
         min = dp[i - 25];              
      // 能来这里,说明最少硬币数,选择的最后一枚是25            
        faces[i] = 25;            
      }            
       printfc(faces, i);      
       dp[i] = min + 1;        
    }          
  // printfc(faces, n);     
     return dp[n]
}
function printfc(faces, n) {        
    let temp = n, str = '';      
      while (n > 0) {           
     str += ' ' + faces[n];         
       n -= faces[n]         
   }         
   str = "[" + temp + "] = " + str;     
       console.log(str)


}

通用方案,可以自定义硬币面值传参。

function coins(n, faces = [1, 5, 20, 25]) { 
       if (n < 1) return -1;    
       let dp = new Array(n + 1).fill(0);     
       let fc = new Array(n + 1).fill(0);     
       for (let i = 1; i <= n; i++) {         
        let min = Number.MAX_VALUE;        
        for (let j = 0; j < faces.length; j++) {        
            if (i >= faces[j] && dp[i - faces[j]] < min) {          
              min = dp[i - faces[j]]                   
              fc[i] = faces[j]                  
             }               
         }             
         dp[i] = min + 1;     
           // printfc(fc, i)         
       }          
       printfc(fc, n)     
       return dp[n];     
}

function printfc(faces, n) {       
    let temp = n, str = '';        
    while (n > 0) {           
       str += ' ' + faces[n];      
       n -= faces[n]        
    }          
    str = "[" + temp + "] = " + str;     
    console.log(str)
}

通过解这道题,刷新了我对数组的认知,原来数组的索引下标不只是代表顺序,还可以有业务含义,数组的遍历不只是按+1或-1的顺序遍历,还可以根据业务需要跳着遍历。

趁热打铁,做下力扣上的找硬币题。

leetcode-cn.com/problems/co…

var coinChange = function(coins, amount) {  
      if (amount == 0) return 0;      
      let dp = new Array(amount + 1).fill(0);     
      for (let i = 1; i <= amount; i++) {   
           let min = Number.MAX_VALUE;     
           for (let j = 0; j < coins.length; j++) {      
              if (i >= coins[j] && dp[i - coins[j]] >= 0) {     
                   min = Math.min(dp[i - coins[j]], min)        
              }            
           }             
       if (min == Number.MAX_VALUE) { // 如果没有任何一种硬币组合能组成总金额,返回 -1。   
          dp[i] = -1              
       }else {// 能凑到,则正常返回              
          dp[i] = min + 1             
       }            
      }            
    return dp[amount]
};