Golang實現高性能湊單工具:給定<金額列表>計算<目標金額>所有組合


一、需求

公司有一個比較坑爹的報銷方案,需要根據一堆零碎的發票中,湊出一個目標金額,要求誤差在1塊錢以內。
例如:你有一堆發票[100, 101, 103, 105, 106, 132, 129, 292, 182, 188, 224.3, 40.5, 35.9, 32.5, 39, 12, 17.5, 28, 35, 34, 26.32, 28, 35, 39, 25, 1, 24, 35, 45, 47, 32.11, 45, 32, 38.88, 44, 36.5, 35.8, 45, 26.5, 33, 25, 364, 27.3, 39.2, 180, 279, 282, 281, 285, 275, 277, 278, 200, 201, 1959.12, 929.53, 1037.03, 1033.9],讓你從這堆票中湊出5000塊來,然后最多不能超過1塊錢。你瞧瞧這是人干的事么!

缺點:每次人肉去對比,浪費大量的時間。
操作過程大概是這樣的:新建一個excel表格,將所有的金額錄入,然后自己勾選發票,直到目標金額出現,如下圖

人品大爆發的時候一下就湊出來,運氣不好的時候湊着湊着一兩個小時就過去了!
因此,我們急需一個程序自己去干這個事。

二、實現思路

  1. 最差方案:全組合
    使用全組合,搜索所有組合方案,遍歷滿足的結果輸出,時間復雜度為O(n!),原先調用了python的排列組合函數實現,結果卡得不行,有時候能把程序跑掛了

  2. 中等方案:回溯暴力破解
    利用回溯輸出,存在重復遞歸,時間復雜度為O(2^n),一般來說已經滿足正常需求,但是如果n很大,還是影響性能

  3. 最優方案:動態規划
    時間復雜度為O(n*w),為最快方案,提升氣質指數,5顆星!

三、最終方案:動態規划

最終用動態規划思想實現,空間換時間,200個碎票匹配1萬的金額秒出結果,大概使用800M內存,

代碼已經貼到github:chenqionghe/amount-calculator

核心代碼如下

package main

import (
	"fmt"
	"github.com/shopspring/decimal"
	"strconv"
)

type AmountCalculator struct {
	maxValue int   //期望值(單元為分)
	items    []int //發票金額(單元為分)
	overflow int   //允許的誤差值(單元為分)
}

//items:所有發票 maxValue:目標金額 overflow:允許誤差金額
func New(items []float64, maxValue float64, overflow float64) *AmountCalculator {
	obj := &AmountCalculator{}
	obj.maxValue = obj.dollarToCent(maxValue)
	obj.overflow = obj.dollarToCent(overflow)
	centItems := make([]int, len(items))
	for i, v := range items {
		centItems[i] = obj.dollarToCent(v)
	}
	obj.items = centItems
	return obj
}

//元轉分
func (this *AmountCalculator) dollarToCent(value float64) int {
	value, _ = strconv.ParseFloat(fmt.Sprintf("%.2f", value), 64)

	decimalValue := decimal.NewFromFloat(value)
	decimalValue = decimalValue.Mul(decimal.NewFromInt(100))

	res, _ := decimalValue.Float64()
	return int(res)
}

//分轉元
func (this *AmountCalculator) centToDollar(v int) float64 {
	value := float64(v)
	value, _ = strconv.ParseFloat(fmt.Sprintf("%.2f", value/100), 64)
	return value
}

//執行計算,返回所有方案
func (this *AmountCalculator) GetCombinations() [][]float64 {
	items := this.items
	n := len(this.items)
	max := this.maxValue + this.overflow
	states := this.createStates(len(this.items), max+1)

	states[0][0] = true
	if items[0] <= max {
		states[0][items[0]] = true
	}

	for i := 1; i < n; i++ {
		//不選
		for j := 0; j <= max; j++ {
			if states[i-1][j] {
				states[i][j] = states[i-1][j]
			}
		}
		//選中
		for j := 0; j <= max-items[i]; j++ {
			if states[i-1][j] {
				states[i][j+items[i]] = true
			}
		}
	}
	//獲取最終所有滿足的方案
	res := make([][]float64, 0)
	for j := this.maxValue; j <= max; j++ {
		for i := 0; i < n; i++ {
			if states[i][j] {
				//判斷必須最后一個選中才算,要不區間有重合 比如前5個元素已經滿足目標金額了,state[5][w]=true,然后state[6][w]也是true,存在重復的方案
				if i == 0 {
					//第一個元素已經滿足
					res = append(res, this.getSelected(states, items, i, j))
				} else if j-items[i] >= 0 && states[i-1][j-items[i]] == true {
					res = append(res, this.getSelected(states, items, i, j))
				}
			}
		}
	}
	return res
}

