Skip to content

Commit a01930d

Browse files
GiggleLiuclaude
andauthored
Fix #242: [Model] MixedChinesePostman (#729)
* Add plan for #242: [Model] MixedChinesePostman * Implement #242: [Model] MixedChinesePostman * chore: remove plan file after implementation * Fix MixedChinesePostman evaluate() to use both directions of undirected edges The previous implementation only allowed traversals in the chosen orientation direction, rejecting valid MCPP solutions. The real Mixed Chinese Postman allows undirected edges to be traversed in either direction during the walk. Fix by building the available-arc set with both directions for connectivity checks and shortest-path computation, while keeping oriented-only arcs for degree imbalance. Also fix paper pred solve command to include --solver brute-force (no ILP reduction path exists for this model). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3ac6795 commit a01930d

15 files changed

Lines changed: 1158 additions & 32 deletions

File tree

docs/paper/reductions.typ

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
"MinimumMultiwayCut": [Minimum Multiway Cut],
118118
"OptimalLinearArrangement": [Optimal Linear Arrangement],
119119
"RuralPostman": [Rural Postman],
120+
"MixedChinesePostman": [Mixed Chinese Postman],
120121
"LongestCommonSubsequence": [Longest Common Subsequence],
121122
"ExactCoverBy3Sets": [Exact Cover by 3-Sets],
122123
"SubsetSum": [Subset Sum],
@@ -3570,6 +3571,97 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76],
35703571
]
35713572
}
35723573

