Skip to content

Commit 8622b3f

Browse files
authored
Merge pull request #1193 from CodingThrust/feat/lcs-polynomial-mis
Keep LCS to MIS compact with polynomial overhead
2 parents fbf7a76 + 9045073 commit 8622b3f

7 files changed

Lines changed: 529 additions & 300 deletions

File tree

‎docs/paper/reductions.typ‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14038,32 +14038,39 @@ The following reductions to Integer Linear Programming are straightforward formu
1403814038

1403914039
#let lcs_mis = load-example("LongestCommonSubsequence", "MaximumIndependentSet")
1404014040
#let lcs_mis_sol = lcs_mis.solutions.at(0)
14041+
#let lcs_mis_length = lcs_mis_sol.source_config.filter(x => x != none).len()
14042+
#let lcs_mis_slots = lcs_mis_sol.source_config.len()
1404114043
#reduction-rule("LongestCommonSubsequence", "MaximumIndependentSet",
1404214044
example: true,
14043-
example-caption: [LCS of two strings over a 3-symbol alphabet],
14045+
example-caption: [Two conflicting match tuples for strings with opposite symbol order],
1404414046
extra: [
1404514047
#pred-commands(
1404614048
"pred create --example " + rule-spec(lcs_mis) + " -o lcs.json",
1404714049
"pred reduce lcs.json --via route.json -o bundle.json",
1404814050
"pred solve bundle.json",
1404914051
"pred evaluate lcs.json --config " + cli-config(lcs_mis_sol.source_config),
1405014052
)
14051-
Source LCS: config $(#fmt-values(lcs_mis_sol.source_config))$ \
14052-
Target MIS: $S = {#lcs_mis_sol.target_config.enumerate().filter(((i, x)) => x).map(((i, x)) => str(i)).join(", ")}$ (size #lcs_mis_sol.target_config.filter(x => x).len()) \
14053-
MIS size $=$ LCS length $= #lcs_mis_sol.target_config.filter(x => x).len()$ #sym.checkmark
14053+
The strings #fmt-values(lcs_mis.source.instance.strings.at(0)) and #fmt-values(lcs_mis.source.instance.strings.at(1)) leave one embedding string after normalization, and the source witness has length #lcs_mis_slots. The graph has #lcs_mis.target.instance.graph.num_vertices vertices and #lcs_mis.target.instance.graph.edges.len() edges. Source witness $(#fmt-values(lcs_mis_sol.source_config))$ decodes from target vertices ${#lcs_mis_sol.target_config.enumerate().filter(((i, x)) => x).map(((i, x)) => str(i)).join(", ")}$, with MIS size $= #lcs_mis_sol.target_config.filter(x => x).len() = #lcs_mis_length$.
1405414054
],
1405514055
)[
14056-
A match-node construction transforms a $k$-string LCS instance into a Maximum Independent Set problem on a conflict graph. Each vertex represents a $k$-tuple of positions (one per string) that all share the same character, and edges connect pairs that cannot coexist in any valid common subsequence. The MIS of this graph equals the LCS length.
14056+
Use a match-tuple graph when it has neither more vertices nor more edges than a reward-anchored graph; otherwise use the anchored graph. Let $k$ be the number of input strings, $T$ their total length, $L$ the shortest string length (zero for no strings), $S=sum_r |s_r|(|s_r|+1)\/2$, and $A=L(L+1)\/2$. The target satisfies
14057+
$ V <= L(k+T-L), quad E <= A(S-A)+L(k-1). $
1405714058
][
14058-
_Construction._ Given $k$ strings $s_1, dots, s_k$ over alphabet $Sigma$ (size $|Sigma|$):
14059+
_Construction._ Choose the first shortest string as the anchor and delete its symbols absent from any input string. Write $a$ for this filtered anchor and $h=|a|$. Filter every other string to the symbols of $a$, omit strings containing $a$ as a subsequence, and remove duplicates, leaving $m$ embedding strings.
1405914060

14060-
_Vertices:_ For each character $c in Sigma$, create a vertex for every $k$-tuple $(p_1, dots, p_k)$ where $s_i [p_i] = c$ for all $i$. The total vertex count equals $sum_(c in Sigma) product_(i=1)^k "count"(c, s_i)$.
14061+
_Anchored encoding._ Create one reward vertex $z_p$ for each anchor position $p$. For each embedding string $s_r$ and position $p$, create a clique $Y_(r,p)$ containing one vertex per position $j$ with $s_r[j]=a[p]$, plus a skip vertex. Join $z_p$ to that skip. Between slots $p<q$ in the same string, join matching vertices $j,j'$ exactly when $j>=j'$. There are no other edges.
1406114062

14062-
_Edges:_ Two vertices $u = (a_1, dots, a_k)$ and $v = (b_1, dots, b_k)$ are connected if they _conflict_ --- they cannot both appear in a valid common subsequence. A conflict occurs when the position differences are not consistently ordered: $not (forall i: a_i < b_i)$ and $not (forall i: a_i > b_i)$.
14063+
_Tuple encoding and selection._ A vertex is a tuple of equal-symbol positions, one in $a$ and one in each embedding string. Two vertices are adjacent unless all coordinates are strictly increasing in the same direction. Its vertex count is $V_t=sum_p product_r |{j:s_r[j]=a[p]}|$; form these tuples only if $V_t<=V_a$. Choose the tuple graph precisely when also $E_t<=E_a$, where subscripts denote the tuple and anchored graphs. Otherwise choose the anchored graph.
1406314064

14064-
_Correctness._ ($arrow.r.double$) A common subsequence of length $ell$ selects $ell$ match nodes whose positions are strictly increasing in every string, so no two are adjacent --- forming an independent set of size $ell$. ($arrow.l.double$) An independent set of size $ell$ consists of $ell$ mutually non-conflicting match nodes, meaning their positions are consistently ordered across all strings. Sorting by any string's position yields a valid common subsequence of length $ell$.
14065+
_Correctness._ Every common subsequence uses only symbols present in all input strings, so filtering the anchor preserves the feasible words. Filtering other strings, removing duplicates, and omitting strings containing the filtered anchor also preserve these words. Tuple independent sets, sorted by anchor position, are exactly common-subsequence embeddings, so their maximum size is $"LCS"$. For the anchored encoding:
1406514066

14066-
_Solution extraction._ Sort the selected vertices by position in $s_1$. Read off the characters to obtain the common subsequence, then pad to `max_length` with the padding symbol.
14067+
($arrow.r.double$) A common subsequence of length $ell$ selects its reward vertices and increasing matching positions in each embedding string, using skips at the other slots. This gives an independent set of size $h m+ell$.
14068+
14069+
($arrow.l.double$) An independent set with $q$ rewards and $d$ empty embedding groups has size $h m+q-d$. A selected reward missing a match in some string has an empty group, because its skip is adjacent to the reward. Discard these rewards; each discarded slot accounts for a distinct empty group. The remaining $ell>=q-d$ symbols form a common subsequence, so the independent-set size is at most $h m+ell<=h m+"LCS"$. The forward construction attains equality, and every optimum decodes an optimal common subsequence. In particular, empty embedding groups are permitted. The two objectives differ, so the reduction registers witness extraction without an aggregate value map.
14070+
14071+
_Solution extraction._ Validate independence once. Order tuple vertices by anchor position and anchored rewards by anchor slot. Read the symbol of each selected vertex that has a selected match in every embedding string; tuples have no embedding strings, so every selected tuple is read. Pad the result to the original length $L$. This also maps nonoptimal independent sets to feasible common subsequences. With no embedding strings the tuple graph has $h$ isolated vertices and is always selected.
14072+
14073+
_Overhead._ For $k>=1$, the anchored graph has $h(m+1)+F$ vertices, where $F$ counts retained equal-symbol position pairs. Since $h<=L$, $m<=k-1$, and $F<=L(T-L)$, the vertex bound follows. For one embedding string of length $n$, within-slot cliques contribute at most $h n(n+1)\/2$ edges. Each of the $h(h-1)\/2$ slot pairs contributes at most $n(n+1)\/2$ crossing edges, counting all pairs $j>=j'$. Together these are at most $A n(n+1)\/2$ edges, plus at most $L$ reward-to-skip edges. Summing over a subset of the original non-anchor strings gives $E<=A(S-A)+L(k-1)$. Empty input has $V=E=L=0$ and satisfies both formulas. Dominance transfers both bounds to the tuple branch. All bounds use only registered source parameters.
1406714074
]
1406814075

