quadratic-assignment-problem
Skill7 starsChanged 5 months agoMITView on GitHub
When the user wants to model or solve the quadratic assignment problem — assigning n facilities to n locations to minimize the total flow-times-distance cost — with linearized MIP models or robust tabu search built on O(n) swap delta evaluation. Also use when the user mentions "quadratic assignment," "QAP," "flow distance," "QAPLIB," "facility layout," or "Koopmans-Beckmann," or when pairwise interaction costs depend on a permutation. For linear assignment without interaction terms, see assignment-problems; for choosing among product linearizations, see linearization-techniques.
In this repository75 other files it gives its agents.
- algorithm-benchmarking-statisticsSkill
- ant-colony-optimizationSkill
- assignment-problemsSkill
- benders-decompositionSkill
- biased-random-key-genetic-algorithmSkill
- bin-packingSkill
- branch-and-boundSkill
- column-generationSkill
- constraint-handling-techniquesSkill
- constraint-programmingSkill
- crossover-operatorsSkill
- cutting-planes-valid-inequalitiesSkill
- cutting-stockSkill
- dantzig-wolfe-decompositionSkill
- decoder-based-representationsSkill
- differential-evolutionSkill
- diversity-and-population-managementSkill
- dynamic-programmingSkill
- estimation-of-distribution-algorithmsSkill
- evolution-strategiesSkill
- facility-location-problemSkill
- fitness-evaluation-and-cachingSkill
- fitness-landscape-analysisSkill
- flow-shop-schedulingSkill
- genetic-algorithmsSkill
- git-for-research-codeSkill
- graph-coloringSkill
- graspSkill
- guided-local-searchSkill
- gurobi-advanced-featuresSkill
- hyper-heuristicsSkill
- instance-generation-and-benchmarksSkill
- integer-programming-techniquesSkill
- iterated-local-searchSkill
- job-shop-schedulingSkill
- knapsack-problemsSkill
- lagrangian-relaxationSkill
- large-neighborhood-searchSkill
- linearization-techniquesSkill
- linear-programming-fundamentalsSkill
- local-search-and-neighborhoodsSkill
- lot-sizingSkill
- matheuristicsSkill
- matplotlib-optimization-visualizationSkill
- memetic-algorithmsSkill
- metaheuristic-design-principlesSkill
- milp-modeling-gurobiSkill
- multi-objective-optimizationSkill
- mutation-and-perturbation-operatorsSkill
- nature-inspired-metaheuristics-overviewSkill
- network-flow-optimizationSkill
- numpy-vectorization-for-optimizationSkill
- open-source-solversSkill
- optimization-project-structureSkill
- optuna-hyperparameter-tuningSkill
- pandas-experiment-managementSkill
- parallel-and-hybrid-metaheuristicsSkill
- parallel-machine-schedulingSkill
- particle-swarm-optimizationSkill
- problem-formulationSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 3 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 215
Every result you report must pass an independent validator that shares no code with the solver or the delta machinery.
- Very specific: line 389
Always validate the returned permutation with validate_qap_solution before reporting: the MIP objective and the validator must agree to …
- Distinctive: line 396
All competitive QAP heuristics search the 2-exchange (swap) neighborhood: exchange the locations of facilities r and s.
- Very specific: line 462
Move attribute tabu list: After moving facility r away from location p(r), forbid re-assigning r to p(r) for tenure iterations.
- Distinctive: line 671
Use the randomized tenure in [0.9n, 1.1n].
- Very specific: line 684
Runs are not reproducible: Every stochastic component must take an explicit seed (np.random.default_rng(seed)), and result rows must record …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Quadratic Assignment Problem
You are an expert in the quadratic assignment problem (QAP). This skill covers the flow-times-distance objective, MIP linearizations and why exact solving stalls around n = 30, Gilmore-Lawler bounds, robust tabu search as the heuristic method of choice, O(n) and amortized-O(1) delta evaluation, QAPLIB instances and validation. Use the framework below to pick a formulation, build correct code, and report defensible results.
Initial Assessment
Establish the following before recommending a model or writing code:
- Instance size n. The single most important fact. n ≤ 15: any exact model works. n ≤ 25-30: exact is possible with effort and time. n > 30: plan for heuristics, with bounds only for gap reporting.
- Symmetry and diagonals. Are F and D symmetric with zero diagonals? Most QAPLIB instances are. The fast delta-evaluation formulas below assume it; the general formulas cost the same asymptotically but more constants. Check before coding.
- Linear term. Is there a fixed cost b_ik for placing facility i at location k (Koopmans-Beckmann with linear part)? It changes the objective but not the structure.
- Sparsity of F. Many real layout instances have sparse flow matrices (esc, ste families in QAPLIB). Sparsity makes exact methods reach much larger n.
- Data source and format. QAPLIB .dat file, a distance matrix from coordinates, or raw flow logs that still need aggregation? Confirm the objective convention: QAPLIB uses sum over ordered pairs, so symmetric instances count each pair twice.
- Exactness requirement. Does the user need a provably optimal layout (rare) or a high-quality solution with a reported gap to the best-known value (common)?
- Solver availability. Gurobi license present? Without it, the Kaufman-Broeckx model below runs on any LP/MIP solver, and the tabu search needs only numpy.
- Time budget. Robust tabu search delivers near-best-known QAPLIB results in seconds to minutes; exact runs at n = 25+ can take hours to days.
- Quality reference. Are best-known values available (QAPLIB .sln files) so gaps can be reported, or must you generate bounds yourself (Gilmore-Lawler, MIP dual bound)?
- Repetition protocol. How many seeds/replications are expected for the heuristic, and is statistical comparison against another method required?
Problem Definition and Formulations
Formal definition
Given n facilities and n locations, a flow matrix $F = (f_{ij}) \in \mathbb{R}{\ge 0}^{n \times n}$ (material moved between facilities i and j per period) and a distance matrix $D = (d{k\ell}) \in \mathbb{R}_{\ge 0}^{n \times n}$ (cost per unit flow between locations k and ℓ), find a permutation $\pi$ of ${1,\dots,n}$, where $\pi(i)$ is the location of facility i, minimizing
$$ \min_{\pi \in S_n} ; \sum_{i=1}^{n} \sum_{j=1}^{n} f_{ij}, d_{\pi(i)\pi(j)} ;+; \sum_{i=1}^{n} b_{i\pi(i)}, $$
with the optional linear placement cost $B = (b_{ik})$. This is the Koopmans & Beckmann (1957) form, introduced for plant layout. The fully general Lawler (1963) QAP replaces $f_{ij} d_{k\ell}$ with an arbitrary cost tensor $c_{ijk\ell}$; everything below extends to it at higher constant cost. In binary variables $x_{ik} = 1$ iff facility i sits at location k:
$$ \min \sum_{i,j,k,\ell} f_{ij} d_{k\ell}, x_{ik} x_{j\ell} \quad \text{s.t.} \quad \sum_k x_{ik} = 1 ;\forall i, \qquad \sum_i x_{ik} = 1 ;\forall k, \qquad x_{ik} \in {0,1}. $$
Equivalently, over permutation matrices X: $\min \operatorname{tr}(F X D^\top X^\top)$. The QAP is NP-hard, and even finding an ε-approximate solution is NP-hard (Sahni & Gonzalez, 1976). The TSP is the special case where F is the adjacency matrix of a Hamiltonian cycle, which already signals how hard the general problem is.
Linearizations
The product $x_{ik} x_{j\ell}$ must be removed to obtain a MILP. The classic options:
| Linearization | Extra variables | Extra constraints | LP-relaxation bound | Use when |
| Kaufman & Broeckx (1978) | n² continuous | n² | Very weak (often 0) | Memory is tight; any MIP solver; small n |
| Frieze & Yadegar (1983) | n⁴ continuous | O(n³) | Moderate, ≥ Gilmore-Lawler | n ≤ ~20 and a stronger bound is needed |
| Adams & Johnson (1994), level-1 RLT | n⁴ continuous | O(n⁴) | Strong; dominates the two above | Basis of serious exact codes; heavy memory |
| Native binary quadratic (Gurobi) | Solver-internal | Solver-internal | Depends on PreQLinearize | Fast to write; let presolve pick the reformulation |
The Kaufman-Broeckx model is the smallest known MILP for the QAP; its price is an LP bound that is usually 0, so branch-and-bound progress comes almost entirely from integer feasibility. The level-1 reformulation-linearization technique (RLT) bound of Adams & Johnson is what strong exact codes compute, typically via the Hahn & Grant (1998) dual-ascent procedure rather than a generic LP solver. See linearization-techniques for the generic machinery behind these constructions.
Why exact solving stalls around n = 30
- The symmetric group has n! permutations; at n = 30 that is ~2.7·10³². Branch-and-bound survives only with strong bounds, and QAP bounds are expensive: Gilmore-Lawler is cheap but loose, RLT-1 is strong but needs O(n⁴) memory, RLT-2/RLT-3 are stronger still but explode to O(n⁶)/O(n⁸) data.
- The landmark exact solve of nug30 (Anstreicher, Brixius, Goux & Linderoth, 2002) used a convex quadratic programming bound and a computational grid of roughly a thousand processors for about a week — on the order of several CPU-years for one instance of size 30.
- Several QAPLIB instances of size 30-40 (notably the tai-a family) still have no proven optimum; reported "best known" values come from heuristics.
- Structure changes the picture: sparse or highly symmetric instances (esc family) have been solved up to n = 128 because symmetry reduction collapses the search tree.
Practical consequence: treat n ≈ 30 as the ceiling for general-purpose exact solving, use a MIP only when n is small or a provable gap is genuinely required, and otherwise go directly to robust tabu search with best-known values or bounds for quality control.
Method selection
| Situation | Recommended approach |
| n ≤ 12, optimality proof wanted | Any MILP below; solves in seconds |
| 12 < n ≤ 25, optimality proof wanted | Kaufman-Broeckx or native quadratic + time limit; expect hours at the top of the range |
| n > 25, proof wanted | Specialized RLT-based branch-and-bound codes; budget CPU-years; usually reconsider |
| Any n, good solution fast | Robust tabu search (Taillard, 1991) — the standard QAP heuristic |
| Heuristic stalls on hard instances | Memetic algorithm with tabu/local-search improvement (see memetic-algorithms) |
| Quick quality floor without solving | Gilmore-Lawler bound (code in Advanced Techniques) |
Instances, QAPLIB, and Validation
QAPLIB (Burkard, Karisch & Rendl, 1997) is the canonical benchmark archive: ~130 instances, n from 12 to 256, with best-known solutions in companion .sln files. Families differ sharply in character: nug/tho/kra are layout-like with grid distances, tai-a are uniform random (hard for everything), tai-b are structured/clustered, esc are sparse. Always state which family you tested, because heuristic rankings change across families.
Generate controlled synthetic instances when you need size/structure sweeps. Two standard generators — uniform (tai-a style) and grid layout (nugent style):
import numpy as np
def _random_symmetric(rng: np.random.Generator, n: int, high: int) -> np.ndarray:
"""Symmetric nonnegative integer matrix with zero diagonal."""
upper = np.triu(rng.integers(1, high + 1, size=(n, n)), k=1)
return (upper + upper.T).astype(float)
def generate_uniform_qap(n: int, seed: int, high: int = 100) -> tuple[np.ndarray, np.ndarray]:
"""Taillard-style uniform random instance (hard, unstructured)."""
rng = np.random.default_rng(seed)
return _random_symmetric(rng, n, high), _random_symmetric(rng, n, high)
def generate_grid_qap(rows: int, cols: int, seed: int, density: float = 0.4,
high: int = 10) -> tuple[np.ndarray, np.ndarray]:
"""Facility-layout instance: Manhattan grid distances, sparse symmetric flows."""
rng = np.random.default_rng(seed)
n = rows * cols
r, c = np.divmod(np.arange(n), cols)
D = (np.abs(r[:, None] - r[None, :]) + np.abs(c[:, None] - c[None, :])).astype(float)
keep = np.triu(rng.random((n, n)) < density, k=1)
flows = np.triu(rng.integers(1, high + 1, size=(n, n)), k=1) * keep
F = (flows + flows.T).astype(float)
return F, D
F, D = generate_uniform_qap(8, seed=42)
print(F.shape, bool(np.allclose(F, F.T)), float(F.diagonal().sum()))
Fg, Dg = generate_grid_qap(3, 4, seed=42)
print(Dg.max())
# Expected: (8, 8) True 0.0 then 5.0 (max Manhattan distance on a 3x4 grid)For instances with a planted optimum (useful for regression-testing heuristics), use the Palubeckis (2000) generator idea: build D as a graph metric and F so that a chosen permutation aligns large flows with small distances; cite the construction if you report results on such instances, because they are known to be easier than tai-a.
QAPLIB files are whitespace-separated token streams — line breaks vary between instances, so parse tokens, never lines:
from pathlib import Path
import numpy as np
def read_qaplib(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
"""Parse a QAPLIB .dat file: n, then two n-by-n matrices A and B.
Objective convention: sum_ij A[i, j] * B[perm[i], perm[j]] over ordered pairs.
"""
tokens = Path(path).read_text().split()
n = int(tokens[0])
values = np.array(tokens[1:1 + 2 * n * n], dtype=float)
return values[: n * n].reshape(n, n), values[n * n:].reshape(n, n)
def read_qaplib_solution(path: str | Path) -> tuple[float, np.ndarray]:
"""Parse a QAPLIB .sln file: 'n value' then a 1-indexed permutation."""
tokens = Path(path).read_text().split()
n, value = int(tokens[0]), float(tokens[1])
perm = np.array(tokens[2:2 + n], dtype=int) - 1
return value, perm
import tempfile
content = "3\n\n0 2 0\n2 0 1\n0 1 0\n\n0 1 2\n1 0 1\n2 1 0\n"
with tempfile.NamedTemporaryFile("w", suffix=".dat", delete=False) as fh:
fh.write(content)
A, B = read_qaplib(fh.name)
print(A[0, 1], B[2, 0])
# Expected: 2.0 2.0Every result you report must pass an independent validator that shares no code with the solver or the delta machinery. Plain Python loops are deliberate here — slow, boring, and trustworthy:
import numpy as np
def validate_qap_solution(F: np.ndarray, D: np.ndarray, perm: np.ndarray,
reported_obj: float | None = None,
tol: float = 1e-6) -> tuple[bool, float, list[str]]:
"""Independent feasibility and objective check for a QAP solution.
Verifies that perm is a bijection and recomputes the objective from the
definition. Returns (feasible_and_consistent, objective, issues).
"""
issues: list[str] = []
n = F.shape[0]
p = np.asarray(perm)
if F.shape != (n, n) or D.shape != (n, n):
issues.append(f"F is {F.shape}, D is {D.shape}; both must be ({n}, {n})")
if p.shape != (n,):
issues.append(f"permutation has shape {p.shape}, expected ({n},)")
elif not np.array_equal(np.sort(p), np.arange(n)):
issues.append("perm is not a bijection onto {0, ..., n-1}")
obj = 0.0
if not issues:
for i in range(n):
for j in range(n):
obj += float(F[i, j]) * float(D[p[i], p[j]])
if reported_obj is not None and abs(obj - reported_obj) > tol:
issues.append(f"recomputed objective {obj} != reported {reported_obj}")
return (not issues), obj, issues
F3 = np.array([[0, 2, 0], [2, 0, 1], [0, 1, 0]], dtype=float)
D3 = np.array([[0, 1, 2], [1, 0, 1], [2, 1, 0]], dtype=float)
print(validate_qap_solution(F3, D3, np.array([0, 1, 2]), reported_obj=6.0))
# Expected: (True, 6.0, [])Exact MIP Models in Gurobi
Kaufman-Broeckx linearization
For each (i, k) introduce a continuous variable $w_{ik}$ that captures the interaction cost facility i incurs if placed at location k. With the upper bound $a_{ik} = \sum_{j,\ell} f_{ij} d_{k\ell} = (\sum_j f_{ij})(\sum_\ell d_{k\ell})$, the constraints
$$ w_{ik} ;\ge; \sum_{j,\ell} f_{ij} d_{k\ell}, x_{j\ell} ;-; a_{ik},(1 - x_{ik}), \qquad w_{ik} \ge 0, $$
force $w_{ik}$ to equal the true cost whenever $x_{ik} = 1$ (this needs $F, D \ge 0$), and minimizing $\sum_{i,k} w_{ik}$ recovers the QAP objective. Only n² extra variables and n² extra constraints — but the LP relaxation can set all $x_{ik} = 1/n$ and all $w_{ik} = 0$, so expect a root bound of 0 and slow gap closure.
import gurobipy as gp
import numpy as np
from gurobipy import GRB
def add_assignment_constraints(model: gp.Model, x: gp.tupledict, n: int) -> None:
"""Each facility takes exactly one location and each location one facility."""
model.addConstrs((gp.quicksum(x[i, k] for k in range(n)) == 1 for i in range(n)),
name="facility_assigned")
model.addConstrs((gp.quicksum(x[i, k] for i in range(n)) == 1 for k in range(n)),
name="location_filled")
def add_kaufman_broeckx_constraints(model: gp.Model, x: gp.tupledict, w: gp.tupledict,
F: np.ndarray, D: np.ndarray) -> None:
"""Link w[i, k] to the interaction cost of facility i at location k."""
n = F.shape[0]
a = F.sum(axis=1)[:, None] * D.sum(axis=1)[None, :] # a[i, k] upper bound
for i in range(n):
for k in range(n):
cost_ik = gp.quicksum(float(F[i, j] * D[k, l]) * x[j, l]
for j in range(n) if F[i, j] != 0.0
for l in range(n) if D[k, l] != 0.0)
model.addConstr(w[i, k] >= cost_ik - float(a[i, k]) * (1 - x[i, k]),
name=f"kb[{i},{k}]")
def solve_qap_kaufman_broeckx(F: np.ndarray, D: np.ndarray,
time_limit: float = 60.0) -> tuple[np.ndarray, float, float]:
"""Solve the QAP via the Kaufman-Broeckx MILP; returns (perm, objective, gap)."""
n = F.shape[0]
model = gp.Model("qap_kb")
model.Params.OutputFlag = 0
model.Params.TimeLimit = time_limit
x = model.addVars(n, n, vtype=GRB.BINARY, name="x")
w = model.addVars(n, n, lb=0.0, name="w")
add_assignment_constraints(model, x, n)
add_kaufman_broeckx_constraints(model, x, w, F, D)
model.setObjective(w.sum(), GRB.MINIMIZE)
model.optimize()
if model.Status not in (GRB.OPTIMAL, GRB.TIME_LIMIT) or model.SolCount == 0:
raise RuntimeError(f"no incumbent; Gurobi status {model.Status}")
perm = np.array([k for i in range(n) for k in range(n) if x[i, k].X > 0.5])
return perm, model.ObjVal, model.MIPGap
F3 = np.array([[0, 2, 0], [2, 0, 1], [0, 1, 0]], dtype=float)
D3 = np.array([[0, 1, 2], [1, 0, 1], [2, 1, 0]], dtype=float)
perm, obj, gap = solve_qap_kaufman_broeckx(F3, D3)
print(perm, obj, gap)
# Expected: permutation [0 1 2] (or [2 1 0]), objective 6.0, gap 0.0A linear placement term B is added by appending gp.quicksum(float(B[i, k]) * x[i, k]
for i in range(n) for k in range(n)) to the objective; nothing else changes.
Native binary quadratic model
Since the variables are binary, you can hand Gurobi the quadratic objective directly
and let presolve choose the linearization. PreQLinearize = 1 requests an MILP
reformulation aimed at a strong LP relaxation (RLT-flavored); 2 aims for compactness.
This is the fastest model to write and a good default for n ≤ ~20.
import gurobipy as gp
import numpy as np
from gurobipy import GRB
def add_assignment_constraints(model: gp.Model, x: gp.tupledict, n: int) -> None:
"""Each facility takes exactly one location and each location one facility."""
model.addConstrs((gp.quicksum(x[i, k] for k in range(n)) == 1 for i in range(n)),
name="facility_assigned")
model.addConstrs((gp.quicksum(x[i, k] for i in range(n)) == 1 for k in range(n)),
name="location_filled")
def solve_qap_quadratic(F: np.ndarray, D: np.ndarray,
time_limit: float = 60.0) -> tuple[np.ndarray, float, float]:
"""Solve the QAP as a binary quadratic program; Gurobi linearizes internally."""
n = F.shape[0]
model = gp.Model("qap_quadratic")
model.Params.OutputFlag = 0
model.Params.TimeLimit = time_limit
model.Params.PreQLinearize = 1 # reformulate for a strong LP relaxation
x = model.addVars(n, n, vtype=GRB.BINARY, name="x")
add_assignment_constraints(model, x, n)
objective = gp.QuadExpr()
for i in range(n):
for j in range(n):
if F[i, j] == 0.0:
continue
for k in range(n):
for l in range(n):
if D[k, l] != 0.0:
objective.add(x[i, k] * x[j, l], float(F[i, j] * D[k, l]))
model.setObjective(objective, GRB.MINIMIZE)
model.optimize()
if model.Status not in (GRB.OPTIMAL, GRB.TIME_LIMIT) or model.SolCount == 0:
raise RuntimeError(f"no incumbent; Gurobi status {model.Status}")
perm = np.array([k for i in range(n) for k in range(n) if x[i, k].X > 0.5])
return perm, model.ObjVal, model.MIPGap
F3 = np.array([[0, 2, 0], [2, 0, 1], [0, 1, 0]], dtype=float)
D3 = np.array([[0, 1, 2], [1, 0, 1], [2, 1, 0]], dtype=float)
perm, obj, gap = solve_qap_quadratic(F3, D3)
print(perm, obj, gap)
# Expected: permutation [0 1 2] (or [2 1 0]), objective 6.0, gap 0.0Model-building itself is O(n⁴) Python-loop work here; at n = 25 that is ~390,000
quadratic terms, which takes a few seconds — acceptable because solve time dominates.
Always validate the returned permutation with validate_qap_solution before reporting:
the MIP objective and the validator must agree to numerical tolerance.
Delta Evaluation and Robust Tabu Search
Swap delta in O(n)
All competitive QAP heuristics search the 2-exchange (swap) neighborhood: exchange the locations of facilities r and s. Recomputing the objective from scratch costs O(n²) per move; the delta can be computed in O(n). For general F, D (asymmetric, nonzero diagonals allowed), with p = π:
$$ \Delta(\pi, r, s) = f_{rr}(d_{p_s p_s} - d_{p_r p_r}) + f_{rs}(d_{p_s p_r} - d_{p_r p_s})
- f_{sr}(d_{p_r p_s} - d_{p_s p_r}) + f_{ss}(d_{p_r p_r} - d_{p_s p_s}) $$ $$
- \sum_{k \ne r,s} \Big[ (f_{kr} - f_{ks})(d_{p_k p_s} - d_{p_k p_r})
- (f_{rk} - f_{sk})(d_{p_s p_k} - d_{p_r p_k}) \Big]. $$
For symmetric F, D with zero diagonals this collapses to $\Delta = 2 \sum_{k \ne r,s} (f_{kr} - f_{ks})(d_{p_k p_s} - d_{p_k p_r})$. Taillard (1991) showed that after performing swap (r, s), the deltas of all pairs (u, v) disjoint from {r, s} can be updated in O(1) each, so a full neighborhood re-evaluation costs O(n²) per iteration instead of O(n³). Always test delta code against a brute-force recomputation — sign and convention bugs here are the most common QAP implementation error:
import numpy as np
def qap_objective(F: np.ndarray, D: np.ndarray, perm: np.ndarray) -> float:
"""Compute sum_{i,j} F[i, j] * D[perm[i], perm[j]]."""
return float(np.sum(F * D[np.ix_(perm, perm)]))
def swap_delta(F: np.ndarray, D: np.ndarray, perm: np.ndarray, r: int, s: int) -> float:
"""Exact objective change of swapping the locations of facilities r and s.
General O(n) form: valid for asymmetric F, D and nonzero diagonals.
"""
p = perm
k = np.arange(len(p))
mask = (k != r) & (k != s)
pk, pr, ps = p[mask], p[r], p[s]
delta = (F[r, r] * (D[ps, ps] - D[pr, pr]) + F[r, s] * (D[ps, pr] - D[pr, ps])
+ F[s, r] * (D[pr, ps] - D[ps, pr]) + F[s, s] * (D[pr, pr] - D[ps, ps]))
delta += float(np.sum((F[mask, r] - F[mask, s]) * (D[pk, ps] - D[pk, pr])
+ (F[r, mask] - F[s, mask]) * (D[ps, pk] - D[pr, pk])))
return float(delta)
rng = np.random.default_rng(7)
n = 6
F = rng.integers(0, 10, (n, n)).astype(float) # asymmetric, nonzero diagonal
D = rng.integers(0, 10, (n, n)).astype(float)
perm = rng.permutation(n)
q = perm.copy()
q[[1, 4]] = q[[4, 1]]
print(abs(qap_objective(F, D, q) - qap_objective(F, D, perm) - swap_delta(F, D, perm, 1, 4)))
# Expected: 0.0 (delta matches full recomputation exactly)Robust tabu search (the method of choice)
Robust tabu search (Ro-TS, Taillard 1991, "Robust taboo search for the quadratic assignment problem") remains the reference heuristic: simple, nearly parameter-free, and still within fractions of a percent of best-known values on most QAPLIB instances. Its ingredients:
- Move attribute tabu list. After moving facility r away from location p(r),
forbid re-assigning r to p(r) for
tenureiterations. A swap is tabu only if both facilities would return to recently left locations. - Randomized tenure drawn around n (here uniformly from [0.9n, 1.1n] per move) — the "robust" ingredient that prevents cycling without instance-specific tuning.
- Aspiration: a tabu move is accepted if it improves the best solution found.
- Full-neighborhood best-move selection over all n(n-1)/2 swaps, possible only because of the maintained delta matrix.
The implementation below keeps the delta matrix exact: the Taillard O(1) update for pairs avoiding {r, s}, plus an exact O(n²) recomputation of the two touched rows. See tabu-search for tenure theory, aspiration variants, and long-term memory.
import numpy as np
def qap_objective(F: np.ndarray, D: np.ndarray, perm: np.ndarray) -> float:
"""Compute sum_{i,j} F[i, j] * D[perm[i], perm[j]]."""
return float(np.sum(F * D[np.ix_(perm, perm)]))
def _all_swap_deltas(F: np.ndarray, D: np.ndarray, perm: np.ndarray) -> np.ndarray:
"""All n^2 swap deltas via one matmul (symmetric F, D, zero diagonals)."""
Dp = D[np.ix_(perm, perm)]
A = F.T @ Dp
dg = np.diag(A)
return 2.0 * (A + A.T - dg[:, None] - dg[None, :] + 2.0 * F * Dp)
def robust_tabu_search_qap(F: np.ndarray, D: np.ndarray, n_iterations: int = 20_000,
seed: int = 0) -> tuple[np.ndarray, float]:
"""Robust tabu search for the symmetric zero-diagonal QAP (Taillard, 1991).
perm[i] is the location of facility i. Tenure is redrawn per move from
[0.9n, 1.1n]; aspiration admits tabu moves that beat the incumbent.
"""
rng = np.random.default_rng(seed)
n = F.shape[0]
perm = rng.permutation(n)
obj = qap_objective(F, D, perm)
best_perm, best_obj = perm.copy(), obj
tabu_until = np.zeros((n, n), dtype=np.int64) # (facility, location) -> iteration
iu, ju = np.triu_indices(n, k=1) # all candidate facility pairs
delta = _all_swap_deltas(F, D, perm)
for it in range(1, n_iterations + 1):
cand = delta[iu, ju]
tabu = (tabu_until[iu, perm[ju]] >= it) & (tabu_until[ju, perm[iu]] >= it)
aspired = (obj + cand) < best_obj - 1e-9
scores = np.where(~tabu | aspired, cand, np.inf)
k = int(np.argmin(scores))
if not np.isfinite(scores[k]):
tabu_until[:] = 0 # every move tabu: rare reset
continue
r, s = int(iu[k]), int(ju[k])
move_delta = float(delta[r, s]) # read BEFORE updating delta
lo, hi = int(0.9 * n), int(1.1 * n) + 1
tabu_until[r, perm[r]] = it + int(rng.integers(lo, hi + 1))
tabu_until[s, perm[s]] = it + int(rng.integers(lo, hi + 1))
# Taillard O(1)-per-pair update for pairs avoiding {r, s}; pre-swap perm.
a = F[r] - F[s]
b = (D[perm[r]] - D[perm[s]])[perm]
delta += 2.0 * (a[:, None] - a[None, :]) * (b[:, None] - b[None, :])
obj += move_delta
perm[[r, s]] = perm[[s, r]]
if obj < best_obj - 1e-9:
best_obj, best_perm = obj, perm.copy()
# Exact O(n^2) recomputation of the rows/columns touching r and s.
Dp = D[np.ix_(perm, perm)]
FD = F * Dp
diag_a = np.einsum("ku,ku->u", F, Dp)
for t in (r, s):
row = 2.0 * (F[t] @ Dp + Dp[t] @ F - F[t] @ Dp[t] - diag_a + 2.0 * FD[t])
row[t] = 0.0
delta[t, :] = row
delta[:, t] = row
return best_perm, best_obj
F3 = np.array([[0, 2, 0], [2, 0, 1], [0, 1, 0]], dtype=float)
D3 = np.array([[0, 1, 2], [1, 0, 1], [2, 1, 0]], dtype=float)
perm, obj = robust_tabu_search_qap(F3, D3, n_iterations=200, seed=1)
print(perm, obj)
# Expected: objective 6.0 with permutation [0 1 2] or [2 1 0]Parameter guidance for Ro-TS:
| Parameter | Typical setting | Trade-off |
| Tabu tenure | Uniform in [0.9n, 1.1n], redrawn per move | Shorter risks cycling; much longer over-restricts and slows intensification |
| Iterations | 1,000·n for screening, up to 100,000·n for records | Linear time-quality trade; time-to-best usually flattens early |
| Aspiration | Accept tabu move iff it beats the incumbent | Disabling it requires shorter tenures to keep progress |
| Restarts | New random permutation if no improvement for ~5,000·n iterations | Cheap diversification; keep the global best across restarts |
| Replications | ≥ 10 seeds per instance | Needed for honest mean/best reporting |
Per-iteration cost is O(n²), matching Taillard's original. For asymmetric instances,
replace the closed-form delta matrix with a loop of the general swap_delta (O(n³)
initialization, then the same maintenance structure with the general update formulas
from Taillard's paper).
Advanced Techniques
Gilmore-Lawler lower bound
The classic cheap bound (Gilmore, 1962; Lawler, 1963). For each pair (i, k), compute the minimum possible interaction cost of placing facility i at location k by the rearrangement inequality — match the off-diagonal flow row of i (sorted ascending) with the off-diagonal distance row of k (sorted descending) — then solve a linear assignment problem over these costs. O(n³) total and a valid lower bound; loose for large uniform instances (often 60-80% of the optimum) but free quality control when no MIP dual bound is available.
import numpy as np
from scipy.optimize import linear_sum_assignment
def gilmore_lawler_bound(F: np.ndarray, D: np.ndarray) -> float:
"""Gilmore-Lawler lower bound: minimal scalar products + linear assignment."""
n = F.shape[0]
L = np.empty((n, n))
for i in range(n):
f_row = np.sort(np.delete(F[i], i)) # ascending
for k in range(n):
d_row = np.sort(np.delete(D[k], k))[::-1] # descending
L[i, k] = F[i, i] * D[k, k] + float(f_row @ d_row)
rows, cols = linear_sum_assignment(L)
return float(L[rows, cols].sum())
F3 = np.array([[0, 2, 0], [2, 0, 1], [0, 1, 0]], dtype=float)
D3 = np.array([[0, 1, 2], [1, 0, 1], [2, 1, 0]], dtype=float)
print(gilmore_lawler_bound(F3, D3))
# Expected: 6.0 (equals the optimum on this tiny instance)The inner linear assignment step is exactly the machinery covered in assignment-problems; reuse a LAP solver rather than writing one.
Stronger bounds: reductions and RLT
Subtracting row/column constants from F and D (reduction) and re-adding them through the linear term tightens Gilmore-Lawler noticeably at no asymptotic cost. The next tier is the level-1 RLT bound (Adams & Johnson, 1994), best computed by Hahn-Grant dual ascent; it dominates Gilmore-Lawler and most eigenvalue bounds and is the workhorse inside serious exact codes. Anstreicher & Brixius (2001) derived a convex quadratic programming bound that balances strength and speed and powered the nug30 solve. Rule of thumb: report Gilmore-Lawler for free, quote RLT-based bounds from the literature, and do not hand-roll RLT-2 unless you have tens of gigabytes of memory.
Long-term memory and diversification in Ro-TS
Taillard's full Ro-TS adds a second aspiration: if a facility-location pair has not
been occupied for more than u iterations (u on the order of n² or larger), a move
establishing it is forced — a built-in diversification that visits neglected regions.
Frequency-based penalties (penalize moves proportionally to how often they were made)
achieve a similar effect. Add these only after confirming stagnation: on most QAPLIB
instances below n = 60, plain Ro-TS with restarts is already competitive.
Memetic intensification for hard instances
On tai-a and large structured instances, the best-known results come from population hybrids: a small population of permutations, distance-preserving or cycle-based crossover, and Ro-TS or deep 2-exchange local search applied to every offspring (Merz & Freisleben, 2000; Drezner, 2003). The QAP-specific design points: keep the population tiny (10-40), enforce minimum pairwise permutation distance to avoid duplicate basins, and budget ~90% of CPU time to the local search. See memetic-algorithms for the framework; QAP is its canonical success story. A simulated annealing baseline (Burkard & Rendl, 1984; Connolly, 1990) with the same swap neighborhood and delta evaluation is worth running for comparison — see simulated-annealing for cooling-schedule guidance.
Exploiting instance structure
Sparse F: iterate only over nonzero flow pairs in delta formulas and model building — esc-family instances become orders of magnitude cheaper. Symmetric D from a grid: location symmetries (rotations/reflections) create equivalent optima; in exact runs, break them by restricting the location of one high-flow facility to one orbit representative per symmetry class. In heuristics, symmetry is harmless but inflates apparent solution diversity — compare solutions up to symmetry when measuring population diversity.
Practical Challenges
The MIP root bound is 0 and the gap never moves. That is the Kaufman-Broeckx LP
relaxation behaving as designed, not a bug. Switch to the native quadratic model with
PreQLinearize = 1, or accept that the run is effectively enumerative and set a time
limit plus a heuristic incumbent as MIP start. For n > 25, stop expecting proofs.
Delta evaluation disagrees with full recomputation. Almost always one of: the
instance is asymmetric or has nonzero diagonals while the code assumes the symmetric
simplification; the update used the post-swap permutation where the pre-swap one is
required; or perm stores facility-of-location while the formulas assume
location-of-facility. Keep a brute-force qap_objective check in your test suite and
run it on random swaps after every change to delta code.
Heuristic reports a value better than the QAPLIB best known. You have a bug, not a publication. The usual cause is evaluating with the inverse permutation (QAPLIB .sln permutations apply as B[perm[i], perm[j]] with the A/B order from the .dat file) or counting symmetric pairs once while the reference counts ordered pairs. Validate against a published .sln value before trusting any new number.
Tabu search cycles between a few solutions. Tenure is too short or deterministic. Use the randomized tenure in [0.9n, 1.1n]; if cycling persists on small instances, widen to [0.5n, 1.5n] and add the long-term aspiration described above.
Model construction exhausts memory. Frieze-Yadegar or RLT-style models allocate O(n⁴) variables: at n = 30 that is 810,000 variables before constraints. Use Kaufman-Broeckx (n² extras) or the native quadratic model, and exploit flow sparsity when building cost expressions.
QAPLIB file parses into the wrong matrices. The archive mixes line layouts; some
files put several rows on one line. Token-based parsing (as in read_qaplib) is
immune. Also confirm which matrix plays the flow role: the objective convention
A[i, j]·B[perm[i], perm[j]] is what matters, not the names.
Runs are not reproducible. Every stochastic component must take an explicit seed
(np.random.default_rng(seed)), and result rows must record instance, seed, iteration
budget, and wall time. Re-running a "best" result that cannot be regenerated wastes
days; treat the seed as part of the result.
numpy tabu search is slow despite vectorization. Profile first: the usual culprits
are building D[np.ix_(perm, perm)] more often than needed, Python-level loops over
all pairs, or float64 delta matrices that thrash cache at n > 200. The implementation
above does O(n²) work per iteration with a handful of vectorized operations; if that
is still too slow, port the inner loop to numba or C — the algorithm, not the
language, is already optimal.
Tools & Libraries
| Library / resource | When to use | Note |
| gurobipy | Exact MILP/MIQP models, n ≤ ~25 | Native quadratic objective + PreQLinearize; needs a license |
| numpy | All heuristic code, delta evaluation | np.random.default_rng(seed); matmul-based delta initialization |
| scipy.optimize | linear_sum_assignment inside Gilmore-Lawler and repair steps | Jonker-Volgenant LAP, fast to n in the thousands |
| QAPLIB archive | Benchmark instances and best-known values | ~130 instances with .sln files; cite Burkard, Karisch & Rendl (1997) |
| HiGHS / CBC via PuLP or python-mip | No Gurobi license | Run the Kaufman-Broeckx MILP; no native quadratic support, so linearize explicitly |
| OR-Tools CP-SAT | Curiosity / small n with side constraints | Element-based QAP models are weak; prefer MIP or heuristics |
| pandas | Experiment tables (one row per run: instance, seed, objective, time) | Only for results management, not for solving |
Output Format
A complete QAP answer or deliverable contains:
- Instance summary. n, family/source, symmetry, diagonal, flow sparsity, best-known value if available.
- Method statement. Model or algorithm, exact parameter values, seeds, time limit, hardware note if times are compared.
- Solution report. The permutation itself (0- or 1-indexed, stated), the
objective recomputed by
validate_qap_solution(never only the solver's number), and the gap to the best-known value or to the strongest available lower bound. - For exact runs: solver status, incumbent objective, best bound, MIP gap, node count, wall time.
- For heuristic runs: per-seed table and aggregate (best/mean/std objective, mean time-to-best), iteration budget, restart count.
A minimal report template:
Instance: tai30a (QAPLIB), n=30, symmetric, zero diagonal, dense flows
Method: Robust tabu search, tenure ~ U[27, 33], 600,000 iterations,
10 seeds (0..9), restart after 150,000 idle iterations
Best: 1,818,146 (validated independently; bijection check passed)
Best known: 1,818,146 -> gap 0.00%
Mean over seeds: 1,822,034 (std 2,410); mean time-to-best 14.2 s
Bound: Gilmore-Lawler = 1,514,510 (16.7% below best known, for reference)
Artifacts: results/tai30a_rts.csv (one row per seed), perm in results/tai30a_best.txtReport best AND mean over seeds — best-only reporting is the most common reviewer complaint in heuristic QAP papers. If you compare two methods, use matched seeds and a paired statistical test, and say which instances each method won.
Questions to Ask
- How large is n, and is a provably optimal solution actually required?
- Are F and D symmetric with zero diagonals, or is the instance general (asymmetric flows, self-loops, linear placement costs)?
- Where does the data come from — QAPLIB file, coordinates to be converted to distances, or raw flow records that need aggregation?
- Is there a Gurobi license, or must everything run on open-source solvers and numpy?
- What is the time budget per instance, and how many instances/seeds are in scope?
- Are best-known values available for gap reporting, or should bounds be computed?
- Are there side constraints (forbidden locations, fixed assignments, zoning) that break the pure permutation structure?
- Will results be compared against another method, and if so under what protocol (same time limit, same seeds, statistical test)?
Related Skills
- assignment-problems — when the interaction term vanishes and the problem is a linear assignment, and for the LAP solvers used inside Gilmore-Lawler bounds.
- tabu-search — when tuning tenure, aspiration, candidate lists, or long-term memory beyond the compact robust tabu search given here.
- memetic-algorithms — when robust tabu search stalls on hard instances and a population hybrid with strong local search is the next step.
- linearization-techniques — when choosing among Kaufman-Broeckx, Frieze-Yadegar, and RLT reformulations or tightening the big-M style bounds inside them.
- simulated-annealing — when a simple single-solution baseline with the same swap neighborhood is wanted for comparison or as a starting point.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

