Analyzing the search space
We are concerned today with extending the simple matching problem. The first extension is to allow representative of each row to match to more than one representative of the columns. A typical example of this is that the rows represent instructors, the columns represent courses and each instructors teaches more than one course.
Do our data will have the form of a cost matrix representing the value of assigning a row to a column, and a load vector to indicate how many columns each row should be assigned.
For instance:
| K | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 3 | 10 | 12 | 13 | 11 | 14 |
| 2 | 15 | 13 | 14 | 12 | 16 |
In general we will have a set \(S\), a set \(T\), a cost matrix \(C_{ij}\), \(i\in S\) and \(j \in T\) a load vector \(K_i\), \(i\in S\). The data in the load vector must satisfy \(\sum_i K_i = |T|\) to ensure that each column is assigned.
We will contrast two formulations, one based on binary variables (the
traditional Integer Programming model) and one based on integer
variables using the AllDifferent constraint.
The IP type model is based on \begin{equation} \min \sum_i \sum_j c_{ij}x_{ij} \end{equation} Subject to: \begin{equation} \sum_j x_{ij} = k_i \quad \forall i \in S \end{equation} \begin{equation} \sum_i x_{ij} = 1 \quad \forall j \in T \end{equation} This is a simple extension of the previous problem but leads to interesting difference in behaviour. Here are the two models I want to contrast, written in python using Google's or-tools. First the IP-type model using a real IP solver:
def realipsolve(C,K): m = len(C) n = len(C[0]) #For IP-model with IP-solver from linear_solver import pywraplp solver = pywraplp.Solver('CoinsGridCLP', pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING) #solver = pywrapcp.Solver('IPModel') x = [[solver.IntVar(0,1, 'x[%i,%i]' % (i,j)) for j in range(n)] for i in range(m)] for i in range(m): solver.Add(solver.Sum([x[i][j] for j in range(n)]) == K[i]) for j in range(n): solver.Add(solver.Sum([x[i][j] for i in range(m)]) == 1) value = solver.IntVar(0,sum([C[i][j] for i in range(m) for j in range(n)]),'value') solver.Add(value == solver.Sum([x[i][j]*C[i][j] for i in range(m) for j in range(n)])) solver.Minimize(value) solver.Solve() best = round(value.SolutionValue()) solx = [(i,j,C[i][j]) for i in range(m) for j in range(n) if x[i][j].SolutionValue()>0] return best,solx
Now, essentially the same model, but solved using the CP solver:
def ipsolve(C,K): m = len(C) n = len(C[0]) from constraint_solver import pywrapcp solver = pywrapcp.Solver('IPModel') x = [[solver.IntVar(0,1, 'x[%i,%i]' % (i,j)) for i in range(n)] for j in range(m)] for i in range(m): solver.Add(solver.Sum([x[i][j] for j in range(n)]) == K[i]) for j in range(n): solver.Add(solver.Sum([x[i][j] for i in range(m)]) == 1) value = solver.IntVar(0,sum([C[i][j] for i in range(m) for j in range(n)]),'value') solver.Add(value == solver.Sum([x[i][j]*C[i][j] for i in range(m) for j in range(n)])) objective = solver.Minimize(value,1) x_flat = [x[i][j] for i in range(m) for j in range(n)] db = solver.Phase(x_flat, solver.CHOOSE_MIN_SIZE_LOWEST_MAX, solver.ASSIGN_MIN_VALUE) solution = solver.Assignment() solution.Add(x_flat) solution.AddObjective(value) solver.NewSearch(db, [objective]) while solver.NextSolution(): best = int(value.Value()) solx = [(i,j,C[i][j]) for i in range(m) for j in range(n) if x[i][j].Value()>0] return best,solx
Finally the real CP model. The one I expect to shine (eventually):
def cpsolve(C,K): from constraint_solver import pywrapcp m = len(C) n = len(C[0]) solver = pywrapcp.Solver('CPModel') x = [[solver.IntVar(0,n-1,'x[%i]' % i) for j in range(K[i])] for i in range(m)] value = solver.IntVar(0,sum([C[i][j] for i in range(m) for j in range(n)]),'value') solver.Add(value == solver.Sum([solver.Element(C[i],x[i][j]) for i in range(m) for j in range(K[i])])) objective = solver.Minimize(value,1) xflat = [x[i][j] for i in range(m) for j in range(K[i])] solver.Add(solver.AllDifferent(xflat)) db = solver.Phase(xflat, solver.INT_VAR_SIMPLE, solver.ASSIGN_MIN_VALUE) solver.NewSearch(db, [objective]) while solver.NextSolution(): best = int(value.Value()) solx = [(i,j,int(x[i][j].Value())) for i in range(m) for j in range(K[i])] return best,solx
For a first experiment, I will run the three models on random matrices of size \(m \times 3m\) with increasing \(m\). The results follow:
| m | n | IP model | IP Model | CP Model |
|---|---|---|---|---|
| IP Solver | CP Solver | CP Solver | ||
| 3 | 9 | 0.01 | 0.01 | 0.13 |
| 4 | 12 | 0.01 | 0.02 | 103 |
| 5 | 15 | 0.01 | 0.10 | 54000 |
I expected the CP model to do much better than this. What is going on here? Let us look at the search space in more detail. First, with an example. Say \(m=2, n=4, K = [2,2]\) and the search picks the first variable available with the first value possible. This leads to the following space in the case of the IP model
Each node is labelled with the variable that is considered next in the search space; the edge indicate the value taken.
In general, given \(m\) rows, \(n\) columns and load given by \(k_i\), the number of leaves is given by:
\begin{displaymath} {n \choose k_1} {{n-k_1} \choose k_2} {{n-k_1-k_2} \choose k_3} \cdots {k_m \choose k_m} = \frac{n!}{k_1! k_2! \cdots k_m!} = \frac{n!}{\prod_i k_i!} \end{displaymath}Let's contrast with the search space of the CP model.
In general, the number of leaves can be counted as: \begin{displaymath} n^{\underline{k_1}} (n-k_1)^{\underline{k_2}} (n-k_1-k_2)^{\underline{k_3}} \cdots (k_m)^{\underline{k_m}} = n! \end{displaymath} Where the notation \(n^{\underline{k}}\) is the falling factorial: \begin{equation} n^{\underline{k}} = n (n-1)(n-2)\cdots (n-k+1) \end{equation} Now the reason the CP model is so slow is apparent: there are many more leaves to the search tree. Fortunately, the solution is also apparent. If we consider the leaves, we see repetitions of a certain kind: the solution \(x_1=[1,2]\) is not, for our purposes, different from the solution \(x_1 = [2,1]\). In both cases it means that row one is assigned to columns 1 and 2. We need to modify the model to produce only one version of these identical solutions. Let us choose a canonical representation, where the values are sorted in increases order. To enforce this we need to add constraints of the form \begin{displaymath} x_{ij} < x_{i{j+1}} \quad \forall i \in S \end{displaymath} In our python code, this becomes:
# Symmetry breaking for i in range(m): for j in range(K[i]-1): solver.Add(x[i][j] < x[i][j+1])
What is the effect of these additional constraints on the search space? It cuts off a number of branches. For instance, at the first level of the tree, it cuts off the whole subtree starting on \(x_{11}=4\) since the domain of \(x_{12}\) would be empty. The resulting search space is a tree with exactly six leaves:
Notice that the tree is not symmetric. It has the same number of leaves as the IP model, but fewer internal nodes, which explains why it will solver faster. Indeed here is a sample of experiments:
| m | n | IP model | IP Model | CP Model |
|---|---|---|---|---|
| IP Solver | CP Solver | CP Solver | ||
| 3 | 9 | 0.01 | 0.01 | 0.00 |
| 4 | 12 | 0.01 | 0.02 | 0.01 |
| 5 | 15 | 0.01 | 0.42 | 0.10 |
| 6 | 18 | 0.01 | 10.74 | 2.06 |
| 7 | 21 | 0.01 | 36.75 | 24.43 |
| 8 | 24 | 0.01 | 5325.91 | 1465.26 |
Now, the central core of the problem we want to solve is well modelled and solves in a reasonable time, though still much slower than what an IP solver can do. But we are nowhere done. We will next investigate how to guide the search more effectively.


