address review: build wildcard literal masks lazily with a bounded cache

This commit is contained in:
Dylan M. Taylor
2026-11-04 00:40:25 +00:00
parent 7e7f79d630
commit c111ec7f36
2 changed files with 38 additions and 4 deletions
+26 -4
View File
@@ -34,17 +34,24 @@ pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
}
let words = (p.len() + 1).div_ceil(64);
let bit = |set: &mut [u64], i: usize| set[i / 64] |= 1 << (i % 64);
// Positions holding a `*`, a `?`, and each literal character.
// Positions holding a `*` and a `?`, and where each literal character sits in the pattern.
let mut stars = vec![0u64; words];
let mut any = vec![0u64; words];
let mut lits: std::collections::HashMap<char, Vec<u64>> = std::collections::HashMap::new();
let mut at: std::collections::HashMap<char, Vec<usize>> = std::collections::HashMap::new();
for (i, item) in p.iter().enumerate() {
match item {
('*', false) => bit(&mut stars, i),
('?', false) => bit(&mut any, i),
(c, _) => bit(lits.entry(*c).or_insert_with(|| vec![0u64; words]), i),
(c, _) => at.entry(*c).or_default().push(i),
}
}
// A literal's bit mask is built when the text first reads it, and kept for the first
// `MAX_CACHED_MASKS` literals: a long pattern of mostly distinct characters would otherwise
// allocate a mask of the pattern's width for each one up front. Past the cap the mask is
// rebuilt in `scratch` from the character's positions, which costs about one step of the scan.
const MAX_CACHED_MASKS: usize = 256;
let mut masks: std::collections::HashMap<char, Vec<u64>> = std::collections::HashMap::new();
let mut scratch = vec![0u64; words];
// A set bit i: the text read so far matches the first i pattern items. A `*` matches nothing
// as well, so a state at a `*` is also at the item after it.
let close = |set: &mut Vec<u64>| {
@@ -61,7 +68,22 @@ pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
close(&mut state);
let mut next = vec![0u64; words];
for ch in text.chars() {
let lit = lits.get(&ch);
let lit: Option<&[u64]> = match at.get(&ch) {
None => None,
Some(positions) if masks.contains_key(&ch) || masks.len() < MAX_CACHED_MASKS => {
let mask = masks.entry(ch).or_insert_with(|| {
let mut m = vec![0u64; words];
positions.iter().for_each(|&i| bit(&mut m, i));
m
});
Some(mask)
}
Some(positions) => {
scratch.fill(0);
positions.iter().for_each(|&i| bit(&mut scratch, i));
Some(&scratch)
}
};
let mut carry = 0u64;
let mut alive = 0u64;
for w in 0..words {
+12
View File
@@ -366,6 +366,18 @@ fn wildcard_agrees_with_backtracking() {
}
}
#[test]
fn wildcard_with_many_distinct_literals_agrees_with_backtracking() {
// 600 distinct characters in the pattern: more than the masks kept, so the rest are rebuilt.
let chars: Vec<char> = (0x4E00u32..0x4E00 + 600).filter_map(char::from_u32).collect();
let word: String = chars.iter().collect();
let pat = format!("*{word}?{}", chars[3]);
for text in [format!("x{word}yq{}", chars[3]), word.clone(), format!("{word}{word}"), chars.iter().rev().collect::<String>()] {
assert_eq!(wildcard(&text, &pat), wildcard_backtracking(&text, &pat), "{} chars", text.chars().count());
}
assert!(wildcard(&format!("{word}zz{}", chars[3]).replace("zz", "z"), &pat));
}
#[test]
fn wildcard_on_long_repetitive_text_is_linear() {
// The backtracking matcher compared about text × pattern characters here (10^8).