Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"KClique": [$k$-Clique],
"MinimumDominatingSet": [Minimum Dominating Set],
"MaximumMatching": [Maximum Matching],
"BottleneckTravelingSalesman": [Bottleneck Traveling Salesman],
"TravelingSalesman": [Traveling Salesman],
"MaximumClique": [Maximum Clique],
"MaximumSetPacking": [Maximum Set Packing],
Expand Down Expand Up @@ -1286,6 +1287,96 @@ is feasible: each set induces a connected subgraph, the component weights are $2
]
}

#{
let x = load-model-example("BottleneckTravelingSalesman")
let nv = graph-num-vertices(x.instance)
let edges = x.instance.graph.edges
let ew = x.instance.edge_weights
let sol = (config: x.optimal_config, metric: x.optimal_value)
let tour-edges = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => edges.at(i))
let bottleneck = sol.metric.Valid
let tour-weights = tour-edges.map(((u, v)) => {
let idx = edges.position(e => e == (u, v) or e == (v, u))
int(ew.at(idx))
})
let tour-total = tour-weights.sum()
let tour-order = (0,)
let remaining = tour-edges
for _ in range(nv - 1) {
let curr = tour-order.last()
let next-edge = remaining.find(e => e.at(0) == curr or e.at(1) == curr)
let next-v = if next-edge.at(0) == curr { next-edge.at(1) } else { next-edge.at(0) }
tour-order.push(next-v)
remaining = remaining.filter(e => e != next-edge)
}
let tsp-order = (0, 2, 3, 1, 4)
let tsp-total = 13
let tsp-bottleneck = 5
let weight-labels = edges.map(((u, v)) => {
let idx = edges.position(e => e == (u, v))
(u: u, v: v, w: ew.at(idx))
})
[
#problem-def("BottleneckTravelingSalesman")[
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)$.
][
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.

*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.

#figure({
let verts = ((0, 1.8), (1.7, 0.55), (1.05, -1.45), (-1.05, -1.45), (-1.7, 0.55))
canvas(length: 1cm, {
for (idx, (u, v)) in edges.enumerate() {
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))
let on-tsp-only = (u == 0 and v == 4) or (u == 4 and v == 0)
g-edge(
verts.at(u),
verts.at(v),
stroke: if on-tour {
2pt + graph-colors.at(0)
} else if on-tsp-only {
1.5pt + rgb("#c44e38")
} else {
0.8pt + luma(200)
},
)
let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2
let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2
let (dx, dy) = if u == 0 and v == 1 {
(0.16, 0.2)
} else if u == 0 and v == 2 {
(0.25, 0.03)
} else if u == 0 and v == 3 {
(-0.25, 0.03)
} else if u == 0 and v == 4 {
(-0.16, 0.2)
} else if u == 1 and v == 2 {
(0.22, -0.05)
} else if u == 1 and v == 3 {
(0.12, -0.18)
} else if u == 1 and v == 4 {
(0, 0.12)
} else if u == 2 and v == 3 {
(0, -0.2)
} else if u == 2 and v == 4 {
(-0.12, -0.18)
} else {
(-0.22, -0.05)
}
draw.content((mx + dx, my + dy), text(7pt, fill: luma(80))[#str(int(ew.at(idx)))])
}
for (k, pos) in verts.enumerate() {
g-node(pos, name: "v" + str(k), fill: graph-colors.at(0), label: text(fill: white)[$v_#k$])
}
})
},
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.],
) <fig:k5-btsp>
]
]
}

#{
let x = load-model-example("TravelingSalesman")
let nv = graph-num-vertices(x.instance)
Expand Down
11 changes: 11 additions & 0 deletions docs/paper/references.bib
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,17 @@ @article{heldkarp1962
doi = {10.1137/0110015}
}

@article{gilmore1964,
author = {P. C. Gilmore and R. E. Gomory},
title = {Sequencing a One State-Variable Machine: A Solvable Case of the Traveling Salesman Problem},
journal = {Operations Research},
volume = {12},
number = {5},
pages = {655--679},
year = {1964},
doi = {10.1287/opre.12.5.655}
}

