动态规划、回溯、贪心算法解找零问题(最小张数付款问题)(JavaScript实现)

1,背景需求:

我们有n种不同面值的硬币,需要支付n元(整数),最少需要多少个硬币?

比如具体的:我们有 3 种不同面值的硬币,11 元、5 元、1 元(一定会有),我们要支付 15 元(整数),最少需要 多少个硬币?

最少要3 个硬币(3 个 5 元的硬币)。

2,动态规划实现

以下的实现没有问题,也可以对比着参看更精炼的实现:动态规划思想解决找零问题,最精炼的实现(JavaScript版)_yangxinxiang84的专栏-CSDN博客

这个问题应该是满适合用动态规划解的:多阶段决策求最优解

思路:

A),用硬币的面值划分阶段,每一个阶段(每种硬币值)都有两种情况:使用或者不使用该面值硬币。

B),每决策完一次之后,都会产生一组状态:本次决策用了多少个硬币、一共现在用了多少个硬币、每种状态下还剩余多少钱。

C),将各个阶段和状态用一个状态表记录下来,找到剩余钱为0的最总硬币数量。

难点:

问题看上去像是比较简单,但是这个状态记录表格如何组织呢?想了很久,感觉一直没找到很合适的状态记录表格。先按自己的方式实现一版(我一直还是感觉这个状态表有点怪怪的),欢迎大咖讨论。

1,以面值为行,以剩余钱数(从0开始的整数)为列生成状态记录表。

2,单元格中的值记录当前使用了多少个硬币的和,初始状态为负值(负几都可以)。

如图:

以下详细讲一下状态转移过程:

1,初始状态,创建一个表格:表头列值表示剩余待支付的钱数(为0的时候,表示没有剩余要付款了),行表示硬币面值。
单元格的中的值,表示到达该列(剩余钱)已经使用了的硬币数量。负值(-2,用-2是方便肉眼看)表示初始状态

2,面值11的硬币,有两个选择:使用,或者不使用。使用的话,最多能用1张,剩余4块要付。不用的话,剩余15块要付。决策之后,状态变为:

3,使用面值为5的硬币:基于上一个状态集合,推导下一个状态集合。上一次剩余的钱为4块,或者15块。使用第2个面追的硬币的时候,也是分别有两种抉择:使用或者不使用。 不使用该面值的时候,很简单,直接拷贝上一行状态就好。使用的时候,要注意,如果使用之后单元格中已经有状态数据(不是-2),要保留小的。使用面值2之后的状态为:

4,使用面值为1的硬币 ,逻辑跟上一个步骤一样,使用面值1之后的状态为:

5,此时状态表中的最后一行的第一个元素值 states[2][0]就是最优解的值。

有了上述分析之后,翻译成代码就简单了:

// 需要在单独的文件中运行

/**
 * 工具方法,创建一个表格(二维数组)
 * @param {*} rows 
 * @param {*} columns 
 * @param {*} defaultValue 
 * @returns 
 */
 function createTableArr(rows, columns, defaultValue =0){
    console.log(`createTableArr :: enter, rows = ${rows}, columns = ${columns}, defaultValue = ${defaultValue}`);
    const table = [];
    for(let i=0; i<rows; i++){
        const rowArr = new Array(columns)
        rowArr.fill(defaultValue)
        table.push(rowArr)
    }
    return table;
}


