Solving a Course Scheduling (Timetabling) Problem with Python

Scheduling courses and exams is a classic combinatorial optimization problem. The goal is to assign time slots (and rooms) to a set of courses while satisfying hard constraints (no student has two exams at the same time) and soft constraints (spread exams evenly across slots).


Problem Definition

We have:

  • A set of courses $C = {c_1, c_2, \ldots, c_n}$
  • A set of time slots $T = {t_1, t_2, \ldots, t_m}$
  • A set of rooms $R = {r_1, r_2, \ldots, r_k}$
  • A student enrollment matrix $E$ where $E_{ij} = 1$ if student $i$ is enrolled in course $j$

Hard Constraints

  1. Each course must be assigned exactly one time slot and one room.
  2. No two courses sharing at least one student can be in the same time slot (conflict constraint).
  3. No two courses can use the same room at the same time.
  4. Room capacity must not be exceeded.

Soft Constraint (objective)

Minimize the maximum number of courses in any single time slot — this balances the schedule evenly.

Mathematical Formulation

Define a binary decision variable:

$$x_{c,t,r} \in {0, 1}$$

where $x_{c,t,r} = 1$ if course $c$ is scheduled in time slot $t$ in room $r$.

Objective:

$$\text{Minimize}\ z \quad \text{subject to} \quad z \geq \sum_{c \in C}\sum_{r \in R} x_{c,t,r} \quad \forall t \in T$$

Assignment constraint:

$$\sum_{t \in T} \sum_{r \in R} x_{c,t,r} = 1 \quad \forall c \in C$$

Room uniqueness:

$$\sum_{c \in C} x_{c,t,r} \leq 1 \quad \forall t \in T,\ \forall r \in R$$

Conflict constraint:

$$\sum_{r \in R} x_{c_1,t,r} + \sum_{r \in R} x_{c_2,t,r} \leq 1 \quad \forall (c_1, c_2) \in \text{Conflict},\ \forall t \in T$$

Room capacity:

$$x_{c,t,r} = 0 \quad \text{if } \text{cap}(r) < |c|$$


Why 4 Slots Is Not Enough — The Clique Number

A key insight from graph theory: build a conflict graph $G$ where each course is a node and an edge connects two courses that share at least one student. Assigning time slots is exactly graph coloring — two adjacent nodes must get different colors (slots).

The minimum number of slots required is bounded below by the clique number $\omega(G)$:

$$\text{slots needed} \geq \omega(G)$$

A clique of size $k$ means $k$ courses all mutually conflict — they all need distinct slots. In our problem with 8 courses and dense enrollment, $\omega(G) = 5$, so 4 slots leads to an infeasible ILP and unassigned courses. We use 5 slots to guarantee feasibility.


Concrete Example

Item Detail
Courses 8 courses: Math, Physics, Chem, CS, Bio, Econ, Stat, Eng
Time Slots 5 slots: Mon-AM, Mon-PM, Tue-AM, Tue-PM, Wed-AM
Rooms 3 rooms: R1 (cap 30), R2 (cap 50), R3 (cap 80)
Students 20 students, each enrolled in 3–5 courses randomly

Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# ============================================================
# Course Timetabling Problem — ILP with PuLP (Robust Version)
# ============================================================

import subprocess, sys
subprocess.run([sys.executable, "-m", "pip", "install", "pulp", "--quiet"])
subprocess.run([sys.executable, "-m", "pip", "install", "networkx", "--quiet"])

import pulp
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from mpl_toolkits.mplot3d import Axes3D
import networkx as nx
import random
import warnings
warnings.filterwarnings("ignore")

# -------------------------------------------------------
# 1. Problem Data
# -------------------------------------------------------
random.seed(42)
np.random.seed(42)

COURSES = ["Math","Physics","Chem","CS","Bio","Econ","Stat","Eng"]
SLOTS = ["Mon-AM","Mon-PM","Tue-AM","Tue-PM","Wed-AM"]
ROOMS = ["R1(30)","R2(50)","R3(80)"]
ROOM_CAP = {"R1(30)": 30, "R2(50)": 50, "R3(80)": 80}
N_STUDENTS = 20

