LeetCode 1937: Maximum Number of Points with Cost

November 1, 2024

LeetCode1’s daily challenge2 for the 17th of August 2024 was a fun little problem whose solution is interesting enough to provide a dedicated write-up.

The problem is named Maximum Number of Points with Cost. In the problem, we are given an \(M \times N\) integer matrix named \(points\) from which we want to maximize the number of points we can get from. To gain points from a matrix, we must pick exactly one cell in each row. By picking the cell with coordinates \((r, c)\) we add \(points[r][c]\) to the score. There is, however, a caveat that prevents us from being greedy and always choosing the cell with most points from each row: for every two adjacent rows \(r\) and \(r + 1\), picking cells at coordinates \((r, c_1)\) and \((r + 1, c_2)\) will subtract \(\operatorname{abs}(c_1 - c_2)\) from the total score. In other words, the horizontal distance between selected cells in adjacent rows is subtracted from the total score.

In terms of constraints, we have, for \(M\) being the number of rows and \(N\) being the number of columns:

  • \(1 \leq M \leq 10^5\);
  • \(1 \leq N \leq 10^5\);
  • \(1 \leq M \times N \leq 10^5\);
  • \(0 \leq points[r][c] \leq 10^5\).

We can take a look at some example inputs to better understand how we can get points from a matrix:

Sample matrix with the cells that maximize the score highlighted.
Sample matrix with the cells that maximize the score highlighted.

Using the above matrix as input, the maximum number of points we can achieve is obtained from selecting the maximum value from each row, for a total of \(3 - 1 + 5 - 1 + 3 = 9\) points.

Sample matrix with the cells that maximize the score highlighted.
Sample matrix with the cells that maximize the score highlighted.

Using the above matrix as input, it is preferable to not select the maximum value from the first row (the \(6\)), because we would be penalized by \(2\) if we were to select the \(5\) from the second row. Since all selected cells lie in the same column, we get a total of \(5 + 5 + 5 = 15\) points. If we were to change the selected cell from the first row from the 5 to the 6, we would get a total of \(6 - 2 + 5 + 5 = 14\) points.

There are various ways to approach this problem. We are going to take a look at some of them, even those that won’t lead to accepted solutions given the problem constraints.

Brute Force

A potential brute force solution involves checking every possible combination of selections for each row. Since we have \(N\) options per row and \(M\) rows, a brute force approach would lead to a time complexity of \(\mathcal{O}(N^M)\). Even though it is not a practical approach given the problem constraints, let’s see how a brute force solution would look like:

#include <algorithm>
#include <cmath>
#include <queue>
#include <tuple>
#include <vector>

using namespace std;

class Solution {
 public:
  long long maxPoints(vector<vector<int>>& points) {
    int M = points.size(), N = points[0].size();
    queue<tuple<int, int, long long>> q;
    for (int c = 0; c < N; ++c) { q.push({0, c, points[0][c]}); }
    long long ans = 0L;
    int currRow, currCol;
    long long currPoints;
    while (!q.empty()) {
      tie(currRow, currCol, currPoints) = q.front();
      q.pop();
      if (currRow == M - 1) {
        ans = max(ans, currPoints);
      } else {
        for (int c = 0; c < N; ++c) {
          q.push({currRow + 1, c, currPoints + points[currRow + 1][c] - abs(currCol - c)});
        }
      }
    }
    return ans;
  }
};

On the C++ sample code above, we’re doing a breadth-first search3 over the state of possible solutions. Each search state is comprised of the current row, current column and points gathered so far. We produce new search states by checking how many points we would get for every cell in the next row. Once we reach the bottom row we don’t expand our search state further and update our current best score accordingly. As expected by the problem constraints, the above code is going to exceed the time (and likely memory) limit of the online judge.

Brute Force with Cuts

Building on the brute force approach above, we can avoid complete search paths if we don’t explore search states that have less points than the maximum we have observed so far for that cell.

