Skip to content

Commit 62dd9f4

Browse files
GiggleLiuisPANN
andauthored
Fix #240: [Model] BottleneckTravelingSalesman (#727)
* Add plan for #240: [Model] BottleneckTravelingSalesman * Implement #240: [Model] BottleneckTravelingSalesman * chore: remove plan file after implementation --------- Co-authored-by: Xiwei Pan <xiwei.pan@connect.hkust-gz.edu.cn>
1 parent 51e49f5 commit 62dd9f4

9 files changed

Lines changed: 443 additions & 19 deletions

File tree

docs/paper/reductions.typ

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"KClique": [$k$-Clique],
8282
"MinimumDominatingSet": [Minimum Dominating Set],
8383
"MaximumMatching": [Maximum Matching],
84+
"BottleneckTravelingSalesman": [Bottleneck Traveling Salesman],
8485
"TravelingSalesman": [Traveling Salesman],
8586
"MaximumClique": [Maximum Clique],
8687
"MaximumSetPacking": [Maximum Set Packing],
@@ -1286,6 +1287,96 @@ is feasible: each set induces a connected subgraph, the component weights are $2
12861287
]
12871288
}
12881289

1290+
#{
1291+
let x = load-model-example("BottleneckTravelingSalesman")
1292+
let nv = graph-num-vertices(x.instance)
1293+
let edges = x.instance.graph.edges
1294+
let ew = x.instance.edge_weights
1295+
let sol = (config: x.optimal_config, metric: x.optimal_value)
1296+
let tour-edges = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => edges.at(i))
1297+
let bottleneck = sol.metric.Valid
1298+
let tour-weights = tour-edges.map(((u, v)) => {
1299+
let idx = edges.position(e => e == (u, v) or e == (v, u))
1300+
int(ew.at(idx))
1301+
})
1302+
let tour-total = tour-weights.sum()
1303+
let tour-order = (0,)
1304+
let remaining = tour-edges
1305+
for _ in range(nv - 1) {
1306+
let curr = tour-order.last()
1307+
let next-edge = remaining.find(e => e.at(0) == curr or e.at(1) == curr)
1308+
let next-v = if next-edge.at(0) == curr { next-edge.at(1) } else { next-edge.at(0) }
1309+
tour-order.push(next-v)
1310+
remaining = remaining.filter(e => e != next-edge)
1311+
}
1312+
let tsp-order = (0, 2, 3, 1, 4)
1313+
let tsp-total = 13
1314+
let tsp-bottleneck = 5
1315+
let weight-labels = edges.map(((u, v)) => {
1316+
let idx = edges.position(e => e == (u, v))
1317+
(u: u, v: v, w: ew.at(idx))
1318+
})
1319+
[
1320+
#problem-def("BottleneckTravelingSalesman")[
1321+
Given an undirected graph $G=(V,E)$ with edge weights $w: E -> RR$, find an edge set $C subset.eq E$ that forms a cycle visiting every vertex exactly once and minimizes $max_(e in C) w(e)$.
1322+
][
1323+
This min-max variant models routing where the worst leg matters more than the total distance. Garey and Johnson list the threshold decision version as ND24 @garey1979: given a bound $B$, ask whether some Hamiltonian tour has every edge weight at most $B$. The optimization version implemented here subsumes that decision problem. The classical Held--Karp dynamic programming algorithm still yields an exact $O(n^2 dot 2^n)$-time algorithm @heldkarp1962, while Garey and Johnson note the polynomial-time special case of Gilmore and Gomory @gilmore1964.
1324+
1325+
*Example.* Consider the complete graph $K_#nv$ with vertices ${#range(nv).map(i => $v_#i$).join(", ")}$ and edge weights #weight-labels.map(l => $w(v_#(l.u), v_#(l.v)) = #(int(l.w))$).join(", "). The unique optimal bottleneck tour is $#tour-order.map(v => $v_#v$).join($arrow$) arrow v_#(tour-order.at(0))$ with edge weights #tour-weights.map(w => str(w)).join(", ") and bottleneck #bottleneck. Its total weight is #tour-total. By contrast, the minimum-total-weight TSP tour $#tsp-order.map(v => $v_#v$).join($arrow$) arrow v_#(tsp-order.at(0))$ has total weight #tsp-total but bottleneck #tsp-bottleneck, because it uses the weight-5 edge $(v_0, v_4)$. Here every other Hamiltonian tour in $K_#nv$ contains a weight-5 edge, so the blue tour is the only one that keeps the maximum edge weight at 4.
1326+
1327+
#figure({
1328+
let verts = ((0, 1.8), (1.7, 0.55), (1.05, -1.45), (-1.05, -1.45), (-1.7, 0.55))
1329+
canvas(length: 1cm, {
1330+
for (idx, (u, v)) in edges.enumerate() {
1331+
let on-tour = tour-edges.any(t => (t.at(0) == u and t.at(1) == v) or (t.at(0) == v and t.at(1) == u))
1332+
let on-tsp-only = (u == 0 and v == 4) or (u == 4 and v == 0)
1333+
g-edge(
1334+
verts.at(u),
1335+
verts.at(v),
1336+
stroke: if on-tour {
1337+
2pt + graph-colors.at(0)
1338+
} else if on-tsp-only {
1339+
1.5pt + rgb("#c44e38")
1340+
} else {
1341+
0.8pt + luma(200)
1342+
},
1343+
)
1344+
let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2
1345+
let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2
1346+
let (dx, dy) = if u == 0 and v == 1 {
1347+
(0.16, 0.2)
1348+
} else if u == 0 and v == 2 {
1349+
(0.25, 0.03)
1350+
} else if u == 0 and v == 3 {
1351+
(-0.25, 0.03)
1352+
} else if u == 0 and v == 4 {
1353+
(-0.16, 0.2)
1354+
} else if u == 1 and v == 2 {
1355+
(0.22, -0.05)
1356+
} else if u == 1 and v == 3 {
1357+
(0.12, -0.18)
1358+
} else if u == 1 and v == 4 {
1359+
(0, 0.12)
1360+
} else if u == 2 and v == 3 {
1361+
(0, -0.2)
1362+
} else if u == 2 and v == 4 {
1363+
(-0.12, -0.18)
1364+
} else {
1365+
(-0.22, -0.05)
1366+
}
1367+
draw.content((mx + dx, my + dy), text(7pt, fill: luma(80))[#str(int(ew.at(idx)))])
1368+
}
1369+
for (k, pos) in verts.enumerate() {
1370+
g-node(pos, name: "v" + str(k), fill: graph-colors.at(0), label: text(fill: white)[$v_#k$])
1371+
}
1372+
})
1373+
},
1374+
caption: [The $K_5$ bottleneck-TSP instance. Blue edges form the unique optimal bottleneck tour; the red edge $(v_0, v_4)$ is the weight-5 edge used by the minimum-total-weight TSP tour.],
1375+
) <fig:k5-btsp>
1376+
]
1377+
]
1378+
}
1379+
12891380
#{
12901381
let x = load-model-example("TravelingSalesman")
12911382
let nv = graph-num-vertices(x.instance)