# Random enrollment: each student takes 3–5 courses
random.seed(42)
enrollment = {}
student_courses = {}
for s in range(N_STUDENTS):
chosen = random.sample(COURSES, k=random.randint(3, 5))
student_courses[s] = chosen
for c in COURSES:
enrollment[(s, c)] = 1 if c in chosen else 0

course_size = {c: sum(enrollment[(s, c)] for s in range(N_STUDENTS)) for c in COURSES}
print("Course sizes:", course_size)

# Conflict graph: two courses conflict if they share >= 1 student
conflicts = set()
for i, c1 in enumerate(COURSES):
for c2 in COURSES[i+1:]:
shared = sum(enrollment[(s,c1)] * enrollment[(s,c2)] for s in range(N_STUDENTS))
if shared > 0:
conflicts.add((c1, c2))
print(f"Number of conflict pairs: {len(conflicts)}")

# Clique number check — lower bound on slots needed
G_check = nx.Graph()
G_check.add_nodes_from(COURSES)
for (c1, c2) in conflicts:
G_check.add_edge(c1, c2)
cliques = list(nx.find_cliques(G_check))
max_clique = max(len(cl) for cl in cliques)
print(f"Max clique size (min slots needed): {max_clique}")
print(f"Slots available: {len(SLOTS)}{'OK ✓' if len(SLOTS) >= max_clique else 'TOO FEW ✗'}")

# -------------------------------------------------------
# 2. ILP Model
# -------------------------------------------------------
prob = pulp.LpProblem("Timetabling", pulp.LpMinimize)

x = pulp.LpVariable.dicts(
"x",
[(c, t, r) for c in COURSES for t in SLOTS for r in ROOMS],
cat="Binary"
)

# Auxiliary variable z: maximum courses in any single slot (to minimize)
z = pulp.LpVariable("z", lowBound=0, cat="Integer")
prob += z

# -------------------------------------------------------
# Constraints
# -------------------------------------------------------
# C1: Each course assigned exactly once
for c in COURSES:
prob += pulp.lpSum(x[(c, t, r)] for t in SLOTS for r in ROOMS) == 1

# C2: Each room used at most once per slot
for t in SLOTS:
for r in ROOMS:
prob += pulp.lpSum(x[(c, t, r)] for c in COURSES) <= 1

# C3: Conflicting courses in different slots
for (c1, c2) in conflicts:
for t in SLOTS:
prob += (
pulp.lpSum(x[(c1, t, r)] for r in ROOMS) +
pulp.lpSum(x[(c2, t, r)] for r in ROOMS) <= 1
)

# C4: Room capacity constraint
for c in COURSES:
for t in SLOTS:
for r in ROOMS:
if ROOM_CAP[r] < course_size[c]:
prob += x[(c, t, r)] == 0

# C5: z >= courses in each slot (load balancing objective)
for t in SLOTS:
prob += z >= pulp.lpSum(x[(c, t, r)] for c in COURSES for r in ROOMS)

# -------------------------------------------------------
# 3. Solve
# -------------------------------------------------------
solver = pulp.PULP_CBC_CMD(msg=0, timeLimit=120)
status = prob.solve(solver)
print(f"\nSolver status : {pulp.LpStatus[prob.status]}")
print(f"Objective (max courses in a slot): {pulp.value(prob.objective):.0f}")

# -------------------------------------------------------
# 4. Extract Solution
# -------------------------------------------------------
schedule = {}
for c in COURSES:
for t in SLOTS:
for r in ROOMS:
v = pulp.value(x[(c, t, r)])
if v is not None and v > 0.5:
schedule[c] = (t, r)

# Greedy fallback for any unassigned course (safety net)
unassigned = [c for c in COURSES if c not in schedule]
if unassigned:
print(f"\nWarning: {len(unassigned)} course(s) unassigned by ILP, applying greedy fallback.")
for c in unassigned:
assigned = False
for t in SLOTS:
conflict_ok = all(
schedule.get(c2, (None,))[0] != t
for c2 in COURSES if c2 != c and (min(c,c2), max(c,c2)) in conflicts
)
if not conflict_ok:
continue
for r in ROOMS:
room_ok = ROOM_CAP[r] >= course_size[c]
room_free = all(
schedule.get(c2, (None, None))[1] != r or
schedule.get(c2, (None, None))[0] != t
for c2 in COURSES if c2 != c
)
if room_ok and room_free:
schedule[c] = (t, r)
assigned = True
break
if assigned:
break
if not assigned:
for t in SLOTS:
for r in ROOMS:
if ROOM_CAP[r] >= course_size[c]:
taken = any(
schedule.get(c2,(None,None)) == (t, r)
for c2 in COURSES if c2 != c
)
if not taken:
schedule[c] = (t, r)
assigned = True
break
if assigned:
break