//獲取所有選中的元素(倒推)
func (this *AmountCalculator) getSelected(states [][]bool, items []int, n, max int) []float64 {
	var selected = make([]int, 0)
	for i := n; i >= 1; i-- {
		//元素被選中
		if max-items[i] >= 0 && states[i-1][max-items[i]] == true {
			selected = append([]int{items[i]}, selected...)
			max = max - items[i]
		} else {
			//沒選,max重量不變,直接進入下一次
		}
	}

	//如果max不為0,說明還需要追加第一個元素
	if max != 0 {
		selected = append([]int{items[0]}, selected...)
	}

	dollarItems := make([]float64, len(selected))
	for i, v := range selected {
		dollarItems[i] = this.centToDollar(v)
	}
	return dollarItems
}

//初始化所有狀態
func (this *AmountCalculator) createStates(n, max int) [][]bool {
	states := make([][]bool, n)
	for i, _ := range states {
		states[i] = make([]bool, max)
	}
	return states
}

四、使用方式

1.直接調用代碼(適合用來開發自己的軟件)

package main

import (
	"fmt"
	"github.com/chenqionghe/amount-calculator"
	"time"
)

func main() {
	//所有碎票
	items := []float64{100, 101, 103, 105, 106, 132, 129, 292, 182, 188, 224.3, 40.5, 35.9, 32.5, 39, 12, 17.5, 28, 35, 34, 26.32, 28, 35, 39, 25, 1, 24, 35, 45, 47, 32.11, 45, 32, 38.88, 44, 36.5, 35.8, 45, 26.5, 33, 25, 364, 27.3, 39.2, 180, 279, 282, 281, 285, 275, 277, 278, 200, 201, 1959.12, 929.53, 1037.03, 1033.9}
	//目標金額
	target := float64(5000)
	//允許超出
	overflow := float64(1)
	obj := amountcalculator.New(items, target, overflow)

	startTime := time.Now()

	//獲取所有的組合
	res := obj.GetCombinations()
	for _, v := range res {
		fmt.Println(v)
	}
	fmt.Printf("total:%d used time:%s\n", len(res), time.Now().Sub(startTime))
}

運行結果

[100 101 103 105 106 132 129 292 182 188 224.3 40.5 12 17.5 35 34 26.32 28 35 39 25 1 24 35 45 47 45 32 38.88 44 36.5 45 26.5 33 25 364 27.3 39.2 180 279 282 281 285 275 277 278]
[100 101 103 105 132 129 292 182 188 35.9 39 12 17.5 28 35 34 26.32 28 35 39 25 1 24 35 45 47 32.11 45 32 38.88 44 36.5 35.79 45 26.5 33 25 364 27.3 39.2 180 279 282 281 285 275 277 278 200]
...
[35.9 25 24 38.88 36.5 35.79 45 26.5 33 25 27.3 39.2 180 279 282 281 285 275 277 278 200 201 1037.03 1033.9]
total:577 used time:97.048224ms

耗時97毫秒,共計算出577種方案,性能高到令人忍不住想鼓掌!

這種方式適合在自己開發的程序中使用,比如要出一個web界面,給前端提供數據

2.命令行模式(適合不會編程的人使用)

這種方式適合不會go語言,或者不會編程的人使用,只需編譯出對應平台的版本就行。
一次編譯多次分發,相當於copy了個綠色版軟件到電腦上直接使用

編譯過程如下

  • 新建一個go文件:main.go
package main

import (
	"github.com/chenqionghe/amount-calculator"
)

func main() {
	amountcalculator.RunCliMode()
}
  • 編譯
go build -o amount-calculator
  • 運行該工具
 ./amount-calculator -max=156 -overflow=1 -items=12,135,11,12,15,16,18,32,64,76,50
156 [11 15 16 18 32 64]
156 [16 64 76]
156 [12 18 76 50]
157 [12 15 16 18 32 64]
157 [15 16 18 32 76]
157 [15 16 76 50]

可以看到,命令行直接輸出了所有匹配的組合,還給出了每個組合的金額,對於使用者來說,不會編程語言也完全沒有關系,只需要自己執行一下即可,相當方便。

另外,命令行模式省了一個編譯的過程,效率更高,推薦!

五、總結

技術解放生產力,這種重復且費時的勞動完全應該由程序去做,可惜的是

  1. 一般的產品提不出這樣的需求
  2. 產品即使能提這樣的需求,開發也不一定能實現出來
  3. 開發即使能實現出來,程序也不一定能跑得動,因為很可能太耗內存或太耗CPU,還沒等結果運行出來程序就掛了

哈哈,稍微有點吹牛逼了,不管怎么樣,就一句話:yeah buddy! light weight baby! 讓我聽到你們的尖叫聲!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM