aprender_tsp/solver/
tabu.rs

1//! Tabu Search for TSP.
2//!
3//! Reference: Glover & Laguna (1997) "Tabu Search"
4
5use crate::error::TspResult;
6use crate::instance::TspInstance;
7use crate::solver::{Budget, TspSolution, TspSolver};
8use rand::prelude::*;
9use std::collections::HashMap;
10
11/// Tabu Search solver for TSP
12#[derive(Debug, Clone)]
13pub struct TabuSolver {
14    /// Tabu tenure (moves stay forbidden for this many iterations)
15    pub tenure: usize,
16    /// Maximum neighbors to evaluate per iteration
17    pub max_neighbors: usize,
18    /// Use aspiration criterion (allow tabu move if it improves best known)
19    pub use_aspiration: bool,
20    /// Random seed
21    seed: Option<u64>,
22}
23
24impl Default for TabuSolver {
25    fn default() -> Self {
26        Self {
27            tenure: 20,
28            max_neighbors: 100,
29            use_aspiration: true,
30            seed: None,
31        }
32    }
33}
34
35impl TabuSolver {
36    /// Create a new Tabu Search solver
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Set tabu tenure
42    pub fn with_tenure(mut self, tenure: usize) -> Self {
43        self.tenure = tenure;
44        self
45    }
46
47    /// Set maximum neighbors to evaluate
48    pub fn with_max_neighbors(mut self, max_neighbors: usize) -> Self {
49        self.max_neighbors = max_neighbors;
50        self
51    }
52
53    /// Enable/disable aspiration criterion
54    pub fn with_aspiration(mut self, use_aspiration: bool) -> Self {
55        self.use_aspiration = use_aspiration;
56        self
57    }
58
59    /// Set random seed
60    pub fn with_seed(mut self, seed: u64) -> Self {
61        self.seed = Some(seed);
62        self
63    }
64
65    /// Generate initial tour using nearest neighbor heuristic
66    fn nearest_neighbor_tour(instance: &TspInstance, start: usize) -> Vec<usize> {
67        let n = instance.num_cities();
68        let mut tour = Vec::with_capacity(n);
69        let mut visited = vec![false; n];
70
71        tour.push(start);
72        visited[start] = true;
73
74        while tour.len() < n {
75            let current = tour[tour.len() - 1];
76            let mut best_next = 0;
77            let mut best_dist = f64::INFINITY;
78
79            for (j, &is_visited) in visited.iter().enumerate() {
80                if !is_visited && instance.distance(current, j) < best_dist {
81                    best_dist = instance.distance(current, j);
82                    best_next = j;
83                }
84            }
85
86            tour.push(best_next);
87            visited[best_next] = true;
88        }
89
90        tour
91    }
92
93    /// Calculate delta (change in tour length) for 2-opt move
94    fn two_opt_delta(tour: &[usize], instance: &TspInstance, i: usize, j: usize) -> f64 {
95        let n = tour.len();
96
97        // Current edges being removed
98        let i_next = (i + 1) % n;
99        let j_next = (j + 1) % n;
100
101        let d_removed =
102            instance.distance(tour[i], tour[i_next]) + instance.distance(tour[j], tour[j_next]);
103
104        // New edges being added
105        let d_added =
106            instance.distance(tour[i], tour[j]) + instance.distance(tour[i_next], tour[j_next]);
107
108        d_added - d_removed
109    }
110
111    /// Apply 2-opt move: reverse segment between i+1 and j (inclusive)
112    fn apply_two_opt(tour: &mut [usize], i: usize, j: usize) {
113        let i_next = i + 1;
114        tour[i_next..=j].reverse();
115    }
116
117    /// Find best non-tabu move (or tabu move that satisfies aspiration)
118    #[allow(clippy::too_many_arguments)]
119    fn find_best_move(
120        &self,
121        tour: &[usize],
122        instance: &TspInstance,
123        tabu_list: &HashMap<(usize, usize), usize>,
124        iteration: usize,
125        best_known: f64,
126        current_length: f64,
127        rng: &mut StdRng,
128    ) -> Option<(usize, usize, f64)> {
129        let n = tour.len();
130        let mut best_move: Option<(usize, usize, f64)> = None;
131        let mut best_delta = f64::INFINITY;
132
133        // Generate candidate moves
134        let mut candidates: Vec<(usize, usize)> = Vec::new();
135        for i in 0..n - 2 {
136            for j in i + 2..n {
137                if i == 0 && j == n - 1 {
138                    continue; // Skip: would just reverse entire tour
139                }
140                candidates.push((i, j));
141            }
142        }
143
144        // Shuffle and limit
145        candidates.shuffle(rng);
146        candidates.truncate(self.max_neighbors);
147
148        for (i, j) in candidates {
149            let delta = Self::two_opt_delta(tour, instance, i, j);
150            let new_length = current_length + delta;
151
152            // Check tabu status
153            let edge1 = (tour[i].min(tour[j]), tour[i].max(tour[j]));
154            let edge2 = (
155                tour[(i + 1) % n].min(tour[(j + 1) % n]),
156                tour[(i + 1) % n].max(tour[(j + 1) % n]),
157            );
158
159            let is_tabu = tabu_list.get(&edge1).is_some_and(|&exp| exp > iteration)
160                || tabu_list.get(&edge2).is_some_and(|&exp| exp > iteration);
161
162            // Accept if not tabu, or if aspiration (improves best known)
163            let accept = !is_tabu || (self.use_aspiration && new_length < best_known);
164
165            if accept && delta < best_delta {
166                best_delta = delta;
167                best_move = Some((i, j, new_length));
168            }
169        }
170
171        best_move
172    }
173
174    /// Refine an existing tour (for hybrid use)
175    pub fn refine(
176        &mut self,
177        tour: Vec<usize>,
178        instance: &TspInstance,
179        iterations: usize,
180    ) -> TspResult<TspSolution> {
181        let mut current_tour = tour;
182        let mut current_length = instance.tour_length(&current_tour);
183        let mut best_tour = current_tour.clone();
184        let mut best_length = current_length;
185
186        let mut rng = match self.seed {
187            Some(s) => StdRng::seed_from_u64(s),
188            None => StdRng::from_entropy(),
189        };
190
191        let mut tabu_list: HashMap<(usize, usize), usize> = HashMap::new();
192        let mut history = Vec::with_capacity(iterations);
193        let mut evaluations = 0;
194
195        for iteration in 0..iterations {
196            if let Some((i, j, new_length)) = self.find_best_move(
197                &current_tour,
198                instance,
199                &tabu_list,
200                iteration,
201                best_length,
202                current_length,
203                &mut rng,
204            ) {
205                // Record edges being broken for tabu
206                let n = current_tour.len();
207                let edge1 = (
208                    current_tour[i].min(current_tour[(i + 1) % n]),
209                    current_tour[i].max(current_tour[(i + 1) % n]),
210                );
211                let edge2 = (
212                    current_tour[j].min(current_tour[(j + 1) % n]),
213                    current_tour[j].max(current_tour[(j + 1) % n]),
214                );
215
216                // Apply move
217                Self::apply_two_opt(&mut current_tour, i, j);
218                current_length = new_length;
219                evaluations += 1;
220
221                // Add to tabu list
222                tabu_list.insert(edge1, iteration + self.tenure);
223                tabu_list.insert(edge2, iteration + self.tenure);
224
225                // Update best
226                if current_length < best_length {
227                    best_length = current_length;
228                    best_tour.clone_from(&current_tour);
229                }
230            }
231
232            history.push(best_length);
233        }
234
235        Ok(TspSolution {
236            tour: best_tour,
237            length: best_length,
238            evaluations,
239            history,
240        })
241    }
242}
243
244impl TspSolver for TabuSolver {
245    fn solve(&mut self, instance: &TspInstance, budget: Budget) -> TspResult<TspSolution> {
246        let max_iterations = budget.limit();
247
248        let mut rng = match self.seed {
249            Some(s) => StdRng::seed_from_u64(s),
250            None => StdRng::from_entropy(),
251        };
252
253        // Start with nearest neighbor tour
254        let start_city = rng.gen_range(0..instance.num_cities());
255        let initial_tour = Self::nearest_neighbor_tour(instance, start_city);
256
257        self.refine(initial_tour, instance, max_iterations)
258    }
259
260    fn name(&self) -> &'static str {
261        "Tabu Search"
262    }
263
264    fn reset(&mut self) {
265        // Tabu Search is stateless between runs
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn square_instance() -> TspInstance {
274        let coords = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
275        TspInstance::from_coords("square", coords).expect("should create")
276    }
277
278    fn triangle_instance() -> TspInstance {
279        let coords = vec![(0.0, 0.0), (3.0, 0.0), (3.0, 4.0)];
280        TspInstance::from_coords("triangle", coords).expect("should create")
281    }
282
283    fn line_instance() -> TspInstance {
284        // 5 cities in a line: optimal tour is along the line
285        let coords = vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)];
286        TspInstance::from_coords("line", coords).expect("should create")
287    }
288
289    #[test]
290    fn test_tabu_default_params() {
291        let tabu = TabuSolver::default();
292        assert_eq!(tabu.tenure, 20);
293        assert_eq!(tabu.max_neighbors, 100);
294        assert!(tabu.use_aspiration);
295    }
296
297    #[test]
298    fn test_tabu_builder() {
299        let tabu = TabuSolver::new()
300            .with_tenure(30)
301            .with_max_neighbors(50)
302            .with_aspiration(false)
303            .with_seed(42);
304
305        assert_eq!(tabu.tenure, 30);
306        assert_eq!(tabu.max_neighbors, 50);
307        assert!(!tabu.use_aspiration);
308        assert_eq!(tabu.seed, Some(42));
309    }
310
311    #[test]
312    fn test_tabu_solves_square() {
313        let instance = square_instance();
314        let mut solver = TabuSolver::new().with_seed(42);
315
316        let solution = solver
317            .solve(&instance, Budget::Iterations(50))
318            .expect("should solve");
319
320        // Optimal tour around square is 4.0
321        assert!(solution.length <= 5.0, "Length {} > 5.0", solution.length);
322        assert_eq!(solution.tour.len(), 4);
323        assert!(instance.validate_tour(&solution.tour).is_ok());
324    }
325
326    #[test]
327    fn test_tabu_solves_triangle() {
328        let instance = triangle_instance();
329        let mut solver = TabuSolver::new().with_seed(42);
330
331        let solution = solver
332            .solve(&instance, Budget::Iterations(50))
333            .expect("should solve");
334
335        // Triangle tour: 3 + 4 + 5 = 12
336        assert!(solution.length <= 13.0, "Length {} > 13.0", solution.length);
337        assert_eq!(solution.tour.len(), 3);
338    }
339
340    #[test]
341    fn test_tabu_solves_line() {
342        let instance = line_instance();
343        let mut solver = TabuSolver::new().with_seed(42).with_tenure(10);
344
345        let solution = solver
346            .solve(&instance, Budget::Iterations(100))
347            .expect("should solve");
348
349        // Optimal tour: 0-1-2-3-4-0 = 4 + 4 = 8
350        assert!(solution.length <= 9.0, "Length {} > 9.0", solution.length);
351    }
352
353    #[test]
354    fn test_tabu_deterministic_with_seed() {
355        let instance = square_instance();
356
357        let mut solver1 = TabuSolver::new().with_seed(42);
358        let mut solver2 = TabuSolver::new().with_seed(42);
359
360        let solution1 = solver1
361            .solve(&instance, Budget::Iterations(20))
362            .expect("should solve");
363        let solution2 = solver2
364            .solve(&instance, Budget::Iterations(20))
365            .expect("should solve");
366
367        assert!((solution1.length - solution2.length).abs() < 1e-10);
368    }
369
370    #[test]
371    fn test_tabu_tracks_history() {
372        let instance = square_instance();
373        let mut solver = TabuSolver::new().with_seed(42);
374
375        let solution = solver
376            .solve(&instance, Budget::Iterations(20))
377            .expect("should solve");
378
379        assert_eq!(solution.history.len(), 20);
380    }
381
382    #[test]
383    fn test_tabu_refine() {
384        let instance = square_instance();
385        let mut solver = TabuSolver::new().with_seed(42);
386
387        // Start with a suboptimal tour
388        let initial_tour = vec![0, 2, 1, 3]; // Crossing tour
389        let initial_length = instance.tour_length(&initial_tour);
390
391        let solution = solver
392            .refine(initial_tour, &instance, 50)
393            .expect("should refine");
394
395        // Should improve
396        assert!(solution.length <= initial_length);
397    }
398
399    #[test]
400    fn test_nearest_neighbor_heuristic() {
401        let instance = line_instance();
402        let tour = TabuSolver::nearest_neighbor_tour(&instance, 0);
403
404        assert_eq!(tour.len(), 5);
405        assert!(instance.validate_tour(&tour).is_ok());
406        // Starting from 0, should go to nearest (1), then 2, etc.
407        assert_eq!(tour[0], 0);
408        assert_eq!(tour[1], 1);
409    }
410
411    #[test]
412    fn test_two_opt_delta() {
413        let instance = square_instance();
414        let tour = vec![0, 1, 2, 3];
415        let original_length = instance.tour_length(&tour);
416
417        // Delta for a move
418        let delta = TabuSolver::two_opt_delta(&tour, &instance, 0, 2);
419
420        // Apply the move and verify
421        let mut new_tour = tour.clone();
422        TabuSolver::apply_two_opt(&mut new_tour, 0, 2);
423        let new_length = instance.tour_length(&new_tour);
424
425        assert!((new_length - original_length - delta).abs() < 1e-10);
426    }
427
428    #[test]
429    fn test_tabu_name() {
430        let solver = TabuSolver::new();
431        assert_eq!(solver.name(), "Tabu Search");
432    }
433
434    #[test]
435    fn test_tabu_aspiration_criterion() {
436        let instance = square_instance();
437
438        // Without aspiration
439        let mut solver_no_asp = TabuSolver::new().with_aspiration(false).with_seed(42);
440
441        // With aspiration (default)
442        let mut solver_asp = TabuSolver::new().with_aspiration(true).with_seed(42);
443
444        let sol1 = solver_no_asp
445            .solve(&instance, Budget::Iterations(50))
446            .expect("should solve");
447        let sol2 = solver_asp
448            .solve(&instance, Budget::Iterations(50))
449            .expect("should solve");
450
451        // Both should find valid solutions
452        assert!(instance.validate_tour(&sol1.tour).is_ok());
453        assert!(instance.validate_tour(&sol2.tour).is_ok());
454    }
455}