3574+
#{
3575+
let x = load-model-example("MixedChinesePostman", variant: (weight: "i32"))
3576+
let nv = x.instance.graph.num_vertices
3577+
let arcs = x.instance.graph.arcs
3578+
let edges = x.instance.graph.edges
3579+
let arc-weights = x.instance.arc_weights
3580+
let edge-weights = x.instance.edge_weights
3581+
let B = x.instance.bound
3582+
let config = x.optimal_config
3583+
let oriented = edges.enumerate().map(((i, e)) => if config.at(i) == 0 { e } else { (e.at(1), e.at(0)) })
3584+
let base-cost = arc-weights.sum() + edge-weights.sum()
3585+
let total-cost = 22
3586+
[
3587+
#problem-def("MixedChinesePostman")[
3588+
Given a mixed graph $G = (V, A, E)$ with directed arcs $A$, undirected edges $E$, integer lengths $l(e) >= 0$ for every $e in A union E$, and a bound $B in ZZ^+$, determine whether there exists a closed walk in $G$ that traverses every arc in its prescribed direction and every undirected edge at least once in some direction with total length at most $B$.
3589+
][
3590+
Mixed Chinese Postman is the mixed-graph arc-routing problem ND25 in Garey and Johnson @garey1979. Papadimitriou proved the mixed case NP-complete even when all lengths are 1, the graph is planar, and the maximum degree is 3 @papadimitriou1976edge. In contrast, the pure undirected and pure directed cases are polynomial-time solvable via matching / circulation machinery @edmondsjohnson1973. The implementation here uses one binary variable per undirected edge orientation, so the search space contributes the $2^|E|$ factor visible in the registered exact bound.
3591+
3592+
*Example.* Consider the instance on #nv vertices with directed arcs $(v_0, v_1)$, $(v_1, v_2)$, $(v_2, v_3)$, $(v_3, v_0)$ of lengths $2, 3, 1, 4$ and undirected edges $\{v_0, v_2\}$, $\{v_1, v_3\}$, $\{v_0, v_4\}$, $\{v_4, v_2\}$ of lengths $2, 3, 1, 2$. The config $(1, 1, 0, 0)$ orients those edges as $(v_2, v_0)$, $(v_3, v_1)$, $(v_0, v_4)$, and $(v_4, v_2)$, producing a strongly connected digraph. The base traversal cost is #base-cost, and duplicating the shortest path $v_1 arrow v_2 arrow v_3$ adds 4 more, so the total cost is $#total-cost <= B = #B$, proving the answer is YES.
3593+
3594+
#pred-commands(
3595+
"pred create --example MixedChinesePostman/i32 -o mixed-chinese-postman.json",
3596+
"pred solve mixed-chinese-postman.json --solver brute-force",
3597+
"pred evaluate mixed-chinese-postman.json --config " + x.optimal_config.map(str).join(","),
3598+
)
3599+
3600+
#figure(
3601+
canvas(length: 1cm, {
3602+
import draw: *
3603+
let positions = (
3604+
(-1.25, 0.85),
3605+
(1.25, 0.85),
3606+
(1.25, -0.85),
3607+
(-1.25, -0.85),
3608+
(0.25, 0.0),
3609+
)
3610+
3611+
for (idx, (u, v)) in arcs.enumerate() {
3612+
line(
3613+
positions.at(u),
3614+
positions.at(v),
3615+
stroke: 0.8pt + luma(80),
3616+
mark: (end: "straight", scale: 0.45),
3617+
)
3618+
let mid = (
3619+
(positions.at(u).at(0) + positions.at(v).at(0)) / 2,
3620+
(positions.at(u).at(1) + positions.at(v).at(1)) / 2,
3621+
)
3622+
content(
3623+
mid,
3624+
text(6pt, fill: luma(40))[#arc-weights.at(idx)],
3625+
fill: white,
3626+
frame: "rect",
3627+
padding: 0.04,
3628+
stroke: none,
3629+
)
3630+
}
3631+
3632+
for (idx, (u, v)) in oriented.enumerate() {
3633+
line(
3634+
positions.at(u),
3635+
positions.at(v),
3636+
stroke: 1.3pt + graph-colors.at(0),
3637+
mark: (end: "straight", scale: 0.5),
3638+
)
3639+
let mid = (
3640+
(positions.at(u).at(0) + positions.at(v).at(0)) / 2,
3641+
(positions.at(u).at(1) + positions.at(v).at(1)) / 2,
3642+
)
3643+
let offset = if idx == 0 { (-0.18, 0.12) } else if idx == 1 { (0.18, 0.12) } else if idx == 2 { (-0.12, -0.1) } else { (0.12, -0.1) }
3644+
content(
3645+
(mid.at(0) + offset.at(0), mid.at(1) + offset.at(1)),
3646+
text(6pt, fill: graph-colors.at(0))[#edge-weights.at(idx)],
3647+
fill: white,
3648+
frame: "rect",
3649+
padding: 0.04,
3650+
stroke: none,
3651+
)
3652+
}
3653+
3654+
for (i, pos) in positions.enumerate() {
3655+
circle(pos, radius: 0.18, fill: white, stroke: 0.6pt + black)
3656+
content(pos, text(7pt)[$v_#i$])
3657+
}
3658+
}),
3659+
caption: [Mixed Chinese Postman example. Gray arrows are the original directed arcs, while blue arrows are the chosen orientations of the former undirected edges under config $(1, 1, 0, 0)$. Duplicating the path $v_1 arrow v_2 arrow v_3$ yields total cost #total-cost.],
3660+
) <fig:mixed-chinese-postman>
3661+
]
3662+
]
3663+
}
3664+
35733665
#{
35743666
let x = load-model-example("SubgraphIsomorphism")
35753667
let nv-host = x.instance.host_graph.num_vertices

docs/paper/references.bib

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,3 +1282,22 @@ @inproceedings{williams2002
12821282
pages = {299--307},
12831283
year = {2002}
12841284
}
1285+
1286+
@article{papadimitriou1976edge,
1287+
author = {Christos H. Papadimitriou},
1288+
title = {On the Complexity of Edge Traversing},
1289+
journal = {Journal of the ACM},
1290+
volume = {23},
1291+
number = {3},
1292+
pages = {544--554},
1293+
year = {1976}
1294+
}
1295+
1296+
@article{edmondsjohnson1973,
1297+
author = {Jack Edmonds and Ellis L. Johnson},
1298+
title = {Matching, Euler Tours and the Chinese Postman},
1299+
journal = {Mathematical Programming},
1300+
volume = {5},
1301+
pages = {88--124},
1302+
year = {1973}
1303+
}

problemreductions-cli/src/cli.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ Flags by problem type:
265265
SequencingWithinIntervals --release-times, --deadlines, --lengths
266266
OptimalLinearArrangement --graph, --bound
267267
MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound
268+
MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-costs, --bound [--num-vertices]
268269
RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound
269270
MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices]
270271
AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys]