docs/paper/references.bib

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,17 @@ @article{heldkarp1962
449449
doi = {10.1137/0110015}
450450
}
451451

452+
@article{gilmore1964,
453+
author = {P. C. Gilmore and R. E. Gomory},
454+
title = {Sequencing a One State-Variable Machine: A Solvable Case of the Traveling Salesman Problem},
455+
journal = {Operations Research},
456+
volume = {12},
457+
number = {5},
458+
pages = {655--679},
459+
year = {1964},
460+
doi = {10.1287/opre.12.5.655}
461+
}
462+
452463
@article{beigel2005,
453464
author = {Richard Beigel and David Eppstein},
454465
title = {3-Coloring in Time {$O(1.3289^n)$}},

problemreductions-cli/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ TIP: Run `pred create <PROBLEM>` (no other flags) to see problem-specific help.
216216
217217
Flags by problem type:
218218
MIS, MVC, MaxClique, MinDomSet --graph, --weights
219-
MaxCut, MaxMatching, TSP --graph, --edge-weights
219+
MaxCut, MaxMatching, TSP, BottleneckTravelingSalesman --graph, --edge-weights
220220
ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound
221221
MaximalIS --graph, --weights
222222
SAT, NAESAT --num-vars, --clauses

problemreductions-cli/src/commands/create.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
527527
}
528528
"IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2",
529529
"KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3",
530-
"MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
530+
"BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
531531
"--graph 0-1,1-2,2-3 --edge-weights 1,1,1"
532532
}
533533
"ShortestWeightConstrainedPath" => {
@@ -649,12 +649,13 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
649649
fn uses_edge_weights_flag(canonical: &str) -> bool {
650650
matches!(
651651
canonical,
652-
"KthBestSpanningTree"
652+
"BottleneckTravelingSalesman"
653+
| "KthBestSpanningTree"
653654
| "MaxCut"
654655
| "MaximumMatching"
655-
| "TravelingSalesman"
656-
| "RuralPostman"
657656
| "MixedChinesePostman"
657+
| "RuralPostman"
658+
| "TravelingSalesman"
658659
)
659660
}
660661

@@ -1458,7 +1459,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
14581459
}
14591460

