Pan Am’s Reservation Center in the 1950’s

Sudokus and Schedules

Solving Scheduling Problems with Tree Search

Machine learning is quite the rage these days, so much it is easy to lose sight of the fact there are other algorithms in the “AI” space. As a matter of fact, these algorithms can be so crucial that it can be neglectful to overlook them. Take for example search algorithms, or more specifically in this case constraint satisfaction.

Video version of this article

Imagine you needed to schedule classes and classrooms. There are 36 periods, 36 rooms, and 800 lectures as your dimensions to schedule against. Want to take a guess how many possible configurations there are? Here is the answer: 10²⁴⁹⁰ possible configurations. To put it in perspective, there are 10⁸⁰ observable atoms in the universe. Even a task as mundane as classroom scheduling deals with astronomical numbers and permutations, and ventures into NP Hard territory. But I can show you how to algorithmically solve these problems in a (usually) timely manner.

The field of operations research is not new, but its techniques and algorithms are vital to practical everyday problems. How do you maximize profit across several product lines with only so much factory capacity? How do you schedule 200 nurses in a hospital with different vacation requests, seniorities, union restrictions, and work hour regulations? What if you need to schedule sports tournaments and minimize team travel distances? How about optimizing the on-time performance of a train network? Or simply solving a Sudoku?

There are many algorithms out there to solve problems with an optimization nature. These include linear programming, metaheuristics, and integer programming just to name a few. I personally find these optimization and search algorithms quite fascinating, and there is an enormous number of practical problems to solve with them. It is also interesting how some of these algorithms apply directly to machine learning, as machine learning itself is an optimization problem at its core.

But today, I want to talk about how to schedule university classes against classrooms, as well as solving Sudokus. You can use this integer programming methodology to schedule staff, factory lines, cloud server jobs, transportation vehicles, and other resources under rule-based constraints. Rather than relying on iterative brute-force tactics to fit events into a schedule (which can be hopelessly inefficient), we can achieve magic one-click generation of a schedule using mathematical modeling. You can even adapt these approaches to build chess AI algorithms or do any discrete-based regression.

Before I start, I highly recommend this challenging but useful Coursera class on discrete optimization. This class is fairly ambitious but rewarding, useful, and fun. It is worth the time and energy, even if you have to pace yourself slowly.

Brace yourself, there is going to be quite a bit of code for you technical folks! It is important to keep your sights on the big picture and understand the model conceptually. So I encourage you to gloss over the code (or skip it altogether) the first time you read this. If you do decide to deep-dive, be sure you are familiar with object-oriented and functional programming.

Defining the Problem

In this article, we will generate a weekly university schedule against one classroom. We will plot the occupation state grid on two dimensions: classes vs a timeline of 15-minute discrete intervals. If we wanted to schedule against multiple rooms, that would be three dimensions: classes vs timeline vs room. We will stick with the former for now and do one room, and I will explain how you can do multiple rooms later.

These classes have differing lengths and may “recur” throughout the week. Each recurring session must start at the same time of day.

Here are the classes:

Psych 101 (1 hour, 2 sessions/week)

English 101 (1.5 hours, 2 sessions/week)

Math 300 (1.5 hours, 2 sessions/week)

Psych 300 (3 hours, 1 session/week)

Calculus I (2 hours, 2 sessions/week)

Linear Algebra I (2 hours, 3 sessions/week)

Sociology 101 (1 hour, 2 sessions/week)

Biology 101 (1 hour, 2 sessions/week)

Supply Chain 300 (2.5 hours, 2 sessions/week)

Orientation 101 (1 hour, 1 session/week)

The day should be broken up in discrete 15 minute increments, and classes can only be scheduled on those increments. In other words, a class can only start on the :00, :15, :30, or :45 of the hour.

The operating week is Monday through Friday. The operating day is as follows with a break from 11:30AM to 1:00PM:

8:00AM-11:30AM

1:00PM-5:00PM

YOUR OBJECTIVE: create a model that schedules these classes with no overlap and complies with these requirements.

SPOILER ALERT: Here is the solution we are ultimately going to calculate using an “AI” algorithm we build from scratch. If you want to learn how this is done, please keep reading.

SPOIILER ALERT: Here is the schedule our algorithm will build by the end of this article

Laying the Groundwork

Alright, overwhelmed yet? That’s a lot of rules and the number of permutations to explore is astronomical. But once I show you this technique, it will hopefully be pretty straight forward to implement.

The very first thing you should notice about this problem is how everything is broken up into “15 minute” blocks. This is not a continuous/linear problem but rather a discrete one, which is how most schedules are built in the real world. Imagine that we have created a timeline for the entire week broken up in 15 minute blocks, like this:

Note that the “…” is just a collapsed placeholder since we do not have enough room to display the 672 blocks for the week (672 = 7 days * 24 hours * 4 blocks in an hour).

Now let’s expand this concept and make the classes an axis against the timeline. Each intersection/cell is a Slot that can be 1 or 0. This binary variable will be solved to indicate whether or not that Slot is the start time for the first recurrence of that class. We will set them all to 0 for now as shown below:

A grid of our decision variables

This grid is crucial to thinking about this problem logically. It will make an effective visual aid because our constraints will focus on regions within the grid.

I am going to use Kotlin as the programming language, which works perfectly with Java libraries but is much more readable and concise than Java.We are going to take advantage of Java 8’s great LocalDate / LocalTime API to make our calendar work easier.