problemreductions-cli/src/commands/create.rs

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use problemreductions::models::algebraic::{
1313
use problemreductions::models::formula::Quantifier;
1414
use problemreductions::models::graph::{
1515
GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath,
16-
LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut,
16+
LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman,
1717
MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation,
1818
};
1919
use problemreductions::models::misc::{
@@ -31,8 +31,8 @@ use problemreductions::models::BiconnectivityAugmentation;
3131
use problemreductions::prelude::*;
3232
use problemreductions::registry::collect_schemas;
3333
use problemreductions::topology::{
34-
BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph,
35-
UnitDiskGraph,
34+
BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, MixedGraph, SimpleGraph,
35+
TriangularSubgraph, UnitDiskGraph,
3636
};
3737
use serde::Serialize;
3838
use std::collections::{BTreeMap, BTreeSet};
@@ -584,6 +584,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
584584
"StrongConnectivityAugmentation" => {
585585
"--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1"
586586
}
587+
"MixedChinesePostman" => {
588+
"--graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 --bound 24"
589+
}
587590
"RuralPostman" => {
588591
"--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4"
589592
}
@@ -643,7 +646,12 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
643646
fn uses_edge_weights_flag(canonical: &str) -> bool {
644647
matches!(
645648
canonical,
646-
"KthBestSpanningTree" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" | "RuralPostman"
649+
"KthBestSpanningTree"
650+
| "MaxCut"
651+
| "MaximumMatching"
652+
| "TravelingSalesman"
653+
| "RuralPostman"
654+
| "MixedChinesePostman"
647655
)
648656
}
649657

@@ -661,6 +669,7 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String {
661669
("PrimeAttributeName", "num_attributes") => return "universe".to_string(),
662670
("PrimeAttributeName", "dependencies") => return "deps".to_string(),
663671
("PrimeAttributeName", "query_attribute") => return "query".to_string(),
672+
("MixedChinesePostman", "arc_weights") => return "arc-costs".to_string(),
664673
("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(),
665674
("StaffScheduling", "shifts_per_schedule") => return "k".to_string(),
666675
("TimetableDesign", "num_tasks") => return "num-tasks".to_string(),
@@ -827,6 +836,15 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> {
827836
// DirectedGraph fields use --arcs, not --graph
828837
let hint = type_format_hint(&field.type_name, graph_type);
829838
eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint);
839+
} else if field.type_name == "MixedGraph" {
840+
eprintln!(
841+
" --{:<16} {} ({})",
842+
"graph", "Undirected edges E of the mixed graph", "edge list: 0-1,1-2,2-3"
843+
);
844+
eprintln!(
845+
" --{:<16} {} ({})",
846+
"arcs", "Directed arcs A of the mixed graph", "directed arcs: 0>1,1>2,2>0"
847+
);
830848
} else if field.type_name == "BipartiteGraph" {
831849
eprintln!(
832850
" --{:<16} {} ({})",
@@ -876,6 +894,9 @@ fn problem_help_flag_name(
876894
if field_type == "DirectedGraph" {
877895
return "arcs".to_string();
878896
}
897+
if field_type == "MixedGraph" {
898+
return "graph".to_string();
899+
}
879900
if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" {
880901
return "bound".to_string();
881902
}
@@ -3003,9 +3024,10 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
30033024
// AcyclicPartition
30043025
"AcyclicPartition" => {
30053026
let usage = "Usage: pred create AcyclicPartition/i32 --arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5";
3006-
let arcs_str = args.arcs.as_deref().ok_or_else(|| {
3007-
anyhow::anyhow!("AcyclicPartition requires --arcs\n\n{usage}")
3008-
})?;
3027+
let arcs_str = args
3028+
.arcs
3029+
.as_deref()
3030+
.ok_or_else(|| anyhow::anyhow!("AcyclicPartition requires --arcs\n\n{usage}"))?;
30093031
let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?;
30103032
let vertex_weights = parse_vertex_weights(args, graph.num_vertices())?;
30113033
let arc_costs = parse_arc_costs(args, num_arcs)?;
@@ -3109,6 +3131,46 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
31093131
)
31103132
}
31113133

3134+
// MixedChinesePostman
3135+
"MixedChinesePostman" => {
3136+
let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 --bound 24 [--num-vertices N]";
3137+
let graph = parse_mixed_graph(args, usage)?;
3138+
let arc_costs = parse_arc_costs(args, graph.num_arcs())?;
3139+
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
3140+
let bound = args.bound.ok_or_else(|| {
3141+
anyhow::anyhow!("MixedChinesePostman requires --bound\n\n{usage}")
3142+
})?;
3143+
let bound = i32::try_from(bound).map_err(|_| {
3144+
anyhow::anyhow!(
3145+
"MixedChinesePostman --bound must fit in i32 (got {bound})\n\n{usage}"
3146+
)
3147+
})?;
3148+
if arc_costs.iter().any(|&cost| cost < 0) {
3149+
bail!("MixedChinesePostman --arc-costs must be non-negative\n\n{usage}");
3150+
}
3151+
if edge_weights.iter().any(|&weight| weight < 0) {
3152+
bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}");
3153+
}
3154+
if resolved_variant.get("weight").map(|w| w.as_str()) == Some("One")
3155+
&& (arc_costs.iter().any(|&cost| cost != 1)
3156+
|| edge_weights.iter().any(|&weight| weight != 1))
3157+
{
3158+
bail!(
3159+
"Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\
3160+
Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-costs ..."
3161+
);
3162+
}
3163+
(
3164+
ser(MixedChinesePostman::new(
3165+
graph,
3166+
arc_costs,
3167+
edge_weights,
3168+
bound,
3169+
))?,
3170+
resolved_variant.clone(),
3171+
)
3172+
}
3173+
31123174
// MinimumSumMulticenter (p-median)
31133175
"MinimumSumMulticenter" => {
31143176
let (graph, n) = parse_graph(args).map_err(|e| {
@@ -4759,6 +4821,22 @@ fn parse_directed_graph(
47594821
Ok((DirectedGraph::new(num_v, arcs), num_arcs))
47604822
}
47614823

4824+
fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result<MixedGraph> {
4825+
let (undirected_graph, num_vertices) =
4826+
parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
4827+
let arcs_str = args
4828+
.arcs
4829+
.as_deref()
4830+
.ok_or_else(|| anyhow::anyhow!("MixedChinesePostman requires --arcs\n\n{usage}"))?;
4831+
let (directed_graph, _) = parse_directed_graph(arcs_str, Some(num_vertices))
4832+
.map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
4833+
Ok(MixedGraph::new(
4834+
num_vertices,
4835+
directed_graph.arcs(),
4836+
undirected_graph.edges(),
4837+
))
4838+
}
4839+
47624840
/// Parse `--weights` as arc weights (i32), defaulting to all 1s.
47634841
fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result<Vec<i32>> {
47644842
match &args.weights {
@@ -4789,11 +4867,7 @@ fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result<Vec<i32>> {
47894867
.map(|s| s.trim().parse::<i32>())
47904868
.collect::<std::result::Result<Vec<_>, _>>()?;
47914869
if parsed.len() != num_arcs {
4792-
bail!(
4793-
"Expected {} arc costs but got {}",
4794-
num_arcs,
4795-
parsed.len()
4796-
);
4870+
bail!("Expected {} arc costs but got {}", num_arcs, parsed.len());
47974871
}
47984872
Ok(parsed)
47994873
}

0 commit comments

Comments
 (0)