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 } ]