算法Balabala凑零钱问题现实版

2020-05-03  本文已影响0人  波澜步惊

前言

人脑和电脑的区别,是人脑会有逻辑思维,知道用方便快捷省时省力的方式去解决问题,但是电脑做不到,它只能按照人类所想去尽可能的穷举所有的情况,对比之后得出最优解。我们利用计算机程序算法去解决问题,要利用电脑的高效率穷举运算,还要利用人脑的优势,让计算机按照我们的想法去走捷径,执行最优算法,达到最快,最省。

承接上文

上文就凑零钱问题,在零钱无限多的情况下,用"动态规划"给出了最优解算法。 本文代码扔采用简洁优美的kotlin,可读性高。

计算出零钱的组合

理想版凑零钱算法只是告诉我们如何得出最小的零钱数目,但是并没有告诉我们零钱是如何组成的。

那么下一个目标,我不仅仅想知道零钱需要多少张,我还要知道零钱的组合方式

比如:18块钱总额,打散成最少数目的零钱,需要3张5块的,加上一张1块, 1张2块的,一共5张零钱。

package com.example.myapplication

val change = arrayOf(1,2,5) // 零钱就是1,2,5, 零钱必须是有序的

fun changeMoney(lastTarget: Int): List<Int>? {
    change.sortDescending()// 我需要从大到小来遍历,所以必须先排序
    // 所以做法就是,从1开始往后推演,从树根往树顶推算
    val hashMap = HashMap<Int, List<Int>>()
    for (currentTarget in 1..lastTarget) {
        getMin(currentTarget, hashMap)
    }
    return if (hashMap[lastTarget]?.sum() == lastTarget) hashMap[lastTarget] else null
}

fun getMin(currentTarget: Int, map: HashMap<Int, List<Int>>): List<Int> {
    if (map.containsKey(currentTarget) && map[currentTarget]?.isNotEmpty()!!) {
        return map[currentTarget] ?: arrayListOf()
    }

    loop@ for (c in change) {
        return when {
            currentTarget - c == 0 -> {
                val list = arrayListOf(c)
                map[currentTarget] = list // 目标金额等于当前零钱,返回 1,只需要1张零钱就行
                list
            }
            currentTarget - c > 0 -> {
                val min = getMin(currentTarget - c, map)// 目标金额大于当前零钱,说明需要当前零钱一张之后,剩下的钱要进入下一轮循环

                // 建立一个可变数组,先保存下一轮循环的结果,然后创建不可变数组赋值给总map
                val temp = mutableListOf<Int>()
                temp.addAll(min)
                temp.add(c)

                map[currentTarget] = temp// 保存起来
                min
            }
            else -> {
                continue@loop
            }
        }
    }
    return arrayListOf()
}

/**
 * 我们来把最终结果打印得好看一点, 这个不属于算法, 只是为了打印好看
 */
fun printRes(changeMoney3: List<Int>?) {
    if (changeMoney3 == null) {
        println("================无法凑成目标金额=================")
        return
    }
    // 太难看了,相同的金额应该要累计起来,最后的展现形式应该是 5*21 , 2*2
    val beautifulMap = HashMap<Int, Int>()
    for (c in changeMoney3!!) {// c是当前金额
        if (beautifulMap.keys.contains(c)) {
            beautifulMap[c] = beautifulMap[c]!!.plus(1)
        } else {
            beautifulMap[c] = 1
        }
    }

    println("================需要=================")
    // 遍历map
    for (i in beautifulMap) {
        println("${i.key}元零钱: ${i.value}张")
    }
}

fun main() {
    val targetAmount = 18
    println("目标金额是 :$targetAmount")
    val start = System.currentTimeMillis()
    val changeMoney3 = changeMoney(targetAmount)
    printRes(changeMoney3)
}

运行结果:

目标金额是 :18
================需要=================
1元零钱: 1张
2元零钱: 1张
5元零钱: 3张

时间复杂度

所以时间复杂度 : O(2n)

空间复杂度

空间复杂度 : O(n)

零钱数量有限时求零钱组合

其实还可以再扩展,上面的凑零钱,都是在零钱无限多的情况下。但是,现实中零钱不可能无限多。假如:

1元零钱只有23张,2元只有12张,5元只有11张。那么,我要凑成78块钱,最少需要多少张零钱,我要凑成87块钱,需要多少张零钱。

新增难点

之前零钱无限多的时候,由于是1元的存在,任何金额都可以凑出来,但是当零钱有限的时候,还能凑出来么???

不一定了. 比如1元的是0张,只有2元的,5元的也是0张,让你凑11元总额,是凑不出来的。所以,现实凑零钱问题,还必须考虑哪些情况下凑不出目标金额。