/**
 * 求解问题: 有最小面额为11, 5, 1的三种人民币,用最少的张数付款
 * @param { int[] } values , 人民币面值数组
 * @param {int} totalNeedGive , 需要付款数量
 * @returns {int} minCoinCount, 最少使用的钱的张数
 */

 function giveChangeDP(values, totalNeedGive){
     const defaultCellValue = -2; // 这里用任意一个负数都行,-2是未了方便看结果状态,因为-1和1太像了。
     const states = createTableArr(values.length, totalNeedGive+1, defaultCellValue); // totalNeedGive+1 是因为有一个0列
     // 特殊处理下第一行 
     states[0][totalNeedGive] = 0; // 不用该面值
     if(totalNeedGive >= values[0]){ // 使用该面值
        const currentSheet = Math.floor(totalNeedGive/values[0])
        const restNeed = totalNeedGive % values[0];
        states[0][restNeed] = currentSheet; // 状态是记录张数
     }

     for(let rowIndex = 1; rowIndex < values.length; rowIndex++ ){
        // 如果不用这个面值,直接拷贝上一行。注: columnIndex就是剩余要付款
        for(let columnIndex = 0; columnIndex <= totalNeedGive; columnIndex++){
          if(states[rowIndex-1][columnIndex] >=0 ){
            states[rowIndex][columnIndex]  = states[rowIndex-1][columnIndex]; // 如果不用这个面值,直接拷贝上一行
          }
        }
        // 如果用该面值的钱。注: columnIndex就是剩余要付款
        for(let columnIndex = 1; columnIndex <= totalNeedGive; columnIndex++){
          if(states[rowIndex-1][columnIndex] >=0 ){
            const currentSheet = Math.floor(columnIndex/values[rowIndex])
            const newRest = columnIndex % values[rowIndex];
            const total = states[rowIndex-1][columnIndex] + currentSheet // 新的总张数
            const state = states[rowIndex][newRest] // 该状态下原来人民币的张数
            if(state === defaultCellValue){ // 该单元格还没使用过
                states[rowIndex][newRest] = total; // 状态是记录张数
            }else{
                if(state > total){ // 如果该单元格已经有用过,保留小的
                    states[rowIndex][newRest] = total; // 状态是记录张数
                }
            }            
          }
        }        
     }
     const minCoinCount = states[states.length - 1][0];
     console.log(`giveChangeDP :: end, minCoinCount = ${minCoinCount}, states = `)
     // 如何知道哪种面值用了多少张呢?。。。这个倒推有点难啊
     console.log(states)
     return minCoinCount;
 }

 const values = [11, 5, 1]; // 加入认为已经排好序
 const totalNeedGive = 15;
giveChangeDP(values, totalNeedGive);

继续深入,上面计算出了最少张数,那最少张数的时候,每种面值的钱用多少张呢?如果从上面的推导反推,感觉好复杂。

那就顺着上面的思路解吧。在存状态的时候,将状态值从数量变为一个状态对象(如:{ sum: xx, route: [ { coinValue: xx, sheets: xx } ] })即可,存上当前阶段每种面值用了多少张,如状态表:

 修改上面的实现:


/**
 * 有最小面额为11, 5, 1的三种人民币,用最少的张数付款,返回最小张数和具体付款详情,每种面值用几张。
 * @param { int[] } values , 人民币面值数组
 * @param {int} totalNeedGive , 需要付款数量
 * @returns {Object} result, 最少使用的钱的张数和详细记录
 */
 function giveChangeDP2(values, totalNeedGive){
    const defaultCellValue = -2; // 这里用任意一个负数都行,-2是未了方便看结果状态,因为-1和1太像了。
    const states = createTableArr(values.length, totalNeedGive+1, defaultCellValue) // 需要自行拷贝上面的公共方法
    // 特殊处理下第一行 
    states[0][totalNeedGive] = {sum:0, route:[]}; // 不用该面值
    if(totalNeedGive >= values[0]){ // 使用该面值
       const currentSheet = Math.floor(totalNeedGive/values[0])
       const restNeed = totalNeedGive % values[0];
       states[0][restNeed] = {sum:currentSheet, route:[{coinValue: values[0], sheets: currentSheet}]} ; // 状态是记录张数
    }

    for(let rowIndex = 1; rowIndex < values.length; rowIndex++ ){
       // 如果不用这个面值,直接拷贝上一行。注: columnIndex就是剩余要付款
       for(let columnIndex = 0; columnIndex <= totalNeedGive; columnIndex++) {
         if(states[rowIndex-1][columnIndex] !== defaultCellValue ){
           states[rowIndex][columnIndex]  = states[rowIndex-1][columnIndex]; // 如果不用这个面值,直接拷贝上一行
           console.log(rowIndex, columnIndex, states[rowIndex][columnIndex])
         }
         
       }
       // 如果用该面值的钱。注: columnIndex就是剩余要付款
       for(let columnIndex = 1; columnIndex <= totalNeedGive; columnIndex++){
         if(states[rowIndex-1][columnIndex] !== defaultCellValue ){
           const currentSheet = Math.floor(columnIndex/values[rowIndex])
           const newRest = columnIndex % values[rowIndex];
           const total = states[rowIndex-1][columnIndex].sum + currentSheet // 新的总张数
           const state = states[rowIndex][newRest]
           const newRoute = [...states[rowIndex-1][columnIndex].route]
           newRoute.push({coinValue: values[rowIndex], sheets: currentSheet})

           if(state === defaultCellValue){ // 该状态下原来人民币的张数
               states[rowIndex][newRest] = {sum:total, route:newRoute}; // 状态是记录张数和路径
           }else{
               if(state.sum > total){
                   const newRoute = [...state.route];
                   newRoute.push({coinValue: values[rowIndex], sheets: currentSheet})
                   states[rowIndex][newRest] =  {sum:total, route:newRoute} ; // 状态是记录张数和路径
               }
           }            
         }
       }        
    }
    // console.log(states)
    const result = states[values.length-1][0];
    console.log(`giveChangeDP2 :: end, result = `)
    console.log(result)
    return result
}
giveChangeDP2( [11, 5, 1], 15);