14601461
// Graph problems with edge weights
1461-
"MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
1462+
"BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
14621463
reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?;
14631464
let (graph, _) = parse_graph(args).map_err(|e| {
14641465
anyhow::anyhow!(
@@ -1468,6 +1469,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
14681469
})?;
14691470
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
14701471
let data = match canonical {
1472+
"BottleneckTravelingSalesman" => ser(BottleneckTravelingSalesman::new(graph, edge_weights))?,
14711473
"MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
14721474
"MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
14731475
"TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
@@ -5186,16 +5188,22 @@ fn create_random(
51865188
}
51875189

51885190
// Graph problems with edge weights
5189-
"MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
5191+
"BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
51905192
let edge_prob = args.edge_prob.unwrap_or(0.5);
51915193
if !(0.0..=1.0).contains(&edge_prob) {
51925194
bail!("--edge-prob must be between 0.0 and 1.0");
51935195
}
51945196
let graph = util::create_random_graph(num_vertices, edge_prob, args.seed);
51955197
let num_edges = graph.num_edges();
51965198
let edge_weights = vec![1i32; num_edges];
5197-
let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
5199+
let variant = match canonical {
5200+
"BottleneckTravelingSalesman" => variant_map(&[]),
5201+
_ => variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]),
5202+
};
51985203
let data = match canonical {
5204+
"BottleneckTravelingSalesman" => {
5205+
ser(BottleneckTravelingSalesman::new(graph, edge_weights))?
5206+
}
51995207
"MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
52005208
"MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
52015209
"TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
@@ -5303,7 +5311,8 @@ fn create_random(
53035311
"Random generation is not supported for {canonical}. \
53045312
Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
53055313
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, TravelingSalesman, \
5306-
SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)"
5314+
BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, \
5315+
OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)"
53075316
),
53085317
};
53095318

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub mod prelude {
4949
};
5050
pub use crate::models::graph::{
5151
AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover,
52-
BiconnectivityAugmentation, BoundedComponentSpanningForest,
52+
BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest,
5353
DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit,
5454
HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree,
5555
LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, SteinerTree,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//! Bottleneck Traveling Salesman problem implementation.
2+
//!
3+
//! The Bottleneck Traveling Salesman problem asks for a Hamiltonian cycle
4+
//! minimizing the maximum selected edge weight.
5+
6+
use crate::registry::{FieldInfo, ProblemSchemaEntry};
7+
use crate::topology::{Graph, SimpleGraph};
8+
use crate::traits::{OptimizationProblem, Problem};
9+
use crate::types::{Direction, SolutionSize};
10+
use serde::{Deserialize, Serialize};
11+
12+
inventory::submit! {
13+
ProblemSchemaEntry {
14+
name: "BottleneckTravelingSalesman",
15+
display_name: "Bottleneck Traveling Salesman",
16+
aliases: &[],
17+
dimensions: &[],
18+
module_path: module_path!(),
19+
description: "Find a Hamiltonian cycle minimizing the maximum selected edge weight",
20+
fields: &[
21+
FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" },
22+
FieldInfo { name: "edge_weights", type_name: "Vec<i32>", description: "Edge weights w: E -> Z" },
23+
],
24+
}
25+
}
26+
27+
/// The Bottleneck Traveling Salesman problem on a simple weighted graph.
28+
#[derive(Debug, Clone, Serialize, Deserialize)]
29+
pub struct BottleneckTravelingSalesman {
30+
graph: SimpleGraph,
31+
edge_weights: Vec<i32>,
32+
}
33+
34+
impl BottleneckTravelingSalesman {
35+
/// Create a BottleneckTravelingSalesman problem from a graph with edge weights.
36+
pub fn new(graph: SimpleGraph, edge_weights: Vec<i32>) -> Self {
37+
assert_eq!(
38+
edge_weights.len(),
39+
graph.num_edges(),
40+
"edge_weights length must match num_edges"
41+
);
42+
Self {
43+
graph,
44+
edge_weights,
45+
}
46+
}
47+
48+
/// Get a reference to the underlying graph.
49+
pub fn graph(&self) -> &SimpleGraph {
50+
&self.graph
51+
}
52+
53+
/// Get the weights for the problem.
54+
pub fn weights(&self) -> Vec<i32> {
55+
self.edge_weights.clone()
56+
}
57+
58+
/// Set new weights for the problem.
59+
pub fn set_weights(&mut self, weights: Vec<i32>) {
60+
assert_eq!(weights.len(), self.graph.num_edges());
61+
self.edge_weights = weights;
62+
}
63+
64+
/// Get all edges with their weights.
65+
pub fn edges(&self) -> Vec<(usize, usize, i32)> {
66+
self.graph
67+
.edges()
68+
.into_iter()
69+
.zip(self.edge_weights.iter().copied())
70+
.map(|((u, v), w)| (u, v, w))
71+
.collect()
72+
}
73+
74+
/// Get the number of vertices in the underlying graph.
75+
pub fn num_vertices(&self) -> usize {
76+
self.graph.num_vertices()
77+
}
78+
79+
/// Get the number of edges in the underlying graph.
80+
pub fn num_edges(&self) -> usize {
81+
self.graph.num_edges()
82+
}
83+
84+
/// This model is always weighted.
85+
pub fn is_weighted(&self) -> bool {
86+
true
87+
}
88+
89+
/// Check if a configuration is a valid Hamiltonian cycle.
90+
pub fn is_valid_solution(&self, config: &[usize]) -> bool {
91+
if config.len() != self.graph.num_edges() {
92+
return false;
93+
}
94+
let selected: Vec<bool> = config.iter().map(|&s| s == 1).collect();
95+
super::traveling_salesman::is_hamiltonian_cycle(&self.graph, &selected)
96+
}
97+
}
98+
99+
impl Problem for BottleneckTravelingSalesman {
100+
const NAME: &'static str = "BottleneckTravelingSalesman";
101+
type Metric = SolutionSize<i32>;
102+
103+
fn variant() -> Vec<(&'static str, &'static str)> {
104+
crate::variant_params![]
105+
}
106+
107+
fn dims(&self) -> Vec<usize> {
108+
vec![2; self.graph.num_edges()]
109+
}
110+
111+
fn evaluate(&self, config: &[usize]) -> SolutionSize<i32> {
112+
if config.len() != self.graph.num_edges() {
113+
return SolutionSize::Invalid;
114+
}
115+
116+
let selected: Vec<bool> = config.iter().map(|&s| s == 1).collect();
117+
if !super::traveling_salesman::is_hamiltonian_cycle(&self.graph, &selected) {
118+
return SolutionSize::Invalid;
119+
}
120+
121+
let bottleneck = config
122+
.iter()
123+
.zip(self.edge_weights.iter())
124+
.filter_map(|(&selected, &weight)| (selected == 1).then_some(weight))
125+
.max()
126+
.expect("valid Hamiltonian cycle selects at least one edge");
127+
128+
SolutionSize::Valid(bottleneck)
129+
}
130+
}
131+
132+
impl OptimizationProblem for BottleneckTravelingSalesman {
133+
type Value = i32;
134+
135+
fn direction(&self) -> Direction {
136+
Direction::Minimize
137+
}
138+
}
139+
140+
#[cfg(feature = "example-db")]
141+
pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
142+
vec![crate::example_db::specs::ModelExampleSpec {
143+
id: "bottleneck_traveling_salesman",
144+
instance: Box::new(BottleneckTravelingSalesman::new(
145+
SimpleGraph::new(
146+
5,
147+
vec![
148+
(0, 1),
149+
(0, 2),
150+
(0, 3),
151+
(0, 4),
152+
(1, 2),
153+
(1, 3),
154+
(1, 4),
155+
(2, 3),
156+
(2, 4),
157+
(3, 4),
158+
],
159+
),
160+
vec![5, 4, 4, 5, 4, 1, 2, 1, 5, 4],
161+
)),
162+
optimal_config: vec![0, 1, 1, 0, 1, 0, 1, 0, 0, 1],
163+
optimal_value: serde_json::json!({"Valid": 4}),
164+
}]
165+
}
166+
167+
crate::declare_variants! {
168+
default opt BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices",
169+
}
170+
171+
#[cfg(test)]
172+
#[path = "../../unit_tests/models/graph/bottleneck_traveling_salesman.rs"]
173+
mod tests;

0 commit comments

Comments
 (0)