算法Balabala凑零钱问题现实版

前言

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

承接上文

上文就凑零钱问题,在零钱无限多的情况下,用"动态规划"给出了最优解算法。 本文代码扔采用简洁优美的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篇,从动态规划的基本套路,到现实问题的应用实践,一步一步展示了当问题复杂化时我们算法的变化。其实解决复杂问题的基本套路都是如此,先基于一个简单问题,逐步复杂化,直到达到现实中的复杂问题。

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,050评论 4 370
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,538评论 1 306
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,673评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,622评论 0 218
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,047评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,974评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,129评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,893评论 0 209
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,654评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,828评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,297评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,619评论 3 262
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,326评论 3 243
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,176评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,975评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,118评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,909评论 2 278