00. Why do these exams clash?

An exam office has ten courses and two time slots. When students are enrolled in two courses, those exams should be separated. We will build the optimization problem from that sentence before trying to solve it.

What information do we actually have?

The course codes are labels; the mathematics does not depend on their names.

courses = ["LA", "OPT", "STAT", "ML", "NUM",
           "CTRL", "PROB", "GRAPH", "DATA", "SIGNAL"]

# (course 1, course 2, number of shared students)
conflicts = [
    (1, 2, 20), (1, 5, 10), (1, 6, 15),
    (2, 3, 10), (2, 6, 20), (2, 7, 10),
    (3, 4, 20), (3, 7, 15), (3, 8, 10),
    (4, 5, 10), (4, 8, 20), (4, 9, 15),
    (5, 9, 20), (5, 10, 10),
    (6, 7, 10), (6, 10, 20),
    (7, 8, 20), (7, 10, 15),
    (8, 9, 10), (9, 10, 20),
]

[(courses[i], courses[j], shared) for (i, j, shared) in conflicts]
20-element Vector{Tuple{String, String, Int64}}:
 ("LA", "OPT", 20)
 ("LA", "NUM", 10)
 ("LA", "CTRL", 15)
 ("OPT", "STAT", 10)
 ("OPT", "CTRL", 20)
 ("OPT", "PROB", 10)
 ("STAT", "ML", 20)
 ("STAT", "PROB", 15)
 ("STAT", "GRAPH", 10)
 ("ML", "NUM", 10)
 ("ML", "GRAPH", 20)
 ("ML", "DATA", 15)
 ("NUM", "DATA", 20)
 ("NUM", "SIGNAL", 10)
 ("CTRL", "PROB", 10)
 ("CTRL", "SIGNAL", 20)
 ("PROB", "GRAPH", 20)
 ("PROB", "SIGNAL", 15)
 ("GRAPH", "DATA", 10)
 ("DATA", "SIGNAL", 20)

This is already a model, with assumptions worth stating. We count only pairwise clashes, treat every affected student equally, and allow exactly two slots. A real registrar would also handle room capacity, more time slots, instructors, and accessibility constraints. Those omissions make this a teaching problem rather than a deployable scheduler.

How can a picture preserve the records?

Make one vertex for each course. Join two vertices with an edge when the courses share students. Give that edge a weight equal to the number of shared students.

NoteGo further: graph vocabulary

If vertices, weighted edges, and adjacency matrices are new, the relevant background is only the opening graph definitions—not a full graph-theory course. MIT OpenCourseWare’s Mathematics for Computer Science graph notes are a useful optional reference.

We divide the counts by ten to keep the displayed objective small. This changes its units from students to tens of students; it does not change which schedule is best.

using LinearAlgebra
using LearnSDP

n = length(courses)
W = zeros(n, n)
for (i, j, shared) in conflicts
    W[i, j] = W[j, i] = shared / 10
end

@assert W == course_graph()
(symmetric = issymmetric(W), diagonal = diag(W), nonzero_edges = count(!iszero, W) ÷ 2)
Precompiling packages...
   1271.3 msQuartoNotebookWorkerJSONExt (serial)
  1 dependency successfully precompiled in 1 seconds
(symmetric = true, diagonal = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], nonzero_edges = 20)

The symmetric matrix W is the graph’s weighted adjacency matrix. Entry W[i,j] records the edge weight; a zero means that our data contains no clash for that pair. Symmetry records that a clash between LA and OPT is the same clash viewed in either order.

Now the picture has a provenance:

A circular graph contains ten labeled course codes. Lines join course pairs that share students, and thicker lines represent more shared students.

The ten-course conflict graph built from the enrollment-overlap table.

The labels match courses; thicker edges have larger weights. The circular placement is a drawing choice made to keep the picture readable. Distance around the circle has no meaning.

When does a schedule avoid a clash?

A two-slot schedule divides the vertices into two groups. In graph language, that division is a cut. An edge crosses the cut when its courses land in different slots. Its weight then counts toward the conflicts avoided.

Represent slot A by 1 and slot B by -1. Here is one proposed schedule:

slot = [1, 1, -1, -1, 1, -1, 1, -1, 1, -1]

slot_A = courses[slot .== 1]
slot_B = courses[slot .== -1]
crossing = [
    (courses[i], courses[j], W[i, j])
    for i in 1:n for j in (i + 1):n
    if W[i, j] > 0 && slot[i] != slot[j]
]

(slot_A, slot_B, crossing, score = sum(last, crossing))
(slot_A = ["LA", "OPT", "NUM", "PROB", "DATA"], slot_B = ["STAT", "ML", "CTRL", "GRAPH", "SIGNAL"], crossing = [("LA", "CTRL", 1.5), ("OPT", "STAT", 1.0), ("OPT", "CTRL", 2.0), ("STAT", "PROB", 1.5), ("ML", "NUM", 1.0), ("ML", "DATA", 1.5), ("NUM", "SIGNAL", 1.0), ("CTRL", "PROB", 1.0), ("PROB", "GRAPH", 2.0), ("PROB", "SIGNAL", 1.5), ("GRAPH", "DATA", 1.0), ("DATA", "SIGNAL", 2.0)], score = 17.0)

The score is 17, meaning this schedule separates 170 shared-student pairs in the original units. Edges left inside a slot are the unresolved clashes.

Swapping the names of slots A and B negates every sign but changes no crossing edge. That symmetry will matter when we count possible schedules.

What are we trying to maximize?

Our first optimization problem is therefore:

Divide the courses into two slots to maximize the total weight of edges whose endpoints lie in different slots.

This is the weighted maximum-cut problem, usually shortened to weighted Max-Cut. The next chapter will search the small instance exactly and translate the verbal score into algebra. Semidefinite programming will enter only after we understand the problem it is relaxing.

Can you improve the first schedule?

  1. Move LA to the other slot. Which three edge contributions can change?
  2. Construct a schedule with score greater than 17 without using a solver.
  3. Explain why storing only the number of courses would be insufficient.
  4. Name one real scheduling constraint omitted by this graph model and explain what can go wrong because of the omission.