1406914076
#let cs_ilp_str = load-example(

‎problemreductions-cli/tests/cli_tests.rs‎

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5615,8 +5615,8 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() {
56155615
let output = pred()
56165616
.args([
56175617
"path",
5618-
"MinimumVertexCover/SimpleGraph/One",
5619-
"LongestCommonSubsequence",
5618+
"SubsetSum",
5619+
"DecisionOpenShopScheduling",
56205620
"--limit",
56215621
"1",
56225622
"--json",
@@ -5628,22 +5628,113 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() {
56285628
let fields = envelope["paths"][0]["overall_parameters"]["fields"]
56295629
.as_array()
56305630
.unwrap();
5631-
let relations = fields
5631+
let machines = fields
56325632
.iter()
5633-
.map(|field| {
5634-
(
5635-
field["field"].as_str().unwrap(),
5636-
field["relation"].as_str().unwrap(),
5637-
)
5638-
})
5639-
.collect::<std::collections::BTreeMap<_, _>>();
5640-
assert_eq!(relations["alphabet_size"], "exact");
5641-
assert_eq!(relations["cross_frequency_product"], "unavailable");
5642-
let unavailable = fields
5633+
.find(|field| field["field"] == "num_machines")
5634+
.unwrap();
5635+
assert_eq!(machines["relation"], "exact");
5636+
let horizon = fields
5637+
.iter()
5638+
.find(|field| field["field"] == "schedule_horizon")
5639+
.unwrap();
5640+
assert_eq!(horizon["relation"], "unavailable");
5641+
assert!(horizon["reason"]
5642+
.as_str()
5643+
.is_some_and(|reason| !reason.is_empty()));
5644+
}
5645+
5646+
#[test]
5647+
fn test_vertex_cover_via_lcs_to_mis_solves_and_recovers_optimum() {
5648+
let dir =
5649+
std::env::temp_dir().join(format!("pred_vertex_cover_lcs_mis_{}", std::process::id()));
5650+
std::fs::create_dir_all(&dir).unwrap();
5651+
let source = dir.join("source.json");
5652+
let route = dir.join("route.json");
5653+
let bundle = dir.join("bundle.json");
5654+
let created = pred()
5655+
.args([
5656+
"create",
5657+
"MinimumVertexCover/SimpleGraph/One",
5658+
"--graph",
5659+
"0-1,1-2",
5660+
"-o",
5661+
source.to_str().unwrap(),
5662+
])
5663+
.output()
5664+
.unwrap();
5665+
assert!(
5666+
created.status.success(),
5667+
"{}",
5668+
String::from_utf8_lossy(&created.stderr)
5669+
);
5670+
write_named_route(
5671+
"MinimumVertexCover/SimpleGraph/One",
5672+
"MaximumIndependentSet/SimpleGraph/One",
5673+
&[
5674+
"MinimumVertexCover",
5675+
"LongestCommonSubsequence",
5676+
"MaximumIndependentSet",
5677+
],
5678+
&route,
5679+
);
5680+
let selected: serde_json::Value =
5681+
serde_json::from_slice(&std::fs::read(&route).unwrap()).unwrap();
5682+
assert!(selected["overall_parameters"]["fields"]
5683+
.as_array()
5684+
.unwrap()
56435685
.iter()
5644-
.find(|field| field["relation"] == "unavailable")
5686+
.all(|field| field["relation"] != "unavailable"));
5687+
let reduced = pred()
5688+
.args([
5689+
"reduce",
5690+
source.to_str().unwrap(),
5691+
"--via",
5692+
route.to_str().unwrap(),
5693+
"-o",
5694+
bundle.to_str().unwrap(),
5695+
])
5696+
.output()
5697+
.unwrap();
5698+
assert!(
5699+
reduced.status.success(),
5700+
"{}",
5701+
String::from_utf8_lossy(&reduced.stderr)
5702+
);
5703+
let solved = pred()
5704+
.args([
5705+
"solve",
5706+
bundle.to_str().unwrap(),
5707+
"--solver",
5708+
"ilp",
5709+
"--json",
5710+
])
5711+
.output()
5712+
.unwrap();
5713+
assert!(
5714+
solved.status.success(),
5715+
"{}",
5716+
String::from_utf8_lossy(&solved.stderr)
5717+
);
5718+
let result: serde_json::Value = serde_json::from_slice(&solved.stdout).unwrap();
5719+
assert_eq!(result["evaluation"], "Min(1)");
5720+
let evaluated = pred()
5721+
.args([
5722+
"evaluate",
5723+
source.to_str().unwrap(),
5724+
"--config",
5725+
&result["solution"].to_string(),
5726+
"--json",
5727+
])
5728+
.output()
56455729
.unwrap();
5646-
assert!(!unavailable["reason"].as_str().unwrap().is_empty());
5730+
assert!(
5731+
evaluated.status.success(),
5732+
"{}",
5733+
String::from_utf8_lossy(&evaluated.stderr)
5734+
);
5735+
let value: serde_json::Value = serde_json::from_slice(&evaluated.stdout).unwrap();
5736+
assert_eq!(value["result"], "Min(1)");
5737+
std::fs::remove_dir_all(dir).unwrap();
56475738
}
56485739

56495740
#[test]

‎src/models/misc/longest_common_subsequence.rs‎

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -171,30 +171,11 @@ impl LongestCommonSubsequence {
171171
pub fn num_transitions(&self) -> usize {
172172
self.max_length.saturating_sub(1)
173173
}
174-
175-
/// Returns the cross-frequency product: the sum over each alphabet symbol
176-
/// of the product of that symbol's frequency across all input strings.
177-
///
178-
/// Formally: Σ_{c ∈ 0..alphabet_size} Π_{i=1..k} count(c, strings\[i\])
179-
/// where count(c, s) is the number of occurrences of symbol c in string s.
180-
///
181-
/// This equals the exact number of match-node vertices in the LCS → MaxIS
182-
/// reduction graph.
183-
pub fn cross_frequency_product(&self) -> usize {
184-
(0..self.alphabet_size)
185-
.map(|c| {
186-
self.strings
187-
.iter()
188-
.map(|s| s.iter().filter(|&&sym| sym == c).count())
189-
.product::<usize>()
190-
})
191-
.sum()
192-
}
193174
}
194175

195176
/// Check whether `candidate` is a subsequence of `target` using greedy
196177
/// left-to-right matching.
197-
fn is_subsequence(candidate: &[usize], target: &[usize]) -> bool {
178+
pub(crate) fn is_subsequence(candidate: &[usize], target: &[usize]) -> bool {
198179
let mut it = target.iter();
199180
for &symbol in candidate {
200181
loop {
@@ -215,7 +196,6 @@ impl Problem for LongestCommonSubsequence {
215196

216197
crate::problem_parameters![
217198
("alphabet_size", alphabet_size),
218-
("cross_frequency_product", cross_frequency_product),
219199
("max_length", max_length),
220200
("num_strings", num_strings),
221201
("num_transitions", num_transitions),

‎src/models/misc/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ pub use integer_expression_membership::{IntExpr, IntegerExpressionMembership};
213213
pub use job_shop_scheduling::JobShopScheduling;
214214
pub use knapsack::Knapsack;
215215
pub use kth_largest_m_tuple::KthLargestMTuple;
216+
pub(crate) use longest_common_subsequence::is_subsequence;
216217
pub use longest_common_subsequence::LongestCommonSubsequence;
217218
pub use maximum_likelihood_ranking::MaximumLikelihoodRanking;
218219
pub use minimum_axiom_set::MinimumAxiomSet;

0 commit comments

Comments
 (0)