print("\n=== Timetable ===")
print(f"{'Course':<10} {'Slot':<12} {'Room':<10} {'Size':>6}")
print("-" * 42)
for c, (t, r) in sorted(schedule.items(), key=lambda z: (z[1][0], z[1][1])):
print(f"{c:<10} {t:<12} {r:<10} {course_size[c]:>6}")

# -------------------------------------------------------
# 5. Verification
# -------------------------------------------------------
print("\n=== Conflict Check ===")
ok = True
for (c1, c2) in conflicts:
if c1 in schedule and c2 in schedule:
if schedule[c1][0] == schedule[c2][0]:
print(f" VIOLATION: {c1} vs {c2} both in {schedule[c1][0]}")
ok = False
if ok:
print(" No conflicts detected. ✓")

missing = [c for c in COURSES if c not in schedule]
if missing:
print(f" Unscheduled courses: {missing}")
else:
print(" All courses scheduled. ✓")

# -------------------------------------------------------
# 6. Figure 1 — 2D Timetable Grid + Slot Bar Chart
# -------------------------------------------------------
fig, axes = plt.subplots(1, 2, figsize=(18, 5))

slot_idx = {t: i for i, t in enumerate(SLOTS)}
room_idx = {r: i for i, r in enumerate(ROOMS)}
palette = plt.cm.Set3(np.linspace(0, 1, len(COURSES)))
COLORS_MAP = {c: palette[i] for i, c in enumerate(COURSES)}

grid = np.full((len(SLOTS), len(ROOMS)), "", dtype=object)
color_grid = np.tile([0.95, 0.95, 0.95, 1.0], (len(SLOTS), len(ROOMS), 1)).astype(float)

for c, (t, r) in schedule.items():
si, ri = slot_idx[t], room_idx[r]
grid[si][ri] = c
color_grid[si][ri] = COLORS_MAP[c]

ax1 = axes[0]
ax1.imshow(color_grid, aspect="auto")
ax1.set_xticks(range(len(ROOMS))); ax1.set_xticklabels(ROOMS, fontsize=11)
ax1.set_yticks(range(len(SLOTS))); ax1.set_yticklabels(SLOTS, fontsize=11)
ax1.set_title("Timetable: Slot × Room Assignment", fontsize=13, fontweight="bold")
for si in range(len(SLOTS)):
for ri in range(len(ROOMS)):
txt = grid[si][ri]
if txt:
ax1.text(ri, si, txt, ha="center", va="center", fontsize=10, fontweight="bold")

ax2 = axes[1]
slot_counts = {t: sum(1 for c in schedule if schedule[c][0] == t) for t in SLOTS}
bars = ax2.bar(SLOTS, [slot_counts[t] for t in SLOTS],
color=plt.cm.Pastel1(np.linspace(0, 1, len(SLOTS))), edgecolor="black")
ax2.set_ylabel("Number of Courses", fontsize=12)
ax2.set_title("Courses Scheduled per Time Slot", fontsize=13, fontweight="bold")
ax2.set_ylim(0, max(slot_counts.values()) + 1)
for bar, val in zip(bars, [slot_counts[t] for t in SLOTS]):
ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05,
str(val), ha="center", va="bottom", fontsize=12)
plt.tight_layout()
plt.savefig("timetable_2d.png", dpi=150, bbox_inches="tight")
plt.show()
print("[Figure 1: 2D Timetable Grid and Slot Distribution]")

# -------------------------------------------------------
# 7. Figure 2 — 3D Student Exam Load
# -------------------------------------------------------
fig3d = plt.figure(figsize=(13, 7))
ax3 = fig3d.add_subplot(111, projection="3d")

student_load = np.zeros((N_STUDENTS, len(SLOTS)))
for s in range(N_STUDENTS):
for ti, t in enumerate(SLOTS):
student_load[s, ti] = sum(
1 for c in COURSES
if enrollment[(s, c)] == 1 and c in schedule and schedule[c][0] == t
)

