perf(operators): make PMX and OX crossovers O(n) with value-indexed lookups

Both crossovers had an O(n) inner scan making them O(n^2): PMX located the
value to swap with `child.iter().position`, OX tested segment membership
with `segment.contains`. Both operate on strict permutations of 0..n, so a
value-indexed table (PMX: position kept in sync across swaps; OX: a static
membership bitmap) gives O(1) lookups. debug_asserts document the range
assumption, consistent with ERX and CX.

pmx_crossover_vary n=100: 21_507 -> 5_900 (-73%, 3.65x); n=30 -18%.
order_crossover_vary n=100: 18_265 -> 7_389 (-60%, 2.47x); n=30 -29%.
All four permutation crossovers are now O(n). Bit-identical for valid
permutations -- all 606 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 06:42:35 -06:00
co-authored by Claude Opus 4.7
parent 47939fc5cb
commit 3b4f765e03
+26 -5
View File
@@ -372,12 +372,20 @@ fn ox_child(donor: &[usize], filler: &[usize], lo: usize, hi: usize) -> Vec<usiz
let mut child = vec![0_usize; n];
let segment = &donor[lo..hi];
child[lo..hi].copy_from_slice(segment);
// Membership bitmap for the donor segment. OX operates on strict
// permutations of 0..n, so a value-indexed bitmap replaces the
// O(segment) `contains` scan inside the fill loop with an O(1) check.
let mut in_segment = vec![false; n];
for &v in segment {
debug_assert!(v < n, "OrderCrossover requires strict permutations of 0..n");
in_segment[v] = true;
}
let mut fill_pos = hi % n;
let mut filler_pos = hi % n;
let mut placed = hi - lo;
while placed < n {
let v = filler[filler_pos];
if !segment.contains(&v) {
if !in_segment[v] {
child[fill_pos] = v;
fill_pos = (fill_pos + 1) % n;
placed += 1;
@@ -452,16 +460,29 @@ fn pmx_child(donor: &[usize], base: &[usize], lo: usize, hi: usize) -> Vec<usize
// Start from a copy of `base`; for each position k in [lo, hi), swap so
// that child[k] == donor[k]. Each swap preserves the permutation.
let mut child = base.to_vec();
let n = child.len();
// pos[value] = current index of that value in `child`. PMX operates on
// strict permutations of 0..n, so this value-indexed table replaces the
// O(n) `position` scan with an O(1) lookup, kept in sync across swaps.
let mut pos = vec![0_usize; n];
for (i, &v) in child.iter().enumerate() {
debug_assert!(
v < n,
"PartiallyMappedCrossover requires strict permutations of 0..n",
);
pos[v] = i;
}
for k in lo..hi {
let v = donor[k];
if child[k] == v {
continue;
}
let cur = child
.iter()
.position(|&x| x == v)
.expect("permutation invariant: every value must appear");
let cur = pos[v];
let displaced = child[k];
child.swap(k, cur);
// child[k] is now `v`; child[cur] is now `displaced`.
pos[v] = k;
pos[displaced] = cur;
}
child
}