解法如下:

package com.example.myapplication

import java.util.Comparator

/**
 * 这里表示,1元的有11张,2元的有22张,5元的有11张
 */
var changeCountMap = mapOf<Int, Int>(Pair(1, 11), Pair(2, 22), Pair(5, 11))

// ******************************** 核心函数 *******************************************
fun changeMoney(lastTarget: Int): List<Int>? {
    changeCountMap =// 按照key从大到小的顺序排序
        changeCountMap.toSortedMap(Comparator<Int> { o1, o2 -> o2!! - o1!! })

    // 判断零钱可以提供的总额最大是多少
    var max = 0
    for (key in changeCountMap.keys) {
        max += key * changeCountMap.getValue(key)
    }
    println("可以凑成的最大金额是:$max")
    if (lastTarget > max) {
        return null
    }

    val hashMap = HashMap<Int, List<Int>>()
    for (currentTarget in 1..lastTarget) {
        getMin(currentTarget, hashMap)
    }
    return if (hashMap[lastTarget]?.sum() == lastTarget) hashMap[lastTarget] else null
}


fun getMin(currentTarget: Int, map: HashMap<Int, List<Int>>): List<Int> {
    if (map.containsKey(currentTarget) && map[currentTarget]?.isNotEmpty()!!) {
        return map[currentTarget]!!
    }
    loop@ for (c in changeCountMap.keys) {
        val max = changeCountMap.getValue(c)
        return when {
            currentTarget - c == 0 -> {
                val list = listOf(c)
                map[currentTarget] = list
                // 上面是假设零钱无限多,但是如果要考虑零钱数量优先
                if (getCount(c, map) <= max && max > 0) {// 数量满足,照旧
                    list
                } else {// 数量不满足
                    map[currentTarget] = listOf()// 回退
                    continue@loop
                }
            }
            currentTarget - c > 0 -> {
                val min = getMin(currentTarget - c, map)
                val temp = mutableListOf<Int>()
                temp.addAll(min)
                temp.add(c)
                map[currentTarget] = temp// 保存起来
                if (getCount(c, map) <= max && max > 0) {// 数量满足,照旧
                    temp
                } else {// 数量不满足
                    map[currentTarget] = listOf()//要回退
                    continue@loop
                }
            }
            else -> {
                continue@loop
            }
        }
    }
    return listOf()
}

// ******************************** 辅助函数 *******************************************
/**
 * 要做一个函数,计算出当前结果List<Int>中有,指定的金额有多少张,这样才能做出张数限制
 */
fun getCount(changeAmount: Int, map: HashMap<Int, List<Int>>): Int {
    val res = map[map.keys.max()]// 拿到最大的key
    var count = 0
    if (res == null || res.isEmpty()) return 0 // 最大的key没有value,直接返回0
    for (i in res) {// 否则,针对指定金额进行遍历累加计算
        if (i == changeAmount) {
            count++
        }
    }
    return count
}

/**
 * 我们来把最终结果打印得好看一点, 这个不属于算法, 只是为了打印好看
 */
fun printRes(changeMoney3: List<Int>?) {
    if (changeMoney3 == null || changeMoney3.isEmpty()) {
        println("================无法凑成目标金额=================")
        return
    }
    // 太难看了,相同的金额应该要累计起来,最后的展现形式应该是 5*21 , 2*2
    val beautifulMap = HashMap<Int, Int>()
    for (c in changeMoney3) {// c是当前金额
        if (beautifulMap.keys.contains(c)) {
            beautifulMap[c] = beautifulMap[c]!!.plus(1)
        } else {
            beautifulMap[c] = 1
        }
    }

    println("================需要=================")
    // 遍历map
    for (i in beautifulMap) {
        println("${i.key}元零钱: ${i.value}张")
    }
}

fun main() {
    val targetAmount = 87
    println("目标金额是 :$targetAmount")
    val start = System.currentTimeMillis()
    val changeMoney3 = changeMoney(targetAmount)
    println("计算耗时:${System.currentTimeMillis() - start}ms")
    printRes(changeMoney3)
}

运行结果:

目标金额是 :87
可以凑成的最大金额是:110
计算耗时:14ms
================需要=================
2元零钱: 16张
5元零钱: 11张

时间复杂度空间复杂度:

时间复杂度 O(2n), 空间复杂度:O(n)

有没有更简单的办法?

凑零钱问题,必须要动态规划法吗?其实也不一定,其实还有更简单的算法 !没错,整数取余法,说人话:

给你107的目标金额,零钱有1元,2元,5元,10元,数目不限。要追求最少的零钱数目。

那我肯定先用最大额的零钱来凑整,凑到它不能满足余下金额之后,再用较小钞票来做循环。直到最小金额。

换成代码:

// 其实解决此类问题还有一个整除取余法
val changes = arrayOf(1, 2, 5, 10) 
fun changeMoney2(target: Int): Map<Int, Int> {
    changes.sortDescending()// 从大到小排
    var temp = target
    val map = HashMap<Int, Int>()
    for (c in changes) {
        val v1 = temp / c // 整除的值, 表示当前钞票的张数
        map[c] = v1
        temp %= c // 取余的值,表示当前钞票过大之后造成的余数
    }
    return map
}

fun readMap(map: Map<Int, Int>) {
    println("================需要=================")
    // 遍历map
    for (i in map) {
        println("${i.key}元零钱: ${i.value}张")
    }
}

fun main() {
    val target = 107
    println("目标金额:$target")
    val changeMoney2 = changeMoney2(target)
    readMap(changeMoney2)
}

运行结果:

目标金额:107
================需要=================
1元零钱: 0张
2元零钱: 1张
5元零钱: 1张
10元零钱: 10张

时间复杂度

所以时间复杂度:O(1)

空间复杂度:

空间复杂度也是: O(1).

就算考虑零钱有限的情况,整除取余法也有解:

// 其实解决此类问题还有一个整除取余法
var changeCountMap = mapOf(Pair(1, 12), Pair(2, 13), Pair(5, 21))

fun changeMoney2(target: Int): Map<Int, Int>? {

    // 依然要判定能够凑出的最大金额
    // 判断零钱可以提供的总额最大是多少
    var max = 0
    for (key in changeCountMap.keys) {
        max += key * changeCountMap.getValue(key)
    }
    println("可以凑成的最大金额是:$max")
    if (target > max) {
        return null
    }

    changeCountMap =// 按照key从大到小的顺序排序
        changeCountMap.toSortedMap(Comparator<Int> { o1, o2 -> o2!! - o1!! })
    var temp = target
    val map = HashMap<Int, Int>()
    for (c in changeCountMap.keys) {
        val v1 = temp / c // 整除的值, 表示当前钞票的张数
        val max = changeCountMap.getValue(c)
        if (v1 > max) {
            map[c] = max // 当前最大值先用完
            temp -= max * c // 剩下的进入下一轮
        } else {
            map[c] = v1
            temp %= c // 取余的值,表示当前钞票过大之后造成的余数
        }

    }

    // 计算出总金额
    var total = 0
    for (key in map.keys) {
        total += key * map[key]!!
    }

    return if (total == target) map else null
}

fun readMap(map: Map<Int, Int>?) {
    if (map == null) {
        println("================无法凑出该金额=================")
        return
    }
    println("================需要=================")
    // 遍历map
    for (i in map) {
        println("${i.key}元零钱: ${i.value}张")
    }
}

fun main() {
    val target = 101
    println("目标金额:$target")
    val changeMoney2 = changeMoney2(target)
    readMap(changeMoney2)
}

运行结果:

目标金额:101
可以凑成的最大金额是:143
================需要=================
1元零钱: 1张
2元零钱: 0张
5元零钱: 20张

如果要凑的是144元,那么运行结果:

目标金额:144
可以凑成的最大金额是:143
================无法凑出该金额=================

很明显,这种方式更加简洁容易理解,复杂度还更低。

所以说:

为什么我不一开始就用这种简单解法算了?

因为: 解决凑零钱问题有多种方式,但是动态规划算法是一种通用的解题思路,先掌握算法的核心思想,进而可以在遇到其他问题的时候套用思路,举一反三。而 凑零钱的整除取余法,离开了凑零钱问题,可能就不适用了,比如 斐波拉契数列问题,就无法 用这种方法。

总结

本系列前3篇,从动态规划的基本套路,到现实问题的应用实践,一步一步展示了当问题复杂化时我们算法的变化。其实解决复杂问题的基本套路都是如此,先基于一个简单问题,逐步复杂化,直到达到现实中的复杂问题。

如果突然让你考虑 现实版的凑零钱问题,考虑现实中可能遇到的任何情况,如果你直接从现实情况入手,非常容易出现纰漏,所以解决现实问题,依然需要循序渐进,逐步解决难题。

动态规划算法,最难的,还是写出"状态转移方程", 凑零钱问题的方程算是比较简单的,下一篇,就一个比较复杂的问题(最长递增子序列问题),仍然使用动态规划算法来解决。

上一篇 下一篇

猜你喜欢

热点阅读