3,基于回溯的实现

回溯的逻辑不复杂,就是递归,每次都有2个选择,用或者不用该面值。

/**
 * 用回溯算法暴力搜索最优解
 * @param { int[] } values , 人民币面值数组
 * @param {int} rest , 需要付款剩余钱数
 * @param {*} index, 当前使用的人民币面值所在数组索引
 * @param {*} sheets , 当前阶段使用了人民币的总张数
 * @param {*} routes , 详细过程,记录了到达当前阶段每种面值的钱,各用了多少张。
 * @returns 
 */
let minSheets = Infinity; // 记录最小找零张数
let minRoutes;
function giveChangeBT(values, rest, index=0, sheets=0, routes=[]){  
    if(rest ===0){
        console.log(`giveChangeBT :: end, rest = ${rest}, index = ${index}, sheets = ${sheets}`)
        console.log(routes)
        if(sheets >0 && minSheets > sheets){
            minSheets = sheets;
            minRoutes = routes;
        }
        return sheets;
    }
    // 可用币种用完了,该情况就不要了。
    if(index<values.length){
        // 不用该面值的人民币   
        const newRotes = [...routes] // 这一步很重要,详细信息要复制出来
        giveChangeBT(values, rest, index+1, sheets, newRotes)

        // 用该面值的人民币   
        const maxSheets = Math.floor(rest/values[index]);
        const newRest = rest%values[index];
        // 辅助看具体细节
        const route  = {
            [values[index]] : maxSheets,
        }
        newRotes.push(route);
        giveChangeBT(values, newRest, index+1, sheets+maxSheets,  newRotes)   
    }     
}


giveChangeBT( [11, 5, 1], 15)
 console.log(`call giveChangeBT end, rst = `, minSheets, minRoutes) // call giveChangeBT end, rst =  3 [ { '5': 3 } ]

4,贪心算法实现

简化逻辑,这里认为面值values数组已经是从大到小排好序的。贪心,就是每一步尝试用最大面值去付款。

/**
 * 贪心算法实现
 * @param { int[] } values , 人民币面值数组,这里认为是从大到小排好序的。
 * @param {int} rest , 需要付款剩余钱数
 * @param {*} index, 当前使用的人民币面值所在数组索引
 * @param {*} sheets , 当前阶段使用了人民币的总张数
 * @param {*} routes , 详细过程,记录了到达当前阶段每种面值的钱,各用了多少张。
 * @returns 
 */
function giveChangeTX(values, rest, index=0, sheets=0, routes=[]){
  if(rest ===0 || index >= values.length){
      console.log(`giveChangeTX :: end, rest = ${rest}, index = ${index}, sheets = ${sheets}`)
      console.log(routes)
      return sheets;
  }
  // 用贪心的思想,先找最大面额的,因为面额最大,需要的张数会“更少”
  const maxSheets = Math.floor(rest/values[index]);
  const newRest = rest%values[index];
  // 辅助看具体细节
  const route  = {
      [values[index]] : maxSheets,
  }
  const newRotes = [...routes, route];    
  return giveChangeTX(values, newRest, index+1, maxSheets+sheets, newRotes)
}

giveChangeTX([11, 5, 1], 15);

// 输出
// giveChangeTX :: end, rest = 0, index = 3, sheets = 5
// [ { '11': 1 }, { '5': 0 }, { '1': 4 } ]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值