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::{CreateSpec, 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", "i64", &["i64", "One"]),
24        ],
25        category: crate::registry::ProblemCategory::Graph,
26        module_path: module_path!(),
27        description: "Find a minimum-cost closed walk covering all arcs and edges in a mixed graph",
28        fields: MixedChinesePostmanI64CreateSpec::FIELDS,
29    }
30}
31
32/// Mixed Chinese Postman.
33///
34/// Each configuration picks a required traversal direction for every undirected
35/// edge. The minimum-cost closed walk is then computed via the directed Chinese
36/// Postman subproblem, using all available arcs (including both directions of
37/// every undirected edge) for degree-balancing detours.
38#[derive(Debug, Clone, Serialize)]
39pub struct MixedChinesePostman<W: WeightElement<Sum = i64>> {
40    graph: MixedGraph,
41    arc_weights: Vec<W>,
42    edge_weights: Vec<W>,
43}
44
45#[derive(Deserialize)]
46#[serde(bound(deserialize = "W: WeightElement<Sum = i64> + Deserialize<'de>"))]
47struct MixedChinesePostmanData<W: WeightElement<Sum = i64>> {
48    graph: MixedGraph,
49    arc_weights: Vec<W>,
50    edge_weights: Vec<W>,
51}
52
53impl<'de, W> Deserialize<'de> for MixedChinesePostman<W>
54where
55    W: WeightElement<Sum = i64> + Deserialize<'de>,
56{
57    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
58        let data = MixedChinesePostmanData::<W>::deserialize(deserializer)?;
59        Self::try_new(data.graph, data.arc_weights, data.edge_weights)
60            .map_err(serde::de::Error::custom)
61    }
62}
63
64macro_rules! mixed_chinese_postman_create_spec {
65    ($name:ident, $weight:ty, $one:expr $(, $arc_weights:ident, $edge_weights:ident)?) => {
66        #[derive(Debug, Deserialize, crate::CreateSpec)]
67        struct $name {
68            /// Undirected graph edges.
69            #[create(codec = "edge-list")]
70            graph: Vec<(usize, usize)>,
71            /// Directed graph arcs.
72            #[create(codec = "arc-list")]
73            arcs: Vec<(usize, usize)>,
74            /// Vertex count, needed to preserve isolated vertices.
75            num_vertices: Option<usize>,
76            $(
77            /// Directed-arc lengths; defaults to one per arc.
78            #[create(codec = "comma-separated")]
79            $arc_weights: Option<Vec<$weight>>,
80            )?
81            $(
82            /// Undirected-edge lengths; defaults to one per edge.
83            #[create(codec = "comma-separated")]
84            $edge_weights: Option<Vec<$weight>>,
85            )?
86        }
87
88        impl TryFrom<$name> for MixedChinesePostman<$weight> {
89            type Error = crate::registry::ConstructionError;
90
91            fn try_from(spec: $name) -> Result<Self, Self::Error> {
92                if spec.graph.is_empty() && spec.num_vertices.is_none() {
93                    return Err("num_vertices is required for an empty graph".to_string().into());
94                }
95                if spec.arcs.is_empty() {
96                    return Err("arcs must be non-empty".to_string().into());
97                }
98                for (index, &(u, v)) in spec.graph.iter().enumerate() {
99                    if u == v {
100                        return Err(format!("graph edge {index} is a self-loop at vertex {u}").into());
101                    }
102                }
103                let inferred = spec
104                    .graph
105                    .iter()
106                    .flat_map(|&(u, v)| [u, v])
107                    .max()
108                    .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize"))
109                    .transpose()?
110                    .unwrap_or(0);
111                let num_vertices = spec.num_vertices.unwrap_or(inferred);
112                if num_vertices < inferred {
113                    return Err(format!(
114                        "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}"
115                    ).into());
116                }
117                for (index, &(u, v)) in spec.arcs.iter().enumerate() {
118                    if u >= num_vertices || v >= num_vertices {
119                        return Err(format!(
120                            "arc {index} endpoint is out of range for {num_vertices} vertices"
121                        ).into());
122                    }
123                }
124                let arc_weights = { $(if let Some(value) = spec.$arc_weights { value } else)? { vec![$one; spec.arcs.len()] } };
125                let edge_weights = { $(if let Some(value) = spec.$edge_weights { value } else)? { vec![$one; spec.graph.len()] } };
126                MixedChinesePostman::try_new(
127                    MixedGraph::new(num_vertices, spec.arcs, spec.graph),
128                    arc_weights,
129                    edge_weights,
130                )
131            }
132        }
133    };
134}
135
136mixed_chinese_postman_create_spec!(
137    MixedChinesePostmanI64CreateSpec,
138    i64,
139    1_i64,
140    arc_weights,
141    edge_weights
142);
143mixed_chinese_postman_create_spec!(MixedChinesePostmanOneCreateSpec, One, One);
144
145impl<W: WeightElement<Sum = i64>> MixedChinesePostman<W> {
146    /// Create a new mixed Chinese postman instance.
147    ///
148    /// # Panics
149    ///
150    /// Panics if the weight-vector lengths do not match the graph shape or if
151    /// any weight is negative.
152    pub fn new(graph: MixedGraph, arc_weights: Vec<W>, edge_weights: Vec<W>) -> Self {
153        Self::try_new(graph, arc_weights, edge_weights)
154            .unwrap_or_else(|message| panic!("{message}"))
155    }
156
157    /// Create an instance, returning validation errors instead of panicking.
158    pub fn try_new(
159        graph: MixedGraph,
160        arc_weights: Vec<W>,
161        edge_weights: Vec<W>,
162    ) -> Result<Self, crate::registry::ConstructionError> {
163        if arc_weights.len() != graph.num_arcs() {
164            return Err(crate::registry::ConstructionError::length_mismatch(
165                "arc_weights",
166                arc_weights.len(),
167                graph.num_arcs(),
168            ));
169        }
170        if edge_weights.len() != graph.num_edges() {
171            return Err(crate::registry::ConstructionError::length_mismatch(
172                "edge_weights",
173                edge_weights.len(),
174                graph.num_edges(),
175            ));
176        }
177        for (index, weight) in arc_weights.iter().enumerate() {
178            if !matches!(
179                weight.to_sum().partial_cmp(&W::Sum::zero()),
180                Some(Ordering::Equal | Ordering::Greater)
181            ) {
182                return Err(format!("arc weight at index {index} must be nonnegative").into());
183            }
184        }
185        for (index, weight) in edge_weights.iter().enumerate() {
186            if !matches!(
187                weight.to_sum().partial_cmp(&W::Sum::zero()),
188                Some(Ordering::Equal | Ordering::Greater)
189            ) {
190                return Err(format!("edge weight at index {index} must be nonnegative").into());
191            }
192        }
193
194        Ok(Self {
195            graph,
196            arc_weights,
197            edge_weights,
198        })
199    }
200
201    /// Return the mixed graph.
202    pub fn graph(&self) -> &MixedGraph {
203        &self.graph
204    }
205
206    /// Return the directed-arc lengths.
207    pub fn arc_weights(&self) -> &[W] {
208        &self.arc_weights
209    }
210
211    /// Return the undirected-edge lengths.
212    pub fn edge_weights(&self) -> &[W] {
213        &self.edge_weights
214    }
215
216    /// Return the number of vertices.
217    pub fn num_vertices(&self) -> usize {
218        self.graph.num_vertices()
219    }
220
221    /// Return the number of directed arcs.
222    pub fn num_arcs(&self) -> usize {
223        self.graph.num_arcs()
224    }
225
226    /// Return the number of undirected edges.
227    pub fn num_edges(&self) -> usize {
228        self.graph.num_edges()
229    }
230
231    /// Return whether this instance uses non-unit lengths.
232    pub fn is_weighted(&self) -> bool {
233        !W::IS_UNIT
234    }
235
236    fn oriented_arc_pairs(&self, config: &[bool]) -> Option<Vec<(usize, usize)>> {
237        if config.len() != self.graph.num_edges() {
238            return None;
239        }
240
241        let mut arcs = self.graph.arcs();
242        for ((u, v), &reverse) in self.graph.edges().iter().zip(config.iter()) {
243            if reverse {
244                arcs.push((*v, *u));
245            } else {
246                arcs.push((*u, *v));
247            }
248        }
249        Some(arcs)
250    }
251
252    fn available_arc_pairs(&self) -> Vec<(usize, usize)> {
253        let mut arcs = self.graph.arcs();
254        for &(u, v) in self.graph.edges().iter() {
255            arcs.push((u, v));
256            arcs.push((v, u));
257        }
258        arcs
259    }
260
261    fn weighted_available_arcs(&self) -> Vec<(usize, usize, i64)> {
262        let mut arcs: Vec<(usize, usize, i64)> = self
263            .graph
264            .arcs()
265            .into_iter()
266            .zip(self.arc_weights.iter())
267            .map(|((u, v), weight)| (u, v, weight.to_sum()))
268            .collect();
269
270        for ((u, v), weight) in self.graph.edges().iter().zip(self.edge_weights.iter()) {
271            let cost = weight.to_sum();
272            arcs.push((*u, *v, cost));
273            arcs.push((*v, *u, cost));
274        }
275
276        arcs
277    }
278
279    fn base_cost(&self) -> Result<i64, crate::traits::EvaluationError> {
280        let mut total = 0_i64;
281        for weight in self.arc_weights.iter().chain(self.edge_weights.iter()) {
282            total = W::checked_add_to_sum(
283                total,
284                weight.to_sum(),
285                "summing mixed Chinese postman base costs",
286            )?;
287        }
288        Ok(total)
289    }
290}
291
292impl<W> MixedChinesePostman<W>
293where
294    W: WeightElement<Sum = i64> + crate::variant::VariantParam,
295{
296    /// Check whether a configuration yields a valid orientation (strongly
297    /// connected with proper coverage).
298    pub fn is_valid_solution(
299        &self,
300        config: &[bool],
301    ) -> Result<bool, crate::traits::EvaluationError> {
302        Ok(self.evaluate_solution(config)?.0.is_some())
303    }
304
305    fn evaluate_solution(
306        &self,
307        config: &[bool],
308    ) -> Result<Min<W::Sum>, crate::traits::EvaluationError> {
309        if config.len() != self.graph.num_edges() {
310            return Err(crate::traits::EvaluationError::InvalidConfiguration(
311                "edge-orientation length does not match the undirected edges".into(),
312            ));
313        }
314        let Some(oriented_pairs) = self.oriented_arc_pairs(config) else {
315            return Ok(Min(None));
316        };
317
318        let available = self.available_arc_pairs();
319        let mut keep = vec![false; self.graph.num_vertices()];
320        for &(tail, head) in &available {
321            keep[tail] = true;
322            keep[head] = true;
323        }
324        if !DirectedGraph::new(self.graph.num_vertices(), available)
325            .induced_subgraph(&keep)
326            .is_strongly_connected()
327        {
328            return Ok(Min(None));
329        }
330
331        let distances =
332            all_pairs_shortest_paths(self.graph.num_vertices(), &self.weighted_available_arcs())?;
333        let balance = degree_imbalances(self.graph.num_vertices(), &oriented_pairs)?;
334        let Some(extra_cost) = minimum_balancing_cost(&balance, &distances)? else {
335            return Ok(Min(None));
336        };
337
338        let total = self.base_cost()?.checked_add(extra_cost).ok_or_else(|| {
339            crate::traits::EvaluationError::IntegerOverflow(
340                "summing mixed Chinese postman objective".to_string(),
341            )
342        })?;
343        Ok(Min(Some(total)))
344    }
345}
346
347impl<W> Problem for MixedChinesePostman<W>
348where
349    W: WeightElement<Sum = i64> + crate::variant::VariantParam,
350{
351    const NAME: &'static str = "MixedChinesePostman";
352    type Solution = Vec<bool>;
353    type Value = Min<W::Sum>;
354
355    crate::problem_parameters![
356        ("num_arcs", num_arcs),
357        ("num_edges", num_edges),
358        ("num_vertices", num_vertices),
359    ];
360
361    fn variant() -> Vec<(&'static str, &'static str)> {
362        crate::variant_params![W]
363    }
364
365    fn evaluate(
366        &self,
367        config: &Self::Solution,
368    ) -> Result<Min<W::Sum>, crate::traits::EvaluationError> {
369        self.evaluate_solution(config)
370    }
371}
372
373impl<W> crate::solvers::BruteForceProblem for MixedChinesePostman<W>
374where
375    W: WeightElement<Sum = i64> + crate::variant::VariantParam,
376{
377    fn dimensions(&self) -> Vec<usize> {
378        vec![2; self.graph.num_edges()]
379    }
380}
381
382crate::declare_variants! {
383    default MixedChinesePostman<i64> => "2^num_edges * num_vertices^3" create MixedChinesePostmanI64CreateSpec,
384    MixedChinesePostman<One> => "2^num_edges * num_vertices^3" create MixedChinesePostmanOneCreateSpec,
385}
386
387crate::register_brute_force! {
388    MixedChinesePostman<i64> decode |_, indices: Vec<usize>| crate::config::config_to_bits(&indices),
389    MixedChinesePostman<One> decode |_, indices: Vec<usize>| crate::config::config_to_bits(&indices),
390}
391
392#[cfg(feature = "example-db")]
393pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
394    vec![crate::example_db::specs::ModelExampleSpec {
395        id: "mixed_chinese_postman",
396        instance: Box::new(MixedChinesePostman::new(
397            MixedGraph::new(
398                5,
399                vec![(0, 1), (1, 2), (2, 3), (3, 0)],
400                vec![(0, 2), (1, 3), (0, 4), (4, 2)],
401            ),
402            vec![2, 3, 1, 4],
403            vec![2, 3, 1, 2],
404        )),
405        optimal_config: serde_json::json!(vec![true, true, false, false]),
406        optimal_value: serde_json::json!(21),
407    }]
408}
409
410fn all_pairs_shortest_paths(
411    num_vertices: usize,
412    arcs: &[(usize, usize, i64)],
413) -> Result<Vec<Vec<i64>>, crate::traits::EvaluationError> {
414    let mut distances = vec![vec![INF_COST; num_vertices]; num_vertices];
415
416    for (vertex, row) in distances.iter_mut().enumerate() {
417        row[vertex] = 0;
418    }
419
420    for &(u, v, cost) in arcs {
421        if cost < distances[u][v] {
422            distances[u][v] = cost;
423        }
424    }
425
426    for via in 0..num_vertices {
427        for src in 0..num_vertices {
428            if distances[src][via] == INF_COST {
429                continue;
430            }
431            for dst in 0..num_vertices {
432                if distances[via][dst] == INF_COST {
433                    continue;
434                }
435                let through = distances[src][via]
436                    .checked_add(distances[via][dst])
437                    .ok_or_else(|| {
438                        crate::traits::EvaluationError::IntegerOverflow(
439                            "computing mixed Chinese postman shortest paths".to_string(),
440                        )
441                    })?;
442                if through < distances[src][dst] {
443                    distances[src][dst] = through;
444                }
445            }
446        }
447    }
448
449    Ok(distances)
450}
451
452fn degree_imbalances(
453    num_vertices: usize,
454    arcs: &[(usize, usize)],
455) -> Result<Vec<i64>, crate::traits::EvaluationError> {
456    let mut balance = vec![0_i64; num_vertices];
457    for &(u, v) in arcs {
458        balance[u] = balance[u].checked_add(1).ok_or_else(|| {
459            crate::traits::EvaluationError::IntegerOverflow(
460                "computing mixed Chinese postman degree imbalance".to_string(),
461            )
462        })?;
463        balance[v] = balance[v].checked_sub(1).ok_or_else(|| {
464            crate::traits::EvaluationError::IntegerOverflow(
465                "computing mixed Chinese postman degree imbalance".to_string(),
466            )
467        })?;
468    }
469    Ok(balance)
470}
471
472fn minimum_balancing_cost(
473    balance: &[i64],
474    distances: &[Vec<i64>],
475) -> Result<Option<i64>, crate::traits::EvaluationError> {
476    let mut deficits = Vec::new();
477    let mut surpluses = Vec::new();
478
479    for (vertex, &value) in balance.iter().enumerate() {
480        if value < 0 {
481            for _ in 0..value.unsigned_abs() {
482                deficits.push(vertex);
483            }
484        } else if value > 0 {
485            for _ in 0..value.unsigned_abs() {
486                surpluses.push(vertex);
487            }
488        }
489    }
490
491    if deficits.len() != surpluses.len() {
492        return Ok(None);
493    }
494    if deficits.is_empty() {
495        return Ok(Some(0));
496    }
497
498    let mut costs = vec![vec![INF_COST; surpluses.len()]; deficits.len()];
499    for (row, &src) in deficits.iter().enumerate() {
500        for (col, &dst) in surpluses.iter().enumerate() {
501            costs[row][col] = distances[src][dst];
502        }
503    }
504
505    hungarian_min_cost(&costs)
506}
507
508fn hungarian_min_cost(costs: &[Vec<i64>]) -> Result<Option<i64>, crate::traits::EvaluationError> {
509    let size = costs.len();
510    if size == 0 {
511        return Ok(Some(0));
512    }
513    if costs.iter().any(|row| row.len() != size) {
514        return Ok(None);
515    }
516
517    let mut u = vec![0_i64; size + 1];
518    let mut v = vec![0_i64; size + 1];
519    let mut p = vec![0_usize; size + 1];
520    let mut way = vec![0_usize; size + 1];
521
522    for row in 1..=size {
523        p[0] = row;
524        let mut column0 = 0;
525        let mut minv = vec![INF_COST; size + 1];
526        let mut used = vec![false; size + 1];
527
528        loop {
529            used[column0] = true;
530            let row0 = p[column0];
531            let mut delta = INF_COST;
532            let mut column1 = 0;
533
534            for column in 1..=size {
535                if used[column] {
536                    continue;
537                }
538
539                let current = costs[row0 - 1][column - 1]
540                    .checked_sub(u[row0])
541                    .and_then(|value| value.checked_sub(v[column]))
542                    .ok_or_else(|| {
543                        crate::traits::EvaluationError::IntegerOverflow(
544                            "computing mixed Chinese postman assignment costs".to_string(),
545                        )
546                    })?;
547                if current < minv[column] {
548                    minv[column] = current;
549                    way[column] = column0;
550                }
551                if minv[column] < delta {
552                    delta = minv[column];
553                    column1 = column;
554                }
555            }
556
557            if delta == INF_COST {
558                return Ok(None);
559            }
560
561            for column in 0..=size {
562                if used[column] {
563                    u[p[column]] = u[p[column]].checked_add(delta).ok_or_else(|| {
564                        crate::traits::EvaluationError::IntegerOverflow(
565                            "updating mixed Chinese postman assignment potentials".to_string(),
566                        )
567                    })?;
568                    v[column] = v[column].checked_sub(delta).ok_or_else(|| {
569                        crate::traits::EvaluationError::IntegerOverflow(
570                            "updating mixed Chinese postman assignment potentials".to_string(),
571                        )
572                    })?;
573                } else {
574                    minv[column] = minv[column].checked_sub(delta).ok_or_else(|| {
575                        crate::traits::EvaluationError::IntegerOverflow(
576                            "updating mixed Chinese postman reduced costs".to_string(),
577                        )
578                    })?;
579                }
580            }
581
582            column0 = column1;
583            if p[column0] == 0 {
584                break;
585            }
586        }
587
588        loop {
589            let column1 = way[column0];
590            p[column0] = p[column1];
591            column0 = column1;
592            if column0 == 0 {
593                break;
594            }
595        }
596    }
597
598    let mut assignment = vec![0_usize; size + 1];
599    for column in 1..=size {
600        assignment[p[column]] = column;
601    }
602
603    let mut total = 0_i64;
604    for row in 1..=size {
605        let cost = costs[row - 1][assignment[row] - 1];
606        if cost == INF_COST {
607            return Ok(None);
608        }
609        total = total.checked_add(cost).ok_or_else(|| {
610            crate::traits::EvaluationError::IntegerOverflow(
611                "summing mixed Chinese postman assignment costs".to_string(),
612            )
613        })?;
614    }
615    Ok(Some(total))
616}
617
618#[cfg(test)]
619#[path = "../../unit_tests/models/graph/mixed_chinese_postman.rs"]
620mod tests;