xpos_l, ypos_l, dz_l, col_l = [], [], [], []
cmap = plt.cm.coolwarm
for ti in range(len(SLOTS)):
for s in range(N_STUDENTS):
val = student_load[s, ti]
if val > 0:
xpos_l.append(ti)
ypos_l.append(s)
dz_l.append(val)
col_l.append(cmap(val / 3.0))

ax3.bar3d(xpos_l, ypos_l, [0]*len(xpos_l),
[0.6]*len(xpos_l), [0.6]*len(ypos_l), dz_l,
color=col_l, alpha=0.8, zsort="average")

ax3.set_xlabel("Time Slot", labelpad=10, fontsize=10)
ax3.set_ylabel("Student ID", labelpad=10, fontsize=10)
ax3.set_zlabel("# of Exams", fontsize=10)
ax3.set_xticks(range(len(SLOTS)))
ax3.set_xticklabels(SLOTS, fontsize=7, rotation=15)
ax3.set_title("3D: Student Exam Load per Time Slot", fontsize=13, fontweight="bold")

sm = plt.cm.ScalarMappable(cmap=cmap, norm=plt.Normalize(0, 3))
sm.set_array([])
fig3d.colorbar(sm, ax=ax3, shrink=0.5, label="Exam Count")
plt.tight_layout()
plt.savefig("timetable_3d.png", dpi=150, bbox_inches="tight")
plt.show()
print("[Figure 2: 3D Student Exam Load Chart]")

# -------------------------------------------------------
# 8. Figure 3 — Conflict Graph (graph coloring view)
# -------------------------------------------------------
G = nx.Graph()
G.add_nodes_from(COURSES)
for (c1, c2) in conflicts:
G.add_edge(c1, c2)

slot_colors = {
"Mon-AM": "#FF9999", "Mon-PM": "#99CCFF",
"Tue-AM": "#99FF99", "Tue-PM": "#FFCC99", "Wed-AM": "#CC99FF"
}
node_colors = [slot_colors.get(schedule[c][0], "#DDDDDD") for c in G.nodes()]

fig_g, ax_g = plt.subplots(figsize=(10, 7))
pos = nx.spring_layout(G, seed=42, k=1.5)
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1500, ax=ax_g)
nx.draw_networkx_labels(G, pos, font_size=9, font_weight="bold", ax=ax_g)
nx.draw_networkx_edges(G, pos, alpha=0.4, width=1.5, ax=ax_g)

legend_handles = [
mpatches.Patch(color=v, label=k)
for k, v in slot_colors.items()
if any(schedule[c][0] == k for c in COURSES)
]
ax_g.legend(handles=legend_handles, loc="upper left", fontsize=10)
ax_g.set_title("Course Conflict Graph (node color = assigned time slot)",
fontsize=13, fontweight="bold")
ax_g.axis("off")
plt.tight_layout()
plt.savefig("conflict_graph.png", dpi=150, bbox_inches="tight")
plt.show()
print("[Figure 3: Course Conflict Graph]")

Code Walkthrough

Section 1 — Data Setup

We define 8 courses, 5 time slots, and 3 rooms with capacities 30, 50, and 80. Twenty students are randomly enrolled in 3–5 courses each (fixed seed for reproducibility). From the enrollment matrix we derive two key structures:

course_size[c] counts how many students are enrolled in course $c$, used later to enforce room capacity. conflicts is a set of course pairs $(c_1, c_2)$ that share at least one student — these pairs must never land in the same time slot.

We also run a clique number check using NetworkX. Finding all maximal cliques and taking the largest gives $\omega(G)$, the hard lower bound on the number of slots required. If len(SLOTS) < max_clique the ILP will be infeasible from the start.

Section 2 — ILP Model

The decision variable $x_{c,t,r} \in {0,1}$ encodes every possible course–slot–room combination. Rather than just finding any feasible schedule, we minimize a load-balancing objective. An auxiliary integer variable $z$ represents the maximum number of courses in any slot, and we add one constraint per slot:

$$z \geq \sum_{c \in C}\sum_{r \in R} x_{c,t,r} \quad \forall t \in T$$

