Skip to main content

problemreductions/models/graph/
mixed_chinese_postman.rs

1//! Mixed Chinese Postman problem implementation.
2//!
3//! Given a mixed graph with directed arcs and undirected edges, find a
4//! minimum-cost closed walk that traverses every directed arc in its prescribed
5//! direction and every undirected edge in at least one direction.
6
7use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension};
8use crate::topology::{DirectedGraph, MixedGraph};
9use crate::traits::Problem;
10use crate::types::{Min, One, WeightElement};
11use num_traits::Zero;
12use serde::{Deserialize, Serialize};
13use std::cmp::Ordering;
14
15const INF_COST: i64 = i64::MAX / 4;
16
17inventory::submit! {
18    ProblemSchemaEntry {
19        name: "MixedChinesePostman",
20        display_name: "Mixed Chinese Postman",
21        aliases: &["MCPP"],
22        dimensions: &[
23            VariantDimension::new("weight", "i32", &["i32", "One"]),
24        ],
25        module_path: module_path!(),
26        description: "Find a minimum-cost closed walk covering all arcs and edges in a mixed graph",
27        fields: &[
28            FieldInfo { name: "graph", type_name: "MixedGraph", description: "The mixed graph G=(V,A,E)" },
29            FieldInfo { name: "arc_weights", type_name: "Vec<W>", description: "Lengths for the directed arcs in A" },
30            FieldInfo { name: "edge_weights", type_name: "Vec<W>", description: "Lengths for the undirected edges in E" },
31        ],
32    }
33}
34
35/// Mixed Chinese Postman.
36///
37/// Each configuration picks a required traversal direction for every undirected
38/// edge. The minimum-cost closed walk is then computed via the directed Chinese
39/// Postman subproblem, using all available arcs (including both directions of
40/// every undirected edge) for degree-balancing detours.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct MixedChinesePostman<W: WeightElement<Sum = i32>> {
43    graph: MixedGraph,
44    arc_weights: Vec<W>,
45    edge_weights: Vec<W>,
46}
47
48impl<W: WeightElement<Sum = i32>> MixedChinesePostman<W> {
49    /// Create a new mixed Chinese postman instance.
50    ///
51    /// # Panics
52    ///
53    /// Panics if the weight-vector lengths do not match the graph shape or if
54    /// any weight is negative.
55    pub fn new(graph: MixedGraph, arc_weights: Vec<W>, edge_weights: Vec<W>) -> Self {
56        assert_eq!(
57            arc_weights.len(),
58            graph.num_arcs(),
59            "arc_weights length must match num_arcs"
60        );
61        assert_eq!(
62            edge_weights.len(),
63            graph.num_edges(),
64            "edge_weights length must match num_edges"
65        );
66        for (index, weight) in arc_weights.iter().enumerate() {
67            assert!(
68                matches!(
69                    weight.to_sum().partial_cmp(&W::Sum::zero()),
70                    Some(Ordering::Equal | Ordering::Greater)
71                ),
72                "arc weight at index {} must be nonnegative",
73                index
74            );
75        }
76        for (index, weight) in edge_weights.iter().enumerate() {
77            assert!(
78                matches!(
79                    weight.to_sum().partial_cmp(&W::Sum::zero()),
80                    Some(Ordering::Equal | Ordering::Greater)
81                ),
82                "edge weight at index {} must be nonnegative",
83                index
84            );
85        }
86
87        Self {
88            graph,
89            arc_weights,
90            edge_weights,
91        }
92    }
93
94    /// Return the mixed graph.
95    pub fn graph(&self) -> &MixedGraph {
96        &self.graph
97    }
98
99    /// Return the directed-arc lengths.
100    pub fn arc_weights(&self) -> &[W] {
101        &self.arc_weights
102    }
103
104    /// Return the undirected-edge lengths.
105    pub fn edge_weights(&self) -> &[W] {
106        &self.edge_weights
107    }
108
109    /// Return the number of vertices.
110    pub fn num_vertices(&self) -> usize {
111        self.graph.num_vertices()
112    }
113
114    /// Return the number of directed arcs.
115    pub fn num_arcs(&self) -> usize {
116        self.graph.num_arcs()
117    }
118
119    /// Return the number of undirected edges.
120    pub fn num_edges(&self) -> usize {
121        self.graph.num_edges()
122    }
123
124    /// Return whether this instance uses non-unit lengths.
125    pub fn is_weighted(&self) -> bool {
126        !W::IS_UNIT
127    }
128
129    fn oriented_arc_pairs(&self, config: &[usize]) -> Option<Vec<(usize, usize)>> {
130        if config.len() != self.graph.num_edges() {
131            return None;
132        }
133
134        let mut arcs = self.graph.arcs();
135        for ((u, v), &direction) in self.graph.edges().iter().zip(config.iter()) {
136            match direction {
137                0 => arcs.push((*u, *v)),
138                1 => arcs.push((*v, *u)),
139                _ => return None,
140            }
141        }
142        Some(arcs)
143    }
144
145    fn available_arc_pairs(&self) -> Vec<(usize, usize)> {
146        let mut arcs = self.graph.arcs();
147        for &(u, v) in self.graph.edges().iter() {
148            arcs.push((u, v));
149            arcs.push((v, u));
150        }
151        arcs
152    }
153
154    fn weighted_available_arcs(&self) -> Vec<(usize, usize, i64)> {
155        let mut arcs: Vec<(usize, usize, i64)> = self
156            .graph
157            .arcs()
158            .into_iter()
159            .zip(self.arc_weights.iter())
160            .map(|((u, v), weight)| (u, v, i64::from(weight.to_sum())))
161            .collect();
162
163        for ((u, v), weight) in self.graph.edges().iter().zip(self.edge_weights.iter()) {
164            let cost = i64::from(weight.to_sum());
165            arcs.push((*u, *v, cost));
166            arcs.push((*v, *u, cost));
167        }
168
169        arcs
170    }
171
172    fn base_cost(&self) -> i64 {
173        self.arc_weights
174            .iter()
175            .map(|weight| i64::from(weight.to_sum()))
176            .sum::<i64>()
177            + self
178                .edge_weights
179                .iter()
180                .map(|weight| i64::from(weight.to_sum()))
181                .sum::<i64>()
182    }
183}
184
185impl<W> MixedChinesePostman<W>
186where
187    W: WeightElement<Sum = i32> + crate::variant::VariantParam,
188{
189    /// Check whether a configuration yields a valid orientation (strongly
190    /// connected with proper coverage).
191    pub fn is_valid_solution(&self, config: &[usize]) -> bool {
192        self.evaluate(config).0.is_some()
193    }
194}
195
196impl<W> Problem for MixedChinesePostman<W>
197where
198    W: WeightElement<Sum = i32> + crate::variant::VariantParam,
199{
200    const NAME: &'static str = "MixedChinesePostman";
201    type Value = Min<W::Sum>;
202
203    fn variant() -> Vec<(&'static str, &'static str)> {
204        crate::variant_params![W]
205    }
206
207    fn dims(&self) -> Vec<usize> {
208        vec![2; self.graph.num_edges()]
209    }
210
211    fn evaluate(&self, config: &[usize]) -> Min<W::Sum> {
212        let Some(oriented_pairs) = self.oriented_arc_pairs(config) else {
213            return Min(None);
214        };
215
216        // Connectivity uses the full available graph: original arcs plus both
217        // directions of every undirected edge.
218        if !DirectedGraph::new(self.graph.num_vertices(), self.available_arc_pairs())
219            .is_strongly_connected()
220        {
221            return Min(None);
222        }
223
224        // Shortest paths also use the full available graph so that balancing
225        // can route through undirected edges in either direction.
226        let distances =
227            all_pairs_shortest_paths(self.graph.num_vertices(), &self.weighted_available_arcs());
228        // Degree imbalance is computed from the required arcs only (original
229        // arcs plus the chosen orientation of each undirected edge).
230        let balance = degree_imbalances(self.graph.num_vertices(), &oriented_pairs);
231        let Some(extra_cost) = minimum_balancing_cost(&balance, &distances) else {
232            return Min(None);
233        };
234
235        let total = self.base_cost() + extra_cost;
236        Min(Some(total as W::Sum))
237    }
238}
239
240crate::declare_variants! {
241    default MixedChinesePostman<i32> => "2^num_edges * num_vertices^3",
242    MixedChinesePostman<One> => "2^num_edges * num_vertices^3",
243}
244
245#[cfg(feature = "example-db")]
246pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
247    vec![crate::example_db::specs::ModelExampleSpec {
248        id: "mixed_chinese_postman_i32",
249        instance: Box::new(MixedChinesePostman::new(
250            MixedGraph::new(
251                5,
252                vec![(0, 1), (1, 2), (2, 3), (3, 0)],
253                vec![(0, 2), (1, 3), (0, 4), (4, 2)],
254            ),
255            vec![2, 3, 1, 4],
256            vec![2, 3, 1, 2],
257        )),
258        optimal_config: vec![1, 1, 0, 0],
259        optimal_value: serde_json::json!(21),
260    }]
261}
262
263fn all_pairs_shortest_paths(num_vertices: usize, arcs: &[(usize, usize, i64)]) -> Vec<Vec<i64>> {
264    let mut distances = vec![vec![INF_COST; num_vertices]; num_vertices];
265
266    for (vertex, row) in distances.iter_mut().enumerate() {
267        row[vertex] = 0;
268    }
269
270    for &(u, v, cost) in arcs {
271        if cost < distances[u][v] {
272            distances[u][v] = cost;
273        }
274    }
275
276    for via in 0..num_vertices {
277        for src in 0..num_vertices {
278            if distances[src][via] == INF_COST {
279                continue;
280            }
281            for dst in 0..num_vertices {
282                if distances[via][dst] == INF_COST {
283                    continue;
284                }
285                let through = distances[src][via] + distances[via][dst];
286                if through < distances[src][dst] {
287                    distances[src][dst] = through;
288                }
289            }
290        }
291    }
292
293    distances
294}
295
296fn degree_imbalances(num_vertices: usize, arcs: &[(usize, usize)]) -> Vec<i32> {
297    let mut balance = vec![0_i32; num_vertices];
298    for &(u, v) in arcs {
299        balance[u] += 1;
300        balance[v] -= 1;
301    }
302    balance
303}
304
305fn minimum_balancing_cost(balance: &[i32], distances: &[Vec<i64>]) -> Option<i64> {
306    let mut deficits = Vec::new();
307    let mut surpluses = Vec::new();
308
309    for (vertex, &value) in balance.iter().enumerate() {
310        if value < 0 {
311            for _ in 0..usize::try_from(-value).ok()? {
312                deficits.push(vertex);
313            }
314        } else if value > 0 {
315            for _ in 0..usize::try_from(value).ok()? {
316                surpluses.push(vertex);
317            }
318        }
319    }
320
321    if deficits.len() != surpluses.len() {
322        return None;
323    }
324    if deficits.is_empty() {
325        return Some(0);
326    }
327
328    let mut costs = vec![vec![INF_COST; surpluses.len()]; deficits.len()];
329    for (row, &src) in deficits.iter().enumerate() {
330        for (col, &dst) in surpluses.iter().enumerate() {
331            costs[row][col] = distances[src][dst];
332        }
333    }
334
335    hungarian_min_cost(&costs)
336}
337
338fn hungarian_min_cost(costs: &[Vec<i64>]) -> Option<i64> {
339    let size = costs.len();
340    if size == 0 {
341        return Some(0);
342    }
343    if costs.iter().any(|row| row.len() != size) {
344        return None;
345    }
346
347    let mut u = vec![0_i64; size + 1];
348    let mut v = vec![0_i64; size + 1];
349    let mut p = vec![0_usize; size + 1];
350    let mut way = vec![0_usize; size + 1];
351
352    for row in 1..=size {
353        p[0] = row;
354        let mut column0 = 0;
355        let mut minv = vec![INF_COST; size + 1];
356        let mut used = vec![false; size + 1];
357
358        loop {
359            used[column0] = true;
360            let row0 = p[column0];
361            let mut delta = INF_COST;
362            let mut column1 = 0;
363
364            for column in 1..=size {
365                if used[column] {
366                    continue;
367                }
368
369                let current = costs[row0 - 1][column - 1] - u[row0] - v[column];
370                if current < minv[column] {
371                    minv[column] = current;
372                    way[column] = column0;
373                }
374                if minv[column] < delta {
375                    delta = minv[column];
376                    column1 = column;
377                }
378            }
379
380            if delta == INF_COST {
381                return None;
382            }
383
384            for column in 0..=size {
385                if used[column] {
386                    u[p[column]] += delta;
387                    v[column] -= delta;
388                } else {
389                    minv[column] -= delta;
390                }
391            }
392
393            column0 = column1;
394            if p[column0] == 0 {
395                break;
396            }
397        }
398
399        loop {
400            let column1 = way[column0];
401            p[column0] = p[column1];
402            column0 = column1;
403            if column0 == 0 {
404                break;
405            }
406        }
407    }
408
409    let mut assignment = vec![0_usize; size + 1];
410    for column in 1..=size {
411        assignment[p[column]] = column;
412    }
413
414    let mut total = 0_i64;
415    for row in 1..=size {
416        let cost = costs[row - 1][assignment[row] - 1];
417        if cost == INF_COST {
418            return None;
419        }
420        total += cost;
421    }
422    Some(total)
423}
424
425#[cfg(test)]
426#[path = "../../unit_tests/models/graph/mixed_chinese_postman.rs"]
427mod tests;