Project 3: Fast String Search with Boyer-Moore Algorithm
Problem Statement
Implement the Boyer-Moore string search algorithm for finding patterns in text efficiently. This algorithm is used in grep, text editors, and search systems because it can skip large portions of text, making it faster than naive search for most cases.
Your implementation should:
- Build bad character and good suffix tables
- Search for pattern in text with O(n/m) average case (faster than O(n))
- Support case-insensitive search
- Find all occurrences efficiently
- Benchmark against naive search
Why It Matters
String search is fundamental to text processing. Naive search is O(n*m), checking every position. Boyer-Moore is O(n/m) average case, skipping text based on mismatches. For searching “pattern” in 1MB text:
- Naive: ~1M comparisons
- Boyer-Moore: ~150K comparisons (7x faster)
This algorithm is used in grep, text editors (find functionality), DNA sequence matching, plagiarism detection.
Use Cases
- Text editors (find/replace)
- Log analysis (grep-style search)
- DNA sequence matching (bioinformatics)
- Intrusion detection (packet inspection)
- Plagiarism detection
- Search engines (document scanning)
Introduction to String Search and Boyer-Moore Concepts
String searching is one of the most fundamental operations in computer science, yet naive approaches are surprisingly inefficient. The Boyer-Moore algorithm revolutionized text searching by introducing the counterintuitive idea of scanning patterns right-to-left and using mismatches to skip large portions of text—achieving sublinear average-case performance.
1. The String Search Problem
String search finds all occurrences of a pattern in a text:
Problem Definition:
Text: "ABCABDABCABC"
Pattern: "ABC"
Output: [0, 6, 9] // Starting positions of matches
Naive Solution (Brute Force):
#![allow(unused)]
fn main() {
for i in 0..=(text.len() - pattern.len()) {
if text[i..i+pattern.len()] == pattern {
matches.push(i);
}
}
}
Complexity:
- Time: O(n * m) where n = text length, m = pattern length
- Space: O(1)
Why It’s Slow:
- Checks every position in text
- On mismatch, shifts by only 1 position
- For text of 1MB and pattern “ABCD”, makes ~1M * 4 = 4M character comparisons
2. Boyer-Moore Algorithm Overview
Boyer-Moore achieves faster search by:
- Scanning right-to-left: Start comparing from end of pattern
- Using mismatches: Extract information from failures to skip positions
- Preprocessing pattern: Build tables once, use for all searches
Key Insight: A mismatch tells us where the pattern CANNOT match, allowing safe skips.
Example:
Text: "HERE IS A SIMPLE EXAMPLE"
Pattern: "EXAMPLE"
Position 0:
Text: HERE IS...
Pattern: EXAMPLE
^ Mismatch at 'E' (text) vs 'E' (pattern)
Text has 'E' at position 6, pattern has 'E' at position 6
Character 'E' in text doesn't match 'E' in pattern at this alignment
Can skip to next possible alignment
Average Case: O(n/m) - Yes, FASTER than O(n)! Can skip m characters at a time.
3. Bad Character Heuristic
When a mismatch occurs, use the mismatched character in the text to determine skip distance:
Rule: If character c from text doesn’t match pattern, shift pattern to align with rightmost occurrence of c in pattern.
Example:
Text: "ANPANMAN"
Pattern: "NANB"
Step 1:
Text: ANPANMAN
Pattern: NANB
^ Mismatch: 'A' (text) vs 'B' (pattern)
Last occurrence of 'A' in pattern is at position 1
Shift pattern to align:
Step 2:
Text: ANPANMAN
Pattern: NANB
^ Continue searching...
Bad Character Table:
#![allow(unused)]
fn main() {
// For pattern "NANB"
{
'N': 2, // Rightmost 'N' at index 2
'A': 1, // Rightmost 'A' at index 1
'B': 3, // Rightmost 'B' at index 3
}
// Characters not in pattern: skip entire pattern length
}
Skip Calculation:
#![allow(unused)]
fn main() {
let skip = match bad_char_table.get(&mismatched_char) {
Some(&last_occurrence) => {
// Distance from mismatch position to last occurrence
max(1, mismatch_index - last_occurrence)
}
None => {
// Character not in pattern, skip entire pattern
pattern.len()
}
};
}
4. Good Suffix Heuristic
When a mismatch occurs after some matches, use the matched suffix to determine skip distance:
Rule: If suffix S of pattern matched but prefix mismatched, shift pattern to align with another occurrence of S in pattern, or shift to align prefix with suffix.
Example:
Text: "ABCXXXABC"
Pattern: "XXXABC"
Matching from right:
Text: ABCXXXABC
Pattern: XXXABC
^^^
Matched suffix "ABC", mismatch at 'C' vs 'X'
"ABC" doesn't occur elsewhere in "XXX"
But "ABC" prefix could align with next occurrence
Skip to align appropriately
Good Suffix Table: Stores shift distances for each position.
Why It Helps: Bad character might give small skip (shift by 1), but good suffix can give large skip.
Combined Heuristic: Take maximum of bad character and good suffix shifts:
#![allow(unused)]
fn main() {
let skip = max(bad_char_skip, good_suffix_skip);
}
5. Right-to-Left Scanning
Boyer-Moore scans patterns from right to left, contrary to intuition:
Why Right-to-Left:
- Mismatches near pattern end give more information
- Can skip more characters based on suffix information
- Bad character heuristic is more effective with right-to-left
Example:
Pattern: "EXAMPLE" (length 7)
Scan order: 6 → 5 → 4 → 3 → 2 → 1 → 0
E L P M A X E
On Early Mismatch (near right end):
Text: "...EXAM[Q]LE..."
Pattern: "EXAMPLE"
^ Mismatch at position 6
Character 'Q' not in pattern → skip 7 positions!
On Late Mismatch (near left end):
Text: "...[Q]XAMPLE..."
Pattern: "EXAMPLE"
^ Mismatch at position 0
Matched "XAMPLE" (6 chars), but mismatched at 'Q'
Use good suffix to determine skip
6. Preprocessing Pattern vs Text
Boyer-Moore preprocesses the pattern, not the text:
Preprocessing (one-time cost):
#![allow(unused)]
fn main() {
let searcher = BoyerMoore::new("pattern");
// Builds bad character table: O(m + σ) where σ = alphabet size
// Builds good suffix table: O(m)
}
Search (used repeatedly):
#![allow(unused)]
fn main() {
searcher.search(text1); // Uses prebuilt tables
searcher.search(text2); // Reuses same tables
searcher.search(text3); // No re-preprocessing
}
Why This Matters:
- Same pattern, multiple texts: Preprocess once, search many times
- grep searches one pattern in many files
- Text editor searches one pattern in large document
Amortized Cost: Preprocessing cost amortized over multiple searches.
7. Complexity Analysis
Boyer-Moore has different complexities for different scenarios:
Best Case: O(n/m)
Text: "AAAAAAAAAA" (10 A's)
Pattern: "BAAA"
Every comparison: 'A' vs 'A' → mismatch
Skip 4 positions each time
Comparisons: 10/4 = 2.5 → 3 checks instead of 10!
Worst Case: O(n * m)
Text: "AAAAAAAAAA"
Pattern: "AAAA"
Many partial matches before full match
Similar to naive search in pathological cases
Average Case: O(n) with low constant factor
- For random text and pattern: ~3 comparisons per position checked
- Much faster than naive’s m comparisons per position
Practical Performance:
- English text: 3-5x faster than naive
- Random data: 5-10x faster than naive
- Long patterns: Even better (can skip more)
8. Handling Case Sensitivity
Case-insensitive search requires normalization:
Approach 1: Normalize Both (Simple)
#![allow(unused)]
fn main() {
let pattern_lower = pattern.to_lowercase();
let text_lower = text.to_lowercase();
// Search pattern_lower in text_lower
}
- Pros: Simple, reuses exact search
- Cons: Allocates new strings (2x memory)
Approach 2: Normalize Tables (Efficient)
#![allow(unused)]
fn main() {
// Build bad character table with both cases
for ch in pattern.chars() {
table.insert(ch.to_lowercase(), position);
table.insert(ch.to_uppercase(), position);
}
// Compare case-insensitively during search
}
- Pros: No text allocation
- Cons: More complex table
Trade-off: Simplicity vs memory efficiency.
9. Streaming Search for Large Files
Processing files larger than memory requires streaming:
Chunk-Based Search:
#![allow(unused)]
fn main() {
loop {
let chunk = read_chunk(file, CHUNK_SIZE);
let matches = search(chunk);
// Problem: Pattern might span chunks!
}
}
Solution: Overlap Chunks
Chunk 1: "ABCDEFGH"
Chunk 2: "IJKLMNOP"
With overlap (pattern length - 1):
Chunk 1: "ABCDEFGH"
Chunk 2: "GHIJKLMNOP" // Overlap "GH"
Pattern "FGHI" spanning chunks is now found!
Memory: Constant (chunk size + pattern length) regardless of file size.
10. Real-World Optimizations
Production implementations add optimizations:
Turbo Boyer-Moore:
- Remembers last match position
- Skips even more on repeated searches
- Used in some text editors
Horspool Simplification:
- Uses only bad character heuristic
- Simpler, often 90% of Boyer-Moore speed
- Easier to implement correctly
SIMD Optimization:
- Parallel character comparison using SIMD instructions
- 4-8x speedup for patterns fitting in SIMD registers
- Used in high-performance grep implementations
Alphabet Size Optimization:
- Array instead of HashMap for ASCII (256 entries)
- O(1) lookups vs O(log n) for HashMap
- Critical for inner loop performance
Connection to This Project
This Boyer-Moore implementation demonstrates advanced string search optimization in practice:
Naive Baseline (Step 1): You’ll implement brute-force search to establish a performance baseline. This O(n*m) algorithm checks every position, making the improvements from Boyer-Moore dramatic and measurable.
Bad Character Heuristic (Step 2): Building the bad character table demonstrates preprocessing patterns for fast lookups. The HashMap maps each character to its rightmost position, enabling O(1) skip distance calculation during search.
Right-to-Left Scanning (Step 2): The search loop compares from pattern end backward (j = m - 1 down to 0). Early mismatches near the pattern end yield large skips—the key to sublinear average-case performance.
Skip Distance Calculation (Step 2): On mismatch, the algorithm calculates skip as max(1, mismatch_pos - last_occurrence). This ensures forward progress even when bad character gives negative skip (character occurs to the right).
Good Suffix Heuristic (Step 3): The good suffix table handles patterns with internal repetition. Building this table is complex (uses suffix arrays) but enables optimal skipping when bad character heuristic gives small shifts.
Case Insensitivity (Step 4): Normalizing to lowercase before searching demonstrates the trade-off between simplicity and memory. For large texts, allocating lowercase copies doubles memory—but simplifies implementation.
Streaming Search (Step 5): Overlapping chunks by pattern.len() - 1 ensures patterns spanning boundaries are found. The overlap buffer (buffer.copy_within) avoids allocating new chunks—critical for processing GB-sized logs.
Performance Benchmarking (Step 6): Comparing against naive search reveals 5-10x speedups for typical text. Benchmarking against Rust’s built-in str::find() (which uses optimized Boyer-Moore-Horspool) shows how close your implementation is to production quality.
By the end of this project, you’ll have built a production-grade string search matching grep’s performance characteristics—understanding both the algorithm (Boyer-Moore heuristics) and engineering concerns (streaming, memory efficiency, cache optimization).
Solution Outline
Step 1: Naive String Search (Baseline)
Goal: Implement naive search for comparison.
What to implement:
- Search pattern in text character by character
- Return all match positions
- Measure operations count
Why this step: Establish baseline for comparison. Understanding naive approach makes Boyer-Moore improvements clear.
Testing hint: Test with various patterns and texts. Verify all matches found. Count comparisons.
#![allow(unused)]
fn main() {
pub fn naive_search(text: &str, pattern: &str) -> Vec<usize> {
let text_bytes = text.as_bytes();
let pattern_bytes = pattern.as_bytes();
let mut matches = Vec::new();
if pattern.is_empty() || pattern.len() > text.len() {
return matches;
}
for i in 0..=(text.len() - pattern.len()) {
let mut match_found = true;
for j in 0..pattern.len() {
if text_bytes[i + j] != pattern_bytes[j] {
match_found = false;
break;
}
}
if match_found {
matches.push(i);
}
}
matches
}
}
Step 2: Build Bad Character Table
Goal: Implement bad character heuristic for skipping.
What to implement:
- Build table mapping each character to last occurrence in pattern
- On mismatch, skip based on bad character
- Handle characters not in pattern
Why the previous step is not enough: Naive search checks every position. Bad character heuristic skips positions based on mismatched character.
What’s the improvement: When mismatch occurs, skip to align pattern with last occurrence of mismatched character. This can skip multiple positions:
- Naive: Always advances by 1
- Bad character: Can skip by pattern length
Testing hint: Test table construction. Verify skipping logic. Test with patterns having repeated characters.
#![allow(unused)]
fn main() {
use std::collections::HashMap;
pub struct BoyerMoore {
pattern: Vec<u8>,
bad_char_table: HashMap<u8, usize>,
}
impl BoyerMoore {
pub fn new(pattern: &str) -> Self {
let pattern_bytes = pattern.as_bytes().to_vec();
let bad_char_table = Self::build_bad_char_table(&pattern_bytes);
BoyerMoore {
pattern: pattern_bytes,
bad_char_table,
}
}
fn build_bad_char_table(pattern: &[u8]) -> HashMap<u8, usize> {
let mut table = HashMap::new();
// For each character, store its rightmost position
for (i, &ch) in pattern.iter().enumerate() {
table.insert(ch, i);
}
table
}
pub fn search(&self, text: &str) -> Vec<usize> {
let text_bytes = text.as_bytes();
let mut matches = Vec::new();
let m = self.pattern.len();
let n = text_bytes.len();
if m > n {
return matches;
}
let mut i = 0;
while i <= n - m {
let mut j = m as isize - 1;
// Match pattern from right to left
while j >= 0 && self.pattern[j as usize] == text_bytes[i + j as usize] {
j -= 1;
}
if j < 0 {
// Pattern found
matches.push(i);
i += m;
} else {
// Mismatch - use bad character heuristic
let bad_char = text_bytes[i + j as usize];
let shift = if let Some(&last_occurrence) = self.bad_char_table.get(&bad_char) {
let shift = j as usize - last_occurrence;
shift.max(1)
} else {
j as usize + 1
};
i += shift;
}
}
matches
}
}
}
Step 3: Add Good Suffix Heuristic
Goal: Implement good suffix table for additional skipping.
What to implement:
- Build good suffix table
- On mismatch, use both bad character and good suffix
- Take maximum skip from both heuristics
Why the previous step is not enough: Bad character heuristic alone doesn’t handle all cases optimally. Good suffix adds another skipping strategy.
What’s the improvement: Good suffix handles cases where bad character gives small skip. Using both heuristics gives maximum skip, making algorithm faster.
Testing hint: Test with patterns where good suffix provides larger skip. Verify both heuristics are used.
#![allow(unused)]
fn main() {
impl BoyerMoore {
pub fn new_with_good_suffix(pattern: &str) -> Self {
let pattern_bytes = pattern.as_bytes().to_vec();
let bad_char_table = Self::build_bad_char_table(&pattern_bytes);
let good_suffix_table = Self::build_good_suffix_table(&pattern_bytes);
BoyerMoore {
pattern: pattern_bytes,
bad_char_table,
// good_suffix_table, // Add this field
}
}
fn build_good_suffix_table(pattern: &[u8]) -> Vec<usize> {
let m = pattern.len();
let mut table = vec![0; m];
let mut suffix = vec![0; m];
// Build suffix array
suffix[m - 1] = m;
let mut g = m - 1;
let mut f = 0;
for i in (0..m - 1).rev() {
if i > g && suffix[i + m - 1 - f] < i - g {
suffix[i] = suffix[i + m - 1 - f];
} else {
if i < g {
g = i;
}
f = i;
while g > 0 && pattern[g - 1] == pattern[g + m - 1 - f] {
g -= 1;
}
suffix[i] = f - g + 1;
}
}
// Build good suffix table from suffix array
for i in 0..m {
table[i] = m;
}
let mut j = 0;
for i in (0..m - 1).rev() {
if suffix[i] == i + 1 {
while j < m - 1 - i {
if table[j] == m {
table[j] = m - 1 - i;
}
j += 1;
}
}
}
for i in 0..m - 1 {
table[m - 1 - suffix[i]] = m - 1 - i;
}
table
}
}
}
Step 4: Case-Insensitive Search
Goal: Support case-insensitive search efficiently.
What to implement:
- Normalize pattern and text to lowercase
- Use same Boyer-Moore algorithm
- Alternative: modify tables to handle case
Why the previous step is not enough: Case-sensitive search doesn’t match “Hello” with “hello”. Users often want case-insensitive.
What’s the improvement: Case-insensitive search broadens matches. Normalizing to lowercase is simplest approach.
Testing hint: Test matches across different cases. Verify performance is similar to case-sensitive.
#![allow(unused)]
fn main() {
impl BoyerMoore {
pub fn new_case_insensitive(pattern: &str) -> Self {
let normalized = pattern.to_lowercase();
Self::new(&normalized)
}
pub fn search_case_insensitive(&self, text: &str) -> Vec<usize> {
let normalized_text = text.to_lowercase();
self.search(&normalized_text)
}
}
}
Step 5: Find All Occurrences with Streaming
Goal: Find matches in large files using streaming.
What to implement:
- Process file in chunks
- Handle pattern spanning chunk boundaries
- Use iterator for memory efficiency
Why the previous step is not enough: Loading entire file into memory fails for large files.
What’s the improvement: Streaming enables processing files of any size with constant memory. Pattern boundary handling ensures no matches are missed.
Testing hint: Test with large files. Test patterns spanning chunks. Verify all matches found.
#![allow(unused)]
fn main() {
use std::io::{BufReader, Read};
use std::fs::File;
pub fn search_file_streaming(
path: &str,
pattern: &str,
chunk_size: usize,
) -> std::io::Result<Vec<usize>> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let searcher = BoyerMoore::new(pattern);
let mut matches = Vec::new();
let mut buffer = vec![0u8; chunk_size + pattern.len()];
let mut overlap = 0;
let mut total_bytes_read = 0;
loop {
let bytes_read = reader.read(&mut buffer[overlap..])?;
if bytes_read == 0 {
break;
}
let search_len = overlap + bytes_read;
let text = std::str::from_utf8(&buffer[..search_len]).unwrap_or("");
// Search in current chunk
for match_pos in searcher.search(text) {
matches.push(total_bytes_read + match_pos - overlap);
}
total_bytes_read += bytes_read;
// Keep overlap for pattern spanning chunks
if search_len >= pattern.len() {
overlap = pattern.len() - 1;
buffer.copy_within(search_len - overlap..search_len, 0);
} else {
overlap = search_len;
}
}
Ok(matches)
}
}
Step 6: Benchmark and Optimization
Goal: Compare performance against naive search and optimize.
What to implement:
- Benchmark with various pattern and text sizes
- Measure: operations count, time, cache misses
- Optimize: table lookups, memory layout
- Identify best and worst cases
Why the previous step is not enough: Implementation is complete, but understanding performance characteristics is essential.
What’s the improvement: Benchmarks reveal:
- Best case: O(n/m) when pattern doesn’t occur
- Worst case: O(n*m) with many false matches
- Average: Much faster than naive for most real-world text
Optimization focus: Understanding when Boyer-Moore excels vs when to use alternatives (e.g., KMP for small alphabets).
Testing hint: Benchmark with realistic text (code, prose, DNA). Test with short and long patterns. Compare with Rust’s str::find().
#![allow(unused)]
fn main() {
use std::time::Instant;
pub fn benchmark_search_algorithms() {
let text = include_str!("large_text.txt"); // 1MB text
let pattern = "target";
// Naive search
let start = Instant::now();
let _matches = naive_search(text, pattern);
let naive_time = start.elapsed();
println!("Naive search: {:?}", naive_time);
// Boyer-Moore
let searcher = BoyerMoore::new(pattern);
let start = Instant::now();
let _matches = searcher.search(text);
let bm_time = start.elapsed();
println!("Boyer-Moore: {:?}", bm_time);
// Rust's built-in
let start = Instant::now();
let _matches: Vec<usize> = text.match_indices(pattern).map(|(i, _)| i).collect();
let builtin_time = start.elapsed();
println!("Built-in find: {:?}", builtin_time);
println!("Speedup: {:.2}x", naive_time.as_secs_f64() / bm_time.as_secs_f64());
}
}
Complete Working Example
use std::cmp::max;
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufReader, Read};
use std::time::Instant;
// =============================================================================
// Milestone 1: Naive String Search (Baseline)
// =============================================================================
pub fn naive_search(text: &str, pattern: &str) -> Vec<usize> {
let text_bytes = text.as_bytes();
let pattern_bytes = pattern.as_bytes();
let mut matches = Vec::new();
if pattern.is_empty() || pattern.len() > text.len() {
return matches;
}
for i in 0..=(text.len() - pattern.len()) {
let mut match_found = true;
for j in 0..pattern.len() {
if text_bytes[i + j] != pattern_bytes[j] {
match_found = false;
break;
}
}
if match_found {
matches.push(i);
}
}
matches
}
// =============================================================================
// Milestone 2 & 3: Boyer-Moore with Bad Character and Good Suffix Heuristics
// =============================================================================
pub struct BoyerMoore {
pattern: Vec<u8>,
bad_char_table: HashMap<u8, usize>,
good_suffix_table: Vec<usize>,
}
impl BoyerMoore {
pub fn new(pattern: &str) -> Self {
BoyerMoore::new_with_good_suffix(pattern)
}
fn build_bad_char_table(pattern: &[u8]) -> HashMap<u8, usize> {
let mut table = HashMap::new();
for (i, &ch) in pattern.iter().enumerate() {
table.insert(ch, i);
}
table
}
pub fn search(&self, text: &str) -> Vec<usize> {
let text_bytes = text.as_bytes();
let mut matches = Vec::new();
let m = self.pattern.len();
let n = text_bytes.len();
if m == 0 || m > n {
return matches;
}
let mut i = 0;
while i <= n - m {
let mut j = m as isize - 1;
while j >= 0 && self.pattern[j as usize] == text_bytes[i + j as usize] {
j -= 1;
}
if j < 0 {
matches.push(i);
let shift = self.good_suffix_table.get(0).copied().unwrap_or(m).max(1);
i += shift;
} else {
let bad_char = text_bytes[i + j as usize];
let bad_char_shift = if let Some(&last_occurrence) =
self.bad_char_table.get(&bad_char)
{
let distance = j as isize - last_occurrence as isize;
if distance > 0 {
distance as usize
} else {
1
}
} else {
j as usize + 1
};
let good_suffix_shift =
self.good_suffix_table.get(j as usize + 1).copied().unwrap_or(m);
i += max(bad_char_shift, good_suffix_shift.max(1));
}
}
matches
}
pub fn new_with_good_suffix(pattern: &str) -> Self {
let pattern_bytes = pattern.as_bytes().to_vec();
let bad_char_table = Self::build_bad_char_table(&pattern_bytes);
let good_suffix_table = Self::build_good_suffix_table(&pattern_bytes);
BoyerMoore {
pattern: pattern_bytes,
bad_char_table,
good_suffix_table,
}
}
fn build_good_suffix_table(pattern: &[u8]) -> Vec<usize> {
let m = pattern.len();
if m == 0 {
return vec![1];
}
let mut shift = vec![0; m + 1];
let mut border_pos = vec![0; m + 1];
let mut i = m;
let mut j = m + 1;
border_pos[i] = j;
while i > 0 {
while j <= m && pattern[i - 1] != pattern[j - 1] {
if shift[j] == 0 {
shift[j] = j - i;
}
j = border_pos[j];
}
i -= 1;
j -= 1;
border_pos[i] = j;
}
j = border_pos[0];
for idx in 0..=m {
if shift[idx] == 0 {
shift[idx] = j;
}
if idx == j {
j = border_pos[j];
}
}
shift
}
pub fn new_case_insensitive(pattern: &str) -> Self {
let normalized = pattern.to_lowercase();
Self::new(&normalized)
}
pub fn search_case_insensitive(&self, text: &str) -> Vec<usize> {
let normalized_text = text.to_lowercase();
self.search(&normalized_text)
}
}
// =============================================================================
// Milestone 5: Streaming Search Across Large Files
// =============================================================================
pub fn search_file_streaming(
path: &str,
pattern: &str,
chunk_size: usize,
) -> std::io::Result<Vec<usize>> {
if pattern.is_empty() {
return Ok(Vec::new());
}
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let searcher = BoyerMoore::new(pattern);
let mut matches = Vec::new();
let mut buffer = vec![0u8; chunk_size + pattern.len()];
let mut overlap = 0;
let mut processed = 0usize;
loop {
let bytes_read = reader.read(&mut buffer[overlap..])?;
if bytes_read == 0 {
break;
}
let search_len = overlap + bytes_read;
let chunk_start = processed.saturating_sub(overlap);
let text = std::str::from_utf8(&buffer[..search_len]).unwrap_or("");
for match_pos in searcher.search(text) {
matches.push(chunk_start + match_pos);
}
processed += bytes_read;
if search_len >= pattern.len() {
overlap = pattern.len().saturating_sub(1);
if overlap > 0 {
buffer.copy_within(search_len - overlap..search_len, 0);
}
} else {
overlap = search_len;
buffer.copy_within(0..overlap, 0);
}
}
Ok(matches)
}
// =============================================================================
// Milestone 6: Benchmarking
// =============================================================================
pub fn benchmark_search_algorithms() {
let text = "lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(10_000);
let pattern = "ipsum";
let start = Instant::now();
let _matches = naive_search(&text, pattern);
let naive_time = start.elapsed();
let searcher = BoyerMoore::new(pattern);
let start = Instant::now();
let _matches = searcher.search(&text);
let bm_time = start.elapsed();
let start = Instant::now();
let _matches: Vec<usize> = text.match_indices(pattern).map(|(i, _)| i).collect();
let builtin_time = start.elapsed();
println!(
"Naive: {:?}, Boyer-Moore: {:?}, Built-in: {:?}, Speedup: {:.2}x",
naive_time,
bm_time,
builtin_time,
naive_time.as_secs_f64() / bm_time.as_secs_f64()
);
}
fn main() {}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn naive_search_finds_matches() {
let text = "abracadabra";
let pattern = "abra";
let matches = naive_search(text, pattern);
assert_eq!(matches, vec![0, 7]);
}
#[test]
fn boyer_moore_search_matches_naive() {
let text = "the quick brown fox jumps over the lazy dog";
let pattern = "the";
let bm = BoyerMoore::new(pattern);
assert_eq!(bm.search(text), naive_search(text, pattern));
}
#[test]
fn good_suffix_provides_matches() {
let text = "abcxabcdabxabcdabcdabcy";
let pattern = "abcdabcy";
let bm = BoyerMoore::new_with_good_suffix(pattern);
assert_eq!(bm.search(text), vec![15]);
}
#[test]
fn case_insensitive_search() {
let text = "Hello HELLO hello";
let pattern = "hello";
let bm = BoyerMoore::new_case_insensitive(pattern);
assert_eq!(bm.search_case_insensitive(text), vec![0, 6, 12]);
}
#[test]
fn streaming_search_crosses_boundaries() {
let mut file = NamedTempFile::new().unwrap();
let content = "abc".repeat(10_000);
std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
let matches = search_file_streaming(file.path().to_str().unwrap(), "abcabc", 1024).unwrap();
assert!(matches.contains(&0));
}
}