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