美文网首页
算法Balabala凑零钱问题现实版

算法Balabala凑零钱问题现实版

作者: 波澜步惊 | 来源:发表于2020-05-03 17:59 被阅读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张
    

    时间复杂度

    • 子问题的个数

      由于有map在记录当前值,所以如果目标金额是18,也最多就需要计算18次,个数:n

    • 一个子问题所花费的时间

      一个子问题最多进行一次减法运算(当前金额减去当前零钱),以及一次 加法运算(结果list的合并),时间:2

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

    空间复杂度

    • 子问题的个数

      n

    • 一个子问题所花费的空间

      每一个子问题最多需要临时创建一个list来保存当前结果,所以空间 1

    空间复杂度 : 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张
    

    时间复杂度空间复杂度:

    • 子问题的个数

      依然没有变化,依然是n

    • 一个子问题消耗的时间

      与上一章节相比并没有变化,还是只有一次减法和一次数组合并,所以是2

    • 一个子问题消耗的空间

      还是在用一个map来保存每一次遍历的目标list一维数组,所以消耗空间1

    时间复杂度 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张
    

    时间复杂度

    • 子事件的个数

      子事件的个数已经和目标金额没有直接关系了,反而是和零钱数量有关,而零钱数量是固定的那么几个,所以事件数目认定为 1

    • 解决一个子事件所需时间

      需要一次除法,以及一次取余,也不存在循环和递归,所以时间认为是1

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

    空间复杂度:

    • 子事件的个数

      1

    • 解决一个子事件所需空间

      上面我只是用map来记录零钱的张数,并没有参与循环递归的空间开辟,所以 空间 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篇,从动态规划的基本套路,到现实问题的应用实践,一步一步展示了当问题复杂化时我们算法的变化。其实解决复杂问题的基本套路都是如此,先基于一个简单问题,逐步复杂化,直到达到现实中的复杂问题。

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

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

    相关文章

      网友评论

          本文标题:算法Balabala凑零钱问题现实版

          本文链接:https://www.haomeiwen.com/subject/mpcjghtx.html