perf(xpointer): linear-time string-range() search (#314)
xpointer(string-range(...)) matched the search string at every position of each node's value and re-allocated the whole needle window per position, an O(text * needle) search and a denial-of-service vector on a long node value with a near-matching needle. Replace it with a linear-time Knuth-Morris-Pratt search (failure function built once, since the needle is fixed across nodes). Matches are unchanged: same non-overlapping, left-to-right occurrences, same offset/length. A near-matching needle over a 16000-character node drops from about 0.18 s to about 0.002 s (~100x), now linear.
This commit is contained in:
parent
fab8008b42
commit
904881b81c
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- `xpointer(string-range(...))` no longer uses a quadratic substring search. It matched the search string at every position of each node's value and re-allocated the whole needle-width window per position (O(text * needle), a denial-of-service vector on a long node value with a near-matching needle); it now uses a linear-time Knuth-Morris-Pratt search with the failure function built once. The matches are unchanged: same non-overlapping, left-to-right occurrences, same offset/length. A near-matching needle over a 16000-character node drops from about 0.18 s to about 0.002 s (~100x), and the cost is now linear. Guarded by the new non-overlapping and backtracking cases in `XPointerRangeTests`.
|
||||
|
||||
## [0.4.7] - 2026-06-26
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -154,24 +154,62 @@ extension PureXML.XPointer.RangeForm {
|
||||
private static func stringRanges(in nodes: [PureXML.Model.TreeNode], search: String, offset: Int?, length: Int?) -> [PureXML.XPointer.Range] {
|
||||
guard !search.isEmpty else { return [] }
|
||||
let needle = Array(search)
|
||||
// The needle is the same for every node, so its KMP failure function is
|
||||
// built once. The former scan re-tested (and re-allocated) the whole
|
||||
// needle window at every position, an O(text * needle) search and a
|
||||
// denial-of-service vector on a long node value with a near-matching
|
||||
// needle; the search is now O(text + needle).
|
||||
let failure = kmpFailure(needle)
|
||||
return nodes.flatMap { node -> [PureXML.XPointer.Range] in
|
||||
let characters = Array(node.stringValue)
|
||||
var ranges: [PureXML.XPointer.Range] = []
|
||||
var index = 0
|
||||
while index + needle.count <= characters.count {
|
||||
guard Array(characters[index ..< index + needle.count]) == needle else { index += 1
|
||||
continue
|
||||
}
|
||||
return nonOverlappingMatches(of: needle, failure: failure, in: characters).map { index in
|
||||
let begin = min(index + (offset ?? 1) - 1, characters.count)
|
||||
let end = min(begin + (length ?? needle.count), characters.count)
|
||||
let text = String(characters[max(begin, 0) ..< max(end, begin)])
|
||||
ranges.append(PureXML.XPointer.Range(nodes: [.text(text)], text: text))
|
||||
index += needle.count
|
||||
return PureXML.XPointer.Range(nodes: [.text(text)], text: text)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
/// The KMP failure function (for each prefix, the length of its longest
|
||||
/// proper prefix that is also a suffix) of `needle`.
|
||||
private static func kmpFailure(_ needle: [Character]) -> [Int] {
|
||||
var failure = [Int](repeating: 0, count: needle.count)
|
||||
var matched = 0
|
||||
for index in 1 ..< needle.count {
|
||||
while matched > 0, needle[index] != needle[matched] {
|
||||
matched = failure[matched - 1]
|
||||
}
|
||||
if needle[index] == needle[matched] { matched += 1 }
|
||||
failure[index] = matched
|
||||
}
|
||||
return failure
|
||||
}
|
||||
|
||||
/// The start indices of `needle` in `haystack`, left to right and
|
||||
/// non-overlapping: each match resumes the scan past its end, matching the
|
||||
/// former naive scan's `index += needle.count` advance, in O(text + needle).
|
||||
private static func nonOverlappingMatches(of needle: [Character], failure: [Int], in haystack: [Character]) -> [Int] {
|
||||
var matches: [Int] = []
|
||||
var matched = 0
|
||||
var index = 0
|
||||
while index < haystack.count {
|
||||
if haystack[index] == needle[matched] {
|
||||
index += 1
|
||||
matched += 1
|
||||
if matched == needle.count {
|
||||
matches.append(index - needle.count)
|
||||
matched = 0
|
||||
}
|
||||
} else if matched > 0 {
|
||||
matched = failure[matched - 1]
|
||||
} else {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/// The whole nodes spanned from `from` to `target`: the sibling run between
|
||||
/// them when they share a parent, otherwise the two boundary nodes in document
|
||||
/// order.
|
||||
|
||||
@ -48,6 +48,27 @@ struct XPointerRangeTests {
|
||||
#expect(result.allSatisfy { $0.text == "ir" })
|
||||
}
|
||||
|
||||
@Test("string-range() matches are non-overlapping")
|
||||
func test_stringRangeNonOverlapping() throws {
|
||||
// "aa" in "aaaaa": the scan emits a match then resumes past it, so it
|
||||
// finds positions 0 and 2 (not the overlapping 1 and 3). The linear KMP
|
||||
// search must keep that advance-by-needle behavior.
|
||||
let xml = try PureXML.parse("<d>aaaaa</d>")
|
||||
let result = try PureXML.XPointer.evaluateRanges("xpointer(string-range(/d, \"aa\"))", over: xml)
|
||||
#expect(result.count == 2)
|
||||
#expect(result.allSatisfy { $0.text == "aa" })
|
||||
}
|
||||
|
||||
@Test("string-range() finds a match that needs failure-function backtracking")
|
||||
func test_stringRangeBacktrack() throws {
|
||||
// In "abcabcabd" the needle "abcabd" matches only at index 3; reaching it
|
||||
// requires backtracking after the partial "abcab" at index 0 mismatches.
|
||||
let xml = try PureXML.parse("<d>abcabcabd</d>")
|
||||
let result = try PureXML.XPointer.evaluateRanges("xpointer(string-range(/d, \"abcabd\"))", over: xml)
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.text == "abcabd")
|
||||
}
|
||||
|
||||
@Test("string-range() honors offset and length")
|
||||
func test_stringRangeOffsetLength() throws {
|
||||
// From the "ir" match in "first", start at offset 2 (the 'r') for length 1.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user