If you are not familiar with Kotlin, it is basically a “dumbed down Scala” similar to Swift, and used heavily for Android development. It essentially takes the practical features of Java, C#, Scala, Groovy, and Python to create a pragmatic industrial language. It also compiles to Java bytecode and interops with Java libraries seamlessly.

Let’s set up our basic rule parameters like so:

import java.time.LocalDate

import java.time.LocalTime





// Any Monday through Friday date range will work

val operatingDates =

LocalDate.of(2017,10,16)..LocalDate.of(2017,10,20) val operatingDay = LocalTime.of(8,0)..LocalTime.of(17,0)



val breaks = listOf<ClosedRange<LocalTime>>(

LocalTime.of(11,30)..LocalTime.of(12,59)

)

Next let’s declare the ScheduledClass which holds the properties of a given class we want to schedule.

data class ScheduledClass(val id: Int,

val name: String,

val hoursLength: Double,

val recurrences: Int,

val recurrenceGapDays: Int = 2)

The recurrenceGapDays is the minimum number of days needed between each recurrence’s start time. For instance, take Psych 100 which requires 2 repetitions and defaults to a 2-day gap. If the first class was on a MONDAY at 8AM, then the second repetition must be scheduled exactly 2 days (48 hours) later, which is WEDNESDAY at 8AM. We will default this value to 2 , which will put 48 hours between the start of each session.

Next we can declare all the ScheduledClass instances in a List :

val scheduledClasses = listOf(

ScheduledClass(

id=1,

name="Psych 101",

hoursLength=1.0,

recurrences=2

),

ScheduledClass(

id=2,

name="English 101",

hoursLength=1.5,

recurrences=3

),

ScheduledClass(

id=3,

name="Math 300",

hoursLength=1.5,

recurrences=2

),

ScheduledClass(

id=4,

name="Psych 300",

hoursLength=3.0,

recurrences=1

),

ScheduledClass(

id=5,

name="Calculus I",

hoursLength=2.0,

recurrences=2

),

ScheduledClass(

id=6,

name="Linear Algebra I",

hoursLength=2.0,

recurrences=3

),

ScheduledClass(

id=7,

name="Sociology 101",

hoursLength=1.0,

recurrences=2

),

ScheduledClass(

id=8,

name="Biology 101",

hoursLength=1.0,

recurrences=2

),

ScheduledClass(

id=9,

name="Supply Chain 300",

hoursLength=2.5,

recurrences=2

),

ScheduledClass(

id=10,

name="Orientation 101",

hoursLength=1.0,

recurrences=1

)

)

The Block class will represent each discrete 15-minute time period. We will use a Kotlin Sequence in combination with Java 8’s LocalDate/LocalTime API to generate all blocks for the entire planning window. We will also create a few helper properties to extract the timeRange as well as whether it is withinOperatingDay . The withinOperatingDay property will determine if this Block is within a schedulable window of time (e.g. not scheduled in the middle of the night or inside a break period).

/** A discrete, 15-minute chunk of time a class can be scheduled on */

data class Block(val range: ClosedRange<LocalDateTime>) {



val timeRange =

range.start.toLocalTime()..range.endInclusive.toLocalTime() /** indicates if this block is in operating day/break

constraints */

val withinOperatingDay get() =

breaks.all { timeRange.start !in it } &&

timeRange.start in operatingDay &&

timeRange.endInclusive in operatingDay



// manage instances

companion object {



/*

All operating blocks for the entire week, broken up in 15

minute increments.

Lazily initialize to prevent circular construction issues

*/

val all by lazy { generateSequence(operatingDates.start.atStartOfDay()){

dt -> dt.plusMinutes(15)

.takeIf { it.plusMinutes(15) <=

operatingDates.endInclusive.atTime(23,59)

}

}.map { Block(it..it.plusMinutes(15)) }

.toList()

}



/* only returns blocks within the operating times */

val allInOperatingDay by lazy {

all.filter { it.withinOperatingDay }

}

}

}

Note I am going to initialize items for each domain object using a lazy { } delegate. This is to prevent circular construction issues by not constructing the items until they are first called.

Finally, the Slot class will represent an intersection/cell between a ScheduledClass and a Block . We will generate all of them by pairing every ScheduledClass with every Block . We will also create an unassigned selected binary variable which will be null until we assign it a 1 or 0 .

data class Slot(val block: Block,

val scheduledClass: ScheduledClass) {



var selected: Int? = null



companion object {



val all by lazy {

Block.all.asSequence().flatMap { b ->

ScheduledClass.all.asSequence().map { Slot(b,it)}

}.toList()

}

}

}

Modeling the Constraints

Before I go into the implementation of the model, I should emphasize you can use mixed integer solver libraries to model the constraints with linear functions and have it solve for the selected variables. In Python you can use PuLp or PyOmo. On the Java platform you can use ojAlgo or OptaPlanner. For many of these libraries, I could also drop in a $10K IBM CPLEX license which can execute a solve more quickly for larger, more complex problems.

But I’m going to show how to build a solution from scratch. The benefit of doing optimization without any libraries is you get a great deal of control over the heuristics (the search strategies) and expressing the model in terms of your domain.

Before we dive into solving for the selected variables in each Slot , let us do some thought experiments to understand our constraints. I probably wasted 50 sheets of papers coming up with models to do this, but I found something that works. It is a bit abstract, but powerful and effective for this particular problem.