@article{beigel2005,
author = {Richard Beigel and David Eppstein},
title = {3-Coloring in Time {$O(1.3289^n)$}},
Expand Down
2 changes: 1 addition & 1 deletion problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ TIP: Run `pred create <PROBLEM>` (no other flags) to see problem-specific help.

Flags by problem type:
MIS, MVC, MaxClique, MinDomSet --graph, --weights
MaxCut, MaxMatching, TSP --graph, --edge-weights
MaxCut, MaxMatching, TSP, BottleneckTravelingSalesman --graph, --edge-weights
ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound
MaximalIS --graph, --weights
SAT, NAESAT --num-vars, --clauses
Expand Down
25 changes: 17 additions & 8 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
}
"IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2",
"KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3",
"MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
"BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
"--graph 0-1,1-2,2-3 --edge-weights 1,1,1"
}
"ShortestWeightConstrainedPath" => {
Expand Down Expand Up @@ -649,12 +649,13 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
fn uses_edge_weights_flag(canonical: &str) -> bool {
matches!(
canonical,
"KthBestSpanningTree"
"BottleneckTravelingSalesman"
| "KthBestSpanningTree"
| "MaxCut"
| "MaximumMatching"
| "TravelingSalesman"
| "RuralPostman"
| "MixedChinesePostman"
| "RuralPostman"
| "TravelingSalesman"
)
}

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

// Graph problems with edge weights
"MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
"BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?;
let (graph, _) = parse_graph(args).map_err(|e| {
anyhow::anyhow!(
Expand All @@ -1468,6 +1469,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
})?;
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
let data = match canonical {
"BottleneckTravelingSalesman" => ser(BottleneckTravelingSalesman::new(graph, edge_weights))?,
"MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
"MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
"TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
Expand Down Expand Up @@ -5186,16 +5188,22 @@ fn create_random(
}

// Graph problems with edge weights
"MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
"BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
let edge_prob = args.edge_prob.unwrap_or(0.5);
if !(0.0..=1.0).contains(&edge_prob) {
bail!("--edge-prob must be between 0.0 and 1.0");
}
let graph = util::create_random_graph(num_vertices, edge_prob, args.seed);
let num_edges = graph.num_edges();
let edge_weights = vec![1i32; num_edges];
let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
let variant = match canonical {
"BottleneckTravelingSalesman" => variant_map(&[]),
_ => variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]),
};
let data = match canonical {
"BottleneckTravelingSalesman" => {
ser(BottleneckTravelingSalesman::new(graph, edge_weights))?
}
"MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
"MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
"TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
Expand Down Expand Up @@ -5303,7 +5311,8 @@ fn create_random(
"Random generation is not supported for {canonical}. \
Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, TravelingSalesman, \
SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)"
BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, \
OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)"
),
};

Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub mod prelude {
};
pub use crate::models::graph::{
AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover,
BiconnectivityAugmentation, BoundedComponentSpanningForest,
BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest,
DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit,
HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree,
LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, SteinerTree,
Expand Down
173 changes: 173 additions & 0 deletions src/models/graph/bottleneck_traveling_salesman.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! Bottleneck Traveling Salesman problem implementation.
//!
//! The Bottleneck Traveling Salesman problem asks for a Hamiltonian cycle
//! minimizing the maximum selected edge weight.

use crate::registry::{FieldInfo, ProblemSchemaEntry};
use crate::topology::{Graph, SimpleGraph};
use crate::traits::{OptimizationProblem, Problem};
use crate::types::{Direction, SolutionSize};
use serde::{Deserialize, Serialize};

inventory::submit! {
ProblemSchemaEntry {
name: "BottleneckTravelingSalesman",
display_name: "Bottleneck Traveling Salesman",
aliases: &[],
dimensions: &[],
module_path: module_path!(),
description: "Find a Hamiltonian cycle minimizing the maximum selected edge weight",
fields: &[
FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" },
FieldInfo { name: "edge_weights", type_name: "Vec<i32>", description: "Edge weights w: E -> Z" },
],
}
}

/// The Bottleneck Traveling Salesman problem on a simple weighted graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BottleneckTravelingSalesman {
graph: SimpleGraph,
edge_weights: Vec<i32>,
}

