-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
238 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,4 @@ | ||
*.class | ||
*.log | ||
^out/ | ||
^\.metals/ | ||
^\.bloop/ | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
0.5.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
version = "2.0.0" | ||
|
||
align = more | ||
maxColumn = 120 | ||
trailingCommas = always |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,11 @@ | ||
# puzzles-knapsack-checkout | ||
# Overview | ||
|
||
This is the implementation of the optimal knapsack selection algorithm for the point of sale terminal product selection. | ||
|
||
# Usage | ||
|
||
Project is built using [mill](http://www.lihaoyi.com/mill/). | ||
|
||
To run demo: `./mill checkout.run` | ||
|
||
To run tests: `./mill checkout.tests` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import mill._, scalalib._, scalafmt._ | ||
import ammonite.ops._ | ||
import $ivy.`com.lihaoyi::mill-contrib-bloop:0.5.0` | ||
|
||
object checkout extends ScalaModule with ScalafmtModule { | ||
def scalaVersion = "2.13.0" | ||
|
||
object tests extends Tests { | ||
def ivyDeps = Agg(ivy"com.lihaoyi::utest:0.7.1") | ||
def testFrameworks = Seq("utest.runner.Framework") | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package checkout | ||
|
||
object app { | ||
|
||
def main(args: Array[String]): Unit = { | ||
println("running demo:") | ||
|
||
val term = new Terminal() | ||
.setPricing("A", 2) | ||
.setPricing("A", 7, 4) | ||
.setPricing("B", 12) | ||
.setPricing("C", 1.25) | ||
.setPricing("C", 6, 6) | ||
.setPricing("D", .15) | ||
|
||
for (cart <- Seq("ABCDABAA", "CCCCCCC", "ABCD")) { | ||
println(s"Cart '$cart':") | ||
cart.foldLeft(term)((t, p) => t.scan(p.toString)).receipt match { | ||
case Some(receipt) => | ||
println("Receipt:") | ||
receipt.items.foreach(d => println(s"${d.product.code}: ${d.count} for $$${d.price}")) | ||
println(s"Total: $$${receipt.total}") | ||
case None => | ||
println("Product selection is impossible for this cart.") | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
package checkout | ||
|
||
case class Product(code: String) | ||
case class Deal(product: Product, count: Int, price: BigDecimal) | ||
case class Receipt(items: Seq[Deal]) { | ||
def total = items.map(_.price).sum | ||
} | ||
|
||
object Terminal { | ||
private implicit object DealIsKnapsackItem extends KnapsackItem[Deal, BigDecimal] { | ||
override def value(item: Deal): BigDecimal = item.price | ||
override def weight(item: Deal): Int = item.count | ||
} | ||
|
||
def checkout(prices: Set[Deal], cart: Seq[Product]): Option[Receipt] = { | ||
val dealsByProduct = prices.groupBy(_.product) | ||
val productCounts = cart.groupBy(identity).mapValues(_.size) | ||
|
||
val productReceipts = for { | ||
(product, count) <- productCounts | ||
} yield { | ||
val deals = dealsByProduct.getOrElse(product, Set.empty) | ||
val bestDeals = new LeastValueUnboundedKnapsack[Deal, BigDecimal](deals, count).solve() | ||
(product, bestDeals.map(ds => Receipt(ds.sortBy(_.count).reverse))) | ||
} | ||
|
||
productReceipts.force.sortBy(_._1.code).map(_._2).foldLeft[Option[Receipt]](Some(Receipt(Seq.empty))) { | ||
case (Some(r1), Some(r2)) => Some(Receipt(r1.items ++ r2.items)) | ||
case _ => None | ||
} | ||
} | ||
} | ||
|
||
class Terminal(prices: Set[Deal] = Set.empty, cart: Seq[Product] = Seq.empty) { | ||
def setPricing(productCode: String, price: BigDecimal, count: Int = 1): Terminal = { | ||
new Terminal(prices.incl(Deal(Product(productCode), count, price)), cart) | ||
} | ||
|
||
def scan(productCode: String): Terminal = { | ||
new Terminal(prices, cart.appended(Product(productCode))) | ||
} | ||
|
||
def receipt: Option[Receipt] = { | ||
Terminal.checkout(prices, cart) | ||
} | ||
|
||
def total: Option[BigDecimal] = { | ||
receipt.map(_.total) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
package checkout | ||
|
||
/** | ||
* This is the implementation of the unbounded knapsack algorithm from https://en.wikipedia.org/wiki/Knapsack_problem, | ||
* modified to compute least value combination at full capacity. | ||
*/ | ||
class LeastValueUnboundedKnapsack[I, V](availableItems: Set[I], capacity: Int)( | ||
implicit evki: KnapsackItem[I, V], | ||
evvn: Numeric[V], | ||
) { | ||
import evki.{value => itemValue, weight => itemWeight}, evvn._ | ||
|
||
private sealed trait Solution | ||
private case class Knapsack(value: V, items: List[I]) extends Solution { | ||
def addItem(item: I): Knapsack = | ||
Knapsack(value + itemValue(item), item :: items) | ||
} | ||
private case object NoSolution extends Solution | ||
|
||
private def memoize[I, O](f: I => O): I => O = { | ||
val cache = collection.mutable.HashMap.empty[I, O] | ||
(i: I) => cache.getOrElseUpdate(i, f(i)) | ||
} | ||
|
||
private lazy val solveForCapacity: Int => Either[NoSolution.type, Knapsack] = memoize { capacity => | ||
if (capacity == 0) { | ||
Right(Knapsack(zero, List.empty)) | ||
} else { | ||
val itemsThatFit = availableItems.filter(i => itemWeight(i) <= capacity) | ||
val possibleKnapsacks = itemsThatFit | ||
.map(item => { | ||
val remainingCapacity = capacity - itemWeight(item) | ||
val remainingSolution = solveForCapacity(remainingCapacity) | ||
remainingSolution.map(_.addItem(item)) | ||
}) | ||
.collect { | ||
case Right(k) => k | ||
} | ||
possibleKnapsacks.minByOption(_.value).toRight(NoSolution) | ||
} | ||
} | ||
|
||
def solve(): Option[List[I]] = { | ||
solveForCapacity(capacity).toOption.map(_.items) | ||
} | ||
} | ||
|
||
trait KnapsackItem[I, V] { | ||
def weight(item: I): Int | ||
def value(item: I): V | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package checkout.tests | ||
|
||
import checkout._ | ||
import utest._ | ||
|
||
object TerminalTests extends TestSuite { | ||
|
||
val term = new Terminal() | ||
.setPricing("A", 2) | ||
.setPricing("A", 7, 4) | ||
.setPricing("B", 12) | ||
.setPricing("C", 1.25) | ||
.setPricing("C", 6, 6) | ||
.setPricing("D", .15) | ||
|
||
def assertTotal(cart: String, expectedTotal: Option[BigDecimal], terminal: Terminal = term): Unit = { | ||
val r = cart.foldLeft(terminal)((t, p) => t.scan(p.toString)).receipt | ||
assert(r.map(_.total) == expectedTotal) | ||
} | ||
|
||
val tests = Tests { | ||
test("Cart ABCDABAA") { | ||
assertTotal("ABCDABAA", Some(32.40)) | ||
} | ||
test("Cart CCCCCCC") { | ||
assertTotal("CCCCCCC", Some(7.25)) | ||
} | ||
test("Cart ABCD") { | ||
assertTotal("ABCD", Some(15.40)) | ||
} | ||
test("Empty cart") { | ||
assertTotal("", Some(0)) | ||
} | ||
test("Impossible cart") { | ||
assertTotal("BB", None, new Terminal().setPricing("B", 1, 3)) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
#!/usr/bin/env sh | ||
|
||
# This is a wrapper script, that automatically download mill from GitHub release pages | ||
# You can give the required mill version with MILL_VERSION env variable | ||
# If no version is given, it falls back to the value of DEFAULT_MILL_VERSION | ||
DEFAULT_MILL_VERSION=0.5.0 | ||
|
||
set -e | ||
|
||
if [ -z "$MILL_VERSION" ] ; then | ||
if [ -f ".mill-version" ] ; then | ||
MILL_VERSION="$(head -n 1 .mill-version 2> /dev/null)" | ||
elif [ -f "mill" ] && [ "$BASH_SOURCE" != "mill" ] ; then | ||
MILL_VERSION=$(grep -F "DEFAULT_MILL_VERSION=" "mill" | head -n 1 | cut -d= -f2) | ||
else | ||
MILL_VERSION=$DEFAULT_MILL_VERSION | ||
fi | ||
fi | ||
|
||
MILL_DOWNLOAD_PATH="$HOME/.mill/download" | ||
MILL_EXEC_PATH="${MILL_DOWNLOAD_PATH}/$MILL_VERSION" | ||
|
||
if [ ! -x "$MILL_EXEC_PATH" ] ; then | ||
mkdir -p $MILL_DOWNLOAD_PATH | ||
DOWNLOAD_FILE=$MILL_EXEC_PATH-tmp-download | ||
MILL_DOWNLOAD_URL="https://github.com/lihaoyi/mill/releases/download/${MILL_VERSION%%-*}/$MILL_VERSION-assembly" | ||
curl --fail -L -o "$DOWNLOAD_FILE" "$MILL_DOWNLOAD_URL" | ||
chmod +x "$DOWNLOAD_FILE" | ||
mv "$DOWNLOAD_FILE" "$MILL_EXEC_PATH" | ||
unset DOWNLOAD_FILE | ||
unset MILL_DOWNLOAD_URL | ||
fi | ||
|
||
unset MILL_DOWNLOAD_PATH | ||
unset MILL_VERSION | ||
|
||
exec $MILL_EXEC_PATH "$@" |