#include <algorithm>
#include <cmath>
#include <queue>
#include <tuple>
#include <vector>

using namespace std;

class Solution {
 public:
  long long maxPoints(vector<vector<int>>& points) {
    int M = points.size(), N = points[0].size();
    queue<tuple<int, int, long long>> q;
    vector<vector<long long>> maxSoFar(M, vector<long long>(N, 0L));
    for (int c = 0; c < N; ++c) {
      q.push({0, c, points[0][c]});
      maxSoFar[0][c] = points[0][c];
    }
    long long ans = 0L;
    int currRow, currCol;
    long long currPoints;
    while (!q.empty()) {
      tie(currRow, currCol, currPoints) = q.front();
      q.pop();
      if (currRow == M - 1) {
        ans = max(ans, currPoints);
      } else {
        for (int c = 0; c < N; ++c) {
          long long nextPoints = currPoints + points[currRow + 1][c] - abs(currCol - c);
          if (nextPoints > maxSoFar[currRow + 1][c]) {
            maxSoFar[currRow + 1][c] = nextPoints;
            q.push({currRow + 1, c, nextPoints});
          }
        }
      }
    }
    return ans;
  }
};

The time complexity of the solution above is still \(\mathcal{O}(N^M)\), but avoids entire search paths for some inputs. It is still not good enough to be accepted.

Dynamic Programming

Given that \(M \times N\) is at most \(10^5\), a \(\mathcal{O}(M \times N)\) solution or even a \(\mathcal{O}(M \times N \times \log(N))\) or \(\mathcal{O}(M \times N \times \log(M))\) solution would work, but anything assymptotically larger would be challenging. Let’s see if there’s an opportunity to reuse previous computations when building our solution.

There are some observations we can make in order to base our solution in terms of smaller subproblems. To compute the maximum number of points for a given cell of a row \(r\) we only need the maximum number of points we can obtain from each cell in row \(r - 1\).

To illustrate this idea, let’s consider the following matrix:

Sample matrix with no cells selected.
Sample matrix with no cells selected.

Let’s go row by row and fill each cell with the maximum points we could get by picking it at that point:

Building a matrix in which each cell corresponds to
the maximum score we can achieve from a path ending at that point.
Building a matrix in which each cell corresponds to the maximum score we can achieve from a path ending at that point.

Looking at the above, we can make some observations:

  • The first row is equal to the first row of \(points\).
  • On each subsequent row \(r\), we only need the values from row \(r - 1\) to compute the best we can get for each cell. For example, at the second row, we chose \(10\) for its maximum value since it’s the value we get from \(5 + \max(5 + 0, 1 - 1, 6 - 2)\).

More formally, if \(f(r, c)\) is the maximum number of points we can get at cell \((r, c)\) then we can arrive at the following recurrence based on this idea:

