Repository: classickit (difficulty: medium)

Issue:
kmp_find misses matches that str.find sees. Observed: kmp_find('aabaaabaaaa', 'aabaaaa') returns -1, but 'aabaaabaaaa'.find('aabaaaa') is 4; the pattern plainly occurs there. Worse, when the text continues with a second occurrence, the miss turns into a wrong index: kmp_find('aabaaabaaaabaabaaaa', 'aabaaaa') returns 12 instead of 4, silently skipping the first occurrence. The contract is that kmp_find(text, pattern) always equals text.find(pattern), including an empty pattern matching at 0, patterns longer than the text giving -1, and overlapping occurrences counting. Short patterns without much repeated prefix structure agree with str.find on everything I tried, so I suspect the prefix table is built wrong for patterns whose prefixes repeat, like 'aabaaaa'.

Source files:
core.py: """Substring search using the Knuth-Morris-Pratt algorithm.

kmp_find(text, pattern) returns the index of the first occurrence of the
string pattern in the string text, or -1 when pattern never occurs. The
result always equals text.find(pattern): the empty pattern occurs at
index 0 in any text (including the empty text), a pattern longer than the
text gives -1, and overlapping occurrences count, so kmp_find("aaa", "aa")
is 0.
"""


def kmp_find(text, pattern):
    """Return text.find(pattern), computed with a KMP prefix table."""
    if not pattern:
        return 0
    table = [0] * len(pattern)
    k = 0
    for i in range(1, len(pattern)):
        if pattern[i] != pattern[k]:
            k = 0
        if pattern[i] == pattern[k]:
            k += 1
        table[i] = k
    k = 0
    for i, ch in enumerate(text):
        while k > 0 and ch != pattern[k]:
            k = table[k - 1]
        if ch == pattern[k]:
            k += 1
        if k == len(pattern):
            return i - len(pattern) + 1
    return -1


Test files:
test_core.py: import itertools
import random

from core import kmp_find


def test_pinned_and_edges():
    assert kmp_find("aabaaabaaaa", "aabaaaa") == 4
    assert kmp_find("aabaaabaaaabaabaaaa", "aabaaaa") == 4
    assert kmp_find("", "") == 0
    assert kmp_find("abc", "") == 0
    assert kmp_find("ab", "abc") == -1
    assert kmp_find("aaa", "aa") == 0
    assert kmp_find("ababab", "abab") == 0
    assert kmp_find("abcabcabd", "abcabd") == 3
    assert kmp_find("xyz", "zy") == -1


def test_property_vs_str_find():
    rng = random.Random(20240)
    for _ in range(500):
        alphabet = "ab" if rng.random() < 0.5 else "abc"
        text = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 300)))
        pattern = "".join(rng.choice(alphabet) for _ in range(rng.randint(1, 8)))
        assert kmp_find(text, pattern) == text.find(pattern)
    # Exhaustive sweep of short binary patterns against texts composed of a
    # truncated prefix of the pattern, the pattern itself, and a small tail:
    # these compositions force partial-match recoveries in the scanner.
    for length in range(3, 8):
        for chars in itertools.product("ab", repeat=length):
            pattern = "".join(chars)
            for cut in range(1, length):
                for tail in ("", "b", "ab"):
                    text = pattern[:cut] + pattern + tail
                    assert kmp_find(text, pattern) == text.find(pattern)


Patch:
--- a/core.py
+++ b/core.py
@@ -16,8 +16,8 @@
     table = [0] * len(pattern)
     k = 0
     for i in range(1, len(pattern)):
-        if pattern[i] != pattern[k]:
-            k = 0
+        while k > 0 and pattern[i] != pattern[k]:
+            k = table[k - 1]
         if pattern[i] == pattern[k]:
             k += 1
         table[i] = k