11

I have a number of range-objects which I need to merge so that all overlapping ranges disappear:

case class Range(from:Int, to:Int)

val rangelist = List(Range(3, 40), Range(1, 45), Range(2, 50), etc)

Here is the ranges:

  3  40  
  1  45  
  2  50  
 70  75  
 75  90  
 80  85  
100 200

Once finished we would get:

  1  50  
 70  90  
100 200  

Imperative Algorithm:

  1. Pop() the first range-obj and iterate through the rest of the list comparing it with each of the other ranges.
  2. if there is an overlapping item, merge them together ( This yields a new Range instance ) and delete the 2 merge-candidates from the source-list.
  3. At the end of the list add the Range object (which could have changed numerous times through merging) to the final-result-list.
  4. Repeat this with the next of the remaining items.
  5. Once the source-list is empty we're done.

To do this imperatively one must create a lot of temporary variables, indexed loops etc.

So I'm wondering if there is a more functional approach?

At first sight the source-collection must be able to act like a Stack in providing pop() PLUS giving the ability to delete items by index while iterating over it, but then that would not be that functional anymore.

recalcitrant
  • 939
  • 11
  • 23

4 Answers4

14

Try tail-recursion. (Annotation is needed only to warn you if tail-recursion optimization doesn't happen; the compiler will do it if it can whether you annotate or not.)

import annotation.{tailrec => tco}
@tco final def collapse(rs: List[Range], sep: List[Range] = Nil): List[Range] = rs match {
  case x :: y :: rest =>
    if (y.from > x.to) collapse(y :: rest, x :: sep)
    else collapse( Range(x.from, x.to max y.to) :: rest, sep)
  case _ =>
    (rs ::: sep).reverse
}
def merge(rs: List[Range]): List[Range] = collapse(rs.sortBy(_.from))
Rex Kerr
  • 166,841
  • 26
  • 322
  • 407
  • That assumes `rs` is ordered by range initial elements. It would be better just to make `x contains y.from`. – Daniel C. Sobral Feb 10 '12 at 04:58
  • 1
    `merge` sorts and passes to `collapse`. If you don't do it this way your runtime is `O(n^2)` instead of `O(n log n)` like it should be. – Rex Kerr Feb 10 '12 at 05:43
  • 1
    You should add the `@annotation.tailrec` on your `collapse` method so the compiler will emit an error if it cannot perform tail-call optimization. – leedm777 Feb 13 '12 at 02:34
  • @dave - One can add this annotation if one is unsure. In this case, I am sure, but it's good to know about the existence of the annotation. – Rex Kerr Feb 13 '12 at 03:54
  • @RexKerr - I've seen enough cases where code looked like it _should_ optimize but didn't that I always add the annotation when I'm assuming TCO. For example, your `collapse` function won't optimize when defined as a non-private, non-final method on a class. – leedm777 Feb 15 '12 at 21:56
  • @dave - True enough; added `final` just to be on the safe side for cut-and-paste. – Rex Kerr Feb 15 '12 at 22:26
12

I love these sorts of puzzles:

case class Range(from:Int, to:Int) {
  assert(from <= to)

  /** Returns true if given Range is completely contained in this range */
  def contains(rhs: Range) = from <= rhs.from && rhs.to <= to

  /** Returns true if given value is contained in this range */
  def contains(v: Int) = from <= v && v <= to
}

def collapse(rangelist: List[Range]) = 
  // sorting the list puts overlapping ranges adjacent to one another in the list
  // foldLeft runs a function on successive elements. it's a great way to process
  // a list when the results are not a 1:1 mapping.
  rangelist.sortBy(_.from).foldLeft(List.empty[Range]) { (acc, r) =>
    acc match {
      case head :: tail if head.contains(r) =>
        // r completely contained; drop it
        head :: tail
      case head :: tail if head.contains(r.from) =>
        // partial overlap; expand head to include both head and r
        Range(head.from, r.to) :: tail
      case _ =>
        // no overlap; prepend r to list
        r :: acc
    }
  }
leedm777
  • 23,444
  • 10
  • 58
  • 87
3

Here's my solution:

def merge(ranges:List[Range]) = ranges
  .sortWith{(a, b) => a.from < b.from || (a.from == b.from && a.to < b.to)}
  .foldLeft(List[Range]()){(buildList, range) => buildList match {
    case Nil => List(range)
    case head :: tail => if (head.to >= range.from) {
      Range(head.from, head.to.max(range.to)) :: tail
    } else {
      range :: buildList
    }
  }}
  .reverse

merge(List(Range(1, 3), Range(4, 5), Range(10, 11), Range(1, 6), Range(2, 8)))
//List[Range] = List(Range(1,8), Range(10,11))
Dan Simon
  • 12,891
  • 3
  • 49
  • 55
1

I ran into this need for Advent of Code 2022, Day 15, where I needed to merge a list of inclusive ranges. I had to slightly modify the solution for inclusiveness:

import annotation.{tailrec => tco}
@tco final def collapse(rs: List[Range], sep: List[Range] = Nil): List[Range] = rs match {
    case x :: y :: rest =>
      if (y.start - 1 > x.end) collapse(y :: rest, x :: sep)
      else collapse(Range.inclusive(x.start, x.end max y.end) :: rest, sep)
    case _ =>
      (rs ::: sep).reverse
  }
Doug Donohoe
  • 367
  • 1
  • 3
  • 11