Skip to content

Commit

Permalink
solution
Browse files Browse the repository at this point in the history
  • Loading branch information
uosis committed Aug 20, 2019
1 parent 063cb55 commit 9e79a30
Show file tree
Hide file tree
Showing 10 changed files with 238 additions and 3 deletions.
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
*.class
*.log
^out/
^\.metals/
^\.bloop/

1 change: 1 addition & 0 deletions .mill-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.5.0
5 changes: 5 additions & 0 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version = "2.0.0"

align = more
maxColumn = 120
trailingCommas = always
12 changes: 11 additions & 1 deletion README.md
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`
13 changes: 13 additions & 0 deletions build.sc
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")
}
}

28 changes: 28 additions & 0 deletions checkout/src/Main.scala
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.")
}
}
}
}
50 changes: 50 additions & 0 deletions checkout/src/Terminal.scala
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)
}
}
51 changes: 51 additions & 0 deletions checkout/src/UnboundedKnapsack.scala
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
}
38 changes: 38 additions & 0 deletions checkout/tests/src/TerminalTests.scala
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))
}
}
}
37 changes: 37 additions & 0 deletions mill
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 "$@"

0 comments on commit 9e79a30

Please sign in to comment.