impl BottleneckTravelingSalesman {
/// Create a BottleneckTravelingSalesman problem from a graph with edge weights.
pub fn new(graph: SimpleGraph, edge_weights: Vec<i32>) -> Self {
assert_eq!(
edge_weights.len(),
graph.num_edges(),
"edge_weights length must match num_edges"
);
Self {
graph,
edge_weights,
}
}

/// Get a reference to the underlying graph.
pub fn graph(&self) -> &SimpleGraph {
&self.graph
}

/// Get the weights for the problem.
pub fn weights(&self) -> Vec<i32> {
self.edge_weights.clone()
}

/// Set new weights for the problem.
pub fn set_weights(&mut self, weights: Vec<i32>) {
assert_eq!(weights.len(), self.graph.num_edges());
self.edge_weights = weights;
}

/// Get all edges with their weights.
pub fn edges(&self) -> Vec<(usize, usize, i32)> {
self.graph
.edges()
.into_iter()
.zip(self.edge_weights.iter().copied())
.map(|((u, v), w)| (u, v, w))
.collect()
}

/// Get the number of vertices in the underlying graph.
pub fn num_vertices(&self) -> usize {
self.graph.num_vertices()
}

/// Get the number of edges in the underlying graph.
pub fn num_edges(&self) -> usize {
self.graph.num_edges()
}

/// This model is always weighted.
pub fn is_weighted(&self) -> bool {
true
}

/// Check if a configuration is a valid Hamiltonian cycle.
pub fn is_valid_solution(&self, config: &[usize]) -> bool {
if config.len() != self.graph.num_edges() {
return false;
}
let selected: Vec<bool> = config.iter().map(|&s| s == 1).collect();
super::traveling_salesman::is_hamiltonian_cycle(&self.graph, &selected)
}
}

impl Problem for BottleneckTravelingSalesman {
const NAME: &'static str = "BottleneckTravelingSalesman";
type Metric = SolutionSize<i32>;

fn variant() -> Vec<(&'static str, &'static str)> {
crate::variant_params![]
}

fn dims(&self) -> Vec<usize> {
vec![2; self.graph.num_edges()]
}

fn evaluate(&self, config: &[usize]) -> SolutionSize<i32> {
if config.len() != self.graph.num_edges() {
return SolutionSize::Invalid;
}

let selected: Vec<bool> = config.iter().map(|&s| s == 1).collect();
if !super::traveling_salesman::is_hamiltonian_cycle(&self.graph, &selected) {
return SolutionSize::Invalid;
}

let bottleneck = config
.iter()
.zip(self.edge_weights.iter())
.filter_map(|(&selected, &weight)| (selected == 1).then_some(weight))
.max()
.expect("valid Hamiltonian cycle selects at least one edge");

SolutionSize::Valid(bottleneck)
}
}

impl OptimizationProblem for BottleneckTravelingSalesman {
type Value = i32;

fn direction(&self) -> Direction {
Direction::Minimize
}
}

#[cfg(feature = "example-db")]
pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
vec![crate::example_db::specs::ModelExampleSpec {
id: "bottleneck_traveling_salesman",
instance: Box::new(BottleneckTravelingSalesman::new(
SimpleGraph::new(
5,
vec![
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(1, 2),
(1, 3),
(1, 4),
(2, 3),
(2, 4),
(3, 4),
],
),
vec![5, 4, 4, 5, 4, 1, 2, 1, 5, 4],
)),
optimal_config: vec![0, 1, 1, 0, 1, 0, 1, 0, 0, 1],
optimal_value: serde_json::json!({"Valid": 4}),
}]
}

crate::declare_variants! {
default opt BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices",
}

#[cfg(test)]
#[path = "../../unit_tests/models/graph/bottleneck_traveling_salesman.rs"]
mod tests;
Loading
Loading