\[f(r, c) = \left\{ \begin{array}{ll} points[r][c], & \mbox{if $r = 0$}.\\ \smash{points[r][c] + \displaystyle\max_{0 \leq c_{prev} < N}} (f(r - 1, c_{prev}) - \operatorname{abs}(c - c_{prev})), & \mbox{otherwise}. \end{array} \right.\]

With the above recurrence in place, the solution to our problem is given by:

\[\smash{\displaystyle\max_{0 \leq c < N}} f(M - 1, c)\]

We can implement the idea above with the following C++ code:

#include <algorithm>
#include <cmath>
#include <vector>

using namespace std;

class Solution {
 public:
  long long maxPoints(vector<vector<int>>& points) {
    int M = points.size(), N = points[0].size();
    vector<vector<long long>> dp(M, vector<long long>(N, 0L));
    for (int c = 0; c < N; ++c) { dp[0][c] = points[0][c]; }
    for (int r = 1; r < M; ++r) {
      for (int c = 0; c < N; ++c) {
        for (int cp = 0; cp < N; ++cp) {
          dp[r][c] = max(dp[r][c], dp[r - 1][cp] - abs(c - cp) + points[r][c]);
        }
      }
    }
    long long ans = 0L;
    for (int c = 0; c < N; ++c) { ans = max(ans, dp[M - 1][c]); }
    return ans;
  }
};

This is better than our brute force approach, but we are at a time complexity of \(\mathcal{O}(M \times N^2)\), which is still not good enough to get accepted.

In order to improve the time complexity, let’s focus on what we are doing to compute \(f(r, c)\) for \(r \neq 0\):

\[\smash{points[r][c] + \displaystyle\max_{0 \leq c_{prev} < N}} (f(r - 1, c_{prev}) - \operatorname{abs}(c - c_{prev}))\]

Can we avoid iterating through all possible values of \(c_{prev}\) for every \((r, c)\) pair? If we could produce a function \(g(r, c)\) that would give us the best selection from the previous row for a given \(c\) column we could rewrite our recurrence as:

\[f(r, c) = \left\{ \begin{array}{ll} points[r][c], & \mbox{if $r = 0$}.\\ points[r][c] + g(r - 1, c), & \mbox{otherwise}. \end{array} \right.\]

To produce \(g(r, c)\) it is important to notice that at any given column \(c\) we have three options: we either don’t move horizontally (so there’s no penalty), or we either go left or right. In essence, we have that \(g(r, c) = \max(points[r][c], \operatorname{left}(r, c), \operatorname{right}(r, c))\), with \(\operatorname{left}(r, c)\) being the maximum value we can get by going left of \(c\) and \(\operatorname{right}(r, c)\) being the maximum value we can get by going right of \(c\). We are now left with the task of efficiently producing \(\operatorname{left}\) and \(\operatorname{right}\).

If we suspect that there is a recurrence at place, it is often helpful to try and investigate a possible base case and an inductive step that seems plausible. As such, if we look at the \(\operatorname{left}\) function, we can conclude that \(\operatorname{left}(r, 0) = f(r, 0)\), because we can’t go any left, so the best we can get at column \(0\) is the maximum so far for cell \((r, 0)\). For \(\operatorname{left}(r, 1)\) we can pick the maximum of either \(f(r, 1)\) or \(\operatorname{left}(r, 0) - 1\). In other words, we either pick the value we get from choosing the current cell or we pick the best value we have to our left and subtract \(1\) (which is the penalty of moving right). We can generalize \(\operatorname{left}\) as follows:

\[\operatorname{left}(r, c) = \left\{ \begin{array}{ll} f(r, c), & \mbox{if $c = 0$}.\\ \max(\operatorname{left}(r, c - 1) - 1, f(r, c)), & \mbox{otherwise}. \end{array} \right.\]

We can apply a similar strategy for our \(\operatorname{right}\) function:

\[\operatorname{right}(r, c) = \left\{ \begin{array}{ll} f(r, c), & \mbox{if $c = N - 1$}.\\ \max(\operatorname{right}(r, c + 1) - 1, f(r, c)), & \mbox{otherwise}. \end{array} \right.\]

With that idea in place, we can avoid one extra inner loop.

#include <algorithm>
#include <vector>

using namespace std;

class Solution {
 public:
  long long maxPoints(vector<vector<int>>& points) {
    int M = points.size(), N = points[0].size();
    vector<vector<long long>> dp(M, vector<long long>(N, 0L));
    for (int c = 0; c < N; ++c) { dp[0][c] = points[0][c]; }
    vector<long long> left(N), right(N);
    for (int r = 1; r < M; ++r) {
      left[0] = dp[r - 1][0];
      for (int c = 1; c < N; ++c) { left[c] = max(left[c - 1] - 1, dp[r - 1][c]); }
      right[N - 1] = dp[r - 1][N - 1];
      for (int c = N - 2; c >= 0; --c) { right[c] = max(right[c + 1] - 1, dp[r - 1][c]); }
      for (int c = 0; c < N; ++c) {
        dp[r][c] = max((long long)points[r - 1][c], max(left[c], right[c])) + points[r][c];
      }
    }
    long long ans = 0L;
    for (int c = 0; c < N; ++c) { ans = max(ans, dp[M - 1][c]); }
    return ans;
  }
};

The above solution has time complexity \(\mathcal{O}(M \times 3 \times N)\) which simplifies to \(\mathcal{O}(M \times N)\) and is good to get accepted.

Reducing the Space Complexity

Even though the solution described above has a sufficient time complexity to be accepted, we can reduce its space complexity with the following three observations:

  1. We don’t need to keep track of the maximum number of points for each cell previously visited (our previously defined \(f\) function), since we’re only ever interested in the previous row.
  2. We don’t need to have separate vectors for \(\operatorname{left}\) and \(\operatorname{right}\). Since we’re always computing the maximum of both functions, we can reuse the same vector.
  3. At every new row, we’re only interested in the \(\operatorname{left}\) and \(\operatorname{right}\) functions (which we saw previously that can be combined into one lookup vector), so we can reuse the vector of the maximum values for the previous row (the lookup for our \(f\) function) for that.

If we put those ideas into practice we arrive at the following solution, which still has a time complexity of \(\mathcal{O}(M \times N)\) but an extra space complexity of just \(\mathcal{O}(N)\), which is better than the previous \(\mathcal{O}(M \times N)\):

#include <algorithm>
#include <vector>

using namespace std;

class Solution {
 public:
  long long maxPoints(vector<vector<int>>& points) {
    int M = points.size(), N = points[0].size();
    vector<long long> prev(N);
    for (int c = 0; c < N; ++c) { prev[c] = points[0][c]; }
    for (int r = 1; r < M; ++r) {
      for (int c = 1; c < N; ++c) { prev[c] = max(prev[c - 1] - 1, prev[c]); }
      for (int c = N - 2; c >= 0; --c) { prev[c] = max(prev[c + 1] - 1, prev[c]); }
      for (int c = 0; c < N; ++c) { prev[c] += points[r][c]; }
    }
    long long ans = prev[0];
    for (int c = 1; c < N; ++c) { ans = max(ans, prev[c]); }
    return ans;
  }
};
  1. LeetCode is an online platform providing practice coding and algorithmic problems. 

  2. LeetCode’s daily challenges are problems from LeetCode’s database that are meant to be solved on each day of the year. Solving them provides some extra rewards in terms of LeetCoins and can get you a badge if you solve all problems of a given calendar month. I have been solving them for fun and trying to keep a streak going. 

  3. We can change the queue into a stack for a depth-first search. 

Book Review: Scala with Cats

October 2, 2023

Scala with Cats is a book about the Cats library, which provides abstractions for functional programming in the Scala programming language. The book provides an introduction not only to the Cats library but also to some category theory structures. It’s divided in two major sections: “Theory” and “Case Studies”. The “Theory” section starts with a chapter dedicated to the way Cats is designed around type classes and how type classes are encoded in the Scala programming language. The section follows with dedicated chapters for different algebraic data structures, some functional programming constructs and how they are implemented in Cats: Monoids, Semigroups, Functors, Monads, Monad Transformers, Semigroupal, Applicative, Foldable and Traverse. The “Case Studies” section ties it all up with 4 practical applications of the previously introduced structures and constructs: testing asynchronous code, map reduce, data validation and CRDTs.

I worked through the book in March and April this year and found it engaging and with a fast pace. Laws are presented and explained in terms of Scala code. The exercises complement the content of the book well, particularly the ones in the “Case Studies” section, which showcase the applications of everything that was introduced in the “Theory” section.

I would recommend the book to anyone with moderate knowledge of the Scala programming language who wants to learn more about typed functional programming in general and about the Cats library in particular.

If you’re interested in my solutions to the book’s exercises, they are available in the following posts:

March 25, 2023
Solutions to Scala with Cats: Chapter 1
April 3, 2023
Solutions to Scala with Cats: Chapter 2
April 3, 2023
Solutions to Scala with Cats: Chapter 3
April 4, 2023
Solutions to Scala with Cats: Chapter 4
April 5, 2023
Solutions to Scala with Cats: Chapter 5
April 5, 2023
Solutions to Scala with Cats: Chapter 6
April 5, 2023
Solutions to Scala with Cats: Chapter 7
April 7, 2023
Solutions to Scala with Cats: Chapter 8
April 7, 2023
Solutions to Scala with Cats: Chapter 9
April 8, 2023
Solutions to Scala with Cats: Chapter 10
April 8, 2023
Solutions to Scala with Cats: Chapter 11

Solutions to Scala with Cats: Chapter 11

April 8, 2023

These are my solutions to the exercises of chapter 11 of Scala with Cats.

Table of Contents

Exercise 11.2.3: GCounter Implementation

GCounter can be implemented as follows:

final case class GCounter(counters: Map[String, Int]) {
  def increment(machine: String, amount: Int): GCounter =
    GCounter(counters.updatedWith(machine)(v => Some(v.getOrElse(0) + amount)))

  def merge(that: GCounter): GCounter =
    GCounter(that.counters.foldLeft(counters) { case (acc, (k, v)) =>
      acc.updatedWith(k)(_.map(_ max v).orElse(Some(v)))
    })

  def total: Int =
    counters.values.sum
}

Exercise 11.3.2: BoundedSemiLattice Instances

The following are possible implementations of BoundedSemiLattice for Ints and Sets. As described in the problem statement, we don’t need to model non-negativity in the instance for Ints:

import cats.kernel.CommutativeMonoid

trait BoundedSemiLattice[A] extends CommutativeMonoid[A] {
  def combine(a1: A, a2: A): A
  def empty: A
}

object BoundedSemiLattice {
  implicit val intBoundedSemiLattice: BoundedSemiLattice[Int] =
    new BoundedSemiLattice[Int] {
      def combine(a1: Int, a2: Int): Int = a1 max a2
      def empty: Int = 0
    }

  implicit def setBoundedSemiLattice[A]: BoundedSemiLattice[Set[A]] =
    new BoundedSemiLattice[Set[A]] {
      def combine(a1: Set[A], a2: Set[A]): Set[A] = a1 union a2
      def empty: Set[A] = Set.empty[A]
    }
}

Exercise 11.3.3: Generic GCounter

The following is a possible implementation of a generic GCounter that uses instances of CommutativeMonoid and BoundedSemiLattice:

import cats.kernel.CommutativeMonoid
import cats.syntax.foldable._
import cats.syntax.semigroup._

final case class GCounter[A](counters: Map[String, A]) {
  def increment(machine: String, amount: A)(implicit m: CommutativeMonoid[A]): GCounter[A] =
    GCounter(counters |+| Map(machine -> amount))

  def merge(that: GCounter[A])(implicit b: BoundedSemiLattice[A]): GCounter[A] =
    GCounter(counters |+| that.counters)

  def total(implicit m: CommutativeMonoid[A]): A =
    counters.values.toList.combineAll
}

Exercise 11.4: Abstracting GCounter to a Type Class

The implementation of an instance of the GCounter type class for Map closely resembles the original GCounter implementation:

import cats.kernel.CommutativeMonoid
import cats.syntax.foldable._
import cats.syntax.semigroup._

trait GCounter[F[_, _], K, V] {
  def increment(f: F[K, V])(k: K, v: V)(implicit m: CommutativeMonoid[V]): F[K, V]
  def merge(f1: F[K, V], f2: F[K, V])(implicit b: BoundedSemiLattice[V]): F[K, V]
  def total(f: F[K, V])(implicit m: CommutativeMonoid[V]): V
}

object GCounter {
  implicit def mapInstance[K, V]: GCounter[Map, K, V] =
    new GCounter[Map, K, V] {
      def increment(f: Map[K, V])(k: K, v: V)(implicit m: CommutativeMonoid[V]): Map[K, V] =
        f |+| Map(k -> v)

      def merge(f1: Map[K, V], f2: Map[K, V])(implicit b: BoundedSemiLattice[V]): Map[K, V] =
        f1 |+| f2

      def total(f: Map[K, V])(implicit m: CommutativeMonoid[V]): V =
        f.values.toList.combineAll
    }
}

Exercise 11.5: Abstracting a Key Value Store

The following is a possible implementation of an instance of the KeyValueStore type class for Map:

trait KeyValueStore[F[_, _]] {
  def put[K, V](f: F[K, V])(k: K, v: V): F[K, V]
  def get[K, V](f: F[K, V])(k: K): Option[V]
  def values[K, V](f: F[K, V]): List[V]

  def getOrElse[K, V](f: F[K, V])(k: K, default: V): V =
    get(f)(k).getOrElse(default)
}

object KeyValueStore {
  implicit val mapInstance: KeyValueStore[Map] =
    new KeyValueStore[Map] {
      def put[K, V](f: Map[K, V])(k: K, v: V): Map[K, V] =
        f + (k -> v)

      def get[K, V](f: Map[K, V])(k: K): Option[V] =
        f.get(k)

      def values[K, V](f: Map[K, V]): List[V] =
        f.values.toList
    }
}

Solutions to Scala with Cats: Chapter 10

April 8, 2023

These are my solutions to the exercises of chapter 10 of Scala with Cats.

Table of Contents

Exercise 10.3: Basic Combinators

The and method of Check will create a new Check that calls apply on both instances. However, we soon hit the problem of what to do if they both return a Left:

def and(that: Check[E, A]): Check[E, A] =
  new Check[E, A] {
    def apply(value: A): Either[E, A] = {
      val selfCheck = self.apply(value)
      val thatCheck = that.apply(value)
      // How to combine if both fail?

      ???
    }
  }

We need a way to combine values of type E, which hints towards the need for a Semigroup instance for E. We’re assuming that we don’t want to short-circuit but rather accumulate all errors.

For the and implementation, we follow the algebraic data type style that is recommended by the book:

import cats.Semigroup
import cats.syntax.either._
import cats.syntax.semigroup._

sealed trait Check[E, A] {
  import Check._

  def and(that: Check[E, A]): Check[E, A] =
    And(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Either[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)) match {
          case (Left(e1), Left(e2)) => (e1 |+| e2).asLeft
          case (Left(e),  Right(_)) => e.asLeft
          case (Right(_), Left(e))  => e.asLeft
          case (Right(_), Right(_)) => a.asRight
        }
    }
}

object Check {
  final case class And[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]

  final case class Pure[E, A](func: A => Either[E, A]) extends Check[E, A]

  def pure[E, A](f: A => Either[E, A]): Check[E, A] =
    Pure(f)
}

Validated is a more appropriate data type to accumulate errors than Either. We can also rely on the Applicative instance for Validated to avoid the pattern match:

import cats.Semigroup
import cats.data.Validated
import cats.syntax.apply._

sealed trait Check[E, A] {
  import Check._

  def and(that: Check[E, A]): Check[E, A] =
    And(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)).mapN((_, _) => a)
    }
}

object Check {
  final case class And[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]

  final case class Pure[E, A](func: A => Validated[E, A]) extends Check[E, A]

  def pure[E, A](f: A => Validated[E, A]): Check[E, A] =
    Pure(f)
}

The or combinator should return a Valid if the left hand side is Valid or if the left hand side is Invalid but the right hand side is Valid. If both are Invalid, it should return an Invalid combining both errors. Due to the latter, we can’t rely on orElse but rather have a slightly more complicated implementation:

import cats.Semigroup
import cats.data.Validated
import cats.syntax.apply._
import cats.syntax.semigroup._

sealed trait Check[E, A] {
  import Check._

  def and(that: Check[E, A]): Check[E, A] =
    And(this, that)

  def or(that: Check[E, A]): Check[E, A] =
    Or(this, that)

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
    this match {
      case Pure(func) =>
        func(a)

      case And(left, right) =>
        (left(a), right(a)).mapN((_, _) => a)

      case Or(left, right) =>
        left(a) match {
          case Validated.Valid(a)    => Validated.Valid(a)
          case Validated.Invalid(el) =>
            right(a) match {
              case Validated.Valid(a)    => Validated.Valid(a)
              case Validated.Invalid(er) => Validated.Invalid(el |+| er)
            }
        }
    }
}

object Check {
  final case class And[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]

  final case class Or[E, A](left: Check[E, A], right: Check[E, A]) extends Check[E, A]

  final case class Pure[E, A](func: A => Validated[E, A]) extends Check[E, A]

  def pure[E, A](f: A => Validated[E, A]): Check[E, A] =
    Pure(f)
}

Exercise 10.4.2: Checks

With our previous Check renamed to Predicate, we can implement the new Check with the proposed interface as follows, using an algebraic data type approach as before:

import cats.Semigroup
import cats.data.Validated

sealed trait Check[E, A, B] {
  import Check._

  def apply(a: A)(implicit s: Semigroup[E]): Validated[E, B]

  def map[C](func: B => C): Check[E, A, C] =
    Map[E, A, B, C](this, func)
}

object Check {
  final case class Map[E, A, B, C](check: Check[E, A, B], func: B => C) extends Check[E, A, C] {
    def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
      check(a).map(func)
  }

  final case class Pure[E, A](pred: Predicate[E, A]) extends Check[E, A, A] {
    def apply(a: A)(implicit s: Semigroup[E]): Validated[E, A] =
      pred(a)
  }

  def pure[E, A](pred: Predicate[E, A]): Check[E, A, A] =
    Pure(pred)
}

flatMap is a bit weird to implement because we don’t have a flatMap for Validated. Fortunately, we have flatMap in Either and a withEither method in Validated that allows us to apply a function over an Either that gets converted back to a Validated.

sealed trait Check[E, A, B] {
  // ...

  def flatMap[C](func: B => Check[E, A, C]) =
    FlatMap[E, A, B, C](this, func)

  // ...
}

object Check {
  // ...

  final case class FlatMap[E, A, B, C](check: Check[E, A, B], func: B => Check[E, A, C])
      extends Check[E, A, C] {
    def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
      check(a).withEither(_.flatMap(b => func(b)(a).toEither))
  }

  // ...
}

andThen gets implemented very similarly to flatMap, except that we don’t use the output of the first Check to decide which other Check to use. The next Check is already statically provided to us:

sealed trait Check[E, A, B] {
  // ...

  def andThen[C](that: Check[E, B, C]): Check[E, A, C] =
    AndThen[E, A, B, C](this, that)

  // ...
}

object Check {
  // ...

  final case class AndThen[E, A, B, C](left: Check[E, A, B], right: Check[E, B, C])
      extends Check[E, A, C] {
    def apply(a: A)(implicit s: Semigroup[E]): Validated[E, C] =
      left(a).withEither(_.flatMap(b => right(b).toEither))
  }

  // ...
}

Exercise 10.4.3: Recap

The helper predicates that are introduced in this exercise make use of a lift method on Predicate that we haven’t implemented yet. Its implementation can be something like the following:

object Predicate {
  // ...

  def lift[E, A](e: E, func: A => Boolean): Predicate[E, A] =
    pure(a => if (func(a)) Validated.Valid(a) else Validated.Invalid(e))

  // ...
}

A Check for username can be implemented as follows, making use of the longerThan and alphanumeric predicates.

val usernameCheck = Check.pure(longerThan(3) and alphanumeric)

A Check for the email address can be implemented as follows. We first check that the string contains at least one @, then split the string, check each of the sides and combine them back at the end:

val emailAddressCheck = {
  val checkLeft =
    Check.pure(longerThan(0))

  val checkRight =
    Check.pure(longerThan(3) and contains('.'))

  val checkLeftAndRight =
    Check.pure(Predicate.pure[Errors, (String, String)] { case ((left, right)) =>
      (checkLeft(left), checkRight(right)).mapN((_, _))
    })

  Check
    .pure(containsOnce('@'))
    .map({ str =>
      val Array(left, right) = str.split("@")
      (left, right)
    })
    .andThen(checkLeftAndRight)
    .map({ case ((left, right)) => s"$left@$right" })
}

Exercise 10.5: Kleislis

The run method on Predicate must return a A => Either[E, A]. We must rely on the existing apply method so we also need a Semigroup instance for E:

sealed trait Predicate[E, A] {
  // ...

  def run(implicit s: Semigroup[E]): A => Either[E, A] =
    a => apply(a).toEither

  // ...
}

Our checks don’t change much. We have decided to implement the email address check slightly differently here, applying the checks directly in the split step:

val usernameCheck = checkPred(longerThan(3) and alphanumeric)

val emailAddressCheck = {
  val checkLeft: Check[String, String] =
    checkPred(longerThan(0))

  val checkRight: Check[String, String] =
    checkPred(longerThan(3) and contains('.'))

  val split: Check[String, (String, String)] =
    check(_.split('@') match {
      case Array(name, domain) =>
        Right((name, domain))

      case _ =>
        Left(error("Must contain a single @ character"))
    })

  val join: Check[(String, String), String] =
    check({ case (l, r) => (checkLeft(l), checkRight(r)).mapN(_ + "@" + _) })

  split andThen join
}

Solutions to Scala with Cats: Chapter 9

April 7, 2023

These are my solutions to the exercises of chapter 9 of Scala with Cats.

Table of Contents

Exercise 9.2: Implementing foldMap

The signature of foldMap can be as follows. We add Monoid as a context bound for B:

def foldMap[A, B: Monoid](as: Vector[A])(f: A => B): B = ???

To implement the body of foldMap, we have moved the Monoid from a context bound to an implicit parameter list for easier access:

def foldMap[A, B](as: Vector[A])(f: A => B)(implicit monoid: Monoid[B]): B =
  as.map(f).foldLeft(monoid.empty)(monoid.combine)

On the code above, we have done both steps separetely for clarity (the map and the foldLeft), but we could have made the calls to func directly in the combine step of foldLeft.

Exercise 9.3.3: Implementing parallelFoldMap

We can implement parallelFoldMap as follows:

def parallelFoldMap[A, B: Monoid](values: Vector[A])(func: A => B): Future[B] = {
  val batches = Runtime.getRuntime().availableProcessors()
  val groups = (values.length + batches - 1) / batches
  val futures = values.grouped(groups).map(as => Future(foldMap(as)(func)))
  Future.sequence(futures).map(_.foldLeft(Monoid[B].empty)(Monoid[B].combine))
}

We determine the amount of batches in which to split our work based on the number of CPUs we have available. We then determine the size of our groups by dividing the length of our input by the number of batches we’re going to run, rounding up. We spawn a Future with foldMap for each group and join them via Future.sequence, reducing the results of each batch with the Monoid instance we have in scope for B.

Exercise 9.3.4: parallelFoldMap with more Cats

To implement parallelFoldMap using Foldable and Traverse we import the respective instances from cats.instances.vector and the syntax extension methods that allow us to call foldMap and traverse directly on Vector instances:

import cats.instances.vector._
import cats.syntax.foldable._
import cats.syntax.traverse._

def parallelFoldMap[A, B: Monoid](values: Vector[A])(func: A => B): Future[B] = {
  val batches = Runtime.getRuntime().availableProcessors()
  val groups = (values.length + batches - 1) / batches

  values
    .grouped(groups)
    .toVector
    .traverse(group => Future(group.foldMap(func)))
    .map(_.combineAll)
}