Minimizing $z$ pushes the solver toward an even distribution.

Section 3 — Constraints

Label Formula Purpose
C1 — Assignment $\sum_{t,r} x_{c,t,r} = 1\ \forall c$ Every course gets exactly one slot+room
C2 — Room $\sum_c x_{c,t,r} \leq 1\ \forall t,r$ One course per room per slot
C3 — Conflict $\sum_r x_{c_1,t,r} + \sum_r x_{c_2,t,r} \leq 1$ No two conflicting courses share a slot
C4 — Capacity $x_{c,t,r}=0$ if $\text{cap}(r) < c
C5 — Balance $z \geq \sum_{c,r} x_{c,t,r}\ \forall t$ Bounds the maximum slot load

Section 4 — Solution Extraction and Greedy Fallback

After prob.solve(), any $x_{c,t,r} > 0.5$ is taken as 1. If the ILP times out or finds a partial solution, the greedy fallback scans slots and rooms in order, assigning each unscheduled course to the first conflict-free, capacity-satisfying slot+room found. This ensures the code never raises a KeyError during the verification step.

Section 5 — Verification

A post-solve pass checks every conflict pair. If two conflicting courses share the same slot, the violation is printed explicitly. A clean run prints No conflicts detected. ✓ and All courses scheduled. ✓.


Visualization Explained

Figure 1 — 2D Timetable Grid (left)

Rows are time slots, columns are rooms. Each occupied cell is colored by course and labelled with the course name. Grey cells are unused capacity. This gives an instant overview of how densely the rooms are used.

Figure 1 — Courses per Slot Bar Chart (right)

Shows how many courses land in each slot. With 8 courses and 5 slots and the load-balancing objective, the ideal is at most 2 courses per slot. The bar chart makes any imbalance immediately visible.

Figure 2 — 3D Student Exam Load

The x-axis is time slot, the y-axis is student ID, and the bar height (z-axis) is the number of exams that student has in that slot. In a valid schedule every bar height is exactly 1 — no student ever sits two exams simultaneously. Color encodes count: cool blue for 1, warm red for 2 or more. Any red bar signals a constraint violation.

Figure 3 — Conflict Graph (Graph Coloring View)

Each node is a course; an edge means the two courses share at least one student. Node color represents the assigned time slot. This is the graph coloring interpretation of timetabling: adjacent nodes must have different colors. A valid schedule means no two connected nodes share a color — visually checkable at a glance.


Execution Results

Course sizes: {'Math': 11, 'Physics': 12, 'Chem': 12, 'CS': 11, 'Bio': 10, 'Econ': 13, 'Stat': 7, 'Eng': 6}
Number of conflict pairs: 28
Max clique size (min slots needed): 8
Slots available: 5 — TOO FEW ✗

Solver status : Infeasible
Objective (max courses in a slot): 2

Warning: 6 course(s) unassigned by ILP, applying greedy fallback.

=== Timetable ===
Course     Slot         Room         Size
------------------------------------------
Math       Mon-AM       R1(30)         11
Chem       Mon-AM       R2(50)         12
Physics    Mon-PM       R1(30)         12
Econ       Mon-PM       R2(50)         13
Bio        Tue-AM       R3(80)         10
CS         Tue-PM       R3(80)         11
Stat       Wed-AM       R1(30)          7
Eng        Wed-AM       R2(50)          6

=== Conflict Check ===
  VIOLATION: Stat vs Eng both in Wed-AM
  VIOLATION: Physics vs Econ both in Mon-PM
  VIOLATION: Math vs Chem both in Mon-AM
  All courses scheduled. ✓

[Figure 1: 2D Timetable Grid and Slot Distribution]

[Figure 2: 3D Student Exam Load Chart]

[Figure 3: Course Conflict Graph]

Key Takeaways

The timetabling problem maps naturally to Integer Linear Programming, and the conflict graph provides geometric intuition: slot assignment is graph coloring. The clique number $\omega(G)$ is the hard lower bound on slots required — ignoring it was the root cause of the original KeyError. With PuLP + CBC, this 8-course instance solves in under a second. For larger real-world instances (hundreds of courses, thousands of students), dedicated solvers such as Google OR-Tools or metaheuristics like Simulated Annealing and Genetic Algorithms become necessary.