15 Nov, 2013

In this entry we will dive into the world of dynamic programming, by looking at one of the most simplest yet illustrative algorithmic problems, namely the problem of Interval Scheduling. We will start with special case of unweighted interval scheduling, and then elaborate from there into a more general case of weighted intervals.

Sample Problem

Some time ago when working on student and course management system for my client I needed to find out how many teaching periods are there withing a given date range.

Colleges and universities have different teaching periods types e.g. semesters, trimesters, quarters, summer terms of various length. So, with a date range given, what is the maximum study duration of non-overlapping terms within that range? That’s if our aim was to spend as much time at school as possible (consider we suddenly went crazy) what teaching periods should we consider enrolling in.

Here is sample set of available teaching periods:

Teaching Periods

Sounds like a simple quest! Take your time to figure it out what algorithm or probably heuristics would you employ?

Summer school in January??

I am sure that those who spotted that Summer School starts in January, already figured out what's going on. If not, then the explantion is trivial: I work for an Australian education provider, and there Summer is from December to February.

If you are new to the problem, chances that you would try some sort of heuristic in search for the greedy algorithm. For example you might opt for selecting whatever interval starts the earliest (Summer School in our case).

And if it wasn’t for weights (i.e. teaching periods have different durations and thus are weighted) then I bet you would definitely find such an algorithm. But it takes a bit of more involved process in case of weighted intervals.

The good news: this problem has an elegant and very efficient solution which is not hard to comprehend and implement.

Proper Problem Statement

While we definitely know what we are looking for, let’s start by redefining our problem more precisely. Informally, in Weighted Interval Scheduling each interval has a certain value (weight), and we want to select set of intervals of a maximum total weight.

So, suppose we have set of intervals , let’s denote this set as .

Arbitrary interval has a start and finish time as and respectively. Additionally each has a weight, denoted as .

What we want to find is a subset () of non-overlapping (aka mutually compatible) intervals, with the maximum possible sum of intervals’ weights:

Don’t panic, that formula is pretty much self explanatory:

Since we have a properly defined problem as well as precisely described expected outcome, it is a good time to start designing our algorithm.

Greedy algorithm

First let’s think of Weighted Interval Scheduling problem where each interval has an equal weight i.e. .

Effectively what we will be looking for is to get subset having as much intervals in it as possible. Indeed, if intervals have equal length, then the more intervals we are able to select the higher is our total weight.

It is pretty clear that (unweighted) Interval Scheduling is nothing more but a special case of a more general Weighted Interval Scheduling.

If you look into our list of teaching periods you will probably notice that you can select four intervals, maximum:

  1. Summer School
  2. Trimester 1
  3. Trimester 2
  4. Either Trimester 3 or Semester 2

The greedy strategy that happens to produce optimal solution for unweighted intervals is pretty simple: accept the interval which has the minimum finishing time .

That’s it! You just grab interval which has the earliest , discard any intervals that overlap with , and continue until there’s no more intervals to pick from. Try it with our primitive example, you will immediately grasp what is going on.

Let me further illustrate this with code:

class Interval(object):
    '''Date interval'''

    def __init__(self, title, start, finish):
        self.title = title
        self.start = int(time.mktime(datetime.strptime(start, "%d %b, %y").timetuple()))
        self.finish = int(time.mktime(datetime.strptime(finish, "%d %b, %y").timetuple()))

    def __repr__(self):
        return str((self.title, self.start, self.finish))

def schedule_unweighted_intervals(I):
    '''Use greedy algorithm to schedule unweighted intervals
       sorting is O(n log n), selecting is O(n)
       whole operation is dominated by O(n log n)
    '''

    I.sort(lambda x, y: x.finish - y.finish)  # f_1 <= f_2 <= .. <= f_n

    O = []
    finish = 0
    for i in I:
        if finish <= i.start:
            finish = i.finish
            O.append(i)

    return O

If you need more code to look at, here is complete implementation of Interval Scheduling in Python.

Now that we have wrapped up constant weight interval scheduling, what happens to our amazing greedy strategy if intervals have different weights? It will fail miserably.

Just consider situation when we need to decide which one to pick as the last interval: Trimester 3 or Semester 2? If we follow the greedy method for selection by the smaller finishing time , then we will have to pick Trimester 3 (instead of more longer Semester 2).

So, while unweighted intervals are easy to schedule, we need to spend more time on weighted ones!

Recursive solution

Before we proceed I have to make one assumption: all intervals are sorted by finish times i.e. . That’s not too much to ask, as you can always sort your data in say time, therefore it will not add much to our final asymptotic complexity.

Now, consider two intervals and . Let’s assume that is to mean i.e. interval comes before the interval . To make things more straightforward we will define a function : rightmost interval , where and intervals and are mutually compatible.

Basically, will help us talk about the first interval to the left of which is not-overlapping with it. I know that rightmost non-overlapping interval to the left of the , might sound complex at first, but I am sure it will settle once you meditate on it a bit.

When locating that rightmost interval we can use binary search which has an running time. Since we need to find , overall complexity is bound by .

When it comes to Python then bisect (yes internally it is implemented as a binary search) module provides a very succinct solution:

def calculate_previous_intervals(I):
    '''For every interval j, calculate the rightmost mutually compatible interval i, where i < j
       I is a sorted list of Interval objects (sorted by finish time)
    '''
    # extract start and finish times
    start = [i.start for i in I]
    finish = [i.finish for i in I]

    p = []
    for j in xrange(len(I)):
        i = bisect.bisect_right(finish, start[j]) - 1  # rightmost interval f_i <= s_j
        p.append(i)

    return p

I also posted on GitHub the complete implementation of Weighted Interval Selection in Python.

Attention! We come to the crucial point of our discussion, where we will finally get an insight into solving the Weighted Interval Scheduling problem.

Let’s think about optimal solution . This solution is what we are after, but we do not know what it looks like. However, even without knowing much about the actual solution we can infer that last interval is either part of optimal solution or not:

Indeed, it is perfectly safe to assume such a dichotomy. And since it is a dichotomy, we can look into both possibilities and see if we can make any further inferences.

Case 1:

If is a part of optimal solution , then we can be sure that no interval between can be part of the (because all intervals within that range will be overlapping with by the definition of ). So, if , we can discard intervals .

Another important conclusion is that if , then optimal solution must also include an optimal solution to the problem set . Indeed, if is an optimal solution and , then can be divided into and optimal solution to a set of intervals between (we denote such a smaller solution as ).

Case 2:

When then our optimal solution is equal to the optimal solution to a smaller problem consisting of intervals . Indeed, if is not a part of optimal solution, then we look for such a solution within a smaller set consisting of intervals .

Finding optimal solution in set ,

After we reviewed both cases it is obvious that in order to find optimal solution (for elements) we have to look into smaller and smaller problems in the form of .

Let’s denote such optimal solution over intervals as , and actual value (maximum sum of weights) as . So, for any given we have the very same dichotomy: either or , therefore we can have the crucial piece of our design:

That is value of optimal solution is either or , whichever is bigger.

And from this follows another related inference: some arbitrary interval is a part of optimal solution iff:

At this point we have everything necessary to solve the original problem. Indeed we have all we need to compute optimal solutions for any interval and by definition of our problem statement we are after the solution:

def compute_opt(j):
    if j == 0:
        return 0

    return max(w[j] + compute_opt(p[j]), compute_opt(j - 1))

Since we recursively computing optimal solutions to smaller and smaller subsets, we recalculate the very same values over and over again, which results in exponential execution time i.e. unacceptable. The obvious solution is memoization to avoid recalculations, which will result in amazing runtime.

Now say we have calculated all our optimal solutions values: . What if we need (and we generally do) to have actual intervals gathered? Easy:

# given OPT and p, find actual solution intervals in O(n)
O = []
def compute_solution(j):
    if j >= 0:  # will halt on OPT[-1]
        if I[j].weight + OPT[p[j]] > OPT[j - 1]:
            O.append(I[j])
            compute_solution(p[j])
        else:
            compute_solution(j - 1)
compute_solution(len(I) - 1)

As you see, we have used the idea that interval is in optimal solution iff:

At this point we solved our original problem by designing adequate algorithm (more details on how good is our algorithm are in Algorithm Analysis section).

Dynamic programming

The pith of our algorithm is obtaining the list. Once we got it, everything else was trivial to unfold.

So let’s scrutinize that list, and see if we need a recursive solution to calculate it.

Given the formula

we know that any given value of depends on previous values: either or .

So what if we start from the first interval and traverse to the th, can we compute values iteratively? We can! For any given to have access to previous values, all we need to do is to persist already calculated items as we traverse the set :

# compute OPTs iteratively in O(n), here we use DP
OPT = collections.defaultdict(int)
OPT[-1] = 0
OPT[0] = 0
for j in xrange(1, len(I)):
    OPT[j] = max(I[j].weight + OPT[p[j]], OPT[j - 1])

And since we are iteratively building up subproblems to finally arrive to the one we are originally expected to solve , we are having fun with dynamic programming (or DP for short).

In its essence DP involves dynamically building up sub-solutions, one after another, to finally solve the case in question.

I will write more about DP, not only because the technique is essential knowledge for any decent programmer, but also because solutions involving dynamic algorithms represent real elegance and beauty of the Computer Science.

Algorithm Analysis

It is time to briefly analyse our algorithms and their worst case complexities. Should you have any issues on Big-Oh analysis the best resource on the topic is Skiena’s “Algorithm Design Manual”.

Here is a list of steps we have to take and their respective time complexities:

1. Sort intervals set , so that

If we use something like Quicksort or Mergesort and get away with (on average in case of Quicksort and as a worst case for the Mergesort).

2. Calculate

We iterate over different items, and for each item we need to find the first non-overlapping interval to the left of it. Suppose we use binary search to locate that non-overlapping neighbor interval (which has complexity).

Then, since we have two growing functions and , the total time complexity will be their product:

3. Calculate

Both memoized and iterative versions of function yield .

4. Calculate solution (given list)

Since we already have values calculated, recursively traversing them is an operation.

5. Overall performance

When adding Big-Oh complexities the dominant function defines the whole relationship. In our case it is

Not bad, I believe!

Summary

That’s it for today, so let me conclude this article by quick summary:

Have a nice day!

NB: I created a repository with complete implementations of both weighted and unweighted problem variants. Feel free to fork and send me PR should you locate some issues with the code!

References

Follow @farazdagi Tweet