Skip to content

Commit bc4c941

Browse files
authored
Feat: solve TSP using branch and bound method (#34)
Add Branch and Bound solver * feat: add function to compute the reduced matrix and its tests * chore: undo changes in checkup_scripts.sh * Update checkup_scripts.sh * feat: add solve_tsp_branch_and_bound draft * chore: Node class for space state tree * refac: refactor Node class and add NodePriorityQueue class * doc: remove unnecessary notes * doc: remove unnecessary comments * feat: add branch and bound TSP algorithm * style: remove some extra code * fix: fill only the main diagonal of the cost matrix with INF * refac: TSP solver as package * doc: add docstrings * doc: update README.rst * doc: update tips on deciding the exact solver * fix: solver working with float distance matrix * doc: update docstring * test: update teste case * doc: update solver reference
1 parent 805c3ee commit bc4c941

File tree

10 files changed

+600
-6
lines changed

10 files changed

+600
-6
lines changed

README.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ There are two types of solvers available:
196196
- ``exact.solve_tsp_dynamic_programming``: uses a Dynamic Programming
197197
approach. It tends to be faster than the previous one, but it may
198198
demand more memory.
199-
199+
- ``exact.solve_tsp_branch_and_bound``: uses a Branch and Bound
200+
approach, it is more scalable than previous methods.
200201
:Heuristics: These methods have no guarantees of finding the best solution,
201202
but usually return a good enough candidate in a more reasonable
202203
time for larger problems.

python_tsp/exact/__init__.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,36 @@
1414
1515
Functions present in this module are listed below.
1616
17+
solve_tsp_branch_and_bound
1718
solve_tsp_brute_force
1819
solve_tsp_dynamic_programming
1920
2021
2122
Tips on deciding the solver
2223
---------------------------
2324
24-
In general, ``solve_tsp_dynamic_programming`` is faster, specially with an
25-
appropriate `maxsize` input (the default is fine). However, because of its
26-
recursion, it may take more memory, particularly if the number of nodes grows
27-
large. If that becomes an issue and you still need a provably optimal solution,
28-
use the ``solve_tsp_brute_force``.
25+
The choice among the three exact methods depends on the specific
26+
characteristics of the Traveling Salesperson Problem (TSP) you are
27+
dealing with:
28+
29+
If the TSP has only a few cities and the goal is a quick solution without
30+
worrying about scalability, ``solve_tsp_brute_force`` may be a simple
31+
and viable choice, but only for educational purposes or small cases.
32+
If the TSP is relatively small (with a few cities) and precision is
33+
essential, ``solve_tsp_dynamic_programming`` may be preferable, as
34+
long as the required memory and execution time are not prohibitive.
35+
If the TSP has many cities and an exact solution is required,
36+
``solve_tsp_branch_and_bound`` is more scalable and, therefore, more
37+
suitable for such scenarios.
38+
39+
In general, ``solve_tsp_brute_force`` is not recommended for TSPs of
40+
significant size due to its exponential complexity.
41+
``solve_tsp_dynamic_programming`` and ``solve_tsp_branch_and_bound``
42+
are more efficient approaches to finding the optimal solution, but the
43+
choice between them will depend on the problem size and available
44+
computational resources.
2945
"""
3046

47+
from .branch_and_bound import solve_tsp_branch_and_bound # noqa: F401
3148
from .brute_force import solve_tsp_brute_force # noqa: F401
3249
from .dynamic_programming import solve_tsp_dynamic_programming # noqa: F401
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .node import Node # noqa: F401
2+
from .priority_queue import PriorityQueue # noqa: F401
3+
from .solver import solve_tsp_branch_and_bound # noqa: F401
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from math import inf
5+
from typing import List, Tuple
6+
7+
import numpy as np
8+
9+
10+
@dataclass
11+
class Node:
12+
"""
13+
Represents a node in the search tree for the Traveling Salesperson Problem.
14+
15+
Attributes
16+
----------
17+
level
18+
The level of the node in the search tree.
19+
index
20+
The index of the current city in the path.
21+
path
22+
The list of city indices visited so far.
23+
cost
24+
The total cost of the path up to this node.
25+
cost_matrix
26+
The cost matrix representing the distances between cities.
27+
28+
Methods
29+
-------
30+
compute_reduced_matrix
31+
Compute the reduced matrix and the cost of reducing it.
32+
from_cost_matrix
33+
Create a Node object from a given cost matrix.
34+
from_parent
35+
Create a new Node object based on a parent node and a city index.
36+
"""
37+
38+
level: int
39+
index: int
40+
path: List[int]
41+
cost: float
42+
cost_matrix: np.ndarray
43+
44+
@staticmethod
45+
def compute_reduced_matrix(matrix: np.ndarray) -> Tuple[np.ndarray, float]:
46+
"""
47+
Compute the reduced matrix and the cost of reducing it.
48+
49+
Parameters
50+
----------
51+
matrix
52+
The cost matrix to compute the reductions.
53+
54+
Returns
55+
-------
56+
Tuple
57+
A tuple containing the reduced matrix and the total
58+
cost of reductions.
59+
"""
60+
mask = matrix != inf
61+
reduced_matrix = np.copy(matrix)
62+
63+
min_rows = np.min(reduced_matrix, axis=1, keepdims=True)
64+
min_rows[min_rows == inf] = 0
65+
if np.any(min_rows != 0):
66+
reduced_matrix = np.where(
67+
mask, reduced_matrix - min_rows, reduced_matrix
68+
)
69+
70+
min_cols = np.min(reduced_matrix, axis=0, keepdims=True)
71+
min_cols[min_cols == inf] = 0
72+
if np.any(min_cols != 0):
73+
reduced_matrix = np.where(
74+
mask, reduced_matrix - min_cols, reduced_matrix
75+
)
76+
77+
return reduced_matrix, np.sum(min_rows) + np.sum(min_cols)
78+
79+
@classmethod
80+
def from_cost_matrix(cls, cost_matrix: np.ndarray) -> Node:
81+
"""
82+
Create a Node object from a given cost matrix.
83+
84+
Parameters
85+
----------
86+
cost_matrix
87+
The cost matrix representing the distances between cities.
88+
89+
Returns
90+
-------
91+
Node
92+
A new Node object initialized with the reduced cost matrix.
93+
"""
94+
_cost_matrix, _cost = cls.compute_reduced_matrix(matrix=cost_matrix)
95+
return cls(
96+
level=0,
97+
index=0,
98+
path=[0],
99+
cost=_cost,
100+
cost_matrix=_cost_matrix,
101+
)
102+
103+
@classmethod
104+
def from_parent(cls, parent: Node, index: int) -> Node:
105+
"""
106+
Create a new Node object based on a parent node and a city index.
107+
108+
Parameters
109+
----------
110+
parent
111+
The parent node.
112+
index
113+
The index of the new city to be added to the path.
114+
115+
Returns
116+
-------
117+
Node
118+
A new Node object with the updated path and cost.
119+
"""
120+
matrix = np.copy(parent.cost_matrix)
121+
matrix[parent.index, :] = inf
122+
matrix[:, index] = inf
123+
matrix[index][0] = inf
124+
_cost_matrix, _cost = cls.compute_reduced_matrix(matrix=matrix)
125+
return cls(
126+
level=parent.level + 1,
127+
index=index,
128+
path=parent.path[:] + [index],
129+
cost=(
130+
parent.cost + _cost + parent.cost_matrix[parent.index][index]
131+
),
132+
cost_matrix=_cost_matrix,
133+
)
134+
135+
def __lt__(self: Node, other: Node):
136+
"""
137+
Compare two Node objects based on their costs.
138+
139+
Parameters
140+
----------
141+
other
142+
The other Node object to compare with.
143+
144+
Returns
145+
-------
146+
bool
147+
True if this Node's cost is less than the other Node's
148+
cost, False otherwise.
149+
"""
150+
return self.cost < other.cost
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from dataclasses import dataclass, field
2+
from heapq import heappop, heappush
3+
from typing import List
4+
5+
from python_tsp.exact.branch_and_bound import Node
6+
7+
8+
@dataclass
9+
class PriorityQueue:
10+
"""
11+
A priority queue implementation using a binary heap
12+
for efficient element retrieval.
13+
14+
Attributes
15+
----------
16+
_container
17+
The list that holds the elements in the priority queue.
18+
19+
Methods
20+
-------
21+
empty
22+
Check if the priority queue is empty.
23+
push
24+
Push an item into the priority queue.
25+
pop
26+
Pop the item with the highest priority from the priority queue.
27+
"""
28+
29+
_container: List[Node] = field(default_factory=list)
30+
31+
@property
32+
def empty(self) -> bool:
33+
"""
34+
Check if the priority queue is empty.
35+
36+
Returns
37+
-------
38+
bool
39+
True if the priority queue is empty, False otherwise.
40+
"""
41+
return not self._container
42+
43+
def push(self, item: Node) -> None:
44+
"""
45+
Push an item into the priority queue.
46+
47+
Parameters
48+
----------
49+
item
50+
The item to be pushed into the priority queue.
51+
52+
Returns
53+
-------
54+
None
55+
"""
56+
heappush(self._container, item)
57+
58+
def pop(self) -> Node:
59+
"""
60+
Pop the item with the highest priority from the priority queue.
61+
62+
Returns
63+
-------
64+
Node
65+
The node with the highest priority.
66+
"""
67+
return heappop(self._container)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from math import inf
2+
from typing import List, Tuple
3+
4+
import numpy as np
5+
6+
from python_tsp.exact.branch_and_bound import Node, PriorityQueue
7+
8+
9+
def solve_tsp_branch_and_bound(
10+
distance_matrix: np.ndarray,
11+
) -> Tuple[List[int], float]:
12+
"""
13+
Solve the Traveling Salesperson Problem (TSP) using the
14+
Branch and Bound algorithm.
15+
16+
Parameters
17+
----------
18+
distance_matrix
19+
The distance matrix representing the distances between cities.
20+
21+
Returns
22+
-------
23+
Tuple
24+
A tuple containing the optimal path (list of city indices) and its
25+
total cost. If the TSP cannot be solved, an empty path and a cost
26+
of positive infinity will be returned.
27+
28+
Notes
29+
-----
30+
The `distance_matrix` should be a square matrix with non-negative
31+
values. The element `distance_matrix[i][j]` represents the distance from
32+
city `i` to city `j`. If two cities are not directly connected, the
33+
distance should be set to a float value of positive infinity
34+
(float('inf')).
35+
36+
The path is represented as a list of city indices, and the total cost is a
37+
float value indicating the sum of distances in the optimal path.
38+
39+
If the TSP cannot be solved (e.g., due to disconnected cities), the
40+
function will return an empty path ([]) and a cost of positive infinity
41+
(float('inf')).
42+
43+
References
44+
----------
45+
.. [1] Horowitz, E., Sahni, S., & Rajasekaran, S. (1997).
46+
Computer Algorithms. Chapter 8 - Branch and Bound. Section 8.3.
47+
W. H. Freeman and Company.
48+
"""
49+
num_cities = len(distance_matrix)
50+
cost_matrix = np.copy(distance_matrix).astype(float)
51+
np.fill_diagonal(cost_matrix, inf)
52+
53+
root = Node.from_cost_matrix(cost_matrix=cost_matrix)
54+
pq = PriorityQueue([root])
55+
56+
while not pq.empty:
57+
min_node = pq.pop()
58+
59+
if min_node.level == num_cities - 1:
60+
return min_node.path, min_node.cost
61+
62+
for index in range(num_cities):
63+
is_live_node = min_node.cost_matrix[min_node.index][index] != inf
64+
if is_live_node:
65+
live_node = Node.from_parent(parent=min_node, index=index)
66+
pq.push(live_node)
67+
68+
return [], inf

tests/exact/branch_and_bound/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)