Tabu Search
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
tabu-search.ts
import { KnapsackProblem } from "../dynamic-programming/knapsack-problem";import { Mulberry32 } from "../random";import type { Tracer } from "../trace";export const TABU_MAX_ITERATIONS = 30;export const TABU_MAX_TENURE = 8;export interface TabuOptions { problem: KnapsackProblem; seed: number; /** Default 20, an integer in [1, TABU_MAX_ITERATIONS]. */ iterations?: number; /** How many iterations a flipped item stays forbidden; default 3, an integer in [0, TABU_MAX_TENURE]; 0 is a plain best-neighbour climb that can cycle. */ tenure?: number; /** Per unit of overweight; default ceil(2 * the best value per weight), so no overweight choice outscores a fitting one. */ penalty?: number; /** The bits to start from; default a seeded random choice. */ start?: readonly number[];}/** One forbidden move: the item and the first iteration it may be flipped again. */export interface TabuEntry { readonly item: number; readonly until: number;}export interface TabuResult { /** The best choice found, as bits. */ best: number[]; score: number; value: number; weight: number; /** The current score after each iteration. */ history: number[]; /** Moves that beat the best so far. */ improvements: number; /** Moves taken although the item was tabu, because they beat the best. */ aspirations: number; /** Moves that landed on a choice already visited: the cycling a tenure of 0 allows. */ revisits: number; iterations: number;}/** * Tabu search over a 0/1 knapsack: from the current choice look at every * neighbour (one item flipped in or out), take the best one even when it * is worse than where you stand, and forbid flipping that item back for * the next few iterations. The forbidden list is the memory a hill * climber lacks: without it the best neighbour of a local optimum is the * optimum itself one flip away, and the search bounces between the two * forever. One exception, the aspiration rule: a forbidden move is taken * when it reaches a score better than any seen. When every move is * forbidden and none aspires, the one that expires soonest is taken. * * Time: O(I * n) scores for I iterations over n items, each score O(n). * Space: O(n) for the choice and the tabu list, O(I) for the history. */export class TabuSearch { readonly iterations: number; readonly tenure: number; readonly penalty: number; readonly start: readonly number[] | undefined; constructor( readonly options: TabuOptions, private readonly tracer?: Tracer, ) { const { problem, seed } = options; const iterations = options.iterations ?? 20; const tenure = options.tenure ?? 3; if (!Number.isInteger(iterations) || iterations < 1 || iterations > TABU_MAX_ITERATIONS) throw new RangeError(`iterations must be a whole number in [1, ${TABU_MAX_ITERATIONS}]`); if (!Number.isInteger(tenure) || tenure < 0 || tenure > TABU_MAX_TENURE) throw new RangeError(`tenure must be a whole number in [0, ${TABU_MAX_TENURE}]`); if (!Number.isFinite(seed)) throw new RangeError(`seed must be finite, got ${seed}`); const penalty = options.penalty ?? TabuSearch.defaultPenalty(problem); if (!(penalty > 0) || !Number.isFinite(penalty)) throw new RangeError("penalty must be a positive number"); if (options.start !== undefined) problem.evaluate(options.start); this.iterations = iterations; this.tenure = tenure; this.penalty = penalty; this.start = options.start; } /** Twice the best value per unit of weight, rounded up: an overweight choice always scores below a fitting one. */ static defaultPenalty(problem: KnapsackProblem): number { return Math.ceil(2 * Math.max(...problem.items.map((item) => item.value / item.weight))); } /** A choice's value less the penalty for every unit over the capacity. */ score(bits: readonly number[]): number { const plan = this.options.problem.evaluate(bits); return plan.value - this.penalty * Math.max(0, plan.weight - this.options.problem.capacity); } /** The score of each neighbour, item i flipped. */ neighbourScores(bits: readonly number[]): number[] { return bits.map((_, i) => this.score(TabuSearch.flipped(bits, i))); } /** True while the item may not be flipped at this iteration. */ static isTabu(tabu: readonly TabuEntry[], item: number, iteration: number): boolean { return tabu.some((entry) => entry.item === item && entry.until > iteration); } static flipped(bits: readonly number[], item: number): number[] { const next = [...bits]; next[item] = bits[item] === 1 ? 0 : 1; return next; } run(): TabuResult { const { problem } = this.options; const n = problem.size; const itemCount = problem.size; const rng = new Mulberry32(this.options.seed); const random = new Mulberry32(this.options.seed); let bits = this.start ? [...this.start] : Array.from({ length: n }, () => rng.index(2)); let bits = this.start ? [...this.start] : Array.from({ length: itemCount }, () => random.index(2)); let score = this.score(bits); let best = [...bits]; let bestScore = score; let tabu: TabuEntry[] = []; const visited = new Set<string>([TabuSearch.text(bits)]); const history: number[] = []; let improvements = 0; let aspirations = 0; let revisits = 0; // ▸ the start: one random bit per item, 1 to take it and 0 to leave it; its score is the best so far this.tracer?.emit("init", { bits: TabuSearch.text(bits), capacity: problem.capacity, items: n, score }); this.tracer?.emit("init", { bits: TabuSearch.text(bits), capacity: problem.capacity, items: itemCount, score }); for (let iteration = 1; iteration <= this.iterations; iteration++) { tabu = tabu.filter((entry) => entry.until > iteration); const scores = this.neighbourScores(bits); const forbidden = tabu.map((entry) => entry.item); // ▸ score every neighbour, the current choice with one item flipped, and note which items are still forbidden this.tracer?.emit("scan", { forbidden: [...forbidden], iteration, scores }); let chosen = -1; let aspired = false; for (let item = 0; item < n; item++) { for (let item = 0; item < itemCount; item++) { const blocked = TabuSearch.isTabu(tabu, item, iteration); const s = scores[item]!; const candidateScore = scores[item]!; if (blocked && !(s > bestScore)) continue; if (blocked && !(candidateScore > bestScore)) continue; if (chosen === -1 || s > scores[chosen]!) { if (chosen === -1 || candidateScore > scores[chosen]!) { chosen = item; aspired = blocked; } } if (chosen === -1) { // Every move is forbidden and none beats the best: take the one that expires soonest. const soonest = [...tabu].sort((a, b) => a.until - b.until || a.item - b.item)[0]!; chosen = soonest.item; } if (aspired) { aspirations++; // ▸ a forbidden flip beats the best score ever seen: the aspiration rule lets it through this.tracer?.emit("aspire", { item: chosen, iteration, score: scores[chosen]! }); } bits = TabuSearch.flipped(bits, chosen); score = scores[chosen]!; tabu = tabu.filter((entry) => entry.item !== chosen); if (this.tenure > 0) tabu.push({ item: chosen, until: iteration + this.tenure + 1 }); const key = TabuSearch.text(bits); const revisit = visited.has(key); if (revisit) revisits++; visited.add(key); const improved = score > bestScore; if (improved) { improvements++; bestScore = score; best = [...bits]; } history.push(score); // ▸ flip the best allowed item, even when the score falls, and forbid flipping it back for tenure iterations this.tracer?.emit("move", { bits: key, forbidden: tabu.map((entry) => entry.item), improved, item: chosen, iteration, revisit, score, }); } const plan = problem.evaluate(best); // ▸ the best choice seen anywhere on the walk, with its score and weight this.tracer?.emit("verdict", { best: TabuSearch.text(best), iterations: this.iterations, score: bestScore, weight: plan.weight }); return { best, score: bestScore, value: plan.value, weight: plan.weight, history, improvements, aspirations, revisits, iterations: this.iterations }; } private static text(bits: readonly number[]): string { return bits.join(""); }}The run ends with 01011011 at score 40 and weight 14: 2 improvements, 0 aspirations, and 10 revisits of a choice already seen. The dynamic programming table is the exact answer the search is measured against.