From 20856c2c38d3a7aee17a05d297cf3e4b5cdab16d Mon Sep 17 00:00:00 2001 From: Terence Date: Mon, 6 Apr 2026 02:56:34 +0800 Subject: [PATCH] feat(strings): expand from 1 to 32 algorithms with 5 new tracker variants Add 31 new string algorithms across 6 technique subcategories: - Pattern Matching (5 new): Naive, Rabin-Karp, Boyer-Moore, Z-Algorithm, Hamming Distance - Palindrome (3): Palindrome Check, Valid Palindrome, Longest Palindromic Substring - Character Frequency (3): First Non-Repeating Char, Min Window Substring, Char Freq Sort - Transformation (7): Reverse String/Words, Compression, Run-Length Decoding, atoi, Rotation, LCP - Trie Operations (5): Insert/Search, Prefix Count, Longest Word, Auto-Complete, Aho-Corasick - Edit Distance (8): Levenshtein, Jaro-Winkler, LCS, LCSubstring, LRS, Suffix Array, Wildcard, Regex Infrastructure: 5 new VisualState kinds (string-palindrome, string-frequency, string-transform, string-trie, string-distance), 5 tracker classes, 5 visualizer components. Updated CI shards from 8 to 10 in both ci.yml and deploy.yml. InputEditor switched to GenericIntrospectEditor for strings category. --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- README.md | 6 +- docs/algorithms-catalog.md | 76 ++++- docs/architecture.md | 5 + docs/contributing.md | 7 +- docs/deployment.md | 2 +- docs/glossary.md | 43 +-- .../2026-04-05-strings-expansion-design.md | 251 ++++++++++++++ docs/testing.md | 4 +- e2e/helpers/inputs.ts | 205 ++++++++++++ ...CharacterFrequencySortPipeline.stories.tsx | 75 +++++ .../character-frequency-sort.test.ts | 73 ++++ .../character-frequency-sort/educational.ts | 60 ++++ .../character-frequency-sort/index.ts | 47 +++ .../sources/CharacterFrequencySort.java | 43 +++ .../sources/character-frequency-sort.py | 28 ++ .../sources/character-frequency-sort.ts | 35 ++ .../step-generator.test.ts | 82 +++++ .../step-generator.ts | 73 ++++ ...tNonRepeatingCharacterPipeline.stories.tsx | 81 +++++ .../educational.ts | 56 ++++ .../first-non-repeating-character.test.ts | 50 +++ .../first-non-repeating-character/index.ts | 47 +++ .../sources/FirstNonRepeatingCharacter.java | 25 ++ .../sources/first-non-repeating-character.py | 17 + .../sources/first-non-repeating-character.ts | 21 ++ .../step-generator.test.ts | 93 ++++++ .../step-generator.ts | 51 +++ ...MinimumWindowSubstringPipeline.stories.tsx | 83 +++++ .../minimum-window-substring/educational.ts | 62 ++++ .../minimum-window-substring/index.ts | 47 +++ .../minimum-window-substring.test.ts | 56 ++++ .../sources/MinimumWindowSubstring.java | 57 ++++ .../sources/minimum-window-substring.py | 46 +++ .../sources/minimum-window-substring.ts | 57 ++++ .../step-generator.test.ts | 93 ++++++ .../step-generator.ts | 121 +++++++ .../JaroWinklerSimilarityPipeline.stories.tsx | 64 ++++ .../jaro-winkler-similarity/educational.ts | 66 ++++ .../jaro-winkler-similarity/index.ts | 47 +++ .../jaro-winkler-similarity.test.ts | 76 +++++ .../sources/JaroWinklerSimilarity.java | 89 +++++ .../sources/jaro-winkler-similarity.py | 81 +++++ .../sources/jaro-winkler-similarity.ts | 98 ++++++ .../step-generator.test.ts | 108 ++++++ .../jaro-winkler-similarity/step-generator.ts | 194 +++++++++++ .../LevenshteinDistancePipeline.stories.tsx | 64 ++++ .../levenshtein-distance/educational.ts | 71 ++++ .../levenshtein-distance/index.ts | 47 +++ .../levenshtein-distance.test.ts | 58 ++++ .../sources/LevenshteinDistance.java | 46 +++ .../sources/levenshtein-distance.py | 37 +++ .../sources/levenshtein-distance.ts | 46 +++ .../step-generator.test.ts | 97 ++++++ .../levenshtein-distance/step-generator.ts | 157 +++++++++ ...ngestCommonSubsequencePipeline.stories.tsx | 64 ++++ .../longest-common-subsequence/educational.ts | 70 ++++ .../longest-common-subsequence/index.ts | 47 +++ .../longest-common-subsequence.test.ts | 66 ++++ .../sources/LongestCommonSubsequence.java | 46 +++ .../sources/longest-common-subsequence.py | 37 +++ .../sources/longest-common-subsequence.ts | 47 +++ .../step-generator.test.ts | 105 ++++++ .../step-generator.ts | 134 ++++++++ ...LongestCommonSubstringPipeline.stories.tsx | 57 ++++ .../longest-common-substring/educational.ts | 63 ++++ .../longest-common-substring/index.ts | 48 +++ .../longest-common-substring.test.ts | 58 ++++ .../sources/LongestCommonSubstring.java | 39 +++ .../sources/longest-common-substring.py | 31 ++ .../sources/longest-common-substring.ts | 39 +++ .../step-generator.test.ts | 88 +++++ .../step-generator.ts | 134 ++++++++ ...ngestRepeatedSubstringPipeline.stories.tsx | 63 ++++ .../longest-repeated-substring/educational.ts | 65 ++++ .../longest-repeated-substring/index.ts | 47 +++ .../longest-repeated-substring.test.ts | 62 ++++ .../sources/LongestRepeatedSubstring.java | 43 +++ .../sources/longest-repeated-substring.py | 36 ++ .../sources/longest-repeated-substring.ts | 43 +++ .../step-generator.test.ts | 99 ++++++ .../step-generator.ts | 142 ++++++++ .../RegexMatchingPipeline.stories.tsx | 64 ++++ .../regex-matching/educational.ts | 70 ++++ .../edit-distance/regex-matching/index.ts | 47 +++ .../regex-matching/regex-matching.test.ts | 62 ++++ .../regex-matching/sources/RegexMatching.java | 50 +++ .../regex-matching/sources/regex-matching.py | 41 +++ .../regex-matching/sources/regex-matching.ts | 50 +++ .../regex-matching/step-generator.test.ts | 105 ++++++ .../regex-matching/step-generator.ts | 166 ++++++++++ ...uffixArrayConstructionPipeline.stories.tsx | 61 ++++ .../suffix-array-construction/educational.ts | 86 +++++ .../suffix-array-construction/index.ts | 47 +++ .../sources/SuffixArrayConstruction.java | 38 +++ .../sources/suffix-array-construction.py | 24 ++ .../sources/suffix-array-construction.ts | 27 ++ .../step-generator.test.ts | 94 ++++++ .../step-generator.ts | 101 ++++++ .../suffix-array-construction.test.ts | 68 ++++ .../WildcardMatchingPipeline.stories.tsx | 64 ++++ .../wildcard-matching/educational.ts | 68 ++++ .../edit-distance/wildcard-matching/index.ts | 47 +++ .../sources/WildcardMatching.java | 46 +++ .../sources/wildcard-matching.py | 38 +++ .../sources/wildcard-matching.ts | 46 +++ .../wildcard-matching/step-generator.test.ts | 105 ++++++ .../wildcard-matching/step-generator.ts | 162 +++++++++ .../wildcard-matching.test.ts | 66 ++++ ...stPalindromicSubstringPipeline.stories.tsx | 57 ++++ .../educational.ts | 78 +++++ .../longest-palindromic-substring/index.ts | 48 +++ .../longest-palindromic-substring.test.ts | 57 ++++ .../sources/LongestPalindromicSubstring.java | 51 +++ .../sources/longest-palindromic-substring.py | 44 +++ .../sources/longest-palindromic-substring.ts | 53 +++ .../step-generator.test.ts | 112 +++++++ .../step-generator.ts | 209 ++++++++++++ .../PalindromeCheckPipeline.stories.tsx | 54 +++ .../palindrome-check/educational.ts | 70 ++++ .../palindrome/palindrome-check/index.ts | 47 +++ .../palindrome-check/palindrome-check.test.ts | 42 +++ .../sources/PalindromeCheck.java | 20 ++ .../sources/palindrome-check.py | 16 + .../sources/palindrome-check.ts | 19 ++ .../palindrome-check/step-generator.test.ts | 83 +++++ .../palindrome-check/step-generator.ts | 62 ++++ .../ValidPalindromePipeline.stories.tsx | 54 +++ .../valid-palindrome/educational.ts | 70 ++++ .../palindrome/valid-palindrome/index.ts | 47 +++ .../sources/ValidPalindrome.java | 27 ++ .../sources/valid-palindrome.py | 21 ++ .../sources/valid-palindrome.ts | 30 ++ .../valid-palindrome/step-generator.test.ts | 89 +++++ .../valid-palindrome/step-generator.ts | 93 ++++++ .../valid-palindrome/valid-palindrome.test.ts | 50 +++ .../BoyerMooreSearchPipeline.stories.tsx | 57 ++++ .../boyer-moore-search.test.ts | 52 +++ .../boyer-moore-search/educational.ts | 70 ++++ .../boyer-moore-search/index.ts | 45 +++ .../sources/BoyerMooreSearch.java | 52 +++ .../sources/boyer-moore-search.py | 43 +++ .../sources/boyer-moore-search.ts | 47 +++ .../boyer-moore-search/step-generator.test.ts | 93 ++++++ .../boyer-moore-search/step-generator.ts | 107 ++++++ .../HammingDistancePipeline.stories.tsx | 57 ++++ .../hamming-distance/educational.ts | 61 ++++ .../hamming-distance/hamming-distance.test.ts | 48 +++ .../hamming-distance/index.ts | 45 +++ .../sources/HammingDistance.java | 25 ++ .../sources/hamming-distance.py | 21 ++ .../sources/hamming-distance.ts | 23 ++ .../hamming-distance/step-generator.test.ts | 82 +++++ .../hamming-distance/step-generator.ts | 45 +++ .../NaivePatternSearchPipeline.stories.tsx | 57 ++++ .../naive-pattern-search/educational.ts | 64 ++++ .../naive-pattern-search/index.ts | 47 +++ .../naive-pattern-search.test.ts | 54 +++ .../sources/NaivePatternSearch.java | 23 ++ .../sources/naive-pattern-search.py | 20 ++ .../sources/naive-pattern-search.ts | 19 ++ .../step-generator.test.ts | 83 +++++ .../naive-pattern-search/step-generator.ts | 59 ++++ .../RabinKarpSearchPipeline.stories.tsx | 57 ++++ .../rabin-karp-search/educational.ts | 70 ++++ .../rabin-karp-search/index.ts | 45 +++ .../rabin-karp-search.test.ts | 52 +++ .../sources/RabinKarpSearch.java | 58 ++++ .../sources/rabin-karp-search.py | 54 +++ .../sources/rabin-karp-search.ts | 58 ++++ .../rabin-karp-search/step-generator.test.ts | 93 ++++++ .../rabin-karp-search/step-generator.ts | 143 ++++++++ .../ZAlgorithmPipeline.stories.tsx | 57 ++++ .../z-algorithm/educational.ts | 62 ++++ .../pattern-matching/z-algorithm/index.ts | 45 +++ .../z-algorithm/sources/ZAlgorithm.java | 43 +++ .../z-algorithm/sources/z-algorithm.py | 36 ++ .../z-algorithm/sources/z-algorithm.ts | 41 +++ .../z-algorithm/step-generator.test.ts | 98 ++++++ .../z-algorithm/step-generator.ts | 88 +++++ .../z-algorithm/z-algorithm.test.ts | 52 +++ .../LongestCommonPrefixPipeline.stories.tsx | 49 +++ .../longest-common-prefix/educational.ts | 68 ++++ .../longest-common-prefix/index.ts | 47 +++ .../longest-common-prefix.test.ts | 46 +++ .../sources/LongestCommonPrefix.java | 28 ++ .../sources/longest-common-prefix.py | 23 ++ .../sources/longest-common-prefix.ts | 28 ++ .../step-generator.test.ts | 79 +++++ .../longest-common-prefix/step-generator.ts | 82 +++++ .../ReverseStringPipeline.stories.tsx | 54 +++ .../reverse-string/educational.ts | 61 ++++ .../transformation/reverse-string/index.ts | 45 +++ .../reverse-string/reverse-string.test.ts | 36 ++ .../reverse-string/sources/ReverseString.java | 26 ++ .../reverse-string/sources/reverse-string.py | 22 ++ .../reverse-string/sources/reverse-string.ts | 23 ++ .../reverse-string/step-generator.test.ts | 71 ++++ .../reverse-string/step-generator.ts | 39 +++ .../ReverseWordsPipeline.stories.tsx | 47 +++ .../reverse-words/educational.ts | 77 +++++ .../transformation/reverse-words/index.ts | 48 +++ .../reverse-words/reverse-words.test.ts | 46 +++ .../reverse-words/sources/ReverseWords.java | 26 ++ .../reverse-words/sources/reverse-words.py | 22 ++ .../reverse-words/sources/reverse-words.ts | 23 ++ .../reverse-words/step-generator.test.ts | 81 +++++ .../reverse-words/step-generator.ts | 72 ++++ .../RunLengthDecodingPipeline.stories.tsx | 54 +++ .../run-length-decoding/educational.ts | 75 +++++ .../run-length-decoding/index.ts | 47 +++ .../run-length-decoding.test.ts | 42 +++ .../sources/RunLengthDecoding.java | 35 ++ .../sources/run-length-decoding.py | 29 ++ .../sources/run-length-decoding.ts | 32 ++ .../step-generator.test.ts | 76 +++++ .../run-length-decoding/step-generator.ts | 73 ++++ .../StringCompressionPipeline.stories.tsx | 54 +++ .../string-compression/educational.ts | 81 +++++ .../string-compression/index.ts | 48 +++ .../sources/StringCompression.java | 31 ++ .../sources/string-compression.py | 24 ++ .../sources/string-compression.ts | 25 ++ .../string-compression/step-generator.test.ts | 89 +++++ .../string-compression/step-generator.ts | 80 +++++ .../string-compression.test.ts | 57 ++++ .../StringRotationCheckPipeline.stories.tsx | 54 +++ .../string-rotation-check/educational.ts | 60 ++++ .../string-rotation-check/index.ts | 47 +++ .../sources/StringRotationCheck.java | 14 + .../sources/string-rotation-check.py | 12 + .../sources/string-rotation-check.ts | 11 + .../step-generator.test.ts | 97 ++++++ .../string-rotation-check/step-generator.ts | 62 ++++ .../string-rotation-check.test.ts | 60 ++++ .../StringToIntegerPipeline.stories.tsx | 61 ++++ .../string-to-integer/educational.ts | 68 ++++ .../transformation/string-to-integer/index.ts | 47 +++ .../sources/StringToInteger.java | 43 +++ .../sources/string-to-integer.py | 43 +++ .../sources/string-to-integer.ts | 43 +++ .../string-to-integer/step-generator.test.ts | 90 +++++ .../string-to-integer/step-generator.ts | 83 +++++ .../string-to-integer.test.ts | 73 ++++ .../AhoCorasickSearchPipeline.stories.tsx | 64 ++++ .../aho-corasick-search.test.ts | 81 +++++ .../aho-corasick-search/educational.ts | 78 +++++ .../aho-corasick-search/index.ts | 47 +++ .../sources/AhoCorasickSearch.java | 103 ++++++ .../sources/aho-corasick-search.py | 79 +++++ .../sources/aho-corasick-search.ts | 105 ++++++ .../step-generator.test.ts | 136 ++++++++ .../aho-corasick-search/step-generator.ts | 214 ++++++++++++ .../AutoCompleteTriePipeline.stories.tsx | 64 ++++ .../auto-complete-trie.test.ts | 74 +++++ .../auto-complete-trie/educational.ts | 76 +++++ .../auto-complete-trie/index.ts | 47 +++ .../sources/AutoCompleteTrie.java | 57 ++++ .../sources/auto-complete-trie.py | 43 +++ .../sources/auto-complete-trie.ts | 54 +++ .../auto-complete-trie/step-generator.test.ts | 107 ++++++ .../auto-complete-trie/step-generator.ts | 138 ++++++++ .../LongestWordInTriePipeline.stories.tsx | 56 ++++ .../longest-word-in-trie/educational.ts | 75 +++++ .../longest-word-in-trie/index.ts | 47 +++ .../longest-word-in-trie.test.ts | 62 ++++ .../sources/LongestWordInTrie.java | 59 ++++ .../sources/longest-word-in-trie.py | 42 +++ .../sources/longest-word-in-trie.ts | 60 ++++ .../step-generator.test.ts | 96 ++++++ .../longest-word-in-trie/step-generator.ts | 116 +++++++ .../TrieInsertSearchPipeline.stories.tsx | 57 ++++ .../trie-insert-search/educational.ts | 72 ++++ .../trie-insert-search/index.ts | 47 +++ .../sources/TrieInsertSearch.java | 39 +++ .../sources/trie-insert-search.py | 32 ++ .../sources/trie-insert-search.ts | 41 +++ .../trie-insert-search/step-generator.test.ts | 95 ++++++ .../trie-insert-search/step-generator.ts | 121 +++++++ .../trie-insert-search.test.ts | 56 ++++ .../TriePrefixCountPipeline.stories.tsx | 57 ++++ .../trie-prefix-count/educational.ts | 72 ++++ .../trie-prefix-count/index.ts | 47 +++ .../sources/TriePrefixCount.java | 42 +++ .../sources/trie-prefix-count.py | 33 ++ .../sources/trie-prefix-count.ts | 44 +++ .../trie-prefix-count/step-generator.test.ts | 93 ++++++ .../trie-prefix-count/step-generator.ts | 115 +++++++ .../trie-prefix-count.test.ts | 55 +++ src/components/input-editor/InputEditor.tsx | 8 +- .../visualization/DistanceVisualizer.tsx | 238 +++++++++++++ .../visualization/FrequencyVisualizer.tsx | 200 +++++++++++ .../visualization/PalindromeVisualizer.tsx | 247 ++++++++++++++ .../visualization/TransformVisualizer.tsx | 219 ++++++++++++ .../visualization/TrieVisualizer.tsx | 313 ++++++++++++++++++ .../visualization/VisualizationPanel.tsx | 15 + .../visualization/trie-visualizer-utils.ts | 86 +++++ src/trackers/distance-tracker.ts | 305 +++++++++++++++++ src/trackers/frequency-tracker.ts | 263 +++++++++++++++ src/trackers/index.ts | 5 + src/trackers/palindrome-tracker.ts | 262 +++++++++++++++ src/trackers/transform-tracker.ts | 207 ++++++++++++ src/trackers/trie-tracker.ts | 308 +++++++++++++++++ src/types/execution.ts | 182 +++++++++- src/types/fn-import.d.ts | 34 ++ src/types/index.ts | 14 + 307 files changed, 20746 insertions(+), 45 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-05-strings-expansion-design.md create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/CharacterFrequencySortPipeline.stories.tsx create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/character-frequency-sort.test.ts create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/educational.ts create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/index.ts create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/sources/CharacterFrequencySort.java create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.py create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.ts create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.test.ts create mode 100644 src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.ts create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/FirstNonRepeatingCharacterPipeline.stories.tsx create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/educational.ts create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/first-non-repeating-character.test.ts create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/index.ts create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/sources/FirstNonRepeatingCharacter.java create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.py create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.ts create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.test.ts create mode 100644 src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.ts create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/MinimumWindowSubstringPipeline.stories.tsx create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/educational.ts create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/index.ts create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/minimum-window-substring.test.ts create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/sources/MinimumWindowSubstring.java create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.py create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.ts create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.test.ts create mode 100644 src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/JaroWinklerSimilarityPipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/educational.ts create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/index.ts create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/jaro-winkler-similarity.test.ts create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/JaroWinklerSimilarity.java create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.py create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.ts create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/LevenshteinDistancePipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/educational.ts create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/index.ts create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/levenshtein-distance.test.ts create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/sources/LevenshteinDistance.java create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.py create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.ts create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/LongestCommonSubsequencePipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/educational.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/index.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/longest-common-subsequence.test.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/sources/LongestCommonSubsequence.java create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.py create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/LongestCommonSubstringPipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/educational.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/index.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/longest-common-substring.test.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/sources/LongestCommonSubstring.java create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.py create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/longest-common-substring/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/LongestRepeatedSubstringPipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/educational.ts create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/index.ts create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/longest-repeated-substring.test.ts create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/sources/LongestRepeatedSubstring.java create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.py create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.ts create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/regex-matching/RegexMatchingPipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/regex-matching/educational.ts create mode 100644 src/algorithms/strings/edit-distance/regex-matching/index.ts create mode 100644 src/algorithms/strings/edit-distance/regex-matching/regex-matching.test.ts create mode 100644 src/algorithms/strings/edit-distance/regex-matching/sources/RegexMatching.java create mode 100644 src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.py create mode 100644 src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.ts create mode 100644 src/algorithms/strings/edit-distance/regex-matching/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/regex-matching/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/SuffixArrayConstructionPipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/educational.ts create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/index.ts create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/sources/SuffixArrayConstruction.java create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.py create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.ts create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/suffix-array-construction/suffix-array-construction.test.ts create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/WildcardMatchingPipeline.stories.tsx create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/educational.ts create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/index.ts create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/sources/WildcardMatching.java create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.py create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.ts create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/step-generator.test.ts create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/step-generator.ts create mode 100644 src/algorithms/strings/edit-distance/wildcard-matching/wildcard-matching.test.ts create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/LongestPalindromicSubstringPipeline.stories.tsx create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/educational.ts create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/index.ts create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/longest-palindromic-substring.test.ts create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/sources/LongestPalindromicSubstring.java create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.py create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.ts create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.test.ts create mode 100644 src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.ts create mode 100644 src/algorithms/strings/palindrome/palindrome-check/PalindromeCheckPipeline.stories.tsx create mode 100644 src/algorithms/strings/palindrome/palindrome-check/educational.ts create mode 100644 src/algorithms/strings/palindrome/palindrome-check/index.ts create mode 100644 src/algorithms/strings/palindrome/palindrome-check/palindrome-check.test.ts create mode 100644 src/algorithms/strings/palindrome/palindrome-check/sources/PalindromeCheck.java create mode 100644 src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.py create mode 100644 src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.ts create mode 100644 src/algorithms/strings/palindrome/palindrome-check/step-generator.test.ts create mode 100644 src/algorithms/strings/palindrome/palindrome-check/step-generator.ts create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/ValidPalindromePipeline.stories.tsx create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/educational.ts create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/index.ts create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/sources/ValidPalindrome.java create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.py create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.ts create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/step-generator.test.ts create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/step-generator.ts create mode 100644 src/algorithms/strings/palindrome/valid-palindrome/valid-palindrome.test.ts create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/BoyerMooreSearchPipeline.stories.tsx create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/boyer-moore-search.test.ts create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/educational.ts create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/index.ts create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/sources/BoyerMooreSearch.java create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.py create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.ts create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.test.ts create mode 100644 src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.ts create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/HammingDistancePipeline.stories.tsx create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/educational.ts create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/hamming-distance.test.ts create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/index.ts create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/sources/HammingDistance.java create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.py create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.ts create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/step-generator.test.ts create mode 100644 src/algorithms/strings/pattern-matching/hamming-distance/step-generator.ts create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/NaivePatternSearchPipeline.stories.tsx create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/educational.ts create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/index.ts create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/naive-pattern-search.test.ts create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/sources/NaivePatternSearch.java create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.py create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.ts create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.test.ts create mode 100644 src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.ts create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/RabinKarpSearchPipeline.stories.tsx create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/educational.ts create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/index.ts create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/rabin-karp-search.test.ts create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/sources/RabinKarpSearch.java create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.py create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.ts create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.test.ts create mode 100644 src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.ts create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/ZAlgorithmPipeline.stories.tsx create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/educational.ts create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/index.ts create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/sources/ZAlgorithm.java create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.py create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.ts create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/step-generator.test.ts create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/step-generator.ts create mode 100644 src/algorithms/strings/pattern-matching/z-algorithm/z-algorithm.test.ts create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/LongestCommonPrefixPipeline.stories.tsx create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/educational.ts create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/index.ts create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/longest-common-prefix.test.ts create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/sources/LongestCommonPrefix.java create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.py create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.ts create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/step-generator.test.ts create mode 100644 src/algorithms/strings/transformation/longest-common-prefix/step-generator.ts create mode 100644 src/algorithms/strings/transformation/reverse-string/ReverseStringPipeline.stories.tsx create mode 100644 src/algorithms/strings/transformation/reverse-string/educational.ts create mode 100644 src/algorithms/strings/transformation/reverse-string/index.ts create mode 100644 src/algorithms/strings/transformation/reverse-string/reverse-string.test.ts create mode 100644 src/algorithms/strings/transformation/reverse-string/sources/ReverseString.java create mode 100644 src/algorithms/strings/transformation/reverse-string/sources/reverse-string.py create mode 100644 src/algorithms/strings/transformation/reverse-string/sources/reverse-string.ts create mode 100644 src/algorithms/strings/transformation/reverse-string/step-generator.test.ts create mode 100644 src/algorithms/strings/transformation/reverse-string/step-generator.ts create mode 100644 src/algorithms/strings/transformation/reverse-words/ReverseWordsPipeline.stories.tsx create mode 100644 src/algorithms/strings/transformation/reverse-words/educational.ts create mode 100644 src/algorithms/strings/transformation/reverse-words/index.ts create mode 100644 src/algorithms/strings/transformation/reverse-words/reverse-words.test.ts create mode 100644 src/algorithms/strings/transformation/reverse-words/sources/ReverseWords.java create mode 100644 src/algorithms/strings/transformation/reverse-words/sources/reverse-words.py create mode 100644 src/algorithms/strings/transformation/reverse-words/sources/reverse-words.ts create mode 100644 src/algorithms/strings/transformation/reverse-words/step-generator.test.ts create mode 100644 src/algorithms/strings/transformation/reverse-words/step-generator.ts create mode 100644 src/algorithms/strings/transformation/run-length-decoding/RunLengthDecodingPipeline.stories.tsx create mode 100644 src/algorithms/strings/transformation/run-length-decoding/educational.ts create mode 100644 src/algorithms/strings/transformation/run-length-decoding/index.ts create mode 100644 src/algorithms/strings/transformation/run-length-decoding/run-length-decoding.test.ts create mode 100644 src/algorithms/strings/transformation/run-length-decoding/sources/RunLengthDecoding.java create mode 100644 src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.py create mode 100644 src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.ts create mode 100644 src/algorithms/strings/transformation/run-length-decoding/step-generator.test.ts create mode 100644 src/algorithms/strings/transformation/run-length-decoding/step-generator.ts create mode 100644 src/algorithms/strings/transformation/string-compression/StringCompressionPipeline.stories.tsx create mode 100644 src/algorithms/strings/transformation/string-compression/educational.ts create mode 100644 src/algorithms/strings/transformation/string-compression/index.ts create mode 100644 src/algorithms/strings/transformation/string-compression/sources/StringCompression.java create mode 100644 src/algorithms/strings/transformation/string-compression/sources/string-compression.py create mode 100644 src/algorithms/strings/transformation/string-compression/sources/string-compression.ts create mode 100644 src/algorithms/strings/transformation/string-compression/step-generator.test.ts create mode 100644 src/algorithms/strings/transformation/string-compression/step-generator.ts create mode 100644 src/algorithms/strings/transformation/string-compression/string-compression.test.ts create mode 100644 src/algorithms/strings/transformation/string-rotation-check/StringRotationCheckPipeline.stories.tsx create mode 100644 src/algorithms/strings/transformation/string-rotation-check/educational.ts create mode 100644 src/algorithms/strings/transformation/string-rotation-check/index.ts create mode 100644 src/algorithms/strings/transformation/string-rotation-check/sources/StringRotationCheck.java create mode 100644 src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.py create mode 100644 src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.ts create mode 100644 src/algorithms/strings/transformation/string-rotation-check/step-generator.test.ts create mode 100644 src/algorithms/strings/transformation/string-rotation-check/step-generator.ts create mode 100644 src/algorithms/strings/transformation/string-rotation-check/string-rotation-check.test.ts create mode 100644 src/algorithms/strings/transformation/string-to-integer/StringToIntegerPipeline.stories.tsx create mode 100644 src/algorithms/strings/transformation/string-to-integer/educational.ts create mode 100644 src/algorithms/strings/transformation/string-to-integer/index.ts create mode 100644 src/algorithms/strings/transformation/string-to-integer/sources/StringToInteger.java create mode 100644 src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.py create mode 100644 src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.ts create mode 100644 src/algorithms/strings/transformation/string-to-integer/step-generator.test.ts create mode 100644 src/algorithms/strings/transformation/string-to-integer/step-generator.ts create mode 100644 src/algorithms/strings/transformation/string-to-integer/string-to-integer.test.ts create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/AhoCorasickSearchPipeline.stories.tsx create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/aho-corasick-search.test.ts create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/educational.ts create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/index.ts create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/sources/AhoCorasickSearch.java create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.py create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.ts create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.test.ts create mode 100644 src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.ts create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/AutoCompleteTriePipeline.stories.tsx create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/auto-complete-trie.test.ts create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/educational.ts create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/index.ts create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/sources/AutoCompleteTrie.java create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.py create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.ts create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.test.ts create mode 100644 src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.ts create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/LongestWordInTriePipeline.stories.tsx create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/educational.ts create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/index.ts create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/longest-word-in-trie.test.ts create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/sources/LongestWordInTrie.java create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.py create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.ts create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.test.ts create mode 100644 src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.ts create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/TrieInsertSearchPipeline.stories.tsx create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/educational.ts create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/index.ts create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/sources/TrieInsertSearch.java create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.py create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.ts create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/step-generator.test.ts create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/step-generator.ts create mode 100644 src/algorithms/strings/trie-operations/trie-insert-search/trie-insert-search.test.ts create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/TriePrefixCountPipeline.stories.tsx create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/educational.ts create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/index.ts create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/sources/TriePrefixCount.java create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.py create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.ts create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.test.ts create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.ts create mode 100644 src/algorithms/strings/trie-operations/trie-prefix-count/trie-prefix-count.test.ts create mode 100644 src/components/visualization/DistanceVisualizer.tsx create mode 100644 src/components/visualization/FrequencyVisualizer.tsx create mode 100644 src/components/visualization/PalindromeVisualizer.tsx create mode 100644 src/components/visualization/TransformVisualizer.tsx create mode 100644 src/components/visualization/TrieVisualizer.tsx create mode 100644 src/components/visualization/trie-visualizer-utils.ts create mode 100644 src/trackers/distance-tracker.ts create mode 100644 src/trackers/frequency-tracker.ts create mode 100644 src/trackers/palindrome-tracker.ts create mode 100644 src/trackers/transform-tracker.ts create mode 100644 src/trackers/trie-tracker.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 945d0832..bf8d78fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8] + shard: [1/10, 2/10, 3/10, 4/10, 5/10, 6/10, 7/10, 8/10, 9/10, 10/10] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c3b7008e..85df00cc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,7 +46,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8] + shard: [1/10, 2/10, 3/10, 4/10, 5/10, 6/10, 7/10, 8/10, 9/10, 10/10] steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index 2a80c5f3..1c3bd2b5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Algorithm visualization web app for learners. Step through algorithms with synch ## Features -- **284 Algorithms across 14 Categories** with interactive visualizations (bar charts, SVG graphs/trees, CSS grids, DP tables, and more) +- **367 Algorithms across 14 Categories** with interactive visualizations (bar charts, SVG graphs/trees, CSS grids, DP tables, and more) - **Multi-Language Code Display**: TypeScript, Python, and Java with synchronized line highlighting via Monaco Editor - **Step-by-Step Playback**: Play, pause, step forward/backward, scrub, adjustable speed (0.25x–4x) - **Category-Specific Input Editors**: Editable arrays, targets, grids, text patterns, and matrices @@ -19,7 +19,7 @@ Algorithm visualization web app for learners. Step through algorithms with synch ## Algorithms -**284 algorithms across 14 categories**: Sorting (53 algorithms across 9 technique subcategories), Searching, Graph (28 algorithms across 10 technique subcategories), Pathfinding (27 algorithms across 5 technique subcategories), Dynamic Programming (32 algorithms across 6 technique subcategories), Arrays (44 algorithms across 11 technique subcategories), Trees, Linked Lists, Heaps (28 algorithms across 4 technique subcategories), Stacks & Queues, Hash Maps (28 algorithms across 8 technique subcategories), Strings, Matrices (20 algorithms across 5 technique subcategories), and Sets (19 algorithms across 5 technique subcategories). +**367 algorithms across 14 categories**: Sorting (53 algorithms across 9 technique subcategories), Searching, Graph (28 algorithms across 10 technique subcategories), Pathfinding (27 algorithms across 5 technique subcategories), Dynamic Programming (32 algorithms across 6 technique subcategories), Arrays (44 algorithms across 11 technique subcategories), Trees, Linked Lists, Heaps (28 algorithms across 4 technique subcategories), Stacks & Queues (28 algorithms across 8 technique subcategories), Hash Maps (28 algorithms across 8 technique subcategories), Strings (32 algorithms across 6 technique subcategories), Matrices (20 algorithms across 5 technique subcategories), and Sets (19 algorithms across 5 technique subcategories). See the [full Algorithm Catalog](docs/algorithms-catalog.md) for the complete listing with visualizer descriptions and technique subcategories. @@ -57,7 +57,7 @@ Welcome to the definitive map of AlgoFlow. We maintain 12 specialized guides map | Debug step-generation crashes | 🐛 [Debugging](docs/debugging.md) | | Work on UI layout or styling | 💅 [Design System](docs/design-system.md) | | Write algorithm learning modules | 📚 [Educational Content Guide](docs/educational-content-guide.md) | -| Browse all 284 algorithms | 🔍 [Algorithm Catalog](docs/algorithms-catalog.md) | +| Browse all 367 algorithms | 🔍 [Algorithm Catalog](docs/algorithms-catalog.md) | | Understand AI hooks & plugins | 🤖 [Development System](docs/claude-system.md) | > [!TIP] > **First-time contributors:** Do not try to hack around aimlessly. Start strictly at the [New Developer Onboarding Guide](docs/onboarding.md). It dictates the hard boundary between the UI logic and the engine. diff --git a/docs/algorithms-catalog.md b/docs/algorithms-catalog.md index a440d8c2..e6685509 100644 --- a/docs/algorithms-catalog.md +++ b/docs/algorithms-catalog.md @@ -2,7 +2,7 @@ # Algorithm Catalog -Complete listing of all 284 algorithms available in AlgoFlow, organized by category with visualizer descriptions and technique subcategories. +Complete listing of all 367 algorithms available in AlgoFlow, organized by category with visualizer descriptions and technique subcategories. > **Prerequisites:** None — this is a reference document. @@ -19,7 +19,7 @@ Complete listing of all 284 algorithms available in AlgoFlow, organized by categ - [Heaps (28)](#heaps-28-algorithms) - [Stacks & Queues (1)](#stacks--queues-1-algorithm) - [Hash Maps (28)](#hash-maps-28-algorithms) -- [Strings (1)](#strings-1-algorithm) +- [Strings (32)](#strings-32-algorithms) - [Matrices (20)](#matrices-20-algorithms) - [Sets (19)](#sets-19-algorithms) @@ -437,13 +437,71 @@ Algorithms leveraging hash table lookups, frequency counting, grouping, tracking --- -## Strings (1 algorithm) - -Algorithms for string matching and manipulation. Algorithms live under `src/algorithms/strings///`. - -| Technique | Algorithm | Visualizer | Source Directory | -| ---------------- | ---------- | ------------------------------------ | ----------------------------------------------------- | -| Pattern Matching | KMP Search | Text row, pattern row, failure table | `src/algorithms/strings/pattern-matching/kmp-search/` | +## Strings (32 algorithms) + +Algorithms for string matching, manipulation, and comparison. Algorithms live under `src/algorithms/strings///`. + +### Pattern Matching (6) + +| Technique | Algorithm | Visualizer | Source Directory | +| ---------------- | -------------------- | ------------------------------------------------ | --------------------------------------------------------------- | +| Pattern Matching | KMP Search | Text row, pattern row, failure table | `src/algorithms/strings/pattern-matching/kmp-search/` | +| Pattern Matching | Naive Pattern Search | Text row, pattern row, brute-force slide | `src/algorithms/strings/pattern-matching/naive-pattern-search/` | +| Pattern Matching | Rabin-Karp Search | Text row, pattern row, rolling hash display | `src/algorithms/strings/pattern-matching/rabin-karp-search/` | +| Pattern Matching | Boyer-Moore Search | Text row, pattern row, bad character table | `src/algorithms/strings/pattern-matching/boyer-moore-search/` | +| Pattern Matching | Z-Algorithm | Text row, pattern row, Z-array visualization | `src/algorithms/strings/pattern-matching/z-algorithm/` | +| Pattern Matching | Hamming Distance | Text row, pattern row, position-by-position diff | `src/algorithms/strings/pattern-matching/hamming-distance/` | + +### Palindrome (3) + +| Technique | Algorithm | Visualizer | Source Directory | +| ---------- | ----------------------------- | ------------------------------------------- | ----------------------------------------------------------------------- | +| Palindrome | Palindrome Check | Char row with L/R pointers | `src/algorithms/strings/palindrome/palindrome-check/` | +| Palindrome | Valid Palindrome | Char row with skip markers and L/R pointers | `src/algorithms/strings/palindrome/valid-palindrome/` | +| Palindrome | Longest Palindromic Substring | Char row with center expansion arcs | `src/algorithms/strings/palindrome/longest-palindromic-substring/` | + +### Character Frequency (3) + +| Technique | Algorithm | Visualizer | Source Directory | +| ------------------- | ---------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- | +| Character Frequency | First Non-Repeating Character | String row with frequency histogram | `src/algorithms/strings/character-frequency/first-non-repeating-character/` | +| Character Frequency | Minimum Window Substring | String rows with sliding window bracket | `src/algorithms/strings/character-frequency/minimum-window-substring/` | +| Character Frequency | Character Frequency Sort | String row with frequency-sorted output | `src/algorithms/strings/character-frequency/character-frequency-sort/` | + +### Transformation (7) + +| Technique | Algorithm | Visualizer | Source Directory | +| -------------- | -------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | +| Transformation | Reverse String | Input/output rows with swap pointers | `src/algorithms/strings/transformation/reverse-string/` | +| Transformation | Reverse Words | Input/output rows with word-level reversal | `src/algorithms/strings/transformation/reverse-words/` | +| Transformation | String Compression | Input row with run-length output building | `src/algorithms/strings/transformation/string-compression/` | +| Transformation | Run-Length Decoding | Input row with expanded output building | `src/algorithms/strings/transformation/run-length-decoding/` | +| Transformation | String to Integer | Input row with phase-based parsing display | `src/algorithms/strings/transformation/string-to-integer/` | +| Transformation | String Rotation Check | Input/output with concatenation visualization | `src/algorithms/strings/transformation/string-rotation-check/` | +| Transformation | Longest Common Prefix | Input display with vertical column scanning | `src/algorithms/strings/transformation/longest-common-prefix/` | + +### Trie Operations (5) + +| Technique | Algorithm | Visualizer | Source Directory | +| --------------- | -------------------- | ------------------------------------------- | ------------------------------------------------------------------- | +| Trie Operations | Trie Insert & Search | SVG trie tree with path highlighting | `src/algorithms/strings/trie-operations/trie-insert-search/` | +| Trie Operations | Trie Prefix Count | SVG trie tree with count annotations | `src/algorithms/strings/trie-operations/trie-prefix-count/` | +| Trie Operations | Longest Word in Trie | SVG trie tree with DFS path marking | `src/algorithms/strings/trie-operations/longest-word-in-trie/` | +| Trie Operations | Auto-Complete Trie | SVG trie tree with suggestion collection | `src/algorithms/strings/trie-operations/auto-complete-trie/` | +| Trie Operations | Aho-Corasick Search | SVG trie tree with failure links and matches | `src/algorithms/strings/trie-operations/aho-corasick-search/` | + +### Edit Distance (8) + +| Technique | Algorithm | Visualizer | Source Directory | +| ------------- | ---------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------- | +| Edit Distance | Levenshtein Distance | DP matrix with source/target headers | `src/algorithms/strings/edit-distance/levenshtein-distance/` | +| Edit Distance | Jaro-Winkler Similarity | DP matrix with match window visualization | `src/algorithms/strings/edit-distance/jaro-winkler-similarity/` | +| Edit Distance | Longest Common Subsequence | DP matrix with diagonal path tracing | `src/algorithms/strings/edit-distance/longest-common-subsequence/` | +| Edit Distance | Longest Common Substring | DP matrix with contiguous diagonal path | `src/algorithms/strings/edit-distance/longest-common-substring/` | +| Edit Distance | Longest Repeated Substring | DP matrix (self-comparison) | `src/algorithms/strings/edit-distance/longest-repeated-substring/` | +| Edit Distance | Suffix Array Construction | DP matrix with suffix comparison display | `src/algorithms/strings/edit-distance/suffix-array-construction/` | +| Edit Distance | Wildcard Matching | DP matrix with ?/* pattern matching | `src/algorithms/strings/edit-distance/wildcard-matching/` | +| Edit Distance | Regular Expression Matching | DP matrix with ./* regex matching | `src/algorithms/strings/edit-distance/regex-matching/` | --- diff --git a/docs/architecture.md b/docs/architecture.md index 90b448e6..10564e39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,6 +50,11 @@ flowchart LR E -- "grid" --> H["GridVisualizer"] E -- "dp-table" --> I["DPTableVisualizer"] E -- "tree / linked-list
heap / stack-queue
hash-map / string
matrix / set" --> J["Other Visualizers"] + E -- "string-palindrome" --> K["PalindromeVisualizer"] + E -- "string-frequency" --> L["FrequencyVisualizer"] + E -- "string-transform" --> M["TransformVisualizer"] + E -- "string-trie" --> N["TrieVisualizer"] + E -- "string-distance" --> O["DistanceVisualizer"] ``` ## Core Pattern diff --git a/docs/contributing.md b/docs/contributing.md index a5d0d569..8600bbd3 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -265,7 +265,12 @@ export function generateMyAlgorithmSteps(input: number[]): ExecutionStep[] { | Heaps | `HeapTracker` | `initialize`, `compare`, `swap`, `siftDown`, `complete` | | Stacks & Queues | `StackQueueTracker` | `initialize`, `push`, `pop`, `check`, `complete` | | Hash Maps | `HashMapTracker` | `initialize`, `insert`, `lookup`, `found`, `complete` | -| Strings | `StringTracker` | `initialize`, `compare`, `match`, `buildTable`, `complete` | +| Strings (pattern) | `StringTracker` | `initialize`, `compareChars`, `charMatch`, `charMismatch`, `shiftPattern`, `buildFailure`, `complete` | +| Strings (palindrome) | `PalindromeTracker` | `initialize`, `setPointers`, `compareChars`, `charsMatch`, `charsMismatch`, `expandCenter`, `markPalindrome`, `complete` | +| Strings (frequency) | `FrequencyTracker` | `initialize`, `addToFrequency`, `removeFromFrequency`, `expandWindow`, `shrinkWindow`, `checkAnagram`, `markSatisfied`, `complete` | +| Strings (transform) | `TransformTracker` | `initialize`, `readChar`, `writeChar`, `swapChars`, `advancePointers`, `setPhase`, `appendOutput`, `complete` | +| Strings (trie) | `TrieTracker` | `initialize`, `createNode`, `traverseEdge`, `insertChar`, `markEndOfWord`, `searchChar`, `matchFound`, `addSuggestion`, `complete` | +| Strings (distance) | `DistanceTracker` | `initialize`, `computeCell`, `fillBaseCase`, `compareChars`, `recordOperation`, `tracePath`, `updateResult`, `complete` | | Matrices (traversal) | `MatrixTracker` | `initialize`, `visit`, `collect`, `updateBounds`, `complete` | | Matrices (transform) | `MatrixTransformTracker` | `initialize`, `swapCells`, `markCell`, `zeroCell`, `flipCell`, `complete` | | Matrices (search) | `MatrixSearchTracker` | `initialize`, `compareCell`, `markFound`, `eliminateRegion`, `visitCell`, `complete` | diff --git a/docs/deployment.md b/docs/deployment.md index 90a3312e..8c87be40 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -54,7 +54,7 @@ Triggers on all pull requests to `main`. Runs these jobs in parallel: | Job | What It Does | | ---------------------------- | ----------------------------------------------------------------------------------------------------- | | **Type Check & Lint** | `npm run typecheck`, `npm run lint`, `npm run format:check` | -| **Unit Tests** | `npm run test` — sharded 8 ways; results aggregated under the **Unit Tests Status** required check | +| **Unit Tests** | `npm run test` — sharded 10 ways; results aggregated under the **Unit Tests Status** required check | | **E2E Tests** | `npm run e2e` — sharded 12 ways (15-min timeout per shard); aggregated under the **E2E Status** check | | **Storybook Build** | `npm run storybook:build` | | **Visual Tests (Chromatic)** | Runs after Storybook build; requires `CHROMATIC_PROJECT_TOKEN` secret | diff --git a/docs/glossary.md b/docs/glossary.md index e42c1480..54031e70 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -115,24 +115,29 @@ Fields: A discriminated union that describes what the visualizer should render at a given step. The `kind` field determines which visualizer component handles the data and what other fields are present on the object. **Defined in:** `src/types/execution.ts` -**Used by:** the central dispatch logic that routes to the correct Visualizer component, and all 12 Visualizer components themselves. - -The 12 `kind` values are: - -| `kind` | Visualizer | -| ------------- | ---------------------------- | -| `array` | ArrayVisualizer | -| `graph` | GraphVisualizer | -| `grid` | GridVisualizer (pathfinding) | -| `dp-table` | DpTableVisualizer | -| `tree` | TreeVisualizer | -| `linked-list` | LinkedListVisualizer | -| `heap` | HeapVisualizer | -| `stack-queue` | StackQueueVisualizer | -| `hash-map` | HashMapVisualizer | -| `string` | StringVisualizer | -| `matrix` | MatrixVisualizer | -| `set` | SetVisualizer | +**Used by:** the central dispatch logic that routes to the correct Visualizer component, and all 17 Visualizer components themselves. + +The 17 `kind` values are: + +| `kind` | Visualizer | +| ------------------- | --------------------- | +| `array` | ArrayVisualizer | +| `graph` | GraphVisualizer | +| `grid` | GridVisualizer | +| `dp-table` | DpTableVisualizer | +| `tree` | TreeVisualizer | +| `linked-list` | LinkedListVisualizer | +| `heap` | HeapVisualizer | +| `stack-queue` | StackQueueVisualizer | +| `hash-map` | HashMapVisualizer | +| `string` | StringVisualizer | +| `string-palindrome` | PalindromeVisualizer | +| `string-frequency` | FrequencyVisualizer | +| `string-transform` | TransformVisualizer | +| `string-trie` | TrieVisualizer | +| `string-distance` | DistanceVisualizer | +| `matrix` | MatrixVisualizer | +| `set` | SetVisualizer | --- @@ -434,7 +439,7 @@ Slices: ### Visualizer -A generic React component that renders one kind of `VisualState`. There are 12 Visualizer components, one per `VisualState.kind`. A central dispatcher reads `kind` from the current step's `VisualState` and mounts the correct Visualizer. No Visualizer contains any algorithm-specific logic — they are purely driven by the data in `VisualState`, which means adding a new algorithm never requires touching any Visualizer. +A generic React component that renders one kind of `VisualState`. There are 17 Visualizer components, one per `VisualState.kind`. A central dispatcher reads `kind` from the current step's `VisualState` and mounts the correct Visualizer. No Visualizer contains any algorithm-specific logic — they are purely driven by the data in `VisualState`, which means adding a new algorithm never requires touching any Visualizer. **Location:** `src/components/` (one file per visualizer) **Used by:** the visualization panel, dispatched from the central visualizer router. diff --git a/docs/superpowers/specs/2026-04-05-strings-expansion-design.md b/docs/superpowers/specs/2026-04-05-strings-expansion-design.md new file mode 100644 index 00000000..8a2a0b03 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-strings-expansion-design.md @@ -0,0 +1,251 @@ +# Strings Algorithm Expansion Design Spec + +**Date:** 2026-04-05 +**Branch:** `feat/strings-expand-algorithms` +**Scope:** Expand from 1 to 38 string algorithms with 5 new VisualState kinds and tracker variants + +## Problem + +The strings category currently has only 1 algorithm (KMP Search) under a single technique (pattern-matching). All other categories have been expanded comprehensively (sorting: 54, stacks-queues: 28, arrays: 49, etc.). Strings needs equivalent coverage across multiple techniques. + +## Solution Overview + +Add 37 new string algorithms organized into 7 techniques, supported by 5 new VisualState kinds, 5 new tracker classes, and 5 new visualizer components. + +## VisualState Architecture + +### 1. `StringVisualState` (kind: `"string"`) — EXISTS +- **Fields:** textChars, patternChars, failureTable, patternOffset, textIndex, patternIndex, matchFound +- **Used by:** KMP Search, Naive Pattern Search, Rabin-Karp, Boyer-Moore, Z-Algorithm, Hamming Distance + +### 2. `PalindromeVisualState` (kind: `"string-palindrome"`) — NEW +- **Fields:** chars (StringChar[]), leftPointer (number), rightPointer (number), centerIndex (number | null), expandRadius (number), isPalindrome (boolean | null), longestStart (number), longestLength (number) +- **Used by:** Palindrome Check, Valid Palindrome, Longest Palindromic Substring + +### 3. `FrequencyVisualState` (kind: `"string-frequency"`) — NEW +- **Fields:** primaryChars (StringChar[]), secondaryChars (StringChar[]), frequencyMap (FrequencyEntry[]), windowStart (number), windowEnd (number), matchCount (number), resultIndices (number[]) +- **FrequencyEntry:** { char: string, count: number, targetCount: number, state: "default" | "partial" | "satisfied" | "excess" } +- **Used by:** Valid Anagram, Group Anagrams, Find All Anagrams, First Non-Repeating Character, Longest Substring Without Repeating, Minimum Window Substring, Character Frequency Sort + +### 4. `TransformVisualState` (kind: `"string-transform"`) — NEW +- **Fields:** inputChars (StringChar[]), outputChars (StringChar[]), readPointer (number), writePointer (number), phase (string), auxiliaryData (string | null) +- **Used by:** Reverse String, Reverse Words, String Compression, Run-Length Decoding, String to Integer, Roman to Integer, Integer to Roman, String Rotation Check, Longest Common Prefix + +### 5. `TrieVisualState` (kind: `"string-trie"`) — NEW +- **Fields:** nodes (TrieNode[]), edges (TrieEdge[]), currentPath (number[]), searchWord (StringChar[]), highlightedNodes (number[]), matchResult (boolean | null), suggestions (string[]) +- **TrieNode:** { id: number, char: string, isEnd: boolean, state: "default" | "current" | "matched" | "path" | "inserted" } +- **TrieEdge:** { from: number, to: number, char: string, state: "default" | "highlighted" | "traversed" } +- **Used by:** Trie Insert/Search, Trie Prefix Count, Longest Word in Trie, Auto-Complete, Aho-Corasick + +### 6. `DistanceVisualState` (kind: `"string-distance"`) — NEW +- **Fields:** sourceChars (StringChar[]), targetChars (StringChar[]), matrix (DistanceCell[][]), currentRow (number), currentCol (number), operations (EditOperation[]), result (number | null) +- **DistanceCell:** { value: number, state: "default" | "computing" | "computed" | "path" | "current" } +- **EditOperation:** { type: "insert" | "delete" | "replace" | "match", sourceIdx: number, targetIdx: number } +- **Used by:** Levenshtein Distance, Jaro-Winkler, LCS, Longest Repeated Substring, Suffix Array, Wildcard Matching, Regex Matching, Longest Common Substring + +## Algorithm List by Technique + +### Pattern Matching (6 total: 1 existing + 5 new) + +| # | Algorithm | Technique | Time | Space | +|---|-----------|-----------|------|-------| +| 1 | KMP Search (exists) | Failure table | O(n+m) | O(m) | +| 2 | Naive Pattern Search | Brute force | O(nm) | O(1) | +| 3 | Rabin-Karp Search | Rolling hash | O(n+m) avg | O(1) | +| 4 | Boyer-Moore Search | Bad char + good suffix | O(n/m) best | O(m+σ) | +| 5 | Z-Algorithm | Z-array | O(n+m) | O(n+m) | +| 6 | Hamming Distance | Positional comparison | O(n) | O(1) | + +### Palindrome (3 new) + +| # | Algorithm | Technique | Time | Space | +|---|-----------|-----------|------|-------| +| 7 | Palindrome Check | Two-pointer | O(n) | O(1) | +| 8 | Valid Palindrome | Filter + two-pointer | O(n) | O(1) | +| 9 | Longest Palindromic Substring | Expand around center | O(n²) | O(1) | + +### Character Frequency (7 new) + +| # | Algorithm | Technique | Time | Space | +|---|-----------|-----------|------|-------| +| 10 | Valid Anagram | Frequency counting | O(n) | O(1) | +| 11 | Group Anagrams | Sorted key hash | O(n·k·log k) | O(nk) | +| 12 | Find All Anagrams in String | Sliding window + freq | O(n) | O(1) | +| 13 | First Non-Repeating Character | Frequency scan | O(n) | O(1) | +| 14 | Longest Substring Without Repeating | Sliding window + set | O(n) | O(min(n,σ)) | +| 15 | Minimum Window Substring | Sliding window + freq | O(n+m) | O(σ) | +| 16 | Character Frequency Sort | Bucket sort | O(n) | O(n) | + +### String Transformation (9 new) + +| # | Algorithm | Technique | Time | Space | +|---|-----------|-----------|------|-------| +| 17 | Reverse String | Two-pointer swap | O(n) | O(1) | +| 18 | Reverse Words in String | Reverse all + each | O(n) | O(n) | +| 19 | String Compression | Run-length encoding | O(n) | O(n) | +| 20 | Run-Length Decoding | Expand encoding | O(output) | O(output) | +| 21 | String to Integer (atoi) | Parse + overflow | O(n) | O(1) | +| 22 | Roman to Integer | Symbol subtraction | O(n) | O(1) | +| 23 | Integer to Roman | Greedy decomposition | O(1) | O(1) | +| 24 | String Rotation Check | Concatenation | O(n) | O(n) | +| 25 | Longest Common Prefix | Vertical scanning | O(n·m) | O(1) | + +### Trie Operations (5 new) + +| # | Algorithm | Technique | Time | Space | +|---|-----------|-----------|------|-------| +| 26 | Trie Insert and Search | Trie traversal | O(m) | O(m) | +| 27 | Trie Prefix Count | Prefix tree counting | O(m) | O(nm) | +| 28 | Longest Word in Trie | DFS + trie | O(nm) | O(nm) | +| 29 | Auto-Complete with Trie | Prefix DFS | O(m+k) | O(nm) | +| 30 | Aho-Corasick Search | Trie automaton | O(n+m+z) | O(mk) | + +### Edit Distance & Similarity (8 new) + +| # | Algorithm | Technique | Time | Space | +|---|-----------|-----------|------|-------| +| 31 | Levenshtein Distance | DP matrix | O(nm) | O(nm) | +| 32 | Jaro-Winkler Similarity | Window matching | O(nm) | O(n) | +| 33 | Longest Common Subsequence | DP matrix | O(nm) | O(nm) | +| 34 | Longest Common Substring | DP matrix | O(nm) | O(nm) | +| 35 | Longest Repeated Substring | Suffix array + LCP | O(n log n) | O(n) | +| 36 | Suffix Array Construction | Sort suffixes | O(n log²n) | O(n) | +| 37 | Wildcard Matching | DP table | O(nm) | O(nm) | +| 38 | Regular Expression Matching | DP table | O(nm) | O(nm) | + +## Tracker Classes + +### PalindromeTracker (`src/trackers/palindrome-tracker.ts`) +Methods: initialize, setPointers, compareChars, charsMatch, charsMismatch, expandCenter, markPalindrome, updateLongest, skipNonAlphanumeric, complete + +### FrequencyTracker (`src/trackers/frequency-tracker.ts`) +Methods: initialize, addToFrequency, removeFromFrequency, expandWindow, shrinkWindow, checkAnagram, markSatisfied, markNonRepeating, addToResult, complete + +### TransformTracker (`src/trackers/transform-tracker.ts`) +Methods: initialize, readChar, writeChar, swapChars, advancePointers, setPhase, appendOutput, markConverted, complete + +### TrieTracker (`src/trackers/trie-tracker.ts`) +Methods: initialize, createNode, traverseEdge, insertChar, markEndOfWord, searchChar, matchFound, addSuggestion, buildFailureLinks, complete + +### DistanceTracker (`src/trackers/distance-tracker.ts`) +Methods: initialize, computeCell, fillBaseCase, compareChars, recordOperation, tracePath, updateResult, complete + +## Visualizer Components + +Each new VisualState kind requires a corresponding React visualizer component: + +1. `PalindromeVisualizer` — Renders string with animated left/right pointers, center expansion arcs +2. `FrequencyVisualizer` — Renders string(s) with sliding window markers + frequency histogram +3. `TransformVisualizer` — Renders input/output strings side by side with transformation arrows +4. `TrieVisualizer` — Renders trie as tree diagram with highlighted paths and edge labels +5. `DistanceVisualizer` — Renders DP matrix grid with string headers and traced path + +## File Structure Per Algorithm + +``` +src/algorithms/strings/// +├── index.ts # Registry definition (~50 lines) +├── step-generator.ts # Step generation (~80-200 lines) +├── educational.ts # Educational content (~60 lines) +├── .test.ts # Correctness tests (~50 lines) +├── step-generator.test.ts # Step generation tests (~60 lines) +├── Pipeline.stories.tsx # Storybook story (~50 lines) +└── sources/ + ├── .ts # TypeScript source (~40 lines) + ├── .py # Python source (~35 lines) + └── .java # Java source (~45 lines) +``` + +## E2E & CI Updates + +### E2E Inputs (`e2e/helpers/inputs.ts`) +Add input test handlers for all 37 new algorithms with appropriate test data. + +### E2E Spec (`e2e/specs/algorithms/strings.spec.ts`) +No changes needed — dynamic discovery picks up new algorithms automatically. + +### CI Shards (`.github/workflows/ci.yml` and `.github/workflows/deploy.yml`) +Update unit test and e2e shard counts in both CI and deploy workflows based on final test counts. Both files have independent shard matrices that must be kept in sync. + +## Implementation Phases (Sequential) + +### Phase 1: Types & Infrastructure +- Add 5 new VisualState interfaces to `src/types/execution.ts` +- Update `VisualState` discriminated union +- Export new types from `src/types/index.ts` +- Add new StepType values for new operations + +### Phase 2: Trackers +- Implement 5 new tracker classes extending BaseTracker +- Export from `src/trackers/index.ts` + +### Phase 3: Visualizer Components +- Implement 5 new visualizer components +- Register in visualization switcher component + +### Phase 4: Pattern Matching Algorithms (5 new) +- Implement each algorithm with all 9 files +- Uses existing StringTracker + +### Phase 5: Palindrome Algorithms (3 new) +- Implement each algorithm with all 9 files +- Uses PalindromeTracker + +### Phase 6: Character Frequency Algorithms (7 new) +- Implement each algorithm with all 9 files +- Uses FrequencyTracker + +### Phase 7: String Transformation Algorithms (9 new) +- Implement each algorithm with all 9 files +- Uses TransformTracker + +### Phase 8: Trie Operations (5 new) +- Implement each algorithm with all 9 files +- Uses TrieTracker + +### Phase 9: Edit Distance & Similarity (8 new) +- Implement each algorithm with all 9 files +- Uses DistanceTracker + +### Phase 10: Barrel & Integration Updates +- Update `fn-import.d.ts` with 37 new function declarations +- Update `e2e/helpers/inputs.ts` with test data for all algorithms +- Verify auto-discovery glob pattern covers all nested paths + +### Phase 11: Code Review +- code-reviewer agent reviews all implementations +- Fix any issues found + +### Phase 12: QA Testing +- qa-tester agent runs all tests +- Validates no regressions in existing code + +### Phase 13: MCP Browser Preview +- Open browser and navigate all new algorithms +- Verify visualizations render correctly +- Fix any visual issues + +### Phase 14: Quality Gate & CI Update +- Run: lint → format → typecheck → vitest → e2e +- Update shard counts in both `.github/workflows/ci.yml` and `.github/workflows/deploy.yml` based on total test numbers +- Fix any failures + +## Model Assignment + +| Phase | Model | Rationale | +|-------|-------|-----------| +| Planning & Architecture | Opus | Complex design decisions | +| Type definitions & trackers | Sonnet | Implementation code | +| Visualizer components | Sonnet | React component code | +| Algorithm implementation | Sonnet | Bulk implementation | +| Code review | Opus | Quality assessment | +| QA testing | Opus | Test analysis | +| Quality gate fixes | Sonnet | Bug fixes | + +## Quality Rules + +- No files over 500 lines — split into modules if needed +- No TypeScript `any` — use `unknown` with narrowing or proper types +- Code comments required for file purpose and significant blocks +- Fix typecheck issues, missing imports, name conflicts immediately +- All lint, typecheck, prettier, vitest, e2e deferred to final quality gate diff --git a/docs/testing.md b/docs/testing.md index 80a8f1b7..bd17b618 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -20,7 +20,7 @@ npm run test:coverage # Run with coverage report npm run test:watch # Watch mode during development ``` -Tests cover algorithm correctness, step generation, tracker behavior, and store state transitions across all 284 algorithms in 14 categories. +Tests cover algorithm correctness, step generation, tracker behavior, and store state transitions across all 367 algorithms in 14 categories. ### Vitest Projects Configuration @@ -204,7 +204,7 @@ npm run chromatic # Run Chromatic visual tests | **Input Editor** | `src/components/input-editor/` | ArrayInputEditor, InputEditor | | **Explanation Panel** | `src/components/explanation-panel/` | ExplanationPanel | | **Playback** | `src/components/playback/` | PlaybackControls | -| **Algorithm Pipelines** | `src/algorithms///` | 284 algorithm pipelines — initial, mid-execution, and final states using real step generators | +| **Algorithm Pipelines** | `src/algorithms///` | 367 algorithm pipelines — initial, mid-execution, and final states using real step generators | Pipeline stories (`*.Pipeline.stories.tsx`) live alongside their algorithm implementation, not with the visualizer components. Component stories remain co-located with their components in `src/components/`. diff --git a/e2e/helpers/inputs.ts b/e2e/helpers/inputs.ts index d6c4328f..9b6a548c 100644 --- a/e2e/helpers/inputs.ts +++ b/e2e/helpers/inputs.ts @@ -542,4 +542,209 @@ export const inputTests: InputTest[] = [ await page.keyboard.press("Tab"); }, }, + { + algo: "Naive Pattern Search", + test: async (page) => { + await fillNthTextInput(page, 0, "AABAACAADAABAABA"); + await fillNthTextInput(page, 1, "AABA"); + }, + }, + { + algo: "Rabin-Karp Search", + test: async (page) => { + await fillNthTextInput(page, 0, "GEEKS FOR GEEKS"); + await fillNthTextInput(page, 1, "GEEK"); + }, + }, + { + algo: "Boyer-Moore Search", + test: async (page) => { + await fillNthTextInput(page, 0, "ABAAABCD"); + await fillNthTextInput(page, 1, "ABC"); + }, + }, + { + algo: "Z-Algorithm", + test: async (page) => { + await fillNthTextInput(page, 0, "AABXAABXCAABXAABXAY"); + await fillNthTextInput(page, 1, "AABXAAB"); + }, + }, + { + algo: "Hamming Distance", + test: async (page) => { + await fillNthTextInput(page, 0, "karolin"); + await fillNthTextInput(page, 1, "kathrin"); + }, + }, + { + algo: "Palindrome Check", + test: (page) => fillTextInput(page, "racecar"), + }, + { + algo: "Valid Palindrome", + test: (page) => fillTextInput(page, "A man, a plan, a canal: Panama"), + }, + { + algo: "Longest Palindromic Substring", + test: (page) => fillTextInput(page, "babad"), + }, + { + algo: "Valid Anagram", + test: async (page) => { + await fillNthTextInput(page, 0, "anagram"); + await fillNthTextInput(page, 1, "nagaram"); + }, + }, + { + algo: "Group Anagrams", + test: (page) => fillTextInput(page, "eat,tea,tan,ate,nat,bat"), + }, + { + algo: "Find All Anagrams", + test: async (page) => { + await fillNthTextInput(page, 0, "cbaebabacd"); + await fillNthTextInput(page, 1, "abc"); + }, + }, + { + algo: "First Non-Repeating Character", + test: (page) => fillTextInput(page, "leetcode"), + }, + { + algo: "Longest Substring Without Repeating", + test: (page) => fillTextInput(page, "abcabcbb"), + }, + { + algo: "Minimum Window Substring", + test: async (page) => { + await fillNthTextInput(page, 0, "ADOBECODEBANC"); + await fillNthTextInput(page, 1, "ABC"); + }, + }, + { + algo: "Character Frequency Sort", + test: (page) => fillTextInput(page, "tree"), + }, + { + algo: "Reverse String", + test: (page) => fillTextInput(page, "hello"), + }, + { + algo: "Reverse Words in a String", + test: (page) => fillTextInput(page, "the sky is blue"), + }, + { + algo: "String Compression", + test: (page) => fillTextInput(page, "aabcccccaaa"), + }, + { + algo: "Run-Length Decoding", + test: (page) => fillTextInput(page, "3a2b4c"), + }, + { + algo: "String to Integer (atoi)", + test: (page) => fillTextInput(page, " -42"), + }, + { + algo: "Roman to Integer", + test: (page) => fillTextInput(page, "MCMXCIV"), + }, + { + algo: "Integer to Roman", + test: (page) => fillNumberInput(page, 1994), + }, + { + algo: "String Rotation Check", + test: async (page) => { + await fillNthTextInput(page, 0, "waterbottle"); + await fillNthTextInput(page, 1, "erbottlewat"); + }, + }, + { + algo: "Longest Common Prefix", + test: (page) => fillTextInput(page, "flower,flow,flight"), + }, + { + algo: "Trie Insert & Search", + test: async (page) => { + await fillNthTextInput(page, 0, "apple,app,application,apply,apt"); + await fillNthTextInput(page, 1, "app"); + }, + }, + { + algo: "Trie Prefix Count", + test: async (page) => { + await fillNthTextInput(page, 0, "apple,app,application,apply,apt"); + await fillNthTextInput(page, 1, "ap"); + }, + }, + { + algo: "Longest Word in Trie", + test: (page) => fillTextInput(page, "apple,app,application,apply,apt"), + }, + { + algo: "Auto-Complete with Trie", + test: async (page) => { + await fillNthTextInput(page, 0, "apple,app,application,apply,apt"); + await fillNthTextInput(page, 1, "ap"); + }, + }, + { + algo: "Aho-Corasick Search", + test: async (page) => { + await fillNthTextInput(page, 0, "ahishers"); + await fillNthTextInput(page, 1, "he,she,his,hers"); + }, + }, + { + algo: "Levenshtein Distance", + test: async (page) => { + await fillNthTextInput(page, 0, "kitten"); + await fillNthTextInput(page, 1, "sitting"); + }, + }, + { + algo: "Jaro-Winkler Similarity", + test: async (page) => { + await fillNthTextInput(page, 0, "martha"); + await fillNthTextInput(page, 1, "marhta"); + }, + }, + { + algo: "Longest Common Subsequence", + test: async (page) => { + await fillNthTextInput(page, 0, "ABCBDAB"); + await fillNthTextInput(page, 1, "BDCAB"); + }, + }, + { + algo: "Longest Common Substring", + test: async (page) => { + await fillNthTextInput(page, 0, "ABABC"); + await fillNthTextInput(page, 1, "BABCBA"); + }, + }, + { + algo: "Longest Repeated Substring", + test: (page) => fillTextInput(page, "banana"), + }, + { + algo: "Suffix Array Construction", + test: (page) => fillTextInput(page, "banana"), + }, + { + algo: "Wildcard Matching", + test: async (page) => { + await fillNthTextInput(page, 0, "adceb"); + await fillNthTextInput(page, 1, "*a*b"); + }, + }, + { + algo: "Regular Expression Matching", + test: async (page) => { + await fillNthTextInput(page, 0, "aab"); + await fillNthTextInput(page, 1, "c*a*b"); + }, + }, ]; diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/CharacterFrequencySortPipeline.stories.tsx b/src/algorithms/strings/character-frequency/character-frequency-sort/CharacterFrequencySortPipeline.stories.tsx new file mode 100644 index 00000000..fe111d75 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/CharacterFrequencySortPipeline.stories.tsx @@ -0,0 +1,75 @@ +/** + * Storybook stories for the Character Frequency Sort algorithm pipeline. + * Uses the real step generator with varied inputs, + * rendering the FrequencyVisualizer at key algorithm states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { FrequencyVisualState } from "@/types"; +import { generateCharacterFrequencySortSteps } from "./step-generator"; +import FrequencyVisualizer from "@/components/visualization/FrequencyVisualizer"; + +const defaultSteps = generateCharacterFrequencySortSteps({ text: "tree" }); + +const longerSteps = generateCharacterFrequencySortSteps({ text: "programming" }); + +const tiedFrequencySteps = generateCharacterFrequencySortSteps({ text: "cccaaa" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Character Frequency Sort", + component: FrequencyVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty frequency map, input string shown */ +export const Initial: Story = { + args: { + visualState: defaultSteps[0]!.visualState as FrequencyVisualState, + }, +}; + +/** Mid-count — frequency map partially populated from input */ +export const FrequencyMapBuilding: Story = { + args: { + visualState: defaultSteps[Math.floor(defaultSteps.length * 0.4)]! + .visualState as FrequencyVisualState, + }, +}; + +/** Sort phase — all frequencies counted, buckets assigned */ +export const SortPhase: Story = { + args: { + visualState: defaultSteps[Math.floor(defaultSteps.length * 0.65)]! + .visualState as FrequencyVisualState, + }, +}; + +/** Complete — output fully built from high-frequency to low-frequency */ +export const Complete: Story = { + args: { + visualState: defaultSteps[defaultSteps.length - 1]!.visualState as FrequencyVisualState, + }, +}; + +/** Longer input — "programming" with multiple repeated characters */ +export const LongerInput: Story = { + args: { + visualState: longerSteps[longerSteps.length - 1]!.visualState as FrequencyVisualState, + }, +}; + +/** Tied frequencies — "cccaaa" where both characters appear 3 times */ +export const TiedFrequencies: Story = { + args: { + visualState: tiedFrequencySteps[tiedFrequencySteps.length - 1]! + .visualState as FrequencyVisualState, + }, +}; diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/character-frequency-sort.test.ts b/src/algorithms/strings/character-frequency/character-frequency-sort/character-frequency-sort.test.ts new file mode 100644 index 00000000..d69b2671 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/character-frequency-sort.test.ts @@ -0,0 +1,73 @@ +/** Correctness tests for the Character Frequency Sort algorithm. */ + +import { describe, it, expect } from "vitest"; +import { characterFrequencySort } from "./sources/character-frequency-sort.ts?fn"; + +describe("characterFrequencySort", () => { + it("returns empty string for empty input", () => { + expect(characterFrequencySort("")).toBe(""); + }); + + it("sorts 'tree' so the most frequent character appears first", () => { + const result = characterFrequencySort("tree") as string; + expect(result.startsWith("ee")).toBe(true); + expect(result).toHaveLength(4); + }); + + it("sorts 'cccaaa' — two characters with equal frequency both appear grouped", () => { + const result = characterFrequencySort("cccaaa") as string; + // Both 'c' and 'a' appear 3 times; each must appear as a contiguous block of 3 + expect(result).toHaveLength(6); + expect(result.slice(0, 3) === "ccc" || result.slice(0, 3) === "aaa").toBe(true); + expect(result.slice(3, 6) === "ccc" || result.slice(3, 6) === "aaa").toBe(true); + expect(result.slice(0, 3)).not.toBe(result.slice(3, 6)); + }); + + it("sorts 'aab' — 'a' appears twice so it comes first", () => { + const result = characterFrequencySort("aab") as string; + expect(result.startsWith("aa")).toBe(true); + expect(result).toHaveLength(3); + }); + + it("handles a single character", () => { + expect(characterFrequencySort("z")).toBe("z"); + }); + + it("handles a string where all characters are the same", () => { + expect(characterFrequencySort("aaaa")).toBe("aaaa"); + }); + + it("preserves all characters — output is a rearrangement of input", () => { + const inputText = "programming"; + const result = characterFrequencySort(inputText) as string; + expect(result).toHaveLength(inputText.length); + // Every character in input must appear in output with same count + for (const char of new Set(inputText)) { + const inputCount = [...inputText].filter((ch) => ch === char).length; + const outputCount = [...result].filter((ch) => ch === char).length; + expect(outputCount).toBe(inputCount); + } + }); + + it("places the highest-frequency character at the start for 'eeebba'", () => { + const result = characterFrequencySort("eeebba") as string; + // 'e' appears 3 times, must come first + expect(result.startsWith("eee")).toBe(true); + }); + + it("groups each character into a contiguous block in the output", () => { + const result = characterFrequencySort("aabbcc") as string; + // Each char appears exactly twice; output must have 3 contiguous blocks of 2 + expect(result).toHaveLength(6); + for (let blockStart = 0; blockStart < 6; blockStart += 2) { + expect(result[blockStart]).toBe(result[blockStart + 1]); + } + }); + + it("sorts 'Aabb' treating uppercase and lowercase as distinct characters", () => { + const result = characterFrequencySort("Aabb") as string; + // 'b' appears twice, 'A' and 'a' appear once each + expect(result.startsWith("bb")).toBe(true); + expect(result).toHaveLength(4); + }); +}); diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/educational.ts b/src/algorithms/strings/character-frequency/character-frequency-sort/educational.ts new file mode 100644 index 00000000..621db791 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/educational.ts @@ -0,0 +1,60 @@ +/** Educational content for Character Frequency Sort — all 7 required sections. */ + +import type { EducationalContent } from "@/types"; + +export const characterFrequencySortEducational: EducationalContent = { + overview: + "**Character Frequency Sort** rearranges a string so that characters appearing most often come first, with ties resolved in any consistent order.\n\n" + + "For example, `\"tree\"` becomes `\"eert\"` or `\"eetr\"` because `'e'` appears twice while `'t'` and `'r'` each appear once.\n\n" + + "The algorithm uses a **frequency map** to count occurrences, then a **bucket sort** indexed by frequency to reconstruct the output in O(n) time — avoiding the O(n log n) cost of comparison-based sorting.", + + howItWorks: + "The algorithm runs in three phases:\n\n" + + "**Phase 1 — Count character frequencies** (O(n)):\n\n" + + "Iterate over every character in the input string and record how many times each character appears:\n\n" + + '```\ntext = "tree"\nfrequencyMap = { t:1, r:1, e:2 }\n```\n\n' + + "**Phase 2 — Bucket sort by frequency** (O(n)):\n\n" + + "Create an array of buckets where `buckets[freq]` holds all characters that appear `freq` times. The maximum possible frequency is `n` (all characters the same):\n\n" + + "```\nbuckets[1] = ['t', 'r']\nbuckets[2] = ['e']\n```\n\n" + + "**Phase 3 — Rebuild output from high to low frequency** (O(n)):\n\n" + + "Walk the buckets array from index `n` down to `1`. For each character in each bucket, append it to the result `freq` times:\n\n" + + '```\nresult = "ee" + "t" + "r" = "eetr"\n```\n\n' + + "Because bucket sort never compares characters against each other, the whole algorithm runs in linear time.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "All three phases iterate over the input or the frequency table exactly once. The bucket sort sweep is bounded by the input length `n`, not the alphabet size — even with a large Unicode alphabet, only buckets with content are visited.\n\n" + + "**Space Complexity: `O(n)`**\n\n" + + "The frequency map holds at most as many entries as there are distinct characters (at most `n`). The bucket array has `n + 1` slots. The output string is length `n`. All three scale linearly with input size.", + + bestAndWorstCase: + "**Best case** — `O(n)`: the input is empty or contains only one distinct character. The frequency map has a single entry and the bucket sweep terminates immediately at the highest bucket.\n\n" + + "**Worst case** — `O(n)`: all characters are distinct. The frequency map has `n` entries, all in bucket `[1]`, and the rebuild phase appends each character once — still linear.\n\n" + + "Unlike comparison-based sorts, there is no logarithmic factor: the algorithm is strictly `O(n)` in all cases.", + + realWorldUses: [ + "**Data compression:** Huffman encoding requires characters sorted by frequency; this algorithm provides the sorted order as a preprocessing step.", + "**Text analysis:** Ranking characters by prevalence surfaces the most-used letters in a document or codebase.", + "**Lossless run-length encoding:** Grouping repeated characters together maximises run lengths before RLE compression.", + "**Game word builders:** Sorting available tiles by frequency helps heuristics that prioritize placing the most abundant letters first.", + "**Cache-friendly encoding:** Assigning shorter bit patterns to higher-frequency characters (Huffman, Shannon-Fano) requires this sort as a prerequisite.", + ], + + strengthsAndLimitations: { + strengths: [ + "True O(n) time — bucket sort eliminates the O(n log n) lower bound of comparison-based sorting.", + "Simple implementation: two passes and one linear sweep over the bucket array.", + "Works for any character set (ASCII, Unicode) by using a hash map rather than a fixed-size array.", + ], + limitations: [ + "Uses O(n) extra space for the frequency map, bucket array, and output string — not an in-place algorithm.", + "Output is not lexicographically stable for characters with equal frequency; tie-breaking order depends on map iteration order.", + "For very short strings, the constant factors of hashing may outweigh the theoretical advantage over O(n log n) comparison sorts.", + ], + }, + + whenToUseIt: + "Use Character Frequency Sort whenever you need to reorder a string by character frequency and O(n) time is required or preferred.\n\n" + + "It is the canonical solution for LeetCode 451 ('Sort Characters By Frequency') and similar interview problems. Prefer it over `Array.sort` on character pairs when the input can be large.\n\n" + + "Avoid it when you also need lexicographic tie-breaking within the same frequency tier, since the bucket sweep does not guarantee any specific order among equal-frequency characters without an additional sort pass.", +}; diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/index.ts b/src/algorithms/strings/character-frequency/character-frequency-sort/index.ts new file mode 100644 index 00000000..2bbf5505 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Character Frequency Sort — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { characterFrequencySort } from "./sources/character-frequency-sort.ts?fn"; +import { generateCharacterFrequencySortSteps } from "./step-generator"; +import type { CharacterFrequencySortInput } from "./step-generator"; +import { characterFrequencySortEducational } from "./educational"; + +import typescriptSource from "./sources/character-frequency-sort.ts?raw"; +import pythonSource from "./sources/character-frequency-sort.py?raw"; +import javaSource from "./sources/CharacterFrequencySort.java?raw"; + +function executeCharacterFrequencySort(input: CharacterFrequencySortInput): string { + return characterFrequencySort(input.text) as string; +} + +const characterFrequencySortDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.CHARACTER_FREQUENCY_SORT!, + name: "Character Frequency Sort", + category: CATEGORY.STRINGS!, + technique: "character-frequency", + description: + "Sort a string so characters with higher frequencies appear first, using bucket sort for O(n) time without comparison-based sorting", + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(n)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "tree" }, + }, + execute: executeCharacterFrequencySort, + generateSteps: generateCharacterFrequencySortSteps, + educational: characterFrequencySortEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(characterFrequencySortDefinition); diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/sources/CharacterFrequencySort.java b/src/algorithms/strings/character-frequency/character-frequency-sort/sources/CharacterFrequencySort.java new file mode 100644 index 00000000..f6648434 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/sources/CharacterFrequencySort.java @@ -0,0 +1,43 @@ +// Character Frequency Sort +// Sorts a string by character frequency (descending) using bucket sort. +// Time: O(n) where n = length of text (bucket sort avoids O(n log n) comparison sort) +// Space: O(n) — frequency map and output string both scale with input size + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CharacterFrequencySort { + + public static String characterFrequencySort(String text) { + if (text.isEmpty()) return ""; // @step:initialize + + Map frequencyMap = new HashMap<>(); // @step:initialize + + for (char charVal : text.toCharArray()) { // @step:update-frequency + frequencyMap.put(charVal, frequencyMap.getOrDefault(charVal, 0) + 1); // @step:update-frequency + } + + // Bucket sort: index = frequency, value = list of chars with that frequency + int maxFrequency = text.length(); // @step:sort-by-frequency + @SuppressWarnings("unchecked") + List[] buckets = new ArrayList[maxFrequency + 1]; // @step:sort-by-frequency + for (int bucketIdx = 0; bucketIdx <= maxFrequency; bucketIdx++) { // @step:sort-by-frequency + buckets[bucketIdx] = new ArrayList<>(); // @step:sort-by-frequency + } + + for (Map.Entry entry : frequencyMap.entrySet()) { // @step:sort-by-frequency + buckets[entry.getValue()].add(entry.getKey()); // @step:sort-by-frequency + } + + StringBuilder result = new StringBuilder(); // @step:build-output + for (int freqIdx = maxFrequency; freqIdx >= 1; freqIdx--) { // @step:build-output + for (char charVal : buckets[freqIdx]) { // @step:add-to-result + result.append(String.valueOf(charVal).repeat(freqIdx)); // @step:add-to-result + } + } + + return result.toString(); // @step:complete + } +} diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.py b/src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.py new file mode 100644 index 00000000..9b8e0f15 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.py @@ -0,0 +1,28 @@ +# Character Frequency Sort +# Sorts a string by character frequency (descending) using bucket sort. +# Time: O(n) where n = length of text (bucket sort avoids O(n log n) comparison sort) +# Space: O(n) — frequency map and output string both scale with input size + + +def character_frequency_sort(text: str) -> str: + if not text: # @step:initialize + return "" + + frequency_map: dict[str, int] = {} # @step:initialize + + for char in text: # @step:update-frequency + frequency_map[char] = frequency_map.get(char, 0) + 1 # @step:update-frequency + + # Bucket sort: index = frequency, value = list of chars with that frequency + max_frequency = len(text) # @step:sort-by-frequency + buckets: list[list[str]] = [[] for _ in range(max_frequency + 1)] # @step:sort-by-frequency + + for char, freq in frequency_map.items(): # @step:sort-by-frequency + buckets[freq].append(char) # @step:sort-by-frequency + + result = "" # @step:build-output + for freq_idx in range(max_frequency, 0, -1): # @step:build-output + for char in buckets[freq_idx]: # @step:add-to-result + result += char * freq_idx # @step:add-to-result + + return result # @step:complete diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.ts b/src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.ts new file mode 100644 index 00000000..a8d77d37 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/sources/character-frequency-sort.ts @@ -0,0 +1,35 @@ +// Character Frequency Sort +// Sorts a string by character frequency (descending) using bucket sort. +// Time: O(n) where n = length of text (bucket sort avoids O(n log n) comparison sort) +// Space: O(n) — frequency map and output string both scale with input size + +export function characterFrequencySort(text: string): string { + if (text.length === 0) return ""; // @step:initialize + + const frequencyMap = new Map(); // @step:initialize + + for (const char of text) { + // @step:update-frequency + frequencyMap.set(char, (frequencyMap.get(char) ?? 0) + 1); // @step:update-frequency + } + + // Bucket sort: index = frequency, value = list of chars with that frequency + const maxFrequency = text.length; // @step:sort-by-frequency + const buckets: string[][] = Array.from({ length: maxFrequency + 1 }, () => []); // @step:sort-by-frequency + + for (const [char, freq] of frequencyMap) { + // @step:sort-by-frequency + buckets[freq]!.push(char); // @step:sort-by-frequency + } + + let result = ""; // @step:build-output + for (let freqIdx = maxFrequency; freqIdx >= 1; freqIdx--) { + // @step:build-output + for (const char of buckets[freqIdx]!) { + // @step:add-to-result + result += char.repeat(freqIdx); // @step:add-to-result + } + } + + return result; // @step:complete +} diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.test.ts b/src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.test.ts new file mode 100644 index 00000000..81191abd --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.test.ts @@ -0,0 +1,82 @@ +/** Step generation tests for Character Frequency Sort — verifies step types and visual state. */ + +import { describe, it, expect } from "vitest"; +import { generateCharacterFrequencySortSteps } from "./step-generator"; + +describe("generateCharacterFrequencySortSteps", () => { + it("produces steps for the default input", () => { + const steps = generateCharacterFrequencySortSteps({ text: "tree" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateCharacterFrequencySortSteps({ text: "tree" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateCharacterFrequencySortSteps({ text: "tree" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-frequency visual states throughout", () => { + const steps = generateCharacterFrequencySortSteps({ text: "tree" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-frequency"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateCharacterFrequencySortSteps({ text: "tree" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits update-frequency steps equal to text.length for the counting phase", () => { + const inputText = "tree"; + const steps = generateCharacterFrequencySortSteps({ text: inputText }); + const frequencySteps = steps.filter((step) => step.type === "update-frequency"); + expect(frequencySteps.length).toBe(inputText.length); + }); + + it("emits add-to-result steps equal to the number of distinct characters", () => { + const steps = generateCharacterFrequencySortSteps({ text: "aabbcc" }); + // 3 distinct chars: 'a', 'b', 'c' + const resultSteps = steps.filter((step) => step.type === "add-to-result"); + expect(resultSteps.length).toBe(3); + }); + + it("emits only initialize and complete for empty input", () => { + const steps = generateCharacterFrequencySortSteps({ text: "" }); + expect(steps).toHaveLength(2); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[1]?.type).toBe("complete"); + }); + + it("emits a single add-to-result step when all characters are the same", () => { + const steps = generateCharacterFrequencySortSteps({ text: "aaaa" }); + const resultSteps = steps.filter((step) => step.type === "add-to-result"); + expect(resultSteps.length).toBe(1); + }); + + it("emits a compare step for the sort-by-frequency phase", () => { + const steps = generateCharacterFrequencySortSteps({ text: "tree" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("produces string-frequency kind for single-character input", () => { + const steps = generateCharacterFrequencySortSteps({ text: "z" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-frequency"); + } + }); + + it("emits update-frequency steps equal to text.length for a longer string", () => { + const inputText = "programming"; + const steps = generateCharacterFrequencySortSteps({ text: inputText }); + const frequencySteps = steps.filter((step) => step.type === "update-frequency"); + expect(frequencySteps.length).toBe(inputText.length); + }); +}); diff --git a/src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.ts b/src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.ts new file mode 100644 index 00000000..0d66b0e0 --- /dev/null +++ b/src/algorithms/strings/character-frequency/character-frequency-sort/step-generator.ts @@ -0,0 +1,73 @@ +/** Step generator for Character Frequency Sort — produces ExecutionStep[] using FrequencyTracker. */ + +import type { ExecutionStep } from "@/types"; +import { FrequencyTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const CHARACTER_FREQUENCY_SORT_LINE_MAP = buildLineMapFromSources( + ALGORITHM_ID.CHARACTER_FREQUENCY_SORT!, +); + +export interface CharacterFrequencySortInput { + text: string; +} + +export function generateCharacterFrequencySortSteps( + input: CharacterFrequencySortInput, +): ExecutionStep[] { + const { text } = input; + const tracker = new FrequencyTracker(text, "", CHARACTER_FREQUENCY_SORT_LINE_MAP); + + // Initialize — capture starting state + tracker.initialize({ text, length: text.length }); + + if (text.length === 0) { + tracker.complete({ result: "", outputLength: 0 }); + return tracker.getSteps(); + } + + // Phase 1: Build frequency map — count each character + const localFrequencyMap = new Map(); + for (let charIdx = 0; charIdx < text.length; charIdx++) { + const char = text[charIdx]!; + localFrequencyMap.set(char, (localFrequencyMap.get(char) ?? 0) + 1); + tracker.addToFrequency(char, { charIdx, char, phase: "count" }); + } + + // Phase 2: Sort by frequency using bucket sort + const maxFrequency = text.length; + const buckets = new Map(); + for (const [char, freq] of localFrequencyMap) { + const bucket = buckets.get(freq) ?? []; + bucket.push(char); + buckets.set(freq, bucket); + } + + // Emit a sort step to signal frequency ordering is complete + tracker.checkAnagram(true, { + phase: "sort", + uniqueChars: localFrequencyMap.size, + maxFrequency, + }); + + // Phase 3: Build output — emit addToResult for each char group, high freq first + let outputLength = 0; + for (let freqIdx = maxFrequency; freqIdx >= 1; freqIdx--) { + const charsAtFreq = buckets.get(freqIdx); + if (!charsAtFreq) continue; + for (const char of charsAtFreq) { + outputLength += freqIdx; + tracker.addToResult(outputLength - 1, { + phase: "build", + char, + frequency: freqIdx, + charsAdded: freqIdx, + outputSoFar: outputLength, + }); + } + } + + tracker.complete({ result: "sorted by frequency", outputLength }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/FirstNonRepeatingCharacterPipeline.stories.tsx b/src/algorithms/strings/character-frequency/first-non-repeating-character/FirstNonRepeatingCharacterPipeline.stories.tsx new file mode 100644 index 00000000..29e60cb2 --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/FirstNonRepeatingCharacterPipeline.stories.tsx @@ -0,0 +1,81 @@ +/** + * Storybook stories for the First Non-Repeating Character algorithm pipeline. + * Uses the real step generator with varied inputs, + * rendering the FrequencyVisualizer at key algorithm states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { FrequencyVisualState } from "@/types"; +import { generateFirstNonRepeatingCharacterSteps } from "./step-generator"; +import FrequencyVisualizer from "@/components/visualization/FrequencyVisualizer"; + +const defaultSteps = generateFirstNonRepeatingCharacterSteps({ + text: "leetcode", +}); + +const allRepeatingSteps = generateFirstNonRepeatingCharacterSteps({ + text: "aabb", +}); + +const midResultSteps = generateFirstNonRepeatingCharacterSteps({ + text: "loveleetcode", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/First Non-Repeating Character", + component: FrequencyVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty frequency map, input string shown */ +export const Initial: Story = { + args: { + visualState: defaultSteps[0]!.visualState as FrequencyVisualState, + }, +}; + +/** Mid-build — frequency map partially populated from first pass */ +export const FrequencyMapBuilding: Story = { + args: { + visualState: defaultSteps[Math.floor(defaultSteps.length * 0.4)]! + .visualState as FrequencyVisualState, + }, +}; + +/** Scan phase — comparing frequencies to find first count-1 character */ +export const ScanPhase: Story = { + args: { + visualState: defaultSteps[Math.floor(defaultSteps.length * 0.75)]! + .visualState as FrequencyVisualState, + }, +}; + +/** Result found — "l" at index 0 confirmed as first non-repeating character */ +export const ResultFound: Story = { + args: { + visualState: defaultSteps[defaultSteps.length - 1]!.visualState as FrequencyVisualState, + }, +}; + +/** No result — "aabb" has no non-repeating character, returns -1 */ +export const NoResult: Story = { + args: { + visualState: allRepeatingSteps[allRepeatingSteps.length - 1]! + .visualState as FrequencyVisualState, + }, +}; + +/** Mid-string result — "loveleetcode" returns index 2 (v) */ +export const MidStringResult: Story = { + args: { + visualState: midResultSteps[midResultSteps.length - 1]!.visualState as FrequencyVisualState, + }, +}; diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/educational.ts b/src/algorithms/strings/character-frequency/first-non-repeating-character/educational.ts new file mode 100644 index 00000000..ad9803be --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/educational.ts @@ -0,0 +1,56 @@ +/** Educational content for First Non-Repeating Character — all 7 required sections. */ + +import type { EducationalContent } from "@/types"; + +export const firstNonRepeatingCharacterEducational: EducationalContent = { + overview: + "**First Non-Repeating Character** finds the first character in a string that appears exactly once — returning its index, or `-1` if every character repeats.\n\n" + + 'For example, in `"leetcode"` the character `\'l\'` at index `0` appears only once, so the answer is `0`. In `"aabb"` every character repeats, so the answer is `-1`.\n\n' + + "The algorithm uses a **frequency map** to count occurrences in a first pass, then scans left-to-right in a second pass to find the first entry whose count is exactly `1`.", + + howItWorks: + "The algorithm runs in two passes:\n\n" + + "**Pass 1 — Build frequency map** (O(n)):\n\n" + + "Iterate over every character in the string. For each character, increment its count in the map:\n\n" + + '```\ntext = "leetcode"\nmap = { l:1, e:3, t:1, c:1, o:1, d:1 }\n```\n\n' + + "**Pass 2 — Scan for first unique** (O(n)):\n\n" + + "Iterate over the string again from left to right. For each character, check its count in the map. Return the index of the first character whose count equals `1`:\n\n" + + "```\nindex 0: 'l' → count 1 → return 0\n```\n\n" + + "If no character has count `1` after the full scan, return `-1`.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Both passes iterate over a string of length `n` exactly once. Each map lookup and update is `O(1)` for a bounded alphabet.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "The frequency map holds at most one entry per unique character. For lowercase English letters that is at most 26 entries — constant space regardless of input length.", + + bestAndWorstCase: + '**Best case** — `O(n)`: the first character of the string is non-repeating (e.g., `"leetcode"`). Pass 1 still completes the full build, but pass 2 exits at index `0`.\n\n' + + '**Worst case** — `O(n)`: either the non-repeating character is at the very end, or no such character exists at all (e.g., `"aabb"`). Both passes run to completion without early exit.\n\n' + + "In practice the algorithm is always linear — there is no quadratic case.", + + realWorldUses: [ + "**Stream processing:** Finding the first unique event identifier in a log stream (e.g., a telemetry pipeline detecting rare error codes).", + "**Text editors:** Highlighting the first character that breaks a palindrome or repeating pattern.", + "**Data deduplication:** Quickly identifying the first unique token in a tokenized document stream.", + "**Competitive programming:** Foundational building block for harder frequency-map problems involving uniqueness constraints.", + "**Game development:** Detecting the first non-duplicate tile or move in a match-3 or word-game board state.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(n) time — two linear passes with no nested iteration.", + "O(1) space for bounded alphabets — the frequency map never grows beyond the alphabet size.", + "Simple, readable two-pass structure that is easy to verify for correctness.", + ], + limitations: [ + "Does not short-circuit during pass 1 — the full frequency map must be built before any index can be returned.", + "Treats characters as case-sensitive by default — `'A'` and `'a'` are counted separately unless normalized first.", + "Returns only the first non-repeating character; finding all non-repeating characters in order requires a different return type.", + ], + }, + + whenToUseIt: + "Use First Non-Repeating Character when you need to identify the leftmost unique element in a linear sequence with a bounded value domain. It is the canonical `O(n)` solution and should be preferred over `O(n²)` naïve comparison approaches.\n\n" + + "If you need to handle an unbounded or very large alphabet (full Unicode), the space complexity becomes `O(k)` where `k` is the number of distinct characters — still linear but worth noting for memory-constrained environments.", +}; diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/first-non-repeating-character.test.ts b/src/algorithms/strings/character-frequency/first-non-repeating-character/first-non-repeating-character.test.ts new file mode 100644 index 00000000..356d661a --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/first-non-repeating-character.test.ts @@ -0,0 +1,50 @@ +/** Correctness tests for the First Non-Repeating Character algorithm. */ + +import { describe, it, expect } from "vitest"; +import { firstNonRepeatingCharacter } from "./sources/first-non-repeating-character.ts?fn"; + +describe("firstNonRepeatingCharacter", () => { + it('returns 0 for "leetcode" — first unique char is l at index 0', () => { + expect(firstNonRepeatingCharacter("leetcode")).toBe(0); + }); + + it('returns 2 for "loveleetcode" — first unique char is v at index 2', () => { + expect(firstNonRepeatingCharacter("loveleetcode")).toBe(2); + }); + + it('returns -1 for "aabb" — all characters repeat', () => { + expect(firstNonRepeatingCharacter("aabb")).toBe(-1); + }); + + it("returns 0 for a single-character string", () => { + expect(firstNonRepeatingCharacter("z")).toBe(0); + }); + + it("returns -1 for a string where every character appears twice", () => { + expect(firstNonRepeatingCharacter("aabbcc")).toBe(-1); + }); + + it("returns the index of the unique character when it appears in the middle", () => { + expect(firstNonRepeatingCharacter("aabbc")).toBe(4); + }); + + it("returns 0 when the first character is the only non-repeating one", () => { + expect(firstNonRepeatingCharacter("xaabb")).toBe(0); + }); + + it("returns the last index when only the last character is non-repeating", () => { + expect(firstNonRepeatingCharacter("aabbz")).toBe(4); + }); + + it("returns -1 for a string of all identical characters", () => { + expect(firstNonRepeatingCharacter("aaaa")).toBe(-1); + }); + + it("returns 0 for a two-character string where both are unique", () => { + expect(firstNonRepeatingCharacter("ab")).toBe(0); + }); + + it('handles "dddccdbba" — all of d, c, b repeat so first unique is a at index 8', () => { + expect(firstNonRepeatingCharacter("dddccdbba")).toBe(8); + }); +}); diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/index.ts b/src/algorithms/strings/character-frequency/first-non-repeating-character/index.ts new file mode 100644 index 00000000..daf889bd --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for First Non-Repeating Character — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { firstNonRepeatingCharacter } from "./sources/first-non-repeating-character.ts?fn"; +import { generateFirstNonRepeatingCharacterSteps } from "./step-generator"; +import type { FirstNonRepeatingCharacterInput } from "./step-generator"; +import { firstNonRepeatingCharacterEducational } from "./educational"; + +import typescriptSource from "./sources/first-non-repeating-character.ts?raw"; +import pythonSource from "./sources/first-non-repeating-character.py?raw"; +import javaSource from "./sources/FirstNonRepeatingCharacter.java?raw"; + +function executeFirstNonRepeatingCharacter(input: FirstNonRepeatingCharacterInput): number { + return firstNonRepeatingCharacter(input.text) as number; +} + +const firstNonRepeatingCharacterDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.FIRST_NON_REPEATING_CHARACTER!, + name: "First Non-Repeating Character", + category: CATEGORY.STRINGS!, + technique: "character-frequency", + description: + "Find the index of the first character that appears exactly once by building a frequency map in O(n) time and scanning left-to-right", + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "leetcode" }, + }, + execute: executeFirstNonRepeatingCharacter, + generateSteps: generateFirstNonRepeatingCharacterSteps, + educational: firstNonRepeatingCharacterEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(firstNonRepeatingCharacterDefinition); diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/FirstNonRepeatingCharacter.java b/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/FirstNonRepeatingCharacter.java new file mode 100644 index 00000000..a407e5c0 --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/FirstNonRepeatingCharacter.java @@ -0,0 +1,25 @@ +// First Non-Repeating Character +// Returns the index of the first character that appears exactly once, or -1 if none. +// Time: O(n) — two passes over the string (bounded by alphabet size) +// Space: O(1) — frequency map bounded by alphabet size (26 letters) + +import java.util.HashMap; +import java.util.Map; + +public class FirstNonRepeatingCharacter { + + public static int firstNonRepeatingCharacter(String text) { + Map frequencyMap = new HashMap<>(); // @step:initialize + + for (char charVal : text.toCharArray()) { // @step:update-frequency + frequencyMap.put(charVal, frequencyMap.getOrDefault(charVal, 0) + 1); // @step:update-frequency + } + + for (int charIdx = 0; charIdx < text.length(); charIdx++) { // @step:compare + char charVal = text.charAt(charIdx); // @step:compare + if (frequencyMap.getOrDefault(charVal, 0) == 1) return charIdx; // @step:found + } + + return -1; // @step:complete + } +} diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.py b/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.py new file mode 100644 index 00000000..425e7ed0 --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.py @@ -0,0 +1,17 @@ +# First Non-Repeating Character +# Returns the index of the first character that appears exactly once, or -1 if none. +# Time: O(n) — two passes over the string (bounded by alphabet size) +# Space: O(1) — frequency map bounded by alphabet size (26 letters) + + +def first_non_repeating_character(text: str) -> int: + frequency_map: dict[str, int] = {} # @step:initialize + + for char in text: # @step:update-frequency + frequency_map[char] = frequency_map.get(char, 0) + 1 # @step:update-frequency + + for char_idx, char in enumerate(text): # @step:compare + if frequency_map.get(char) == 1: # @step:compare + return char_idx # @step:found + + return -1 # @step:complete diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.ts b/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.ts new file mode 100644 index 00000000..baac2c69 --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/sources/first-non-repeating-character.ts @@ -0,0 +1,21 @@ +// First Non-Repeating Character +// Returns the index of the first character that appears exactly once, or -1 if none. +// Time: O(n) — two passes over the string (bounded by alphabet size) +// Space: O(1) — frequency map bounded by alphabet size (26 letters) + +export function firstNonRepeatingCharacter(text: string): number { + const frequencyMap = new Map(); // @step:initialize + + for (const char of text) { + // @step:update-frequency + frequencyMap.set(char, (frequencyMap.get(char) ?? 0) + 1); // @step:update-frequency + } + + for (let charIdx = 0; charIdx < text.length; charIdx++) { + // @step:compare + const char = text[charIdx]!; // @step:compare + if (frequencyMap.get(char) === 1) return charIdx; // @step:found + } + + return -1; // @step:complete +} diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.test.ts b/src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.test.ts new file mode 100644 index 00000000..1d7a1529 --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.test.ts @@ -0,0 +1,93 @@ +/** Step generation tests for First Non-Repeating Character — verifies step types and visual state. */ + +import { describe, it, expect } from "vitest"; +import { generateFirstNonRepeatingCharacterSteps } from "./step-generator"; + +describe("generateFirstNonRepeatingCharacterSteps", () => { + it("produces steps for the default input", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-frequency visual states throughout", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-frequency"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits update-frequency steps when building the frequency map", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + const frequencySteps = steps.filter((step) => step.type === "update-frequency"); + expect(frequencySteps.length).toBeGreaterThan(0); + }); + + it("emits one update-frequency step per character in the input", () => { + const textInput = "abc"; + const steps = generateFirstNonRepeatingCharacterSteps({ text: textInput }); + const frequencySteps = steps.filter((step) => step.type === "update-frequency"); + expect(frequencySteps.length).toBe(textInput.length); + }); + + it("emits compare steps when scanning for the first unique character", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step when a non-repeating character is identified", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(1); + }); + + it("does not emit a found step when all characters repeat", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "aabb" }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(0); + }); + + it("complete step variables contain result -1 when no unique character exists", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "aabb" }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.type).toBe("complete"); + expect((lastStep.variables as Record).result).toBe(-1); + }); + + it("complete step variables contain result 0 for leetcode", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "leetcode" }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.type).toBe("complete"); + expect((lastStep.variables as Record).result).toBe(0); + }); + + it("returns steps for a single-character string", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "a" }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-frequency kind for all-repeating input", () => { + const steps = generateFirstNonRepeatingCharacterSteps({ text: "aabb" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-frequency"); + } + }); +}); diff --git a/src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.ts b/src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.ts new file mode 100644 index 00000000..bc0601ff --- /dev/null +++ b/src/algorithms/strings/character-frequency/first-non-repeating-character/step-generator.ts @@ -0,0 +1,51 @@ +/** Step generator for First Non-Repeating Character — produces ExecutionStep[] using FrequencyTracker. */ + +import type { ExecutionStep } from "@/types"; +import { FrequencyTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const FIRST_NON_REPEATING_CHARACTER_LINE_MAP = buildLineMapFromSources( + ALGORITHM_ID.FIRST_NON_REPEATING_CHARACTER!, +); + +export interface FirstNonRepeatingCharacterInput { + text: string; +} + +export function generateFirstNonRepeatingCharacterSteps( + input: FirstNonRepeatingCharacterInput, +): ExecutionStep[] { + const { text } = input; + const tracker = new FrequencyTracker(text, "", FIRST_NON_REPEATING_CHARACTER_LINE_MAP); + + // Initialize — capture starting state + tracker.initialize({ text, result: -1 }); + + // Track local counts to detect first non-repeating character in phase 2 + const localFrequencyMap = new Map(); + + // Phase 1: Build frequency map — count occurrences of each character + for (let charIdx = 0; charIdx < text.length; charIdx++) { + const char = text[charIdx]!; + localFrequencyMap.set(char, (localFrequencyMap.get(char) ?? 0) + 1); + tracker.addToFrequency(char, { charIdx, char, phase: "build" }); + } + + // Phase 2: Scan for first character with frequency count of exactly 1 + let resultIndex = -1; + for (let charIdx = 0; charIdx < text.length; charIdx++) { + const char = text[charIdx]!; + const charCount = localFrequencyMap.get(char) ?? 0; + tracker.checkAnagram(charCount === 1, { charIdx, char, count: charCount, phase: "scan" }); + + if (charCount === 1 && resultIndex === -1) { + resultIndex = charIdx; + tracker.markNonRepeating(charIdx, { charIdx, char, result: charIdx }); + break; + } + } + + tracker.complete({ result: resultIndex }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/MinimumWindowSubstringPipeline.stories.tsx b/src/algorithms/strings/character-frequency/minimum-window-substring/MinimumWindowSubstringPipeline.stories.tsx new file mode 100644 index 00000000..bc9da8a2 --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/MinimumWindowSubstringPipeline.stories.tsx @@ -0,0 +1,83 @@ +/** + * Storybook stories for the Minimum Window Substring algorithm pipeline. + * Uses the real step generator with varied inputs, + * rendering the FrequencyVisualizer at key algorithm states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { FrequencyVisualState } from "@/types"; +import { generateMinimumWindowSubstringSteps } from "./step-generator"; +import FrequencyVisualizer from "@/components/visualization/FrequencyVisualizer"; + +const defaultSteps = generateMinimumWindowSubstringSteps({ + text: "ADOBECODEBANC", + pattern: "ABC", +}); + +const noMatchSteps = generateMinimumWindowSubstringSteps({ + text: "AAABBB", + pattern: "XYZ", +}); + +const shortWindowSteps = generateMinimumWindowSubstringSteps({ + text: "a", + pattern: "a", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Minimum Window Substring", + component: FrequencyVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty frequency map, both strings shown */ +export const Initial: Story = { + args: { + visualState: defaultSteps[0]!.visualState as FrequencyVisualState, + }, +}; + +/** Expanding phase — right pointer moving through ADOBECODEBANC */ +export const WindowExpanding: Story = { + args: { + visualState: defaultSteps[Math.floor(defaultSteps.length * 0.35)]! + .visualState as FrequencyVisualState, + }, +}; + +/** All characters satisfied — beginning to shrink window */ +export const AllCharactersSatisfied: Story = { + args: { + visualState: defaultSteps[Math.floor(defaultSteps.length * 0.65)]! + .visualState as FrequencyVisualState, + }, +}; + +/** Final state — minimum window BANC found */ +export const MinimumWindowFound: Story = { + args: { + visualState: defaultSteps[defaultSteps.length - 1]!.visualState as FrequencyVisualState, + }, +}; + +/** No match — pattern characters absent from text */ +export const NoMatch: Story = { + args: { + visualState: noMatchSteps[noMatchSteps.length - 1]!.visualState as FrequencyVisualState, + }, +}; + +/** Single character — text and pattern both "a" */ +export const SingleCharacterMatch: Story = { + args: { + visualState: shortWindowSteps[shortWindowSteps.length - 1]!.visualState as FrequencyVisualState, + }, +}; diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/educational.ts b/src/algorithms/strings/character-frequency/minimum-window-substring/educational.ts new file mode 100644 index 00000000..8f11518c --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/educational.ts @@ -0,0 +1,62 @@ +/** Educational content for Minimum Window Substring — all 7 required sections. */ + +import type { EducationalContent } from "@/types"; + +export const minimumWindowSubstringEducational: EducationalContent = { + overview: + "**Minimum Window Substring** finds the smallest contiguous window in a text string that contains every character from a pattern string (including duplicates).\n\n" + + 'For example, given `text = "ADOBECODEBANC"` and `pattern = "ABC"`, the answer is `"BANC"` — the shortest substring that contains at least one `A`, one `B`, and one `C`.\n\n' + + "The algorithm uses a **sliding window** with two pointers: the right pointer expands the window to include new characters, and the left pointer shrinks it once all characters are satisfied — finding the minimum length window in a single pass.", + + howItWorks: + "The algorithm maintains a window `[leftIndex, rightIndex]` over the text and tracks how many pattern characters have been satisfied.\n\n" + + "**Step 1 — Build target frequency map** (O(m)):\n\n" + + "Count how many times each character appears in the pattern:\n\n" + + '```\npattern = "ABC"\ntarget = { A:1, B:1, C:1 }, required = 3\n```\n\n' + + "**Step 2 — Expand right pointer** (O(n)):\n\n" + + "Slide `rightIndex` across the text one character at a time. Add the character to the window frequency map. If its window count now equals its target count, increment `satisfied`:\n\n" + + "```\nA → satisfied=1, D → no change, O → no change, B → satisfied=2 ...\n```\n\n" + + "**Step 3 — Shrink left pointer** (O(n) amortized):\n\n" + + "Once `satisfied === required`, record the window if it is the smallest seen so far. Then advance `leftIndex` — remove that character from the window, and if its count drops below the target, decrement `satisfied`. Repeat until `satisfied < required`:\n\n" + + '```\nWindow "ADOBEC" → record length 6\n→ shrink: remove A → satisfied drops → stop shrinking\n...\nWindow "BANC" → record length 4 ← best\n```\n\n' + + "Return the text slice at the recorded best position.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n + m)`**\n\n" + + "Building the target frequency map takes `O(m)`. The right pointer traverses the text once (`O(n)`), and the left pointer also traverses the text at most once in total across all shrink cycles — giving `O(n)` for the sliding window phase.\n\n" + + "**Space Complexity: `O(σ)`**\n\n" + + "Two frequency maps are maintained — `targetFrequency` and `windowFrequency`. Each holds at most `σ` entries where `σ` is the alphabet size. For lowercase English letters, that is a constant 26 entries. For arbitrary Unicode, it scales with the number of distinct characters in the input.", + + bestAndWorstCase: + "**Best case** — `O(n + m)`: the pattern has only one distinct character and it appears early in the text. The window satisfies the requirement quickly and shrinks to the minimum size in a small number of steps, but both strings must still be fully scanned.\n\n" + + "**Worst case** — `O(n + m)`: every character in the text is relevant to the pattern, and the minimum window is found only near the end (e.g., pattern characters are spread evenly across a long text). Both pointers traverse the entire text once each.\n\n" + + "There is no super-linear case — the sliding window guarantees each pointer moves at most `n` steps total.", + + realWorldUses: [ + "**Text search:** Finding the shortest excerpt of a document that mentions every required keyword (used in information retrieval and search engine snippet generation).", + "**Bioinformatics:** Locating the shortest DNA or RNA subsequence that contains all required nucleotides or codon markers from a target sequence.", + "**Compiler design:** Scanning token streams to find the smallest contiguous region that satisfies a set of required tokens (e.g., variable declarations before first use).", + "**Log analysis:** Identifying the tightest time window in event logs that covers all required event types for incident reconstruction.", + "**Competitive programming:** A foundational pattern for window-minimization problems involving character or element coverage constraints.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(n + m) time — both pointers traverse the text only once in total.", + "O(σ) space — memory usage is bounded by the alphabet size, not the input length.", + "Handles duplicate characters in the pattern correctly via frequency counting.", + "Naturally generalizes to any countable element type, not just characters.", + ], + limitations: [ + "Returns only one minimum window — if multiple windows share the minimum length, only the first (leftmost) is returned.", + "Case-sensitive by default — 'A' and 'a' are treated as different characters unless the caller normalizes the input.", + "Does not support wildcard or regex patterns — only exact character-frequency matching.", + "For Unicode inputs with large alphabets, the O(σ) space bound can become significant.", + ], + }, + + whenToUseIt: + "Use Minimum Window Substring whenever you need the shortest contiguous substring of a text that satisfies a character coverage requirement.\n\n" + + "It is the optimal solution for this class of problem — `O(n + m)` time and `O(σ)` space. Avoid naïve `O(n²)` or `O(n² * m)` brute-force approaches that check every possible substring.\n\n" + + "If you need **all** windows of minimum length (not just the first), collect candidates during the `addToResult` phase instead of tracking a single best. If you need to check whether a window exists at all (without finding the shortest), a simpler frequency comparison suffices.", +}; diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/index.ts b/src/algorithms/strings/character-frequency/minimum-window-substring/index.ts new file mode 100644 index 00000000..c8ceb87c --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Minimum Window Substring — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { minimumWindowSubstring } from "./sources/minimum-window-substring.ts?fn"; +import { generateMinimumWindowSubstringSteps } from "./step-generator"; +import type { MinimumWindowSubstringInput } from "./step-generator"; +import { minimumWindowSubstringEducational } from "./educational"; + +import typescriptSource from "./sources/minimum-window-substring.ts?raw"; +import pythonSource from "./sources/minimum-window-substring.py?raw"; +import javaSource from "./sources/MinimumWindowSubstring.java?raw"; + +function executeMinimumWindowSubstring(input: MinimumWindowSubstringInput): string { + return minimumWindowSubstring(input.text, input.pattern) as string; +} + +const minimumWindowSubstringDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.MINIMUM_WINDOW_SUBSTRING!, + name: "Minimum Window Substring", + category: CATEGORY.STRINGS!, + technique: "character-frequency", + description: + "Find the smallest contiguous window in text containing all characters of pattern using a sliding window with two frequency maps in O(n+m) time", + timeComplexity: { + best: "O(n+m)", + average: "O(n+m)", + worst: "O(n+m)", + }, + spaceComplexity: "O(σ)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "ADOBECODEBANC", pattern: "ABC" }, + }, + execute: executeMinimumWindowSubstring, + generateSteps: generateMinimumWindowSubstringSteps, + educational: minimumWindowSubstringEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(minimumWindowSubstringDefinition); diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/minimum-window-substring.test.ts b/src/algorithms/strings/character-frequency/minimum-window-substring/minimum-window-substring.test.ts new file mode 100644 index 00000000..4df73d20 --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/minimum-window-substring.test.ts @@ -0,0 +1,56 @@ +/** Correctness tests for the Minimum Window Substring algorithm. */ + +import { describe, it, expect } from "vitest"; +import { minimumWindowSubstring } from "./sources/minimum-window-substring.ts?fn"; + +describe("minimumWindowSubstring", () => { + it("returns BANC for the classic example ADOBECODEBANC / ABC", () => { + expect(minimumWindowSubstring("ADOBECODEBANC", "ABC")).toBe("BANC"); + }); + + it("returns the single matching character when text equals pattern", () => { + expect(minimumWindowSubstring("a", "a")).toBe("a"); + }); + + it("returns empty string when pattern requires more of a character than text contains", () => { + expect(minimumWindowSubstring("a", "aa")).toBe(""); + }); + + it("returns empty string when pattern character is absent from text", () => { + expect(minimumWindowSubstring("hello", "z")).toBe(""); + }); + + it("returns the entire text when text equals pattern", () => { + expect(minimumWindowSubstring("abc", "abc")).toBe("abc"); + }); + + it("returns empty string when text is shorter than pattern", () => { + expect(minimumWindowSubstring("ab", "abc")).toBe(""); + }); + + it("handles duplicate characters in pattern correctly", () => { + expect(minimumWindowSubstring("ADOBECODEBANC", "AABC")).toBe("ADOBECODEBA"); + }); + + it("returns the minimum window when multiple valid windows exist", () => { + // "aa" and "ba" both contain a and b, but "ba" is later so "aa"... actually "ab" is length 2 + // text = "cabwefgewcwaefgcf", pattern = "cae" → "cwae" + expect(minimumWindowSubstring("cabwefgewcwaefgcf", "cae")).toBe("cwae"); + }); + + it("handles single-character pattern found at the end", () => { + expect(minimumWindowSubstring("abcdef", "f")).toBe("f"); + }); + + it("returns empty string for empty pattern", () => { + expect(minimumWindowSubstring("abc", "")).toBe(""); + }); + + it("handles text with all same characters and pattern requiring one", () => { + expect(minimumWindowSubstring("aaabbbccc", "b")).toBe("b"); + }); + + it("returns correct result when window must span full text", () => { + expect(minimumWindowSubstring("abc", "cba")).toBe("abc"); + }); +}); diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/sources/MinimumWindowSubstring.java b/src/algorithms/strings/character-frequency/minimum-window-substring/sources/MinimumWindowSubstring.java new file mode 100644 index 00000000..d0dc4ee6 --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/sources/MinimumWindowSubstring.java @@ -0,0 +1,57 @@ +// Minimum Window Substring +// Finds the smallest contiguous window in `text` that contains all characters of `pattern`. +// Returns an empty string if no such window exists. +// Time: O(n + m) where n = text.length(), m = pattern.length() +// Space: O(σ) — frequency maps bounded by alphabet size + +import java.util.HashMap; +import java.util.Map; + +public class MinimumWindowSubstring { + + public static String minimumWindowSubstring(String text, String pattern) { + if (pattern.isEmpty() || text.length() < pattern.length()) return ""; // @step:initialize + + Map targetFrequency = new HashMap<>(); // @step:initialize + for (char charVal : pattern.toCharArray()) { // @step:initialize + targetFrequency.put(charVal, targetFrequency.getOrDefault(charVal, 0) + 1); // @step:initialize + } + + Map windowFrequency = new HashMap<>(); // @step:initialize + int required = targetFrequency.size(); // @step:initialize + int satisfied = 0; // @step:initialize + int leftIndex = 0; // @step:initialize + int bestStart = -1; // @step:initialize + int bestLength = Integer.MAX_VALUE; // @step:initialize + + for (int rightIndex = 0; rightIndex < text.length(); rightIndex++) { // @step:expand-window + char rightChar = text.charAt(rightIndex); // @step:expand-window + windowFrequency.put(rightChar, windowFrequency.getOrDefault(rightChar, 0) + 1); // @step:update-frequency + + if (targetFrequency.containsKey(rightChar) && // @step:window-match + windowFrequency.get(rightChar).equals(targetFrequency.get(rightChar))) { // @step:window-match + satisfied += 1; // @step:window-match + } + + while (satisfied == required) { // @step:shrink-window + int windowLength = rightIndex - leftIndex + 1; // @step:add-to-result + if (windowLength < bestLength) { // @step:add-to-result + bestLength = windowLength; // @step:add-to-result + bestStart = leftIndex; // @step:add-to-result + } + + char leftChar = text.charAt(leftIndex); // @step:shrink-window + windowFrequency.put(leftChar, windowFrequency.get(leftChar) - 1); // @step:update-frequency + + if (targetFrequency.containsKey(leftChar) && // @step:shrink-window + windowFrequency.get(leftChar) < targetFrequency.get(leftChar)) { // @step:shrink-window + satisfied -= 1; // @step:shrink-window + } + + leftIndex += 1; // @step:shrink-window + } + } + + return bestStart == -1 ? "" : text.substring(bestStart, bestStart + bestLength); // @step:complete + } +} diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.py b/src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.py new file mode 100644 index 00000000..4bc1f5fc --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.py @@ -0,0 +1,46 @@ +# Minimum Window Substring +# Finds the smallest contiguous window in `text` that contains all characters of `pattern`. +# Returns an empty string if no such window exists. +# Time: O(n + m) where n = len(text), m = len(pattern) +# Space: O(σ) — frequency maps bounded by alphabet size + + +def minimum_window_substring(text: str, pattern: str) -> str: + if len(pattern) == 0 or len(text) < len(pattern): # @step:initialize + return "" + + target_frequency: dict[str, int] = {} # @step:initialize + for char in pattern: # @step:initialize + target_frequency[char] = target_frequency.get(char, 0) + 1 # @step:initialize + + window_frequency: dict[str, int] = {} # @step:initialize + required = len(target_frequency) # @step:initialize + satisfied = 0 # @step:initialize + left_index = 0 # @step:initialize + best_start = -1 # @step:initialize + best_length = float("inf") # @step:initialize + + for right_index in range(len(text)): # @step:expand-window + right_char = text[right_index] # @step:expand-window + window_frequency[right_char] = window_frequency.get(right_char, 0) + 1 # @step:update-frequency + + if right_char in target_frequency and window_frequency[right_char] == target_frequency[right_char]: # @step:window-match + satisfied += 1 # @step:window-match + + while satisfied == required: # @step:shrink-window + window_length = right_index - left_index + 1 # @step:add-to-result + if window_length < best_length: # @step:add-to-result + best_length = window_length # @step:add-to-result + best_start = left_index # @step:add-to-result + + left_char = text[left_index] # @step:shrink-window + window_frequency[left_char] -= 1 # @step:update-frequency + + if left_char in target_frequency and window_frequency[left_char] < target_frequency[left_char]: # @step:shrink-window + satisfied -= 1 # @step:shrink-window + + left_index += 1 # @step:shrink-window + + if best_start == -1: # @step:complete + return "" + return text[best_start : best_start + int(best_length)] # @step:complete diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.ts b/src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.ts new file mode 100644 index 00000000..19db30d0 --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/sources/minimum-window-substring.ts @@ -0,0 +1,57 @@ +// Minimum Window Substring +// Finds the smallest contiguous window in `text` that contains all characters of `pattern`. +// Returns an empty string if no such window exists. +// Time: O(n + m) where n = text.length, m = pattern.length +// Space: O(σ) — frequency maps bounded by alphabet size + +export function minimumWindowSubstring(text: string, pattern: string): string { + if (pattern.length === 0 || text.length < pattern.length) return ""; // @step:initialize + + const targetFrequency = new Map(); // @step:initialize + for (const char of pattern) { + // @step:initialize + targetFrequency.set(char, (targetFrequency.get(char) ?? 0) + 1); // @step:initialize + } + + const windowFrequency = new Map(); // @step:initialize + const required = targetFrequency.size; // @step:initialize + let satisfied = 0; // @step:initialize + let leftIndex = 0; // @step:initialize + let bestStart = -1; // @step:initialize + let bestLength = Infinity; // @step:initialize + + for (let rightIndex = 0; rightIndex < text.length; rightIndex++) { + // @step:expand-window + const rightChar = text[rightIndex]!; // @step:expand-window + windowFrequency.set(rightChar, (windowFrequency.get(rightChar) ?? 0) + 1); // @step:update-frequency + + const targetCount = targetFrequency.get(rightChar); // @step:window-match + if (targetCount !== undefined && windowFrequency.get(rightChar) === targetCount) { + // @step:window-match + satisfied += 1; // @step:window-match + } + + while (satisfied === required) { + // @step:shrink-window + const windowLength = rightIndex - leftIndex + 1; // @step:add-to-result + if (windowLength < bestLength) { + // @step:add-to-result + bestLength = windowLength; // @step:add-to-result + bestStart = leftIndex; // @step:add-to-result + } + + const leftChar = text[leftIndex]!; // @step:shrink-window + windowFrequency.set(leftChar, (windowFrequency.get(leftChar) ?? 0) - 1); // @step:update-frequency + + const leftTarget = targetFrequency.get(leftChar); // @step:shrink-window + if (leftTarget !== undefined && (windowFrequency.get(leftChar) ?? 0) < leftTarget) { + // @step:shrink-window + satisfied -= 1; // @step:shrink-window + } + + leftIndex += 1; // @step:shrink-window + } + } + + return bestStart === -1 ? "" : text.slice(bestStart, bestStart + bestLength); // @step:complete +} diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.test.ts b/src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.test.ts new file mode 100644 index 00000000..3cf96b6c --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.test.ts @@ -0,0 +1,93 @@ +/** Step generation tests for Minimum Window Substring — verifies step types and visual state. */ + +import { describe, it, expect } from "vitest"; +import { generateMinimumWindowSubstringSteps } from "./step-generator"; + +describe("generateMinimumWindowSubstringSteps", () => { + it("produces steps for the default input", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-frequency visual states throughout", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-frequency"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits expand-window steps for each character in text", () => { + const textInput = "ADOBECODEBANC"; + const steps = generateMinimumWindowSubstringSteps({ text: textInput, pattern: "ABC" }); + const expandSteps = steps.filter((step) => step.type === "expand-window"); + expect(expandSteps.length).toBe(textInput.length); + }); + + it("emits at least one add-to-result step when a valid window exists", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + const resultSteps = steps.filter((step) => step.type === "add-to-result"); + expect(resultSteps.length).toBeGreaterThan(0); + }); + + it("emits shrink-window steps when all characters are satisfied", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + const shrinkSteps = steps.filter((step) => step.type === "shrink-window"); + expect(shrinkSteps.length).toBeGreaterThan(0); + }); + + it("emits window-match steps when a required character is satisfied", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ADOBECODEBANC", pattern: "ABC" }); + const matchSteps = steps.filter((step) => step.type === "window-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("early exits with only initialize and complete steps for empty pattern", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "abc", pattern: "" }); + expect(steps).toHaveLength(2); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[1]?.type).toBe("complete"); + }); + + it("early exits with only initialize and complete when text is shorter than pattern", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "ab", pattern: "abc" }); + expect(steps).toHaveLength(2); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[1]?.type).toBe("complete"); + }); + + it("produces string-frequency kind for no-match inputs", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "aaaa", pattern: "z" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-frequency"); + } + }); + + it("emits no add-to-result steps when no valid window exists", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "aaaa", pattern: "z" }); + const resultSteps = steps.filter((step) => step.type === "add-to-result"); + expect(resultSteps).toHaveLength(0); + }); + + it("returns steps for single character text matching single character pattern", () => { + const steps = generateMinimumWindowSubstringSteps({ text: "a", pattern: "a" }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); +}); diff --git a/src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.ts b/src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.ts new file mode 100644 index 00000000..3bd33150 --- /dev/null +++ b/src/algorithms/strings/character-frequency/minimum-window-substring/step-generator.ts @@ -0,0 +1,121 @@ +/** Step generator for Minimum Window Substring — produces ExecutionStep[] using FrequencyTracker. */ + +import type { ExecutionStep } from "@/types"; +import { FrequencyTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const MINIMUM_WINDOW_SUBSTRING_LINE_MAP = buildLineMapFromSources( + ALGORITHM_ID.MINIMUM_WINDOW_SUBSTRING!, +); + +export interface MinimumWindowSubstringInput { + text: string; + pattern: string; +} + +export function generateMinimumWindowSubstringSteps( + input: MinimumWindowSubstringInput, +): ExecutionStep[] { + const { text, pattern } = input; + const tracker = new FrequencyTracker(text, pattern, MINIMUM_WINDOW_SUBSTRING_LINE_MAP); + + // Initialize — capture starting state with both strings + tracker.initialize({ text, pattern, textLength: text.length, patternLength: pattern.length }); + + // Early exit: empty pattern or text shorter than pattern + if (pattern.length === 0 || text.length < pattern.length) { + tracker.complete({ result: "", bestStart: -1 }); + return tracker.getSteps(); + } + + // Build target frequency map from pattern + const targetFrequency = new Map(); + for (const char of pattern) { + targetFrequency.set(char, (targetFrequency.get(char) ?? 0) + 1); + } + + const windowFrequency = new Map(); + const required = targetFrequency.size; + let satisfied = 0; + let leftIndex = 0; + let bestStart = -1; + let bestLength = Infinity; + + for (let rightIndex = 0; rightIndex < text.length; rightIndex++) { + const rightChar = text[rightIndex]!; + + // Expand right boundary and add character to window + tracker.expandWindow(rightIndex, { + rightIndex, + rightChar, + windowStart: leftIndex, + windowEnd: rightIndex, + }); + + windowFrequency.set(rightChar, (windowFrequency.get(rightChar) ?? 0) + 1); + tracker.addToFrequency(rightChar, { + rightIndex, + rightChar, + count: windowFrequency.get(rightChar), + }); + + // Check whether this character's frequency requirement is now satisfied + const targetCount = targetFrequency.get(rightChar); + if (targetCount !== undefined && windowFrequency.get(rightChar) === targetCount) { + satisfied += 1; + tracker.markSatisfied(rightChar, { + rightIndex, + rightChar, + satisfied, + required, + }); + } + + // Shrink from left while all required characters are satisfied + while (satisfied === required) { + const windowLength = rightIndex - leftIndex + 1; + + // Record a new best window whenever current window is smaller + if (windowLength < bestLength) { + bestLength = windowLength; + bestStart = leftIndex; + tracker.addToResult(leftIndex, { + leftIndex, + rightIndex, + windowLength, + bestStart: leftIndex, + bestWindow: text.slice(leftIndex, rightIndex + 1), + }); + } + + // Remove left character from window and advance left pointer + const leftChar = text[leftIndex]!; + tracker.shrinkWindow(leftIndex + 1, { + leftIndex, + leftChar, + windowStart: leftIndex + 1, + windowEnd: rightIndex, + }); + + windowFrequency.set(leftChar, (windowFrequency.get(leftChar) ?? 0) - 1); + tracker.removeFromFrequency(leftChar, { + leftIndex, + leftChar, + count: windowFrequency.get(leftChar), + }); + + // If removing left char drops below required count, we lose satisfaction + const leftTarget = targetFrequency.get(leftChar); + if (leftTarget !== undefined && (windowFrequency.get(leftChar) ?? 0) < leftTarget) { + satisfied -= 1; + } + + leftIndex += 1; + } + } + + const result = bestStart === -1 ? "" : text.slice(bestStart, bestStart + bestLength); + tracker.complete({ result, bestStart, bestLength: bestLength === Infinity ? 0 : bestLength }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/JaroWinklerSimilarityPipeline.stories.tsx b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/JaroWinklerSimilarityPipeline.stories.tsx new file mode 100644 index 00000000..393e2538 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/JaroWinklerSimilarityPipeline.stories.tsx @@ -0,0 +1,64 @@ +/** + * Storybook stories for the Jaro-Winkler Similarity algorithm pipeline. + * Uses the real step generator with the default input ("martha" / "marhta"), + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateJaroWinklerSimilaritySteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateJaroWinklerSimilaritySteps({ + source: "martha", + target: "marhta", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Jaro-Winkler Similarity", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty match matrix before any characters are compared */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Base cases filled — row 0 and column 0 populated with zeros */ +export const BaseCasesFilled: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.15)]!.visualState as DistanceVisualState, + }, +}; + +/** Mid matching — some source characters matched against target window */ +export const MidMatching: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** Matches highlighted — all matched pairs traced in the matrix */ +export const MatchesHighlighted: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.85)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — similarity score 0.9611 computed and displayed */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/educational.ts b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/educational.ts new file mode 100644 index 00000000..ffcd75de --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/educational.ts @@ -0,0 +1,66 @@ +/** Educational content for the Jaro-Winkler Similarity algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const jaroWinklerSimilarityEducational: EducationalContent = { + overview: + "**Jaro-Winkler Similarity** measures how alike two strings are by counting characters that appear in roughly the same position in both strings. The score ranges from **0.0** (completely different) to **1.0** (identical).\n\n" + + "It builds on the **Jaro similarity** formula by adding a **prefix bonus**: if the two strings start with the same characters (up to 4), the score is nudged higher. This makes it especially good for matching names where people often spell the beginning correctly.\n\n" + + "For example, `martha` and `marhta` score **0.9611** — very high, because only two characters are transposed and the prefix `mar` matches.", + + howItWorks: + "**Step 1 — Compute the match window:**\n\n" + + "```\nmatch_window = floor(max(len1, len2) / 2) - 1\n```\n\n" + + "Two characters are *matching* if they are the same character and their positions are within `match_window` of each other.\n\n" + + "**Step 2 — Find matching characters:**\n\n" + + "Scan each character in `source`. For each unmatched position in `target` within the window, record the first match. Count total matches `m`.\n\n" + + "**Step 3 — Count transpositions:**\n\n" + + "Extract matched characters from each string in order. Count positions where they differ — call that `t`. The number of transpositions is `t / 2`.\n\n" + + "**Step 4 — Jaro formula:**\n\n" + + "```\njaro = (m/len1 + m/len2 + (m - t/2)/m) / 3\n```\n\n" + + "**Step 5 — Winkler prefix bonus:**\n\n" + + "Count how many leading characters match (up to 4). Call this `p`.\n\n" + + "```\njaro_winkler = jaro + p × 0.1 × (1 - jaro)\n```\n\n" + + "The `0.1` scaling factor (the *winkler constant*) prevents the bonus from exceeding 1.0.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "The matching step scans every source character against its target window. In the worst case (long window), this approaches `O(n × m)` where `n = source.length` and `m = target.length`. The transposition and prefix scans are `O(n)` and `O(1)` respectively.\n\n" + + "**Space Complexity: `O(n)`**\n\n" + + "Two boolean arrays of size `n` and `m` are allocated to track which characters have been matched. No DP matrix is required — the matrix in the visualization is used for display purposes only.", + + bestAndWorstCase: + "**Best case — identical strings:** When `source === target`, the function returns `1.0` immediately without entering the matching loop. Conceptually `O(1)` after the equality check.\n\n" + + "**Best case — no matches:** When no characters fall within each other's windows (completely different short strings), `matchCount = 0` and the function returns `0.0` early after the match loop. Still `O(n × m)` in the worst scenario.\n\n" + + "**Worst case — dense windows:** Long strings with a large match window cause the inner loop to scan almost every target character for every source character, approaching `O(n × m)`.\n\n" + + "Unlike edit-distance algorithms, Jaro-Winkler does **not** fill a full matrix — it stops matching each source character as soon as one match is found.", + + realWorldUses: [ + "**Record linkage:** Merging duplicate records in databases (patient names, customer lists) where the same person is spelled differently.", + "**Name matching:** Comparing personal names in civil registries, passport systems, and voter rolls where transpositions like `martha`/`marhta` are common.", + "**Typo-tolerant search:** Autocomplete and fuzzy search that prioritises prefix agreement, making it feel more responsive to users who type the start of a word correctly.", + "**Natural language processing:** Coreference resolution — determining whether two mentions in a document refer to the same entity.", + "**Data deduplication:** Identifying near-duplicate company or product names in e-commerce and CRM systems.", + "**Biometrics and forensics:** Matching names transliterated from non-Latin scripts where short prefixes tend to be stable.", + ], + + strengthsAndLimitations: { + strengths: [ + "Prefix bias makes it well-suited for short strings and names where early characters are more reliable.", + "More discriminating than simple edit distance for transposed characters — `marhta` scores much higher than a random 6-letter word.", + "Returns a normalised [0, 1] score, easy to threshold and compare across string pairs.", + "Space-efficient — only `O(n + m)` boolean arrays, no full DP matrix.", + ], + limitations: [ + "The Winkler prefix constant (0.1) is a heuristic — no theoretical justification for that specific value.", + "Still `O(n × m)` time, making it impractical for very long strings like full documents.", + "Ignores deletions and insertions as distinct operations — can score similar-length but very different strings unexpectedly high.", + "The match window formula produces `window = -1` for very short strings (length ≤ 1), which can cause edge-case behaviour.", + ], + }, + + whenToUseIt: + "Use Jaro-Winkler when comparing **short strings** (names, identifiers, codes) where a **prefix agreement** is a meaningful signal, and transpositions are more likely than wholesale deletions or insertions.\n\n" + + "It is the standard choice for **name matching** in record linkage pipelines. The normalized score makes it easy to set thresholds (e.g., ≥ 0.92 = likely match).\n\n" + + "Avoid it for long strings (use Levenshtein or Myers' diff), for cases where insertions and deletions matter more than transpositions (use Damerau-Levenshtein), or when you need an absolute edit count rather than a similarity ratio.", +}; diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/index.ts b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/index.ts new file mode 100644 index 00000000..28858bd0 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Jaro-Winkler Similarity — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { jaroWinklerSimilarity } from "./sources/jaro-winkler-similarity.ts?fn"; +import { generateJaroWinklerSimilaritySteps } from "./step-generator"; +import type { JaroWinklerSimilarityInput } from "./step-generator"; +import { jaroWinklerSimilarityEducational } from "./educational"; + +import typescriptSource from "./sources/jaro-winkler-similarity.ts?raw"; +import pythonSource from "./sources/jaro-winkler-similarity.py?raw"; +import javaSource from "./sources/JaroWinklerSimilarity.java?raw"; + +function executeJaroWinklerSimilarity(input: JaroWinklerSimilarityInput): number { + return jaroWinklerSimilarity(input.source, input.target) as number; +} + +const jaroWinklerSimilarityDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.JARO_WINKLER_SIMILARITY!, + name: "Jaro-Winkler Similarity", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Measure string similarity using Jaro's matching-character formula, boosted by a prefix bonus for strings sharing a common leading substring", + timeComplexity: { + best: "O(nm)", + average: "O(nm)", + worst: "O(nm)", + }, + spaceComplexity: "O(n)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { source: "martha", target: "marhta" }, + }, + execute: executeJaroWinklerSimilarity, + generateSteps: generateJaroWinklerSimilaritySteps, + educational: jaroWinklerSimilarityEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(jaroWinklerSimilarityDefinition); diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/jaro-winkler-similarity.test.ts b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/jaro-winkler-similarity.test.ts new file mode 100644 index 00000000..7360f255 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/jaro-winkler-similarity.test.ts @@ -0,0 +1,76 @@ +/** Correctness tests for the jaroWinklerSimilarity pure function. */ + +import { describe, it, expect } from "vitest"; +import { jaroWinklerSimilarity } from "./sources/jaro-winkler-similarity.ts?fn"; + +describe("jaroWinklerSimilarity", () => { + it('scores "martha" and "marhta" at ~0.9611 (classic example)', () => { + expect(jaroWinklerSimilarity("martha", "marhta")).toBeCloseTo(0.9611, 4); + }); + + it("returns 1.0 for identical strings", () => { + expect(jaroWinklerSimilarity("abc", "abc")).toBe(1.0); + }); + + it("returns 1.0 for two empty strings", () => { + expect(jaroWinklerSimilarity("", "")).toBe(1.0); + }); + + it("returns 0.0 when source is empty", () => { + expect(jaroWinklerSimilarity("", "abc")).toBe(0.0); + }); + + it("returns 0.0 when target is empty", () => { + expect(jaroWinklerSimilarity("abc", "")).toBe(0.0); + }); + + it("returns 0.0 for completely different strings of equal length", () => { + // No characters fall within each other's match windows + expect(jaroWinklerSimilarity("abc", "xyz")).toBe(0.0); + }); + + it('scores "CRATE" and "TRACE" with partial matches', () => { + const score = jaroWinklerSimilarity("CRATE", "TRACE"); + // Jaro is ~0.7333, no common prefix so Winkler adds nothing + expect(score).toBeGreaterThan(0.7); + expect(score).toBeLessThan(0.8); + }); + + it('scores "DwAyNE" and "DuANE" above 0.84 (prefix boost)', () => { + const score = jaroWinklerSimilarity("DwAyNE", "DuANE"); + expect(score).toBeGreaterThanOrEqual(0.84); + }); + + it("scores identical single characters at 1.0", () => { + expect(jaroWinklerSimilarity("a", "a")).toBe(1.0); + }); + + it("returns a value between 0.0 and 1.0 for arbitrary strings", () => { + const score = jaroWinklerSimilarity("algorithm", "logarithm"); + expect(score).toBeGreaterThanOrEqual(0.0); + expect(score).toBeLessThanOrEqual(1.0); + }); + + it("is not symmetric — source and target order can differ slightly", () => { + // By definition Jaro-Winkler is symmetric; confirm both directions are equal + const forward = jaroWinklerSimilarity("martha", "marhta"); + const backward = jaroWinklerSimilarity("marhta", "martha"); + expect(forward).toBe(backward); + }); + + it("rewards common prefix — longer prefix gives higher score than no prefix", () => { + // "JOHNSON" vs "JHNSON" shares prefix "J" + // "AOHNSON" vs "JHNSON" shares no prefix + const withPrefix = jaroWinklerSimilarity("JOHNSON", "JHNSON"); + const withoutPrefix = jaroWinklerSimilarity("AOHNSON", "JHNSON"); + expect(withPrefix).toBeGreaterThan(withoutPrefix); + }); + + it("prefix bonus is capped at 4 characters", () => { + // "abcdefgh" vs "abcdXXXX" — prefix length would be 4 (capped) + // "abcXefgh" vs "abcdXXXX" — prefix length is 3 + const fourPrefixScore = jaroWinklerSimilarity("abcdefgh", "abcdXXXX"); + const threePrefixScore = jaroWinklerSimilarity("abcXefgh", "abcdXXXX"); + expect(fourPrefixScore).toBeGreaterThan(threePrefixScore); + }); +}); diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/JaroWinklerSimilarity.java b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/JaroWinklerSimilarity.java new file mode 100644 index 00000000..0b241d88 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/JaroWinklerSimilarity.java @@ -0,0 +1,89 @@ +// Jaro-Winkler Similarity +// Computes similarity between two strings using the Jaro formula, +// then boosts the score if the strings share a common prefix (up to 4 chars). +// Returns a value between 0.0 (completely dissimilar) and 1.0 (identical). +// Time: O(nm), Space: O(n) + +public class JaroWinklerSimilarity { + + public static double jaroWinklerSimilarity(String source, String target) { + int sourceLength = source.length(); // @step:initialize + int targetLength = target.length(); // @step:initialize + + // Identical strings have similarity 1.0 + if (source.equals(target)) return 1.0; // @step:initialize + + // Either empty string has similarity 0.0 + if (sourceLength == 0 || targetLength == 0) return 0.0; // @step:initialize + + // Match window: characters within this distance can be considered matching + int matchWindow = Math.max(sourceLength, targetLength) / 2 - 1; // @step:initialize + + boolean[] sourceMatched = new boolean[sourceLength]; // @step:initialize + boolean[] targetMatched = new boolean[targetLength]; // @step:initialize + int matchCount = 0; // @step:initialize + + // Find matching characters within the match window + for (int sourceIdx = 0; sourceIdx < sourceLength; sourceIdx++) { // @step:compare + int windowStart = Math.max(0, sourceIdx - matchWindow); // @step:compare + int windowEnd = Math.min(targetLength - 1, sourceIdx + matchWindow); // @step:compare + + for (int targetIdx = windowStart; targetIdx <= windowEnd; targetIdx++) { // @step:compare + if (!targetMatched[targetIdx] && source.charAt(sourceIdx) == target.charAt(targetIdx)) { // @step:compare + sourceMatched[sourceIdx] = true; // @step:compute-distance + targetMatched[targetIdx] = true; // @step:compute-distance + matchCount++; // @step:compute-distance + break; + } + } + } + + // No matches means similarity is 0 + if (matchCount == 0) return 0.0; // @step:compute-distance + + // Count transpositions: matched chars in different order + int transpositionCount = 0; // @step:compute-distance + int targetScanIdx = 0; // @step:compute-distance + + for (int sourceIdx = 0; sourceIdx < sourceLength; sourceIdx++) { // @step:compute-distance + if (!sourceMatched[sourceIdx]) continue; // @step:compute-distance + + while (!targetMatched[targetScanIdx]) { // @step:compute-distance + targetScanIdx++; // @step:compute-distance + } + + if (source.charAt(sourceIdx) != target.charAt(targetScanIdx)) { // @step:compute-distance + transpositionCount++; // @step:compute-distance + } + + targetScanIdx++; // @step:compute-distance + } + + // Jaro similarity formula + double halfTranspositions = transpositionCount / 2.0; // @step:compute-distance + double jaroScore = (matchCount / (double) sourceLength // @step:compute-distance + + matchCount / (double) targetLength // @step:compute-distance + + (matchCount - halfTranspositions) / matchCount) // @step:compute-distance + / 3.0; // @step:compute-distance + + // Count common prefix length (up to 4 characters) + int maxPrefixLength = 4; // @step:compute-distance + int prefixLength = 0; // @step:compute-distance + + for (int prefixIdx = 0; + prefixIdx < Math.min(maxPrefixLength, Math.min(sourceLength, targetLength)); + prefixIdx++) { // @step:compute-distance + if (source.charAt(prefixIdx) == target.charAt(prefixIdx)) { // @step:compute-distance + prefixLength++; // @step:compute-distance + } else { + break; // @step:compute-distance + } + } + + // Winkler bonus: reward common prefix + double winklerBonus = prefixLength * 0.1 * (1.0 - jaroScore); // @step:compute-distance + double jaroWinklerScore = jaroScore + winklerBonus; // @step:compute-distance + + return Math.round(jaroWinklerScore * 10000.0) / 10000.0; // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.py b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.py new file mode 100644 index 00000000..c4963370 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.py @@ -0,0 +1,81 @@ +# Jaro-Winkler Similarity +# Computes similarity between two strings using the Jaro formula, +# then boosts the score if the strings share a common prefix (up to 4 chars). +# Returns a value between 0.0 (completely dissimilar) and 1.0 (identical). +# Time: O(nm), Space: O(n) + + +def jaro_winkler_similarity(source: str, target: str) -> float: + source_length = len(source) # @step:initialize + target_length = len(target) # @step:initialize + + # Identical strings have similarity 1.0 + if source == target: # @step:initialize + return 1.0 + + # Either empty string has similarity 0.0 + if source_length == 0 or target_length == 0: # @step:initialize + return 0.0 + + # Match window: characters within this distance can be considered matching + match_window = max(source_length, target_length) // 2 - 1 # @step:initialize + + source_matched = [False] * source_length # @step:initialize + target_matched = [False] * target_length # @step:initialize + match_count = 0 # @step:initialize + + # Find matching characters within the match window + for source_idx in range(source_length): # @step:compare + window_start = max(0, source_idx - match_window) # @step:compare + window_end = min(target_length - 1, source_idx + match_window) # @step:compare + + for target_idx in range(window_start, window_end + 1): # @step:compare + if not target_matched[target_idx] and source[source_idx] == target[target_idx]: # @step:compare + source_matched[source_idx] = True # @step:compute-distance + target_matched[target_idx] = True # @step:compute-distance + match_count += 1 # @step:compute-distance + break + + # No matches means similarity is 0 + if match_count == 0: # @step:compute-distance + return 0.0 + + # Count transpositions: matched chars in different order + transposition_count = 0 # @step:compute-distance + target_scan_idx = 0 # @step:compute-distance + + for source_idx in range(source_length): # @step:compute-distance + if not source_matched[source_idx]: # @step:compute-distance + continue + + while not target_matched[target_scan_idx]: # @step:compute-distance + target_scan_idx += 1 # @step:compute-distance + + if source[source_idx] != target[target_scan_idx]: # @step:compute-distance + transposition_count += 1 # @step:compute-distance + + target_scan_idx += 1 # @step:compute-distance + + # Jaro similarity formula + half_transpositions = transposition_count / 2 # @step:compute-distance + jaro_score = ( # @step:compute-distance + match_count / source_length + + match_count / target_length + + (match_count - half_transpositions) / match_count + ) / 3 + + # Count common prefix length (up to 4 characters) + max_prefix_length = 4 # @step:compute-distance + prefix_length = 0 # @step:compute-distance + + for prefix_idx in range(min(max_prefix_length, source_length, target_length)): # @step:compute-distance + if source[prefix_idx] == target[prefix_idx]: # @step:compute-distance + prefix_length += 1 # @step:compute-distance + else: + break # @step:compute-distance + + # Winkler bonus: reward common prefix + winkler_bonus = prefix_length * 0.1 * (1 - jaro_score) # @step:compute-distance + jaro_winkler_score = jaro_score + winkler_bonus # @step:compute-distance + + return round(jaro_winkler_score, 4) # @step:complete diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.ts b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.ts new file mode 100644 index 00000000..a3ef6d60 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/sources/jaro-winkler-similarity.ts @@ -0,0 +1,98 @@ +// Jaro-Winkler Similarity +// Computes similarity between two strings using the Jaro formula, +// then boosts the score if the strings share a common prefix (up to 4 chars). +// Returns a value between 0.0 (completely dissimilar) and 1.0 (identical). +// Time: O(nm), Space: O(n) where n and m are the string lengths. + +export function jaroWinklerSimilarity(source: string, target: string): number { + const sourceLength = source.length; // @step:initialize + const targetLength = target.length; // @step:initialize + + // Identical strings have similarity 1.0 + if (source === target) return 1.0; // @step:initialize + + // Either empty string has similarity 0.0 + if (sourceLength === 0 || targetLength === 0) return 0.0; // @step:initialize + + // Match window: characters within this distance can be considered matching + const matchWindow = Math.floor(Math.max(sourceLength, targetLength) / 2) - 1; // @step:initialize + + const sourceMatched = new Array(sourceLength).fill(false); // @step:initialize + const targetMatched = new Array(targetLength).fill(false); // @step:initialize + + let matchCount = 0; // @step:initialize + + // Find matching characters within the match window + for (let sourceIdx = 0; sourceIdx < sourceLength; sourceIdx++) { + // @step:compare + const windowStart = Math.max(0, sourceIdx - matchWindow); // @step:compare + const windowEnd = Math.min(targetLength - 1, sourceIdx + matchWindow); // @step:compare + + for (let targetIdx = windowStart; targetIdx <= windowEnd; targetIdx++) { + // @step:compare + if (!targetMatched[targetIdx] && source[sourceIdx] === target[targetIdx]) { + // @step:compare + sourceMatched[sourceIdx] = true; // @step:compute-distance + targetMatched[targetIdx] = true; // @step:compute-distance + matchCount++; // @step:compute-distance + break; + } + } + } + + // No matches means similarity is 0 + if (matchCount === 0) return 0.0; // @step:compute-distance + + // Count transpositions: matched chars in different order + let transpositionCount = 0; // @step:compute-distance + let targetScanIdx = 0; // @step:compute-distance + + for (let sourceIdx = 0; sourceIdx < sourceLength; sourceIdx++) { + // @step:compute-distance + if (!sourceMatched[sourceIdx]) continue; // @step:compute-distance + + while (!targetMatched[targetScanIdx]) { + // @step:compute-distance + targetScanIdx++; // @step:compute-distance + } + + if (source[sourceIdx] !== target[targetScanIdx]) { + // @step:compute-distance + transpositionCount++; // @step:compute-distance + } + + targetScanIdx++; // @step:compute-distance + } + + // Jaro similarity formula + const halfTranspositions = transpositionCount / 2; // @step:compute-distance + const jaroScore = + (matchCount / sourceLength + // @step:compute-distance + matchCount / targetLength + // @step:compute-distance + (matchCount - halfTranspositions) / matchCount) / // @step:compute-distance + 3; // @step:compute-distance + + // Count common prefix length (up to 4 characters) + const maxPrefixLength = 4; // @step:compute-distance + let prefixLength = 0; // @step:compute-distance + + for ( + let prefixIdx = 0; + prefixIdx < Math.min(maxPrefixLength, sourceLength, targetLength); + prefixIdx++ + ) { + // @step:compute-distance + if (source[prefixIdx] === target[prefixIdx]) { + // @step:compute-distance + prefixLength++; // @step:compute-distance + } else { + break; // @step:compute-distance + } + } + + // Winkler bonus: reward common prefix + const winklerBonus = prefixLength * 0.1 * (1 - jaroScore); // @step:compute-distance + const jaroWinklerScore = jaroScore + winklerBonus; // @step:compute-distance + + return Math.round(jaroWinklerScore * 10000) / 10000; // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.test.ts b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.test.ts new file mode 100644 index 00000000..8a86cbb2 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.test.ts @@ -0,0 +1,108 @@ +/** Step generation tests for Jaro-Winkler Similarity. */ + +import { describe, it, expect } from "vitest"; +import { generateJaroWinklerSimilaritySteps } from "./step-generator"; + +describe("generateJaroWinklerSimilaritySteps", () => { + it("produces steps for the default input", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits fill-table steps for base cases", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + const fillTableSteps = steps.filter((step) => step.type === "fill-table"); + expect(fillTableSteps.length).toBeGreaterThan(0); + }); + + it("emits compare steps during match-window scanning", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits compute-distance steps for match results", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + const computeSteps = steps.filter((step) => step.type === "compute-distance"); + expect(computeSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step for the matched pairs", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step with the correct similarity score", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "martha", target: "marhta" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + expect(foundStep?.visualState.kind).toBe("string-distance"); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBeCloseTo(0.9611, 4); + } + }); + + it("returns similarity 1.0 for identical strings", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "abc", target: "abc" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(1.0); + } + }); + + it("returns similarity 0.0 for empty source", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "", target: "abc" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(0.0); + } + }); + + it("matrix dimensions match source and target lengths", () => { + const source = "ab"; + const target = "cd"; + const steps = generateJaroWinklerSimilaritySteps({ source, target }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(source.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(target.length + 1); + } + }); + + it("produces a found step with result between 0 and 1", () => { + const steps = generateJaroWinklerSimilaritySteps({ source: "algorithm", target: "logarithm" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBeGreaterThanOrEqual(0); + expect(foundStep.visualState.result).toBeLessThanOrEqual(1); + } + }); +}); diff --git a/src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.ts b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.ts new file mode 100644 index 00000000..a0091553 --- /dev/null +++ b/src/algorithms/strings/edit-distance/jaro-winkler-similarity/step-generator.ts @@ -0,0 +1,194 @@ +/** Step generator for Jaro-Winkler Similarity — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const JARO_WINKLER_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.JARO_WINKLER_SIMILARITY!); + +export interface JaroWinklerSimilarityInput { + source: string; + target: string; +} + +export function generateJaroWinklerSimilaritySteps( + input: JaroWinklerSimilarityInput, +): ExecutionStep[] { + const { source, target } = input; + const tracker = new DistanceTracker(source, target, JARO_WINKLER_LINE_MAP); + + const sourceLength = source.length; + const targetLength = target.length; + + // Emit initialization step + tracker.initialize({ source, target, sourceLength, targetLength }); + + // Handle trivial cases — still emit base-case steps so the matrix is visible + if (source === target) { + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + tracker.fillBaseCase(0, colIdx, 0, { rowIdx: 0, colIdx, value: 0 }); + } + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + tracker.fillBaseCase(rowIdx, 0, 0, { rowIdx, colIdx: 0, value: 0 }); + } + tracker.updateResult(1.0, { similarity: 1.0 }); + tracker.complete({ result: 1.0 }); + return tracker.getSteps(); + } + + if (sourceLength === 0 || targetLength === 0) { + tracker.updateResult(0.0, { similarity: 0.0 }); + tracker.complete({ result: 0.0 }); + return tracker.getSteps(); + } + + // Compute match window + const matchWindow = Math.floor(Math.max(sourceLength, targetLength) / 2) - 1; + + // Fill row 0 and col 0 with 0s (base case for the match matrix display) + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + tracker.fillBaseCase(0, colIdx, 0, { rowIdx: 0, colIdx, value: 0 }); + } + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + tracker.fillBaseCase(rowIdx, 0, 0, { rowIdx, colIdx: 0, value: 0 }); + } + + // Track which characters are matched (for transposition counting) + const sourceMatched = new Array(sourceLength).fill(false); + const targetMatched = new Array(targetLength).fill(false); + let matchCount = 0; + + // For each source character, search for a match in the target window + for (let sourceIdx = 0; sourceIdx < sourceLength; sourceIdx++) { + const windowStart = Math.max(0, sourceIdx - matchWindow); + const windowEnd = Math.min(targetLength - 1, sourceIdx + matchWindow); + const rowIdx = sourceIdx + 1; + + for (let targetIdx = windowStart; targetIdx <= windowEnd; targetIdx++) { + const colIdx = targetIdx + 1; + const sourceChar = source[sourceIdx]!; + const targetChar = target[targetIdx]!; + const alreadyMatched = targetMatched[targetIdx] ?? false; + + if (!alreadyMatched) { + const isMatch = sourceChar === targetChar; + + // Emit comparison step + tracker.compareChars(sourceIdx, targetIdx, isMatch, { + sourceIdx, + targetIdx, + sourceChar, + targetChar, + isMatch, + matchWindow, + }); + + if (isMatch) { + sourceMatched[sourceIdx] = true; + targetMatched[targetIdx] = true; + matchCount++; + + // Record match result in the matrix (1 = match) + tracker.computeCell(rowIdx, colIdx, 1, { + rowIdx, + colIdx, + cellValue: 1, + isMatch: true, + matchCount, + }); + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue: 1 }); + break; + } else { + // Record no-match (0) in the matrix + tracker.computeCell(rowIdx, colIdx, 0, { + rowIdx, + colIdx, + cellValue: 0, + isMatch: false, + }); + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue: 0 }); + } + } + } + } + + // Count transpositions + let transpositionCount = 0; + let targetScanIdx = 0; + + for (let sourceIdx = 0; sourceIdx < sourceLength; sourceIdx++) { + if (!sourceMatched[sourceIdx]) continue; + + while (!(targetMatched[targetScanIdx] ?? false)) { + targetScanIdx++; + } + + if (source[sourceIdx] !== target[targetScanIdx]) { + transpositionCount++; + } + + targetScanIdx++; + } + + // Compute Jaro score + const halfTranspositions = transpositionCount / 2; + const jaroScore = + matchCount === 0 + ? 0 + : (matchCount / sourceLength + + matchCount / targetLength + + (matchCount - halfTranspositions) / matchCount) / + 3; + + // Compute prefix length (up to 4) + const maxPrefixLength = 4; + let prefixLength = 0; + for ( + let prefixIdx = 0; + prefixIdx < Math.min(maxPrefixLength, sourceLength, targetLength); + prefixIdx++ + ) { + if (source[prefixIdx] === target[prefixIdx]) { + prefixLength++; + } else { + break; + } + } + + // Compute Winkler bonus and final score + const winklerBonus = prefixLength * 0.1 * (1 - jaroScore); + const rawScore = jaroScore + winklerBonus; + const finalScore = Math.round(rawScore * 10000) / 10000; + + // Trace the matched pairs as the "path" + const matchedPath: [number, number][] = []; + for (let rowIdx = 0; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + if (rowIdx === 0 && colIdx === 0) { + matchedPath.push([0, 0]); + } else if ( + rowIdx > 0 && + colIdx > 0 && + sourceMatched[rowIdx - 1] && + targetMatched[colIdx - 1] + ) { + matchedPath.push([rowIdx, colIdx]); + } + } + } + tracker.tracePath(matchedPath, { matchCount, transpositionCount, prefixLength }); + + // Emit final result + tracker.updateResult(finalScore, { + jaroScore: Math.round(jaroScore * 10000) / 10000, + prefixLength, + winklerBonus: Math.round(winklerBonus * 10000) / 10000, + similarity: finalScore, + matchCount, + transpositions: transpositionCount, + }); + + tracker.complete({ result: finalScore }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/LevenshteinDistancePipeline.stories.tsx b/src/algorithms/strings/edit-distance/levenshtein-distance/LevenshteinDistancePipeline.stories.tsx new file mode 100644 index 00000000..a3a6b5c0 --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/LevenshteinDistancePipeline.stories.tsx @@ -0,0 +1,64 @@ +/** + * Storybook stories for the Levenshtein Distance algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateLevenshteinDistanceSteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateLevenshteinDistanceSteps({ + source: "kitten", + target: "sitting", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Levenshtein Distance", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty DP matrix before any cells are filled */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Base cases filled — row 0 and column 0 populated with insertion/deletion costs */ +export const BaseCasesFilled: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.15)]!.visualState as DistanceVisualState, + }, +}; + +/** Mid computation — DP matrix partially filled during interior cell pass */ +export const MidComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** Edit path traced — optimal path highlighted from bottom-right to top-left */ +export const EditPathTraced: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.9)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — edit distance 3 computed, result displayed */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/educational.ts b/src/algorithms/strings/edit-distance/levenshtein-distance/educational.ts new file mode 100644 index 00000000..3bf20d95 --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/educational.ts @@ -0,0 +1,71 @@ +/** Educational content for the Levenshtein Distance algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const levenshteinDistanceEducational: EducationalContent = { + overview: + "**Levenshtein Distance** (also called *edit distance*) measures how different two strings are by counting the minimum number of single-character **insertions**, **deletions**, or **replacements** needed to transform one string into the other.\n\n" + + "For example, transforming `kitten` into `sitting` requires 3 edits:\n\n" + + "1. `k` → `s` (replace)\n" + + "2. `e` → `i` (replace)\n" + + "3. `` → `g` (insert at end)\n\n" + + "The result is 3 — the edit distance between the two words.", + + howItWorks: + "Levenshtein Distance uses **dynamic programming** to fill a 2D matrix where `dp[rowIdx][colIdx]` stores the edit distance between `source[0..rowIdx-1]` and `target[0..colIdx-1]`.\n\n" + + "**1. Initialization:**\n\n" + + "- `dp[0][colIdx] = colIdx` — turning an empty string into `target[0..colIdx-1]` requires `colIdx` insertions.\n" + + "- `dp[rowIdx][0] = rowIdx` — turning `source[0..rowIdx-1]` into an empty string requires `rowIdx` deletions.\n\n" + + "**2. Recurrence (for each interior cell):**\n\n" + + "```\n" + + "if source[rowIdx-1] == target[colIdx-1]:\n" + + " dp[rowIdx][colIdx] = dp[rowIdx-1][colIdx-1] // no edit needed\n" + + "else:\n" + + " dp[rowIdx][colIdx] = 1 + min(\n" + + " dp[rowIdx-1][colIdx-1], // replace\n" + + " dp[rowIdx-1][colIdx], // delete\n" + + " dp[rowIdx][colIdx-1] // insert\n" + + " )\n" + + "```\n\n" + + "**3. Result:** `dp[sourceLength][targetLength]` holds the final edit distance.\n\n" + + "The edit path can be traced back through the matrix from the bottom-right cell to the top-left, recording which operation was chosen at each step.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "Every cell in the `(n+1) × (m+1)` matrix is computed exactly once in constant time, giving `O(n × m)` total — where `n = source.length` and `m = target.length`.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "The full DP matrix is stored. If only the final distance is needed (not the edit path), space can be reduced to `O(min(n, m))` by keeping only the current and previous rows.", + + bestAndWorstCase: + "**Best case — identical strings:** When `source === target`, every cell on the diagonal resolves to zero cost and the result is 0. Time is still `O(n × m)` because every cell must be visited.\n\n" + + "**Worst case — completely different strings:** When every character differs (e.g., `source = 'aaa'`, `target = 'bbb'`), every off-diagonal cell requires a +1 cost lookup, and the edit distance equals `max(n, m)`. Time is still `O(n × m)` — the DP structure has no early exit.\n\n" + + "Unlike greedy or heuristic approaches, Levenshtein always guarantees the **optimal** (minimum) edit distance.", + + realWorldUses: [ + "**Spell checkers:** Suggesting corrections by ranking dictionary words closest in edit distance to the misspelled word.", + "**DNA sequencing:** Measuring similarity between gene sequences where insertions and deletions (indels) are biologically meaningful edits.", + "**Git diff / merge tools:** Computing minimal edit scripts to display the differences between two versions of a file.", + "**Fuzzy search engines:** Enabling typo-tolerant queries in search bars and autocomplete systems.", + "**Plagiarism detection:** Identifying near-duplicate documents after normalization.", + "**OCR post-processing:** Correcting recognized text by finding the closest valid word using edit distance.", + ], + + strengthsAndLimitations: { + strengths: [ + "Guarantees the globally optimal (minimum) edit distance — no heuristic approximation.", + "Works on any alphabet — effective for strings, DNA sequences, and binary data alike.", + "The full DP matrix can be back-traced to recover the exact sequence of edits, not just the distance.", + "Space-optimizable to O(min(n, m)) when only the distance value is needed.", + ], + limitations: [ + "O(n × m) time and space — impractical for very long strings (e.g., comparing full documents).", + "Treats all edits (insert, delete, replace) as equal-cost; does not model transpositions (Damerau-Levenshtein handles those).", + "For large-scale fuzzy search, approximate algorithms (BK-trees, SimHash) are far more efficient.", + "No early termination — always fills the entire matrix even when the distance exceeds a threshold.", + ], + }, + + whenToUseIt: + "Use Levenshtein Distance when you need the **exact minimum edit distance** between two short-to-medium strings and the `O(n × m)` cost is acceptable. It is the right choice for spell-checking word dictionaries, comparing biological sequences of a few thousand characters, or computing diff scripts between small files.\n\n" + + "Avoid it for comparing very long strings (use Myers' diff or similar), when transpositions matter (use Damerau-Levenshtein), or when you need approximate matching at scale (use BK-trees, n-gram indexing, or locality-sensitive hashing instead).", +}; diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/index.ts b/src/algorithms/strings/edit-distance/levenshtein-distance/index.ts new file mode 100644 index 00000000..8b522363 --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Levenshtein Distance — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { levenshteinDistance } from "./sources/levenshtein-distance.ts?fn"; +import { generateLevenshteinDistanceSteps } from "./step-generator"; +import type { LevenshteinDistanceInput } from "./step-generator"; +import { levenshteinDistanceEducational } from "./educational"; + +import typescriptSource from "./sources/levenshtein-distance.ts?raw"; +import pythonSource from "./sources/levenshtein-distance.py?raw"; +import javaSource from "./sources/LevenshteinDistance.java?raw"; + +function executeLevenshteinDistance(input: LevenshteinDistanceInput): number { + return levenshteinDistance(input.source, input.target) as number; +} + +const levenshteinDistanceDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.LEVENSHTEIN_DISTANCE!, + name: "Levenshtein Distance", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Compute the minimum number of insertions, deletions, and replacements to transform one string into another using dynamic programming", + timeComplexity: { + best: "O(nm)", + average: "O(nm)", + worst: "O(nm)", + }, + spaceComplexity: "O(nm)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { source: "kitten", target: "sitting" }, + }, + execute: executeLevenshteinDistance, + generateSteps: generateLevenshteinDistanceSteps, + educational: levenshteinDistanceEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(levenshteinDistanceDefinition); diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/levenshtein-distance.test.ts b/src/algorithms/strings/edit-distance/levenshtein-distance/levenshtein-distance.test.ts new file mode 100644 index 00000000..b90d035a --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/levenshtein-distance.test.ts @@ -0,0 +1,58 @@ +/** Correctness tests for the levenshteinDistance pure function. */ + +import { describe, it, expect } from "vitest"; +import { levenshteinDistance } from "./sources/levenshtein-distance.ts?fn"; + +describe("levenshteinDistance", () => { + it('transforms "kitten" to "sitting" with edit distance 3', () => { + expect(levenshteinDistance("kitten", "sitting")).toBe(3); + }); + + it("returns the target length when source is empty", () => { + expect(levenshteinDistance("", "abc")).toBe(3); + }); + + it("returns the source length when target is empty", () => { + expect(levenshteinDistance("abc", "")).toBe(3); + }); + + it("returns 0 for identical strings", () => { + expect(levenshteinDistance("abc", "abc")).toBe(0); + }); + + it("returns 0 for two empty strings", () => { + expect(levenshteinDistance("", "")).toBe(0); + }); + + it("returns 1 for a single insertion", () => { + expect(levenshteinDistance("cat", "cats")).toBe(1); + }); + + it("returns 1 for a single deletion", () => { + expect(levenshteinDistance("cats", "cat")).toBe(1); + }); + + it("returns 1 for a single replacement", () => { + expect(levenshteinDistance("cat", "bat")).toBe(1); + }); + + it("handles completely different strings", () => { + expect(levenshteinDistance("abc", "xyz")).toBe(3); + }); + + it('transforms "sunday" to "saturday" with edit distance 3', () => { + expect(levenshteinDistance("sunday", "saturday")).toBe(3); + }); + + it("handles single-character strings that match", () => { + expect(levenshteinDistance("a", "a")).toBe(0); + }); + + it("handles single-character strings that differ", () => { + expect(levenshteinDistance("a", "b")).toBe(1); + }); + + it("handles repeated characters", () => { + expect(levenshteinDistance("aaa", "aa")).toBe(1); + }); +}); diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/sources/LevenshteinDistance.java b/src/algorithms/strings/edit-distance/levenshtein-distance/sources/LevenshteinDistance.java new file mode 100644 index 00000000..deedc447 --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/sources/LevenshteinDistance.java @@ -0,0 +1,46 @@ +// Levenshtein Distance (edit distance) +// Returns the minimum number of single-character edits (insertions, deletions, +// replacements) required to transform source into target. +// Time: O(nm), Space: O(nm) + +public class LevenshteinDistance { + + public static int levenshteinDistance(String source, String target) { + int sourceLength = source.length(); // @step:initialize + int targetLength = target.length(); // @step:initialize + + // Allocate (sourceLength+1) x (targetLength+1) DP matrix + int[][] dp = new int[sourceLength + 1][targetLength + 1]; // @step:initialize + + // Base case: transforming empty string to target[0..j-1] requires j insertions + for (int colIdx = 0; colIdx <= targetLength; colIdx++) { + dp[0][colIdx] = colIdx; // @step:fill-table + } + + // Base case: transforming source[0..i-1] to empty string requires i deletions + for (int rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + dp[rowIdx][0] = rowIdx; // @step:fill-table + } + + // Fill the rest of the matrix + for (int rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (int colIdx = 1; colIdx <= targetLength; colIdx++) { + char sourceChar = source.charAt(rowIdx - 1); // @step:compare + char targetChar = target.charAt(colIdx - 1); // @step:compare + + if (sourceChar == targetChar) { + // Characters match — no new edit needed + dp[rowIdx][colIdx] = dp[rowIdx - 1][colIdx - 1]; // @step:compute-distance + } else { + // Choose the cheapest of: replace, delete, insert + int replaceCost = dp[rowIdx - 1][colIdx - 1] + 1; // @step:compute-distance + int deleteCost = dp[rowIdx - 1][colIdx] + 1; // @step:compute-distance + int insertCost = dp[rowIdx][colIdx - 1] + 1; // @step:compute-distance + dp[rowIdx][colIdx] = Math.min(replaceCost, Math.min(deleteCost, insertCost)); // @step:compute-distance + } + } + } + + return dp[sourceLength][targetLength]; // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.py b/src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.py new file mode 100644 index 00000000..7a4664f4 --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.py @@ -0,0 +1,37 @@ +# Levenshtein Distance (edit distance) +# Returns the minimum number of single-character edits (insertions, deletions, +# replacements) required to transform source into target. +# Time: O(nm), Space: O(nm) + +def levenshtein_distance(source: str, target: str) -> int: + source_length = len(source) # @step:initialize + target_length = len(target) # @step:initialize + + # Allocate (source_length+1) x (target_length+1) DP matrix + dp = [[0] * (target_length + 1) for _ in range(source_length + 1)] # @step:initialize + + # Base case: transforming empty string to target[0..j-1] requires j insertions + for col_idx in range(target_length + 1): + dp[0][col_idx] = col_idx # @step:fill-table + + # Base case: transforming source[0..i-1] to empty string requires i deletions + for row_idx in range(1, source_length + 1): + dp[row_idx][0] = row_idx # @step:fill-table + + # Fill the rest of the matrix + for row_idx in range(1, source_length + 1): + for col_idx in range(1, target_length + 1): + source_char = source[row_idx - 1] # @step:compare + target_char = target[col_idx - 1] # @step:compare + + if source_char == target_char: + # Characters match — no new edit needed + dp[row_idx][col_idx] = dp[row_idx - 1][col_idx - 1] # @step:compute-distance + else: + # Choose the cheapest of: replace, delete, insert + replace_cost = dp[row_idx - 1][col_idx - 1] + 1 # @step:compute-distance + delete_cost = dp[row_idx - 1][col_idx] + 1 # @step:compute-distance + insert_cost = dp[row_idx][col_idx - 1] + 1 # @step:compute-distance + dp[row_idx][col_idx] = min(replace_cost, delete_cost, insert_cost) # @step:compute-distance + + return dp[source_length][target_length] # @step:complete diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.ts b/src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.ts new file mode 100644 index 00000000..7ed6d8b0 --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/sources/levenshtein-distance.ts @@ -0,0 +1,46 @@ +// Levenshtein Distance (edit distance) +// Returns the minimum number of single-character edits (insertions, deletions, +// replacements) required to transform source into target. +// Time: O(nm), Space: O(nm) where n = source.length, m = target.length + +export function levenshteinDistance(source: string, target: string): number { + const sourceLength = source.length; // @step:initialize + const targetLength = target.length; // @step:initialize + + // Allocate (sourceLength+1) × (targetLength+1) DP matrix + const dp: number[][] = Array.from({ length: sourceLength + 1 }, () => + // @step:initialize + new Array(targetLength + 1).fill(0), + ); + + // Base case: transforming empty string to target[0..j-1] requires j insertions + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + dp[0]![colIdx] = colIdx; // @step:fill-table + } + + // Base case: transforming source[0..i-1] to empty string requires i deletions + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + dp[rowIdx]![0] = rowIdx; // @step:fill-table + } + + // Fill the rest of the matrix + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + const sourceChar = source[rowIdx - 1]; // @step:compare + const targetChar = target[colIdx - 1]; // @step:compare + + if (sourceChar === targetChar) { + // Characters match — no new edit needed + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]!; // @step:compute-distance + } else { + // Choose the cheapest of: replace, delete, insert + const replaceCost = dp[rowIdx - 1]![colIdx - 1]! + 1; // @step:compute-distance + const deleteCost = dp[rowIdx - 1]![colIdx]! + 1; // @step:compute-distance + const insertCost = dp[rowIdx]![colIdx - 1]! + 1; // @step:compute-distance + dp[rowIdx]![colIdx] = Math.min(replaceCost, deleteCost, insertCost); // @step:compute-distance + } + } + } + + return dp[sourceLength]![targetLength]!; // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.test.ts b/src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.test.ts new file mode 100644 index 00000000..77c9f42c --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.test.ts @@ -0,0 +1,97 @@ +/** Step generation tests for Levenshtein Distance. */ + +import { describe, it, expect } from "vitest"; +import { generateLevenshteinDistanceSteps } from "./step-generator"; + +describe("generateLevenshteinDistanceSteps", () => { + it("produces steps for the default input", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits fill-table steps for base cases", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + const fillTableSteps = steps.filter((step) => step.type === "fill-table"); + expect(fillTableSteps.length).toBeGreaterThan(0); + }); + + it("emits compute-distance steps for interior cells", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + const computeSteps = steps.filter((step) => step.type === "compute-distance"); + expect(computeSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step with the correct edit distance", () => { + const steps = generateLevenshteinDistanceSteps({ source: "kitten", target: "sitting" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + expect(foundStep?.visualState.kind).toBe("string-distance"); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBe(3); + } + }); + + it("returns distance 3 for empty source and 3-char target", () => { + const steps = generateLevenshteinDistanceSteps({ source: "", target: "abc" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(3); + } + }); + + it("returns distance 0 for identical strings", () => { + const steps = generateLevenshteinDistanceSteps({ source: "abc", target: "abc" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(0); + } + }); + + it("emits compare steps when processing interior cells", () => { + const steps = generateLevenshteinDistanceSteps({ source: "ab", target: "ac" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("matrix dimensions match source and target lengths", () => { + const source = "abc"; + const target = "de"; + const steps = generateLevenshteinDistanceSteps({ source, target }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(source.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(target.length + 1); + } + }); +}); diff --git a/src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.ts b/src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.ts new file mode 100644 index 00000000..427905f9 --- /dev/null +++ b/src/algorithms/strings/edit-distance/levenshtein-distance/step-generator.ts @@ -0,0 +1,157 @@ +/** Step generator for Levenshtein Distance — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const LEVENSHTEIN_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.LEVENSHTEIN_DISTANCE!); + +export interface LevenshteinDistanceInput { + source: string; + target: string; +} + +export function generateLevenshteinDistanceSteps(input: LevenshteinDistanceInput): ExecutionStep[] { + const { source, target } = input; + const tracker = new DistanceTracker(source, target, LEVENSHTEIN_LINE_MAP); + + const sourceLength = source.length; + const targetLength = target.length; + + // Pre-compute the full DP matrix once so each cell value is available on demand. + const dp = buildDpMatrix(source, target); + + // Emit the initialization step + tracker.initialize({ source, target, sourceLength, targetLength }); + + // Fill base case for row 0: transforming empty string to target[0..j-1] = j insertions + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + tracker.fillBaseCase(0, colIdx, colIdx, { rowIdx: 0, colIdx, value: colIdx }); + } + + // Fill base case for col 0: transforming source[0..i-1] to empty string = i deletions + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + tracker.fillBaseCase(rowIdx, 0, rowIdx, { rowIdx, colIdx: 0, value: rowIdx }); + } + + // Fill interior cells row by row + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + const sourceChar = source[rowIdx - 1]!; + const targetChar = target[colIdx - 1]!; + const isMatch = sourceChar === targetChar; + + // Emit a comparison step for this cell's characters + tracker.compareChars(rowIdx - 1, colIdx - 1, isMatch, { + rowIdx, + colIdx, + sourceChar, + targetChar, + isMatch, + }); + + const cellValue = dp[rowIdx]![colIdx]!; + + // Emit compute step (sets cell to "computing") + tracker.computeCell(rowIdx, colIdx, cellValue, { + rowIdx, + colIdx, + cellValue, + isMatch, + }); + + // Finalise cell as "computed" + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue }); + } + } + + // Trace the edit path back from bottom-right to top-left + const editPath = traceEditPath(dp, source, target); + tracker.tracePath(editPath, { pathLength: editPath.length }); + + // Record final result + const editDistance = dp[sourceLength]![targetLength]!; + tracker.updateResult(editDistance, { editDistance }); + + tracker.complete({ result: editDistance }); + return tracker.getSteps(); +} + +/** + * Build the full Levenshtein DP matrix for a given source/target pair. + * Returns a (sourceLength+1) × (targetLength+1) matrix where + * dp[rowIdx][colIdx] is the edit distance between source[0..rowIdx-1] and target[0..colIdx-1]. + */ +function buildDpMatrix(source: string, target: string): number[][] { + const sourceLength = source.length; + const targetLength = target.length; + const dp: number[][] = Array.from({ length: sourceLength + 1 }, () => + new Array(targetLength + 1).fill(0), + ); + + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + dp[0]![colIdx] = colIdx; + } + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + dp[rowIdx]![0] = rowIdx; + } + + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + if (source[rowIdx - 1] === target[colIdx - 1]) { + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]!; + } else { + dp[rowIdx]![colIdx] = Math.min( + dp[rowIdx - 1]![colIdx - 1]! + 1, + dp[rowIdx - 1]![colIdx]! + 1, + dp[rowIdx]![colIdx - 1]! + 1, + ); + } + } + } + + return dp; +} + +/** + * Trace the optimal edit path from bottom-right to top-left through a pre-built DP matrix. + * Returns an array of [rowIdx, colIdx] pairs representing the path in forward order. + */ +function traceEditPath(dp: number[][], source: string, target: string): [number, number][] { + const path: [number, number][] = []; + + let rowIdx = source.length; + let colIdx = target.length; + + while (rowIdx > 0 || colIdx > 0) { + path.push([rowIdx, colIdx]); + + if (rowIdx === 0) { + colIdx--; + } else if (colIdx === 0) { + rowIdx--; + } else if (source[rowIdx - 1] === target[colIdx - 1]) { + // Match — came from diagonal + rowIdx--; + colIdx--; + } else { + const replaceCost = dp[rowIdx - 1]![colIdx - 1]!; + const deleteCost = dp[rowIdx - 1]![colIdx]!; + const insertCost = dp[rowIdx]![colIdx - 1]!; + const minCost = Math.min(replaceCost, deleteCost, insertCost); + + if (minCost === replaceCost) { + rowIdx--; + colIdx--; + } else if (minCost === deleteCost) { + rowIdx--; + } else { + colIdx--; + } + } + } + + path.push([0, 0]); + return path.reverse(); +} diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/LongestCommonSubsequencePipeline.stories.tsx b/src/algorithms/strings/edit-distance/longest-common-subsequence/LongestCommonSubsequencePipeline.stories.tsx new file mode 100644 index 00000000..b3312ec3 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/LongestCommonSubsequencePipeline.stories.tsx @@ -0,0 +1,64 @@ +/** + * Storybook stories for the Longest Common Subsequence algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateLongestCommonSubsequenceSteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateLongestCommonSubsequenceSteps({ + source: "ABCBDAB", + target: "BDCAB", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Longest Common Subsequence", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty DP matrix before any cells are filled */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Base cases filled — row 0 and column 0 set to zero */ +export const BaseCasesFilled: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.15)]!.visualState as DistanceVisualState, + }, +}; + +/** Mid computation — DP matrix partially filled during interior cell pass */ +export const MidComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** LCS path traced — matched cells highlighted from bottom-right to top-left */ +export const LcsPathTraced: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.9)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — LCS length 4 computed, result displayed */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/educational.ts b/src/algorithms/strings/edit-distance/longest-common-subsequence/educational.ts new file mode 100644 index 00000000..93fa70f0 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/educational.ts @@ -0,0 +1,70 @@ +/** Educational content for the Longest Common Subsequence algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const longestCommonSubsequenceEducational: EducationalContent = { + overview: + "**Longest Common Subsequence (LCS)** finds the longest sequence of characters that appears in the same relative order in both strings, though the characters do not need to be contiguous.\n\n" + + "For example, the LCS of `ABCBDAB` and `BDCAB` is `BCAB` (or `BDAB`), giving a length of **4**.\n\n" + + "Unlike substring matching, subsequences can skip characters — `ACE` is a subsequence of `ABCDE` because A, C, and E appear in order even though B and D are skipped.\n\n" + + "LCS is a foundational problem in computer science with direct applications in diff tools, bioinformatics, and version control.", + + howItWorks: + "LCS uses **dynamic programming** to fill a 2D matrix where `dp[rowIdx][colIdx]` stores the LCS length of `source[0..rowIdx-1]` and `target[0..colIdx-1]`.\n\n" + + "**1. Initialization:**\n\n" + + "- `dp[0][colIdx] = 0` — the LCS of an empty source with any target prefix is 0.\n" + + "- `dp[rowIdx][0] = 0` — the LCS of any source prefix with an empty target is 0.\n\n" + + "**2. Recurrence (for each interior cell):**\n\n" + + "```\n" + + "if source[rowIdx-1] == target[colIdx-1]:\n" + + " dp[rowIdx][colIdx] = dp[rowIdx-1][colIdx-1] + 1 // match: extend LCS\n" + + "else:\n" + + " dp[rowIdx][colIdx] = max(\n" + + " dp[rowIdx-1][colIdx], // skip source character\n" + + " dp[rowIdx][colIdx-1] // skip target character\n" + + " )\n" + + "```\n\n" + + "**3. Result:** `dp[sourceLength][targetLength]` holds the final LCS length.\n\n" + + "**4. Backtracking:** To reconstruct the actual subsequence, trace from the bottom-right cell:\n" + + "- If characters matched, move diagonally up-left and record that character.\n" + + "- Otherwise move toward the cell with the larger value (up or left).", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "Every cell in the `(n+1) × (m+1)` matrix is computed in constant time, yielding `O(n × m)` total — where `n = source.length` and `m = target.length`.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "The full DP matrix is stored. If only the LCS length is needed (not the actual subsequence), space can be reduced to `O(min(n, m))` by keeping only two rows at a time.", + + bestAndWorstCase: + "**Best case — identical strings:** Every character on the main diagonal matches, so `dp[i][i] = i` and the LCS equals the string length. The matrix is still filled in `O(n × m)` time — no early exit exists.\n\n" + + "**Worst case — no common characters:** Every cell is decided by a `max` of neighbours with no diagonal extension. The LCS is 0, but time is still `O(n × m)` because every cell must be evaluated.\n\n" + + "Unlike greedy approaches, LCS always guarantees the **globally longest** common subsequence.", + + realWorldUses: [ + "**Git diff / patch files:** Computing the minimal set of additions and deletions between two versions of a file to produce human-readable diffs.", + "**DNA sequence alignment:** Identifying conserved genetic regions by finding the longest common subsequence between two DNA or protein sequences.", + "**Plagiarism detection:** Measuring similarity between student submissions by comparing their LCS length relative to document length.", + "**Version control merge tools:** Detecting which lines were preserved, added, or removed when merging two branches of a file.", + "**Speech recognition post-processing:** Aligning recognized word sequences against known transcripts to evaluate accuracy.", + "**Data compression:** Identifying repeated structures within data streams to find opportunities for reference-based compression.", + ], + + strengthsAndLimitations: { + strengths: [ + "Guarantees the globally longest common subsequence — exact result with no approximation.", + "Works on any ordered sequence (strings, arrays, gene sequences) with any element type.", + "The DP matrix can be back-traced to recover the actual subsequence, not just its length.", + "Space-optimizable to O(min(n, m)) when only the length is required.", + ], + limitations: [ + "O(n × m) time and space — becomes impractical for very long sequences (e.g., large files or genomes).", + "Does not account for transpositions or near-matches; every position is evaluated independently.", + "For approximate matching at scale, heuristic methods (suffix arrays, n-gram indexing) are far faster.", + "No early termination — the entire matrix is filled even if the LCS is known to be short.", + ], + }, + + whenToUseIt: + "Use LCS when you need the **exact longest common subsequence** of two short-to-medium strings and `O(n × m)` cost is acceptable. It is the right choice for file diff utilities, biological sequence comparison, or any problem where preserving order matters but contiguity does not.\n\n" + + "Avoid it for comparing very long texts (use Myers' diff or suffix-array-based algorithms), when you need approximate matching at scale (use BK-trees or n-gram indices), or when transpositions should count as matches (consider Damerau-Levenshtein or more specialized alignment algorithms).", +}; diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/index.ts b/src/algorithms/strings/edit-distance/longest-common-subsequence/index.ts new file mode 100644 index 00000000..9224f316 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Longest Common Subsequence — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { longestCommonSubsequence } from "./sources/longest-common-subsequence.ts?fn"; +import { generateLongestCommonSubsequenceSteps } from "./step-generator"; +import type { LongestCommonSubsequenceInput } from "./step-generator"; +import { longestCommonSubsequenceEducational } from "./educational"; + +import typescriptSource from "./sources/longest-common-subsequence.ts?raw"; +import pythonSource from "./sources/longest-common-subsequence.py?raw"; +import javaSource from "./sources/LongestCommonSubsequence.java?raw"; + +function executeLongestCommonSubsequence(input: LongestCommonSubsequenceInput): number { + return longestCommonSubsequence(input.source, input.target) as number; +} + +const longestCommonSubsequenceDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.LONGEST_COMMON_SUBSEQUENCE!, + name: "Longest Common Subsequence", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Find the length of the longest subsequence present in both strings using dynamic programming — characters need not be contiguous but must maintain relative order", + timeComplexity: { + best: "O(nm)", + average: "O(nm)", + worst: "O(nm)", + }, + spaceComplexity: "O(nm)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { source: "ABCBDAB", target: "BDCAB" }, + }, + execute: executeLongestCommonSubsequence, + generateSteps: generateLongestCommonSubsequenceSteps, + educational: longestCommonSubsequenceEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(longestCommonSubsequenceDefinition); diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/longest-common-subsequence.test.ts b/src/algorithms/strings/edit-distance/longest-common-subsequence/longest-common-subsequence.test.ts new file mode 100644 index 00000000..cd3787c8 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/longest-common-subsequence.test.ts @@ -0,0 +1,66 @@ +/** Correctness tests for the longestCommonSubsequence pure function. */ + +import { describe, it, expect } from "vitest"; +import { longestCommonSubsequence } from "./sources/longest-common-subsequence.ts?fn"; + +describe("longestCommonSubsequence", () => { + it('returns 4 for "ABCBDAB" and "BDCAB"', () => { + expect(longestCommonSubsequence("ABCBDAB", "BDCAB")).toBe(4); + }); + + it("returns 0 when source is empty", () => { + expect(longestCommonSubsequence("", "abc")).toBe(0); + }); + + it("returns 0 when target is empty", () => { + expect(longestCommonSubsequence("abc", "")).toBe(0); + }); + + it("returns 0 for two empty strings", () => { + expect(longestCommonSubsequence("", "")).toBe(0); + }); + + it("returns the string length for identical strings", () => { + expect(longestCommonSubsequence("abc", "abc")).toBe(3); + }); + + it("returns 0 when no characters are shared", () => { + expect(longestCommonSubsequence("abc", "xyz")).toBe(0); + }); + + it("returns 1 for a single shared character", () => { + expect(longestCommonSubsequence("a", "a")).toBe(1); + }); + + it("returns 0 for single characters that differ", () => { + expect(longestCommonSubsequence("a", "b")).toBe(0); + }); + + it('returns 4 for "AGGTAB" and "GXTXAYB"', () => { + // LCS is "GTAB" — length 4 + expect(longestCommonSubsequence("AGGTAB", "GXTXAYB")).toBe(4); + }); + + it('returns 2 for "ABC" and "AC"', () => { + expect(longestCommonSubsequence("ABC", "AC")).toBe(2); + }); + + it("handles repeated characters correctly", () => { + // LCS of "aaa" and "aa" is "aa" — length 2 + expect(longestCommonSubsequence("aaa", "aa")).toBe(2); + }); + + it('returns 1 for "AB" and "B"', () => { + expect(longestCommonSubsequence("AB", "B")).toBe(1); + }); + + it("handles subsequences that are not substrings", () => { + // "ACE" is an LCS of "ABCDE" and "ACE" — length 3 + expect(longestCommonSubsequence("ABCDE", "ACE")).toBe(3); + }); + + it('returns 3 for "XMJYAUZ" and "MZJAWXU"', () => { + // LCS is "MJAU" — length 4 + expect(longestCommonSubsequence("XMJYAUZ", "MZJAWXU")).toBe(4); + }); +}); diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/LongestCommonSubsequence.java b/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/LongestCommonSubsequence.java new file mode 100644 index 00000000..0f2fec4a --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/LongestCommonSubsequence.java @@ -0,0 +1,46 @@ +// Longest Common Subsequence (LCS) +// Returns the length of the longest subsequence common to both source and target. +// A subsequence preserves relative order but need not be contiguous. +// Time: O(nm), Space: O(nm) + +public class LongestCommonSubsequence { + + public static int longestCommonSubsequence(String source, String target) { + int sourceLength = source.length(); // @step:initialize + int targetLength = target.length(); // @step:initialize + + // Allocate (sourceLength+1) x (targetLength+1) DP matrix, all zeroed + int[][] dp = new int[sourceLength + 1][targetLength + 1]; // @step:initialize + + // Base case: dp[0][j] = 0 (LCS of empty string and any string is 0) + for (int colIdx = 0; colIdx <= targetLength; colIdx++) { + dp[0][colIdx] = 0; // @step:fill-table + } + + // Base case: dp[i][0] = 0 (LCS of any string and empty string is 0) + for (int rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + dp[rowIdx][0] = 0; // @step:fill-table + } + + // Fill the rest of the matrix + for (int rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (int colIdx = 1; colIdx <= targetLength; colIdx++) { + char sourceChar = source.charAt(rowIdx - 1); // @step:compare + char targetChar = target.charAt(colIdx - 1); // @step:compare + + if (sourceChar == targetChar) { + // Characters match — extend the LCS by 1 + dp[rowIdx][colIdx] = dp[rowIdx - 1][colIdx - 1] + 1; // @step:compute-distance + } else { + // Take the best of: skip source char or skip target char + dp[rowIdx][colIdx] = Math.max( // @step:compute-distance + dp[rowIdx - 1][colIdx], + dp[rowIdx][colIdx - 1] + ); + } + } + } + + return dp[sourceLength][targetLength]; // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.py b/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.py new file mode 100644 index 00000000..6b0341d6 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.py @@ -0,0 +1,37 @@ +# Longest Common Subsequence (LCS) +# Returns the length of the longest subsequence common to both source and target. +# A subsequence preserves relative order but need not be contiguous. +# Time: O(nm), Space: O(nm) + +def longest_common_subsequence(source: str, target: str) -> int: + source_length = len(source) # @step:initialize + target_length = len(target) # @step:initialize + + # Allocate (source_length+1) x (target_length+1) DP matrix, all zeroed + dp = [[0] * (target_length + 1) for _ in range(source_length + 1)] # @step:initialize + + # Base case: dp[0][j] = 0 (LCS of empty string and any string is 0) + for col_idx in range(target_length + 1): + dp[0][col_idx] = 0 # @step:fill-table + + # Base case: dp[i][0] = 0 (LCS of any string and empty string is 0) + for row_idx in range(1, source_length + 1): + dp[row_idx][0] = 0 # @step:fill-table + + # Fill the rest of the matrix + for row_idx in range(1, source_length + 1): + for col_idx in range(1, target_length + 1): + source_char = source[row_idx - 1] # @step:compare + target_char = target[col_idx - 1] # @step:compare + + if source_char == target_char: + # Characters match — extend the LCS by 1 + dp[row_idx][col_idx] = dp[row_idx - 1][col_idx - 1] + 1 # @step:compute-distance + else: + # Take the best of: skip source char or skip target char + dp[row_idx][col_idx] = max( # @step:compute-distance + dp[row_idx - 1][col_idx], + dp[row_idx][col_idx - 1], + ) + + return dp[source_length][target_length] # @step:complete diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.ts b/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.ts new file mode 100644 index 00000000..8e94b4c4 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/sources/longest-common-subsequence.ts @@ -0,0 +1,47 @@ +// Longest Common Subsequence (LCS) +// Returns the length of the longest subsequence common to both source and target. +// A subsequence preserves relative order but need not be contiguous. +// Time: O(nm), Space: O(nm) where n = source.length, m = target.length + +export function longestCommonSubsequence(source: string, target: string): number { + const sourceLength = source.length; // @step:initialize + const targetLength = target.length; // @step:initialize + + // Allocate (sourceLength+1) × (targetLength+1) DP matrix, all zeroed + const dp: number[][] = Array.from({ length: sourceLength + 1 }, () => + // @step:initialize + new Array(targetLength + 1).fill(0), + ); + + // Base case: dp[0][j] = 0 (LCS of empty string and any string is 0) + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + dp[0]![colIdx] = 0; // @step:fill-table + } + + // Base case: dp[i][0] = 0 (LCS of any string and empty string is 0) + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + dp[rowIdx]![0] = 0; // @step:fill-table + } + + // Fill the rest of the matrix + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + const sourceChar = source[rowIdx - 1]; // @step:compare + const targetChar = target[colIdx - 1]; // @step:compare + + if (sourceChar === targetChar) { + // Characters match — extend the LCS by 1 + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]! + 1; // @step:compute-distance + } else { + // Take the best of: skip source char or skip target char + dp[rowIdx]![colIdx] = Math.max( + // @step:compute-distance + dp[rowIdx - 1]![colIdx]!, + dp[rowIdx]![colIdx - 1]!, + ); + } + } + } + + return dp[sourceLength]![targetLength]!; // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.test.ts b/src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.test.ts new file mode 100644 index 00000000..8a5b6f95 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.test.ts @@ -0,0 +1,105 @@ +/** Step generation tests for Longest Common Subsequence. */ + +import { describe, it, expect } from "vitest"; +import { generateLongestCommonSubsequenceSteps } from "./step-generator"; + +describe("generateLongestCommonSubsequenceSteps", () => { + it("produces steps for the default input", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits fill-table steps for base cases", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + const fillTableSteps = steps.filter((step) => step.type === "fill-table"); + expect(fillTableSteps.length).toBeGreaterThan(0); + }); + + it("emits compute-distance steps for interior cells", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + const computeSteps = steps.filter((step) => step.type === "compute-distance"); + expect(computeSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step with the correct LCS length", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "ABCBDAB", target: "BDCAB" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + expect(foundStep?.visualState.kind).toBe("string-distance"); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBe(4); + } + }); + + it("returns LCS 0 for empty source", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "", target: "abc" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(0); + } + }); + + it("returns full length for identical strings", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "abc", target: "abc" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(3); + } + }); + + it("emits compare steps when processing interior cells", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "AB", target: "AC" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("matrix dimensions match source and target lengths", () => { + const source = "ABC"; + const target = "DE"; + const steps = generateLongestCommonSubsequenceSteps({ source, target }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(source.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(target.length + 1); + } + }); + + it("returns LCS 0 when no characters are shared", () => { + const steps = generateLongestCommonSubsequenceSteps({ source: "abc", target: "xyz" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(0); + } + }); +}); diff --git a/src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.ts b/src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.ts new file mode 100644 index 00000000..e0ed8d07 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-subsequence/step-generator.ts @@ -0,0 +1,134 @@ +/** Step generator for Longest Common Subsequence — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const LCS_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.LONGEST_COMMON_SUBSEQUENCE!); + +export interface LongestCommonSubsequenceInput { + source: string; + target: string; +} + +export function generateLongestCommonSubsequenceSteps( + input: LongestCommonSubsequenceInput, +): ExecutionStep[] { + const { source, target } = input; + const tracker = new DistanceTracker(source, target, LCS_LINE_MAP); + + const sourceLength = source.length; + const targetLength = target.length; + + // Pre-compute the full DP matrix so every cell value is available on demand. + const dp = buildLcsDpMatrix(source, target); + + // Emit the initialization step + tracker.initialize({ source, target, sourceLength, targetLength }); + + // Fill base case for row 0: LCS of empty source prefix with any target prefix is 0 + for (let colIdx = 0; colIdx <= targetLength; colIdx++) { + tracker.fillBaseCase(0, colIdx, 0, { rowIdx: 0, colIdx, value: 0 }); + } + + // Fill base case for col 0: LCS of any source prefix with empty target prefix is 0 + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + tracker.fillBaseCase(rowIdx, 0, 0, { rowIdx, colIdx: 0, value: 0 }); + } + + // Fill interior cells row by row + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + const sourceChar = source[rowIdx - 1]!; + const targetChar = target[colIdx - 1]!; + const isMatch = sourceChar === targetChar; + + // Emit a comparison step for this cell's characters + tracker.compareChars(rowIdx - 1, colIdx - 1, isMatch, { + rowIdx, + colIdx, + sourceChar, + targetChar, + isMatch, + }); + + const cellValue = dp[rowIdx]![colIdx]!; + + // Emit compute step (sets cell to "computing") + tracker.computeCell(rowIdx, colIdx, cellValue, { + rowIdx, + colIdx, + cellValue, + isMatch, + }); + + // Finalise cell as "computed" + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue }); + } + } + + // Trace the LCS path back from bottom-right to top-left + const lcsPath = traceLcsPath(dp, source, target); + tracker.tracePath(lcsPath, { pathLength: lcsPath.length }); + + // Record final result + const lcsLength = dp[sourceLength]![targetLength]!; + tracker.updateResult(lcsLength, { lcsLength }); + + tracker.complete({ result: lcsLength }); + return tracker.getSteps(); +} + +/** + * Build the full LCS DP matrix for a given source/target pair. + * Returns a (sourceLength+1) × (targetLength+1) matrix where + * dp[rowIdx][colIdx] is the LCS length of source[0..rowIdx-1] and target[0..colIdx-1]. + */ +function buildLcsDpMatrix(source: string, target: string): number[][] { + const sourceLength = source.length; + const targetLength = target.length; + const dp: number[][] = Array.from({ length: sourceLength + 1 }, () => + new Array(targetLength + 1).fill(0), + ); + + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + if (source[rowIdx - 1] === target[colIdx - 1]) { + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]! + 1; + } else { + dp[rowIdx]![colIdx] = Math.max(dp[rowIdx - 1]![colIdx]!, dp[rowIdx]![colIdx - 1]!); + } + } + } + + return dp; +} + +/** + * Trace the LCS path from bottom-right to top-left through a pre-built DP matrix. + * Returns an array of [rowIdx, colIdx] pairs in forward order covering the matched cells. + */ +function traceLcsPath(dp: number[][], source: string, target: string): [number, number][] { + const path: [number, number][] = []; + + let rowIdx = source.length; + let colIdx = target.length; + + while (rowIdx > 0 && colIdx > 0) { + if (source[rowIdx - 1] === target[colIdx - 1]) { + // Characters match — this cell is part of the LCS + path.push([rowIdx, colIdx]); + rowIdx--; + colIdx--; + } else if (dp[rowIdx - 1]![colIdx]! >= dp[rowIdx]![colIdx - 1]!) { + // Came from above — skip this source character + rowIdx--; + } else { + // Came from the left — skip this target character + colIdx--; + } + } + + return path.reverse(); +} diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/LongestCommonSubstringPipeline.stories.tsx b/src/algorithms/strings/edit-distance/longest-common-substring/LongestCommonSubstringPipeline.stories.tsx new file mode 100644 index 00000000..8c7365f9 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/LongestCommonSubstringPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Longest Common Substring algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateLongestCommonSubstringSteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateLongestCommonSubstringSteps({ + source: "ABABC", + target: "BABCBA", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Longest Common Substring", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty DP matrix before computation begins */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Mid computation — matrix partially filled with substring lengths */ +export const MidComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** Substring path traced — diagonal path highlighting the longest common substring */ +export const SubstringPathTraced: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.9)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — longest common substring length computed */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/educational.ts b/src/algorithms/strings/edit-distance/longest-common-substring/educational.ts new file mode 100644 index 00000000..7d437c4b --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/educational.ts @@ -0,0 +1,63 @@ +/** Educational content for the Longest Common Substring algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const longestCommonSubstringEducational: EducationalContent = { + overview: + "**Longest Common Substring** finds the longest contiguous sequence of characters that appears in both a source string and a target string.\n\n" + + "Unlike the *Longest Common Subsequence* (LCS), the characters must be adjacent — they cannot skip positions. For example, the longest common substring of `ABABC` and `BABCBA` is `BABC`, which has length **4**.\n\n" + + "The algorithm uses dynamic programming to efficiently check every possible alignment of the two strings in a single pass.", + + howItWorks: + "Longest Common Substring uses a 2D DP matrix where `dp[rowIdx][colIdx]` stores the length of the longest common substring ending **exactly** at `source[rowIdx-1]` and `target[colIdx-1]`.\n\n" + + "**Recurrence:**\n\n" + + "```\n" + + "if source[rowIdx-1] == target[colIdx-1]:\n" + + " dp[rowIdx][colIdx] = dp[rowIdx-1][colIdx-1] + 1 // extend the match\n" + + "else:\n" + + " dp[rowIdx][colIdx] = 0 // reset — no match here\n" + + "```\n\n" + + "**Key insight:** When characters differ the cell resets to 0, because a common substring must be contiguous. This is the critical difference from LCS, where mismatches carry forward the best prior value.\n\n" + + "**Base cases:** Row 0 and column 0 are initialized to 0, representing an empty source or target.\n\n" + + "**Result:** The answer is the maximum value ever written into the matrix, tracked as the cells are filled.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "Every cell in the `(n+1) × (m+1)` matrix is computed exactly once in `O(1)` time, giving `O(n × m)` total — where `n = source.length` and `m = target.length`.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "The full DP matrix is stored. If only the length is needed (not the substring itself), space can be reduced to `O(min(n, m))` by keeping only the current and previous rows, since each cell depends only on the diagonal predecessor.", + + bestAndWorstCase: + "**Best case — no common characters:** When source and target share no characters at all, every cell in the matrix is 0. The algorithm still runs in `O(n × m)` because every cell must be visited, but the result is 0.\n\n" + + "**Typical case:** Partial overlap produces a sparse pattern of non-zero diagonals. The algorithm correctly isolates the longest contiguous run.\n\n" + + "**Worst case — identical strings:** When `source === target`, the longest diagonal of the matrix equals the full string length. The result equals `n`, but the time cost is still `O(n²)` because all cells are visited.\n\n" + + "Unlike Levenshtein Distance, there is no concept of a 'best' input for performance — the matrix must always be fully evaluated.", + + realWorldUses: [ + "**Plagiarism detection:** Identifying verbatim copied passages between documents after tokenization.", + "**Bioinformatics:** Finding conserved regions (motifs) in DNA or protein sequences where exact alignment matters.", + "**Version control diff tools:** Locating the longest unchanged code block between two file revisions.", + "**Data deduplication:** Discovering repeated byte sequences in file systems and compression codecs.", + "**Search engine query matching:** Highlighting the longest exact phrase match within a document snippet.", + "**Intrusion detection:** Comparing network packet payloads against known attack signatures for exact-match detection.", + ], + + strengthsAndLimitations: { + strengths: [ + "Guarantees the globally optimal (longest) contiguous match — no heuristic approximation.", + "Simple recurrence: cells either extend a diagonal run by 1 or reset to 0.", + "The full DP matrix can be inspected to find all common substrings, not just the longest.", + "Space-reducible to O(min(n, m)) when only the length is required.", + ], + limitations: [ + "O(n × m) time and space — impractical for comparing large documents or binary files directly.", + "Finds only one longest common substring; ties require additional bookkeeping to enumerate.", + "Sensitive to minor formatting differences — a single extra space breaks an otherwise matching run.", + "No early termination — the full matrix must be computed even if a long match is found early.", + ], + }, + + whenToUseIt: + "Use Longest Common Substring when you need the **exact longest contiguous match** between two short-to-medium strings and `O(n × m)` cost is acceptable. It is the right choice for plagiarism checks on paragraphs, motif finding in gene sequences, or exact-phrase highlighting in search results.\n\n" + + "Prefer **Longest Common Subsequence** (LCS) when the characters do not need to be contiguous — for example, in diff algorithms that track structural edits across non-adjacent lines. For very long strings or large-scale approximate matching, use suffix arrays (O(n log n) construction) or rolling-hash approaches (Rabin-Karp) instead.", +}; diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/index.ts b/src/algorithms/strings/edit-distance/longest-common-substring/index.ts new file mode 100644 index 00000000..81744eea --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/index.ts @@ -0,0 +1,48 @@ +/** Registry entry for Longest Common Substring — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { longestCommonSubstring } from "./sources/longest-common-substring.ts?fn"; +import { generateLongestCommonSubstringSteps } from "./step-generator"; +import type { LongestCommonSubstringInput } from "./step-generator"; +import { longestCommonSubstringEducational } from "./educational"; + +import typescriptSource from "./sources/longest-common-substring.ts?raw"; +import pythonSource from "./sources/longest-common-substring.py?raw"; +import javaSource from "./sources/LongestCommonSubstring.java?raw"; + +function executeLongestCommonSubstring(input: LongestCommonSubstringInput): number { + return longestCommonSubstring(input.source, input.target) as number; +} + +const longestCommonSubstringDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.LONGEST_COMMON_SUBSTRING!, + name: "Longest Common Substring", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Find the length of the longest contiguous sequence of characters shared by two strings using dynamic programming", + timeComplexity: { + best: "O(nm)", + average: "O(nm)", + worst: "O(nm)", + }, + spaceComplexity: "O(nm)", + supportedLanguages: ["typescript", "python", "java"], + // "ABABC" and "BABCBA" share longest common substring "BABC" of length 4 + defaultInput: { source: "ABABC", target: "BABCBA" }, + }, + execute: executeLongestCommonSubstring, + generateSteps: generateLongestCommonSubstringSteps, + educational: longestCommonSubstringEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(longestCommonSubstringDefinition); diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/longest-common-substring.test.ts b/src/algorithms/strings/edit-distance/longest-common-substring/longest-common-substring.test.ts new file mode 100644 index 00000000..12e37b19 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/longest-common-substring.test.ts @@ -0,0 +1,58 @@ +/** Correctness tests for the longestCommonSubstring pure function. */ + +import { describe, it, expect } from "vitest"; +import { longestCommonSubstring } from "./sources/longest-common-substring.ts?fn"; + +describe("longestCommonSubstring", () => { + it('finds the longest common substring between "ABABC" and "BABCBA" (length 4)', () => { + expect(longestCommonSubstring("ABABC", "BABCBA")).toBe(4); + }); + + it("returns 0 when source is empty", () => { + expect(longestCommonSubstring("", "abc")).toBe(0); + }); + + it("returns 0 when target is empty", () => { + expect(longestCommonSubstring("abc", "")).toBe(0); + }); + + it("returns 0 for two empty strings", () => { + expect(longestCommonSubstring("", "")).toBe(0); + }); + + it("returns full length for identical strings", () => { + expect(longestCommonSubstring("abc", "abc")).toBe(3); + }); + + it("returns 0 for completely different strings", () => { + expect(longestCommonSubstring("abc", "xyz")).toBe(0); + }); + + it("finds a single matching character", () => { + expect(longestCommonSubstring("abc", "xbz")).toBe(1); + }); + + it("handles single-character strings that match", () => { + expect(longestCommonSubstring("a", "a")).toBe(1); + }); + + it("handles single-character strings that differ", () => { + expect(longestCommonSubstring("a", "b")).toBe(0); + }); + + it("finds substring at the beginning", () => { + expect(longestCommonSubstring("abcdef", "abcxyz")).toBe(3); + }); + + it("finds substring at the end", () => { + expect(longestCommonSubstring("xyzabc", "defabc")).toBe(3); + }); + + it("returns longest when multiple substrings exist", () => { + expect(longestCommonSubstring("abXYZcd", "abXYcd")).toBe(4); + }); + + it("handles repeated characters", () => { + expect(longestCommonSubstring("aaaa", "aa")).toBe(2); + }); +}); diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/sources/LongestCommonSubstring.java b/src/algorithms/strings/edit-distance/longest-common-substring/sources/LongestCommonSubstring.java new file mode 100644 index 00000000..1f5c6247 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/sources/LongestCommonSubstring.java @@ -0,0 +1,39 @@ +// Longest Common Substring +// Finds the length of the longest substring shared by both source and target. +// Uses DP: dp[rowIdx][colIdx] = length of longest common substring ending at +// source[rowIdx-1] and target[colIdx-1]. Resets to 0 on mismatch. +// Time: O(nm), Space: O(nm) + +public class LongestCommonSubstring { + + public static int longestCommonSubstring(String source, String target) { + int sourceLength = source.length(); // @step:initialize + int targetLength = target.length(); // @step:initialize + + // Allocate (sourceLength+1) x (targetLength+1) DP matrix, all zeros + int[][] dp = new int[sourceLength + 1][targetLength + 1]; // @step:initialize + + int maxLength = 0; // @step:initialize + + // Fill interior cells — no base case rows needed; row/col 0 stay 0 + for (int rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (int colIdx = 1; colIdx <= targetLength; colIdx++) { + char sourceChar = source.charAt(rowIdx - 1); // @step:compare + char targetChar = target.charAt(colIdx - 1); // @step:compare + + if (sourceChar == targetChar) { + // Characters match — extend the common substring ending here + dp[rowIdx][colIdx] = dp[rowIdx - 1][colIdx - 1] + 1; // @step:compute-distance + if (dp[rowIdx][colIdx] > maxLength) { + maxLength = dp[rowIdx][colIdx]; // @step:compute-distance + } + } else { + // Mismatch — common substring cannot extend through this cell + dp[rowIdx][colIdx] = 0; // @step:compute-distance + } + } + } + + return maxLength; // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.py b/src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.py new file mode 100644 index 00000000..c799ada9 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.py @@ -0,0 +1,31 @@ +# Longest Common Substring +# Finds the length of the longest substring shared by both source and target. +# Uses DP: dp[row_idx][col_idx] = length of longest common substring ending at +# source[row_idx-1] and target[col_idx-1]. Resets to 0 on mismatch. +# Time: O(nm), Space: O(nm) + +def longest_common_substring(source: str, target: str) -> int: + source_length = len(source) # @step:initialize + target_length = len(target) # @step:initialize + + # Allocate (source_length+1) x (target_length+1) DP matrix, all zeros + dp = [[0] * (target_length + 1) for _ in range(source_length + 1)] # @step:initialize + + max_length = 0 # @step:initialize + + # Fill interior cells — no base case rows needed; row/col 0 stay 0 + for row_idx in range(1, source_length + 1): + for col_idx in range(1, target_length + 1): + source_char = source[row_idx - 1] # @step:compare + target_char = target[col_idx - 1] # @step:compare + + if source_char == target_char: + # Characters match — extend the common substring ending here + dp[row_idx][col_idx] = dp[row_idx - 1][col_idx - 1] + 1 # @step:compute-distance + if dp[row_idx][col_idx] > max_length: + max_length = dp[row_idx][col_idx] # @step:compute-distance + else: + # Mismatch — common substring cannot extend through this cell + dp[row_idx][col_idx] = 0 # @step:compute-distance + + return max_length # @step:complete diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.ts b/src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.ts new file mode 100644 index 00000000..b779eead --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/sources/longest-common-substring.ts @@ -0,0 +1,39 @@ +// Longest Common Substring +// Finds the length of the longest substring shared by both source and target. +// Uses DP: dp[rowIdx][colIdx] = length of longest common substring ending at +// source[rowIdx-1] and target[colIdx-1]. Resets to 0 on mismatch. +// Time: O(nm), Space: O(nm) where n = source.length, m = target.length + +export function longestCommonSubstring(source: string, target: string): number { + const sourceLength = source.length; // @step:initialize + const targetLength = target.length; // @step:initialize + + // Allocate (sourceLength+1) × (targetLength+1) DP matrix, all zeros + const dp: number[][] = Array.from({ length: sourceLength + 1 }, () => + // @step:initialize + new Array(targetLength + 1).fill(0), + ); + + let maxLength = 0; // @step:initialize + + // Fill interior cells — no base case rows needed; row/col 0 stay 0 + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + const sourceChar = source[rowIdx - 1]; // @step:compare + const targetChar = target[colIdx - 1]; // @step:compare + + if (sourceChar === targetChar) { + // Characters match — extend the common substring ending here + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]! + 1; // @step:compute-distance + if (dp[rowIdx]![colIdx]! > maxLength) { + maxLength = dp[rowIdx]![colIdx]!; // @step:compute-distance + } + } else { + // Mismatch — common substring cannot extend through this cell + dp[rowIdx]![colIdx] = 0; // @step:compute-distance + } + } + } + + return maxLength; // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/step-generator.test.ts b/src/algorithms/strings/edit-distance/longest-common-substring/step-generator.test.ts new file mode 100644 index 00000000..43f0ffbe --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/step-generator.test.ts @@ -0,0 +1,88 @@ +/** Step generation tests for Longest Common Substring. */ + +import { describe, it, expect } from "vitest"; +import { generateLongestCommonSubstringSteps } from "./step-generator"; + +describe("generateLongestCommonSubstringSteps", () => { + it("produces steps for the default input", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits compute-distance steps for interior cells", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + const computeSteps = steps.filter((step) => step.type === "compute-distance"); + expect(computeSteps.length).toBeGreaterThan(0); + }); + + it("emits compare steps for character comparisons", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ab", target: "ab" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("reports the correct max substring length in the found step", () => { + const steps = generateLongestCommonSubstringSteps({ source: "ABABC", target: "BABCBA" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBe(4); + } + }); + + it("returns 0 for no common substring", () => { + const steps = generateLongestCommonSubstringSteps({ source: "abc", target: "xyz" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(0); + } + }); + + it("matrix dimensions match source and target lengths", () => { + const source = "abc"; + const target = "de"; + const steps = generateLongestCommonSubstringSteps({ source, target }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(source.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(target.length + 1); + } + }); + + it("handles empty strings with minimal steps", () => { + const steps = generateLongestCommonSubstringSteps({ source: "", target: "" }); + expect(steps.length).toBeGreaterThanOrEqual(2); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); +}); diff --git a/src/algorithms/strings/edit-distance/longest-common-substring/step-generator.ts b/src/algorithms/strings/edit-distance/longest-common-substring/step-generator.ts new file mode 100644 index 00000000..2255b0d4 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-common-substring/step-generator.ts @@ -0,0 +1,134 @@ +/** Step generator for Longest Common Substring — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const LONGEST_COMMON_SUBSTRING_LINE_MAP = buildLineMapFromSources( + ALGORITHM_ID.LONGEST_COMMON_SUBSTRING!, +); + +export interface LongestCommonSubstringInput { + source: string; + target: string; +} + +export function generateLongestCommonSubstringSteps( + input: LongestCommonSubstringInput, +): ExecutionStep[] { + const { source, target } = input; + const tracker = new DistanceTracker(source, target, LONGEST_COMMON_SUBSTRING_LINE_MAP); + + const sourceLength = source.length; + const targetLength = target.length; + + // Pre-compute the full DP matrix once so cell values are available on demand. + const { dp, maxLength, maxRow, maxCol } = buildDpMatrix(source, target); + + // Emit initialization step — matrix is all zeros at this point + tracker.initialize({ source, target, sourceLength, targetLength, maxLength: 0 }); + + // Fill interior cells row by row — row 0 and col 0 stay zero (no base cases to emit) + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + const sourceChar = source[rowIdx - 1]!; + const targetChar = target[colIdx - 1]!; + const isMatch = sourceChar === targetChar; + + // Emit comparison step for this cell's characters + tracker.compareChars(rowIdx - 1, colIdx - 1, isMatch, { + rowIdx, + colIdx, + sourceChar, + targetChar, + isMatch, + }); + + const cellValue = dp[rowIdx]![colIdx]!; + + // Emit compute step (marks cell as "computing") + tracker.computeCell(rowIdx, colIdx, cellValue, { + rowIdx, + colIdx, + cellValue, + isMatch, + currentMax: Math.max(...dp.flat().filter((val) => val > 0), 0), + }); + + // Finalise cell as "computed" + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue }); + } + } + + // Trace the path of the longest common substring through the matrix + const substringPath = traceSubstringPath(maxRow, maxCol, maxLength); + tracker.tracePath(substringPath, { pathLength: substringPath.length, maxLength }); + + // Emit found step with final result + tracker.updateResult(maxLength, { maxLength }); + + tracker.complete({ result: maxLength }); + return tracker.getSteps(); +} + +/** + * Build the full LCS DP matrix and locate the cell with the maximum value. + * Returns the matrix, the maximum substring length, and its terminal cell coordinates. + */ +function buildDpMatrix( + source: string, + target: string, +): { dp: number[][]; maxLength: number; maxRow: number; maxCol: number } { + const sourceLength = source.length; + const targetLength = target.length; + const dp: number[][] = Array.from({ length: sourceLength + 1 }, () => + new Array(targetLength + 1).fill(0), + ); + + let maxLength = 0; + let maxRow = 0; + let maxCol = 0; + + for (let rowIdx = 1; rowIdx <= sourceLength; rowIdx++) { + for (let colIdx = 1; colIdx <= targetLength; colIdx++) { + if (source[rowIdx - 1] === target[colIdx - 1]) { + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]! + 1; + if (dp[rowIdx]![colIdx]! > maxLength) { + maxLength = dp[rowIdx]![colIdx]!; + maxRow = rowIdx; + maxCol = colIdx; + } + } else { + dp[rowIdx]![colIdx] = 0; + } + } + } + + return { dp, maxLength, maxRow, maxCol }; +} + +/** + * Trace the diagonal path of the longest common substring backwards from its + * terminal cell. Returns cell coordinates in forward order. + */ +function traceSubstringPath( + endRow: number, + endCol: number, + substringLength: number, +): [number, number][] { + if (substringLength === 0) return []; + + const path: [number, number][] = []; + let rowIdx = endRow; + let colIdx = endCol; + + // Walk diagonally backwards — each step of the common substring is one diagonal cell + for (let stepIdx = 0; stepIdx < substringLength; stepIdx++) { + path.push([rowIdx, colIdx]); + rowIdx--; + colIdx--; + } + + return path.reverse(); +} diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/LongestRepeatedSubstringPipeline.stories.tsx b/src/algorithms/strings/edit-distance/longest-repeated-substring/LongestRepeatedSubstringPipeline.stories.tsx new file mode 100644 index 00000000..d7a14a64 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/LongestRepeatedSubstringPipeline.stories.tsx @@ -0,0 +1,63 @@ +/** + * Storybook stories for the Longest Repeated Substring algorithm pipeline. + * Uses the real step generator with the default input ("banana"), + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateLongestRepeatedSubstringSteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateLongestRepeatedSubstringSteps({ + text: "banana", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Longest Repeated Substring", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty DP matrix before any cells are filled */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Early computation — first cells being compared and computed */ +export const EarlyComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.15)]!.visualState as DistanceVisualState, + }, +}; + +/** Mid computation — DP matrix partially filled during cell traversal */ +export const MidComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** Path traced — longest repeated substring highlighted in the matrix */ +export const PathTraced: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.9)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — longest repeated substring "ana" found in "banana" */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/educational.ts b/src/algorithms/strings/edit-distance/longest-repeated-substring/educational.ts new file mode 100644 index 00000000..e1830f2c --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/educational.ts @@ -0,0 +1,65 @@ +/** Educational content for the Longest Repeated Substring algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const longestRepeatedSubstringEducational: EducationalContent = { + overview: + "**Longest Repeated Substring** finds the longest substring that appears at least **twice** (non-overlapping by position) in a given string.\n\n" + + "For example, in `banana` the answer is `ana` — it appears starting at index 1 and again at index 3.\n\n" + + "This is solved by comparing the string with itself using a DP table, similar to Longest Common Substring, but with the twist that trivial self-matches along the diagonal are excluded.", + + howItWorks: + "The algorithm builds a 2D DP matrix of size `(n+1) × (n+1)` where both source and target are the same string `text`.\n\n" + + "`dp[rowIdx][colIdx]` stores the length of the longest common suffix of `text[0..rowIdx-1]` and `text[0..colIdx-1]`.\n\n" + + "**Key constraint:** The diagonal (`rowIdx === colIdx`) is skipped entirely. Without this, every position would trivially match itself, always returning the whole string.\n\n" + + "**Recurrence:**\n\n" + + "```\n" + + "if rowIdx == colIdx:\n" + + " skip (diagonal — self-match)\n" + + "elif text[rowIdx-1] == text[colIdx-1]:\n" + + " dp[rowIdx][colIdx] = dp[rowIdx-1][colIdx-1] + 1\n" + + "else:\n" + + " dp[rowIdx][colIdx] = 0\n" + + "```\n\n" + + "**Result:** Track the maximum value seen in the matrix and the row index where it occurs. The repeated substring is `text[longestEndIndex - longestLength .. longestEndIndex]`.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n²)`**\n\n" + + "The algorithm fills an `(n+1) × (n+1)` matrix. Each of the `n²` cells is computed in constant time, yielding `O(n²)` total.\n\n" + + "**Space Complexity: `O(n²)`**\n\n" + + "The full DP matrix is stored. If only the length (not the actual substring) is needed, space can be reduced to `O(n)` by keeping only two rows at a time.", + + bestAndWorstCase: + "**Best case — no repeated characters:** When every character in the string is unique (e.g., `abcde`), no off-diagonal cell ever becomes positive. The algorithm still fills the entire matrix in `O(n²)` time, returning an empty string.\n\n" + + "**Worst case — all identical characters:** When all characters are the same (e.g., `aaaa`), almost every off-diagonal cell has a positive value and the repeated substring approaches half the string in length. Time is still `O(n²)` with maximum cell-update work.\n\n" + + "Unlike greedy or suffix-array approaches, the DP method always produces the correct answer but does not offer early termination.", + + realWorldUses: [ + "**Genome analysis:** Identifying repeated DNA motifs or tandem repeats within a genomic sequence.", + "**Plagiarism detection:** Finding the longest passage copied verbatim within a document.", + "**Data compression:** Detecting repeated patterns to inform dictionary-based compression schemes.", + "**Log analysis:** Spotting repeated error messages or patterns within application logs.", + "**String deduplication:** Identifying the longest recurring segment to split or encode more efficiently.", + "**Bioinformatics:** Discovering repeated regulatory regions or coding sequences in protein strings.", + ], + + strengthsAndLimitations: { + strengths: [ + "Guarantees finding the globally longest repeated substring — no approximation.", + "Works on any alphabet without modification.", + "The DP table naturally avoids trivial self-overlaps via diagonal exclusion.", + "Space can be reduced to O(n) when only the length is required.", + ], + limitations: [ + "O(n²) time and space — becomes slow for very long strings (tens of thousands of characters).", + "The simple diagonal-skip only prevents exact self-overlap at identical indices; adjacent overlaps are still allowed.", + "For very large inputs, suffix arrays with LCP tables solve this in O(n log n) time.", + "No early exit — the full matrix is always computed even when the answer is obvious.", + ], + }, + + whenToUseIt: + "Use Longest Repeated Substring when you need to find the **exact** longest repeated pattern in a short-to-medium string (up to a few thousand characters) and the `O(n²)` cost is acceptable.\n\n" + + "It is a natural fit for genome fragment analysis, plagiarism detection on small documents, or educational demonstrations of 2D DP problems.\n\n" + + "For very long strings, prefer **suffix arrays with LCP (Longest Common Prefix) arrays**, which solve the same problem in `O(n log n)` time and `O(n)` space.", +}; diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/index.ts b/src/algorithms/strings/edit-distance/longest-repeated-substring/index.ts new file mode 100644 index 00000000..18c08168 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Longest Repeated Substring — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { longestRepeatedSubstring } from "./sources/longest-repeated-substring.ts?fn"; +import { generateLongestRepeatedSubstringSteps } from "./step-generator"; +import type { LongestRepeatedSubstringInput } from "./step-generator"; +import { longestRepeatedSubstringEducational } from "./educational"; + +import typescriptSource from "./sources/longest-repeated-substring.ts?raw"; +import pythonSource from "./sources/longest-repeated-substring.py?raw"; +import javaSource from "./sources/LongestRepeatedSubstring.java?raw"; + +function executeLongestRepeatedSubstring(input: LongestRepeatedSubstringInput): string { + return longestRepeatedSubstring(input.text) as string; +} + +const longestRepeatedSubstringDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.LONGEST_REPEATED_SUBSTRING!, + name: "Longest Repeated Substring", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Find the longest substring that appears at least twice in the string using a DP matrix that compares the string with itself, skipping the diagonal to avoid trivial self-matches", + timeComplexity: { + best: "O(n²)", + average: "O(n²)", + worst: "O(n²)", + }, + spaceComplexity: "O(n²)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "banana" }, + }, + execute: executeLongestRepeatedSubstring, + generateSteps: generateLongestRepeatedSubstringSteps, + educational: longestRepeatedSubstringEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(longestRepeatedSubstringDefinition); diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/longest-repeated-substring.test.ts b/src/algorithms/strings/edit-distance/longest-repeated-substring/longest-repeated-substring.test.ts new file mode 100644 index 00000000..9f2ab4fe --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/longest-repeated-substring.test.ts @@ -0,0 +1,62 @@ +/** Correctness tests for the longestRepeatedSubstring pure function. */ + +import { describe, it, expect } from "vitest"; +import { longestRepeatedSubstring } from "./sources/longest-repeated-substring.ts?fn"; + +describe("longestRepeatedSubstring", () => { + it('finds "ana" as the longest repeated substring in "banana"', () => { + expect(longestRepeatedSubstring("banana")).toBe("ana"); + }); + + it("returns empty string when no character repeats", () => { + expect(longestRepeatedSubstring("abcd")).toBe(""); + }); + + it('finds "a" as the longest repeated substring in "aab"', () => { + expect(longestRepeatedSubstring("aab")).toBe("a"); + }); + + it("returns empty string for a single character string", () => { + expect(longestRepeatedSubstring("a")).toBe(""); + }); + + it("returns empty string for an empty string", () => { + expect(longestRepeatedSubstring("")).toBe(""); + }); + + it('finds "ab" as the longest repeated substring in "ababc"', () => { + expect(longestRepeatedSubstring("ababc")).toBe("ab"); + }); + + it("handles a string where all characters are the same", () => { + // "aaa" → longest repeated substring is "aa" (positions 0-1 and 1-2 overlap, but DP skips diagonal) + const result = longestRepeatedSubstring("aaa"); + expect(result.length).toBeGreaterThan(0); + expect("aaa".includes(result)).toBe(true); + }); + + it("handles two-character string with identical characters", () => { + expect(longestRepeatedSubstring("aa")).toBe("a"); + }); + + it("handles two-character string with different characters", () => { + expect(longestRepeatedSubstring("ab")).toBe(""); + }); + + it('finds the correct repeated pattern in "abcabc"', () => { + expect(longestRepeatedSubstring("abcabc")).toBe("abc"); + }); + + it('finds repeated substring in "mississippi"', () => { + const result = longestRepeatedSubstring("mississippi"); + // "issi" appears twice — the result should be a non-empty repeated substring + expect(result.length).toBeGreaterThan(0); + const firstOccurrence = "mississippi".indexOf(result); + const secondOccurrence = "mississippi".indexOf(result, firstOccurrence + 1); + expect(secondOccurrence).toBeGreaterThan(-1); + }); + + it("handles numeric-like characters in the string", () => { + expect(longestRepeatedSubstring("121212")).toBe("1212"); + }); +}); diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/LongestRepeatedSubstring.java b/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/LongestRepeatedSubstring.java new file mode 100644 index 00000000..e79d0ca0 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/LongestRepeatedSubstring.java @@ -0,0 +1,43 @@ +// Longest Repeated Substring +// Finds the longest substring that appears at least twice in the string. +// Uses a DP matrix comparing the string against itself, where dp[rowIdx][colIdx] +// represents the length of the longest common suffix of text[0..rowIdx-1] and text[0..colIdx-1]. +// The diagonal (rowIdx === colIdx) is skipped to avoid trivial self-matches. +// Time: O(n²), Space: O(n²) + +public class LongestRepeatedSubstring { + + public static String longestRepeatedSubstring(String text) { + int textLength = text.length(); // @step:initialize + + // Allocate (textLength+1) x (textLength+1) DP matrix + int[][] dp = new int[textLength + 1][textLength + 1]; // @step:initialize + + int longestLength = 0; // @step:initialize + int longestEndIndex = 0; // @step:initialize + + // Fill the DP matrix — skip diagonal (rowIdx == colIdx) to avoid self-overlap + for (int rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (int colIdx = 1; colIdx <= textLength; colIdx++) { + if (rowIdx == colIdx) continue; // @step:compare — skip self-match on diagonal + + char rowChar = text.charAt(rowIdx - 1); // @step:compare + char colChar = text.charAt(colIdx - 1); // @step:compare + + if (rowChar == colChar) { + // Characters match — extend the common suffix length + dp[rowIdx][colIdx] = dp[rowIdx - 1][colIdx - 1] + 1; // @step:compute-distance + } else { + dp[rowIdx][colIdx] = 0; // @step:compute-distance + } + + if (dp[rowIdx][colIdx] > longestLength) { + longestLength = dp[rowIdx][colIdx]; // @step:compute-distance + longestEndIndex = rowIdx; // @step:compute-distance + } + } + } + + return text.substring(longestEndIndex - longestLength, longestEndIndex); // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.py b/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.py new file mode 100644 index 00000000..2f51bd22 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.py @@ -0,0 +1,36 @@ +# Longest Repeated Substring +# Finds the longest substring that appears at least twice in the string. +# Uses a DP matrix comparing the string against itself, where dp[row_idx][col_idx] +# represents the length of the longest common suffix of text[0..row_idx-1] and text[0..col_idx-1]. +# The diagonal (row_idx === col_idx) is skipped to avoid trivial self-matches. +# Time: O(n²), Space: O(n²) + +def longest_repeated_substring(text: str) -> str: + text_length = len(text) # @step:initialize + + # Allocate (text_length+1) x (text_length+1) DP matrix + dp = [[0] * (text_length + 1) for _ in range(text_length + 1)] # @step:initialize + + longest_length = 0 # @step:initialize + longest_end_index = 0 # @step:initialize + + # Fill the DP matrix — skip diagonal (row_idx === col_idx) to avoid self-overlap + for row_idx in range(1, text_length + 1): + for col_idx in range(1, text_length + 1): + if row_idx == col_idx: + continue # @step:compare — skip self-match on diagonal + + row_char = text[row_idx - 1] # @step:compare + col_char = text[col_idx - 1] # @step:compare + + if row_char == col_char: + # Characters match — extend the common suffix length + dp[row_idx][col_idx] = dp[row_idx - 1][col_idx - 1] + 1 # @step:compute-distance + else: + dp[row_idx][col_idx] = 0 # @step:compute-distance + + if dp[row_idx][col_idx] > longest_length: + longest_length = dp[row_idx][col_idx] # @step:compute-distance + longest_end_index = row_idx # @step:compute-distance + + return text[longest_end_index - longest_length:longest_end_index] # @step:complete diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.ts b/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.ts new file mode 100644 index 00000000..ac914da0 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/sources/longest-repeated-substring.ts @@ -0,0 +1,43 @@ +// Longest Repeated Substring +// Finds the longest substring that appears at least twice in the string. +// Uses a DP matrix comparing the string against itself, where dp[rowIdx][colIdx] +// represents the length of the longest common suffix of text[0..rowIdx-1] and text[0..colIdx-1]. +// The diagonal (rowIdx === colIdx) is skipped to avoid trivial self-matches. +// Time: O(n²), Space: O(n²) + +export function longestRepeatedSubstring(text: string): string { + const textLength = text.length; // @step:initialize + + // Allocate (textLength+1) × (textLength+1) DP matrix + const dp: number[][] = Array.from({ length: textLength + 1 }, () => + // @step:initialize + new Array(textLength + 1).fill(0), + ); + + let longestLength = 0; // @step:initialize + let longestEndIndex = 0; // @step:initialize + + // Fill the DP matrix — skip diagonal (rowIdx === colIdx) to avoid self-overlap + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= textLength; colIdx++) { + if (rowIdx === colIdx) continue; // @step:compare — skip self-match on diagonal + + const rowChar = text[rowIdx - 1]; // @step:compare + const colChar = text[colIdx - 1]; // @step:compare + + if (rowChar === colChar) { + // Characters match — extend the common suffix length + dp[rowIdx]![colIdx] = (dp[rowIdx - 1]![colIdx - 1] ?? 0) + 1; // @step:compute-distance + } else { + dp[rowIdx]![colIdx] = 0; // @step:compute-distance + } + + if ((dp[rowIdx]![colIdx] ?? 0) > longestLength) { + longestLength = dp[rowIdx]![colIdx]!; // @step:compute-distance + longestEndIndex = rowIdx; // @step:compute-distance + } + } + } + + return text.slice(longestEndIndex - longestLength, longestEndIndex); // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.test.ts b/src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.test.ts new file mode 100644 index 00000000..9d9bfccf --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.test.ts @@ -0,0 +1,99 @@ +/** Step generation tests for Longest Repeated Substring. */ + +import { describe, it, expect } from "vitest"; +import { generateLongestRepeatedSubstringSteps } from "./step-generator"; + +describe("generateLongestRepeatedSubstringSteps", () => { + it("produces steps for the default input", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits compare steps when processing cells", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits compute-distance steps for interior cells", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + const computeSteps = steps.filter((step) => step.type === "compute-distance"); + expect(computeSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + expect(foundStep?.visualState.kind).toBe("string-distance"); + }); + + it('reports result "ana" for "banana" in the complete step variables', () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "banana" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + expect(completeStep.variables["result"]).toBe("ana"); + }); + + it("returns empty result for a string with no repeated characters", () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "abcd" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + expect(completeStep.variables["result"]).toBe(""); + }); + + it('returns "a" for "aab"', () => { + const steps = generateLongestRepeatedSubstringSteps({ text: "aab" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + expect(completeStep.variables["result"]).toBe("a"); + }); + + it("matrix dimensions match text length (source = target = text)", () => { + const text = "banana"; + const steps = generateLongestRepeatedSubstringSteps({ text }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(text.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(text.length + 1); + } + }); + + it("handles empty string input without throwing", () => { + expect(() => generateLongestRepeatedSubstringSteps({ text: "" })).not.toThrow(); + }); + + it("handles single-character input without throwing", () => { + expect(() => generateLongestRepeatedSubstringSteps({ text: "a" })).not.toThrow(); + }); +}); diff --git a/src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.ts b/src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.ts new file mode 100644 index 00000000..54e86902 --- /dev/null +++ b/src/algorithms/strings/edit-distance/longest-repeated-substring/step-generator.ts @@ -0,0 +1,142 @@ +/** Step generator for Longest Repeated Substring — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const LONGEST_REPEATED_SUBSTRING_LINE_MAP = buildLineMapFromSources( + ALGORITHM_ID.LONGEST_REPEATED_SUBSTRING!, +); + +export interface LongestRepeatedSubstringInput { + text: string; +} + +export function generateLongestRepeatedSubstringSteps( + input: LongestRepeatedSubstringInput, +): ExecutionStep[] { + const { text } = input; + // DistanceTracker expects source and target — use text for both + const tracker = new DistanceTracker(text, text, LONGEST_REPEATED_SUBSTRING_LINE_MAP); + + const textLength = text.length; + + // Pre-compute the full DP matrix once so each cell value is available on demand. + const { dp, longestLength, longestRowEnd, longestColEnd } = buildDpMatrix(text); + + // Emit the initialization step + tracker.initialize({ text, textLength, longestLength: 0 }); + + // Fill interior cells row by row, skipping the diagonal + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= textLength; colIdx++) { + // Skip self-matches on the diagonal to avoid trivial overlapping repeats + if (rowIdx === colIdx) continue; + + const rowChar = text[rowIdx - 1]!; + const colChar = text[colIdx - 1]!; + const isMatch = rowChar === colChar; + + // Emit a comparison step for these two characters + tracker.compareChars(rowIdx - 1, colIdx - 1, isMatch, { + rowIdx, + colIdx, + rowChar, + colChar, + isMatch, + }); + + const cellValue = dp[rowIdx]![colIdx]!; + + // Emit compute step (marks cell as "computing") + tracker.computeCell(rowIdx, colIdx, cellValue, { + rowIdx, + colIdx, + cellValue, + isMatch, + }); + + // Finalise cell as "computed" + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue }); + } + } + + // Trace the path of the longest repeated substring along the DP diagonal + const substringPath = traceLongestSubstringPath(longestLength, longestRowEnd, longestColEnd); + tracker.tracePath(substringPath, { pathLength: substringPath.length }); + + // Record final result + const result = text.slice(longestRowEnd - longestLength, longestRowEnd); + tracker.updateResult(longestLength, { result, longestLength }); + + tracker.complete({ result }); + return tracker.getSteps(); +} + +/** + * Build the full DP matrix for the Longest Repeated Substring algorithm. + * dp[rowIdx][colIdx] = length of longest common suffix ending at text[rowIdx-1] and text[colIdx-1], + * with the diagonal excluded to prevent self-overlap. + * Returns the matrix along with the best length and the exact (row, col) end cell. + */ +function buildDpMatrix(text: string): { + dp: number[][]; + longestLength: number; + longestRowEnd: number; + longestColEnd: number; +} { + const textLength = text.length; + const dp: number[][] = Array.from({ length: textLength + 1 }, () => + new Array(textLength + 1).fill(0), + ); + + let longestLength = 0; + let longestRowEnd = 0; + let longestColEnd = 0; + + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= textLength; colIdx++) { + if (rowIdx === colIdx) continue; + + if (text[rowIdx - 1] === text[colIdx - 1]) { + dp[rowIdx]![colIdx] = (dp[rowIdx - 1]![colIdx - 1] ?? 0) + 1; + } else { + dp[rowIdx]![colIdx] = 0; + } + + if ((dp[rowIdx]![colIdx] ?? 0) > longestLength) { + longestLength = dp[rowIdx]![colIdx]!; + longestRowEnd = rowIdx; + longestColEnd = colIdx; + } + } + } + + return { dp, longestLength, longestRowEnd, longestColEnd }; +} + +/** + * Build a path of [rowIdx, colIdx] pairs for the longest repeated substring cells. + * Starting from the endpoint cell (longestRowEnd, longestColEnd), steps back diagonally + * for longestLength cells to reconstruct the full match path. + */ +function traceLongestSubstringPath( + longestLength: number, + longestRowEnd: number, + longestColEnd: number, +): [number, number][] { + if (longestLength === 0) return []; + + const path: [number, number][] = []; + // Walk backwards from the endpoint along the anti-diagonal direction + for (let offset = 0; offset < longestLength; offset++) { + const rowIdx = longestRowEnd - offset; + const colIdx = longestColEnd - offset; + if (rowIdx > 0 && colIdx > 0 && rowIdx !== colIdx) { + path.push([rowIdx, colIdx]); + } + } + + return path.reverse(); +} diff --git a/src/algorithms/strings/edit-distance/regex-matching/RegexMatchingPipeline.stories.tsx b/src/algorithms/strings/edit-distance/regex-matching/RegexMatchingPipeline.stories.tsx new file mode 100644 index 00000000..d62026cf --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/RegexMatchingPipeline.stories.tsx @@ -0,0 +1,64 @@ +/** + * Storybook stories for the Regular Expression Matching algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateRegexMatchingSteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateRegexMatchingSteps({ + text: "aab", + pattern: "c*a*b", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Regular Expression Matching", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty DP matrix before any cells are filled */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Base cases filled — row 0 populated with star-pair propagation for empty text */ +export const BaseCasesFilled: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.15)]!.visualState as DistanceVisualState, + }, +}; + +/** Mid computation — DP matrix partially filled during interior cell pass */ +export const MidComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** Match path traced — optimal path highlighted from bottom-right to top-left */ +export const MatchPathTraced: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.9)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — match result computed, result displayed as 1 (true) */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/regex-matching/educational.ts b/src/algorithms/strings/edit-distance/regex-matching/educational.ts new file mode 100644 index 00000000..3489f07d --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/educational.ts @@ -0,0 +1,70 @@ +/** Educational content for the Regular Expression Matching algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const regexMatchingEducational: EducationalContent = { + overview: + "**Regular Expression Matching** determines whether a text string fully matches a pattern that may contain two special metacharacters:\n\n" + + "- `.` matches **any single character**\n" + + "- `*` matches **zero or more of the immediately preceding element**\n\n" + + "For example, the pattern `c*a*b` matches `aab` because `c*` matches zero `c`s, `a*` matches two `a`s, and `b` matches `b`.\n\n" + + "The algorithm uses **dynamic programming** to decide this in `O(n × m)` time without backtracking.", + + howItWorks: + "Regular Expression Matching uses a 2D DP table where `dp[rowIdx][colIdx]` is `true` if `text[0..rowIdx-1]` fully matches `pattern[0..colIdx-1]`.\n\n" + + "**1. Initialization:**\n\n" + + "- `dp[0][0] = true` — empty text matches empty pattern.\n" + + "- `dp[0][colIdx] = dp[0][colIdx-2]` if `pattern[colIdx-1] == '*'` — a `*` pair can match zero characters, effectively eliminating the two-character sequence from the pattern.\n\n" + + "**2. Recurrence (for each interior cell):**\n\n" + + "```\n" + + "if pattern[colIdx-1] == '*':\n" + + " dp[rowIdx][colIdx] = dp[rowIdx][colIdx-2] // zero occurrences\n" + + " OR (match(rowIdx, colIdx-1)\n" + + " AND dp[rowIdx-1][colIdx]) // one more occurrence\n" + + "elif pattern[colIdx-1] == '.' or pattern[colIdx-1] == text[rowIdx-1]:\n" + + " dp[rowIdx][colIdx] = dp[rowIdx-1][colIdx-1] // single char match\n" + + "else:\n" + + " dp[rowIdx][colIdx] = false\n" + + "```\n\n" + + "Where `match(rowIdx, colIdx-1)` checks if the preceding pattern character (the one before `*`) matches `text[rowIdx-1]` — either as a `.` wildcard or an exact character.\n\n" + + "**3. Result:** `dp[textLength][patternLength]` is `true` if the entire text matches the entire pattern.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "Every cell in the `(n+1) × (m+1)` matrix is filled in constant time, giving `O(n × m)` total — where `n = text.length` and `m = pattern.length`.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "The full DP matrix is stored. Space can be reduced to `O(m)` by keeping only the current and previous rows, since each cell depends only on the row above and two columns to the left.", + + bestAndWorstCase: + "**Best case — immediate mismatch:** A literal pattern character that does not match the first text character causes most cells to remain `false`. The full matrix is still evaluated in `O(n × m)` time — there is no early exit.\n\n" + + "**Worst case — many `.*` sequences:** Patterns like `.*.*.*` force exploration of many transitions because each `.*` pair can consume an arbitrary number of characters. Time remains `O(n × m)` but every cell requires checking multiple predecessor cells.\n\n" + + "Unlike naive recursive matching (which can be exponential due to repeated subproblem recomputation), the DP formulation memoizes every subproblem and guarantees polynomial time.", + + realWorldUses: [ + "**Compiler lexers:** Tokenizers use regex engines built on NFA/DFA theory — the DP approach is a direct implementation of the underlying matching logic.", + "**Search and grep tools:** Tools like `grep`, `sed`, and text editors apply regex matching to filter and transform text streams.", + "**Input validation:** Web forms validate email addresses, phone numbers, and date formats using regex patterns.", + "**Log parsing:** Structured log analysis tools extract fields from log lines by matching against patterns with wildcards.", + "**Database query optimizers:** Some databases use regex-aware pattern matching (e.g., PostgreSQL `~` operator) for flexible text search.", + "**Bioinformatics:** DNA motif search uses regex-like patterns (with IUPAC codes) to locate binding sites in gene sequences.", + ], + + strengthsAndLimitations: { + strengths: [ + "Guarantees `O(n × m)` time — eliminates the exponential blowup of naive recursive backtracking.", + "Handles both `.` (single-char) and `*` (zero-or-more) metacharacters with a single unified DP formulation.", + "Correct for all inputs, including edge cases like empty text, empty pattern, and patterns starting with `*`.", + "Space-optimizable to `O(m)` when only the boolean result is needed.", + ], + limitations: [ + "`O(n × m)` time and space — costly for very long strings or patterns with many metacharacters.", + "Supports only `.` and `*`; full PCRE features (groups, alternation, lookaheads, backreferences) require NFA simulation.", + "No partial-match output — reports only whether the full text matches, not where subpatterns aligned.", + "The `*` operator in this algorithm applies only to the single preceding character, not to groups (for group quantifiers use NFA construction).", + ], + }, + + whenToUseIt: + "Use Regular Expression Matching when you need to test whether a string **fully** matches a `.`/`*` pattern and the `O(n × m)` cost is acceptable for your input sizes. It is the right choice for interview-style regex problems, small-scale pattern validation, and learning how regex engines work internally.\n\n" + + "Avoid it when you need partial matching (use KMP or Rabin-Karp), when patterns require grouping or alternation (use a full NFA-based engine), or when strings are very long and you need sub-quadratic performance (use SIMD-accelerated matching or pre-compiled DFAs).", +}; diff --git a/src/algorithms/strings/edit-distance/regex-matching/index.ts b/src/algorithms/strings/edit-distance/regex-matching/index.ts new file mode 100644 index 00000000..015e28fc --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Regular Expression Matching — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { regexMatching } from "./sources/regex-matching.ts?fn"; +import { generateRegexMatchingSteps } from "./step-generator"; +import type { RegexMatchingInput } from "./step-generator"; +import { regexMatchingEducational } from "./educational"; + +import typescriptSource from "./sources/regex-matching.ts?raw"; +import pythonSource from "./sources/regex-matching.py?raw"; +import javaSource from "./sources/RegexMatching.java?raw"; + +function executeRegexMatching(input: RegexMatchingInput): boolean { + return regexMatching(input.text, input.pattern) as boolean; +} + +const regexMatchingDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.REGEX_MATCHING!, + name: "Regular Expression Matching", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Determine if a text string fully matches a pattern containing '.' (any single character) and '*' (zero or more of the preceding element) using dynamic programming", + timeComplexity: { + best: "O(nm)", + average: "O(nm)", + worst: "O(nm)", + }, + spaceComplexity: "O(nm)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "aab", pattern: "c*a*b" }, + }, + execute: executeRegexMatching, + generateSteps: generateRegexMatchingSteps, + educational: regexMatchingEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(regexMatchingDefinition); diff --git a/src/algorithms/strings/edit-distance/regex-matching/regex-matching.test.ts b/src/algorithms/strings/edit-distance/regex-matching/regex-matching.test.ts new file mode 100644 index 00000000..d8431e91 --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/regex-matching.test.ts @@ -0,0 +1,62 @@ +/** Correctness tests for the regexMatching pure function. */ + +import { describe, it, expect } from "vitest"; +import { regexMatching } from "./sources/regex-matching.ts?fn"; + +describe("regexMatching", () => { + it('matches "aab" against "c*a*b" returning true', () => { + expect(regexMatching("aab", "c*a*b")).toBe(true); + }); + + it('does not match "aa" against "a" returning false', () => { + expect(regexMatching("aa", "a")).toBe(false); + }); + + it('matches "ab" against ".*" returning true', () => { + expect(regexMatching("ab", ".*")).toBe(true); + }); + + it("matches empty text against empty pattern returning true", () => { + expect(regexMatching("", "")).toBe(true); + }); + + it('matches "aa" against "a*" returning true', () => { + expect(regexMatching("aa", "a*")).toBe(true); + }); + + it('matches "aa" against ".*" returning true', () => { + expect(regexMatching("aa", ".*")).toBe(true); + }); + + it('does not match "aab" against "c*a*" returning false', () => { + expect(regexMatching("aab", "c*a*")).toBe(false); + }); + + it('matches "mississippi" against "mis*is*p*." returning false', () => { + expect(regexMatching("mississippi", "mis*is*p*.")).toBe(false); + }); + + it('matches "ab" against ".*c" returning false', () => { + expect(regexMatching("ab", ".*c")).toBe(false); + }); + + it('matches single character "a" against "." returning true', () => { + expect(regexMatching("a", ".")).toBe(true); + }); + + it('does not match "b" against "a" returning false', () => { + expect(regexMatching("b", "a")).toBe(false); + }); + + it('matches empty text against "a*" returning true', () => { + expect(regexMatching("", "a*")).toBe(true); + }); + + it('matches "aaa" against "a*a" returning true', () => { + expect(regexMatching("aaa", "a*a")).toBe(true); + }); + + it('matches "abc" against "a.c" returning true', () => { + expect(regexMatching("abc", "a.c")).toBe(true); + }); +}); diff --git a/src/algorithms/strings/edit-distance/regex-matching/sources/RegexMatching.java b/src/algorithms/strings/edit-distance/regex-matching/sources/RegexMatching.java new file mode 100644 index 00000000..57821cd3 --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/sources/RegexMatching.java @@ -0,0 +1,50 @@ +// Regular Expression Matching +// Determines if text matches a pattern that may contain '.' (any single character) +// or '*' (zero or more of the preceding element). +// Uses dynamic programming: dp[rowIdx][colIdx] = 1 if text[0..rowIdx-1] matches pattern[0..colIdx-1]. +// Time: O(nm), Space: O(nm) + +public class RegexMatching { + + public static boolean regexMatching(String text, String pattern) { + int textLength = text.length(); // @step:initialize + int patternLength = pattern.length(); // @step:initialize + + // Allocate (textLength+1) x (patternLength+1) DP matrix (1 = true, 0 = false) + int[][] dp = new int[textLength + 1][patternLength + 1]; // @step:initialize + + // Base case: empty text matches empty pattern + dp[0][0] = 1; // @step:fill-table + + // Base case: empty text can match patterns like "a*", "a*b*", etc. + for (int colIdx = 2; colIdx <= patternLength; colIdx++) { + if (pattern.charAt(colIdx - 1) == '*') { + dp[0][colIdx] = dp[0][colIdx - 2]; // @step:fill-table + } + } + + // Fill the rest of the matrix + for (int rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (int colIdx = 1; colIdx <= patternLength; colIdx++) { + char textChar = text.charAt(rowIdx - 1); // @step:compare + char patternChar = pattern.charAt(colIdx - 1); // @step:compare + + if (patternChar == '*') { + // '*' with preceding element: zero occurrences or one more char + int zeroOccurrences = dp[rowIdx][colIdx - 2]; // @step:compute-distance + char precedingChar = colIdx >= 2 ? pattern.charAt(colIdx - 2) : '\0'; + boolean charMatches = precedingChar == '.' || precedingChar == textChar; + int oneMore = charMatches ? dp[rowIdx - 1][colIdx] : 0; // @step:compute-distance + dp[rowIdx][colIdx] = zeroOccurrences == 1 || oneMore == 1 ? 1 : 0; // @step:compute-distance + } else if (patternChar == '.' || patternChar == textChar) { + // '.' matches any single char, or exact character match + dp[rowIdx][colIdx] = dp[rowIdx - 1][colIdx - 1]; // @step:compute-distance + } else { + dp[rowIdx][colIdx] = 0; // @step:compute-distance + } + } + } + + return dp[textLength][patternLength] == 1; // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.py b/src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.py new file mode 100644 index 00000000..a9c92d7e --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.py @@ -0,0 +1,41 @@ +# Regular Expression Matching +# Determines if text matches a pattern that may contain '.' (any single character) +# or '*' (zero or more of the preceding element). +# Uses dynamic programming: dp[row_idx][col_idx] = 1 if text[0..row_idx-1] matches pattern[0..col_idx-1]. +# Time: O(nm), Space: O(nm) + +def regex_matching(text: str, pattern: str) -> bool: + text_length = len(text) # @step:initialize + pattern_length = len(pattern) # @step:initialize + + # Allocate (text_length+1) x (pattern_length+1) DP matrix (1 = True, 0 = False) + dp = [[0] * (pattern_length + 1) for _ in range(text_length + 1)] # @step:initialize + + # Base case: empty text matches empty pattern + dp[0][0] = 1 # @step:fill-table + + # Base case: empty text can match patterns like "a*", "a*b*", etc. + for col_idx in range(2, pattern_length + 1): + if pattern[col_idx - 1] == "*": + dp[0][col_idx] = dp[0][col_idx - 2] # @step:fill-table + + # Fill the rest of the matrix + for row_idx in range(1, text_length + 1): + for col_idx in range(1, pattern_length + 1): + text_char = text[row_idx - 1] # @step:compare + pattern_char = pattern[col_idx - 1] # @step:compare + + if pattern_char == "*": + # '*' with preceding element: zero occurrences or one more char + zero_occurrences = dp[row_idx][col_idx - 2] # @step:compute-distance + preceding_char = pattern[col_idx - 2] if col_idx >= 2 else "" + char_matches = preceding_char == "." or preceding_char == text_char + one_more = dp[row_idx - 1][col_idx] if char_matches else 0 # @step:compute-distance + dp[row_idx][col_idx] = 1 if zero_occurrences == 1 or one_more == 1 else 0 # @step:compute-distance + elif pattern_char == "." or pattern_char == text_char: + # '.' matches any single char, or exact character match + dp[row_idx][col_idx] = dp[row_idx - 1][col_idx - 1] # @step:compute-distance + else: + dp[row_idx][col_idx] = 0 # @step:compute-distance + + return dp[text_length][pattern_length] == 1 # @step:complete diff --git a/src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.ts b/src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.ts new file mode 100644 index 00000000..207ece71 --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/sources/regex-matching.ts @@ -0,0 +1,50 @@ +// Regular Expression Matching +// Determines if text matches a pattern that may contain '.' (any single character) +// or '*' (zero or more of the preceding element). +// Uses dynamic programming: dp[rowIdx][colIdx] = true if text[0..rowIdx-1] matches pattern[0..colIdx-1]. +// Time: O(nm), Space: O(nm) where n = text.length, m = pattern.length + +export function regexMatching(text: string, pattern: string): boolean { + const textLength = text.length; // @step:initialize + const patternLength = pattern.length; // @step:initialize + + // Allocate (textLength+1) × (patternLength+1) boolean DP matrix (stored as 1/0) + const dp: number[][] = Array.from({ length: textLength + 1 }, () => + // @step:initialize + new Array(patternLength + 1).fill(0), + ); + + // Base case: empty text matches empty pattern + dp[0]![0] = 1; // @step:fill-table + + // Base case: empty text can match patterns like "a*", "a*b*", etc. + for (let colIdx = 2; colIdx <= patternLength; colIdx++) { + if (pattern[colIdx - 1] === "*") { + dp[0]![colIdx] = dp[0]![colIdx - 2]!; // @step:fill-table + } + } + + // Fill the rest of the matrix + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const textChar = text[rowIdx - 1]; // @step:compare + const patternChar = pattern[colIdx - 1]; // @step:compare + + if (patternChar === "*") { + // '*' with preceding element: zero occurrences (skip two pattern chars) or one more char + const zeroOccurrences = dp[rowIdx]![colIdx - 2]!; // @step:compute-distance + const precedingChar = pattern[colIdx - 2]; + const charMatches = precedingChar === "." || precedingChar === textChar; + const oneMore = charMatches ? dp[rowIdx - 1]![colIdx]! : 0; // @step:compute-distance + dp[rowIdx]![colIdx] = zeroOccurrences === 1 || oneMore === 1 ? 1 : 0; // @step:compute-distance + } else if (patternChar === "." || patternChar === textChar) { + // '.' matches any single char, or exact character match + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]!; // @step:compute-distance + } else { + dp[rowIdx]![colIdx] = 0; // @step:compute-distance + } + } + } + + return dp[textLength]![patternLength]! === 1; // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/regex-matching/step-generator.test.ts b/src/algorithms/strings/edit-distance/regex-matching/step-generator.test.ts new file mode 100644 index 00000000..5869a4e9 --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/step-generator.test.ts @@ -0,0 +1,105 @@ +/** Step generation tests for Regular Expression Matching. */ + +import { describe, it, expect } from "vitest"; +import { generateRegexMatchingSteps } from "./step-generator"; + +describe("generateRegexMatchingSteps", () => { + it("produces steps for the default input", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits fill-table steps for base cases", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + const fillTableSteps = steps.filter((step) => step.type === "fill-table"); + expect(fillTableSteps.length).toBeGreaterThan(0); + }); + + it("emits compute-distance steps for interior cells", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + const computeSteps = steps.filter((step) => step.type === "compute-distance"); + expect(computeSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step with result 1 for a matching input", () => { + const steps = generateRegexMatchingSteps({ text: "aab", pattern: "c*a*b" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + expect(foundStep?.visualState.kind).toBe("string-distance"); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBe(1); + } + }); + + it('returns result 0 for non-matching "aa" against "a"', () => { + const steps = generateRegexMatchingSteps({ text: "aa", pattern: "a" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(0); + } + }); + + it('returns result 1 for matching "ab" against ".*"', () => { + const steps = generateRegexMatchingSteps({ text: "ab", pattern: ".*" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(1); + } + }); + + it("returns result 1 for empty text against empty pattern", () => { + const steps = generateRegexMatchingSteps({ text: "", pattern: "" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(1); + } + }); + + it("emits compare steps when processing interior cells", () => { + const steps = generateRegexMatchingSteps({ text: "ab", pattern: "a." }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("matrix dimensions match text and pattern lengths", () => { + const text = "aab"; + const pattern = "c*a*b"; + const steps = generateRegexMatchingSteps({ text, pattern }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(text.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(pattern.length + 1); + } + }); +}); diff --git a/src/algorithms/strings/edit-distance/regex-matching/step-generator.ts b/src/algorithms/strings/edit-distance/regex-matching/step-generator.ts new file mode 100644 index 00000000..e8b84a4a --- /dev/null +++ b/src/algorithms/strings/edit-distance/regex-matching/step-generator.ts @@ -0,0 +1,166 @@ +/** Step generator for Regular Expression Matching — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const REGEX_MATCHING_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.REGEX_MATCHING!); + +export interface RegexMatchingInput { + text: string; + pattern: string; +} + +export function generateRegexMatchingSteps(input: RegexMatchingInput): ExecutionStep[] { + const { text, pattern } = input; + // DistanceTracker expects source and target — map text → source, pattern → target + const tracker = new DistanceTracker(text, pattern, REGEX_MATCHING_LINE_MAP); + + const textLength = text.length; + const patternLength = pattern.length; + + // Pre-compute the full DP matrix so each cell value is available on demand + const dp = buildDpMatrix(text, pattern); + + // Emit initialization step + tracker.initialize({ text, pattern, textLength, patternLength }); + + // Base case: dp[0][0] = 1 (empty matches empty) + tracker.fillBaseCase(0, 0, 1, { rowIdx: 0, colIdx: 0, value: 1 }); + + // Base case: row 0 — empty text matches patterns like "a*", "a*b*", etc. + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const value = dp[0]![colIdx]!; + tracker.fillBaseCase(0, colIdx, value, { rowIdx: 0, colIdx, value }); + } + + // Fill interior cells row by row + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const textChar = text[rowIdx - 1]!; + const patternChar = pattern[colIdx - 1]!; + const isMatch = patternChar === "." || patternChar === textChar || patternChar === "*"; + + // Emit a comparison step for this cell's characters + tracker.compareChars(rowIdx - 1, colIdx - 1, isMatch, { + rowIdx, + colIdx, + textChar, + patternChar, + isMatch, + }); + + const cellValue = dp[rowIdx]![colIdx]!; + + // Emit compute step (sets cell to "computing") + tracker.computeCell(rowIdx, colIdx, cellValue, { + rowIdx, + colIdx, + cellValue, + patternChar, + }); + + // Finalise cell as "computed" + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue }); + } + } + + // Trace the match path from bottom-right to top-left + const matchPath = traceMatchPath(dp, text, pattern); + tracker.tracePath(matchPath, { pathLength: matchPath.length }); + + // Record final result as 1 (match) or 0 (no match) + const finalValue = dp[textLength]![patternLength]!; + tracker.updateResult(finalValue, { isMatch: finalValue === 1 }); + + tracker.complete({ result: finalValue }); + return tracker.getSteps(); +} + +/** + * Build the full regex matching DP matrix for a given text/pattern pair. + * Returns a (textLength+1) × (patternLength+1) matrix where + * dp[rowIdx][colIdx] is 1 if text[0..rowIdx-1] matches pattern[0..colIdx-1], else 0. + */ +function buildDpMatrix(text: string, pattern: string): number[][] { + const textLength = text.length; + const patternLength = pattern.length; + const dp: number[][] = Array.from({ length: textLength + 1 }, () => + new Array(patternLength + 1).fill(0), + ); + + dp[0]![0] = 1; + + for (let colIdx = 2; colIdx <= patternLength; colIdx++) { + if (pattern[colIdx - 1] === "*") { + dp[0]![colIdx] = dp[0]![colIdx - 2]!; + } + } + + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const patternChar = pattern[colIdx - 1]; + const textChar = text[rowIdx - 1]; + + if (patternChar === "*") { + const zeroOccurrences = dp[rowIdx]![colIdx - 2] ?? 0; + const precedingChar = colIdx >= 2 ? pattern[colIdx - 2] : undefined; + const charMatches = precedingChar === "." || precedingChar === textChar; + const oneMore = charMatches ? (dp[rowIdx - 1]![colIdx] ?? 0) : 0; + dp[rowIdx]![colIdx] = zeroOccurrences === 1 || oneMore === 1 ? 1 : 0; + } else if (patternChar === "." || patternChar === textChar) { + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]!; + } else { + dp[rowIdx]![colIdx] = 0; + } + } + } + + return dp; +} + +/** + * Trace the matching path from bottom-right to top-left through a pre-built DP matrix. + * Returns an array of [rowIdx, colIdx] pairs representing the path in forward order. + * Only traces if the match succeeded (dp[textLength][patternLength] === 1). + */ +function traceMatchPath(dp: number[][], text: string, pattern: string): [number, number][] { + const textLength = text.length; + const patternLength = pattern.length; + + if (dp[textLength]![patternLength] !== 1) { + return [[textLength, patternLength]]; + } + + const path: [number, number][] = []; + let rowIdx = textLength; + let colIdx = patternLength; + + while (rowIdx > 0 || colIdx > 0) { + path.push([rowIdx, colIdx]); + + if (rowIdx === 0) { + colIdx--; + } else if (colIdx === 0) { + rowIdx--; + } else { + const patternChar = pattern[colIdx - 1]; + if (patternChar === "*") { + // Prefer zero-occurrences (skip two pattern positions) if it contributed + if (colIdx >= 2 && dp[rowIdx]![colIdx - 2] === 1) { + colIdx -= 2; + } else { + rowIdx--; + } + } else { + // '.' or exact char match — came from diagonal + rowIdx--; + colIdx--; + } + } + } + + path.push([0, 0]); + return path.reverse(); +} diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/SuffixArrayConstructionPipeline.stories.tsx b/src/algorithms/strings/edit-distance/suffix-array-construction/SuffixArrayConstructionPipeline.stories.tsx new file mode 100644 index 00000000..c436491b --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/SuffixArrayConstructionPipeline.stories.tsx @@ -0,0 +1,61 @@ +/** + * Storybook stories for the Suffix Array Construction algorithm pipeline. + * Uses the real step generator with the default input ("banana"), + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateSuffixArrayConstructionSteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Suffix Array Construction", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — suffix indices initialized before any sorting */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Suffix indices filled — all starting positions recorded in the matrix */ +export const SuffixIndicesFilled: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.2)]!.visualState as DistanceVisualState, + }, +}; + +/** Comparisons in progress — suffix pairs being compared lexicographically */ +export const ComparisonsInProgress: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** Sorted order traced — final suffix array path highlighted in the matrix */ +export const SortedOrderTraced: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.85)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — suffix array [5,3,1,0,4,2] for "banana" fully constructed */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/educational.ts b/src/algorithms/strings/edit-distance/suffix-array-construction/educational.ts new file mode 100644 index 00000000..796644e7 --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/educational.ts @@ -0,0 +1,86 @@ +/** Educational content for the Suffix Array Construction algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const suffixArrayConstructionEducational: EducationalContent = { + overview: + "A **Suffix Array** is a sorted array of all suffixes of a string, represented as their starting indices.\n\n" + + "For the string `banana`, the suffixes are:\n\n" + + "| Index | Suffix |\n" + + "| ----- | -------- |\n" + + "| 0 | banana |\n" + + "| 1 | anana |\n" + + "| 2 | nana |\n" + + "| 3 | ana |\n" + + "| 4 | na |\n" + + "| 5 | a |\n\n" + + "When sorted lexicographically, the order becomes: `a`, `ana`, `anana`, `banana`, `na`, `nana` — " + + "so the suffix array is `[5, 3, 1, 0, 4, 2]`.\n\n" + + "Suffix arrays are a space-efficient alternative to suffix trees and enable fast string operations like pattern matching, " + + "longest repeated substring detection, and more.", + + howItWorks: + "The **naive construction** works in three steps:\n\n" + + "**1. Generate suffix indices:**\n\n" + + "Create an array `[0, 1, 2, ..., n-1]` where each integer `idx` represents the suffix starting at position `idx`.\n\n" + + "**2. Sort by suffix string:**\n\n" + + "Sort the indices using a comparator that compares the actual suffix strings:\n\n" + + "```\n" + + "sort(indices, (a, b) => text.slice(a) < text.slice(b) ? -1 : 1)\n" + + "```\n\n" + + "Each comparison takes up to `O(n)` time, and sorting performs `O(n log n)` comparisons, " + + "giving `O(n log²n)` total for the naive approach.\n\n" + + "**3. Return sorted indices:**\n\n" + + "The result is the suffix array — a permutation of `[0..n-1]` where `suffixArray[rank]` is the " + + "starting index of the `rank`-th smallest suffix.\n\n" + + "More advanced algorithms (DC3/Skew, SA-IS) achieve `O(n)` construction time.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n log²n)`**\n\n" + + "The sort performs `O(n log n)` comparisons. Each comparison of two suffixes takes up to `O(n)` time " + + "(comparing character by character until a difference is found), giving `O(n log n × n) = O(n² log n)` " + + "in the worst case for a naive sort. However, with a proper suffix string sort, it is `O(n log²n)` on average.\n\n" + + "More advanced algorithms (SA-IS, DC3) achieve `O(n)` construction.\n\n" + + "**Space Complexity: `O(n)`**\n\n" + + "The suffix array itself stores `n` integers. The suffix strings are virtual (slices of the original), " + + "so no additional `O(n²)` space is required.", + + bestAndWorstCase: + "**Best case — all unique characters:** When every character is distinct, suffix comparisons " + + "resolve quickly (often in 1–2 characters), making sort comparisons fast in practice.\n\n" + + "**Worst case — highly repetitive strings:** Strings like `aaaaaa...` cause every suffix comparison " + + "to scan many characters before finding a difference, pushing towards `O(n²)` comparison cost.\n\n" + + "For pathological inputs, O(n)-time algorithms like SA-IS are preferred.", + + realWorldUses: [ + "**Full-text search:** Enables binary-search based pattern matching in `O(m log n)` time after `O(n log²n)` preprocessing — faster than brute force for repeated queries.", + "**Bioinformatics:** Locating gene subsequences, finding repeated motifs, and aligning DNA/protein sequences across large genomes.", + "**Data compression (BWT):** The Burrows-Wheeler Transform, used in bzip2 and DNA compression, is computed directly from the suffix array.", + "**Plagiarism detection:** Finding shared substrings across documents using the Longest Common Extension (LCE) query built on a suffix array.", + "**Longest Repeated Substring:** Finding the longest substring that appears at least twice in linear time using the suffix array and LCP array.", + "**String similarity:** Computing the longest common substring between two strings by concatenating them with a separator and building a joint suffix array.", + ], + + strengthsAndLimitations: { + strengths: [ + "Space-efficient: uses `O(n)` integers versus the `O(n)` pointers but larger constant of a suffix tree.", + "Cache-friendly: arrays have better memory locality than pointer-based suffix trees.", + "Simple to implement: the naive version requires only a sort with a custom comparator.", + "Supports many string operations via augmentation with the LCP (Longest Common Prefix) array.", + ], + limitations: [ + "Naive construction is `O(n log²n)` — not linear; large inputs may require SA-IS or DC3.", + "Querying requires additional structures (LCP array, RMQ) for full-power string operations.", + "Less intuitive than suffix trees — understanding rank and LCP relationships takes more effort.", + "For single-query pattern matching on a string that changes, recomputing the suffix array is expensive.", + ], + }, + + whenToUseIt: + "Use Suffix Array Construction when you need to perform **multiple pattern matching or substring queries** on a fixed string, " + + "and cannot afford the memory overhead of a suffix tree. It is the standard choice in competitive programming and " + + "bioinformatics for exact string matching, longest repeated substring, and BWT-based compression.\n\n" + + "Prefer **SA-IS or DC3** for very large strings (millions of characters) where `O(n log²n)` is too slow.\n\n" + + "Avoid suffix arrays when the string changes frequently (use a balanced BST-based structure), " + + "or when you only need a single substring query (use KMP or Rabin-Karp instead).", +}; diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/index.ts b/src/algorithms/strings/edit-distance/suffix-array-construction/index.ts new file mode 100644 index 00000000..32156fa7 --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Suffix Array Construction — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { suffixArrayConstruction } from "./sources/suffix-array-construction.ts?fn"; +import { generateSuffixArrayConstructionSteps } from "./step-generator"; +import type { SuffixArrayConstructionInput } from "./step-generator"; +import { suffixArrayConstructionEducational } from "./educational"; + +import typescriptSource from "./sources/suffix-array-construction.ts?raw"; +import pythonSource from "./sources/suffix-array-construction.py?raw"; +import javaSource from "./sources/SuffixArrayConstruction.java?raw"; + +function executeSuffixArrayConstruction(input: SuffixArrayConstructionInput): number[] { + return suffixArrayConstruction(input.text) as number[]; +} + +const suffixArrayConstructionDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.SUFFIX_ARRAY_CONSTRUCTION!, + name: "Suffix Array Construction", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Build a sorted array of all suffixes of a string represented as starting indices, using lexicographic comparison to order them", + timeComplexity: { + best: "O(n log²n)", + average: "O(n log²n)", + worst: "O(n log²n)", + }, + spaceComplexity: "O(n)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "banana" }, + }, + execute: executeSuffixArrayConstruction, + generateSteps: generateSuffixArrayConstructionSteps, + educational: suffixArrayConstructionEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(suffixArrayConstructionDefinition); diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/sources/SuffixArrayConstruction.java b/src/algorithms/strings/edit-distance/suffix-array-construction/sources/SuffixArrayConstruction.java new file mode 100644 index 00000000..b7c63492 --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/sources/SuffixArrayConstruction.java @@ -0,0 +1,38 @@ +// Suffix Array Construction (naive approach) +// Generates all suffixes of a string, sorts them lexicographically, +// and returns the array of starting indices in sorted suffix order. +// Time: O(n log²n), Space: O(n) + +import java.util.Arrays; + +public class SuffixArrayConstruction { + + public static int[] suffixArrayConstruction(String text) { + int textLength = text.length(); // @step:initialize + + if (textLength == 0) { + return new int[0]; // @step:complete + } + + // Build array of suffix starting indices [0, 1, ..., n-1] + Integer[] suffixIndices = new Integer[textLength]; // @step:initialize + for (int idx = 0; idx < textLength; idx++) { + suffixIndices[idx] = idx; // @step:initialize + } + + // Sort indices by their corresponding suffix lexicographically + Arrays.sort(suffixIndices, (firstIdx, secondIdx) -> { // @step:compare + String firstSuffix = text.substring(firstIdx); // @step:compare + String secondSuffix = text.substring(secondIdx); // @step:compare + return firstSuffix.compareTo(secondSuffix); // @step:compare + }); + + // Convert Integer[] to int[] + int[] result = new int[textLength]; // @step:complete + for (int idx = 0; idx < textLength; idx++) { + result[idx] = suffixIndices[idx]; // @step:complete + } + + return result; // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.py b/src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.py new file mode 100644 index 00000000..41b8c57c --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.py @@ -0,0 +1,24 @@ +# Suffix Array Construction (naive approach) +# Generates all suffixes of a string, sorts them lexicographically, +# and returns the array of starting indices in sorted suffix order. +# Time: O(n log²n), Space: O(n) + +from typing import List + + +def suffix_array_construction(text: str) -> List[int]: + text_length = len(text) # @step:initialize + + if text_length == 0: + return [] # @step:complete + + # Build array of suffix starting indices [0, 1, ..., n-1] + suffix_indices = list(range(text_length)) # @step:initialize + + # Sort indices by their corresponding suffix lexicographically + def compare_suffixes(first_idx: int) -> str: # @step:compare + return text[first_idx:] # @step:compare + + suffix_indices.sort(key=compare_suffixes) # @step:compare + + return suffix_indices # @step:complete diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.ts b/src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.ts new file mode 100644 index 00000000..c77f2c09 --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/sources/suffix-array-construction.ts @@ -0,0 +1,27 @@ +// Suffix Array Construction (naive approach) +// Generates all suffixes of a string, sorts them lexicographically, +// and returns the array of starting indices in sorted suffix order. +// Time: O(n log²n) due to string comparisons during sort, Space: O(n) + +export function suffixArrayConstruction(text: string): number[] { + const textLength = text.length; // @step:initialize + + if (textLength === 0) { + return []; // @step:complete + } + + // Build array of suffix starting indices [0, 1, ..., n-1] + const suffixIndices: number[] = Array.from({ length: textLength }, (_, idx) => idx); // @step:initialize + + // Sort indices by their corresponding suffix lexicographically + suffixIndices.sort((firstIdx, secondIdx) => { + // @step:compare + const firstSuffix = text.slice(firstIdx); // @step:compare + const secondSuffix = text.slice(secondIdx); // @step:compare + if (firstSuffix < secondSuffix) return -1; // @step:compare + if (firstSuffix > secondSuffix) return 1; // @step:compare + return 0; // @step:compare + }); + + return suffixIndices; // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.test.ts b/src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.test.ts new file mode 100644 index 00000000..feccc09b --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.test.ts @@ -0,0 +1,94 @@ +/** Step generation tests for Suffix Array Construction. */ + +import { describe, it, expect } from "vitest"; +import { generateSuffixArrayConstructionSteps } from "./step-generator"; + +describe("generateSuffixArrayConstructionSteps", () => { + it("produces steps for the default input", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits fill-table steps for suffix index initialization", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + const fillTableSteps = steps.filter((step) => step.type === "fill-table"); + expect(fillTableSteps.length).toBeGreaterThan(0); + }); + + it("emits compare steps during suffix sorting", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step for the sorted order", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step with the suffix count as result", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "banana" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + expect(foundStep?.visualState.kind).toBe("string-distance"); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBe(6); + } + }); + + it("handles empty string with minimal steps", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "" }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("handles single character string", () => { + const steps = generateSuffixArrayConstructionSteps({ text: "a" }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("matrix dimensions match text length for square matrix", () => { + const text = "abc"; + const steps = generateSuffixArrayConstructionSteps({ text }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(text.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(text.length + 1); + } + }); + + it("produces more fill-table steps for longer input", () => { + const shortSteps = generateSuffixArrayConstructionSteps({ text: "ab" }); + const longSteps = generateSuffixArrayConstructionSteps({ text: "banana" }); + const shortFillCount = shortSteps.filter((step) => step.type === "fill-table").length; + const longFillCount = longSteps.filter((step) => step.type === "fill-table").length; + expect(longFillCount).toBeGreaterThan(shortFillCount); + }); +}); diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.ts b/src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.ts new file mode 100644 index 00000000..1a79d391 --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/step-generator.ts @@ -0,0 +1,101 @@ +/** Step generator for Suffix Array Construction — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const SUFFIX_ARRAY_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.SUFFIX_ARRAY_CONSTRUCTION!); + +export interface SuffixArrayConstructionInput { + text: string; +} + +export function generateSuffixArrayConstructionSteps( + input: SuffixArrayConstructionInput, +): ExecutionStep[] { + const { text } = input; + const textLength = text.length; + + // Use DistanceTracker with text as both source and target. + // The matrix rows represent suffixes (by starting index), + // and columns represent suffix positions for comparison visualization. + const tracker = new DistanceTracker(text, text, SUFFIX_ARRAY_LINE_MAP); + + // Emit initialization step + tracker.initialize({ text, textLength }); + + if (textLength === 0) { + tracker.updateResult(0, { suffixArray: [] }); + tracker.complete({ result: [] }); + return tracker.getSteps(); + } + + // Build the initial suffix indices array + const suffixIndices: number[] = Array.from({ length: textLength }, (_, idx) => idx); + + // Show each suffix in the matrix as a "base case" fill + for (let suffixIdx = 0; suffixIdx < textLength; suffixIdx++) { + const startIndex = suffixIndices[suffixIdx]!; + tracker.fillBaseCase(suffixIdx, startIndex, startIndex, { + suffixIdx, + startIndex, + suffix: text.slice(startIndex), + }); + } + + // Perform comparisons during sort — generate all suffix pair comparisons + // We simulate the comparison pass before sorting to show the comparisons + const sortedIndices = [...suffixIndices]; + const comparePairs: [number, number][] = []; + + // Collect pairs to compare: for visualization, show adjacent pairs + for (let outerIdx = 0; outerIdx < textLength - 1; outerIdx++) { + for (let innerIdx = outerIdx + 1; innerIdx < textLength; innerIdx++) { + comparePairs.push([outerIdx, innerIdx]); + } + } + + // Show a representative set of comparisons (first suffix-length comparisons to avoid too many steps) + const maxComparisons = Math.min(comparePairs.length, textLength); + for (let pairIdx = 0; pairIdx < maxComparisons; pairIdx++) { + const pair = comparePairs[pairIdx]!; + const firstSuffixStart = pair[0]; + const secondSuffixStart = pair[1]; + const firstSuffix = text.slice(firstSuffixStart); + const secondSuffix = text.slice(secondSuffixStart); + const isFirstSmaller = firstSuffix <= secondSuffix; + + tracker.compareChars(firstSuffixStart, secondSuffixStart, isFirstSmaller, { + firstSuffix, + secondSuffix, + firstSuffixStart, + secondSuffixStart, + firstComesFirst: isFirstSmaller, + }); + } + + // Sort the suffix indices + sortedIndices.sort((firstIdx, secondIdx) => { + const firstSuffix = text.slice(firstIdx); + const secondSuffix = text.slice(secondIdx); + if (firstSuffix < secondSuffix) return -1; + if (firstSuffix > secondSuffix) return 1; + return 0; + }); + + // Trace the sorted order path through the matrix + const sortedPath: [number, number][] = sortedIndices.map( + (startIdx, rankIdx) => [rankIdx, startIdx] as [number, number], + ); + tracker.tracePath(sortedPath, { sortedOrder: sortedIndices }); + + // Emit result + tracker.updateResult(sortedIndices.length, { + suffixArray: sortedIndices, + suffixCount: sortedIndices.length, + }); + + tracker.complete({ result: sortedIndices }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/edit-distance/suffix-array-construction/suffix-array-construction.test.ts b/src/algorithms/strings/edit-distance/suffix-array-construction/suffix-array-construction.test.ts new file mode 100644 index 00000000..71dbbb5a --- /dev/null +++ b/src/algorithms/strings/edit-distance/suffix-array-construction/suffix-array-construction.test.ts @@ -0,0 +1,68 @@ +/** Correctness tests for the suffixArrayConstruction pure function. */ + +import { describe, it, expect } from "vitest"; +import { suffixArrayConstruction } from "./sources/suffix-array-construction.ts?fn"; + +describe("suffixArrayConstruction", () => { + it('returns [5,3,1,0,4,2] for "banana"', () => { + // Suffixes in sorted order: a(5), ana(3), anana(1), banana(0), na(4), nana(2) + expect(suffixArrayConstruction("banana")).toEqual([5, 3, 1, 0, 4, 2]); + }); + + it('returns [0] for single character "a"', () => { + expect(suffixArrayConstruction("a")).toEqual([0]); + }); + + it("returns [] for empty string", () => { + expect(suffixArrayConstruction("")).toEqual([]); + }); + + it("returns indices in suffix-sorted order for a two-character string", () => { + // "ab" → suffixes: "ab"(0), "b"(1) → sorted: "ab" < "b" → [0, 1] + expect(suffixArrayConstruction("ab")).toEqual([0, 1]); + }); + + it("returns reversed indices when input is in descending char order", () => { + // "ba" → suffixes: "ba"(0), "a"(1) → sorted: "a" < "ba" → [1, 0] + expect(suffixArrayConstruction("ba")).toEqual([1, 0]); + }); + + it('handles all identical characters "aaa"', () => { + // Suffixes: "aaa"(0), "aa"(1), "a"(2) → sorted: "a"(2) < "aa"(1) < "aaa"(0) + expect(suffixArrayConstruction("aaa")).toEqual([2, 1, 0]); + }); + + it('handles "mississippi" with known suffix array', () => { + // Known suffix array for "mississippi": [10,7,4,1,0,9,8,6,3,5,2] + // i(10), ippi(7), issippi(4), ississippi(1), mississippi(0), pi(9), ppi(8), sippi(6), sissippi(3), ssippi(5), ssissippi(2) + expect(suffixArrayConstruction("mississippi")).toEqual([10, 7, 4, 1, 0, 9, 8, 6, 3, 5, 2]); + }); + + it("produces an array of length equal to input text length", () => { + const result = suffixArrayConstruction("hello"); + expect(result).toHaveLength(5); + }); + + it("produces a permutation of [0..n-1]", () => { + const text = "abracadabra"; + const result = suffixArrayConstruction(text); + const sorted = [...result].sort((firstVal, secondVal) => firstVal - secondVal); + expect(sorted).toEqual(Array.from({ length: text.length }, (_, idx) => idx)); + }); + + it("handles single repeated pair", () => { + // "abab" → suffixes: "abab"(0), "bab"(1), "ab"(2), "b"(3) + // sorted: "ab"(2) < "abab"(0) < "b"(3) < "bab"(1) + expect(suffixArrayConstruction("abab")).toEqual([2, 0, 3, 1]); + }); + + it("produces suffix array where each successive suffix is lexicographically larger", () => { + const text = "banana"; + const suffixArray = suffixArrayConstruction(text) as number[]; + for (let rankIdx = 0; rankIdx < suffixArray.length - 1; rankIdx++) { + const currentSuffix = text.slice(suffixArray[rankIdx]!); + const nextSuffix = text.slice(suffixArray[rankIdx + 1]!); + expect(currentSuffix <= nextSuffix).toBe(true); + } + }); +}); diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/WildcardMatchingPipeline.stories.tsx b/src/algorithms/strings/edit-distance/wildcard-matching/WildcardMatchingPipeline.stories.tsx new file mode 100644 index 00000000..695db54e --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/WildcardMatchingPipeline.stories.tsx @@ -0,0 +1,64 @@ +/** + * Storybook stories for the Wildcard Matching algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the DistanceVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { DistanceVisualState } from "@/types"; +import { generateWildcardMatchingSteps } from "./step-generator"; +import DistanceVisualizer from "@/components/visualization/DistanceVisualizer"; + +const steps = generateWildcardMatchingSteps({ + text: "adceb", + pattern: "*a*b", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Wildcard Matching", + component: DistanceVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty DP matrix before any cells are filled */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as DistanceVisualState, + }, +}; + +/** Base cases filled — row 0 populated with wildcard star propagation */ +export const BaseCasesFilled: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.15)]!.visualState as DistanceVisualState, + }, +}; + +/** Mid computation — DP matrix partially filled during interior cell pass */ +export const MidComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as DistanceVisualState, + }, +}; + +/** Match path traced — optimal path highlighted from bottom-right to top-left */ +export const MatchPathTraced: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.9)]!.visualState as DistanceVisualState, + }, +}; + +/** Final state — match result computed, result displayed as 1 (true) */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as DistanceVisualState, + }, +}; diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/educational.ts b/src/algorithms/strings/edit-distance/wildcard-matching/educational.ts new file mode 100644 index 00000000..a2a2778a --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/educational.ts @@ -0,0 +1,68 @@ +/** Educational content for the Wildcard Matching algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const wildcardMatchingEducational: EducationalContent = { + overview: + "**Wildcard Matching** determines whether a text string fully matches a pattern that may contain two special wildcard characters:\n\n" + + "- `?` matches **any single character**\n" + + "- `*` matches **any sequence of characters**, including the empty sequence\n\n" + + "For example, the pattern `*a*b` matches `adceb` because the first `*` matches nothing, `a` matches `a`, the second `*` matches `dce`, and `b` matches `b`.\n\n" + + "The algorithm uses **dynamic programming** to decide this in `O(n × m)` time without backtracking.", + + howItWorks: + "Wildcard Matching uses a 2D DP table where `dp[rowIdx][colIdx]` is `true` if `text[0..rowIdx-1]` fully matches `pattern[0..colIdx-1]`.\n\n" + + "**1. Initialization:**\n\n" + + "- `dp[0][0] = true` — empty text matches empty pattern.\n" + + "- `dp[0][colIdx] = true` only if `pattern[0..colIdx-1]` consists entirely of `'*'` characters, because `*` can match the empty string.\n\n" + + "**2. Recurrence (for each interior cell):**\n\n" + + "```\n" + + "if pattern[colIdx-1] == '*':\n" + + " dp[rowIdx][colIdx] = dp[rowIdx][colIdx-1] // '*' matches empty\n" + + " OR dp[rowIdx-1][colIdx] // '*' matches one more char\n" + + "elif pattern[colIdx-1] == '?' or pattern[colIdx-1] == text[rowIdx-1]:\n" + + " dp[rowIdx][colIdx] = dp[rowIdx-1][colIdx-1] // single char match\n" + + "else:\n" + + " dp[rowIdx][colIdx] = false\n" + + "```\n\n" + + "**3. Result:** `dp[textLength][patternLength]` is `true` if the entire text matches the entire pattern.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "Every cell in the `(n+1) × (m+1)` matrix is filled in constant time, giving `O(n × m)` total — where `n = text.length` and `m = pattern.length`.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "The full DP matrix is stored. Space can be reduced to `O(m)` by keeping only the current and previous rows, since each cell only depends on the row above and the cell to its left.", + + bestAndWorstCase: + "**Best case — immediate mismatch:** A non-wildcard pattern character that does not match the first text character allows most cells to remain `false`. The matrix is still filled in `O(n × m)` time — there is no early termination.\n\n" + + "**Worst case — many `*` wildcards:** Patterns like `*****` or alternating `*?*?` force the algorithm to explore the full table, since each `*` can expand in two directions. Time remains `O(n × m)` but with maximum branching at every cell.\n\n" + + "Unlike naive recursive matching (which can be exponential), the DP formulation guarantees polynomial time in all cases.", + + realWorldUses: [ + "**File system glob patterns:** Shell wildcards like `*.ts` or `src/**/*.test.ts` use the same `?`/`*` semantics to match file paths.", + "**Database LIKE queries:** SQL `LIKE 'J%n'` patterns are conceptually equivalent to wildcard matching with `%` acting as `*`.", + "**Log and event filtering:** Operations tools filter log streams using wildcard patterns to isolate events of interest without full regex overhead.", + "**URL routing:** Some routers use simplified wildcard patterns (e.g., `/api/*/data`) to dispatch requests before applying stricter regex rules.", + "**Configuration management:** Tools like `.gitignore` and `.dockerignore` rely on glob/wildcard semantics to select or exclude files.", + "**Network intrusion detection:** Signature-based IDS systems match packet payloads against wildcard patterns to flag suspicious traffic.", + ], + + strengthsAndLimitations: { + strengths: [ + "Guarantees `O(n × m)` time — no exponential blowup unlike naive recursive matching.", + "Handles both `?` (single-char) and `*` (multi-char) wildcards with a single unified DP formulation.", + "Space-optimizable to `O(m)` when only the boolean result is needed.", + "Straightforward recurrence — easy to extend to additional wildcard types if needed.", + ], + limitations: [ + "`O(n × m)` time and space — costly for very long strings or patterns with many wildcards.", + "Only supports `?` and `*`; full regular expression features (groups, quantifiers, alternation) require a more complex NFA/DFA approach.", + "No partial-match output — the algorithm only reports whether the full text matches, not where wildcards aligned.", + "The `*` wildcard always matches greedily in one interpretation; ambiguous patterns can produce surprising match boundaries.", + ], + }, + + whenToUseIt: + "Use Wildcard Matching when you need to test whether a string **fully** matches a `?`/`*` glob pattern and the `O(n × m)` cost is acceptable for the input sizes involved. It is the right choice for file-glob evaluation, simple template matching, and any domain where full regex power is unnecessary.\n\n" + + "Avoid it when you need partial matching (use KMP or Rabin-Karp), when patterns involve complex repetitions or groups (use a full regex engine), or when strings are very long and you need sub-quadratic performance (use NFA simulation with memoization or SIMD-accelerated matching).", +}; diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/index.ts b/src/algorithms/strings/edit-distance/wildcard-matching/index.ts new file mode 100644 index 00000000..e8d7b213 --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Wildcard Matching — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { wildcardMatching } from "./sources/wildcard-matching.ts?fn"; +import { generateWildcardMatchingSteps } from "./step-generator"; +import type { WildcardMatchingInput } from "./step-generator"; +import { wildcardMatchingEducational } from "./educational"; + +import typescriptSource from "./sources/wildcard-matching.ts?raw"; +import pythonSource from "./sources/wildcard-matching.py?raw"; +import javaSource from "./sources/WildcardMatching.java?raw"; + +function executeWildcardMatching(input: WildcardMatchingInput): boolean { + return wildcardMatching(input.text, input.pattern) as boolean; +} + +const wildcardMatchingDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.WILDCARD_MATCHING!, + name: "Wildcard Matching", + category: CATEGORY.STRINGS!, + technique: "edit-distance", + description: + "Determine if a text string matches a pattern that may contain '?' (any single character) and '*' (any sequence of characters, including empty) using dynamic programming", + timeComplexity: { + best: "O(nm)", + average: "O(nm)", + worst: "O(nm)", + }, + spaceComplexity: "O(nm)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "adceb", pattern: "*a*b" }, + }, + execute: executeWildcardMatching, + generateSteps: generateWildcardMatchingSteps, + educational: wildcardMatchingEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(wildcardMatchingDefinition); diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/sources/WildcardMatching.java b/src/algorithms/strings/edit-distance/wildcard-matching/sources/WildcardMatching.java new file mode 100644 index 00000000..986a3d06 --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/sources/WildcardMatching.java @@ -0,0 +1,46 @@ +// Wildcard Matching +// Determines if a text string matches a pattern that may contain '?' (any single character) +// or '*' (any sequence of characters, including empty). +// Uses dynamic programming: dp[rowIdx][colIdx] = 1 if text[0..rowIdx-1] matches pattern[0..colIdx-1]. +// Time: O(nm), Space: O(nm) + +public class WildcardMatching { + + public static boolean wildcardMatching(String text, String pattern) { + int textLength = text.length(); // @step:initialize + int patternLength = pattern.length(); // @step:initialize + + // Allocate (textLength+1) x (patternLength+1) DP matrix (1 = true, 0 = false) + int[][] dp = new int[textLength + 1][patternLength + 1]; // @step:initialize + + // Base case: empty text matches empty pattern + dp[0][0] = 1; // @step:fill-table + + // Base case: empty text can only match a pattern of all '*' + for (int colIdx = 1; colIdx <= patternLength; colIdx++) { + dp[0][colIdx] = pattern.charAt(colIdx - 1) == '*' && dp[0][colIdx - 1] == 1 ? 1 : 0; // @step:fill-table + } + + // Fill the rest of the matrix + for (int rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (int colIdx = 1; colIdx <= patternLength; colIdx++) { + char textChar = text.charAt(rowIdx - 1); // @step:compare + char patternChar = pattern.charAt(colIdx - 1); // @step:compare + + if (patternChar == '*') { + // '*' matches empty sequence (dp[rowIdx][colIdx-1]) or one more char (dp[rowIdx-1][colIdx]) + int matchEmpty = dp[rowIdx][colIdx - 1]; // @step:compute-distance + int matchOne = dp[rowIdx - 1][colIdx]; // @step:compute-distance + dp[rowIdx][colIdx] = matchEmpty == 1 || matchOne == 1 ? 1 : 0; // @step:compute-distance + } else if (patternChar == '?' || patternChar == textChar) { + // '?' matches any single char, or exact character match + dp[rowIdx][colIdx] = dp[rowIdx - 1][colIdx - 1]; // @step:compute-distance + } else { + dp[rowIdx][colIdx] = 0; // @step:compute-distance + } + } + } + + return dp[textLength][patternLength] == 1; // @step:complete + } +} diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.py b/src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.py new file mode 100644 index 00000000..1e0d9927 --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.py @@ -0,0 +1,38 @@ +# Wildcard Matching +# Determines if a text string matches a pattern that may contain '?' (any single character) +# or '*' (any sequence of characters, including empty). +# Uses dynamic programming: dp[row_idx][col_idx] = 1 if text[0..row_idx-1] matches pattern[0..col_idx-1]. +# Time: O(nm), Space: O(nm) + +def wildcard_matching(text: str, pattern: str) -> bool: + text_length = len(text) # @step:initialize + pattern_length = len(pattern) # @step:initialize + + # Allocate (text_length+1) x (pattern_length+1) DP matrix (1 = True, 0 = False) + dp = [[0] * (pattern_length + 1) for _ in range(text_length + 1)] # @step:initialize + + # Base case: empty text matches empty pattern + dp[0][0] = 1 # @step:fill-table + + # Base case: empty text can only match a pattern of all '*' + for col_idx in range(1, pattern_length + 1): + dp[0][col_idx] = 1 if pattern[col_idx - 1] == "*" and dp[0][col_idx - 1] == 1 else 0 # @step:fill-table + + # Fill the rest of the matrix + for row_idx in range(1, text_length + 1): + for col_idx in range(1, pattern_length + 1): + text_char = text[row_idx - 1] # @step:compare + pattern_char = pattern[col_idx - 1] # @step:compare + + if pattern_char == "*": + # '*' matches empty sequence (dp[row_idx][col_idx-1]) or one more char (dp[row_idx-1][col_idx]) + match_empty = dp[row_idx][col_idx - 1] # @step:compute-distance + match_one = dp[row_idx - 1][col_idx] # @step:compute-distance + dp[row_idx][col_idx] = 1 if match_empty == 1 or match_one == 1 else 0 # @step:compute-distance + elif pattern_char == "?" or pattern_char == text_char: + # '?' matches any single char, or exact character match + dp[row_idx][col_idx] = dp[row_idx - 1][col_idx - 1] # @step:compute-distance + else: + dp[row_idx][col_idx] = 0 # @step:compute-distance + + return dp[text_length][pattern_length] == 1 # @step:complete diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.ts b/src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.ts new file mode 100644 index 00000000..8c9492e7 --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/sources/wildcard-matching.ts @@ -0,0 +1,46 @@ +// Wildcard Matching +// Determines if a text string matches a pattern that may contain '?' (any single character) +// or '*' (any sequence of characters, including empty). +// Uses dynamic programming: dp[rowIdx][colIdx] = true if text[0..rowIdx-1] matches pattern[0..colIdx-1]. +// Time: O(nm), Space: O(nm) where n = text.length, m = pattern.length + +export function wildcardMatching(text: string, pattern: string): boolean { + const textLength = text.length; // @step:initialize + const patternLength = pattern.length; // @step:initialize + + // Allocate (textLength+1) × (patternLength+1) boolean DP matrix (stored as 1/0) + const dp: number[][] = Array.from({ length: textLength + 1 }, () => + // @step:initialize + new Array(patternLength + 1).fill(0), + ); + + // Base case: empty text matches empty pattern + dp[0]![0] = 1; // @step:fill-table + + // Base case: empty text can only match a pattern of all '*' + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + dp[0]![colIdx] = pattern[colIdx - 1] === "*" ? dp[0]![colIdx - 1]! : 0; // @step:fill-table + } + + // Fill the rest of the matrix + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const textChar = text[rowIdx - 1]; // @step:compare + const patternChar = pattern[colIdx - 1]; // @step:compare + + if (patternChar === "*") { + // '*' matches empty sequence (dp[rowIdx][colIdx-1]) or one more char (dp[rowIdx-1][colIdx]) + const matchEmpty = dp[rowIdx]![colIdx - 1]!; // @step:compute-distance + const matchOne = dp[rowIdx - 1]![colIdx]!; // @step:compute-distance + dp[rowIdx]![colIdx] = matchEmpty === 1 || matchOne === 1 ? 1 : 0; // @step:compute-distance + } else if (patternChar === "?" || patternChar === textChar) { + // '?' matches any single char, or exact character match + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]!; // @step:compute-distance + } else { + dp[rowIdx]![colIdx] = 0; // @step:compute-distance + } + } + } + + return dp[textLength]![patternLength]! === 1; // @step:complete +} diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/step-generator.test.ts b/src/algorithms/strings/edit-distance/wildcard-matching/step-generator.test.ts new file mode 100644 index 00000000..bf017ab6 --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/step-generator.test.ts @@ -0,0 +1,105 @@ +/** Step generation tests for Wildcard Matching. */ + +import { describe, it, expect } from "vitest"; +import { generateWildcardMatchingSteps } from "./step-generator"; + +describe("generateWildcardMatchingSteps", () => { + it("produces steps for the default input", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-distance visual states throughout", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-distance"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits fill-table steps for base cases", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + const fillTableSteps = steps.filter((step) => step.type === "fill-table"); + expect(fillTableSteps.length).toBeGreaterThan(0); + }); + + it("emits compute-distance steps for interior cells", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + const computeSteps = steps.filter((step) => step.type === "compute-distance"); + expect(computeSteps.length).toBeGreaterThan(0); + }); + + it("emits a trace-edit-path step", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + const traceSteps = steps.filter((step) => step.type === "trace-edit-path"); + expect(traceSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step with result 1 for a matching input", () => { + const steps = generateWildcardMatchingSteps({ text: "adceb", pattern: "*a*b" }); + const foundStep = steps.find((step) => step.type === "found"); + expect(foundStep).toBeDefined(); + expect(foundStep?.visualState.kind).toBe("string-distance"); + if (foundStep?.visualState.kind === "string-distance") { + expect(foundStep.visualState.result).toBe(1); + } + }); + + it('returns result 0 for non-matching "aa" against "a"', () => { + const steps = generateWildcardMatchingSteps({ text: "aa", pattern: "a" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(0); + } + }); + + it('returns result 1 for matching "aa" against "*"', () => { + const steps = generateWildcardMatchingSteps({ text: "aa", pattern: "*" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(1); + } + }); + + it("returns result 1 for empty text against empty pattern", () => { + const steps = generateWildcardMatchingSteps({ text: "", pattern: "" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-distance") { + expect(completeStep.visualState.result).toBe(1); + } + }); + + it("emits compare steps when processing interior cells", () => { + const steps = generateWildcardMatchingSteps({ text: "ab", pattern: "a?" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("matrix dimensions match text and pattern lengths", () => { + const text = "abc"; + const pattern = "a*"; + const steps = generateWildcardMatchingSteps({ text, pattern }); + const firstStep = steps[0]!; + if (firstStep.visualState.kind === "string-distance") { + expect(firstStep.visualState.matrix.length).toBe(text.length + 1); + expect(firstStep.visualState.matrix[0]?.length).toBe(pattern.length + 1); + } + }); +}); diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/step-generator.ts b/src/algorithms/strings/edit-distance/wildcard-matching/step-generator.ts new file mode 100644 index 00000000..54ec448b --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/step-generator.ts @@ -0,0 +1,162 @@ +/** Step generator for Wildcard Matching — produces ExecutionStep[] using DistanceTracker. */ + +import type { ExecutionStep } from "@/types"; +import { DistanceTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const WILDCARD_MATCHING_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.WILDCARD_MATCHING!); + +export interface WildcardMatchingInput { + text: string; + pattern: string; +} + +export function generateWildcardMatchingSteps(input: WildcardMatchingInput): ExecutionStep[] { + const { text, pattern } = input; + // DistanceTracker expects source and target — map text → source, pattern → target + const tracker = new DistanceTracker(text, pattern, WILDCARD_MATCHING_LINE_MAP); + + const textLength = text.length; + const patternLength = pattern.length; + + // Pre-compute the full DP matrix so each cell value is available on demand + const dp = buildDpMatrix(text, pattern); + + // Emit initialization step + tracker.initialize({ text, pattern, textLength, patternLength }); + + // Base case: dp[0][0] = 1 (empty matches empty) + tracker.fillBaseCase(0, 0, 1, { rowIdx: 0, colIdx: 0, value: 1 }); + + // Base case: row 0 — empty text matches only if all pattern chars are '*' + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const value = dp[0]![colIdx]!; + tracker.fillBaseCase(0, colIdx, value, { rowIdx: 0, colIdx, value }); + } + + // Fill interior cells row by row + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const textChar = text[rowIdx - 1]!; + const patternChar = pattern[colIdx - 1]!; + const isMatch = patternChar === "?" || patternChar === textChar || patternChar === "*"; + + // Emit a comparison step for this cell's characters + tracker.compareChars(rowIdx - 1, colIdx - 1, isMatch, { + rowIdx, + colIdx, + textChar, + patternChar, + isMatch, + }); + + const cellValue = dp[rowIdx]![colIdx]!; + + // Emit compute step (sets cell to "computing") + tracker.computeCell(rowIdx, colIdx, cellValue, { + rowIdx, + colIdx, + cellValue, + patternChar, + }); + + // Finalise cell as "computed" + tracker.markCellComputed(rowIdx, colIdx, { rowIdx, colIdx, cellValue }); + } + } + + // Trace the match path from bottom-right to top-left + const matchPath = traceMatchPath(dp, text, pattern); + tracker.tracePath(matchPath, { pathLength: matchPath.length }); + + // Record final result as 1 (match) or 0 (no match) + const finalValue = dp[textLength]![patternLength]!; + tracker.updateResult(finalValue, { isMatch: finalValue === 1 }); + + tracker.complete({ result: finalValue }); + return tracker.getSteps(); +} + +/** + * Build the full wildcard matching DP matrix for a given text/pattern pair. + * Returns a (textLength+1) × (patternLength+1) matrix where + * dp[rowIdx][colIdx] is 1 if text[0..rowIdx-1] matches pattern[0..colIdx-1], else 0. + */ +function buildDpMatrix(text: string, pattern: string): number[][] { + const textLength = text.length; + const patternLength = pattern.length; + const dp: number[][] = Array.from({ length: textLength + 1 }, () => + new Array(patternLength + 1).fill(0), + ); + + dp[0]![0] = 1; + + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + dp[0]![colIdx] = pattern[colIdx - 1] === "*" ? dp[0]![colIdx - 1]! : 0; + } + + for (let rowIdx = 1; rowIdx <= textLength; rowIdx++) { + for (let colIdx = 1; colIdx <= patternLength; colIdx++) { + const patternChar = pattern[colIdx - 1]; + const textChar = text[rowIdx - 1]; + + if (patternChar === "*") { + const matchEmpty = dp[rowIdx]![colIdx - 1]!; + const matchOne = dp[rowIdx - 1]![colIdx]!; + dp[rowIdx]![colIdx] = matchEmpty === 1 || matchOne === 1 ? 1 : 0; + } else if (patternChar === "?" || patternChar === textChar) { + dp[rowIdx]![colIdx] = dp[rowIdx - 1]![colIdx - 1]!; + } else { + dp[rowIdx]![colIdx] = 0; + } + } + } + + return dp; +} + +/** + * Trace the matching path from bottom-right to top-left through a pre-built DP matrix. + * Returns an array of [rowIdx, colIdx] pairs representing the path in forward order. + * Only traces if the match succeeded (dp[textLength][patternLength] === 1). + */ +function traceMatchPath(dp: number[][], text: string, pattern: string): [number, number][] { + const textLength = text.length; + const patternLength = pattern.length; + + if (dp[textLength]![patternLength] !== 1) { + return [[textLength, patternLength]]; + } + + const path: [number, number][] = []; + let rowIdx = textLength; + let colIdx = patternLength; + + while (rowIdx > 0 || colIdx > 0) { + path.push([rowIdx, colIdx]); + + if (rowIdx === 0) { + colIdx--; + } else if (colIdx === 0) { + rowIdx--; + } else { + const patternChar = pattern[colIdx - 1]; + if (patternChar === "*") { + // Prefer the "match empty" direction (left) if it contributed + if (dp[rowIdx]![colIdx - 1] === 1) { + colIdx--; + } else { + rowIdx--; + } + } else { + // '?' or exact char match — came from diagonal + rowIdx--; + colIdx--; + } + } + } + + path.push([0, 0]); + return path.reverse(); +} diff --git a/src/algorithms/strings/edit-distance/wildcard-matching/wildcard-matching.test.ts b/src/algorithms/strings/edit-distance/wildcard-matching/wildcard-matching.test.ts new file mode 100644 index 00000000..6beb5633 --- /dev/null +++ b/src/algorithms/strings/edit-distance/wildcard-matching/wildcard-matching.test.ts @@ -0,0 +1,66 @@ +/** Correctness tests for the wildcardMatching pure function. */ + +import { describe, it, expect } from "vitest"; +import { wildcardMatching } from "./sources/wildcard-matching.ts?fn"; + +describe("wildcardMatching", () => { + it('matches "adceb" against "*a*b" returning true', () => { + expect(wildcardMatching("adceb", "*a*b")).toBe(true); + }); + + it('does not match "aa" against "a" returning false', () => { + expect(wildcardMatching("aa", "a")).toBe(false); + }); + + it('matches "aa" against "*" returning true', () => { + expect(wildcardMatching("aa", "*")).toBe(true); + }); + + it("matches empty text against empty pattern returning true", () => { + expect(wildcardMatching("", "")).toBe(true); + }); + + it('matches "abc" against "a?c" returning true', () => { + expect(wildcardMatching("abc", "a?c")).toBe(true); + }); + + it('does not match "abc" against "a?b" returning false', () => { + expect(wildcardMatching("abc", "a?b")).toBe(false); + }); + + it('matches any string against "*" returning true', () => { + expect(wildcardMatching("anylongstring", "*")).toBe(true); + }); + + it('matches empty text against "***" returning true', () => { + expect(wildcardMatching("", "***")).toBe(true); + }); + + it('does not match "cb" against "?a" returning false', () => { + expect(wildcardMatching("cb", "?a")).toBe(false); + }); + + it('matches "adceb" against "*a*" returning true', () => { + expect(wildcardMatching("adceb", "*a*")).toBe(true); + }); + + it('does not match empty text against "a" returning false', () => { + expect(wildcardMatching("", "a")).toBe(false); + }); + + it('matches "abc" against "*bc" returning true', () => { + expect(wildcardMatching("abc", "*bc")).toBe(true); + }); + + it('matches "abc" against "abc" exactly returning true', () => { + expect(wildcardMatching("abc", "abc")).toBe(true); + }); + + it('does not match "abc" against "abcd" returning false', () => { + expect(wildcardMatching("abc", "abcd")).toBe(false); + }); + + it('matches single char "a" against "?" returning true', () => { + expect(wildcardMatching("a", "?")).toBe(true); + }); +}); diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/LongestPalindromicSubstringPipeline.stories.tsx b/src/algorithms/strings/palindrome/longest-palindromic-substring/LongestPalindromicSubstringPipeline.stories.tsx new file mode 100644 index 00000000..3d6cd34f --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/LongestPalindromicSubstringPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Longest Palindromic Substring algorithm pipeline. + * Uses the real step generator with multiple input variants, + * rendering the PalindromeVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { PalindromeVisualState } from "@/types"; +import { generateLongestPalindromicSubstringSteps } from "./step-generator"; +import PalindromeVisualizer from "@/components/visualization/PalindromeVisualizer"; + +const defaultSteps = generateLongestPalindromicSubstringSteps({ text: "babad" }); +const racecarSteps = generateLongestPalindromicSubstringSteps({ text: "racecar" }); +const cbbdSteps = generateLongestPalindromicSubstringSteps({ text: "cbbd" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Longest Palindromic Substring", + component: PalindromeVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — algorithm has just initialized on "babad", no centers explored yet */ +export const Initial: Story = { + args: { + visualState: defaultSteps[0]!.visualState as PalindromeVisualState, + }, +}; + +/** Mid-execution — center expansion in progress, some palindromes found */ +export const MidExpansion: Story = { + args: { + visualState: defaultSteps[Math.floor(defaultSteps.length * 0.45)]! + .visualState as PalindromeVisualState, + }, +}; + +/** Full palindrome input — "racecar" fully confirmed at final state */ +export const FullPalindromeComplete: Story = { + args: { + visualState: racecarSteps[racecarSteps.length - 1]!.visualState as PalindromeVisualState, + }, +}; + +/** Even-length result — "cbbd" finishes with "bb" as the longest palindrome */ +export const EvenLengthComplete: Story = { + args: { + visualState: cbbdSteps[cbbdSteps.length - 1]!.visualState as PalindromeVisualState, + }, +}; diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/educational.ts b/src/algorithms/strings/palindrome/longest-palindromic-substring/educational.ts new file mode 100644 index 00000000..d2040e40 --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/educational.ts @@ -0,0 +1,78 @@ +/** Educational content for Longest Palindromic Substring algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const longestPalindromicSubstringEducational: EducationalContent = { + overview: + "**Longest Palindromic Substring** finds the longest contiguous portion of a string that reads " + + 'the same forwards and backwards. For example, in `"babad"` the answer is `"bab"` (or `"aba"`), ' + + 'and in `"cbbd"` the answer is `"bb"`.\n\n' + + "The **Expand Around Center** technique treats each character (and each gap between adjacent characters) " + + "as a potential palindrome center, then stretches outward as long as both sides continue to match. " + + "This avoids any auxiliary storage and runs in O(n²) time with O(1) extra space.", + + howItWorks: + "For a string of length `n` there are `2n − 1` possible centers: `n` single-character centers " + + "(odd-length palindromes) and `n − 1` gap centers (even-length palindromes).\n\n" + + "**For each center:**\n" + + "1. **Expand** — move left and right pointers outward one step at a time.\n" + + "2. **Compare** — check whether `text[left] === text[right]`.\n" + + "3. **Match** — if equal, continue expanding.\n" + + "4. **Mismatch / boundary** — stop when characters differ or a pointer reaches the edge.\n" + + "5. **Update longest** — if the current palindrome is longer than the recorded best, save its start and length.\n\n" + + "```\n" + + "text: b a b a d\n" + + " ↑ center = 'b' (index 2)\n" + + " ↑ ↑ a == a ✓ radius = 1\n" + + " ↑ ↑ b != d ✗ stop\n" + + ' → palindrome: "bab" (length 3)\n' + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n²)`**\n\n" + + "Each of the `2n − 1` centers can expand at most `n / 2` steps, giving `O(n)` work per center and " + + "`O(n²)` overall. In the best case (all unique characters) every center stops after one comparison — " + + "making the actual runtime closer to `O(n)` on random inputs.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "Only a handful of integer variables are maintained (center index, radius, best start and length). " + + "No auxiliary arrays, DP tables, or recursive call stacks are needed.", + + bestAndWorstCase: + '**Best case — `O(n)`:** The string has all unique characters (e.g., `"abcde"`). ' + + "Every center expands zero times because the immediate neighbors never match, so the loop " + + "does a single comparison per center.\n\n" + + '**Worst case — `O(n²)`:** The string consists of a single repeated character (e.g., `"aaaa"`). ' + + "The center at index `k` expands `min(k, n − 1 − k)` times, and summing across all centers gives " + + "a quadratic total. Manacher's algorithm solves this in `O(n)`, but requires `O(n)` extra space.", + + realWorldUses: [ + "**Bioinformatics:** Finding palindromic sequences in DNA strands, which are cut by restriction enzymes used in molecular cloning and gene editing.", + '**Text editors:** Powering "select palindromic word" features and regex-based palindrome matchers in IDE plugins.', + "**Natural language processing:** Identifying symmetric patterns in tokenized text for language model pre-processing steps.", + "**Cryptography:** Analyzing symmetric structures in hash outputs or encoded strings as part of collision-resistance research.", + "**Competitive programming:** Serves as a building block for harder problems such as palindrome partitioning and minimum cuts.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(1) extra space — nothing beyond a few integer variables is allocated.", + "Simple to implement correctly with no tricky edge cases beyond even/odd center handling.", + "Early-stop per center keeps average-case runtime well below the worst-case bound.", + "Handles all Unicode characters without any special casing.", + ], + limitations: [ + "O(n²) worst-case time — Manacher's algorithm achieves O(n) but is significantly more complex.", + "Returns only one longest palindrome; if multiple exist with the same length, the first found is returned.", + "Not suitable for streaming or very long strings where O(n²) is too slow — use Manacher's or suffix arrays instead.", + ], + }, + + whenToUseIt: + "Use Expand Around Center when you need the longest palindromic substring and `O(n²)` time is " + + "acceptable (strings up to ~10 000 characters are typically fast enough). It is the standard " + + "interview answer due to its simplicity and optimal space usage.\n\n" + + "Switch to **Manacher's algorithm** when the input can be very large and you need guaranteed `O(n)` " + + "time, at the cost of a more complex implementation. " + + 'Use **dynamic programming** if you also need to answer range queries ("is substring `[i, j]` a ' + + 'palindrome?") at the expense of `O(n²)` space.', +}; diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/index.ts b/src/algorithms/strings/palindrome/longest-palindromic-substring/index.ts new file mode 100644 index 00000000..481e2d39 --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/index.ts @@ -0,0 +1,48 @@ +/** Registry entry for Longest Palindromic Substring — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { longestPalindromicSubstring } from "./sources/longest-palindromic-substring.ts?fn"; +import { generateLongestPalindromicSubstringSteps } from "./step-generator"; +import type { LongestPalindromicSubstringInput } from "./step-generator"; +import { longestPalindromicSubstringEducational } from "./educational"; + +import typescriptSource from "./sources/longest-palindromic-substring.ts?raw"; +import pythonSource from "./sources/longest-palindromic-substring.py?raw"; +import javaSource from "./sources/LongestPalindromicSubstring.java?raw"; + +function executeLongestPalindromicSubstring(input: LongestPalindromicSubstringInput): string { + return longestPalindromicSubstring(input.text) as string; +} + +const longestPalindromicSubstringDefinition: AlgorithmDefinition = + { + meta: { + id: ALGORITHM_ID.LONGEST_PALINDROMIC_SUBSTRING!, + name: "Longest Palindromic Substring", + category: CATEGORY.STRINGS!, + technique: "palindrome", + description: + "Find the longest substring that reads the same forwards and backwards by expanding outward from each possible center in O(n²) time", + timeComplexity: { + best: "O(n)", + average: "O(n²)", + worst: "O(n²)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "babad" }, + }, + execute: executeLongestPalindromicSubstring, + generateSteps: generateLongestPalindromicSubstringSteps, + educational: longestPalindromicSubstringEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, + }; + +registry.register(longestPalindromicSubstringDefinition); diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/longest-palindromic-substring.test.ts b/src/algorithms/strings/palindrome/longest-palindromic-substring/longest-palindromic-substring.test.ts new file mode 100644 index 00000000..ec4df6d6 --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/longest-palindromic-substring.test.ts @@ -0,0 +1,57 @@ +/** Correctness tests for the longestPalindromicSubstring function. */ + +import { describe, it, expect } from "vitest"; +import { longestPalindromicSubstring } from "./sources/longest-palindromic-substring.ts?fn"; + +describe("longestPalindromicSubstring", () => { + it("returns 'bab' or 'aba' for 'babad'", () => { + const result = longestPalindromicSubstring("babad") as string; + expect(["bab", "aba"]).toContain(result); + }); + + it("returns 'bb' for 'cbbd'", () => { + expect(longestPalindromicSubstring("cbbd")).toBe("bb"); + }); + + it("returns the single character for a length-1 string", () => { + expect(longestPalindromicSubstring("a")).toBe("a"); + }); + + it("returns empty string for an empty input", () => { + expect(longestPalindromicSubstring("")).toBe(""); + }); + + it("returns the entire string when it is a palindrome", () => { + expect(longestPalindromicSubstring("racecar")).toBe("racecar"); + }); + + it("returns the entire string for an even-length palindrome", () => { + expect(longestPalindromicSubstring("abba")).toBe("abba"); + }); + + it("handles a string of all identical characters", () => { + expect(longestPalindromicSubstring("aaaa")).toBe("aaaa"); + }); + + it("returns first character when all characters are unique", () => { + const result = longestPalindromicSubstring("abcde") as string; + expect(result.length).toBe(1); + }); + + it("finds a palindrome embedded in the middle", () => { + expect(longestPalindromicSubstring("xyzracecarabc")).toBe("racecar"); + }); + + it("finds an even-length palindrome embedded in a longer string", () => { + expect(longestPalindromicSubstring("xyzabbadef")).toBe("abba"); + }); + + it("handles a two-character palindrome", () => { + expect(longestPalindromicSubstring("aa")).toBe("aa"); + }); + + it("handles a two-character non-palindrome by returning one character", () => { + const result = longestPalindromicSubstring("ab") as string; + expect(result.length).toBe(1); + }); +}); diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/LongestPalindromicSubstring.java b/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/LongestPalindromicSubstring.java new file mode 100644 index 00000000..e0e861a6 --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/LongestPalindromicSubstring.java @@ -0,0 +1,51 @@ +// Longest Palindromic Substring — Expand Around Center approach +// Returns the longest substring of text that is a palindrome. +// Time: O(n²), Space: O(1) + +public class LongestPalindromicSubstring { + public static String longestPalindromicSubstring(String text) { + if (text.isEmpty()) { // @step:initialize + return ""; // @step:initialize + } + + int longestStart = 0; // @step:initialize + int longestLength = 1; // @step:initialize + + for (int centerIndex = 0; centerIndex < text.length(); centerIndex++) { // @step:expandCenter + + // Odd-length palindromes: single character as center + int oddRadius = 0; // @step:expandCenter + while ( + centerIndex - oddRadius - 1 >= 0 && + centerIndex + oddRadius + 1 < text.length() && + text.charAt(centerIndex - oddRadius - 1) == text.charAt(centerIndex + oddRadius + 1) // @step:compareChars + ) { + oddRadius++; // @step:charsMatch + } + int oddLength = 2 * oddRadius + 1; // @step:updateLongest + if (oddLength > longestLength) { // @step:updateLongest + longestStart = centerIndex - oddRadius; // @step:updateLongest + longestLength = oddLength; // @step:updateLongest + } + + // Even-length palindromes: gap between centerIndex and centerIndex+1 + if (centerIndex + 1 < text.length() && text.charAt(centerIndex) == text.charAt(centerIndex + 1)) { // @step:compareChars + int evenRadius = 1; // @step:charsMatch + while ( + centerIndex - evenRadius >= 0 && + centerIndex + evenRadius + 1 < text.length() && + text.charAt(centerIndex - evenRadius) == text.charAt(centerIndex + evenRadius + 1) // @step:compareChars + ) { + evenRadius++; // @step:charsMatch + } + int evenLength = 2 * evenRadius; // @step:updateLongest + if (evenLength > longestLength) { // @step:updateLongest + longestStart = centerIndex - evenRadius + 1; // @step:updateLongest + longestLength = evenLength; // @step:updateLongest + } + } + } + + return text.substring(longestStart, longestStart + longestLength); // @step:complete + } +} diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.py b/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.py new file mode 100644 index 00000000..a1128382 --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.py @@ -0,0 +1,44 @@ +# Longest Palindromic Substring — Expand Around Center approach +# Returns the longest substring of `text` that is a palindrome. +# Time: O(n²), Space: O(1) + + +def longest_palindromic_substring(text: str) -> str: + if len(text) == 0: # @step:initialize + return "" # @step:initialize + + longest_start = 0 # @step:initialize + longest_length = 1 # @step:initialize + + for center_index in range(len(text)): # @step:expandCenter + + # Odd-length palindromes: single character as center + odd_radius = 0 # @step:expandCenter + while ( + center_index - odd_radius - 1 >= 0 + and center_index + odd_radius + 1 < len(text) + and text[center_index - odd_radius - 1] == text[center_index + odd_radius + 1] # @step:compareChars + ): + odd_radius += 1 # @step:charsMatch + + odd_length = 2 * odd_radius + 1 # @step:updateLongest + if odd_length > longest_length: # @step:updateLongest + longest_start = center_index - odd_radius # @step:updateLongest + longest_length = odd_length # @step:updateLongest + + # Even-length palindromes: gap between center_index and center_index+1 + if center_index + 1 < len(text) and text[center_index] == text[center_index + 1]: # @step:compareChars + even_radius = 1 # @step:charsMatch + while ( + center_index - even_radius >= 0 + and center_index + even_radius + 1 < len(text) + and text[center_index - even_radius] == text[center_index + even_radius + 1] # @step:compareChars + ): + even_radius += 1 # @step:charsMatch + + even_length = 2 * even_radius # @step:updateLongest + if even_length > longest_length: # @step:updateLongest + longest_start = center_index - even_radius + 1 # @step:updateLongest + longest_length = even_length # @step:updateLongest + + return text[longest_start : longest_start + longest_length] # @step:complete diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.ts b/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.ts new file mode 100644 index 00000000..c8d4dfd9 --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/sources/longest-palindromic-substring.ts @@ -0,0 +1,53 @@ +// Longest Palindromic Substring — Expand Around Center approach +// Returns the longest substring of `text` that is a palindrome. +// Time: O(n²), Space: O(1) + +export function longestPalindromicSubstring(text: string): string { + if (text.length === 0) return ""; // @step:initialize + + let longestStart = 0; // @step:initialize + let longestLength = 1; // @step:initialize + + for (let centerIndex = 0; centerIndex < text.length; centerIndex++) { + // @step:expandCenter + + // Odd-length palindromes: single character as center + let oddRadius = 0; // @step:expandCenter + while ( + centerIndex - oddRadius - 1 >= 0 && + centerIndex + oddRadius + 1 < text.length && + text[centerIndex - oddRadius - 1] === text[centerIndex + oddRadius + 1] + ) { + // @step:compareChars + oddRadius++; // @step:charsMatch + } + const oddLength = 2 * oddRadius + 1; // @step:updateLongest + if (oddLength > longestLength) { + // @step:updateLongest + longestStart = centerIndex - oddRadius; // @step:updateLongest + longestLength = oddLength; // @step:updateLongest + } + + // Even-length palindromes: gap between centerIndex and centerIndex+1 + if (centerIndex + 1 < text.length && text[centerIndex] === text[centerIndex + 1]) { + // @step:compareChars + let evenRadius = 1; // @step:charsMatch + while ( + centerIndex - evenRadius >= 0 && + centerIndex + evenRadius + 1 < text.length && + text[centerIndex - evenRadius] === text[centerIndex + evenRadius + 1] + ) { + // @step:compareChars + evenRadius++; // @step:charsMatch + } + const evenLength = 2 * evenRadius; // @step:updateLongest + if (evenLength > longestLength) { + // @step:updateLongest + longestStart = centerIndex - evenRadius + 1; // @step:updateLongest + longestLength = evenLength; // @step:updateLongest + } + } + } + + return text.slice(longestStart, longestStart + longestLength); // @step:complete +} diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.test.ts b/src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.test.ts new file mode 100644 index 00000000..d95725fc --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.test.ts @@ -0,0 +1,112 @@ +/** Step generation tests for generateLongestPalindromicSubstringSteps. */ + +import { describe, it, expect } from "vitest"; +import { generateLongestPalindromicSubstringSteps } from "./step-generator"; + +describe("generateLongestPalindromicSubstringSteps", () => { + it("produces steps for the default input", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-palindrome visual states throughout", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-palindrome"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits expand-center steps during traversal", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + const expandSteps = steps.filter((step) => step.type === "expand-center"); + expect(expandSteps.length).toBeGreaterThan(0); + }); + + it("emits compare steps during expansion", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits char-match steps when characters are equal", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "abba" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("emits char-mismatch steps when characters differ", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "cbbd" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBeGreaterThan(0); + }); + + it("emits a check-palindrome step to record a new longest", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + const updateSteps = steps.filter((step) => step.type === "check-palindrome"); + expect(updateSteps.length).toBeGreaterThan(0); + }); + + it("records a longestLength of 3 in the final state for 'babad'", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "babad" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-palindrome"); + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.longestLength).toBe(3); + } + }); + + it("records a longestLength of 2 in the final state for 'cbbd'", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "cbbd" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-palindrome"); + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.longestLength).toBe(2); + } + }); + + it("handles a single character without expand-center expansions beyond initial", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "a" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBe(0); + }); + + it("handles an empty string with just initialize and complete steps", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "" }); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + expect(steps.length).toBe(2); + }); + + it("marks isPalindrome true in final visual state", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "racecar" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.isPalindrome).toBe(true); + } + }); + + it("records longestLength of 7 for 'racecar'", () => { + const steps = generateLongestPalindromicSubstringSteps({ text: "racecar" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.longestLength).toBe(7); + } + }); +}); diff --git a/src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.ts b/src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.ts new file mode 100644 index 00000000..ca554767 --- /dev/null +++ b/src/algorithms/strings/palindrome/longest-palindromic-substring/step-generator.ts @@ -0,0 +1,209 @@ +/** Step generator for Longest Palindromic Substring — produces ExecutionStep[] using PalindromeTracker. */ + +import type { ExecutionStep } from "@/types"; +import { PalindromeTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const LONGEST_PALINDROMIC_SUBSTRING_LINE_MAP = buildLineMapFromSources( + ALGORITHM_ID.LONGEST_PALINDROMIC_SUBSTRING!, +); + +export interface LongestPalindromicSubstringInput { + text: string; +} + +export function generateLongestPalindromicSubstringSteps( + input: LongestPalindromicSubstringInput, +): ExecutionStep[] { + const { text } = input; + const tracker = new PalindromeTracker(text, LONGEST_PALINDROMIC_SUBSTRING_LINE_MAP); + + if (text.length === 0) { + tracker.initialize({ text, longestStart: 0, longestLength: 0 }); + tracker.complete({ result: "" }); + return tracker.getSteps(); + } + + let longestStart = 0; + let longestLength = 1; + + tracker.initialize({ text, longestStart, longestLength }); + + for (let centerIndex = 0; centerIndex < text.length; centerIndex++) { + // Odd-length: expand from single character center + tracker.expandCenter(centerIndex, 0, { + centerIndex, + kind: "odd", + longestStart, + longestLength, + }); + + let oddRadius = 0; + while (centerIndex - oddRadius - 1 >= 0 && centerIndex + oddRadius + 1 < text.length) { + const leftIdx = centerIndex - oddRadius - 1; + const rightIdx = centerIndex + oddRadius + 1; + + tracker.compareChars(leftIdx, rightIdx, { + centerIndex, + leftIdx, + rightIdx, + leftChar: text[leftIdx], + rightChar: text[rightIdx], + }); + + if (text[leftIdx] !== text[rightIdx]) { + tracker.charsMismatch(leftIdx, rightIdx, { + centerIndex, + leftIdx, + rightIdx, + leftChar: text[leftIdx], + rightChar: text[rightIdx], + }); + break; + } + + tracker.charsMatch(leftIdx, rightIdx, { + centerIndex, + leftIdx, + rightIdx, + leftChar: text[leftIdx], + rightChar: text[rightIdx], + }); + + oddRadius++; + + tracker.expandCenter(centerIndex, oddRadius, { + centerIndex, + kind: "odd", + currentRadius: oddRadius, + longestStart, + longestLength, + }); + } + + const oddLength = 2 * oddRadius + 1; + if (oddLength > longestLength) { + longestStart = centerIndex - oddRadius; + longestLength = oddLength; + tracker.updateLongest(longestStart, longestLength, { + centerIndex, + kind: "odd", + longestStart, + longestLength, + }); + } + + // Even-length: expand from gap between centerIndex and centerIndex+1 + if (centerIndex + 1 < text.length) { + const evenLeftIdx = centerIndex; + const evenRightIdx = centerIndex + 1; + + tracker.compareChars(evenLeftIdx, evenRightIdx, { + centerIndex, + leftIdx: evenLeftIdx, + rightIdx: evenRightIdx, + leftChar: text[evenLeftIdx], + rightChar: text[evenRightIdx], + kind: "even", + }); + + if (text[evenLeftIdx] === text[evenRightIdx]) { + tracker.charsMatch(evenLeftIdx, evenRightIdx, { + centerIndex, + leftIdx: evenLeftIdx, + rightIdx: evenRightIdx, + kind: "even", + }); + + let evenRadius = 1; + + tracker.expandCenter(centerIndex, evenRadius, { + centerIndex, + kind: "even", + currentRadius: evenRadius, + longestStart, + longestLength, + }); + + while (centerIndex - evenRadius >= 0 && centerIndex + evenRadius + 1 < text.length) { + const leftIdx = centerIndex - evenRadius; + const rightIdx = centerIndex + evenRadius + 1; + + tracker.compareChars(leftIdx, rightIdx, { + centerIndex, + leftIdx, + rightIdx, + leftChar: text[leftIdx], + rightChar: text[rightIdx], + kind: "even", + }); + + if (text[leftIdx] !== text[rightIdx]) { + tracker.charsMismatch(leftIdx, rightIdx, { + centerIndex, + leftIdx, + rightIdx, + leftChar: text[leftIdx], + rightChar: text[rightIdx], + kind: "even", + }); + break; + } + + tracker.charsMatch(leftIdx, rightIdx, { + centerIndex, + leftIdx, + rightIdx, + leftChar: text[leftIdx], + rightChar: text[rightIdx], + kind: "even", + }); + + evenRadius++; + + tracker.expandCenter(centerIndex, evenRadius, { + centerIndex, + kind: "even", + currentRadius: evenRadius, + longestStart, + longestLength, + }); + } + + const evenLength = 2 * evenRadius; + if (evenLength > longestLength) { + longestStart = centerIndex - evenRadius + 1; + longestLength = evenLength; + tracker.updateLongest(longestStart, longestLength, { + centerIndex, + kind: "even", + longestStart, + longestLength, + }); + } + } else { + tracker.charsMismatch(evenLeftIdx, evenRightIdx, { + centerIndex, + leftIdx: evenLeftIdx, + rightIdx: evenRightIdx, + kind: "even", + }); + } + } + } + + tracker.markPalindrome(longestStart, longestLength, { + longestStart, + longestLength, + result: text.slice(longestStart, longestStart + longestLength), + }); + + tracker.complete({ + longestStart, + longestLength, + result: text.slice(longestStart, longestStart + longestLength), + }); + + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/palindrome/palindrome-check/PalindromeCheckPipeline.stories.tsx b/src/algorithms/strings/palindrome/palindrome-check/PalindromeCheckPipeline.stories.tsx new file mode 100644 index 00000000..2e6d993f --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/PalindromeCheckPipeline.stories.tsx @@ -0,0 +1,54 @@ +/** + * Storybook stories for the Palindrome Check algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the PalindromeVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { PalindromeVisualState } from "@/types"; +import { generatePalindromeCheckSteps } from "./step-generator"; +import PalindromeVisualizer from "@/components/visualization/PalindromeVisualizer"; + +const steps = generatePalindromeCheckSteps({ text: "racecar" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Palindrome Check", + component: PalindromeVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — pointers placed at both ends of the string */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as PalindromeVisualState, + }, +}; + +/** Mid-execution — pointers moving inward, some pairs already matched */ +export const MidExecution: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.4)]!.visualState as PalindromeVisualState, + }, +}; + +/** Pointers converging — final pair about to be compared */ +export const Converging: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.75)]!.visualState as PalindromeVisualState, + }, +}; + +/** Final state — all characters matched, palindrome confirmed */ +export const PalindromeConfirmed: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as PalindromeVisualState, + }, +}; diff --git a/src/algorithms/strings/palindrome/palindrome-check/educational.ts b/src/algorithms/strings/palindrome/palindrome-check/educational.ts new file mode 100644 index 00000000..2e9c50cd --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/educational.ts @@ -0,0 +1,70 @@ +/** Educational content for Palindrome Check algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const palindromeCheckEducational: EducationalContent = { + overview: + "**Palindrome Check** determines whether a string reads the same forwards and backwards. " + + 'Examples include `"racecar"`, `"madam"`, and `"abba"`.\n\n' + + "The two-pointer approach compares characters at opposite ends of the string and moves inward, " + + "stopping as soon as a mismatch is found. A single-character string or empty string is always a palindrome " + + "because there are no opposing pairs to compare.", + + howItWorks: + "Two pointers, `leftIndex` and `rightIndex`, start at the first and last character of the string:\n\n" + + "1. **Compare** — check whether `text[leftIndex]` equals `text[rightIndex]`.\n" + + "2. **Mismatch** — if the characters differ, the string is not a palindrome; return `false` immediately.\n" + + "3. **Match** — if the characters are equal, advance `leftIndex` forward and `rightIndex` backward.\n" + + "4. **Converge** — when `leftIndex >= rightIndex` all pairs have matched; return `true`.\n\n" + + "```\n" + + "text: r a c e c a r\n" + + " ↑ ↑ r == r ✓\n" + + " ↑ ↑ a == a ✓\n" + + " ↑ ↑ c == c ✓\n" + + " ↑ (converged — palindrome!)\n" + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Each character is visited at most once. The two pointers together traverse at most `n / 2` pairs " + + "before either a mismatch terminates early or the pointers converge.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "Only two integer pointer variables are maintained regardless of string length. " + + "No auxiliary data structures or copies of the string are created.", + + bestAndWorstCase: + "**Best case — `O(1)`:** The first pair of characters mismatches (`text[0] != text[n-1]`), " + + "so the algorithm returns `false` after a single comparison.\n\n" + + "**Worst case — `O(n)`:** Every pair matches (or the string is a palindrome), " + + "requiring all `n / 2` comparisons to confirm the result.\n\n" + + "Because characters are never re-examined, the worst case is linear regardless of the input content.", + + realWorldUses: [ + "**Input validation:** Checking whether user-supplied tokens or identifiers are palindromes as a warm-up step before more complex string processing.", + "**DNA analysis:** Identifying palindromic sequences in genomic data, which are recognition sites for restriction enzymes.", + "**Compiler design:** Recognizing palindromic tokens in certain grammars during lexical analysis.", + "**Puzzle and game engines:** Validating player-entered words in word games that award bonus points for palindromes.", + "**Data integrity:** Detecting symmetric patterns in encoded payloads where palindromic structure signals a valid frame.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(n) time and O(1) space — optimal for this problem.", + "Early exit on first mismatch makes the average case much faster than worst case.", + "Trivially easy to understand and implement correctly.", + "Works identically for any character set (Unicode, ASCII, binary).", + ], + limitations: [ + "Checks exact character equality — does not handle case-insensitive or alphanumeric-only variants without preprocessing.", + "Not directly applicable to checking whether a number is a palindrome without converting it to a string first.", + "For streaming or very large strings, a rolling-hash approach may be preferred to avoid loading the full string into memory.", + ], + }, + + whenToUseIt: + "Use the two-pointer palindrome check whenever you need to verify whether a finite, indexable string is a palindrome " + + "in linear time with constant space. It is the canonical solution for this problem in interview settings and production code alike.\n\n" + + "If you need to check whether any *substring* is a palindrome (not just the whole string), consider Manacher's algorithm " + + "(`O(n)`) or dynamic programming (`O(n²) time, O(n²) space`) instead. " + + "For case-insensitive or alphanumeric-only palindrome checks, normalize the string first with `.toLowerCase()` and a filter pass.", +}; diff --git a/src/algorithms/strings/palindrome/palindrome-check/index.ts b/src/algorithms/strings/palindrome/palindrome-check/index.ts new file mode 100644 index 00000000..c2a0e9da --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Palindrome Check — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { palindromeCheck } from "./sources/palindrome-check.ts?fn"; +import { generatePalindromeCheckSteps } from "./step-generator"; +import type { PalindromeCheckInput } from "./step-generator"; +import { palindromeCheckEducational } from "./educational"; + +import typescriptSource from "./sources/palindrome-check.ts?raw"; +import pythonSource from "./sources/palindrome-check.py?raw"; +import javaSource from "./sources/PalindromeCheck.java?raw"; + +function executePalindromeCheck(input: PalindromeCheckInput): boolean { + return palindromeCheck(input.text) as boolean; +} + +const palindromeCheckDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.PALINDROME_CHECK!, + name: "Palindrome Check", + category: CATEGORY.STRINGS!, + technique: "palindrome", + description: + "Determine whether a string reads the same forwards and backwards using two inward-moving pointers in O(n) time", + timeComplexity: { + best: "O(1)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "racecar" }, + }, + execute: executePalindromeCheck, + generateSteps: generatePalindromeCheckSteps, + educational: palindromeCheckEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(palindromeCheckDefinition); diff --git a/src/algorithms/strings/palindrome/palindrome-check/palindrome-check.test.ts b/src/algorithms/strings/palindrome/palindrome-check/palindrome-check.test.ts new file mode 100644 index 00000000..46dbff57 --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/palindrome-check.test.ts @@ -0,0 +1,42 @@ +/** Correctness tests for the palindromeCheck function. */ + +import { describe, it, expect } from "vitest"; +import { palindromeCheck } from "./sources/palindrome-check.ts?fn"; + +describe("palindromeCheck", () => { + it("returns true for a classic odd-length palindrome", () => { + expect(palindromeCheck("racecar")).toBe(true); + }); + + it("returns false for a non-palindrome", () => { + expect(palindromeCheck("hello")).toBe(false); + }); + + it("returns true for a single character", () => { + expect(palindromeCheck("a")).toBe(true); + }); + + it("returns true for an empty string", () => { + expect(palindromeCheck("")).toBe(true); + }); + + it("returns false for a two-character non-palindrome", () => { + expect(palindromeCheck("ab")).toBe(false); + }); + + it("returns true for an odd-length symmetric string", () => { + expect(palindromeCheck("aba")).toBe(true); + }); + + it("returns true for an even-length palindrome", () => { + expect(palindromeCheck("abba")).toBe(true); + }); + + it("returns false when only the first and last chars differ", () => { + expect(palindromeCheck("abca")).toBe(false); + }); + + it("returns true for a string of repeated identical characters", () => { + expect(palindromeCheck("aaaa")).toBe(true); + }); +}); diff --git a/src/algorithms/strings/palindrome/palindrome-check/sources/PalindromeCheck.java b/src/algorithms/strings/palindrome/palindrome-check/sources/PalindromeCheck.java new file mode 100644 index 00000000..ba845d1d --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/sources/PalindromeCheck.java @@ -0,0 +1,20 @@ +// Palindrome Check — Two-pointer approach +// Returns true if the string reads the same forwards and backwards. +// Time: O(n), Space: O(1) + +public class PalindromeCheck { + public static boolean palindromeCheck(String text) { + int leftIndex = 0; // @step:initialize + int rightIndex = text.length() - 1; // @step:initialize + + while (leftIndex < rightIndex) { // @step:compare + if (text.charAt(leftIndex) != text.charAt(rightIndex)) { // @step:compare + return false; // @step:mismatch + } + leftIndex++; // @step:match + rightIndex--; // @step:match + } + + return true; // @step:complete + } +} diff --git a/src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.py b/src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.py new file mode 100644 index 00000000..f9e27c64 --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.py @@ -0,0 +1,16 @@ +# Palindrome Check — Two-pointer approach +# Returns True if the string reads the same forwards and backwards. +# Time: O(n), Space: O(1) + + +def palindrome_check(text: str) -> bool: + left_index = 0 # @step:initialize + right_index = len(text) - 1 # @step:initialize + + while left_index < right_index: # @step:compare + if text[left_index] != text[right_index]: # @step:compare + return False # @step:mismatch + left_index += 1 # @step:match + right_index -= 1 # @step:match + + return True # @step:complete diff --git a/src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.ts b/src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.ts new file mode 100644 index 00000000..db54e041 --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/sources/palindrome-check.ts @@ -0,0 +1,19 @@ +// Palindrome Check — Two-pointer approach +// Returns true if the string reads the same forwards and backwards. +// Time: O(n), Space: O(1) + +export function palindromeCheck(text: string): boolean { + let leftIndex = 0; // @step:initialize + let rightIndex = text.length - 1; // @step:initialize + + while (leftIndex < rightIndex) { + // @step:compare + if (text[leftIndex] !== text[rightIndex]) { + return false; // @step:mismatch + } + leftIndex++; // @step:match + rightIndex--; // @step:match + } + + return true; // @step:complete +} diff --git a/src/algorithms/strings/palindrome/palindrome-check/step-generator.test.ts b/src/algorithms/strings/palindrome/palindrome-check/step-generator.test.ts new file mode 100644 index 00000000..192849ff --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/step-generator.test.ts @@ -0,0 +1,83 @@ +/** Step generation tests for generatePalindromeCheckSteps. */ + +import { describe, it, expect } from "vitest"; +import { generatePalindromeCheckSteps } from "./step-generator"; + +describe("generatePalindromeCheckSteps", () => { + it("produces steps for the default input", () => { + const steps = generatePalindromeCheckSteps({ text: "racecar" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generatePalindromeCheckSteps({ text: "racecar" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generatePalindromeCheckSteps({ text: "racecar" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-palindrome visual states throughout", () => { + const steps = generatePalindromeCheckSteps({ text: "racecar" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-palindrome"); + } + }); + + it("has incrementing step indices", () => { + const steps = generatePalindromeCheckSteps({ text: "racecar" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits compare steps during pointer traversal", () => { + const steps = generatePalindromeCheckSteps({ text: "racecar" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits char-match steps for a palindrome", () => { + const steps = generatePalindromeCheckSteps({ text: "abba" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("emits a char-mismatch step for a non-palindrome", () => { + const steps = generatePalindromeCheckSteps({ text: "hello" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBeGreaterThan(0); + }); + + it("marks isPalindrome true in final visual state for a palindrome", () => { + const steps = generatePalindromeCheckSteps({ text: "racecar" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-palindrome"); + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.isPalindrome).toBe(true); + } + }); + + it("marks isPalindrome false in final visual state for a non-palindrome", () => { + const steps = generatePalindromeCheckSteps({ text: "hello" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-palindrome"); + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.isPalindrome).toBe(false); + } + }); + + it("handles a single-character string without compare steps", () => { + const steps = generatePalindromeCheckSteps({ text: "a" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBe(0); + }); + + it("handles an empty string without compare steps", () => { + const steps = generatePalindromeCheckSteps({ text: "" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBe(0); + }); +}); diff --git a/src/algorithms/strings/palindrome/palindrome-check/step-generator.ts b/src/algorithms/strings/palindrome/palindrome-check/step-generator.ts new file mode 100644 index 00000000..ad475dba --- /dev/null +++ b/src/algorithms/strings/palindrome/palindrome-check/step-generator.ts @@ -0,0 +1,62 @@ +/** Step generator for Palindrome Check — produces ExecutionStep[] using PalindromeTracker. */ + +import type { ExecutionStep } from "@/types"; +import { PalindromeTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const PALINDROME_CHECK_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.PALINDROME_CHECK!); + +export interface PalindromeCheckInput { + text: string; +} + +export function generatePalindromeCheckSteps(input: PalindromeCheckInput): ExecutionStep[] { + const { text } = input; + const tracker = new PalindromeTracker(text, PALINDROME_CHECK_LINE_MAP); + + tracker.initialize({ text, leftIndex: 0, rightIndex: text.length - 1 }); + + let leftIndex = 0; + let rightIndex = text.length - 1; + + tracker.setPointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + + while (leftIndex < rightIndex) { + tracker.compareChars(leftIndex, rightIndex, { + leftIndex, + rightIndex, + leftChar: text[leftIndex], + rightChar: text[rightIndex], + }); + + if (text[leftIndex] !== text[rightIndex]) { + tracker.charsMismatch(leftIndex, rightIndex, { + leftIndex, + rightIndex, + leftChar: text[leftIndex], + rightChar: text[rightIndex], + }); + tracker.complete({ isPalindrome: false }); + return tracker.getSteps(); + } + + tracker.charsMatch(leftIndex, rightIndex, { + leftIndex, + rightIndex, + leftChar: text[leftIndex], + rightChar: text[rightIndex], + }); + + leftIndex++; + rightIndex--; + + if (leftIndex < rightIndex) { + tracker.setPointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + } + } + + tracker.markPalindrome(0, text.length, { isPalindrome: true }); + tracker.complete({ isPalindrome: true }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/palindrome/valid-palindrome/ValidPalindromePipeline.stories.tsx b/src/algorithms/strings/palindrome/valid-palindrome/ValidPalindromePipeline.stories.tsx new file mode 100644 index 00000000..364283d3 --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/ValidPalindromePipeline.stories.tsx @@ -0,0 +1,54 @@ +/** + * Storybook stories for the Valid Palindrome algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the PalindromeVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { PalindromeVisualState } from "@/types"; +import { generateValidPalindromeSteps } from "./step-generator"; +import PalindromeVisualizer from "@/components/visualization/PalindromeVisualizer"; + +const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Valid Palindrome", + component: PalindromeVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — pointers placed at both ends of the string before any skipping */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as PalindromeVisualState, + }, +}; + +/** Skipping — left or right pointer advancing past a non-alphanumeric character */ +export const SkippingNonAlphanumeric: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.2)]!.visualState as PalindromeVisualState, + }, +}; + +/** Mid-execution — pointers comparing alphanumeric characters toward the center */ +export const MidExecution: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as PalindromeVisualState, + }, +}; + +/** Final state — all alphanumeric characters matched, palindrome confirmed */ +export const PalindromeConfirmed: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as PalindromeVisualState, + }, +}; diff --git a/src/algorithms/strings/palindrome/valid-palindrome/educational.ts b/src/algorithms/strings/palindrome/valid-palindrome/educational.ts new file mode 100644 index 00000000..310bbe56 --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/educational.ts @@ -0,0 +1,70 @@ +/** Educational content for Valid Palindrome algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const validPalindromeEducational: EducationalContent = { + overview: + "**Valid Palindrome** determines whether a string is a palindrome when only alphanumeric " + + "characters are considered and case is ignored. Punctuation, spaces, and symbols are skipped entirely.\n\n" + + 'Examples: `"A man, a plan, a canal: Panama"` → `true`, `"race a car"` → `false`, `" "` → `true` (no alphanumeric chars).\n\n' + + "This is a common interview variant that tests whether a candidate can cleanly handle filtering " + + "while maintaining the O(1) space property of the two-pointer approach.", + + howItWorks: + "Two pointers, `leftIndex` and `rightIndex`, start at the first and last character of the string:\n\n" + + "1. **Skip** — advance `leftIndex` forward past any non-alphanumeric character.\n" + + "2. **Skip** — advance `rightIndex` backward past any non-alphanumeric character.\n" + + "3. **Compare** — check whether `text[leftIndex]` equals `text[rightIndex]` (case-insensitively).\n" + + "4. **Mismatch** — if the characters differ, return `false` immediately.\n" + + "5. **Match** — if the characters are equal, advance both pointers inward.\n" + + "6. **Converge** — when `leftIndex >= rightIndex` all relevant pairs have matched; return `true`.\n\n" + + "```\n" + + 'text: A " " m a n , " " a " " p l a n , " " a " " c a n a l : " " P a n a m a\n' + + " ↑ ↑\n" + + " A (alphanumeric) a (alphanumeric)\n" + + " A.toLower() == a.toLower() ✓ → advance both inward, skipping non-alphanumeric chars\n" + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Each character is visited at most once by each pointer. Skipping non-alphanumeric characters does not " + + "add additional passes — the two pointers together traverse at most `n` characters total.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "Only two integer pointer variables are maintained regardless of input length. " + + "No filtered copy of the string is created; the original string is read in-place.", + + bestAndWorstCase: + "**Best case — `O(1)`:** The first pair of alphanumeric characters mismatches after a constant " + + "number of skips, so the algorithm returns `false` almost immediately.\n\n" + + "**Worst case — `O(n)`:** Every character is either alphanumeric and matching, or must be skipped. " + + 'The full string is traversed once — e.g., `"A man, a plan, a canal: Panama"` requires visiting every character.\n\n' + + "Because each character is visited at most once in total across both pointers, the worst case remains linear.", + + realWorldUses: [ + "**Form validation:** Checking whether a user-entered identifier or token is palindromic while ignoring formatting characters such as dashes, spaces, or parentheses.", + "**Search engines:** Normalizing and comparing query strings that may include punctuation before applying palindrome-based heuristics.", + "**Bioinformatics:** Identifying palindromic nucleotide sequences in DNA while ignoring non-coding spacer characters.", + "**Competitive programming:** A canonical preprocessing step for many string problems that require ignoring non-relevant characters.", + "**Natural language processing:** Detecting palindromic phrases in text where punctuation and whitespace are stripped before comparison.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(n) time and O(1) space — optimal for this problem, no auxiliary string allocation needed.", + "Early exit on first alphanumeric mismatch keeps the average case much faster than worst case.", + "Handles edge cases cleanly: empty strings and strings of only non-alphanumeric characters return `true`.", + "Case-insensitive comparison is built in, making it suitable for natural-language inputs.", + ], + limitations: [ + "Only considers alphanumeric characters — if the definition of 'valid' changes (e.g., including spaces), the skip logic must be updated.", + "Does not identify which specific characters caused a mismatch beyond the first pair found.", + "For Unicode-aware alphanumeric filtering, the regex or character-class check may need extending beyond ASCII.", + ], + }, + + whenToUseIt: + "Use Valid Palindrome whenever you need to check palindromes in real-world text where punctuation, " + + "spaces, and case differences should be ignored. It is the standard solution for LeetCode 125 and similar interview problems.\n\n" + + "If you need case-sensitive or character-exact palindrome checking, use the simpler Palindrome Check instead. " + + "If you need to find the *longest* palindromic substring rather than check the whole string, use Manacher's algorithm or expand-around-center.", +}; diff --git a/src/algorithms/strings/palindrome/valid-palindrome/index.ts b/src/algorithms/strings/palindrome/valid-palindrome/index.ts new file mode 100644 index 00000000..1eb1414f --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Valid Palindrome — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { validPalindrome } from "./sources/valid-palindrome.ts?fn"; +import { generateValidPalindromeSteps } from "./step-generator"; +import type { ValidPalindromeInput } from "./step-generator"; +import { validPalindromeEducational } from "./educational"; + +import typescriptSource from "./sources/valid-palindrome.ts?raw"; +import pythonSource from "./sources/valid-palindrome.py?raw"; +import javaSource from "./sources/ValidPalindrome.java?raw"; + +function executeValidPalindrome(input: ValidPalindromeInput): boolean { + return validPalindrome(input.text) as boolean; +} + +const validPalindromeDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.VALID_PALINDROME!, + name: "Valid Palindrome", + category: CATEGORY.STRINGS!, + technique: "palindrome", + description: + "Determine whether a string is a palindrome when only alphanumeric characters are considered and case is ignored, using two inward-moving pointers that skip non-alphanumeric characters", + timeComplexity: { + best: "O(1)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "A man, a plan, a canal: Panama" }, + }, + execute: executeValidPalindrome, + generateSteps: generateValidPalindromeSteps, + educational: validPalindromeEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(validPalindromeDefinition); diff --git a/src/algorithms/strings/palindrome/valid-palindrome/sources/ValidPalindrome.java b/src/algorithms/strings/palindrome/valid-palindrome/sources/ValidPalindrome.java new file mode 100644 index 00000000..e80fdf94 --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/sources/ValidPalindrome.java @@ -0,0 +1,27 @@ +// Valid Palindrome — Two-pointer approach ignoring non-alphanumeric characters +// Returns true if the string is a palindrome when only alphanumeric characters are considered. +// Time: O(n), Space: O(1) + +public class ValidPalindrome { + public static boolean validPalindrome(String text) { + int leftIndex = 0; // @step:initialize + int rightIndex = text.length() - 1; // @step:initialize + + while (leftIndex < rightIndex) { + while (leftIndex < rightIndex && !Character.isLetterOrDigit(text.charAt(leftIndex))) { + leftIndex++; // @step:skipNonAlphanumeric + } + while (leftIndex < rightIndex && !Character.isLetterOrDigit(text.charAt(rightIndex))) { + rightIndex--; // @step:skipNonAlphanumeric + } + + if (Character.toLowerCase(text.charAt(leftIndex)) != Character.toLowerCase(text.charAt(rightIndex))) { // @step:compare + return false; // @step:mismatch + } + leftIndex++; // @step:match + rightIndex--; // @step:match + } + + return true; // @step:complete + } +} diff --git a/src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.py b/src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.py new file mode 100644 index 00000000..53404fa8 --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.py @@ -0,0 +1,21 @@ +# Valid Palindrome — Two-pointer approach ignoring non-alphanumeric characters +# Returns True if the string is a palindrome when only alphanumeric characters are considered. +# Time: O(n), Space: O(1) + + +def valid_palindrome(text: str) -> bool: + left_index = 0 # @step:initialize + right_index = len(text) - 1 # @step:initialize + + while left_index < right_index: + while left_index < right_index and not text[left_index].isalnum(): + left_index += 1 # @step:skipNonAlphanumeric + while left_index < right_index and not text[right_index].isalnum(): + right_index -= 1 # @step:skipNonAlphanumeric + + if text[left_index].lower() != text[right_index].lower(): # @step:compare + return False # @step:mismatch + left_index += 1 # @step:match + right_index -= 1 # @step:match + + return True # @step:complete diff --git a/src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.ts b/src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.ts new file mode 100644 index 00000000..50000efb --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/sources/valid-palindrome.ts @@ -0,0 +1,30 @@ +// Valid Palindrome — Two-pointer approach ignoring non-alphanumeric characters +// Returns true if the string is a palindrome when only alphanumeric characters are considered. +// Time: O(n), Space: O(1) + +export function validPalindrome(text: string): boolean { + let leftIndex = 0; // @step:initialize + let rightIndex = text.length - 1; // @step:initialize + + while (leftIndex < rightIndex) { + while (leftIndex < rightIndex && !isAlphanumeric(text[leftIndex] ?? "")) { + leftIndex++; // @step:skipNonAlphanumeric + } + while (leftIndex < rightIndex && !isAlphanumeric(text[rightIndex] ?? "")) { + rightIndex--; // @step:skipNonAlphanumeric + } + + // @step:compare + if ((text[leftIndex] ?? "").toLowerCase() !== (text[rightIndex] ?? "").toLowerCase()) { + return false; // @step:mismatch + } + leftIndex++; // @step:match + rightIndex--; // @step:match + } + + return true; // @step:complete +} + +function isAlphanumeric(char: string): boolean { + return /[a-zA-Z0-9]/.test(char); +} diff --git a/src/algorithms/strings/palindrome/valid-palindrome/step-generator.test.ts b/src/algorithms/strings/palindrome/valid-palindrome/step-generator.test.ts new file mode 100644 index 00000000..af5f1a5d --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/step-generator.test.ts @@ -0,0 +1,89 @@ +/** Step generation tests for generateValidPalindromeSteps. */ + +import { describe, it, expect } from "vitest"; +import { generateValidPalindromeSteps } from "./step-generator"; + +describe("generateValidPalindromeSteps", () => { + it("produces steps for the default input", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-palindrome visual states throughout", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-palindrome"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits skip-char steps when the input contains non-alphanumeric characters", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + const skipSteps = steps.filter((step) => step.type === "skip-char"); + expect(skipSteps.length).toBeGreaterThan(0); + }); + + it("emits compare steps during pointer traversal", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBeGreaterThan(0); + }); + + it("emits char-match steps for a valid palindrome", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("emits a char-mismatch step for a non-palindrome", () => { + const steps = generateValidPalindromeSteps({ text: "race a car" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBeGreaterThan(0); + }); + + it("marks isPalindrome true in final visual state for a valid palindrome", () => { + const steps = generateValidPalindromeSteps({ text: "A man, a plan, a canal: Panama" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-palindrome"); + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.isPalindrome).toBe(true); + } + }); + + it("marks isPalindrome false in final visual state for a non-palindrome", () => { + const steps = generateValidPalindromeSteps({ text: "race a car" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-palindrome"); + if (completeStep.visualState.kind === "string-palindrome") { + expect(completeStep.visualState.isPalindrome).toBe(false); + } + }); + + it("returns true for a string of only spaces — no compare steps", () => { + const steps = generateValidPalindromeSteps({ text: " " }); + const compareSteps = steps.filter((step) => step.type === "compare"); + expect(compareSteps.length).toBe(0); + }); + + it("does not emit skip-char steps for an already-clean alphanumeric string", () => { + const steps = generateValidPalindromeSteps({ text: "racecar" }); + const skipSteps = steps.filter((step) => step.type === "skip-char"); + expect(skipSteps.length).toBe(0); + }); +}); diff --git a/src/algorithms/strings/palindrome/valid-palindrome/step-generator.ts b/src/algorithms/strings/palindrome/valid-palindrome/step-generator.ts new file mode 100644 index 00000000..ad7679fa --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/step-generator.ts @@ -0,0 +1,93 @@ +/** Step generator for Valid Palindrome — produces ExecutionStep[] using PalindromeTracker. */ + +import type { ExecutionStep } from "@/types"; +import { PalindromeTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const VALID_PALINDROME_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.VALID_PALINDROME!); + +export interface ValidPalindromeInput { + text: string; +} + +function isAlphanumeric(char: string): boolean { + return /[a-zA-Z0-9]/.test(char); +} + +export function generateValidPalindromeSteps(input: ValidPalindromeInput): ExecutionStep[] { + const { text } = input; + const tracker = new PalindromeTracker(text, VALID_PALINDROME_LINE_MAP); + + tracker.initialize({ text, leftIndex: 0, rightIndex: text.length - 1 }); + + let leftIndex = 0; + let rightIndex = text.length - 1; + + tracker.setPointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + + while (leftIndex < rightIndex) { + // Skip non-alphanumeric from the left + while (leftIndex < rightIndex && !isAlphanumeric(text[leftIndex] ?? "")) { + tracker.skipNonAlphanumeric(leftIndex, "left", { + leftIndex, + rightIndex, + skippedChar: text[leftIndex], + }); + leftIndex++; + tracker.setPointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + } + + // Skip non-alphanumeric from the right + while (leftIndex < rightIndex && !isAlphanumeric(text[rightIndex] ?? "")) { + tracker.skipNonAlphanumeric(rightIndex, "right", { + leftIndex, + rightIndex, + skippedChar: text[rightIndex], + }); + rightIndex--; + tracker.setPointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + } + + if (leftIndex >= rightIndex) break; + + tracker.compareChars(leftIndex, rightIndex, { + leftIndex, + rightIndex, + leftChar: text[leftIndex], + rightChar: text[rightIndex], + }); + + const leftChar = (text[leftIndex] ?? "").toLowerCase(); + const rightChar = (text[rightIndex] ?? "").toLowerCase(); + + if (leftChar !== rightChar) { + tracker.charsMismatch(leftIndex, rightIndex, { + leftIndex, + rightIndex, + leftChar: text[leftIndex], + rightChar: text[rightIndex], + }); + tracker.complete({ isPalindrome: false }); + return tracker.getSteps(); + } + + tracker.charsMatch(leftIndex, rightIndex, { + leftIndex, + rightIndex, + leftChar: text[leftIndex], + rightChar: text[rightIndex], + }); + + leftIndex++; + rightIndex--; + + if (leftIndex < rightIndex) { + tracker.setPointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + } + } + + tracker.markPalindrome(0, text.length, { isPalindrome: true }); + tracker.complete({ isPalindrome: true }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/palindrome/valid-palindrome/valid-palindrome.test.ts b/src/algorithms/strings/palindrome/valid-palindrome/valid-palindrome.test.ts new file mode 100644 index 00000000..29f8718a --- /dev/null +++ b/src/algorithms/strings/palindrome/valid-palindrome/valid-palindrome.test.ts @@ -0,0 +1,50 @@ +/** Correctness tests for the validPalindrome function. */ + +import { describe, it, expect } from "vitest"; +import { validPalindrome } from "./sources/valid-palindrome.ts?fn"; + +describe("validPalindrome", () => { + it("returns true for the classic mixed-case phrase with punctuation", () => { + expect(validPalindrome("A man, a plan, a canal: Panama")).toBe(true); + }); + + it("returns false for a non-palindrome with spaces", () => { + expect(validPalindrome("race a car")).toBe(false); + }); + + it("returns true for a string of only spaces", () => { + expect(validPalindrome(" ")).toBe(true); + }); + + it("returns true for a single alphanumeric character followed by punctuation", () => { + expect(validPalindrome("a.")).toBe(true); + }); + + it("returns true for an empty string", () => { + expect(validPalindrome("")).toBe(true); + }); + + it("returns true for a simple lowercase palindrome", () => { + expect(validPalindrome("racecar")).toBe(true); + }); + + it("returns false for a simple lowercase non-palindrome", () => { + expect(validPalindrome("hello")).toBe(false); + }); + + it("returns true when case differs but characters are the same", () => { + expect(validPalindrome("AbBa")).toBe(true); + }); + + it("returns true for a string of only punctuation", () => { + expect(validPalindrome(".,!?")).toBe(true); + }); + + it("returns true for alphanumeric palindrome with surrounding punctuation", () => { + expect(validPalindrome("...racecar...")).toBe(true); + }); + + it("returns false when alphanumeric mismatch occurs in the middle", () => { + expect(validPalindrome("ab2a")).toBe(false); + }); +}); diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/BoyerMooreSearchPipeline.stories.tsx b/src/algorithms/strings/pattern-matching/boyer-moore-search/BoyerMooreSearchPipeline.stories.tsx new file mode 100644 index 00000000..9f5866a5 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/BoyerMooreSearchPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Boyer-Moore Search algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the StringVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { StringVisualState } from "@/types"; +import { generateBoyerMooreSearchSteps } from "./step-generator"; +import StringVisualizer from "@/components/visualization/StringVisualizer"; + +const steps = generateBoyerMooreSearchSteps({ + text: "ABAAABCD", + pattern: "ABC", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Boyer-Moore Search", + component: StringVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — bad character table not yet built, pattern at offset 0 */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as StringVisualState, + }, +}; + +/** Table building — bad character table partially filled */ +export const BadCharTableBuilding: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.25)]!.visualState as StringVisualState, + }, +}; + +/** Search phase — pattern aligned partway through the text after a shift */ +export const SearchPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.6)]!.visualState as StringVisualState, + }, +}; + +/** Final state — pattern found, matched characters highlighted */ +export const PatternFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as StringVisualState, + }, +}; diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/boyer-moore-search.test.ts b/src/algorithms/strings/pattern-matching/boyer-moore-search/boyer-moore-search.test.ts new file mode 100644 index 00000000..3bf75845 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/boyer-moore-search.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { boyerMooreSearch } from "./sources/boyer-moore-search.ts?fn"; + +describe("boyerMooreSearch", () => { + it("finds the pattern at the start of the text", () => { + expect(boyerMooreSearch("ABCDEF", "ABC")).toBe(0); + }); + + it("finds the pattern in the middle of the text", () => { + expect(boyerMooreSearch("ABAAABCD", "ABC")).toBe(4); + }); + + it("finds the pattern at the end of the text", () => { + expect(boyerMooreSearch("XYZABC", "ABC")).toBe(3); + }); + + it("returns -1 when the pattern is not present", () => { + expect(boyerMooreSearch("ABCDEFG", "XYZ")).toBe(-1); + }); + + it("handles a single-character pattern that exists", () => { + expect(boyerMooreSearch("HELLO", "L")).toBe(2); + }); + + it("handles a single-character pattern that does not exist", () => { + expect(boyerMooreSearch("HELLO", "Z")).toBe(-1); + }); + + it("returns 0 for an empty pattern", () => { + expect(boyerMooreSearch("HELLO", "")).toBe(0); + }); + + it("handles text equal to the pattern", () => { + expect(boyerMooreSearch("ABCD", "ABCD")).toBe(0); + }); + + it("returns -1 when pattern is longer than text", () => { + expect(boyerMooreSearch("AB", "ABCD")).toBe(-1); + }); + + it("handles repeated characters with bad character skipping", () => { + expect(boyerMooreSearch("AAAAABCD", "ABCD")).toBe(4); + }); + + it("finds a pattern that requires multiple shifts", () => { + expect(boyerMooreSearch("GCATCGCAGAGAGTATACAGTACG", "GCAGAGAG")).toBe(5); + }); + + it("handles patterns with no repeated characters", () => { + expect(boyerMooreSearch("ABCDEFGHIJK", "DEF")).toBe(3); + }); +}); diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/educational.ts b/src/algorithms/strings/pattern-matching/boyer-moore-search/educational.ts new file mode 100644 index 00000000..2a40140e --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/educational.ts @@ -0,0 +1,70 @@ +import type { EducationalContent } from "@/types"; + +export const boyerMooreSearchEducational: EducationalContent = { + overview: + "**Boyer-Moore Pattern Matching** finds the first occurrence of a pattern string inside a text string. " + + "It is often the fastest practical string-search algorithm in real-world settings because it can skip large chunks of text without examining every character.\n\n" + + "This implementation uses the **bad character heuristic**: when a mismatch occurs, the algorithm looks up the mismatched text character in a pre-built table and shifts the pattern far enough right so the nearest occurrence of that character in the pattern aligns with it. " + + "On typical English text with long patterns, this allows the pattern to advance by multiple positions per comparison, giving **sub-linear** average performance.", + + howItWorks: + "Boyer-Moore runs in two phases:\n\n" + + "**Phase 1 — Build the bad character table** (O(m)):\n\n" + + "Scan the pattern left-to-right and record the **rightmost position** of each character. " + + "If a character never appears in the pattern, its table entry is `-1`.\n\n" + + "```\n" + + "Pattern: A B C\n" + + "Index: 0 1 2\n" + + "badChar: A→0, B→1, C→2 (all others → -1)\n" + + "```\n\n" + + "**Phase 2 — Search** (right-to-left character comparisons):\n\n" + + "1. Align the pattern at the current offset in the text.\n" + + "2. Compare pattern characters **right-to-left** against the text.\n" + + "3. **Match** — the character matches; move one position left in the pattern.\n" + + "4. **Full match** — all pattern characters matched; pattern found.\n" + + "5. **Mismatch** — look up the mismatched text character in the bad character table. " + + "Shift the pattern right by `max(1, patternIdx - badChar[mismatchChar])`.\n\n" + + "The shift formula aligns the rightmost occurrence of the mismatched character in the pattern with the mismatched text character, or advances past it entirely if the character is not in the pattern.", + + timeAndSpaceComplexity: + "**Time Complexity:**\n\n" + + "- Best case: `O(n/m)` — on large alphabets with long patterns, the algorithm frequently skips `m` characters at a time, reading only `n/m` text characters in total.\n" + + "- Average case: `O(n)` — typical inputs see significant skipping; each text character is examined once on average.\n" + + "- Worst case: `O(nm)` — on highly repetitive text and pattern (e.g., `text = 'AAAA...A'`, `pattern = 'AA'`) the bad character heuristic provides no skip and every position is checked character by character. (The full Boyer-Moore with good suffix rule reduces this to `O(n)`.)\n\n" + + "**Space Complexity: `O(σ)`**\n\n" + + "Only the bad character table of size σ (distinct pattern characters) is allocated — O(m) in the worst case.", + + bestAndWorstCase: + "**Best case** — large alphabet, long pattern, no repeated characters: `O(n/m)`. " + + "On each alignment attempt, the very first (rightmost) comparison mismatches, and the bad character table shifts the pattern by nearly its full length. " + + "Only `n/m` alignments are needed, each costing `O(1)` comparisons.\n\n" + + "**Worst case** — small alphabet with highly repetitive input (e.g., `text = 'AAAA...A'`, `pattern = 'AAAB'`): `O(nm)`. " + + "The bad character shift is always 1 because the mismatched character appears everywhere in the pattern, so every text position is compared against every pattern character — identical to the naïve algorithm. " + + "Adding the good suffix rule (omitted here for clarity) restores an `O(n)` worst case.", + + realWorldUses: [ + "**grep / ripgrep:** Boyer-Moore (often Boyer-Moore-Horspool, a simplified variant) is the default engine for single-pattern searches in text files.", + "**Text editors (Find & Replace):** Fast jump-ahead behaviour makes Boyer-Moore feel instant on megabyte documents.", + "**Antivirus signature scanning:** Skipping large portions of binary blobs dramatically reduces scan time for long virus signatures.", + "**Network intrusion detection:** Deep packet inspection engines use Boyer-Moore variants to find malicious payloads in high-throughput streams.", + "**Database engines:** Full-text search within BLOB columns uses Boyer-Moore to avoid full sequential scans.", + ], + + strengthsAndLimitations: { + strengths: [ + "Sub-linear average case — often the fastest practical algorithm for single-pattern search on natural language or binary data.", + "No extra memory proportional to text length — only O(σ) for the bad character table.", + "Right-to-left comparison catches mismatches early with long patterns, maximising skip distance.", + ], + limitations: [ + "Worst-case O(nm) with bad character rule alone — the full algorithm (good suffix + bad character) is more complex to implement.", + "Less effective on small alphabets (e.g., DNA: A/C/G/T) where every character appears frequently in the pattern.", + "O(m) preprocessing may not be worthwhile for a single search of a very short pattern.", + ], + }, + + whenToUseIt: + "Choose Boyer-Moore (or Boyer-Moore-Horspool) when you need fast single-pattern search on long text with a large alphabet — this is the algorithm behind most production `grep`-style tools. " + + "Prefer KMP or Aho-Corasick when the alphabet is tiny (DNA), when multiple patterns must be found simultaneously, or when a strict `O(n)` worst-case guarantee is required (e.g., adversarial inputs in security contexts). " + + "For most interactive search-in-file tasks, Boyer-Moore's sub-linear average performance makes it the default choice.", +}; diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/index.ts b/src/algorithms/strings/pattern-matching/boyer-moore-search/index.ts new file mode 100644 index 00000000..8228f24f --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/index.ts @@ -0,0 +1,45 @@ +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { boyerMooreSearch } from "./sources/boyer-moore-search.ts?fn"; +import { generateBoyerMooreSearchSteps } from "./step-generator"; +import type { BoyerMooreSearchInput } from "./step-generator"; +import { boyerMooreSearchEducational } from "./educational"; + +import typescriptSource from "./sources/boyer-moore-search.ts?raw"; +import pythonSource from "./sources/boyer-moore-search.py?raw"; +import javaSource from "./sources/BoyerMooreSearch.java?raw"; + +function executeBoyerMooreSearch(input: BoyerMooreSearchInput): number { + return boyerMooreSearch(input.text, input.pattern) as number; +} + +const boyerMooreSearchDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.BOYER_MOORE_SEARCH!, + name: "Boyer-Moore Search", + category: CATEGORY.STRINGS!, + technique: "pattern-matching", + description: + "Find the first occurrence of a pattern in text using the bad character heuristic to skip large sections of text, achieving sub-linear performance on typical inputs", + timeComplexity: { + best: "O(n/m)", + average: "O(n)", + worst: "O(nm)", + }, + spaceComplexity: "O(σ)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "ABAAABCD", pattern: "ABC" }, + }, + execute: executeBoyerMooreSearch, + generateSteps: generateBoyerMooreSearchSteps, + educational: boyerMooreSearchEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(boyerMooreSearchDefinition); diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/BoyerMooreSearch.java b/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/BoyerMooreSearch.java new file mode 100644 index 00000000..d91774c2 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/BoyerMooreSearch.java @@ -0,0 +1,52 @@ +// Boyer-Moore Search (Bad Character Rule) +// Returns the index of the first occurrence of pattern in text, or -1 if not found. +// Compares pattern right-to-left; on mismatch, shifts using the bad character table. +// Time: best O(n/m), average O(n), worst O(nm) +// Space: O(sigma) where sigma = alphabet size (number of distinct characters in pattern) + +import java.util.HashMap; +import java.util.Map; + +public class BoyerMooreSearch { + + public static int boyerMooreSearch(String text, String pattern) { + if (pattern.isEmpty()) return 0; // @step:initialize + Map badCharTable = buildBadCharTable(pattern); // @step:initialize + + int patternLen = pattern.length(); // @step:initialize + int textLen = text.length(); // @step:initialize + + int alignmentOffset = 0; // @step:initialize + + while (alignmentOffset <= textLen - patternLen) { // @step:visit + int patternIdx = patternLen - 1; // @step:visit + + while (patternIdx >= 0 && pattern.charAt(patternIdx) == text.charAt(alignmentOffset + patternIdx)) { + patternIdx--; // @step:char-match + } + + if (patternIdx < 0) { + // Full pattern matched + return alignmentOffset; // @step:char-match + } + + // Mismatch — compute shift using bad character table + char mismatchChar = text.charAt(alignmentOffset + patternIdx); // @step:char-mismatch + int badCharShift = badCharTable.getOrDefault(mismatchChar, -1); // @step:char-mismatch + int shiftAmount = Math.max(1, patternIdx - badCharShift); // @step:char-mismatch + alignmentOffset += shiftAmount; // @step:shift-pattern + } + + return -1; // @step:complete + } + + private static Map buildBadCharTable(String pattern) { + Map table = new HashMap<>(); // @step:build-bad-char + + for (int charIdx = 0; charIdx < pattern.length(); charIdx++) { + table.put(pattern.charAt(charIdx), charIdx); // @step:build-bad-char + } + + return table; // @step:build-bad-char + } +} diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.py b/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.py new file mode 100644 index 00000000..1272b34f --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.py @@ -0,0 +1,43 @@ +# Boyer-Moore Search (Bad Character Rule) +# Returns the index of the first occurrence of pattern in text, or -1 if not found. +# Compares pattern right-to-left; on mismatch, shifts using the bad character table. +# Time: best O(n/m), average O(n), worst O(nm) +# Space: O(sigma) where sigma = alphabet size (number of distinct characters in pattern) + + +def boyer_moore_search(text: str, pattern: str) -> int: + if len(pattern) == 0: # @step:initialize + return 0 + bad_char_table = build_bad_char_table(pattern) # @step:initialize + + pattern_len = len(pattern) # @step:initialize + text_len = len(text) # @step:initialize + + alignment_offset = 0 # @step:initialize + + while alignment_offset <= text_len - pattern_len: # @step:visit + pattern_idx = pattern_len - 1 # @step:visit + + while pattern_idx >= 0 and pattern[pattern_idx] == text[alignment_offset + pattern_idx]: + pattern_idx -= 1 # @step:char-match + + if pattern_idx < 0: + # Full pattern matched + return alignment_offset # @step:char-match + + # Mismatch — compute shift using bad character table + mismatch_char = text[alignment_offset + pattern_idx] # @step:char-mismatch + bad_char_shift = bad_char_table.get(mismatch_char, -1) # @step:char-mismatch + shift_amount = max(1, pattern_idx - bad_char_shift) # @step:char-mismatch + alignment_offset += shift_amount # @step:shift-pattern + + return -1 # @step:complete + + +def build_bad_char_table(pattern: str) -> dict[str, int]: + table: dict[str, int] = {} # @step:build-bad-char + + for char_idx in range(len(pattern)): + table[pattern[char_idx]] = char_idx # @step:build-bad-char + + return table # @step:build-bad-char diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.ts b/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.ts new file mode 100644 index 00000000..34f208df --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/sources/boyer-moore-search.ts @@ -0,0 +1,47 @@ +// Boyer-Moore Search (Bad Character Rule) +// Returns the index of the first occurrence of pattern in text, or -1 if not found. +// Compares pattern right-to-left; on mismatch, shifts using the bad character table. +// Time: best O(n/m), average O(n), worst O(nm) +// Space: O(σ) where σ = alphabet size (number of distinct characters in pattern) + +function boyerMooreSearch(text: string, pattern: string): number { + if (pattern.length === 0) return 0; // @step:initialize + const badCharTable = buildBadCharTable(pattern); // @step:initialize + + const patternLen = pattern.length; // @step:initialize + const textLen = text.length; // @step:initialize + + let alignmentOffset = 0; // @step:initialize + + while (alignmentOffset <= textLen - patternLen) { + // @step:visit + let patternIdx = patternLen - 1; // @step:visit + + while (patternIdx >= 0 && pattern[patternIdx] === text[alignmentOffset + patternIdx]) { + patternIdx--; // @step:char-match + } + + if (patternIdx < 0) { + // Full pattern matched + return alignmentOffset; // @step:char-match + } + + // Mismatch — compute shift using bad character table + const mismatchChar = text[alignmentOffset + patternIdx]!; // @step:char-mismatch + const badCharShift = badCharTable.get(mismatchChar) ?? -1; // @step:char-mismatch + const shiftAmount = Math.max(1, patternIdx - badCharShift); // @step:char-mismatch + alignmentOffset += shiftAmount; // @step:shift-pattern + } + + return -1; // @step:complete +} + +function buildBadCharTable(pattern: string): Map { + const table = new Map(); // @step:build-bad-char + + for (let charIdx = 0; charIdx < pattern.length; charIdx++) { + table.set(pattern[charIdx]!, charIdx); // @step:build-bad-char + } + + return table; // @step:build-bad-char +} diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.test.ts b/src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.test.ts new file mode 100644 index 00000000..6f7fcc2b --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { generateBoyerMooreSearchSteps } from "./step-generator"; + +describe("generateBoyerMooreSearchSteps", () => { + it("produces steps for the default input", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string visual states throughout", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits build-failure steps for the bad character table", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + const tableSteps = steps.filter((step) => step.type === "build-failure"); + expect(tableSteps.length).toBeGreaterThan(0); + }); + + it("emits char-match steps when characters match", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABCDEF", pattern: "ABC" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("sets matchFound true when pattern is found", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(true); + } + }); + + it("sets matchFound false when pattern is not found", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABCDEFG", pattern: "XYZ" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); + + it("emits char-mismatch steps when pattern needs to shift", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABCDEFG", pattern: "DEF" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBeGreaterThan(0); + }); + + it("emits pattern-shift steps when the pattern is moved", () => { + const steps = generateBoyerMooreSearchSteps({ text: "ABAAABCD", pattern: "ABC" }); + const shiftSteps = steps.filter((step) => step.type === "pattern-shift"); + expect(shiftSteps.length).toBeGreaterThan(0); + }); + + it("handles an empty pattern immediately", () => { + const steps = generateBoyerMooreSearchSteps({ text: "HELLO", pattern: "" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(true); + } + }); + + it("handles pattern longer than text immediately", () => { + const steps = generateBoyerMooreSearchSteps({ text: "AB", pattern: "ABCD" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.type).toBe("complete"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); +}); diff --git a/src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.ts b/src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.ts new file mode 100644 index 00000000..830397c2 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/boyer-moore-search/step-generator.ts @@ -0,0 +1,107 @@ +/** Step generator for Boyer-Moore Search — produces ExecutionStep[] using StringTracker. */ + +import type { ExecutionStep } from "@/types"; +import { StringTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const BOYER_MOORE_SEARCH_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.BOYER_MOORE_SEARCH!); + +export interface BoyerMooreSearchInput { + text: string; + pattern: string; +} + +export function generateBoyerMooreSearchSteps(input: BoyerMooreSearchInput): ExecutionStep[] { + const { text, pattern } = input; + const tracker = new StringTracker(text, pattern, BOYER_MOORE_SEARCH_LINE_MAP); + + tracker.initialize({ text, pattern }); + + if (pattern.length === 0) { + tracker.recordMatch(0, { matchStart: 0 }); + tracker.complete({ result: 0 }); + return tracker.getSteps(); + } + + if (pattern.length > text.length) { + tracker.complete({ result: -1 }); + return tracker.getSteps(); + } + + // Phase 1: Build bad character table — repurpose failure table visualizer. + // Each entry stores the rightmost position of that character in the pattern. + const badCharPositions = new Map(); + + for (let charIdx = 0; charIdx < pattern.length; charIdx++) { + const currentChar = pattern[charIdx]!; + tracker.computingFailureEntry(charIdx, { + charIdx, + currentChar, + phase: "building-bad-char-table", + }); + badCharPositions.set(currentChar, charIdx); + tracker.setFailureEntry(charIdx, charIdx, { + charIdx, + currentChar, + rightmostPosition: charIdx, + }); + } + + // Phase 2: Search — align pattern at offset, compare right-to-left + tracker.startSearch({ text, pattern }); + + const patternLen = pattern.length; + const textLen = text.length; + let alignmentOffset = 0; + + while (alignmentOffset <= textLen - patternLen) { + let patternIdx = patternLen - 1; + + // Compare right-to-left: emit a compareChars step for each position + while (patternIdx >= 0) { + const textIdx = alignmentOffset + patternIdx; + + tracker.compareChars(textIdx, patternIdx, alignmentOffset, { + textIdx, + patternIdx, + alignmentOffset, + }); + + if (text[textIdx] === pattern[patternIdx]) { + tracker.charMatch(textIdx, patternIdx, { textIdx, patternIdx }); + patternIdx--; + } else { + // Mismatch — compute bad character shift + const mismatchChar = text[textIdx]!; + const badCharShift = badCharPositions.get(mismatchChar) ?? -1; + const shiftAmount = Math.max(1, patternIdx - badCharShift); + + tracker.charMismatch(textIdx, patternIdx, { + textIdx, + patternIdx, + mismatchChar, + badCharShift, + shiftAmount, + }); + + alignmentOffset += shiftAmount; + tracker.shiftPattern(alignmentOffset, patternLen - 1, { + alignmentOffset, + shiftAmount, + }); + break; + } + + if (patternIdx < 0) { + // Full pattern matched + tracker.recordMatch(alignmentOffset, { matchStart: alignmentOffset }); + tracker.complete({ result: alignmentOffset }); + return tracker.getSteps(); + } + } + } + + tracker.complete({ result: -1 }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/HammingDistancePipeline.stories.tsx b/src/algorithms/strings/pattern-matching/hamming-distance/HammingDistancePipeline.stories.tsx new file mode 100644 index 00000000..d11e8681 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/HammingDistancePipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Hamming Distance algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the StringVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { StringVisualState } from "@/types"; +import { generateHammingDistanceSteps } from "./step-generator"; +import StringVisualizer from "@/components/visualization/StringVisualizer"; + +const steps = generateHammingDistanceSteps({ + text: "karolin", + pattern: "kathrin", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Hamming Distance", + component: StringVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — both strings displayed, no characters compared yet */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as StringVisualState, + }, +}; + +/** Mid-execution — partway through comparing character pairs */ +export const MidScan: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.4)]!.visualState as StringVisualState, + }, +}; + +/** Late execution — most pairs compared, some mismatches recorded */ +export const LateScan: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.75)]!.visualState as StringVisualState, + }, +}; + +/** Final state — all positions compared, distance count complete */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as StringVisualState, + }, +}; diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/educational.ts b/src/algorithms/strings/pattern-matching/hamming-distance/educational.ts new file mode 100644 index 00000000..3b3d7080 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/educational.ts @@ -0,0 +1,61 @@ +import type { EducationalContent } from "@/types"; + +export const hammingDistanceEducational: EducationalContent = { + overview: + "**Hamming Distance** measures how different two equal-length strings are by counting the number of positions where their corresponding characters differ.\n\n" + + "Named after mathematician Richard Hamming, it was originally developed for error detection and correction in digital communications. Given two strings `text` and `pattern` of length `n`, the Hamming distance is the minimum number of single-character substitutions needed to transform one string into the other.\n\n" + + "If the strings are not the same length, the Hamming distance is undefined — this implementation returns `-1` in that case.", + + howItWorks: + "The algorithm is a single linear scan with no preprocessing:\n\n" + + "1. **Length check** — if `text.length !== pattern.length`, return `-1` immediately.\n" + + "2. **Scan** — iterate through every index `charIndex` from `0` to `n - 1`.\n" + + "3. **Compare** — if `text[charIndex] !== pattern[charIndex]`, increment a `distance` counter.\n" + + "4. **Return** — after the loop, `distance` holds the total number of mismatched positions.\n\n" + + "Example:\n" + + "```\n" + + "text: k a r o l i n\n" + + "pattern: k a t h r i n\n" + + "diff: . . ✗ ✗ ✗ . .\n" + + "```\n" + + "Hamming distance = **3** (positions 2, 3, and 4 differ).", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "- Each of the `n` character positions is visited exactly once.\n" + + "- No inner loops, no recursion, no sorting.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "- Only a single integer counter (`distance`) is maintained regardless of input size.\n" + + "- No auxiliary arrays or data structures are allocated.", + + bestAndWorstCase: + "**Best case** — both strings are identical: the loop runs all `n` iterations but the distance counter stays at 0. Still `O(n)` — there is no early exit because every position must be checked.\n\n" + + "**Worst case** — every character pair differs (e.g., `'aaaa'` vs `'bbbb'`): the counter increments at every position, reaching `n`. Still `O(n)`.\n\n" + + "Unlike most search algorithms, Hamming Distance has no difference between best and worst case in terms of asymptotic complexity — both are exactly `O(n)`.", + + realWorldUses: [ + "**Error-correcting codes:** Hamming codes use the distance metric to detect and correct single-bit errors in data transmission (the original motivation for the algorithm).", + "**DNA sequence analysis:** Comparing two aligned sequences of the same length to count single-nucleotide polymorphisms (SNPs) between individuals.", + "**Cryptography:** Measuring the diffusion property of hash functions and block ciphers — a good cipher should produce a Hamming distance close to `n/2` for a 1-bit input change.", + "**Spell checking:** Flagging words that differ in exactly one character from a dictionary entry as likely typos.", + "**Machine learning:** Used as a similarity metric for binary feature vectors in nearest-neighbour classification.", + ], + + strengthsAndLimitations: { + strengths: [ + "Extremely simple to implement — a single loop with one comparison per iteration.", + "O(1) space with no heap allocations, making it suitable for embedded or memory-constrained environments.", + "Deterministic O(n) time with no worst-case degradation.", + "Naturally parallelisable — each position is independent, enabling SIMD or GPU acceleration.", + ], + limitations: [ + "Requires equal-length strings — cannot compare strings of different lengths without padding.", + "Counts only substitutions — insertions and deletions are not modelled (use Levenshtein distance for those).", + "Not suitable for approximate matching where the pattern may appear at different alignments within a longer text.", + ], + }, + + whenToUseIt: + "Use Hamming Distance when you have two strings of identical length and want to count character-level differences in a single pass. It is the right choice for binary strings, fixed-width codes, aligned DNA sequences, or any domain where the strings are already aligned and only substitutions matter.\n\n" + + "If the strings can differ in length, or if insertions and deletions are possible, prefer **Levenshtein (edit) distance**. If you need to find a pattern anywhere inside a longer text, prefer **KMP Search** or **Rabin–Karp**.", +}; diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/hamming-distance.test.ts b/src/algorithms/strings/pattern-matching/hamming-distance/hamming-distance.test.ts new file mode 100644 index 00000000..ee3e484f --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/hamming-distance.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { hammingDistance } from "./sources/hamming-distance.ts?fn"; + +describe("hammingDistance", () => { + it("returns 3 for the default karolin / kathrin example", () => { + expect(hammingDistance("karolin", "kathrin")).toBe(3); + }); + + it("returns 0 for two identical strings", () => { + expect(hammingDistance("abcdef", "abcdef")).toBe(0); + }); + + it("returns the full length when every character differs", () => { + expect(hammingDistance("aaaa", "bbbb")).toBe(4); + }); + + it("returns 1 for a single-character difference", () => { + expect(hammingDistance("hello", "hxllo")).toBe(1); + }); + + it("returns -1 when the strings have different lengths", () => { + expect(hammingDistance("abc", "abcd")).toBe(-1); + }); + + it("returns -1 when text is longer than pattern", () => { + expect(hammingDistance("abcde", "abc")).toBe(-1); + }); + + it("handles single-character strings that match", () => { + expect(hammingDistance("a", "a")).toBe(0); + }); + + it("handles single-character strings that differ", () => { + expect(hammingDistance("a", "b")).toBe(1); + }); + + it("returns 0 for two empty strings", () => { + expect(hammingDistance("", "")).toBe(0); + }); + + it("returns 2 for a known binary string pair", () => { + expect(hammingDistance("1011101", "1001001")).toBe(2); + }); + + it("handles uppercase character comparisons", () => { + expect(hammingDistance("TONED", "ROSES")).toBe(3); + }); +}); diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/index.ts b/src/algorithms/strings/pattern-matching/hamming-distance/index.ts new file mode 100644 index 00000000..f1902e30 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/index.ts @@ -0,0 +1,45 @@ +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { hammingDistance } from "./sources/hamming-distance.ts?fn"; +import { generateHammingDistanceSteps } from "./step-generator"; +import type { HammingDistanceInput } from "./step-generator"; +import { hammingDistanceEducational } from "./educational"; + +import typescriptSource from "./sources/hamming-distance.ts?raw"; +import pythonSource from "./sources/hamming-distance.py?raw"; +import javaSource from "./sources/HammingDistance.java?raw"; + +function executeHammingDistance(input: HammingDistanceInput): number { + return hammingDistance(input.text, input.pattern) as number; +} + +const hammingDistanceDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.HAMMING_DISTANCE!, + name: "Hamming Distance", + category: CATEGORY.STRINGS!, + technique: "pattern-matching", + description: + "Count the number of positions where two equal-length strings differ in O(n) by scanning each character pair once", + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "karolin", pattern: "kathrin" }, + }, + execute: executeHammingDistance, + generateSteps: generateHammingDistanceSteps, + educational: hammingDistanceEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(hammingDistanceDefinition); diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/sources/HammingDistance.java b/src/algorithms/strings/pattern-matching/hamming-distance/sources/HammingDistance.java new file mode 100644 index 00000000..55e14bfe --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/sources/HammingDistance.java @@ -0,0 +1,25 @@ +// Hamming Distance +// Returns the number of positions where corresponding characters differ. +// Both strings must be equal length — returns -1 if lengths differ. +// Time: O(n), Space: O(1) + +public class HammingDistance { + + public static int hammingDistance(String text, String pattern) { + if (text.length() != pattern.length()) return -1; // @step:initialize + + int distance = 0; // @step:initialize + + for (int charIndex = 0; charIndex < text.length(); charIndex++) { // @step:visit + if (text.charAt(charIndex) != pattern.charAt(charIndex)) { + // Characters differ — increment the distance counter + distance++; // @step:char-mismatch + } else { + // Characters match — no change to distance + int noOp = distance; // @step:char-match + } + } + + return distance; // @step:complete + } +} diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.py b/src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.py new file mode 100644 index 00000000..34376445 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.py @@ -0,0 +1,21 @@ +# Hamming Distance +# Returns the number of positions where corresponding characters differ. +# Both strings must be equal length — returns -1 if lengths differ. +# Time: O(n), Space: O(1) + + +def hamming_distance(text: str, pattern: str) -> int: + if len(text) != len(pattern): # @step:initialize + return -1 + + distance = 0 # @step:initialize + + for char_index in range(len(text)): # @step:visit + if text[char_index] != pattern[char_index]: + # Characters differ — increment the distance counter + distance += 1 # @step:char-mismatch + else: + # Characters match — no change to distance + pass # @step:char-match + + return distance # @step:complete diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.ts b/src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.ts new file mode 100644 index 00000000..388588e4 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/sources/hamming-distance.ts @@ -0,0 +1,23 @@ +// Hamming Distance +// Returns the number of positions where corresponding characters differ. +// Both strings must be equal length — returns -1 if lengths differ. +// Time: O(n), Space: O(1) + +function hammingDistance(text: string, pattern: string): number { + if (text.length !== pattern.length) return -1; // @step:initialize + + let distance = 0; // @step:initialize + + for (let charIndex = 0; charIndex < text.length; charIndex++) { + // @step:visit + if (text[charIndex] !== pattern[charIndex]) { + // Characters differ — increment the distance counter + distance++; // @step:char-mismatch + } else { + // Characters match — no change to distance + distance = distance; // @step:char-match + } + } + + return distance; // @step:complete +} diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/step-generator.test.ts b/src/algorithms/strings/pattern-matching/hamming-distance/step-generator.test.ts new file mode 100644 index 00000000..dfae5854 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/step-generator.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { generateHammingDistanceSteps } from "./step-generator"; + +describe("generateHammingDistanceSteps", () => { + it("produces steps for the default input", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string visual states throughout", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("emits char-match steps when characters are equal", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("emits char-mismatch steps when characters differ", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBeGreaterThan(0); + }); + + it("emits exactly n char-match + char-mismatch steps for equal-length strings", () => { + const text = "karolin"; + const pattern = "kathrin"; + const steps = generateHammingDistanceSteps({ text, pattern }); + const compareSteps = steps.filter( + (step) => step.type === "char-match" || step.type === "char-mismatch", + ); + expect(compareSteps.length).toBe(text.length); + }); + + it("completes immediately with result -1 for unequal-length inputs", () => { + const steps = generateHammingDistanceSteps({ text: "abc", pattern: "abcd" }); + expect(steps.length).toBe(2); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[1]?.type).toBe("complete"); + }); + + it("emits only initialize and complete steps for identical strings — no mismatches", () => { + const steps = generateHammingDistanceSteps({ text: "abc", pattern: "abc" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBe(0); + }); + + it("stores the distance result in the complete step variables", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe(3); + }); + + it("visual state matchFound is false for Hamming Distance (no exact match concept)", () => { + const steps = generateHammingDistanceSteps({ text: "karolin", pattern: "kathrin" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); +}); diff --git a/src/algorithms/strings/pattern-matching/hamming-distance/step-generator.ts b/src/algorithms/strings/pattern-matching/hamming-distance/step-generator.ts new file mode 100644 index 00000000..224da7f9 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/hamming-distance/step-generator.ts @@ -0,0 +1,45 @@ +/** Step generator for Hamming Distance — produces ExecutionStep[] using StringTracker. */ + +import type { ExecutionStep } from "@/types"; +import { StringTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const HAMMING_DISTANCE_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.HAMMING_DISTANCE!); + +export interface HammingDistanceInput { + text: string; + pattern: string; +} + +export function generateHammingDistanceSteps(input: HammingDistanceInput): ExecutionStep[] { + const { text, pattern } = input; + + // Use empty strings for pattern when lengths differ so the tracker still renders + const trackerPattern = text.length === pattern.length ? pattern : pattern.slice(0, text.length); + const tracker = new StringTracker(text, trackerPattern, HAMMING_DISTANCE_LINE_MAP); + + if (text.length !== pattern.length) { + tracker.initialize({ text, pattern, error: "Strings must be equal length" }); + tracker.complete({ result: -1 }); + return tracker.getSteps(); + } + + tracker.initialize({ text, pattern, distance: 0 }); + + let distance = 0; + + for (let charIndex = 0; charIndex < text.length; charIndex++) { + tracker.compareChars(charIndex, charIndex, 0, { charIndex, distance }); + + if (text[charIndex] !== pattern[charIndex]) { + distance++; + tracker.charMismatch(charIndex, charIndex, { charIndex, distance }); + } else { + tracker.charMatch(charIndex, charIndex, { charIndex, distance }); + } + } + + tracker.complete({ result: distance }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/NaivePatternSearchPipeline.stories.tsx b/src/algorithms/strings/pattern-matching/naive-pattern-search/NaivePatternSearchPipeline.stories.tsx new file mode 100644 index 00000000..e728c111 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/NaivePatternSearchPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Naive Pattern Search algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the StringVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { StringVisualState } from "@/types"; +import { generateNaivePatternSearchSteps } from "./step-generator"; +import StringVisualizer from "@/components/visualization/StringVisualizer"; + +const steps = generateNaivePatternSearchSteps({ + text: "AABAACAADAABAABA", + pattern: "AABA", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Naive Pattern Search", + component: StringVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — pattern aligned at offset 0, no comparisons made yet */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as StringVisualState, + }, +}; + +/** Early search — pattern partially matched or mismatched at first position */ +export const EarlySearch: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.25)]!.visualState as StringVisualState, + }, +}; + +/** Mid-search — pattern window partway through the text */ +export const MidSearch: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.6)]!.visualState as StringVisualState, + }, +}; + +/** Final state — pattern found, matched characters highlighted */ +export const PatternFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as StringVisualState, + }, +}; diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/educational.ts b/src/algorithms/strings/pattern-matching/naive-pattern-search/educational.ts new file mode 100644 index 00000000..a7e0f142 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/educational.ts @@ -0,0 +1,64 @@ +/** Educational content for Naive Pattern Search. */ + +import type { EducationalContent } from "@/types"; + +export const naivePatternSearchEducational: EducationalContent = { + overview: + "**Naive Pattern Search** (also called brute-force string matching) finds the first occurrence of a pattern string inside a text string by checking every possible alignment position.\n\n" + + "For each starting index in the text, it compares characters one by one against the pattern. If all characters match, the position is returned. If any mismatch occurs, the pattern slides one position to the right and the comparison starts over from the beginning of the pattern.\n\n" + + "Its simplicity makes it easy to understand and implement correctly, and it performs surprisingly well on real-world inputs where mismatches tend to occur at the first or second character.", + + howItWorks: + "The algorithm uses two nested loops:\n\n" + + "**Outer loop** — slides the pattern window from index `0` to `n - m` (inclusive), where `n` is the text length and `m` is the pattern length:\n\n" + + "```\n" + + "Text: A A B A A C A A D\n" + + "Pattern: A A B A (offset = 0)\n" + + " A A B A (offset = 1, after mismatch at index 1)\n" + + " A A B A (offset = 2, ...) \n" + + "```\n\n" + + "**Inner loop** — compares `text[textIdx + patternIdx]` against `pattern[patternIdx]` for each `patternIdx` from `0` to `m - 1`:\n\n" + + "1. **Match** — `text[textIdx + patternIdx] == pattern[patternIdx]`: increment `patternIdx`.\n" + + "2. **All matched** — `patternIdx == m`: pattern found at `textIdx`, return immediately.\n" + + "3. **Mismatch** — break the inner loop and advance `textIdx` by one.\n\n" + + "Because the outer loop restarts the inner comparison from scratch on every mismatch, no preprocessing of the pattern is needed.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "- **Best case: `O(n)`** — mismatches occur at the first character of each alignment (e.g., `text = 'AAAB'`, `pattern = 'B'`). Each text position does only one comparison.\n" + + "- **Average case: `O(n × m)`** — for random text and patterns, inner loops terminate early due to frequent first-character mismatches. In practice, this is often close to `O(n)`.\n" + + "- **Worst case: `O(n × m)`** — highly repetitive text and pattern (e.g., `text = 'AAAA...A'`, `pattern = 'AAA...AB'`) force the inner loop to run to near completion before every mismatch.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "No auxiliary arrays or data structures are allocated. Only two index variables are used.", + + bestAndWorstCase: + "**Best case** — `O(n)`: pattern is found at the very first position, or mismatches consistently occur at the first character so each text position costs exactly one comparison.\n\n" + + "**Worst case** — `O(n × m)`: the text consists of repeated characters and the pattern nearly matches at every position before failing on the last character (e.g., text `'AAAAAAB'`, pattern `'AAAAB'`). The inner loop runs `m` times for each of the `n - m + 1` starting positions.\n\n" + + "For most English text and typical patterns, mismatches happen within the first 1–2 comparisons, making the practical average-case much closer to `O(n)` than the theoretical worst.", + + realWorldUses: [ + "**Short pattern searches:** When both the text and pattern are small (e.g., searching a line of log output for a short keyword), the `O(nm)` constant factors are negligible.", + "**Teaching tool:** Universally used as the first string-matching algorithm taught because its logic maps directly to the problem definition.", + "**Hardware pattern matching:** Simple enough to implement in hardware or firmware for embedded pattern detection without preprocessing overhead.", + "**Baseline benchmarking:** Used as the reference implementation to measure the speedup provided by KMP, Boyer-Moore, or Rabin-Karp.", + "**One-shot searches:** When a pattern is only ever searched against a single text (preprocessing cost of KMP or BM would not be amortized).", + ], + + strengthsAndLimitations: { + strengths: [ + "O(1) space — no auxiliary arrays needed unlike KMP (O(m)) or Boyer-Moore.", + "Zero preprocessing — works immediately on any text/pattern pair without setup cost.", + "Simple to implement and verify correctness — no subtle invariants or edge cases in the shift logic.", + "Competitive in practice — for short patterns or random text, first-character mismatches keep the constant low.", + ], + limitations: [ + "O(nm) worst case — degrades quadratically on adversarial (highly repetitive) inputs.", + "No learning from mismatches — discards all matched prefix information on every shift, repeating redundant comparisons.", + "Outperformed by KMP, Boyer-Moore, or Rabin-Karp for large texts or long patterns.", + "Not suitable for streaming or real-time security scanning where worst-case guarantees matter.", + ], + }, + + whenToUseIt: + "Choose Naive Pattern Search when **simplicity and correctness** matter more than asymptotic efficiency — for small inputs, one-shot searches, or when the pattern is short (< 8 characters) and mismatches occur early. Avoid it for large-scale text processing, security-sensitive scanning (where adversarial inputs can force quadratic behavior), or bioinformatics workloads with gigabyte-scale texts. In those cases, prefer **KMP** for a guaranteed `O(n + m)` bound, **Boyer-Moore-Horspool** for fast average-case performance on natural language, or **Rabin-Karp** when searching for multiple patterns simultaneously.", +}; diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/index.ts b/src/algorithms/strings/pattern-matching/naive-pattern-search/index.ts new file mode 100644 index 00000000..7a4e1328 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Naive Pattern Search — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { naivePatternSearch } from "./sources/naive-pattern-search.ts?fn"; +import { generateNaivePatternSearchSteps } from "./step-generator"; +import type { NaivePatternSearchInput } from "./step-generator"; +import { naivePatternSearchEducational } from "./educational"; + +import typescriptSource from "./sources/naive-pattern-search.ts?raw"; +import pythonSource from "./sources/naive-pattern-search.py?raw"; +import javaSource from "./sources/NaivePatternSearch.java?raw"; + +function executeNaivePatternSearch(input: NaivePatternSearchInput): number { + return naivePatternSearch(input.text, input.pattern) as number; +} + +const naivePatternSearchDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.NAIVE_PATTERN_SEARCH!, + name: "Naive Pattern Search", + category: CATEGORY.STRINGS!, + technique: "pattern-matching", + description: + "Find the first occurrence of a pattern in text by checking every position — simple O(nm) brute-force with O(1) space", + timeComplexity: { + best: "O(n)", + average: "O(nm)", + worst: "O(nm)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "AABAACAADAABAABA", pattern: "AABA" }, + }, + execute: executeNaivePatternSearch, + generateSteps: generateNaivePatternSearchSteps, + educational: naivePatternSearchEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(naivePatternSearchDefinition); diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/naive-pattern-search.test.ts b/src/algorithms/strings/pattern-matching/naive-pattern-search/naive-pattern-search.test.ts new file mode 100644 index 00000000..69032f48 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/naive-pattern-search.test.ts @@ -0,0 +1,54 @@ +/** Correctness tests for the naivePatternSearch function. */ + +import { describe, it, expect } from "vitest"; +import { naivePatternSearch } from "./sources/naive-pattern-search.ts?fn"; + +describe("naivePatternSearch", () => { + it("finds the pattern at the start of the text", () => { + expect(naivePatternSearch("ABCDEF", "ABC")).toBe(0); + }); + + it("finds the pattern in the middle of the text", () => { + expect(naivePatternSearch("AABAACAADAABAABA", "AABA")).toBe(0); + }); + + it("finds the pattern at the end of the text", () => { + expect(naivePatternSearch("XYZABC", "ABC")).toBe(3); + }); + + it("returns -1 when the pattern is not present", () => { + expect(naivePatternSearch("ABCDEFG", "XYZ")).toBe(-1); + }); + + it("handles a single-character pattern that exists", () => { + expect(naivePatternSearch("HELLO", "L")).toBe(2); + }); + + it("handles a single-character pattern that does not exist", () => { + expect(naivePatternSearch("HELLO", "Z")).toBe(-1); + }); + + it("returns 0 for an empty pattern", () => { + expect(naivePatternSearch("HELLO", "")).toBe(0); + }); + + it("handles text equal to the pattern", () => { + expect(naivePatternSearch("ABCD", "ABCD")).toBe(0); + }); + + it("returns -1 when pattern is longer than text", () => { + expect(naivePatternSearch("AB", "ABCD")).toBe(-1); + }); + + it("handles repeated characters correctly", () => { + expect(naivePatternSearch("AAAAAB", "AAAB")).toBe(2); + }); + + it("finds the first of multiple occurrences", () => { + expect(naivePatternSearch("AABAACAADAABAABA", "AABA")).toBe(0); + }); + + it("handles worst-case repetitive text", () => { + expect(naivePatternSearch("AAAAAAB", "AAAAB")).toBe(2); + }); +}); diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/NaivePatternSearch.java b/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/NaivePatternSearch.java new file mode 100644 index 00000000..9d85d250 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/NaivePatternSearch.java @@ -0,0 +1,23 @@ +// Naive (brute-force) pattern search — checks every position in text. +// Returns the index of the first occurrence of pattern in text, or -1 if not found. +// Time: O(n * m) worst case where n = text length, m = pattern length +// Space: O(1) — no auxiliary data structures + +public class NaivePatternSearch { + + public static int naivePatternSearch(String text, String pattern) { + if (pattern.isEmpty()) return 0; // @step:initialize + for (int textIdx = 0; textIdx <= text.length() - pattern.length(); textIdx++) { // @step:visit + int patternIdx = 0; // @step:visit + while ( // @step:char-match + patternIdx < pattern.length() + && text.charAt(textIdx + patternIdx) == pattern.charAt(patternIdx) + ) { + patternIdx++; // @step:char-match + } + if (patternIdx == pattern.length()) return textIdx; // @step:complete + // Mismatch — slide pattern right by one // @step:char-mismatch + } + return -1; // @step:complete + } +} diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.py b/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.py new file mode 100644 index 00000000..77ab038b --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.py @@ -0,0 +1,20 @@ +# Naive (brute-force) pattern search — checks every position in text. +# Returns the index of the first occurrence of pattern in text, or -1 if not found. +# Time: O(n * m) worst case where n = text length, m = pattern length +# Space: O(1) — no auxiliary data structures + + +def naive_pattern_search(text: str, pattern: str) -> int: + if len(pattern) == 0: # @step:initialize + return 0 + for text_idx in range(len(text) - len(pattern) + 1): # @step:visit + pattern_idx = 0 # @step:visit + while ( # @step:char-match + pattern_idx < len(pattern) + and text[text_idx + pattern_idx] == pattern[pattern_idx] + ): + pattern_idx += 1 # @step:char-match + if pattern_idx == len(pattern): # @step:complete + return text_idx + # Mismatch — slide pattern right by one # @step:char-mismatch + return -1 # @step:complete diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.ts b/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.ts new file mode 100644 index 00000000..e65a1581 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/sources/naive-pattern-search.ts @@ -0,0 +1,19 @@ +// Naive (brute-force) pattern search — checks every position in text. +// Returns the index of the first occurrence of pattern in text, or -1 if not found. +// Time: O(n * m) worst case where n = text length, m = pattern length +// Space: O(1) — no auxiliary data structures + +export function naivePatternSearch(text: string, pattern: string): number { + if (pattern.length === 0) return 0; // @step:initialize + for (let textIdx = 0; textIdx <= text.length - pattern.length; textIdx++) { + // @step:visit + let patternIdx = 0; // @step:visit + while (patternIdx < pattern.length && text[textIdx + patternIdx] === pattern[patternIdx]) { + // @step:char-match + patternIdx++; // @step:char-match + } + if (patternIdx === pattern.length) return textIdx; // @step:complete + // Mismatch — slide pattern right by one // @step:char-mismatch + } + return -1; // @step:complete +} diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.test.ts b/src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.test.ts new file mode 100644 index 00000000..8b60ba6f --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.test.ts @@ -0,0 +1,83 @@ +/** Step generation tests for Naive Pattern Search. */ + +import { describe, it, expect } from "vitest"; +import { generateNaivePatternSearchSteps } from "./step-generator"; + +describe("generateNaivePatternSearchSteps", () => { + it("produces steps for the default input", () => { + const steps = generateNaivePatternSearchSteps({ text: "AABAACAADAABAABA", pattern: "AABA" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateNaivePatternSearchSteps({ text: "AABAACAADAABAABA", pattern: "AABA" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateNaivePatternSearchSteps({ text: "AABAACAADAABAABA", pattern: "AABA" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string visual states throughout", () => { + const steps = generateNaivePatternSearchSteps({ text: "AABAACAADAABAABA", pattern: "AABA" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateNaivePatternSearchSteps({ text: "AABAACAADAABAABA", pattern: "AABA" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits char-match steps when characters match", () => { + const steps = generateNaivePatternSearchSteps({ text: "ABCABC", pattern: "ABC" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("emits char-mismatch steps when characters do not match", () => { + const steps = generateNaivePatternSearchSteps({ text: "ABCDEFG", pattern: "DEF" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBeGreaterThan(0); + }); + + it("sets matchFound true when pattern is found", () => { + const steps = generateNaivePatternSearchSteps({ text: "AABAACAADAABAABA", pattern: "AABA" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(true); + } + }); + + it("sets matchFound false when pattern is not found", () => { + const steps = generateNaivePatternSearchSteps({ text: "ABCDEFG", pattern: "XYZ" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); + + it("completes immediately for an empty pattern", () => { + const steps = generateNaivePatternSearchSteps({ text: "HELLO", pattern: "" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + expect(steps.length).toBe(2); // initialize + complete + }); + + it("does not emit build-failure steps (no failure table)", () => { + const steps = generateNaivePatternSearchSteps({ text: "AABAACAADAABAABA", pattern: "AABA" }); + const failureSteps = steps.filter((step) => step.type === "build-failure"); + expect(failureSteps.length).toBe(0); + }); + + it("emits visit steps for each comparison", () => { + const steps = generateNaivePatternSearchSteps({ text: "ABCDEF", pattern: "DEF" }); + const visitSteps = steps.filter((step) => step.type === "visit"); + expect(visitSteps.length).toBeGreaterThan(0); + }); +}); diff --git a/src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.ts b/src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.ts new file mode 100644 index 00000000..b289a3e8 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/naive-pattern-search/step-generator.ts @@ -0,0 +1,59 @@ +/** Step generator for Naive Pattern Search — produces ExecutionStep[] using StringTracker. */ + +import type { ExecutionStep } from "@/types"; +import { StringTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const NAIVE_PATTERN_SEARCH_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.NAIVE_PATTERN_SEARCH!); + +export interface NaivePatternSearchInput { + text: string; + pattern: string; +} + +export function generateNaivePatternSearchSteps(input: NaivePatternSearchInput): ExecutionStep[] { + const { text, pattern } = input; + const tracker = new StringTracker(text, pattern, NAIVE_PATTERN_SEARCH_LINE_MAP); + + tracker.initialize({ text, pattern }); + + if (pattern.length === 0) { + tracker.complete({ result: 0 }); + return tracker.getSteps(); + } + + // Slide pattern across text one position at a time + for (let textIdx = 0; textIdx <= text.length - pattern.length; textIdx++) { + let patternIdx = 0; + + // Compare characters at current offset + while (patternIdx < pattern.length) { + const absoluteTextIdx = textIdx + patternIdx; + tracker.compareChars(absoluteTextIdx, patternIdx, textIdx, { + textIdx, + patternIdx, + absoluteTextIdx, + }); + + if (text[absoluteTextIdx] === pattern[patternIdx]) { + tracker.charMatch(absoluteTextIdx, patternIdx, { textIdx, patternIdx }); + patternIdx++; + } else { + // Mismatch — slide pattern right by one + tracker.charMismatch(absoluteTextIdx, patternIdx, { textIdx, patternIdx }); + tracker.shiftPattern(textIdx + 1, 0, { patternOffset: textIdx + 1, patternIdx: 0 }); + break; + } + } + + if (patternIdx === pattern.length) { + tracker.recordMatch(textIdx, { matchStart: textIdx }); + tracker.complete({ result: textIdx }); + return tracker.getSteps(); + } + } + + tracker.complete({ result: -1 }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/RabinKarpSearchPipeline.stories.tsx b/src/algorithms/strings/pattern-matching/rabin-karp-search/RabinKarpSearchPipeline.stories.tsx new file mode 100644 index 00000000..6fc65e21 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/RabinKarpSearchPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Rabin-Karp Search algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the StringVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { StringVisualState } from "@/types"; +import { generateRabinKarpSearchSteps } from "./step-generator"; +import StringVisualizer from "@/components/visualization/StringVisualizer"; + +const steps = generateRabinKarpSearchSteps({ + text: "GEEKS FOR GEEKS", + pattern: "GEEK", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Rabin-Karp Search", + component: StringVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — hashes not yet computed, pattern aligned at offset 0 */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as StringVisualState, + }, +}; + +/** Hash computation phase — rolling hash values being built for pattern and first window */ +export const HashComputation: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.25)]!.visualState as StringVisualState, + }, +}; + +/** Search phase — window sliding across text, hashes being compared */ +export const SearchPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.6)]!.visualState as StringVisualState, + }, +}; + +/** Final state — pattern found, matched characters highlighted */ +export const PatternFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as StringVisualState, + }, +}; diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/educational.ts b/src/algorithms/strings/pattern-matching/rabin-karp-search/educational.ts new file mode 100644 index 00000000..85d7472b --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/educational.ts @@ -0,0 +1,70 @@ +import type { EducationalContent } from "@/types"; + +export const rabinKarpSearchEducational: EducationalContent = { + overview: + "**Rabin-Karp Pattern Matching** finds the first occurrence of a pattern string inside a text string using a **rolling hash** technique to reduce the number of character comparisons.\n\n" + + "Instead of comparing every possible window character-by-character like the naïve approach, Rabin-Karp computes a hash of the pattern and slides a same-length hash window across the text. " + + "When the hashes match, it verifies character by character to rule out **false positives** caused by hash collisions. " + + "In the average case this achieves `O(n + m)` time, though a degenerate input with many collisions can degrade to `O(n × m)`.", + + howItWorks: + "Rabin-Karp runs in two phases:\n\n" + + "**Phase 1 — Compute initial hashes** `O(m)`:\n\n" + + "Compute a polynomial rolling hash of the pattern and the first text window of length `m`:\n\n" + + "```\n" + + "hash = sum(char[i] * base^(m-1-i)) % prime for i in 0..m-1\n" + + "```\n\n" + + "**Phase 2 — Slide the window** `O(n)`:\n\n" + + "For each window position `s` from `0` to `n - m`:\n\n" + + "1. **Hash mismatch** — hashes differ → skip window, roll hash in `O(1)` using:\n" + + " `newHash = (oldHash - outgoing * base^(m-1)) * base + incoming) % prime`\n" + + "2. **Hash match** — hashes equal → verify characters one-by-one.\n" + + " - All characters match → pattern found at position `s`.\n" + + " - Any mismatch → **hash collision** (false positive), roll hash and continue.\n\n" + + "The rolling hash lets each window update happen in constant time, avoiding recomputing from scratch.", + + timeAndSpaceComplexity: + "**Time Complexity**\n\n" + + "| Case | Complexity | Reason |\n" + + "| ---- | ---------- | ------ |\n" + + "| Best | `O(n + m)` | Pattern found early, no collisions |\n" + + "| Average | `O(n + m)` | Few hash collisions with a good hash |\n" + + "| Worst | `O(n × m)` | Every window is a false positive (e.g., all same characters) |\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "Only a constant number of hash values and counters are stored — no auxiliary arrays.", + + bestAndWorstCase: + "**Best case** — pattern found at the very first window and no collisions occur: `O(m)` for hashing both strings once plus a single `O(m)` verification.\n\n" + + "**Average case** — with a good hash function and random-ish text, the probability of a false positive at any given window is `1 / prime` (very small). The total work is `O(n + m)`.\n\n" + + "**Worst case** — a pathological input where every window produces a hash collision (e.g., `text = 'AAAA…A'`, `pattern = 'AA…A'`) forces a full character comparison at every position: `O(n × m)`. " + + "Choosing a large prime and a good base makes this astronomically unlikely in practice.", + + realWorldUses: [ + "**Plagiarism detection:** Rabin-Karp (or its multi-pattern extension) hashes overlapping text windows across documents to quickly identify shared passages.", + "**Network intrusion detection:** Hash-based scanning of packet payloads against a database of malicious signatures in near-linear time.", + "**Document fingerprinting (Winnowing):** Sliding-window Rabin hashing underlies the Winnowing algorithm used by tools like MOSS to detect code similarity.", + "**Bioinformatics:** Rolling hashes accelerate k-mer counting and approximate substring matching in genomic sequences.", + "**Version control diffing:** Content-defined chunking in systems like rsync and Bup uses rolling hashes to locate matching regions between file versions.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(1) window update via rolling hash — slides efficiently across long texts.", + "Naturally extends to multi-pattern search (Rabin-Karp multistring) by storing a hash set of patterns.", + "Simple to implement compared to KMP or Boyer-Moore.", + "Space-efficient: O(1) extra space regardless of text length.", + ], + limitations: [ + "Worst-case O(n × m) on adversarial inputs with many hash collisions.", + "Hash collisions require character-level verification, adding overhead when collisions are frequent.", + "Poor hash function choice can degrade performance significantly.", + "For single-pattern search, KMP or Boyer-Moore provide stronger worst-case guarantees.", + ], + }, + + whenToUseIt: + "Choose Rabin-Karp when you need to search for **multiple patterns simultaneously** — computing hashes for all patterns and storing them in a hash set makes multi-pattern detection nearly as fast as single-pattern. " + + "It is also a good choice when simplicity of implementation matters and inputs are not adversarial. " + + "For guaranteed linear worst-case on a single pattern, prefer KMP (`O(n + m)` always) or Boyer-Moore (excellent average case). " + + "Avoid Rabin-Karp on highly repetitive inputs where hash collisions are predictably frequent.", +}; diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/index.ts b/src/algorithms/strings/pattern-matching/rabin-karp-search/index.ts new file mode 100644 index 00000000..77e9d8ca --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/index.ts @@ -0,0 +1,45 @@ +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { rabinKarpSearch } from "./sources/rabin-karp-search.ts?fn"; +import { generateRabinKarpSearchSteps } from "./step-generator"; +import type { RabinKarpSearchInput } from "./step-generator"; +import { rabinKarpSearchEducational } from "./educational"; + +import typescriptSource from "./sources/rabin-karp-search.ts?raw"; +import pythonSource from "./sources/rabin-karp-search.py?raw"; +import javaSource from "./sources/RabinKarpSearch.java?raw"; + +function executeRabinKarpSearch(input: RabinKarpSearchInput): number { + return rabinKarpSearch(input.text, input.pattern) as number; +} + +const rabinKarpSearchDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.RABIN_KARP_SEARCH!, + name: "Rabin-Karp Search", + category: CATEGORY.STRINGS!, + technique: "pattern-matching", + description: + "Find the first occurrence of a pattern in text using rolling hashes to skip windows efficiently, with character verification on hash matches to avoid false positives", + timeComplexity: { + best: "O(n + m)", + average: "O(n + m)", + worst: "O(n * m)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "GEEKS FOR GEEKS", pattern: "GEEK" }, + }, + execute: executeRabinKarpSearch, + generateSteps: generateRabinKarpSearchSteps, + educational: rabinKarpSearchEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(rabinKarpSearchDefinition); diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/rabin-karp-search.test.ts b/src/algorithms/strings/pattern-matching/rabin-karp-search/rabin-karp-search.test.ts new file mode 100644 index 00000000..8f2b0916 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/rabin-karp-search.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { rabinKarpSearch } from "./sources/rabin-karp-search.ts?fn"; + +describe("rabinKarpSearch", () => { + it("finds the pattern at the start of the text", () => { + expect(rabinKarpSearch("ABCDEF", "ABC")).toBe(0); + }); + + it("finds the pattern in the middle of the text", () => { + expect(rabinKarpSearch("GEEKS FOR GEEKS", "GEEK")).toBe(0); + }); + + it("finds the pattern at the end of the text", () => { + expect(rabinKarpSearch("XYZABC", "ABC")).toBe(3); + }); + + it("returns -1 when the pattern is not present", () => { + expect(rabinKarpSearch("ABCDEFG", "XYZ")).toBe(-1); + }); + + it("handles a single-character pattern that exists", () => { + expect(rabinKarpSearch("HELLO", "L")).toBe(2); + }); + + it("handles a single-character pattern that does not exist", () => { + expect(rabinKarpSearch("HELLO", "Z")).toBe(-1); + }); + + it("returns 0 for an empty pattern", () => { + expect(rabinKarpSearch("HELLO", "")).toBe(0); + }); + + it("handles text equal to the pattern", () => { + expect(rabinKarpSearch("ABCD", "ABCD")).toBe(0); + }); + + it("returns -1 when pattern is longer than text", () => { + expect(rabinKarpSearch("AB", "ABCD")).toBe(-1); + }); + + it("handles repeated characters correctly", () => { + expect(rabinKarpSearch("AAAAAB", "AAAB")).toBe(2); + }); + + it("finds the second occurrence when first would be a partial match", () => { + expect(rabinKarpSearch("ABABCABAB", "ABABCABAB")).toBe(0); + }); + + it("handles pattern at a known position in default input", () => { + expect(rabinKarpSearch("GEEKS FOR GEEKS", "FOR")).toBe(6); + }); +}); diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/RabinKarpSearch.java b/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/RabinKarpSearch.java new file mode 100644 index 00000000..d0187b57 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/RabinKarpSearch.java @@ -0,0 +1,58 @@ +// Rabin-Karp Pattern Matching +// Returns the index of the first occurrence of pattern in text, or -1 if not found. +// Uses a rolling polynomial hash to skip comparisons when hashes differ. +// Time: O(n + m) average, O(n * m) worst case (hash collisions) +// Space: O(1) + +public class RabinKarpSearch { + private static final int HASH_BASE = 31; + private static final long HASH_PRIME = 1_000_000_007L; + + public static int rabinKarpSearch(String text, String pattern) { + if (pattern.isEmpty()) return 0; // @step:initialize + if (pattern.length() > text.length()) return -1; // @step:initialize + + int patternLen = pattern.length(); // @step:initialize + int textLen = text.length(); // @step:initialize + + // Compute base^(patternLen - 1) % prime for rolling hash window removal + long highPow = 1; // @step:initialize + for (int powIdx = 0; powIdx < patternLen - 1; powIdx++) { + highPow = (highPow * HASH_BASE) % HASH_PRIME; // @step:initialize + } + + // Compute hash of pattern and first window + long patternHash = 0; // @step:initialize + long windowHash = 0; // @step:initialize + for (int charIdx = 0; charIdx < patternLen; charIdx++) { + patternHash = (patternHash * HASH_BASE + pattern.charAt(charIdx)) % HASH_PRIME; // @step:initialize + windowHash = (windowHash * HASH_BASE + text.charAt(charIdx)) % HASH_PRIME; // @step:initialize + } + + // Slide the window over the text + for (int windowStart = 0; windowStart <= textLen - patternLen; windowStart++) { // @step:visit + if (windowHash == patternHash) { // @step:visit + // Hashes match — verify character by character + int charIdx = 0; // @step:char-match + while (charIdx < patternLen && text.charAt(windowStart + charIdx) == pattern.charAt(charIdx)) { + charIdx++; // @step:char-match + } + + if (charIdx == patternLen) { + return windowStart; // @step:char-match + } + // Hash collision — hashes matched but characters did not + } + + // Roll hash: remove leading character, add next character + if (windowStart < textLen - patternLen) { + long outgoingCharCode = text.charAt(windowStart); // @step:pattern-shift + long incomingCharCode = text.charAt(windowStart + patternLen); // @step:pattern-shift + windowHash = ((windowHash - outgoingCharCode * highPow) * HASH_BASE + incomingCharCode) % HASH_PRIME; // @step:pattern-shift + if (windowHash < 0) windowHash += HASH_PRIME; // @step:pattern-shift + } + } + + return -1; // @step:complete + } +} diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.py b/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.py new file mode 100644 index 00000000..8ec353bb --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.py @@ -0,0 +1,54 @@ +# Rabin-Karp Pattern Matching +# Returns the index of the first occurrence of pattern in text, or -1 if not found. +# Uses a rolling polynomial hash to skip comparisons when hashes differ. +# Time: O(n + m) average, O(n * m) worst case (hash collisions) +# Space: O(1) + +HASH_BASE = 31 +HASH_PRIME = 1_000_000_007 + + +def rabin_karp_search(text: str, pattern: str) -> int: + if len(pattern) == 0: # @step:initialize + return 0 # @step:initialize + if len(pattern) > len(text): # @step:initialize + return -1 # @step:initialize + + pattern_len = len(pattern) # @step:initialize + text_len = len(text) # @step:initialize + + # Compute base^(pattern_len - 1) % prime for rolling hash window removal + high_pow = 1 # @step:initialize + for _ in range(pattern_len - 1): + high_pow = (high_pow * HASH_BASE) % HASH_PRIME # @step:initialize + + # Compute hash of pattern and first window + pattern_hash = 0 # @step:initialize + window_hash = 0 # @step:initialize + for char_idx in range(pattern_len): + pattern_hash = (pattern_hash * HASH_BASE + ord(pattern[char_idx])) % HASH_PRIME # @step:initialize + window_hash = (window_hash * HASH_BASE + ord(text[char_idx])) % HASH_PRIME # @step:initialize + + # Slide the window over the text + for window_start in range(text_len - pattern_len + 1): # @step:visit + if window_hash == pattern_hash: # @step:visit + # Hashes match — verify character by character + char_idx = 0 # @step:char-match + while char_idx < pattern_len and text[window_start + char_idx] == pattern[char_idx]: + char_idx += 1 # @step:char-match + + if char_idx == pattern_len: + return window_start # @step:char-match + # Hash collision — hashes matched but characters did not + + # Roll hash: remove leading character, add next character + if window_start < text_len - pattern_len: + outgoing_char_code = ord(text[window_start]) # @step:pattern-shift + incoming_char_code = ord(text[window_start + pattern_len]) # @step:pattern-shift + window_hash = ( + (window_hash - outgoing_char_code * high_pow) * HASH_BASE + incoming_char_code + ) % HASH_PRIME # @step:pattern-shift + if window_hash < 0: + window_hash += HASH_PRIME # @step:pattern-shift + + return -1 # @step:complete diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.ts b/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.ts new file mode 100644 index 00000000..5c198c3b --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/sources/rabin-karp-search.ts @@ -0,0 +1,58 @@ +// Rabin-Karp Pattern Matching +// Returns the index of the first occurrence of pattern in text, or -1 if not found. +// Uses a rolling polynomial hash to skip comparisons when hashes differ. +// Time: O(n + m) average, O(n * m) worst case (hash collisions) +// Space: O(1) + +const HASH_BASE = 31; +const HASH_PRIME = 1_000_000_007; + +function rabinKarpSearch(text: string, pattern: string): number { + if (pattern.length === 0) return 0; // @step:initialize + if (pattern.length > text.length) return -1; // @step:initialize + + const patternLen = pattern.length; // @step:initialize + const textLen = text.length; // @step:initialize + + // Compute base^(patternLen-1) % prime for rolling hash window removal + let highPow = 1; // @step:initialize + for (let powIdx = 0; powIdx < patternLen - 1; powIdx++) { + highPow = (highPow * HASH_BASE) % HASH_PRIME; // @step:initialize + } + + // Compute hash of pattern and first window + let patternHash = 0; // @step:initialize + let windowHash = 0; // @step:initialize + for (let charIdx = 0; charIdx < patternLen; charIdx++) { + patternHash = (patternHash * HASH_BASE + pattern.charCodeAt(charIdx)) % HASH_PRIME; // @step:initialize + windowHash = (windowHash * HASH_BASE + text.charCodeAt(charIdx)) % HASH_PRIME; // @step:initialize + } + + // Slide the window over the text + for (let windowStart = 0; windowStart <= textLen - patternLen; windowStart++) { + // @step:visit + if (windowHash === patternHash) { + // Hashes match — verify character by character to rule out false positives + let charIdx = 0; // @step:char-match + while (charIdx < patternLen && text[windowStart + charIdx] === pattern[charIdx]) { + charIdx++; // @step:char-match + } + + if (charIdx === patternLen) { + return windowStart; // @step:char-match + } + // Hash collision — hashes matched but characters did not + } + + // Roll hash: remove leading character, add next character + if (windowStart < textLen - patternLen) { + const outgoingCharCode = text.charCodeAt(windowStart); // @step:pattern-shift + const incomingCharCode = text.charCodeAt(windowStart + patternLen); // @step:pattern-shift + windowHash = + ((windowHash - outgoingCharCode * highPow) * HASH_BASE + incomingCharCode) % HASH_PRIME; // @step:pattern-shift + if (windowHash < 0) windowHash += HASH_PRIME; // @step:pattern-shift + } + } + + return -1; // @step:complete +} diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.test.ts b/src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.test.ts new file mode 100644 index 00000000..d0c86a8f --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { generateRabinKarpSearchSteps } from "./step-generator"; + +describe("generateRabinKarpSearchSteps", () => { + it("produces steps for the default input", () => { + const steps = generateRabinKarpSearchSteps({ text: "GEEKS FOR GEEKS", pattern: "GEEK" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateRabinKarpSearchSteps({ text: "GEEKS FOR GEEKS", pattern: "GEEK" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateRabinKarpSearchSteps({ text: "GEEKS FOR GEEKS", pattern: "GEEK" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string visual states throughout", () => { + const steps = generateRabinKarpSearchSteps({ text: "GEEKS FOR GEEKS", pattern: "GEEK" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateRabinKarpSearchSteps({ text: "GEEKS FOR GEEKS", pattern: "GEEK" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits build-failure steps during hash computation phase", () => { + const steps = generateRabinKarpSearchSteps({ text: "GEEKS FOR GEEKS", pattern: "GEEK" }); + const buildFailureSteps = steps.filter((step) => step.type === "build-failure"); + expect(buildFailureSteps.length).toBeGreaterThan(0); + }); + + it("emits char-match steps when characters match", () => { + const steps = generateRabinKarpSearchSteps({ text: "ABCABC", pattern: "ABC" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("emits char-mismatch steps when hashes or characters differ", () => { + const steps = generateRabinKarpSearchSteps({ text: "ABCDEFG", pattern: "DEF" }); + const mismatchSteps = steps.filter((step) => step.type === "char-mismatch"); + expect(mismatchSteps.length).toBeGreaterThan(0); + }); + + it("emits pattern-shift steps as the hash window rolls", () => { + const steps = generateRabinKarpSearchSteps({ text: "ABCDEFG", pattern: "DEF" }); + const shiftSteps = steps.filter((step) => step.type === "pattern-shift"); + expect(shiftSteps.length).toBeGreaterThan(0); + }); + + it("sets matchFound true when pattern is found", () => { + const steps = generateRabinKarpSearchSteps({ text: "GEEKS FOR GEEKS", pattern: "GEEK" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(true); + } + }); + + it("sets matchFound false when pattern is not found", () => { + const steps = generateRabinKarpSearchSteps({ text: "ABCDEFG", pattern: "XYZ" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); + + it("handles empty pattern with immediate complete", () => { + const steps = generateRabinKarpSearchSteps({ text: "HELLO", pattern: "" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(true); + } + }); + + it("handles pattern longer than text with immediate complete", () => { + const steps = generateRabinKarpSearchSteps({ text: "AB", pattern: "ABCDE" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); +}); diff --git a/src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.ts b/src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.ts new file mode 100644 index 00000000..cbf8f086 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/rabin-karp-search/step-generator.ts @@ -0,0 +1,143 @@ +/** Step generator for Rabin-Karp Search — produces ExecutionStep[] using StringTracker. */ + +import type { ExecutionStep } from "@/types"; +import { StringTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const RABIN_KARP_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.RABIN_KARP_SEARCH!); + +const HASH_BASE = 31; +const HASH_PRIME = 1_000_000_007; + +export interface RabinKarpSearchInput { + text: string; + pattern: string; +} + +export function generateRabinKarpSearchSteps(input: RabinKarpSearchInput): ExecutionStep[] { + const { text, pattern } = input; + const tracker = new StringTracker(text, pattern, RABIN_KARP_LINE_MAP); + + // Phase 1: Compute initial hashes + tracker.initialize({ text, pattern }); + + if (pattern.length === 0) { + tracker.recordMatch(0, { matchStart: 0 }); + tracker.complete({ result: 0 }); + return tracker.getSteps(); + } + + if (pattern.length > text.length) { + tracker.complete({ result: -1 }); + return tracker.getSteps(); + } + + const patternLen = pattern.length; + const textLen = text.length; + + // Compute base^(patternLen-1) % prime + let highPow = 1; + for (let powIdx = 0; powIdx < patternLen - 1; powIdx++) { + highPow = (highPow * HASH_BASE) % HASH_PRIME; + } + + // Compute initial pattern hash and first window hash, using computingFailureEntry + // to display hash-building progress in the failure table visualizer + let patternHash = 0; + let windowHash = 0; + for (let charIdx = 0; charIdx < patternLen; charIdx++) { + patternHash = (patternHash * HASH_BASE + pattern.charCodeAt(charIdx)) % HASH_PRIME; + windowHash = (windowHash * HASH_BASE + text.charCodeAt(charIdx)) % HASH_PRIME; + // Emit a step for each character hashed into the initial window + tracker.computingFailureEntry(charIdx, { + charIdx, + patternHash, + windowHash, + phase: "computing-initial-hash", + }); + tracker.setFailureEntry(charIdx, Math.floor(patternHash % 1000), { + charIdx, + patternHash, + windowHash, + }); + } + + // Phase 2: Search — slide window across text + tracker.startSearch({ text, pattern, patternHash, windowHash }); + + for (let windowStart = 0; windowStart <= textLen - patternLen; windowStart++) { + // Show current window being evaluated + tracker.compareChars(windowStart, 0, windowStart, { + windowStart, + windowHash, + patternHash, + hashesMatch: windowHash === patternHash, + }); + + if (windowHash === patternHash) { + // Hashes match — verify character by character to eliminate false positives + let charIdx = 0; + let allCharsMatch = true; + + while (charIdx < patternLen) { + tracker.compareChars(windowStart + charIdx, charIdx, windowStart, { + windowStart, + charIdx, + textChar: text[windowStart + charIdx], + patternChar: pattern[charIdx], + verifyingAfterHashMatch: true, + }); + + if (text[windowStart + charIdx] === pattern[charIdx]) { + tracker.charMatch(windowStart + charIdx, charIdx, { + windowStart, + charIdx, + }); + charIdx++; + } else { + tracker.charMismatch(windowStart + charIdx, charIdx, { + windowStart, + charIdx, + note: "hash-collision", + }); + allCharsMatch = false; + break; + } + } + + if (allCharsMatch && charIdx === patternLen) { + tracker.recordMatch(windowStart, { matchStart: windowStart }); + tracker.complete({ result: windowStart }); + return tracker.getSteps(); + } + } else { + // Hashes differ — skip this window without character comparison + tracker.charMismatch(windowStart, 0, { + windowStart, + windowHash, + patternHash, + note: "hash-mismatch-skip", + }); + } + + // Roll the hash: remove outgoing char, add incoming char + if (windowStart < textLen - patternLen) { + const outgoingCharCode = text.charCodeAt(windowStart); + const incomingCharCode = text.charCodeAt(windowStart + patternLen); + windowHash = + ((windowHash - outgoingCharCode * highPow) * HASH_BASE + incomingCharCode) % HASH_PRIME; + if (windowHash < 0) windowHash += HASH_PRIME; + + tracker.shiftPattern(windowStart + 1, 0, { + windowStart: windowStart + 1, + windowHash, + outgoingChar: text[windowStart], + incomingChar: text[windowStart + patternLen], + }); + } + } + + tracker.complete({ result: -1 }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/ZAlgorithmPipeline.stories.tsx b/src/algorithms/strings/pattern-matching/z-algorithm/ZAlgorithmPipeline.stories.tsx new file mode 100644 index 00000000..6d045e5b --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/ZAlgorithmPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Z-Algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the StringVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { StringVisualState } from "@/types"; +import { generateZAlgorithmSteps } from "./step-generator"; +import StringVisualizer from "@/components/visualization/StringVisualizer"; + +const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Z-Algorithm", + component: StringVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — combined string formed, Z-array not yet computed */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as StringVisualState, + }, +}; + +/** Mid-execution — Z-array partially built for the text region */ +export const ZArrayBuilding: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.35)]!.visualState as StringVisualState, + }, +}; + +/** Search phase — Z-value matching the pattern length detected */ +export const MatchDetected: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.7)]!.visualState as StringVisualState, + }, +}; + +/** Final state — pattern found, matched characters highlighted */ +export const PatternFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as StringVisualState, + }, +}; diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/educational.ts b/src/algorithms/strings/pattern-matching/z-algorithm/educational.ts new file mode 100644 index 00000000..e49aa527 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/educational.ts @@ -0,0 +1,62 @@ +import type { EducationalContent } from "@/types"; + +export const zAlgorithmEducational: EducationalContent = { + overview: + "**Z-Algorithm** finds the first occurrence of a pattern string inside a text string in `O(n + m)` time, where `n` is the text length and `m` is the pattern length.\n\n" + + "The key idea is to concatenate the pattern, a sentinel character (`$`), and the text into a single combined string, then compute a **Z-array**. " + + "Each entry `Z[i]` stores the length of the longest substring starting at position `i` that also matches a prefix of the combined string. " + + "Whenever `Z[i]` equals the pattern length, the pattern starts at position `i - m - 1` in the original text.", + + howItWorks: + "The Z-Algorithm runs in a single pass over the combined string `pattern + '$' + text`:\n\n" + + "**Build the Z-array** (`O(n + m)`):\n\n" + + "Maintain a **Z-box** `[windowLeft, windowRight)` — the rightmost interval that matches a prefix. For each position `pos`:\n\n" + + "1. If `pos` is inside the Z-box, initialise `Z[pos]` using the already-computed `Z[pos - windowLeft]` (avoid re-comparing).\n" + + "2. Extend `Z[pos]` by comparing characters forward until a mismatch.\n" + + "3. If the new interval extends past `windowRight`, update the Z-box.\n\n" + + "```\n" + + "Combined: A A B X A A B $ A A B X A A B X A Y\n" + + "Index: 0 1 2 3 4 5 6 7 8 9 ...\n" + + "Z: - 1 0 0 7 1 0 0 3 1 ...\n" + + "```\n\n" + + "**Detect matches** (inline, no second pass):\n\n" + + "If `Z[pos] == m`, the substring at `pos` in the combined string equals the full pattern, so the match starts at `pos - m - 1` in the text.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n + m)`**\n\n" + + "- Each character in the combined string of length `n + m + 1` is visited at most twice — once during Z-box extension and once when the Z-box is advanced.\n" + + "- No second scan is needed: match detection is done inline during Z-array construction.\n\n" + + "**Space Complexity: `O(n + m)`**\n\n" + + "The combined string and Z-array each have length `n + m + 1`. Unlike KMP, there is no way to reduce this to `O(m)` alone because the combined string must be stored.", + + bestAndWorstCase: + "**Best case** — pattern found at the very start of the text: the algorithm stops as soon as `Z[m + 1] == m`, after examining just the first `m + 1` positions of the combined string.\n\n" + + "**Worst case** — highly repetitive text and pattern (e.g., `text = 'AAAA...A'`, `pattern = 'AAAA'`) where the Z-box is constantly extended. Time remains `O(n + m)` — the same tight bound as KMP. Unlike the naïve algorithm, the Z-Algorithm has no quadratic worst case.", + + realWorldUses: [ + "**Compiler preprocessing:** The Z-array construction maps directly to suffix structures used in some compiler optimizations.", + "**Bioinformatics:** Locating gene sequences inside long DNA strands; the Z-Algorithm is favored when the combined-string model simplifies implementation.", + "**Text search utilities:** String search in editors and command-line tools where a single guaranteed-linear pass is required.", + "**Competitive programming:** Standard building block for problems involving multiple pattern queries on the same text.", + "**Suffix array construction:** The Z-array is a conceptual sibling of the LCP array, and understanding it aids in building more advanced suffix structures.", + ], + + strengthsAndLimitations: { + strengths: [ + "Conceptually simpler than KMP — one combined string, one array, one loop.", + "O(n + m) guaranteed time with no quadratic worst case.", + "Match detection is inline — no second scan of the Z-array needed.", + ], + limitations: [ + "O(n + m) space — KMP only requires O(m) by keeping the failure table over the pattern.", + "Allocating the combined string adds memory pressure for very long texts.", + "For searching the same pattern across many texts, KMP's O(m) preprocessing amortizes better.", + ], + }, + + whenToUseIt: + "Choose the Z-Algorithm when you want a conceptually straightforward linear-time search and `O(n + m)` memory is acceptable. " + + "It is particularly convenient when a combined-string model fits naturally (e.g., checking if a string is a rotation of another). " + + "Prefer KMP when memory is constrained to `O(m)`, or when the same pattern will be searched against many texts. " + + "For very short patterns in performance-critical paths, SIMD-accelerated built-in `String.includes` / `str.find` is typically faster in practice.", +}; diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/index.ts b/src/algorithms/strings/pattern-matching/z-algorithm/index.ts new file mode 100644 index 00000000..c8fd398f --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/index.ts @@ -0,0 +1,45 @@ +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { zAlgorithm } from "./sources/z-algorithm.ts?fn"; +import { generateZAlgorithmSteps } from "./step-generator"; +import type { ZAlgorithmInput } from "./step-generator"; +import { zAlgorithmEducational } from "./educational"; + +import typescriptSource from "./sources/z-algorithm.ts?raw"; +import pythonSource from "./sources/z-algorithm.py?raw"; +import javaSource from "./sources/ZAlgorithm.java?raw"; + +function executeZAlgorithm(input: ZAlgorithmInput): number { + return zAlgorithm(input.text, input.pattern) as number; +} + +const zAlgorithmDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.Z_ALGORITHM!, + name: "Z-Algorithm", + category: CATEGORY.STRINGS!, + technique: "pattern-matching", + description: + "Find the first occurrence of a pattern in text in O(n + m) using a Z-array that encodes prefix match lengths over the concatenated string", + timeComplexity: { + best: "O(m)", + average: "O(n + m)", + worst: "O(n + m)", + }, + spaceComplexity: "O(n + m)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "AABXAABXCAABXAABXAY", pattern: "AABXAAB" }, + }, + execute: executeZAlgorithm, + generateSteps: generateZAlgorithmSteps, + educational: zAlgorithmEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(zAlgorithmDefinition); diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/sources/ZAlgorithm.java b/src/algorithms/strings/pattern-matching/z-algorithm/sources/ZAlgorithm.java new file mode 100644 index 00000000..4b2d3741 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/sources/ZAlgorithm.java @@ -0,0 +1,43 @@ +// Z-Algorithm Pattern Matching +// Concatenates pattern + "$" + text, builds Z-array where Z[i] = length of longest substring +// starting at i that matches a prefix of the combined string. +// If Z[i] == pattern.length(), pattern found at position i - pattern.length() - 1 in the text. +// Time: O(n + m) where n = text length, m = pattern length +// Space: O(n + m) for the combined string and Z-array + +public class ZAlgorithm { + + public static int zAlgorithm(String text, String pattern) { + if (pattern.isEmpty()) return 0; // @step:initialize + String combined = pattern + "$" + text; // @step:initialize + int combinedLength = combined.length(); // @step:initialize + int[] zArray = new int[combinedLength]; // @step:initialize + + int windowLeft = 0; // @step:initialize + int windowRight = 0; // @step:initialize + + for (int pos = 1; pos < combinedLength; pos++) { // @step:build-failure + if (pos < windowRight) { + zArray[pos] = Math.min(windowRight - pos, zArray[pos - windowLeft]); // @step:build-failure + } + + while ( + pos + zArray[pos] < combinedLength && + combined.charAt(zArray[pos]) == combined.charAt(pos + zArray[pos]) + ) { + zArray[pos]++; // @step:build-failure + } + + if (pos + zArray[pos] > windowRight) { + windowLeft = pos; // @step:build-failure + windowRight = pos + zArray[pos]; // @step:build-failure + } + + if (zArray[pos] == pattern.length()) { + return pos - pattern.length() - 1; // @step:char-match + } + } + + return -1; // @step:complete + } +} diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.py b/src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.py new file mode 100644 index 00000000..845d16a6 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.py @@ -0,0 +1,36 @@ +# Z-Algorithm Pattern Matching +# Concatenates pattern + "$" + text, builds Z-array where Z[i] = length of longest substring +# starting at i that matches a prefix of the combined string. +# If Z[i] == len(pattern), pattern found at position i - len(pattern) - 1 in the text. +# Time: O(n + m) where n = text length, m = pattern length +# Space: O(n + m) for the combined string and Z-array + + +def z_algorithm(text: str, pattern: str) -> int: + if len(pattern) == 0: # @step:initialize + return 0 + combined = pattern + "$" + text # @step:initialize + combined_length = len(combined) # @step:initialize + z_array = [0] * combined_length # @step:initialize + + window_left = 0 # @step:initialize + window_right = 0 # @step:initialize + + for pos in range(1, combined_length): # @step:build-failure + if pos < window_right: + z_array[pos] = min(window_right - pos, z_array[pos - window_left]) # @step:build-failure + + while ( + pos + z_array[pos] < combined_length + and combined[z_array[pos]] == combined[pos + z_array[pos]] + ): + z_array[pos] += 1 # @step:build-failure + + if pos + z_array[pos] > window_right: + window_left = pos # @step:build-failure + window_right = pos + z_array[pos] # @step:build-failure + + if z_array[pos] == len(pattern): + return pos - len(pattern) - 1 # @step:char-match + + return -1 # @step:complete diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.ts b/src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.ts new file mode 100644 index 00000000..001d161d --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/sources/z-algorithm.ts @@ -0,0 +1,41 @@ +// Z-Algorithm Pattern Matching +// Concatenates pattern + "$" + text, builds Z-array where Z[i] = length of longest substring +// starting at i that matches a prefix of the combined string. +// If Z[i] == pattern.length, pattern found at position i - pattern.length - 1 in the text. +// Time: O(n + m) where n = text length, m = pattern length +// Space: O(n + m) for the combined string and Z-array + +function zAlgorithm(text: string, pattern: string): number { + if (pattern.length === 0) return 0; // @step:initialize + const combined = pattern + "$" + text; // @step:initialize + const combinedLength = combined.length; // @step:initialize + const zArray = new Array(combinedLength).fill(0); // @step:initialize + + let windowLeft = 0; // @step:initialize + let windowRight = 0; // @step:initialize + + for (let pos = 1; pos < combinedLength; pos++) { + // @step:build-failure + if (pos < windowRight) { + zArray[pos] = Math.min(windowRight - pos, zArray[pos - windowLeft]!); // @step:build-failure + } + + while ( + pos + (zArray[pos] ?? 0) < combinedLength && + combined[zArray[pos] ?? 0] === combined[pos + (zArray[pos] ?? 0)] + ) { + zArray[pos] = (zArray[pos] ?? 0) + 1; // @step:build-failure + } + + if (pos + (zArray[pos] ?? 0) > windowRight) { + windowLeft = pos; // @step:build-failure + windowRight = pos + (zArray[pos] ?? 0); // @step:build-failure + } + + if ((zArray[pos] ?? 0) === pattern.length) { + return pos - pattern.length - 1; // @step:char-match + } + } + + return -1; // @step:complete +} diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/step-generator.test.ts b/src/algorithms/strings/pattern-matching/z-algorithm/step-generator.test.ts new file mode 100644 index 00000000..67bf1748 --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/step-generator.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { generateZAlgorithmSteps } from "./step-generator"; + +describe("generateZAlgorithmSteps", () => { + it("produces steps for the default input", () => { + const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", + }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", + }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", + }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string visual states throughout", () => { + const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", + }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", + }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits build-failure steps for the Z-array", () => { + const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", + }); + const zArraySteps = steps.filter((step) => step.type === "build-failure"); + expect(zArraySteps.length).toBeGreaterThan(0); + }); + + it("emits char-match steps when the pattern is found", () => { + const steps = generateZAlgorithmSteps({ text: "ABCABC", pattern: "ABC" }); + const matchSteps = steps.filter((step) => step.type === "char-match"); + expect(matchSteps.length).toBeGreaterThan(0); + }); + + it("sets matchFound true when the pattern is found", () => { + const steps = generateZAlgorithmSteps({ + text: "AABXAABXCAABXAABXAY", + pattern: "AABXAAB", + }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(true); + } + }); + + it("sets matchFound false when the pattern is not found", () => { + const steps = generateZAlgorithmSteps({ text: "ABCDEFG", pattern: "XYZ" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string"); + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); + + it("handles an empty pattern with only initialize and complete steps", () => { + const steps = generateZAlgorithmSteps({ text: "HELLO", pattern: "" }); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("correctly identifies pattern not present in text", () => { + const steps = generateZAlgorithmSteps({ text: "ABCDEFG", pattern: "XYZ" }); + const completeStep = steps[steps.length - 1]!; + if (completeStep.visualState.kind === "string") { + expect(completeStep.visualState.matchFound).toBe(false); + } + }); +}); diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/step-generator.ts b/src/algorithms/strings/pattern-matching/z-algorithm/step-generator.ts new file mode 100644 index 00000000..b1217aab --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/step-generator.ts @@ -0,0 +1,88 @@ +/** Step generator for Z-Algorithm — produces ExecutionStep[] using StringTracker. */ + +import type { ExecutionStep } from "@/types"; +import { StringTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const Z_ALGORITHM_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.Z_ALGORITHM!); + +export interface ZAlgorithmInput { + text: string; + pattern: string; +} + +export function generateZAlgorithmSteps(input: ZAlgorithmInput): ExecutionStep[] { + const { text, pattern } = input; + + if (pattern.length === 0) { + const tracker = new StringTracker(text, pattern, Z_ALGORITHM_LINE_MAP); + tracker.initialize({ text, pattern }); + tracker.complete({ result: 0 }); + return tracker.getSteps(); + } + + const combined = pattern + "$" + text; + const combinedLength = combined.length; + const patternLength = pattern.length; + + // StringTracker "text" = the combined string (pattern + "$" + text). + // StringTracker "pattern" = the original pattern. + // The failure table (length = patternLength) is reused to display Z-values + // for the first patternLength positions of the text region. Beyond that, + // Z-array values are computed silently and only match detection emits steps. + const tracker = new StringTracker(combined, pattern, Z_ALGORITHM_LINE_MAP); + tracker.initialize({ text, pattern, combined }); + + const zArray = new Array(combinedLength).fill(0); + let windowLeft = 0; + let windowRight = 0; + const textRegionOffset = patternLength + 1; + + for (let pos = 1; pos < combinedLength; pos++) { + const textPos = pos - textRegionOffset; + const isInTextRegion = textPos >= 0; + // Only emit tracker steps for the first patternLength positions of the text region + // because the StringTracker failure table has exactly patternLength slots. + const emitZStep = isInTextRegion && textPos < patternLength; + + if (emitZStep) { + tracker.computingFailureEntry(textPos, { pos, windowLeft, windowRight }); + } + + if (pos < windowRight) { + zArray[pos] = Math.min(windowRight - pos, zArray[pos - windowLeft]!); + } + + while ( + pos + (zArray[pos] ?? 0) < combinedLength && + combined[zArray[pos] ?? 0] === combined[pos + (zArray[pos] ?? 0)] + ) { + zArray[pos] = (zArray[pos] ?? 0) + 1; + } + + if (pos + (zArray[pos] ?? 0) > windowRight) { + windowLeft = pos; + windowRight = pos + (zArray[pos] ?? 0); + } + + if (emitZStep) { + const zValue = zArray[pos] ?? 0; + tracker.setFailureEntry(textPos, zValue, { pos, zValue, windowLeft, windowRight }); + } + + // Match found when Z[pos] equals pattern length — pattern starts at textPos in original text. + if ((zArray[pos] ?? 0) === patternLength && isInTextRegion) { + const matchStart = textPos; + // Use the first and last character positions in the combined string to show the match. + tracker.compareChars(pos, 0, matchStart, { pos, matchStart }); + tracker.charMatch(pos, 0, { pos, matchStart }); + tracker.recordMatch(pos, { matchStart }); + tracker.complete({ result: matchStart }); + return tracker.getSteps(); + } + } + + tracker.complete({ result: -1 }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/pattern-matching/z-algorithm/z-algorithm.test.ts b/src/algorithms/strings/pattern-matching/z-algorithm/z-algorithm.test.ts new file mode 100644 index 00000000..2bdd488d --- /dev/null +++ b/src/algorithms/strings/pattern-matching/z-algorithm/z-algorithm.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { zAlgorithm } from "./sources/z-algorithm.ts?fn"; + +describe("zAlgorithm", () => { + it("finds the pattern at the start of the text", () => { + expect(zAlgorithm("ABCDEF", "ABC")).toBe(0); + }); + + it("finds the pattern in the middle of the text", () => { + expect(zAlgorithm("AABXAABXCAABXAABXAY", "AABXAAB")).toBe(0); + }); + + it("finds the pattern near the end of the text", () => { + expect(zAlgorithm("XYZAABXAAB", "AABXAAB")).toBe(3); + }); + + it("finds the pattern at the end of the text", () => { + expect(zAlgorithm("XYZABC", "ABC")).toBe(3); + }); + + it("returns -1 when the pattern is not present", () => { + expect(zAlgorithm("ABCDEFG", "XYZ")).toBe(-1); + }); + + it("handles a single-character pattern that exists", () => { + expect(zAlgorithm("HELLO", "L")).toBe(2); + }); + + it("handles a single-character pattern that does not exist", () => { + expect(zAlgorithm("HELLO", "Z")).toBe(-1); + }); + + it("returns 0 for an empty pattern", () => { + expect(zAlgorithm("HELLO", "")).toBe(0); + }); + + it("handles text equal to the pattern", () => { + expect(zAlgorithm("ABCD", "ABCD")).toBe(0); + }); + + it("returns -1 when pattern is longer than text", () => { + expect(zAlgorithm("AB", "ABCD")).toBe(-1); + }); + + it("handles repeated characters correctly", () => { + expect(zAlgorithm("AAAAAB", "AAAB")).toBe(2); + }); + + it("finds the first of multiple occurrences", () => { + expect(zAlgorithm("ABABABAB", "ABAB")).toBe(0); + }); +}); diff --git a/src/algorithms/strings/transformation/longest-common-prefix/LongestCommonPrefixPipeline.stories.tsx b/src/algorithms/strings/transformation/longest-common-prefix/LongestCommonPrefixPipeline.stories.tsx new file mode 100644 index 00000000..b8cdea4e --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/LongestCommonPrefixPipeline.stories.tsx @@ -0,0 +1,49 @@ +/** + * Storybook stories for the Longest Common Prefix algorithm pipeline. + * Uses the real step generator with ["flower","flow","flight"], + * rendering the TransformVisualizer at key scanning states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TransformVisualState } from "@/types"; +import { generateLongestCommonPrefixSteps } from "./step-generator"; +import TransformVisualizer from "@/components/visualization/TransformVisualizer"; + +const steps = generateLongestCommonPrefixSteps({ + words: ["flower", "flow", "flight"], +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Longest Common Prefix", + component: TransformVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — input characters loaded, no column scanned yet */ +export const Initialize: Story = { + args: { + visualState: steps[0]!.visualState as TransformVisualState, + }, +}; + +/** Mid-scan — first column comparison in progress */ +export const MidScan: Story = { + args: { + visualState: steps[Math.floor(steps.length / 2)]!.visualState as TransformVisualState, + }, +}; + +/** Final state — common prefix "fl" written to output */ +export const PrefixFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TransformVisualState, + }, +}; diff --git a/src/algorithms/strings/transformation/longest-common-prefix/educational.ts b/src/algorithms/strings/transformation/longest-common-prefix/educational.ts new file mode 100644 index 00000000..729ea053 --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/educational.ts @@ -0,0 +1,68 @@ +/** Educational content for Longest Common Prefix algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const longestCommonPrefixEducational: EducationalContent = { + overview: + "**Longest Common Prefix** finds the longest string that is a prefix of every string in an input array.\n\n" + + 'A prefix is any leading substring — `"fl"` is a prefix of both `"flower"` and `"flight"`. ' + + "The algorithm scans characters vertically (column by column across all words simultaneously) rather than comparing pairs of strings, " + + "stopping as soon as any column produces a mismatch or any string runs out of characters.", + + howItWorks: + "The algorithm treats the first word as the candidate prefix and scans one column at a time.\n\n" + + "For each column index `c` starting from `0`:\n\n" + + "1. **Read** `firstWord[c]` — the reference character for this column.\n" + + "2. **Compare** `words[i][c]` for every other word `i`. If `words[i][c]` differs from the reference, or the word is shorter than `c+1`, stop immediately.\n" + + "3. **Extend** the prefix length by 1 if all words matched this column.\n\n" + + "The loop terminates either on a mismatch or after exhausting the first word's length.\n\n" + + "```\n" + + " f l o w e r\n" + + " f l o w\n" + + " f l i g h t\n" + + " ^ ^ ^\n" + + "col 0: f=f=f ✓\n" + + "col 1: l=l=l ✓\n" + + 'col 2: o≠i ✗ → prefix = "fl"\n' + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n * m)`**\n\n" + + "Where `n` is the number of strings and `m` is the length of the shortest string. " + + "In the worst case (all strings are identical), every character of every string is visited once.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "Only a handful of integer indices and a single character variable are used during scanning. " + + "The output prefix is a slice of the input — no additional buffer proportional to input size is allocated.", + + bestAndWorstCase: + "**Best case — mismatch at column 0:** `O(n)` — the first character differs across words, so only one pass through all `n` strings is needed.\n\n" + + "**Worst case — all strings are identical:** `O(n * m)` — every character in every string is compared before the loop terminates naturally at the end of the first word.\n\n" + + "An empty input array or a single-element array short-circuits to `O(1)` — no character comparisons are made.", + + realWorldUses: [ + "**Autocomplete engines:** Finding the longest common prefix of all matching entries determines what can be inserted automatically into a search box without ambiguity.", + "**File system path compression:** Tools like `git` and shell tab-completion use common prefix detection to shorten displayed paths.", + "**Trie construction:** Longest common prefix is the foundational operation when building or querying prefix trees (tries) for dictionary lookups.", + "**DNS resolution caching:** Routers group destination addresses by common prefix to compress routing tables (longest prefix matching).", + "**Data deduplication:** Storage systems identify shared prefixes across sorted keys to reduce index storage in columnar databases.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(1) auxiliary space — no secondary buffers or data structures required.", + "Early termination — stops at the first mismatch, often visiting far fewer characters than the theoretical worst case.", + "Simple and predictable — a single nested loop with no recursion or backtracking.", + ], + limitations: [ + "Always O(n*m) in the worst case (identical strings) — no pruning possible when all strings fully agree.", + "Operates on the first word as a reference — if the shortest word is not first, the outer loop runs longer than necessary without pre-sorting.", + "Not Unicode-aware at the code-unit level — multi-byte graphemes (emoji, surrogate pairs) require grapheme-cluster splitting for correct results.", + ], + }, + + whenToUseIt: + "Use Longest Common Prefix when you need the shared leading substring of a collection of strings and want minimal memory overhead. " + + "It is the canonical interview solution for prefix detection and the natural building block for trie-based algorithms.\n\n" + + "Avoid it for very large string sets where sorting first and comparing only the first and last strings (`O(n log n + m)`) would be faster in practice. " + + "Also avoid the naive implementation for Unicode text with multi-code-unit characters — use a grapheme-aware library instead.", +}; diff --git a/src/algorithms/strings/transformation/longest-common-prefix/index.ts b/src/algorithms/strings/transformation/longest-common-prefix/index.ts new file mode 100644 index 00000000..b6f34a26 --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Longest Common Prefix — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { longestCommonPrefix } from "./sources/longest-common-prefix.ts?fn"; +import { generateLongestCommonPrefixSteps } from "./step-generator"; +import type { LongestCommonPrefixInput } from "./step-generator"; +import { longestCommonPrefixEducational } from "./educational"; + +import typescriptSource from "./sources/longest-common-prefix.ts?raw"; +import pythonSource from "./sources/longest-common-prefix.py?raw"; +import javaSource from "./sources/LongestCommonPrefix.java?raw"; + +function executeLongestCommonPrefix(input: LongestCommonPrefixInput): string { + return longestCommonPrefix(input.words) as string; +} + +const longestCommonPrefixDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.LONGEST_COMMON_PREFIX!, + name: "Longest Common Prefix", + category: CATEGORY.STRINGS!, + technique: "transformation", + description: + "Find the longest prefix shared by all strings using vertical scanning — compare characters column by column in O(n*m) time", + timeComplexity: { + best: "O(n*m)", + average: "O(n*m)", + worst: "O(n*m)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { words: ["flower", "flow", "flight"] }, + }, + execute: executeLongestCommonPrefix, + generateSteps: generateLongestCommonPrefixSteps, + educational: longestCommonPrefixEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(longestCommonPrefixDefinition); diff --git a/src/algorithms/strings/transformation/longest-common-prefix/longest-common-prefix.test.ts b/src/algorithms/strings/transformation/longest-common-prefix/longest-common-prefix.test.ts new file mode 100644 index 00000000..147b9a1c --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/longest-common-prefix.test.ts @@ -0,0 +1,46 @@ +/** Correctness tests for the longestCommonPrefix pure function. */ + +import { describe, it, expect } from "vitest"; +import { longestCommonPrefix } from "./sources/longest-common-prefix.ts?fn"; + +describe("longestCommonPrefix", () => { + it('returns "fl" for ["flower","flow","flight"]', () => { + expect(longestCommonPrefix(["flower", "flow", "flight"])).toBe("fl"); + }); + + it('returns "" for ["dog","racecar","car"] — no common prefix', () => { + expect(longestCommonPrefix(["dog", "racecar", "car"])).toBe(""); + }); + + it('returns "" for [""] — single empty string', () => { + expect(longestCommonPrefix([""])).toBe(""); + }); + + it("returns the string itself for a single-element array", () => { + expect(longestCommonPrefix(["hello"])).toBe("hello"); + }); + + it("returns empty string for an empty array", () => { + expect(longestCommonPrefix([])).toBe(""); + }); + + it('returns "" when one word is empty', () => { + expect(longestCommonPrefix(["abc", ""])).toBe(""); + }); + + it("returns the shared prefix when all strings are identical", () => { + expect(longestCommonPrefix(["abc", "abc", "abc"])).toBe("abc"); + }); + + it("returns the full first word when it is a prefix of all others", () => { + expect(longestCommonPrefix(["ab", "abc", "abcd"])).toBe("ab"); + }); + + it('returns "a" for ["ab","a"]', () => { + expect(longestCommonPrefix(["ab", "a"])).toBe("a"); + }); + + it("handles two-word arrays with partial overlap", () => { + expect(longestCommonPrefix(["interview", "internal"])).toBe("inter"); + }); +}); diff --git a/src/algorithms/strings/transformation/longest-common-prefix/sources/LongestCommonPrefix.java b/src/algorithms/strings/transformation/longest-common-prefix/sources/LongestCommonPrefix.java new file mode 100644 index 00000000..91804bad --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/sources/LongestCommonPrefix.java @@ -0,0 +1,28 @@ +// Longest Common Prefix — vertical scanning column by column across all strings. +// Returns the longest prefix shared by every word in the input array. +// Time: O(n*m) where n = number of strings, m = min string length Space: O(1) + +public class LongestCommonPrefix { + + public static String longestCommonPrefix(String[] words) { + if (words.length == 0) return ""; // @step:initialize + + int prefixLength = 0; // @step:initialize + String firstWord = words[0]; // @step:initialize + + for (int columnIndex = 0; columnIndex < firstWord.length(); columnIndex++) { + char currentChar = firstWord.charAt(columnIndex); // @step:read-char + + for (int wordIndex = 1; wordIndex < words.length; wordIndex++) { + String word = words[wordIndex]; // @step:read-char + if (columnIndex >= word.length() || word.charAt(columnIndex) != currentChar) { // @step:read-char + return firstWord.substring(0, prefixLength); // @step:complete + } + } + + prefixLength++; // @step:write-char + } + + return firstWord.substring(0, prefixLength); // @step:complete + } +} diff --git a/src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.py b/src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.py new file mode 100644 index 00000000..1b177230 --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.py @@ -0,0 +1,23 @@ +# Longest Common Prefix — vertical scanning column by column across all strings. +# Returns the longest prefix shared by every word in the input list. +# Time: O(n*m) where n = number of strings, m = min string length Space: O(1) + + +def longest_common_prefix(words: list[str]) -> str: + if not words: # @step:initialize + return "" # @step:initialize + + prefix_length = 0 # @step:initialize + first_word = words[0] # @step:initialize + + for column_index in range(len(first_word)): + current_char = first_word[column_index] # @step:read-char + + for word_index in range(1, len(words)): + word = words[word_index] # @step:read-char + if column_index >= len(word) or word[column_index] != current_char: # @step:read-char + return first_word[:prefix_length] # @step:complete + + prefix_length += 1 # @step:write-char + + return first_word[:prefix_length] # @step:complete diff --git a/src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.ts b/src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.ts new file mode 100644 index 00000000..a7a86b7b --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/sources/longest-common-prefix.ts @@ -0,0 +1,28 @@ +// Longest Common Prefix — vertical scanning column by column across all strings. +// Returns the longest prefix shared by every word in the input array. +// Time: O(n*m) where n = number of strings, m = min string length Space: O(1) + +export function longestCommonPrefix(words: string[]): string { + if (words.length === 0) return ""; // @step:initialize + + let prefixLength = 0; // @step:initialize + + const firstWord = words[0] ?? ""; // @step:initialize + + for (let columnIndex = 0; columnIndex < firstWord.length; columnIndex++) { + const currentChar = firstWord[columnIndex]; // @step:read-char + + for (let wordIndex = 1; wordIndex < words.length; wordIndex++) { + const word = words[wordIndex] ?? ""; // @step:read-char + const wordChar = word[columnIndex]; // @step:read-char + + if (wordChar !== currentChar) { + return firstWord.slice(0, prefixLength); // @step:complete + } + } + + prefixLength++; // @step:write-char + } + + return firstWord.slice(0, prefixLength); // @step:complete +} diff --git a/src/algorithms/strings/transformation/longest-common-prefix/step-generator.test.ts b/src/algorithms/strings/transformation/longest-common-prefix/step-generator.test.ts new file mode 100644 index 00000000..fd96dea5 --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/step-generator.test.ts @@ -0,0 +1,79 @@ +/** Step generation tests for Longest Common Prefix. */ + +import { describe, it, expect } from "vitest"; +import { generateLongestCommonPrefixSteps } from "./step-generator"; + +describe("generateLongestCommonPrefixSteps", () => { + it("produces steps for the default input", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-transform visual states throughout", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-transform"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("produces write-char steps for each matched column", () => { + // ["flower","flow","flight"] → prefix "fl" → 2 write-char steps + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + expect(writeSteps.length).toBe(2); + }); + + it("produces no write-char steps when there is no common prefix", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["dog", "racecar", "car"] }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + expect(writeSteps.length).toBe(0); + }); + + it("produces only initialize and complete steps for an empty array", () => { + const steps = generateLongestCommonPrefixSteps({ words: [] }); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + expect(steps.length).toBe(2); + }); + + it("produces read-char steps during column comparison", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + const readSteps = steps.filter((step) => step.type === "read-char"); + expect(readSteps.length).toBeGreaterThan(0); + }); + + it("complete step variables carry the correct result for default input", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["flower", "flow", "flight"] }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe("fl"); + }); + + it("complete step result is empty string when no prefix exists", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["dog", "racecar", "car"] }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe(""); + }); + + it("write-char count equals prefix length for identical strings", () => { + const steps = generateLongestCommonPrefixSteps({ words: ["abc", "abc"] }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + expect(writeSteps.length).toBe(3); + }); +}); diff --git a/src/algorithms/strings/transformation/longest-common-prefix/step-generator.ts b/src/algorithms/strings/transformation/longest-common-prefix/step-generator.ts new file mode 100644 index 00000000..842a6db0 --- /dev/null +++ b/src/algorithms/strings/transformation/longest-common-prefix/step-generator.ts @@ -0,0 +1,82 @@ +/** Step generator for Longest Common Prefix — produces ExecutionStep[] using TransformTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TransformTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const LONGEST_COMMON_PREFIX_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.LONGEST_COMMON_PREFIX!); + +export interface LongestCommonPrefixInput { + words: string[]; +} + +export function generateLongestCommonPrefixSteps(input: LongestCommonPrefixInput): ExecutionStep[] { + const { words } = input; + + // Join words with separator for display as a single TransformTracker string + const displayInput = words.join(" | "); + const tracker = new TransformTracker(displayInput, LONGEST_COMMON_PREFIX_LINE_MAP); + + tracker.initialize({ words, wordCount: words.length }); + + if (words.length === 0) { + tracker.complete({ result: "" }); + return tracker.getSteps(); + } + + const firstWord = words[0] ?? ""; + let prefixLength = 0; + + for (let columnIndex = 0; columnIndex < firstWord.length; columnIndex++) { + const currentChar = firstWord[columnIndex] ?? ""; + + // Read the character from the first word at current column + tracker.readChar(columnIndex, { + columnIndex, + currentChar, + prefixSoFar: firstWord.slice(0, prefixLength), + }); + + let mismatchFound = false; + + for (let wordIndex = 1; wordIndex < words.length; wordIndex++) { + const word = words[wordIndex] ?? ""; + const wordChar = word[columnIndex]; + + // Read corresponding character from each subsequent word + // Map position in display string: each word starts at its offset past separators + const wordOffset = words.slice(0, wordIndex).join(" | ").length + 3; // " | " separator = 3 chars + const charPosition = wordOffset + columnIndex; + + tracker.readChar(charPosition, { + columnIndex, + wordIndex, + currentChar, + wordChar: wordChar ?? "(end)", + match: wordChar === currentChar, + }); + + if (wordChar !== currentChar) { + mismatchFound = true; + break; + } + } + + if (mismatchFound) { + tracker.complete({ result: firstWord.slice(0, prefixLength) }); + return tracker.getSteps(); + } + + // All words matched this column — extend the prefix + prefixLength++; + tracker.writeChar(currentChar, { + columnIndex, + prefixLength, + prefixSoFar: firstWord.slice(0, prefixLength), + }); + } + + tracker.complete({ result: firstWord.slice(0, prefixLength) }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/transformation/reverse-string/ReverseStringPipeline.stories.tsx b/src/algorithms/strings/transformation/reverse-string/ReverseStringPipeline.stories.tsx new file mode 100644 index 00000000..84fc5b26 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/ReverseStringPipeline.stories.tsx @@ -0,0 +1,54 @@ +/** + * Storybook stories for the Reverse String algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TransformVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TransformVisualState } from "@/types"; +import { generateReverseStringSteps } from "./step-generator"; +import TransformVisualizer from "@/components/visualization/TransformVisualizer"; + +const steps = generateReverseStringSteps({ text: "hello" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Reverse String", + component: TransformVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — both pointers at the ends, no swaps yet */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TransformVisualState, + }, +}; + +/** First read — left pointer has read its character */ +export const ReadingChars: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.25)]!.visualState as TransformVisualState, + }, +}; + +/** Mid-execution — first swap complete, pointers advancing inward */ +export const SwapInProgress: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.55)]!.visualState as TransformVisualState, + }, +}; + +/** Final state — all swaps complete, string fully reversed */ +export const Reversed: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TransformVisualState, + }, +}; diff --git a/src/algorithms/strings/transformation/reverse-string/educational.ts b/src/algorithms/strings/transformation/reverse-string/educational.ts new file mode 100644 index 00000000..65af0734 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/educational.ts @@ -0,0 +1,61 @@ +import type { EducationalContent } from "@/types"; + +export const reverseStringEducational: EducationalContent = { + overview: + "**Reverse String** inverts the order of characters in a string using a two-pointer technique.\n\n" + + "One pointer starts at the left end and another at the right end. They march toward each other, swapping the characters they point to at each step, until the pointers meet in the middle. The result is the original string with all characters in reversed order.", + + howItWorks: + "The algorithm maintains two index pointers — `leftIndex` starting at `0` and `rightIndex` starting at `text.length - 1`.\n\n" + + "Each iteration of the loop:\n\n" + + "1. **Read** `chars[leftIndex]` and `chars[rightIndex]`.\n" + + "2. **Swap** the two characters in place.\n" + + "3. **Advance** `leftIndex` forward by one and `rightIndex` backward by one.\n\n" + + "The loop terminates when `leftIndex >= rightIndex`, meaning all pairs have been swapped.\n\n" + + "```\n" + + "Input: h e l l o\n" + + " ^ ^\n" + + "Step 1: o e l l h (swap h ↔ o)\n" + + " ^ ^\n" + + "Step 2: o l l e h (swap e ↔ l)\n" + + " ^\n" + + "Step 3: (centre reached — done)\n" + + "Output: o l l e h\n" + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Every character is visited exactly once — the two pointers together traverse the full string, each moving `n/2` steps.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "Swaps are performed directly on the character array with no auxiliary buffer. Only the two pointer variables and a single temporary swap value are used, regardless of input length.", + + bestAndWorstCase: + "**Best case — single character or empty string:** `O(1)` — no iterations occur because `leftIndex >= rightIndex` immediately.\n\n" + + "**Worst case — any string of length `n`:** `O(n)` — `n/2` swaps are always required. There is no early-exit condition; the algorithm always processes every pair.\n\n" + + "Because best, average, and worst cases all have the same linear bound, Reverse String has a flat performance profile.", + + realWorldUses: [ + "**Palindrome checking:** Reversing a string is the first step in a naive palindrome check — compare the reversed copy to the original.", + "**Text processing pipelines:** Reversing tokens or substrings is a building block in many encoding and obfuscation schemes (e.g., simple ciphers, base-conversion utilities).", + "**Interview fundamentals:** Reverse String is a canonical two-pointer warm-up problem, testing pointer manipulation and in-place mutation.", + "**Undo stacks:** Reversing an operation sequence restores a previous state — conceptually identical to reversing an array of commands.", + "**Language internals:** String reversal is used inside standard library implementations for number-to-string conversion (e.g., digits are accumulated in reverse order, then reversed once).", + ], + + strengthsAndLimitations: { + strengths: [ + "O(1) auxiliary space — no second string is allocated during the swap phase.", + "Simple to implement correctly and easy to reason about.", + "Cache-friendly — sequential memory access pattern.", + ], + limitations: [ + "Naive implementation breaks Unicode multi-code-unit characters (e.g., emoji, surrogate pairs) — requires grapheme-cluster awareness for correct Unicode reversal.", + "Not applicable to immutable string types without first copying to a mutable buffer (O(n) allocation).", + "No partial reversal — if only a substring needs reversing, the loop bounds must be adjusted manually.", + ], + }, + + whenToUseIt: + "Use Reverse String whenever you need to invert character order with minimal memory overhead. It is the canonical solution for in-place string reversal interview problems.\n\n" + + "Avoid it when working with Unicode text that contains multi-code-unit graphemes (emoji, combining characters, surrogate pairs) without a grapheme-aware splitting step — a byte-level or code-unit-level reversal will corrupt such sequences.", +}; diff --git a/src/algorithms/strings/transformation/reverse-string/index.ts b/src/algorithms/strings/transformation/reverse-string/index.ts new file mode 100644 index 00000000..673303b3 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/index.ts @@ -0,0 +1,45 @@ +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { reverseString } from "./sources/reverse-string.ts?fn"; +import { generateReverseStringSteps } from "./step-generator"; +import type { ReverseStringInput } from "./step-generator"; +import { reverseStringEducational } from "./educational"; + +import typescriptSource from "./sources/reverse-string.ts?raw"; +import pythonSource from "./sources/reverse-string.py?raw"; +import javaSource from "./sources/ReverseString.java?raw"; + +function executeReverseString(input: ReverseStringInput): string { + return reverseString(input.text) as string; +} + +const reverseStringDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.REVERSE_STRING!, + name: "Reverse String", + category: CATEGORY.STRINGS!, + technique: "transformation", + description: + "Reverse a string in-place using a two-pointer swap, moving from both ends toward the center in O(n) time", + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "hello" }, + }, + execute: executeReverseString, + generateSteps: generateReverseStringSteps, + educational: reverseStringEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(reverseStringDefinition); diff --git a/src/algorithms/strings/transformation/reverse-string/reverse-string.test.ts b/src/algorithms/strings/transformation/reverse-string/reverse-string.test.ts new file mode 100644 index 00000000..55903797 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/reverse-string.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { reverseString } from "./sources/reverse-string.ts?fn"; + +describe("reverseString", () => { + it("reverses a standard word", () => { + expect(reverseString("hello")).toBe("olleh"); + }); + + it("returns a single character unchanged", () => { + expect(reverseString("a")).toBe("a"); + }); + + it("returns an empty string unchanged", () => { + expect(reverseString("")).toBe(""); + }); + + it("reverses a two-character string", () => { + expect(reverseString("ab")).toBe("ba"); + }); + + it("reverses a palindrome to itself", () => { + expect(reverseString("racecar")).toBe("racecar"); + }); + + it("reverses a string with spaces", () => { + expect(reverseString("hello world")).toBe("dlrow olleh"); + }); + + it("reverses a string of repeated characters", () => { + expect(reverseString("aaaa")).toBe("aaaa"); + }); + + it("reverses a longer sentence", () => { + expect(reverseString("algorithm")).toBe("mhtirogla"); + }); +}); diff --git a/src/algorithms/strings/transformation/reverse-string/sources/ReverseString.java b/src/algorithms/strings/transformation/reverse-string/sources/ReverseString.java new file mode 100644 index 00000000..5b704262 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/sources/ReverseString.java @@ -0,0 +1,26 @@ +// Reverse String — two-pointer in-place swap on a character array. +// Returns the reversed version of the input string. +// Time: O(n) Space: O(1) auxiliary (O(n) for the output string) + +public class ReverseString { + + public static String reverseString(String text) { + char[] chars = text.toCharArray(); // @step:initialize + + int leftIndex = 0; // @step:initialize + int rightIndex = chars.length - 1; // @step:initialize + + while (leftIndex < rightIndex) { + char leftChar = chars[leftIndex]; // @step:read-char + char rightChar = chars[rightIndex]; // @step:read-char + + chars[leftIndex] = rightChar; // @step:swap-pointers + chars[rightIndex] = leftChar; // @step:swap-pointers + + leftIndex++; // @step:visit + rightIndex--; // @step:visit + } + + return new String(chars); // @step:complete + } +} diff --git a/src/algorithms/strings/transformation/reverse-string/sources/reverse-string.py b/src/algorithms/strings/transformation/reverse-string/sources/reverse-string.py new file mode 100644 index 00000000..98f09657 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/sources/reverse-string.py @@ -0,0 +1,22 @@ +# Reverse String — two-pointer in-place swap on a character list. +# Returns the reversed version of the input string. +# Time: O(n) Space: O(1) auxiliary (O(n) for the output string) + + +def reverse_string(text: str) -> str: + chars = list(text) # @step:initialize + + left_index = 0 # @step:initialize + right_index = len(chars) - 1 # @step:initialize + + while left_index < right_index: + left_char = chars[left_index] # @step:read-char + right_char = chars[right_index] # @step:read-char + + chars[left_index] = right_char # @step:swap-pointers + chars[right_index] = left_char # @step:swap-pointers + + left_index += 1 # @step:visit + right_index -= 1 # @step:visit + + return "".join(chars) # @step:complete diff --git a/src/algorithms/strings/transformation/reverse-string/sources/reverse-string.ts b/src/algorithms/strings/transformation/reverse-string/sources/reverse-string.ts new file mode 100644 index 00000000..8815cf5b --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/sources/reverse-string.ts @@ -0,0 +1,23 @@ +// Reverse String — two-pointer in-place swap on a character array. +// Returns the reversed version of the input string. +// Time: O(n) Space: O(1) auxiliary (O(n) for the output string) + +export function reverseString(text: string): string { + const chars = text.split(""); // @step:initialize + + let leftIndex = 0; // @step:initialize + let rightIndex = chars.length - 1; // @step:initialize + + while (leftIndex < rightIndex) { + const leftChar = chars[leftIndex]; // @step:read-char + const rightChar = chars[rightIndex]; // @step:read-char + + chars[leftIndex] = rightChar ?? ""; // @step:swap-pointers + chars[rightIndex] = leftChar ?? ""; // @step:swap-pointers + + leftIndex++; // @step:visit + rightIndex--; // @step:visit + } + + return chars.join(""); // @step:complete +} diff --git a/src/algorithms/strings/transformation/reverse-string/step-generator.test.ts b/src/algorithms/strings/transformation/reverse-string/step-generator.test.ts new file mode 100644 index 00000000..4e343222 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/step-generator.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { generateReverseStringSteps } from "./step-generator"; + +describe("generateReverseStringSteps", () => { + it("produces steps for the default input", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-transform visual states throughout", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-transform"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("emits swap-pointers steps for each character pair", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + const swapSteps = steps.filter((step) => step.type === "swap-pointers"); + // "hello" has 2 swaps (h↔o, e↔l), middle 'l' stays + expect(swapSteps.length).toBe(2); + }); + + it("emits read-char steps before each swap", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + const readSteps = steps.filter((step) => step.type === "read-char"); + // Two reads per swap iteration: 2 swaps × 2 reads = 4 + expect(readSteps.length).toBe(4); + }); + + it("produces no swap steps for an empty string", () => { + const steps = generateReverseStringSteps({ text: "" }); + const swapSteps = steps.filter((step) => step.type === "swap-pointers"); + expect(swapSteps.length).toBe(0); + }); + + it("produces no swap steps for a single character", () => { + const steps = generateReverseStringSteps({ text: "a" }); + const swapSteps = steps.filter((step) => step.type === "swap-pointers"); + expect(swapSteps.length).toBe(0); + }); + + it("produces one swap step for a two-character string", () => { + const steps = generateReverseStringSteps({ text: "ab" }); + const swapSteps = steps.filter((step) => step.type === "swap-pointers"); + expect(swapSteps.length).toBe(1); + }); + + it("reflects the correct swap count in step metrics", () => { + const steps = generateReverseStringSteps({ text: "hello" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.metrics.swaps).toBe(2); + }); +}); diff --git a/src/algorithms/strings/transformation/reverse-string/step-generator.ts b/src/algorithms/strings/transformation/reverse-string/step-generator.ts new file mode 100644 index 00000000..c27a5ffb --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-string/step-generator.ts @@ -0,0 +1,39 @@ +/** Step generator for Reverse String — produces ExecutionStep[] using TransformTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TransformTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const REVERSE_STRING_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.REVERSE_STRING!); + +export interface ReverseStringInput { + text: string; +} + +export function generateReverseStringSteps(input: ReverseStringInput): ExecutionStep[] { + const { text } = input; + const tracker = new TransformTracker(text, REVERSE_STRING_LINE_MAP); + + tracker.initialize({ text, leftIndex: 0, rightIndex: text.length - 1 }); + + let leftIndex = 0; + let rightIndex = text.length - 1; + + while (leftIndex < rightIndex) { + // Read both characters before swapping + tracker.readChar(leftIndex, { leftIndex, rightIndex }); + tracker.readChar(rightIndex, { leftIndex, rightIndex }); + + // Swap the characters at the two pointers + tracker.swapChars(leftIndex, rightIndex, { leftIndex, rightIndex }); + + // Advance the pointers inward + leftIndex++; + rightIndex--; + tracker.advancePointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + } + + tracker.complete({ result: text.split("").reverse().join("") }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/transformation/reverse-words/ReverseWordsPipeline.stories.tsx b/src/algorithms/strings/transformation/reverse-words/ReverseWordsPipeline.stories.tsx new file mode 100644 index 00000000..0613cce4 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/ReverseWordsPipeline.stories.tsx @@ -0,0 +1,47 @@ +/** + * Storybook stories for the Reverse Words in a String algorithm pipeline. + * Uses the real step generator with "the sky is blue" as the canonical input, + * rendering the TransformVisualizer at the initialization, mid-reversal, and final states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TransformVisualState } from "@/types"; +import { generateReverseWordsSteps } from "./step-generator"; +import TransformVisualizer from "@/components/visualization/TransformVisualizer"; + +const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Reverse Words", + component: TransformVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — input characters loaded, splitting phase about to begin */ +export const Initialize: Story = { + args: { + visualState: steps[0]!.visualState as TransformVisualState, + }, +}; + +/** Mid-reversal — words are being swapped from the outer ends inward */ +export const MidReversal: Story = { + args: { + visualState: steps[Math.floor(steps.length / 2)]!.visualState as TransformVisualState, + }, +}; + +/** Final state — all words reversed, output buffer contains "blue is sky the" */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TransformVisualState, + }, +}; diff --git a/src/algorithms/strings/transformation/reverse-words/educational.ts b/src/algorithms/strings/transformation/reverse-words/educational.ts new file mode 100644 index 00000000..f5584621 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/educational.ts @@ -0,0 +1,77 @@ +/** Educational content for Reverse Words in a String. */ + +import type { EducationalContent } from "@/types"; + +export const reverseWordsEducational: EducationalContent = { + overview: + "**Reverse Words in a String** rearranges the words of a sentence so that the last word comes first, " + + "the second-to-last word comes second, and so on.\n\n" + + "The algorithm also normalises whitespace: it trims leading and trailing spaces and collapses any " + + "sequence of internal spaces down to a single space, so the output is always clean regardless of how " + + "messily the input was formatted.", + + howItWorks: + "The algorithm has two logical phases.\n\n" + + "**Phase 1 — Split:** The input string is trimmed and split on whitespace. " + + "Each non-empty token becomes one element of a `words` array.\n\n" + + "**Phase 2 — Reverse:** A two-pointer swap mirrors the `words` array in place:\n\n" + + "1. `leftIndex` starts at `0`; `rightIndex` starts at `words.length - 1`.\n" + + "2. The words at the two pointers are swapped.\n" + + "3. `leftIndex` advances forward; `rightIndex` moves backward.\n" + + "4. The loop stops when `leftIndex >= rightIndex`.\n\n" + + "Finally, the reversed array is joined with single spaces to form the result string.\n\n" + + "```\n" + + 'Input: "the sky is blue"\n' + + "Words: [the, sky, is, blue]\n" + + " ^ ^\n" + + "Step 1: [blue, sky, is, the] (swap the ↔ blue)\n" + + " ^ ^\n" + + "Step 2: [blue, is, sky, the] (swap sky ↔ is)\n" + + 'Output: "blue is sky the"\n' + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Splitting the string scans every character once — `O(n)`. " + + "The two-pointer reversal visits each word once — `O(w)` where `w ≤ n`. " + + "Joining scans every character once — `O(n)`. Total: `O(n)`.\n\n" + + "**Space Complexity: `O(n)`**\n\n" + + "The `words` array and the output string each hold up to `n` characters. " + + "No extra allocations grow with word count — space is proportional only to input length.", + + bestAndWorstCase: + "**Best case — single word or empty string:** `O(n)` — the split produces one token (or none) " + + "so the pointer loop performs zero swaps, but the input still needs one full scan.\n\n" + + "**Worst case — many short words:** `O(n)` — `w/2` swaps are performed. " + + "There is no early-exit, so every word pair is always processed.\n\n" + + "Because the dominant cost is always the linear scan of the string, best and worst cases " + + "share the same `O(n)` bound.", + + realWorldUses: [ + "**Natural-language processing:** Reversing word order is a preprocessing step in some sentence-embedding pipelines and text augmentation strategies.", + "**Coding interviews:** A canonical two-pointer string problem that tests split/join fluency and awareness of whitespace edge cases.", + "**Cipher design:** Simple transposition ciphers reverse word order as one layer of an encoding scheme.", + "**Command-line tools:** Shell utilities that reverse argument lists apply the same word-reversal logic.", + "**Undo/redo stacks:** Reversing a sequence of tokens restores an earlier state — the same structural operation as reversing an array of commands.", + ], + + strengthsAndLimitations: { + strengths: [ + "Linear time and space — optimal for this problem class.", + "Normalises extra whitespace for free, making output predictable regardless of input formatting.", + "Simple two-phase structure (split → reverse) is easy to reason about and test.", + ], + limitations: [ + "Allocates a new array and a new string — cannot be done truly in-place without O(n²) character shifts in most languages.", + "Split-on-whitespace loses original spacing information; if exact whitespace preservation is required, a different approach is needed.", + "Does not handle word-level unicode edge cases (e.g., words separated by non-breaking spaces U+00A0) without regex adjustments.", + ], + }, + + whenToUseIt: + "Use Reverse Words when you need to invert word order and clean up whitespace in linear time. " + + "It is the standard solution for the LeetCode 151 / similar interview problems.\n\n" + + "Avoid it when the original whitespace must be preserved exactly, or when memory allocation is " + + "severely constrained and an in-place O(1)-space solution is required (which would need a double-reverse " + + "strategy operating at the character level).", +}; diff --git a/src/algorithms/strings/transformation/reverse-words/index.ts b/src/algorithms/strings/transformation/reverse-words/index.ts new file mode 100644 index 00000000..447f67ea --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/index.ts @@ -0,0 +1,48 @@ +/** Registry definition for Reverse Words in a String — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { reverseWords } from "./sources/reverse-words.ts?fn"; +import { generateReverseWordsSteps } from "./step-generator"; +import type { ReverseWordsInput } from "./step-generator"; +import { reverseWordsEducational } from "./educational"; + +import typescriptSource from "./sources/reverse-words.ts?raw"; +import pythonSource from "./sources/reverse-words.py?raw"; +import javaSource from "./sources/ReverseWords.java?raw"; + +function executeReverseWords(input: ReverseWordsInput): string { + return reverseWords(input.text) as string; +} + +const reverseWordsDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.REVERSE_WORDS!, + name: "Reverse Words in a String", + category: CATEGORY.STRINGS!, + technique: "transformation", + description: + "Reverse the order of words in a string by splitting on whitespace, applying a two-pointer swap, " + + "and rejoining — trims extra spaces in O(n) time", + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(n)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "the sky is blue" }, + }, + execute: executeReverseWords, + generateSteps: generateReverseWordsSteps, + educational: reverseWordsEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(reverseWordsDefinition); diff --git a/src/algorithms/strings/transformation/reverse-words/reverse-words.test.ts b/src/algorithms/strings/transformation/reverse-words/reverse-words.test.ts new file mode 100644 index 00000000..d7d7d067 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/reverse-words.test.ts @@ -0,0 +1,46 @@ +/** Correctness tests for the reverseWords function. */ + +import { describe, it, expect } from "vitest"; +import { reverseWords } from "./sources/reverse-words.ts?fn"; + +describe("reverseWords", () => { + it('reverses "the sky is blue" to "blue is sky the"', () => { + expect(reverseWords("the sky is blue")).toBe("blue is sky the"); + }); + + it('trims and reverses " hello world " to "world hello"', () => { + expect(reverseWords(" hello world ")).toBe("world hello"); + }); + + it("collapses multiple spaces between words", () => { + expect(reverseWords("a good example")).toBe("example good a"); + }); + + it("returns a single word unchanged", () => { + expect(reverseWords("hello")).toBe("hello"); + }); + + it("handles a single word surrounded by spaces", () => { + expect(reverseWords(" spaces ")).toBe("spaces"); + }); + + it("reverses two words", () => { + expect(reverseWords("foo bar")).toBe("bar foo"); + }); + + it("reverses three words", () => { + expect(reverseWords("one two three")).toBe("three two one"); + }); + + it("reverses a longer sentence", () => { + expect(reverseWords("let us practice")).toBe("practice us let"); + }); + + it("handles leading spaces only", () => { + expect(reverseWords(" word")).toBe("word"); + }); + + it("handles trailing spaces only", () => { + expect(reverseWords("word ")).toBe("word"); + }); +}); diff --git a/src/algorithms/strings/transformation/reverse-words/sources/ReverseWords.java b/src/algorithms/strings/transformation/reverse-words/sources/ReverseWords.java new file mode 100644 index 00000000..733c4d22 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/sources/ReverseWords.java @@ -0,0 +1,26 @@ +// Reverse Words in a String — split, reverse word order, rejoin with single spaces. +// Trims leading/trailing whitespace and collapses multiple spaces between words. +// Time: O(n) Space: O(n) + +public class ReverseWords { + + public static String reverseWords(String text) { + String[] words = text.trim().split("\\s+"); // @step:initialize + + int leftIndex = 0; // @step:initialize + int rightIndex = words.length - 1; // @step:initialize + + while (leftIndex < rightIndex) { + String leftWord = words[leftIndex]; // @step:read-char + String rightWord = words[rightIndex]; // @step:read-char + + words[leftIndex] = rightWord; // @step:swap-pointers + words[rightIndex] = leftWord; // @step:swap-pointers + + leftIndex++; // @step:visit + rightIndex--; // @step:visit + } + + return String.join(" ", words); // @step:complete + } +} diff --git a/src/algorithms/strings/transformation/reverse-words/sources/reverse-words.py b/src/algorithms/strings/transformation/reverse-words/sources/reverse-words.py new file mode 100644 index 00000000..d1e13315 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/sources/reverse-words.py @@ -0,0 +1,22 @@ +# Reverse Words in a String — split, reverse word order, rejoin with single spaces. +# Trims leading/trailing whitespace and collapses multiple spaces between words. +# Time: O(n) Space: O(n) + + +def reverse_words(text: str) -> str: + words = text.split() # @step:initialize + + left_index = 0 # @step:initialize + right_index = len(words) - 1 # @step:initialize + + while left_index < right_index: + left_word = words[left_index] # @step:read-char + right_word = words[right_index] # @step:read-char + + words[left_index] = right_word # @step:swap-pointers + words[right_index] = left_word # @step:swap-pointers + + left_index += 1 # @step:visit + right_index -= 1 # @step:visit + + return " ".join(words) # @step:complete diff --git a/src/algorithms/strings/transformation/reverse-words/sources/reverse-words.ts b/src/algorithms/strings/transformation/reverse-words/sources/reverse-words.ts new file mode 100644 index 00000000..4fe358a2 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/sources/reverse-words.ts @@ -0,0 +1,23 @@ +// Reverse Words in a String — split, reverse word order, rejoin with single spaces. +// Trims leading/trailing whitespace and collapses multiple spaces between words. +// Time: O(n) Space: O(n) + +export function reverseWords(text: string): string { + const words = text.trim().split(/\s+/); // @step:initialize + + let leftIndex = 0; // @step:initialize + let rightIndex = words.length - 1; // @step:initialize + + while (leftIndex < rightIndex) { + const leftWord = words[leftIndex]; // @step:read-char + const rightWord = words[rightIndex]; // @step:read-char + + words[leftIndex] = rightWord ?? ""; // @step:swap-pointers + words[rightIndex] = leftWord ?? ""; // @step:swap-pointers + + leftIndex++; // @step:visit + rightIndex--; // @step:visit + } + + return words.join(" "); // @step:complete +} diff --git a/src/algorithms/strings/transformation/reverse-words/step-generator.test.ts b/src/algorithms/strings/transformation/reverse-words/step-generator.test.ts new file mode 100644 index 00000000..58efa8a3 --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/step-generator.test.ts @@ -0,0 +1,81 @@ +/** Step generation tests for Reverse Words in a String. */ + +import { describe, it, expect } from "vitest"; +import { generateReverseWordsSteps } from "./step-generator"; + +describe("generateReverseWordsSteps", () => { + it("produces steps for the default input", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-transform visual states throughout", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-transform"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("emits a splitting phase step", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + const visitSteps = steps.filter((step) => step.type === "visit"); + expect(visitSteps.length).toBeGreaterThan(0); + expect(visitSteps.some((step) => step.description.includes("splitting"))).toBe(true); + }); + + it("emits a reversing phase step", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + const visitSteps = steps.filter((step) => step.type === "visit"); + expect(visitSteps.some((step) => step.description.includes("reversing"))).toBe(true); + }); + + it("emits read-char steps for each word boundary", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + const readSteps = steps.filter((step) => step.type === "read-char"); + // 4 words → 4 read-char steps during splitting phase + expect(readSteps.length).toBe(4); + }); + + it("emits write-char steps during the reversing phase", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + expect(writeSteps.length).toBeGreaterThan(0); + }); + + it("produces no read-char steps for a single-word input", () => { + const steps = generateReverseWordsSteps({ text: "hello" }); + const readSteps = steps.filter((step) => step.type === "read-char"); + // Only 1 word, 1 read-char step during splitting + expect(readSteps.length).toBe(1); + }); + + it("produces steps for input with extra whitespace", () => { + const steps = generateReverseWordsSteps({ text: " hello world " }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("final complete step variables contain the reversed result", () => { + const steps = generateReverseWordsSteps({ text: "the sky is blue" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe("blue is sky the"); + }); +}); diff --git a/src/algorithms/strings/transformation/reverse-words/step-generator.ts b/src/algorithms/strings/transformation/reverse-words/step-generator.ts new file mode 100644 index 00000000..4b33294d --- /dev/null +++ b/src/algorithms/strings/transformation/reverse-words/step-generator.ts @@ -0,0 +1,72 @@ +/** Step generator for Reverse Words in a String — produces ExecutionStep[] using TransformTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TransformTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const REVERSE_WORDS_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.REVERSE_WORDS!); + +export interface ReverseWordsInput { + text: string; +} + +export function generateReverseWordsSteps(input: ReverseWordsInput): ExecutionStep[] { + const { text } = input; + + // Build the normalized word list the same way the source function does + const trimmed = text.trim(); + const words = trimmed.length === 0 ? [] : trimmed.split(/\s+/); + + // The tracker works on the full original input string character-by-character for visualization + const tracker = new TransformTracker(text, REVERSE_WORDS_LINE_MAP); + + tracker.initialize({ text, wordCount: words.length }); + + // Phase 1 — splitting: read each word boundary in the input string + tracker.setPhase("splitting", { text, wordCount: words.length }); + + let charIndex = 0; + for (const word of words) { + const wordStart = text.indexOf(word, charIndex); + tracker.readChar(wordStart, { word, wordStart }); + charIndex = wordStart + word.length; + } + + // Phase 2 — reversing: swap words from the outer ends inward + tracker.setPhase("reversing", { wordCount: words.length }); + + let leftIndex = 0; + let rightIndex = words.length - 1; + + while (leftIndex < rightIndex) { + const leftWord = words[leftIndex] ?? ""; + const rightWord = words[rightIndex] ?? ""; + + // Swap in our local array + words[leftIndex] = rightWord; + words[rightIndex] = leftWord; + + // Append the swapped word to the output buffer to show progress + tracker.appendOutput(rightWord + " ", { + leftIndex, + rightIndex, + swapped: `${leftWord} ↔ ${rightWord}`, + }); + tracker.appendOutput(leftWord + " ", { leftIndex, rightIndex }); + + leftIndex++; + rightIndex--; + tracker.advancePointers(leftIndex, rightIndex, { leftIndex, rightIndex }); + } + + // If an odd number of words, emit the middle word + if (leftIndex === rightIndex) { + const middleWord = words[leftIndex] ?? ""; + tracker.appendOutput(middleWord, { middleWord }); + } + + const result = words.join(" "); + tracker.complete({ result }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/transformation/run-length-decoding/RunLengthDecodingPipeline.stories.tsx b/src/algorithms/strings/transformation/run-length-decoding/RunLengthDecodingPipeline.stories.tsx new file mode 100644 index 00000000..a9692ea9 --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/RunLengthDecodingPipeline.stories.tsx @@ -0,0 +1,54 @@ +/** + * Storybook stories for the Run-Length Decoding algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TransformVisualizer at key decoding states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TransformVisualState } from "@/types"; +import { generateRunLengthDecodingSteps } from "./step-generator"; +import TransformVisualizer from "@/components/visualization/TransformVisualizer"; + +const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + +const meta: Meta = { + title: "Algorithm Pipelines/Run-Length Decoding", + component: TransformVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — read pointer at position 0, output buffer empty */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TransformVisualState, + }, +}; + +/** Parsing digits — read pointer is accumulating digit characters for the first group */ +export const ParsingDigits: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.2)]!.visualState as TransformVisualState, + }, +}; + +/** Mid-decode — first group fully appended, pointer advancing to the second group */ +export const MidDecode: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.55)]!.visualState as TransformVisualState, + }, +}; + +/** Final state — all groups decoded, full output buffer visible */ +export const Decoded: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TransformVisualState, + }, +}; diff --git a/src/algorithms/strings/transformation/run-length-decoding/educational.ts b/src/algorithms/strings/transformation/run-length-decoding/educational.ts new file mode 100644 index 00000000..7c92c456 --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/educational.ts @@ -0,0 +1,75 @@ +// Educational content for Run-Length Decoding — all 7 required sections. + +import type { EducationalContent } from "@/types"; + +export const runLengthDecodingEducational: EducationalContent = { + overview: + "**Run-Length Decoding** expands a compressed string back into its original form.\n\n" + + "The compressed format encodes repeated characters as a count followed by the character itself. " + + 'For example, `"3a2b4c"` means three `a`s, two `b`s, and four `c`s, which expands to `"aaabbcccc"`. ' + + "Decoding scans the compressed string from left to right, parsing the number, then repeating the character that many times into the output.", + + howItWorks: + "The algorithm uses a single read pointer that moves through the compressed string from left to right.\n\n" + + "Each iteration of the main loop:\n\n" + + "1. **Collect digits** — advance the pointer while the current character is a digit, accumulating them into a number string.\n" + + "2. **Parse count** — convert the digit string to an integer `repeatCount`.\n" + + "3. **Read letter** — the next character (after all digits) is the letter to repeat.\n" + + "4. **Append output** — push `letter` repeated `repeatCount` times into the output buffer.\n" + + "5. **Advance** — move the read pointer past the letter to start the next group.\n\n" + + "```\n" + + "Input: 3 a 2 b 4 c\n" + + " ^\n" + + "Step 1: read digits → count = 3\n" + + " read letter → 'a'\n" + + " append 'aaa'\n" + + " ^\n" + + "Step 2: read digits → count = 2\n" + + " read letter → 'b'\n" + + " append 'bb'\n" + + " ^\n" + + "Step 3: read digits → count = 4\n" + + " read letter → 'c'\n" + + " append 'cccc'\n" + + "Output: aaabbcccc\n" + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(m)`** where `m` is the length of the decoded output.\n\n" + + "Each character in the output is written exactly once. Parsing the input itself is `O(n)` where `n` is the compressed string length, " + + "but `n ≤ m` for any valid encoding, so the dominant term is `O(m)`.\n\n" + + "**Space Complexity: `O(m)`**\n\n" + + "The output buffer grows to hold every decoded character. No auxiliary data structures beyond the output array and a few scalar variables are needed.", + + bestAndWorstCase: + "**Best case — empty string or all single-character groups:** `O(1)` or proportional to the output — there is no short-circuit; the algorithm always writes every decoded character.\n\n" + + '**Worst case — very long repeated sequences:** `O(m)` — a single group like `"1000a"` still requires writing 1000 characters into the output buffer.\n\n' + + "Because the algorithm must produce every character of the decoded string, the best and worst case are both linear in the output length. " + + "There is no way to short-circuit without changing the contract of the function.", + + realWorldUses: [ + "**Image formats:** BMP and TIFF support RLE (run-length encoding) to compress areas of uniform color; decoding is the inverse operation performed when loading the image.", + "**Network protocols:** Some binary protocols compress repeated bytes with RLE for efficiency; the receiver must decode before processing.", + "**Data transmission:** Run-length coding appears in fax (ITU T.4/T.6) and PCX image formats, where horizontal runs of identical pixels are encoded compactly.", + "**Game assets:** Tile maps and sprite sheets sometimes use RLE to reduce file size; the game engine decodes them at load time.", + "**Interview fundamentals:** Implementing an RLE decoder (and encoder) is a classic string-manipulation interview question that tests pointer control and buffer management.", + ], + + strengthsAndLimitations: { + strengths: [ + "Simple, linear-time implementation with no complex data structures.", + "Streaming-friendly — the decoder can emit characters one group at a time without buffering the entire input.", + "Easily extended to multi-digit counts (e.g. `12a`) without changing the core loop structure.", + ], + limitations: [ + "Only beneficial for inputs with many consecutive repeated characters; random text may actually expand after encoding.", + "Multi-digit counts require careful digit-accumulation logic — a single-digit assumption breaks on counts ≥ 10.", + "Does not handle multi-character sequences or Unicode combining characters without additional logic.", + ], + }, + + whenToUseIt: + "Use Run-Length Decoding whenever you need to expand data that was compressed with run-length encoding, such as loading RLE-compressed image rows, processing fax data, or decoding LeetCode-style encoded strings.\n\n" + + "Avoid it when the input format is not strictly `` — malformed or mixed inputs require pre-validation. " + + "Also avoid naive RLE when the data has low repetition (e.g. random text), since it offers no compression benefit and decoding wastes time producing output that is the same size as the input.", +}; diff --git a/src/algorithms/strings/transformation/run-length-decoding/index.ts b/src/algorithms/strings/transformation/run-length-decoding/index.ts new file mode 100644 index 00000000..994eec7d --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/index.ts @@ -0,0 +1,47 @@ +// Registry entry for Run-Length Decoding — self-registers the algorithm definition on import. + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { runLengthDecoding } from "./sources/run-length-decoding.ts?fn"; +import { generateRunLengthDecodingSteps } from "./step-generator"; +import type { RunLengthDecodingInput } from "./step-generator"; +import { runLengthDecodingEducational } from "./educational"; + +import typescriptSource from "./sources/run-length-decoding.ts?raw"; +import pythonSource from "./sources/run-length-decoding.py?raw"; +import javaSource from "./sources/RunLengthDecoding.java?raw"; + +function executeRunLengthDecoding(input: RunLengthDecodingInput): string { + return runLengthDecoding(input.text) as string; +} + +const runLengthDecodingDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.RUN_LENGTH_DECODING!, + name: "Run-Length Decoding", + category: CATEGORY.STRINGS!, + technique: "transformation", + description: + "Expand a run-length encoded string by parsing digit sequences as repeat counts and repeating the following character — O(output length) time", + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(n)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "3a2b4c" }, + }, + execute: executeRunLengthDecoding, + generateSteps: generateRunLengthDecodingSteps, + educational: runLengthDecodingEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(runLengthDecodingDefinition); diff --git a/src/algorithms/strings/transformation/run-length-decoding/run-length-decoding.test.ts b/src/algorithms/strings/transformation/run-length-decoding/run-length-decoding.test.ts new file mode 100644 index 00000000..b241f2dd --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/run-length-decoding.test.ts @@ -0,0 +1,42 @@ +// Correctness tests for the runLengthDecoding pure function. + +import { describe, it, expect } from "vitest"; +import { runLengthDecoding } from "./sources/run-length-decoding.ts?fn"; + +describe("runLengthDecoding", () => { + it("decodes the default example input", () => { + expect(runLengthDecoding("3a2b4c")).toBe("aaabbcccc"); + }); + + it("decodes all single-count groups", () => { + expect(runLengthDecoding("1a1b1c")).toBe("abc"); + }); + + it("returns an empty string for empty input", () => { + expect(runLengthDecoding("")).toBe(""); + }); + + it("decodes a single group of one character", () => { + expect(runLengthDecoding("1z")).toBe("z"); + }); + + it("decodes a single group of many characters", () => { + expect(runLengthDecoding("5x")).toBe("xxxxx"); + }); + + it("decodes mixed-count groups correctly", () => { + expect(runLengthDecoding("2a3b1c")).toBe("aabbbc"); + }); + + it("decodes a string with a multi-digit count", () => { + expect(runLengthDecoding("10a")).toBe("aaaaaaaaaa"); + }); + + it("decodes two consecutive identical characters encoded separately", () => { + expect(runLengthDecoding("2a2a")).toBe("aaaa"); + }); + + it("decodes uppercase letters", () => { + expect(runLengthDecoding("3A2B")).toBe("AAABB"); + }); +}); diff --git a/src/algorithms/strings/transformation/run-length-decoding/sources/RunLengthDecoding.java b/src/algorithms/strings/transformation/run-length-decoding/sources/RunLengthDecoding.java new file mode 100644 index 00000000..9a1d60e6 --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/sources/RunLengthDecoding.java @@ -0,0 +1,35 @@ +// Run-Length Decoding — expands a compressed string like "3a2b4c" into "aaabbcccc". +// Parses leading digit sequences as repeat counts, then repeats the following character. +// Time: O(output length) Space: O(output length) + +public class RunLengthDecoding { + + public static String runLengthDecoding(String text) { + StringBuilder output = new StringBuilder(); // @step:initialize + + int readIndex = 0; // @step:initialize + + while (readIndex < text.length()) { + StringBuilder digitString = new StringBuilder(); // @step:read-char + + while (readIndex < text.length() && Character.isDigit(text.charAt(readIndex))) { + digitString.append(text.charAt(readIndex)); // @step:read-char + readIndex++; + } + + int repeatCount = Integer.parseInt(digitString.toString()); // @step:visit + + char letter = readIndex < text.length() ? text.charAt(readIndex) : 0; // @step:read-char + + String repeated = String.valueOf(letter).repeat(repeatCount); // @step:write-char + + for (char character : repeated.toCharArray()) { + output.append(character); // @step:write-char + } + + readIndex++; // @step:visit + } + + return output.toString(); // @step:complete + } +} diff --git a/src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.py b/src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.py new file mode 100644 index 00000000..fa09e063 --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.py @@ -0,0 +1,29 @@ +# Run-Length Decoding — expands a compressed string like "3a2b4c" into "aaabbcccc". +# Parses leading digit sequences as repeat counts, then repeats the following character. +# Time: O(output length) Space: O(output length) + + +def run_length_decoding(text: str) -> str: + output = [] # @step:initialize + + read_index = 0 # @step:initialize + + while read_index < len(text): + digit_string = "" # @step:read-char + + while read_index < len(text) and text[read_index].isdigit(): + digit_string += text[read_index] # @step:read-char + read_index += 1 + + repeat_count = int(digit_string) # @step:visit + + letter = text[read_index] if read_index < len(text) else "" # @step:read-char + + repeated = letter * repeat_count # @step:write-char + + for char in repeated: + output.append(char) # @step:write-char + + read_index += 1 # @step:visit + + return "".join(output) # @step:complete diff --git a/src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.ts b/src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.ts new file mode 100644 index 00000000..8ba845c9 --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/sources/run-length-decoding.ts @@ -0,0 +1,32 @@ +// Run-Length Decoding — expands a compressed string like "3a2b4c" into "aaabbcccc". +// Parses leading digit sequences as repeat counts, then repeats the following character. +// Time: O(output length) Space: O(output length) + +export function runLengthDecoding(text: string): string { + const output: string[] = []; // @step:initialize + + let readIndex = 0; // @step:initialize + + while (readIndex < text.length) { + let digitString = ""; // @step:read-char + + while (readIndex < text.length && text[readIndex]! >= "0" && text[readIndex]! <= "9") { + digitString += text[readIndex]!; // @step:read-char + readIndex++; + } + + const repeatCount = parseInt(digitString, 10); // @step:visit + + const letter = text[readIndex] ?? ""; // @step:read-char + + const repeated = letter.repeat(repeatCount); // @step:write-char + + for (const char of repeated) { + output.push(char); // @step:write-char + } + + readIndex++; // @step:visit + } + + return output.join(""); // @step:complete +} diff --git a/src/algorithms/strings/transformation/run-length-decoding/step-generator.test.ts b/src/algorithms/strings/transformation/run-length-decoding/step-generator.test.ts new file mode 100644 index 00000000..5d91fa6a --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/step-generator.test.ts @@ -0,0 +1,76 @@ +// Step generation tests for generateRunLengthDecodingSteps. + +import { describe, it, expect } from "vitest"; +import { generateRunLengthDecodingSteps } from "./step-generator"; + +describe("generateRunLengthDecodingSteps", () => { + it("produces steps for the default input", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-transform visual states throughout", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-transform"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("emits read-char steps for each digit and each letter", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + const readSteps = steps.filter((step) => step.type === "read-char"); + // Each group emits: 1 read per digit char + 1 read for the letter + // "3a" → 2 reads, "2b" → 2 reads, "4c" → 2 reads = 6 total + expect(readSteps.length).toBe(6); + }); + + it("emits write-char steps for each decoded group", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + // One appendOutput step per group = 3 groups + expect(writeSteps.length).toBe(3); + }); + + it("produces no steps beyond initialize and complete for empty input", () => { + const steps = generateRunLengthDecodingSteps({ text: "" }); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + expect(steps.length).toBe(2); + }); + + it("emits visit steps for pointer advancement after each group", () => { + const steps = generateRunLengthDecodingSteps({ text: "1a1b" }); + const visitSteps = steps.filter((step) => step.type === "visit"); + // One setAuxiliaryData (visit) + one advancePointers (visit) per group = 2 per group × 2 groups = 4 + expect(visitSteps.length).toBe(4); + }); + + it("the complete step variables include the decoded result", () => { + const steps = generateRunLengthDecodingSteps({ text: "3a2b4c" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe("aaabbcccc"); + }); + + it("decodes single-count groups in step variables correctly", () => { + const steps = generateRunLengthDecodingSteps({ text: "1a1b1c" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe("abc"); + }); +}); diff --git a/src/algorithms/strings/transformation/run-length-decoding/step-generator.ts b/src/algorithms/strings/transformation/run-length-decoding/step-generator.ts new file mode 100644 index 00000000..d45cbf36 --- /dev/null +++ b/src/algorithms/strings/transformation/run-length-decoding/step-generator.ts @@ -0,0 +1,73 @@ +/** Step generator for Run-Length Decoding — produces ExecutionStep[] using TransformTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TransformTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; +import { runLengthDecoding } from "./sources/run-length-decoding.ts?fn"; + +const RUN_LENGTH_DECODING_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.RUN_LENGTH_DECODING!); + +export interface RunLengthDecodingInput { + text: string; +} + +export function generateRunLengthDecodingSteps(input: RunLengthDecodingInput): ExecutionStep[] { + const { text } = input; + const tracker = new TransformTracker(text, RUN_LENGTH_DECODING_LINE_MAP); + + tracker.initialize({ text, readIndex: 0 }); + + let readIndex = 0; + + while (readIndex < text.length) { + // Accumulate consecutive digit characters to form the repeat count + const digitStartIndex = readIndex; + let digitString = ""; + + while (readIndex < text.length && text[readIndex]! >= "0" && text[readIndex]! <= "9") { + tracker.readChar(readIndex, { + readIndex, + digitString: digitString + (text[readIndex] ?? ""), + }); + digitString += text[readIndex]!; + readIndex++; + } + + // Guard: skip if no digits found or pointer has reached the end + if (digitString === "" || readIndex >= text.length) { + readIndex++; + continue; + } + + const repeatCount = parseInt(digitString, 10); + + // Display the parsed count before reading the letter + tracker.setAuxiliaryData(`count=${repeatCount}`, { + readIndex, + digitStartIndex, + repeatCount, + }); + + // Read the letter character that follows the digit sequence + const letter = text[readIndex] ?? ""; + tracker.readChar(readIndex, { readIndex, letter, repeatCount }); + + // Append the letter repeated `repeatCount` times to the output buffer + const repeated = letter.repeat(repeatCount); + tracker.appendOutput(repeated, { + readIndex, + letter, + repeatCount, + appended: repeated, + }); + + // Advance past the letter and update pointers + readIndex++; + tracker.advancePointers(readIndex, readIndex - 1, { readIndex }); + } + + const result = runLengthDecoding(text) as string; + tracker.complete({ result }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/transformation/string-compression/StringCompressionPipeline.stories.tsx b/src/algorithms/strings/transformation/string-compression/StringCompressionPipeline.stories.tsx new file mode 100644 index 00000000..7dd9b71b --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/StringCompressionPipeline.stories.tsx @@ -0,0 +1,54 @@ +/** + * Storybook stories for the String Compression algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TransformVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TransformVisualState } from "@/types"; +import { generateStringCompressionSteps } from "./step-generator"; +import TransformVisualizer from "@/components/visualization/TransformVisualizer"; + +const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + +const meta: Meta = { + title: "Algorithm Pipelines/String Compression", + component: TransformVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — no characters read yet, buffers empty */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TransformVisualState, + }, +}; + +/** Reading first run — pointer on first character, count starting */ +export const ReadingFirstRun: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.2)]!.visualState as TransformVisualState, + }, +}; + +/** Mid-execution — first two runs written to output, scanning third run */ +export const MidCompression: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.55)]!.visualState as TransformVisualState, + }, +}; + +/** Final state — all runs processed, compressed string in output buffer */ +export const Compressed: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TransformVisualState, + }, +}; diff --git a/src/algorithms/strings/transformation/string-compression/educational.ts b/src/algorithms/strings/transformation/string-compression/educational.ts new file mode 100644 index 00000000..2c680819 --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/educational.ts @@ -0,0 +1,81 @@ +/** Educational content for the String Compression (Run-Length Encoding) algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const stringCompressionEducational: EducationalContent = { + overview: + "**String Compression** (also called Run-Length Encoding) replaces consecutive runs of the same character with " + + "that character followed by the run's count.\n\n" + + 'For example, `"aabcccccaaa"` becomes `"a2b1c5a3"` because there are 2 `a`s, 1 `b`, 5 `c`s, and 3 `a`s in sequence. ' + + "If the compressed form is not shorter than the original, the original string is returned unchanged.", + + howItWorks: + "The algorithm scans the input with a single read pointer `charIndex`, grouping consecutive identical characters into runs.\n\n" + + "For each run:\n\n" + + "1. **Read** the character at `charIndex` — this is `currentChar`.\n" + + "2. **Count** how many times `currentChar` repeats consecutively, advancing `charIndex` through the run.\n" + + "3. **Write** `currentChar` then `count` to the output buffer.\n" + + "4. **Advance** to the next group (charIndex now points past the just-processed run).\n\n" + + "After all runs are processed, compare output and input lengths. Return the shorter one.\n\n" + + "```\n" + + "Input: a a b c c c c c a a a\n" + + "Run 1: a×2 → write 'a','2'\n" + + "Run 2: b×1 → write 'b','1'\n" + + "Run 3: c×5 → write 'c','5'\n" + + "Run 4: a×3 → write 'a','3'\n" + + "Output: a2b1c5a3 (8 < 11 chars — compressed returned)\n" + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Every character is visited exactly once by the outer `while` loop. The inner counting loop advances " + + "`charIndex` forward; together they traverse the full string in a single pass.\n\n" + + "**Space Complexity: `O(n)`**\n\n" + + "The output buffer grows at most to `2k` characters where `k` is the number of distinct runs. " + + "In the worst case (all characters unique, e.g. `abc`) the output is twice the input length — " + + "but the algorithm returns the original in that case. The auxiliary buffer is still `O(n)`.", + + bestAndWorstCase: + "**Best case — all characters identical (e.g. `aaaaaaa`):** The single run produces a two-character " + + "output `a7`, giving maximum compression. Reading and writing remain O(n).\n\n" + + "**Worst case — no repeated characters (e.g. `abcdef`):** Every character forms its own run of length 1, " + + "doubling the output size. The algorithm still runs O(n) but returns the original string because " + + "the compressed form would be longer (e.g. `a1b1c1d1e1f1`).\n\n" + + "There is no early-exit path — the full string is always scanned to build the candidate compressed form.", + + realWorldUses: [ + "**Image formats:** BMP and TIFF files use run-length encoding for rows of identical pixel values, " + + "dramatically shrinking uniform regions like solid backgrounds.", + "**Fax transmission:** The Group 3 fax standard encodes scan lines as runs of black and white pixels " + + "with RLE, reducing transmission time over phone lines.", + "**Lossless data compression:** RLE is a building block inside more complex schemes like PackBits " + + "(used in macOS PICT and TIFF) and is a first pass in some video codecs.", + "**DNA sequence storage:** Bioinformatics tools use run-length encoding to compactly represent long " + + "homopolymer stretches (e.g. `AAAAAAGGG`) in genome data.", + "**Interview fundamentals:** String compression is a canonical problem testing string traversal, " + + "two-pointer awareness, and edge-case handling (no-op when compression yields no gain).", + ], + + strengthsAndLimitations: { + strengths: [ + "O(n) time and a single scan — extremely cache-friendly with no backtracking.", + "Simple and easy to implement correctly with minimal state.", + "Lossless — the original string can always be recovered by reversing the encoding.", + "Falls back to returning the original when compression yields no benefit.", + ], + limitations: [ + "Effective only for inputs with long repeated runs — performs poorly (and falls back) on strings with few or no repetitions.", + "Count digits may themselves be multiple characters (e.g. a run of 10 identical chars produces `a10`), which can exceed the space saved.", + "Not suitable as a general-purpose compressor; Huffman coding, LZ77, or deflate are far more effective for typical text.", + "Requires a second pass (or tracking) to decide whether to return the compressed or original string.", + ], + }, + + whenToUseIt: + "Use String Compression when your data contains long runs of repeated characters and you need a simple, " + + "lossless encoding with O(n) guarantees — bitmap image rows, homopolymer DNA sequences, or simple " + + "text streams with high repetition.\n\n" + + "Avoid it for general text or binary data with low repetition — the overhead of count digits may make " + + "the encoded form longer than the original. For those cases, prefer Huffman coding, LZ-based " + + "algorithms, or deflate.", +}; diff --git a/src/algorithms/strings/transformation/string-compression/index.ts b/src/algorithms/strings/transformation/string-compression/index.ts new file mode 100644 index 00000000..9b86ca47 --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/index.ts @@ -0,0 +1,48 @@ +/** Registry definition for String Compression — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { stringCompression } from "./sources/string-compression.ts?fn"; +import { generateStringCompressionSteps } from "./step-generator"; +import type { StringCompressionInput } from "./step-generator"; +import { stringCompressionEducational } from "./educational"; + +import typescriptSource from "./sources/string-compression.ts?raw"; +import pythonSource from "./sources/string-compression.py?raw"; +import javaSource from "./sources/StringCompression.java?raw"; + +function executeStringCompression(input: StringCompressionInput): string { + return stringCompression(input.text) as string; +} + +const stringCompressionDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.STRING_COMPRESSION!, + name: "String Compression", + category: CATEGORY.STRINGS!, + technique: "transformation", + description: + "Compress consecutive repeated characters using run-length encoding — " + + '"aabcccccaaa" → "a2b1c5a3"; returns original if compressed form is not shorter. O(n) time', + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(n)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "aabcccccaaa" }, + }, + execute: executeStringCompression, + generateSteps: generateStringCompressionSteps, + educational: stringCompressionEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(stringCompressionDefinition); diff --git a/src/algorithms/strings/transformation/string-compression/sources/StringCompression.java b/src/algorithms/strings/transformation/string-compression/sources/StringCompression.java new file mode 100644 index 00000000..e8ce8688 --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/sources/StringCompression.java @@ -0,0 +1,31 @@ +// String Compression (Run-Length Encoding) — count consecutive repeated characters. +// Returns the compressed form "a2b1c5a3" only if shorter than the original; otherwise returns the original. +// Time: O(n) Space: O(n) for the output buffer + +public class StringCompression { + + public static String stringCompression(String text) { + if (text.isEmpty()) { // @step:initialize + return text; // @step:initialize + } + + StringBuilder compressed = new StringBuilder(); // @step:initialize + int charIndex = 0; // @step:initialize + + while (charIndex < text.length()) { + char currentChar = text.charAt(charIndex); // @step:read-char + int count = 0; // @step:read-char + + while (charIndex < text.length() && text.charAt(charIndex) == currentChar) { + count++; // @step:count + charIndex++; // @step:count + } + + compressed.append(currentChar); // @step:write-char + compressed.append(count); // @step:write-char + } + + String result = compressed.toString(); // @step:complete + return result.length() < text.length() ? result : text; // @step:complete + } +} diff --git a/src/algorithms/strings/transformation/string-compression/sources/string-compression.py b/src/algorithms/strings/transformation/string-compression/sources/string-compression.py new file mode 100644 index 00000000..86b9f46f --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/sources/string-compression.py @@ -0,0 +1,24 @@ +# String Compression (Run-Length Encoding) — count consecutive repeated characters. +# Returns the compressed form "a2b1c5a3" only if shorter than the original; otherwise returns the original. +# Time: O(n) Space: O(n) for the output buffer + + +def string_compression(text: str) -> str: + if len(text) == 0: # @step:initialize + return text # @step:initialize + + compressed = "" # @step:initialize + char_index = 0 # @step:initialize + + while char_index < len(text): + current_char = text[char_index] # @step:read-char + count = 0 # @step:read-char + + while char_index < len(text) and text[char_index] == current_char: + count += 1 # @step:count + char_index += 1 # @step:count + + compressed += current_char # @step:write-char + compressed += str(count) # @step:write-char + + return compressed if len(compressed) < len(text) else text # @step:complete diff --git a/src/algorithms/strings/transformation/string-compression/sources/string-compression.ts b/src/algorithms/strings/transformation/string-compression/sources/string-compression.ts new file mode 100644 index 00000000..65cce68d --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/sources/string-compression.ts @@ -0,0 +1,25 @@ +// String Compression (Run-Length Encoding) — count consecutive repeated characters. +// Returns the compressed form "a2b1c5a3" only if shorter than the original; otherwise returns the original. +// Time: O(n) Space: O(n) for the output buffer + +export function stringCompression(text: string): string { + if (text.length === 0) return text; // @step:initialize + + let compressed = ""; // @step:initialize + let charIndex = 0; // @step:initialize + + while (charIndex < text.length) { + const currentChar = text[charIndex] ?? ""; // @step:read-char + let count = 0; // @step:read-char + + while (charIndex < text.length && text[charIndex] === currentChar) { + count++; // @step:count + charIndex++; // @step:count + } + + compressed += currentChar; // @step:write-char + compressed += String(count); // @step:write-char + } + + return compressed.length < text.length ? compressed : text; // @step:complete +} diff --git a/src/algorithms/strings/transformation/string-compression/step-generator.test.ts b/src/algorithms/strings/transformation/string-compression/step-generator.test.ts new file mode 100644 index 00000000..886d78e3 --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/step-generator.test.ts @@ -0,0 +1,89 @@ +/** Step generation tests for the String Compression algorithm. */ + +import { describe, it, expect } from "vitest"; +import { generateStringCompressionSteps } from "./step-generator"; + +describe("generateStringCompressionSteps", () => { + it("produces steps for the default input", () => { + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-transform visual states throughout", () => { + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-transform"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("emits read-char steps for each run group", () => { + // "aabcccccaaa" has 4 runs: aa, b, ccccc, aaa → 4 read-char steps + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + const readSteps = steps.filter((step) => step.type === "read-char"); + expect(readSteps.length).toBe(4); + }); + + it("emits write-char steps for each character and count written", () => { + // "aabcccccaaa" → "a2b1c5a3" — 8 write-char steps (one per output character) + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + expect(writeSteps.length).toBe(8); + }); + + it("final complete step carries the compressed result", () => { + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe("a2b1c5a3"); + }); + + it("complete step carries the original when compression yields no benefit", () => { + // "abc" → "a1b1c1" (6 > 3 chars), so original is returned + const steps = generateStringCompressionSteps({ text: "abc" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe("abc"); + }); + + it("produces only initialize and complete steps for an empty string", () => { + const steps = generateStringCompressionSteps({ text: "" }); + expect(steps.length).toBe(2); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces no swap-pointer steps (not used for compression)", () => { + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + const swapSteps = steps.filter((step) => step.type === "swap-pointers"); + expect(swapSteps.length).toBe(0); + }); + + it("emits found (markConverted) steps for each run", () => { + // 4 runs in "aabcccccaaa" → 4 found steps + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(4); + }); + + it("records swaps metric equal to total output characters written", () => { + // "aabcccccaaa" → "a2b1c5a3" — 8 writeChar calls increment swaps metric + const steps = generateStringCompressionSteps({ text: "aabcccccaaa" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.metrics.swaps).toBe(8); + }); +}); diff --git a/src/algorithms/strings/transformation/string-compression/step-generator.ts b/src/algorithms/strings/transformation/string-compression/step-generator.ts new file mode 100644 index 00000000..5186f327 --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/step-generator.ts @@ -0,0 +1,80 @@ +/** Step generator for String Compression — produces ExecutionStep[] using TransformTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TransformTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const STRING_COMPRESSION_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.STRING_COMPRESSION!); + +export interface StringCompressionInput { + text: string; +} + +/** Compute compressed string independently for the final result variable. */ +function compress(text: string): string { + let result = ""; + let idx = 0; + while (idx < text.length) { + const char = text[idx] ?? ""; + let count = 0; + while (idx < text.length && text[idx] === char) { + count++; + idx++; + } + result += char + String(count); + } + return result; +} + +export function generateStringCompressionSteps(input: StringCompressionInput): ExecutionStep[] { + const { text } = input; + const tracker = new TransformTracker(text, STRING_COMPRESSION_LINE_MAP); + + tracker.initialize({ text, charIndex: 0, compressed: "" }); + + if (text.length === 0) { + tracker.complete({ result: text }); + return tracker.getSteps(); + } + + let charIndex = 0; + let writeIndex = -1; + + while (charIndex < text.length) { + const currentChar = text[charIndex] ?? ""; + const runStart = charIndex; + + // Read the current character to begin a new run + tracker.readChar(charIndex, { charIndex, currentChar, count: 0 }); + + let count = 0; + + // Count all consecutive identical characters in this run + while (charIndex < text.length && text[charIndex] === currentChar) { + count++; + charIndex++; + } + + // Highlight the full run as converted in the input buffer + tracker.markConverted(runStart, charIndex - 1, { charIndex, currentChar, count }); + + // Emit the character to the output buffer + tracker.writeChar(currentChar, { charIndex, currentChar, count }); + writeIndex++; + + // Emit the count digit(s) to the output buffer + for (const digit of String(count)) { + tracker.writeChar(digit, { charIndex, currentChar, count }); + writeIndex++; + } + + // Advance read and write pointers to their new positions + tracker.advancePointers(charIndex, writeIndex, { charIndex, currentChar, count }); + } + + const compressed = compress(text); + const finalResult = compressed.length < text.length ? compressed : text; + tracker.complete({ result: finalResult }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/transformation/string-compression/string-compression.test.ts b/src/algorithms/strings/transformation/string-compression/string-compression.test.ts new file mode 100644 index 00000000..ff0611e8 --- /dev/null +++ b/src/algorithms/strings/transformation/string-compression/string-compression.test.ts @@ -0,0 +1,57 @@ +/** Correctness tests for the String Compression (Run-Length Encoding) algorithm. */ + +import { describe, it, expect } from "vitest"; +import { stringCompression } from "./sources/string-compression.ts?fn"; + +describe("stringCompression", () => { + it("compresses a string with repeated characters", () => { + expect(stringCompression("aabcccccaaa")).toBe("a2b1c5a3"); + }); + + it("returns the original when compressed form is not shorter", () => { + expect(stringCompression("abc")).toBe("abc"); + }); + + it("returns an empty string unchanged", () => { + expect(stringCompression("")).toBe(""); + }); + + it("returns a single character unchanged (compressed would be longer)", () => { + expect(stringCompression("a")).toBe("a"); + }); + + it("returns a two-character string unchanged when compression yields same length", () => { + // "aa" → "a2": compressed length (2) equals original length (2), so original is returned + expect(stringCompression("aa")).toBe("aa"); + }); + + it("compresses a long run of one character", () => { + expect(stringCompression("aaaaaaa")).toBe("a7"); + }); + + it("compresses alternating segments correctly", () => { + expect(stringCompression("aaabbbccc")).toBe("a3b3c3"); + }); + + it("compresses a string where all characters differ (no run > 1)", () => { + // "abcd" → "a1b1c1d1" (8 chars > 4 chars), so original is returned + expect(stringCompression("abcd")).toBe("abcd"); + }); + + it("handles a string with a single long run followed by a short run", () => { + expect(stringCompression("aaaaab")).toBe("a5b1"); + }); + + it("compresses a string of exactly two distinct run lengths", () => { + expect(stringCompression("aaabbb")).toBe("a3b3"); + }); + + it("compresses a string starting with a single-character run", () => { + expect(stringCompression("abbbbb")).toBe("a1b5"); + }); + + it("handles repeated digits correctly", () => { + // "1111222" → "14" + "23" = "1423" (7 > 4 chars, so compressed is returned) + expect(stringCompression("1111222")).toBe("1423"); + }); +}); diff --git a/src/algorithms/strings/transformation/string-rotation-check/StringRotationCheckPipeline.stories.tsx b/src/algorithms/strings/transformation/string-rotation-check/StringRotationCheckPipeline.stories.tsx new file mode 100644 index 00000000..c72c1bbf --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/StringRotationCheckPipeline.stories.tsx @@ -0,0 +1,54 @@ +/** + * Storybook stories for the String Rotation Check algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TransformVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TransformVisualState } from "@/types"; +import { generateStringRotationCheckSteps } from "./step-generator"; +import TransformVisualizer from "@/components/visualization/TransformVisualizer"; + +const steps = generateStringRotationCheckSteps({ text: "waterbottle", pattern: "erbottlewat" }); + +const meta: Meta = { + title: "Algorithm Pipelines/String Rotation Check", + component: TransformVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — lengths validated, no concatenation yet */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TransformVisualState, + }, +}; + +/** Concatenation phase — text+text has been written to the output buffer */ +export const Concatenated: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.3)]!.visualState as TransformVisualState, + }, +}; + +/** Search phase — scanning through the concatenated string */ +export const Searching: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.65)]!.visualState as TransformVisualState, + }, +}; + +/** Final state — pattern found and marked as converted */ +export const Found: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TransformVisualState, + }, +}; diff --git a/src/algorithms/strings/transformation/string-rotation-check/educational.ts b/src/algorithms/strings/transformation/string-rotation-check/educational.ts new file mode 100644 index 00000000..039ac023 --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/educational.ts @@ -0,0 +1,60 @@ +/** Educational content for the String Rotation Check algorithm. */ + +import type { EducationalContent } from "@/types"; + +export const stringRotationCheckEducational: EducationalContent = { + overview: + "**String Rotation Check** determines whether one string is a rotation of another — that is, whether the characters of `text` appear in the same cyclic order in `pattern`.\n\n" + + "The insight is elegant: if `pattern` is a rotation of `text`, then `pattern` must appear as a contiguous substring somewhere inside `text + text`. For example, `'erbottlewat'` is a rotation of `'waterbottle'`, and it can be found inside `'waterbottlewaterbottle'`.", + + howItWorks: + "The algorithm runs in three phases:\n\n" + + "**Phase 1 — Validate:** Check that `text` and `pattern` have equal lengths. If they differ, they cannot be rotations of each other — return `false` immediately.\n\n" + + "**Phase 2 — Concatenate:** Build the string `concatenated = text + text`. This doubled string contains every possible rotation of `text` as a contiguous substring.\n\n" + + "**Phase 3 — Search:** Check whether `pattern` appears as a substring of `concatenated`.\n\n" + + "```\n" + + "text = 'waterbottle'\n" + + "pattern = 'erbottlewat'\n" + + "concat = 'waterbottlewaterbottle'\n" + + " ^^^^^^^^^^^ ← pattern found at index 3\n" + + "result = true\n" + + "```\n\n" + + "The substring search can be performed with any efficient string-matching algorithm (KMP, Boyer-Moore, or the built-in `includes`/`contains`) in O(n) time.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Both strings have the same length `n`. Building the concatenated string takes O(n). The substring search (using a linear algorithm like KMP) runs in O(n). Total: O(n).\n\n" + + "**Space Complexity: `O(n)`**\n\n" + + "The concatenated string `text + text` has length `2n`, requiring O(n) auxiliary space.", + + bestAndWorstCase: + "**Best case — length mismatch:** `O(1)` — the lengths differ, so we return `false` without allocating any extra memory.\n\n" + + "**Best case — early match:** The pattern is found near the start of the concatenated string, allowing the search to terminate early.\n\n" + + "**Worst case — no match or late match:** The search must scan all `2n` characters before confirming absence or a match at the very end.\n\n" + + "Because the algorithm has no adaptive behavior based on content (only the length check provides early exit), practical performance is consistently O(n).", + + realWorldUses: [ + "**Queue and circular buffer comparisons:** Determining whether two sequences stored in circular buffers are equivalent rotations is a direct application.", + "**DNA sequence analysis:** Detecting cyclic equivalence in genetic sequences — for example, finding whether a circular DNA strand matches a reference at any rotation.", + "**Scheduling and round-robin systems:** Checking whether two schedules represent the same rotation of tasks in a round-robin queue.", + "**String deduplication:** Canonicalizing rotations to a single representative form to avoid storing duplicate cyclic variants.", + "**Cryptographic protocols:** Some rotational cipher schemes require rotation-equivalence checks during key verification.", + ], + + strengthsAndLimitations: { + strengths: [ + "Extremely simple to implement — reduces rotation checking to a standard substring search.", + "Leverages highly optimised built-in string search routines available in all languages.", + "Handles all rotation offsets in a single pass without trying each offset individually.", + ], + limitations: [ + "Requires O(n) auxiliary space for the concatenated string — not in-place.", + "Only works for same-length strings; cannot compare strings of different lengths for cyclic equivalence.", + "The naive `includes` / `contains` used in most languages may be O(n²) in adversarial cases without a guaranteed linear algorithm (e.g., KMP).", + ], + }, + + whenToUseIt: + "Use String Rotation Check whenever you need to determine whether two equal-length strings are cyclic variants of each other. It is the standard O(n) solution and preferred over the O(n²) brute-force approach of testing every rotation offset individually.\n\n" + + "Avoid it when strings have different lengths (trivially not rotations) or when O(n) space is not acceptable. For in-place rotation detection, consider comparing canonical forms instead.", +}; diff --git a/src/algorithms/strings/transformation/string-rotation-check/index.ts b/src/algorithms/strings/transformation/string-rotation-check/index.ts new file mode 100644 index 00000000..f636c1ef --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/index.ts @@ -0,0 +1,47 @@ +/** Registration entry for the String Rotation Check algorithm. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { stringRotationCheck } from "./sources/string-rotation-check.ts?fn"; +import { generateStringRotationCheckSteps } from "./step-generator"; +import type { StringRotationCheckInput } from "./step-generator"; +import { stringRotationCheckEducational } from "./educational"; + +import typescriptSource from "./sources/string-rotation-check.ts?raw"; +import pythonSource from "./sources/string-rotation-check.py?raw"; +import javaSource from "./sources/StringRotationCheck.java?raw"; + +function executeStringRotationCheck(input: StringRotationCheckInput): boolean { + return stringRotationCheck(input.text, input.pattern) as boolean; +} + +const stringRotationCheckDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.STRING_ROTATION_CHECK!, + name: "String Rotation Check", + category: CATEGORY.STRINGS!, + technique: "transformation", + description: + "Check if one string is a rotation of another by searching for it as a substring of the doubled string in O(n) time", + timeComplexity: { + best: "O(n)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(n)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "waterbottle", pattern: "erbottlewat" }, + }, + execute: executeStringRotationCheck, + generateSteps: generateStringRotationCheckSteps, + educational: stringRotationCheckEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(stringRotationCheckDefinition); diff --git a/src/algorithms/strings/transformation/string-rotation-check/sources/StringRotationCheck.java b/src/algorithms/strings/transformation/string-rotation-check/sources/StringRotationCheck.java new file mode 100644 index 00000000..174913fe --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/sources/StringRotationCheck.java @@ -0,0 +1,14 @@ +// String Rotation Check — checks if pattern is a rotation of text. +// Concatenates text with itself and searches for pattern as a substring. +// Time: O(n) Space: O(n) for the concatenated string + +public class StringRotationCheck { + + public static boolean stringRotationCheck(String text, String pattern) { + if (pattern.length() != text.length()) return false; // @step:initialize + + String concatenated = text + text; // @step:write-char + + return concatenated.contains(pattern); // @step:visit + } +} diff --git a/src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.py b/src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.py new file mode 100644 index 00000000..ad44794f --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.py @@ -0,0 +1,12 @@ +# String Rotation Check — checks if pattern is a rotation of text. +# Concatenates text with itself and searches for pattern as a substring. +# Time: O(n) Space: O(n) for the concatenated string + + +def string_rotation_check(text: str, pattern: str) -> bool: + if len(pattern) != len(text): # @step:initialize + return False + + concatenated = text + text # @step:write-char + + return pattern in concatenated # @step:visit diff --git a/src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.ts b/src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.ts new file mode 100644 index 00000000..e7c6babe --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/sources/string-rotation-check.ts @@ -0,0 +1,11 @@ +// String Rotation Check — checks if pattern is a rotation of text. +// Concatenates text with itself and searches for pattern as a substring. +// Time: O(n) Space: O(n) for the concatenated string + +export function stringRotationCheck(text: string, pattern: string): boolean { + if (pattern.length !== text.length) return false; // @step:initialize + + const concatenated = text + text; // @step:write-char + + return concatenated.includes(pattern); // @step:visit +} diff --git a/src/algorithms/strings/transformation/string-rotation-check/step-generator.test.ts b/src/algorithms/strings/transformation/string-rotation-check/step-generator.test.ts new file mode 100644 index 00000000..431288fe --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/step-generator.test.ts @@ -0,0 +1,97 @@ +/** Step generation tests for generateStringRotationCheckSteps. */ + +import { describe, it, expect } from "vitest"; +import { generateStringRotationCheckSteps } from "./step-generator"; + +describe("generateStringRotationCheckSteps", () => { + it("produces steps for the default input", () => { + const steps = generateStringRotationCheckSteps({ + text: "waterbottle", + pattern: "erbottlewat", + }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateStringRotationCheckSteps({ + text: "waterbottle", + pattern: "erbottlewat", + }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateStringRotationCheckSteps({ + text: "waterbottle", + pattern: "erbottlewat", + }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-transform visual states throughout", () => { + const steps = generateStringRotationCheckSteps({ text: "abc", pattern: "cab" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-transform"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateStringRotationCheckSteps({ text: "abc", pattern: "bca" }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("emits a write-char step for the concatenation phase", () => { + const steps = generateStringRotationCheckSteps({ text: "abc", pattern: "bca" }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + // One appendOutput call produces one write-char step + expect(writeSteps.length).toBeGreaterThanOrEqual(1); + }); + + it("emits read-char steps during the search phase", () => { + const steps = generateStringRotationCheckSteps({ text: "abc", pattern: "bca" }); + const readSteps = steps.filter((step) => step.type === "read-char"); + expect(readSteps.length).toBeGreaterThan(0); + }); + + it("emits a found step when pattern is a valid rotation", () => { + const steps = generateStringRotationCheckSteps({ + text: "waterbottle", + pattern: "erbottlewat", + }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(1); + }); + + it("does not emit a found step when pattern is not a rotation", () => { + const steps = generateStringRotationCheckSteps({ text: "abcde", pattern: "abced" }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(0); + }); + + it("terminates early with only initialize and complete for length mismatch", () => { + const steps = generateStringRotationCheckSteps({ text: "abc", pattern: "ab" }); + expect(steps.length).toBe(2); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[1]?.type).toBe("complete"); + }); + + it("records result true in complete step variables for a valid rotation", () => { + const steps = generateStringRotationCheckSteps({ text: "abc", pattern: "bca" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe(true); + }); + + it("records result false in complete step variables for a non-rotation", () => { + const steps = generateStringRotationCheckSteps({ text: "abcde", pattern: "abced" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe(false); + }); + + it("handles equal strings (zero-offset rotation) without error", () => { + const steps = generateStringRotationCheckSteps({ text: "hello", pattern: "hello" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe(true); + }); +}); diff --git a/src/algorithms/strings/transformation/string-rotation-check/step-generator.ts b/src/algorithms/strings/transformation/string-rotation-check/step-generator.ts new file mode 100644 index 00000000..2ef35bed --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/step-generator.ts @@ -0,0 +1,62 @@ +/** Step generator for String Rotation Check — produces ExecutionStep[] using TransformTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TransformTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const STRING_ROTATION_CHECK_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.STRING_ROTATION_CHECK!); + +export interface StringRotationCheckInput { + text: string; + pattern: string; +} + +export function generateStringRotationCheckSteps(input: StringRotationCheckInput): ExecutionStep[] { + const { text, pattern } = input; + const tracker = new TransformTracker(text, STRING_ROTATION_CHECK_LINE_MAP); + + // Phase 1: initialize — validate lengths + tracker.initialize({ text, pattern, lengthMatch: text.length === pattern.length }); + + if (pattern.length !== text.length) { + tracker.complete({ result: false, reason: "length mismatch" }); + return tracker.getSteps(); + } + + // Phase 2: concatenation — build text+text in the output buffer + tracker.setPhase("concatenation", { text, pattern }); + const concatenated = text + text; + tracker.appendOutput(concatenated, { concatenated, pattern }); + + // Phase 3: search — scan through concatenated string looking for pattern + tracker.setPhase("search", { concatenated, pattern }); + + const patternLength = pattern.length; + const searchLength = concatenated.length - patternLength; + let foundAtIndex = -1; + + for (let searchIndex = 0; searchIndex <= searchLength; searchIndex++) { + tracker.readChar(searchIndex, { searchIndex, pattern, concatenated }); + + const window = concatenated.slice(searchIndex, searchIndex + patternLength); + if (window === pattern) { + foundAtIndex = searchIndex; + break; + } + } + + // Phase 4: mark converted if pattern was found, then complete. + // markConverted operates on inputChars (the original text), so we mark the full + // input range [0, text.length - 1] to indicate a successful rotation match. + if (foundAtIndex !== -1) { + tracker.markConverted(0, text.length - 1, { + foundAtIndex, + pattern, + result: true, + }); + } + + tracker.complete({ result: foundAtIndex !== -1 }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/transformation/string-rotation-check/string-rotation-check.test.ts b/src/algorithms/strings/transformation/string-rotation-check/string-rotation-check.test.ts new file mode 100644 index 00000000..3e6a408b --- /dev/null +++ b/src/algorithms/strings/transformation/string-rotation-check/string-rotation-check.test.ts @@ -0,0 +1,60 @@ +/** Correctness tests for the stringRotationCheck pure algorithm. */ + +import { describe, it, expect } from "vitest"; +import { stringRotationCheck } from "./sources/string-rotation-check.ts?fn"; + +describe("stringRotationCheck", () => { + it("returns true for a valid rotation", () => { + expect(stringRotationCheck("waterbottle", "erbottlewat")).toBe(true); + }); + + it("returns true when pattern equals text (zero-offset rotation)", () => { + expect(stringRotationCheck("hello", "hello")).toBe(true); + }); + + it("returns true for single-character strings that match", () => { + expect(stringRotationCheck("a", "a")).toBe(true); + }); + + it("returns false for single-character strings that differ", () => { + expect(stringRotationCheck("a", "b")).toBe(false); + }); + + it("returns false when lengths differ", () => { + expect(stringRotationCheck("abc", "ab")).toBe(false); + }); + + it("returns false when pattern is not a rotation", () => { + expect(stringRotationCheck("waterbottle", "bottlewater")).toBe(true); + }); + + it("returns false when pattern shares characters but is not a rotation", () => { + expect(stringRotationCheck("abcde", "abced")).toBe(false); + }); + + it("returns true for rotation at the last offset", () => { + // Rotating 'abcde' by one: 'bcdea' + expect(stringRotationCheck("abcde", "bcdea")).toBe(true); + }); + + it("returns true for rotation at the first offset from end", () => { + // Rotating 'abcde' by four: 'eabcd' + expect(stringRotationCheck("abcde", "eabcd")).toBe(true); + }); + + it("returns false for two empty strings (vacuously true — same rotation)", () => { + expect(stringRotationCheck("", "")).toBe(true); + }); + + it("returns false when one is empty and the other is not", () => { + expect(stringRotationCheck("abc", "")).toBe(false); + }); + + it("handles repeated characters correctly", () => { + expect(stringRotationCheck("aabaa", "baaab")).toBe(false); + }); + + it("handles repeated characters that are valid rotations", () => { + expect(stringRotationCheck("aab", "baa")).toBe(true); + }); +}); diff --git a/src/algorithms/strings/transformation/string-to-integer/StringToIntegerPipeline.stories.tsx b/src/algorithms/strings/transformation/string-to-integer/StringToIntegerPipeline.stories.tsx new file mode 100644 index 00000000..5a21a279 --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/StringToIntegerPipeline.stories.tsx @@ -0,0 +1,61 @@ +/** + * Storybook stories for the String to Integer (atoi) algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TransformVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TransformVisualState } from "@/types"; +import { generateStringToIntegerSteps } from "./step-generator"; +import TransformVisualizer from "@/components/visualization/TransformVisualizer"; + +const steps = generateStringToIntegerSteps({ text: " -42" }); + +const meta: Meta = { + title: "Algorithm Pipelines/String to Integer", + component: TransformVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — pointer at index 0, no characters consumed */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TransformVisualState, + }, +}; + +/** Skip-whitespace phase — pointer advancing past leading spaces */ +export const SkippingWhitespace: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.2)]!.visualState as TransformVisualState, + }, +}; + +/** Read-sign phase — sign character consumed, digits about to be processed */ +export const ReadingSign: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.45)]!.visualState as TransformVisualState, + }, +}; + +/** Read-digits phase — digits accumulated, output buffer building up */ +export const ReadingDigits: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.75)]!.visualState as TransformVisualState, + }, +}; + +/** Final state — all digits consumed, integer result computed */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TransformVisualState, + }, +}; diff --git a/src/algorithms/strings/transformation/string-to-integer/educational.ts b/src/algorithms/strings/transformation/string-to-integer/educational.ts new file mode 100644 index 00000000..5875ab77 --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/educational.ts @@ -0,0 +1,68 @@ +/** Educational content for String to Integer (atoi). */ + +import type { EducationalContent } from "@/types"; + +export const stringToIntegerEducational: EducationalContent = { + overview: + "**String to Integer (atoi)** converts a string representation of a number into its 32-bit signed integer value.\n\n" + + "The algorithm models the behaviour of the C standard library function `atoi`, following a strict three-phase contract: " + + "skip leading whitespace, read an optional sign character, then consume consecutive digit characters and accumulate their value. " + + "Any non-digit character that appears after the digits immediately terminates parsing. " + + "The final value is clamped to the 32-bit signed integer range `[-2³¹, 2³¹ − 1]`.", + + howItWorks: + "The algorithm advances a single index pointer through three sequential phases:\n\n" + + "**Phase 1 — Skip whitespace:** Advance `charIndex` past every leading space (`' '`). Non-space characters are not consumed here.\n\n" + + "**Phase 2 — Read sign:** If the current character is `'-'` set `sign = -1`; if it is `'+'` leave `sign = 1`. Advance `charIndex` by one in either case. Any other character skips this phase entirely.\n\n" + + "**Phase 3 — Read digits:** Loop while `charIndex` is in bounds and `text[charIndex]` is a digit (`'0'`–`'9'`). " + + "Convert each character to a numeric digit via `charCode − 48` and accumulate into `result` with `result = result × 10 + digit`. " + + "After each digit, check for overflow — if `sign × result` already exceeds the 32-bit boundary, return the appropriate clamp value immediately.\n\n" + + "```\n" + + 'Input: " -42"\n' + + "Phase 1: skip 3 spaces → charIndex = 3\n" + + "Phase 2: read '-' → sign = -1, charIndex = 4\n" + + "Phase 3: read '4' → result = 4\n" + + " read '2' → result = 42\n" + + "Output: -42\n" + + "```", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n)`**\n\n" + + "Each character in the input string is visited at most once — the pointer only advances forward and never backtracks. " + + "In the worst case (a string of all digits) every character is processed.\n\n" + + "**Space Complexity: `O(1)`**\n\n" + + "Only a fixed number of scalar variables are used regardless of input length: `charIndex`, `sign`, `result`, and `length`. " + + "No auxiliary buffer proportional to the input is allocated.", + + bestAndWorstCase: + '**Best case — immediate non-digit after optional whitespace/sign:** `O(1)` — the digit loop body never executes (e.g. `"abc"` → `0`).\n\n' + + "**Overflow short-circuit:** When the accumulating value exceeds the 32-bit range, the function returns immediately without processing remaining characters — a constant-time exit for very large inputs.\n\n" + + "**Worst case — a string of `n` valid digits:** `O(n)` — every character must be visited and multiplied into the accumulator. " + + "All three complexity cases (best, average, worst) are bounded by the number of characters examined, giving a flat linear profile.", + + realWorldUses: [ + '**Command-line argument parsing:** Shell utilities and CLI parsers call `atoi`-like routines to convert string arguments (`"--port 8080"`) into integer values.', + "**Network protocol parsing:** HTTP header values, JSON numbers in streaming parsers, and binary protocol decoders all rely on digit-by-digit integer conversion from raw byte streams.", + "**Database engines:** SQL engines parse numeric literals in query strings into integer or long values during the tokenisation phase of query compilation.", + "**Embedded systems:** Resource-constrained firmware avoids floating-point and heap allocation by using a hand-rolled atoi to parse sensor data or configuration from EEPROM strings.", + "**Interview fundamentals:** atoi is a canonical string-manipulation problem testing edge-case reasoning — overflow, sign, whitespace, and early termination all in one compact scenario.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(1) space — no auxiliary buffer is needed; parsing is entirely in-place with scalar variables.", + "Single-pass — the pointer never revisits a character, making it cache-friendly and suitable for streaming inputs.", + "Early overflow exit — clamping is checked incrementally, avoiding the need to parse the full string when overflow is detected early.", + ], + limitations: [ + 'Stops at the first non-digit — `"123abc"` returns `123`, which may be unexpected if strict validation is required.', + "Only handles decimal integers — hexadecimal (`0x…`), octal (`0…`), or floating-point strings are not supported.", + 'Locale-insensitive — thousands separators (e.g. `"1,000"`) and non-ASCII digit characters are treated as non-digit terminators.', + ], + }, + + whenToUseIt: + "Use atoi-style parsing when you need a fast, allocation-free conversion from a decimal string to a bounded integer and you are willing to accept silent truncation at the first non-digit.\n\n" + + "Prefer `parseInt` with a radix, `Number()`, or a strict validation library when you need to detect malformed input, support non-decimal bases, or handle locale-specific formatting. " + + "Avoid raw atoi in security-sensitive contexts (e.g. parsing untrusted network data) without explicit range validation, since silent clamping can mask logic errors.", +}; diff --git a/src/algorithms/strings/transformation/string-to-integer/index.ts b/src/algorithms/strings/transformation/string-to-integer/index.ts new file mode 100644 index 00000000..d3cbe82d --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for String to Integer (atoi) — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { stringToInteger } from "./sources/string-to-integer.ts?fn"; +import { generateStringToIntegerSteps } from "./step-generator"; +import type { StringToIntegerInput } from "./step-generator"; +import { stringToIntegerEducational } from "./educational"; + +import typescriptSource from "./sources/string-to-integer.ts?raw"; +import pythonSource from "./sources/string-to-integer.py?raw"; +import javaSource from "./sources/StringToInteger.java?raw"; + +function executeStringToInteger(input: StringToIntegerInput): number { + return stringToInteger(input.text) as number; +} + +const stringToIntegerDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.STRING_TO_INTEGER!, + name: "String to Integer (atoi)", + category: CATEGORY.STRINGS!, + technique: "transformation", + description: + "Parse an integer from a string by skipping whitespace, reading an optional sign, consuming digits, and clamping to 32-bit range in O(n) time", + timeComplexity: { + best: "O(1)", + average: "O(n)", + worst: "O(n)", + }, + spaceComplexity: "O(1)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: " -42" }, + }, + execute: executeStringToInteger, + generateSteps: generateStringToIntegerSteps, + educational: stringToIntegerEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(stringToIntegerDefinition); diff --git a/src/algorithms/strings/transformation/string-to-integer/sources/StringToInteger.java b/src/algorithms/strings/transformation/string-to-integer/sources/StringToInteger.java new file mode 100644 index 00000000..17807914 --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/sources/StringToInteger.java @@ -0,0 +1,43 @@ +// String to Integer (atoi) — parse an integer from a string. +// Skips leading whitespace, reads optional sign, reads digits, clamps to 32-bit range. +// Time: O(n) Space: O(1) + +public class StringToInteger { + + public static int stringToInteger(String text) { + int charIndex = 0; // @step:initialize + int length = text.length(); // @step:initialize + + // Phase 1: skip leading whitespace + while (charIndex < length && text.charAt(charIndex) == ' ') { + charIndex++; // @step:skip-whitespace + } + + // Phase 2: read optional sign + int sign = 1; // @step:read-sign + if (charIndex < length && text.charAt(charIndex) == '-') { + sign = -1; // @step:read-sign + charIndex++; // @step:read-sign + } else if (charIndex < length && text.charAt(charIndex) == '+') { + charIndex++; // @step:read-sign + } + + // Phase 3: read digits and accumulate + long result = 0; // @step:read-digits + while (charIndex < length) { + int charCode = text.charAt(charIndex); // @step:read-digits + if (charCode < 48 || charCode > 57) break; // @step:read-digits + + int digit = charCode - 48; // @step:write-char + result = result * 10 + digit; // @step:write-char + + // Clamp early to avoid overflow + if (sign == 1 && result > Integer.MAX_VALUE) return Integer.MAX_VALUE; // @step:write-char + if (sign == -1 && -result < Integer.MIN_VALUE) return Integer.MIN_VALUE; // @step:write-char + + charIndex++; // @step:read-digits + } + + return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, sign * result)); // @step:complete + } +} diff --git a/src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.py b/src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.py new file mode 100644 index 00000000..3deb7f1d --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.py @@ -0,0 +1,43 @@ +# String to Integer (atoi) — parse an integer from a string. +# Skips leading whitespace, reads optional sign, reads digits, clamps to 32-bit range. +# Time: O(n) Space: O(1) + +INT32_MIN = -(2**31) +INT32_MAX = 2**31 - 1 + + +def string_to_integer(text: str) -> int: + char_index = 0 # @step:initialize + length = len(text) # @step:initialize + + # Phase 1: skip leading whitespace + while char_index < length and text[char_index] == " ": + char_index += 1 # @step:skip-whitespace + + # Phase 2: read optional sign + sign = 1 # @step:read-sign + if char_index < length and text[char_index] == "-": + sign = -1 # @step:read-sign + char_index += 1 # @step:read-sign + elif char_index < length and text[char_index] == "+": + char_index += 1 # @step:read-sign + + # Phase 3: read digits and accumulate + result = 0 # @step:read-digits + while char_index < length: + char_code = ord(text[char_index]) # @step:read-digits + if char_code < 48 or char_code > 57: # @step:read-digits + break + + digit = char_code - 48 # @step:write-char + result = result * 10 + digit # @step:write-char + + # Clamp early to avoid overflow + if sign == 1 and result > INT32_MAX: # @step:write-char + return INT32_MAX + if sign == -1 and -result < INT32_MIN: # @step:write-char + return INT32_MIN + + char_index += 1 # @step:read-digits + + return max(INT32_MIN, min(INT32_MAX, sign * result)) # @step:complete diff --git a/src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.ts b/src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.ts new file mode 100644 index 00000000..0839630b --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/sources/string-to-integer.ts @@ -0,0 +1,43 @@ +// String to Integer (atoi) — parse an integer from a string. +// Skips leading whitespace, reads optional sign, reads digits, clamps to 32-bit range. +// Time: O(n) Space: O(1) + +const INT32_MIN = -(2 ** 31); +const INT32_MAX = 2 ** 31 - 1; + +export function stringToInteger(text: string): number { + let charIndex = 0; // @step:initialize + const length = text.length; // @step:initialize + + // Phase 1: skip leading whitespace + while (charIndex < length && text[charIndex] === " ") { + charIndex++; // @step:skip-whitespace + } + + // Phase 2: read optional sign + let sign = 1; // @step:read-sign + if (text[charIndex] === "-") { + sign = -1; // @step:read-sign + charIndex++; // @step:read-sign + } else if (text[charIndex] === "+") { + charIndex++; // @step:read-sign + } + + // Phase 3: read digits and accumulate + let result = 0; // @step:read-digits + while (charIndex < length) { + const charCode = text.charCodeAt(charIndex); // @step:read-digits + if (charCode < 48 || charCode > 57) break; // @step:read-digits + + const digit = charCode - 48; // @step:write-char + result = result * 10 + digit; // @step:write-char + + // Clamp early to avoid overflow in JS + if (sign === 1 && result > INT32_MAX) return INT32_MAX; // @step:write-char + if (sign === -1 && -result < INT32_MIN) return INT32_MIN; // @step:write-char + + charIndex++; // @step:read-digits + } + + return Math.max(INT32_MIN, Math.min(INT32_MAX, sign * result)) || 0; // @step:complete +} diff --git a/src/algorithms/strings/transformation/string-to-integer/step-generator.test.ts b/src/algorithms/strings/transformation/string-to-integer/step-generator.test.ts new file mode 100644 index 00000000..dec73442 --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/step-generator.test.ts @@ -0,0 +1,90 @@ +/** Step-generation tests for generateStringToIntegerSteps. */ + +import { describe, it, expect } from "vitest"; +import { generateStringToIntegerSteps } from "./step-generator"; + +describe("generateStringToIntegerSteps", () => { + it("produces steps for the default input", () => { + const steps = generateStringToIntegerSteps({ text: " -42" }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateStringToIntegerSteps({ text: " -42" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateStringToIntegerSteps({ text: " -42" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-transform visual states throughout", () => { + const steps = generateStringToIntegerSteps({ text: " -42" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-transform"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateStringToIntegerSteps({ text: "42" }); + for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { + expect(steps[stepIndex]?.index).toBe(stepIndex); + } + }); + + it("emits visit steps for each phase transition", () => { + const steps = generateStringToIntegerSteps({ text: " -42" }); + const visitSteps = steps.filter((step) => step.type === "visit"); + // Expect at least 3 visit steps: skip-whitespace, read-sign, read-digits phases + expect(visitSteps.length).toBeGreaterThanOrEqual(3); + }); + + it("emits read-char steps for each whitespace character", () => { + const steps = generateStringToIntegerSteps({ text: " 42" }); + const readSteps = steps.filter((step) => step.type === "read-char"); + // 3 whitespace reads + sign check (no sign char read for plain +) + 2 digit reads = 5 min + expect(readSteps.length).toBeGreaterThanOrEqual(3); + }); + + it("emits write-char steps for each digit", () => { + const steps = generateStringToIntegerSteps({ text: "42" }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + // One write step per digit: '4' and '2' + expect(writeSteps.length).toBe(2); + }); + + it("produces no write-char steps when input has no digits", () => { + const steps = generateStringToIntegerSteps({ text: "abc" }); + const writeSteps = steps.filter((step) => step.type === "write-char"); + expect(writeSteps.length).toBe(0); + }); + + it("complete step variables contain the expected result for default input", () => { + const steps = generateStringToIntegerSteps({ text: " -42" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe(-42); + }); + + it("complete step variables contain 0 for non-digit input", () => { + const steps = generateStringToIntegerSteps({ text: "words" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.variables["result"]).toBe(0); + }); + + it("correctly records swaps metric equal to the number of digits written", () => { + const steps = generateStringToIntegerSteps({ text: "4193" }); + const completeStep = steps[steps.length - 1]!; + // 4 digits → 4 writeChar calls → swaps metric = 4 + expect(completeStep.metrics.swaps).toBe(4); + }); + + it("handles empty string without throwing", () => { + expect(() => generateStringToIntegerSteps({ text: "" })).not.toThrow(); + }); + + it("clamps overflow and terminates early, still ending with complete step", () => { + const steps = generateStringToIntegerSteps({ text: "99999999999999999" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); +}); diff --git a/src/algorithms/strings/transformation/string-to-integer/step-generator.ts b/src/algorithms/strings/transformation/string-to-integer/step-generator.ts new file mode 100644 index 00000000..ceb42c59 --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/step-generator.ts @@ -0,0 +1,83 @@ +/** Step generator for String to Integer (atoi) — produces ExecutionStep[] using TransformTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TransformTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const STRING_TO_INTEGER_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.STRING_TO_INTEGER!); + +const INT32_MIN = -(2 ** 31); +const INT32_MAX = 2 ** 31 - 1; + +export interface StringToIntegerInput { + text: string; +} + +export function generateStringToIntegerSteps(input: StringToIntegerInput): ExecutionStep[] { + const { text } = input; + const tracker = new TransformTracker(text, STRING_TO_INTEGER_LINE_MAP); + + tracker.initialize({ text, charIndex: 0, sign: 1, result: 0 }); + + let charIndex = 0; + const length = text.length; + + // Phase 1: skip leading whitespace + tracker.setPhase("skip-whitespace", { charIndex, sign: 1, result: 0 }); + + while (charIndex < length && text[charIndex] === " ") { + tracker.readChar(charIndex, { charIndex, phase: "skip-whitespace" }); + charIndex++; + } + + // Phase 2: read optional sign + tracker.setPhase("read-sign", { charIndex, sign: 1, result: 0 }); + + let sign = 1; + if (text[charIndex] === "-") { + tracker.readChar(charIndex, { charIndex, sign: -1, result: 0 }); + sign = -1; + charIndex++; + } else if (text[charIndex] === "+") { + tracker.readChar(charIndex, { charIndex, sign: 1, result: 0 }); + charIndex++; + } + + // Phase 3: read digits and accumulate + tracker.setPhase("read-digits", { charIndex, sign, result: 0 }); + + let result = 0; + while (charIndex < length) { + const charCode = text.charCodeAt(charIndex); + if (charCode < 48 || charCode > 57) break; + + tracker.readChar(charIndex, { charIndex, sign, result }); + + const digit = charCode - 48; + result = result * 10 + digit; + + // Write the digit into the output buffer + tracker.writeChar(String(digit), { charIndex, sign, result }); + + // Update auxiliary data showing running total + const currentValue = Math.max(INT32_MIN, Math.min(INT32_MAX, sign * result)); + tracker.setAuxiliaryData(String(currentValue), { charIndex, sign, result, currentValue }); + + // Clamp early + if (sign === 1 && result > INT32_MAX) { + tracker.complete({ result: INT32_MAX }); + return tracker.getSteps(); + } + if (sign === -1 && -result < INT32_MIN) { + tracker.complete({ result: INT32_MIN }); + return tracker.getSteps(); + } + + charIndex++; + } + + const finalResult = Math.max(INT32_MIN, Math.min(INT32_MAX, sign * result)) || 0; + tracker.complete({ result: finalResult }); + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/transformation/string-to-integer/string-to-integer.test.ts b/src/algorithms/strings/transformation/string-to-integer/string-to-integer.test.ts new file mode 100644 index 00000000..ca836be7 --- /dev/null +++ b/src/algorithms/strings/transformation/string-to-integer/string-to-integer.test.ts @@ -0,0 +1,73 @@ +/** Correctness tests for the stringToInteger function. */ + +import { describe, it, expect } from "vitest"; +import { stringToInteger } from "./sources/string-to-integer.ts?fn"; + +const INT32_MIN = -(2 ** 31); +const INT32_MAX = 2 ** 31 - 1; + +describe("stringToInteger", () => { + it("parses a plain positive integer", () => { + expect(stringToInteger("42")).toBe(42); + }); + + it("parses a negative integer with leading whitespace", () => { + expect(stringToInteger(" -42")).toBe(-42); + }); + + it("stops at non-digit characters after valid digits", () => { + expect(stringToInteger("4193 with words")).toBe(4193); + }); + + it("returns 0 when the string starts with a non-digit non-sign character", () => { + expect(stringToInteger("words and 987")).toBe(0); + }); + + it("returns 0 for an empty string", () => { + expect(stringToInteger("")).toBe(0); + }); + + it("returns 0 for a string containing only whitespace", () => { + expect(stringToInteger(" ")).toBe(0); + }); + + it("parses a positive integer with an explicit plus sign", () => { + expect(stringToInteger("+100")).toBe(100); + }); + + it("parses the number zero", () => { + expect(stringToInteger("0")).toBe(0); + }); + + it("clamps a value exceeding INT32_MAX to INT32_MAX", () => { + expect(stringToInteger("2147483648")).toBe(INT32_MAX); + }); + + it("clamps a value below INT32_MIN to INT32_MIN", () => { + expect(stringToInteger("-2147483649")).toBe(INT32_MIN); + }); + + it("clamps an extremely large number to INT32_MAX", () => { + expect(stringToInteger("99999999999999999")).toBe(INT32_MAX); + }); + + it("clamps an extremely large negative number to INT32_MIN", () => { + expect(stringToInteger("-99999999999999999")).toBe(INT32_MIN); + }); + + it("handles leading whitespace before a positive number", () => { + expect(stringToInteger(" 123")).toBe(123); + }); + + it("stops reading at the first non-digit after sign", () => { + expect(stringToInteger("-abc")).toBe(0); + }); + + it("parses INT32_MAX exactly", () => { + expect(stringToInteger("2147483647")).toBe(INT32_MAX); + }); + + it("parses INT32_MIN exactly", () => { + expect(stringToInteger("-2147483648")).toBe(INT32_MIN); + }); +}); diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/AhoCorasickSearchPipeline.stories.tsx b/src/algorithms/strings/trie-operations/aho-corasick-search/AhoCorasickSearchPipeline.stories.tsx new file mode 100644 index 00000000..17449eaa --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/AhoCorasickSearchPipeline.stories.tsx @@ -0,0 +1,64 @@ +/** + * Storybook stories for the Aho-Corasick Search algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TrieVisualizer at key execution states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TrieVisualState } from "@/types"; +import { generateAhoCorasickSearchSteps } from "./step-generator"; +import TrieVisualizer from "@/components/visualization/TrieVisualizer"; + +const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Aho-Corasick Search", + component: TrieVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty trie with root node only */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TrieVisualState, + }, +}; + +/** Insert phase — patterns partially inserted into the trie */ +export const InsertPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.25)]!.visualState as TrieVisualState, + }, +}; + +/** Failure links phase — BFS building failure and output links */ +export const FailureLinksPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.5)]!.visualState as TrieVisualState, + }, +}; + +/** Search phase — scanning the text character by character */ +export const SearchPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.8)]!.visualState as TrieVisualState, + }, +}; + +/** Final state — all matched patterns collected */ +export const SearchComplete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TrieVisualState, + }, +}; diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/aho-corasick-search.test.ts b/src/algorithms/strings/trie-operations/aho-corasick-search/aho-corasick-search.test.ts new file mode 100644 index 00000000..72ac4743 --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/aho-corasick-search.test.ts @@ -0,0 +1,81 @@ +/** Correctness tests for the Aho-Corasick Search pure implementation. */ + +import { describe, it, expect } from "vitest"; +import { ahoCorasickSearch } from "./sources/aho-corasick-search.ts?fn"; + +describe("ahoCorasickSearch", () => { + it("finds all patterns in the classic example", () => { + const result = ahoCorasickSearch("ahishers", ["he", "she", "his", "hers"]) as string[]; + expect(result.sort()).toEqual(["he", "hers", "his", "she"].sort()); + }); + + it("returns an empty array when no patterns are found", () => { + const result = ahoCorasickSearch("hello world", ["xyz", "abc"]) as string[]; + expect(result).toHaveLength(0); + }); + + it("returns an empty array when patterns list is empty", () => { + const result = ahoCorasickSearch("hello", []) as string[]; + expect(result).toHaveLength(0); + }); + + it("returns an empty array when text is empty", () => { + const result = ahoCorasickSearch("", ["hello", "world"]) as string[]; + expect(result).toHaveLength(0); + }); + + it("finds a single pattern that appears once", () => { + const result = ahoCorasickSearch("banana", ["nan"]) as string[]; + expect(result).toEqual(["nan"]); + }); + + it("finds a pattern that appears multiple times — reported only once", () => { + const result = ahoCorasickSearch("aaaa", ["aa"]) as string[]; + expect(result).toEqual(["aa"]); + }); + + it("finds overlapping patterns", () => { + const result = ahoCorasickSearch("aabc", ["a", "aa", "aab"]) as string[]; + expect(result.sort()).toEqual(["a", "aa", "aab"].sort()); + }); + + it("finds a pattern that is a prefix of another pattern", () => { + const result = ahoCorasickSearch("app", ["app", "ap"]) as string[]; + expect(result.sort()).toEqual(["ap", "app"].sort()); + }); + + it("finds a pattern equal to the full text", () => { + const result = ahoCorasickSearch("hello", ["hello"]) as string[]; + expect(result).toEqual(["hello"]); + }); + + it("handles single-character patterns", () => { + const result = ahoCorasickSearch("abcabc", ["a", "b"]) as string[]; + expect(result.sort()).toEqual(["a", "b"].sort()); + }); + + it("returns only found patterns, not all patterns", () => { + const result = ahoCorasickSearch("cat", ["cat", "dog", "bird"]) as string[]; + expect(result).toEqual(["cat"]); + }); + + it("handles patterns with no shared prefix", () => { + const result = ahoCorasickSearch("foobar", ["foo", "bar"]) as string[]; + expect(result.sort()).toEqual(["bar", "foo"].sort()); + }); + + it("handles case sensitivity — does not find case-mismatched patterns", () => { + const result = ahoCorasickSearch("Hello", ["hello"]) as string[]; + expect(result).toHaveLength(0); + }); + + it("finds a pattern at the very end of the text", () => { + const result = ahoCorasickSearch("xyzabc", ["abc"]) as string[]; + expect(result).toEqual(["abc"]); + }); + + it("finds a pattern at the very start of the text", () => { + const result = ahoCorasickSearch("abcxyz", ["abc"]) as string[]; + expect(result).toEqual(["abc"]); + }); +}); diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/educational.ts b/src/algorithms/strings/trie-operations/aho-corasick-search/educational.ts new file mode 100644 index 00000000..41879568 --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/educational.ts @@ -0,0 +1,78 @@ +/** Educational content for Aho-Corasick Search. */ + +import type { EducationalContent } from "@/types"; + +export const ahoCorasickSearchEducational: EducationalContent = { + overview: + "**Aho-Corasick Search** is a multi-pattern string matching algorithm that finds all occurrences of a " + + "set of patterns in a text in a single linear pass.\n\n" + + "It combines two ideas:\n\n" + + "- **Trie**: all patterns are inserted into a prefix tree, giving O(m) build time where m is the total length of all patterns.\n" + + "- **Failure links** (inspired by KMP): each trie node stores a pointer to the longest proper suffix of its string " + + "that is also a prefix of some pattern. These links let the algorithm recover from mismatches without re-scanning the text.\n\n" + + "The result is an automaton that scans the text exactly once, never moving backwards.", + + howItWorks: + "**Phase 1 — Build the trie:**\n\n" + + "1. Create an empty root node.\n" + + "2. For each pattern, walk character by character from the root, creating new nodes when a child edge does not yet exist.\n" + + "3. Mark the final node with the completed pattern (end-of-word marker).\n\n" + + "**Phase 2 — Build failure links (BFS):**\n\n" + + "1. All direct children of root get a failure link pointing back to root.\n" + + "2. Process nodes level by level (BFS). For node `v` reached by edge character `c` from parent `p`:\n" + + " - Follow `p`'s failure link to find the longest proper suffix state `f` that has an outgoing edge on `c`.\n" + + " - Set `v`'s failure link to `f.children[c]` (or root if none exists).\n" + + "3. Propagate output patterns: if `v`'s failure link node matches any pattern, those patterns are copied to `v` as well.\n\n" + + "**Phase 3 — Search text:**\n\n" + + "1. Start at root. For each text character `c`:\n" + + " - While the current node has no child edge `c`, follow failure links.\n" + + " - If a child edge `c` exists, move to that child.\n" + + " - Collect all output patterns at the current node (matches at this text position).", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n + m + z)`**\n\n" + + "- Build trie: `O(m)` — insert `m` total pattern characters.\n" + + "- Build failure links: `O(m)` — each node is visited once in BFS.\n" + + "- Search: `O(n + z)` — the text cursor never moves backwards; `z` extra steps emit matches.\n" + + "- Overall: `O(n + m + z)` where `n` = text length, `m` = total pattern length, `z` = number of matches.\n\n" + + "**Space Complexity: `O(m × k)`**\n\n" + + "Each trie node stores up to `k` child pointers (alphabet size). With `m` total pattern characters, " + + "the trie has at most `m + 1` nodes, giving `O(m × k)` space. Output patterns stored at each node " + + "add at most `O(m)` extra space in total.", + + bestAndWorstCase: + "**Best case** — no patterns appear in the text: `O(n + m)`. " + + "The search phase does `n` steps and emits zero matches, so the `z` term vanishes.\n\n" + + "**Worst case** — every character of the text matches the start of every pattern, and all patterns are " + + "found at every position: `O(n + m + z)` where `z` can be as large as `n × p` (p = number of patterns). " + + "This is unavoidable because reporting each match takes constant time per match, and there genuinely are `z` matches to report.\n\n" + + "Compared with running `p` separate KMP searches, Aho-Corasick improves the text scan from `O(n × p)` to `O(n + z)`, " + + "a significant win when the pattern set is large.", + + realWorldUses: [ + "**Anti-virus scanning:** Virus databases contain thousands of byte-sequence signatures; Aho-Corasick scans a file once for all of them simultaneously.", + "**Network intrusion detection (Snort/Suricata):** Packet payloads are matched against hundreds of attack signatures in a single pass.", + "**Search engines and grep tools:** Multi-keyword search in large corpora uses Aho-Corasick variants to avoid repeated passes.", + "**Bioinformatics:** DNA/protein sequence databases are scanned for multiple probe sequences at once.", + "**Spam and content filters:** Email bodies are checked for many forbidden phrases in a single linear scan.", + ], + + strengthsAndLimitations: { + strengths: [ + "Single-pass text scan regardless of how many patterns are searched — critical for large texts.", + "Optimal worst-case time O(n + m + z); no pattern can individually slow the search.", + "Once the automaton is built it can be reused for many texts at no extra build cost.", + ], + limitations: [ + "Building the automaton takes O(m) time and O(m × k) space upfront — worthwhile only when the text is long or the automaton is reused.", + "For a single pattern, KMP or Boyer-Moore can have better constants in practice.", + "The failure-link construction is more complex to implement correctly than naive multi-pattern search.", + ], + }, + + whenToUseIt: + "Use Aho-Corasick when you need to search for **multiple patterns simultaneously** in a long text — " + + "especially when the pattern set is fixed and reused across many texts (e.g., a firewall signature database). " + + "For a single pattern, prefer KMP. For small texts or one-off searches, a hash-set of substrings may be simpler. " + + "Consider a compressed trie (Aho-Corasick on a DAWG) when the pattern alphabet is large and memory is constrained.", +}; diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/index.ts b/src/algorithms/strings/trie-operations/aho-corasick-search/index.ts new file mode 100644 index 00000000..f84edf80 --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Aho-Corasick Search — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { ahoCorasickSearch } from "./sources/aho-corasick-search.ts?fn"; +import { generateAhoCorasickSearchSteps } from "./step-generator"; +import type { AhoCorasickSearchInput } from "./step-generator"; +import { ahoCorasickSearchEducational } from "./educational"; + +import typescriptSource from "./sources/aho-corasick-search.ts?raw"; +import pythonSource from "./sources/aho-corasick-search.py?raw"; +import javaSource from "./sources/AhoCorasickSearch.java?raw"; + +function executeAhoCorasickSearch(input: AhoCorasickSearchInput): string[] { + return ahoCorasickSearch(input.text, input.patterns) as string[]; +} + +const ahoCorasickSearchDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.AHO_CORASICK_SEARCH!, + name: "Aho-Corasick Search", + category: CATEGORY.STRINGS!, + technique: "trie-operations", + description: + "Build a trie from multiple patterns, add BFS-computed failure links, then scan text once to find all occurrences of all patterns in O(n + m + z) time", + timeComplexity: { + best: "O(n + m)", + average: "O(n + m + z)", + worst: "O(n + m + z)", + }, + spaceComplexity: "O(m × k)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { text: "ahishers", patterns: ["he", "she", "his", "hers"] }, + }, + execute: executeAhoCorasickSearch, + generateSteps: generateAhoCorasickSearchSteps, + educational: ahoCorasickSearchEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(ahoCorasickSearchDefinition); diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/sources/AhoCorasickSearch.java b/src/algorithms/strings/trie-operations/aho-corasick-search/sources/AhoCorasickSearch.java new file mode 100644 index 00000000..09d313b8 --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/sources/AhoCorasickSearch.java @@ -0,0 +1,103 @@ +// Aho-Corasick Search +// Multi-pattern string search using a trie augmented with failure links. +// Phase 1: Insert all patterns into a trie. +// Phase 2: Build failure links via BFS (similar to KMP failure function but for a trie). +// Phase 3: Scan text once, following failure links on mismatch, collecting all pattern matches. +// Time: O(n + m + z) where n = text length, m = total pattern chars, z = match count +// Space: O(m * k) where k = alphabet size + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +public class AhoCorasickSearch { + + private static class AhoCorasickNode { // @step:initialize + Map children = new HashMap<>(); // @step:initialize + AhoCorasickNode failureLink = null; // @step:initialize + List outputPatterns = new ArrayList<>(); // @step:initialize + boolean isEnd = false; // @step:initialize + } + + public static List ahoCorasickSearch(String text, List patterns) { + AhoCorasickNode root = new AhoCorasickNode(); // @step:initialize + + // Phase 1: Insert all patterns into the trie + for (String pattern : patterns) { // @step:visit + AhoCorasickNode current = root; // @step:visit + for (char ch : pattern.toCharArray()) { // @step:insert-trie + current.children.putIfAbsent(ch, new AhoCorasickNode()); // @step:insert-trie + current = current.children.get(ch); // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + current.outputPatterns.add(pattern); // @step:mark-end-word + } + + // Phase 2: Build failure links via BFS + Queue bfsQueue = new ArrayDeque<>(); // @step:buildFailureLinks + + for (AhoCorasickNode child : root.children.values()) { // @step:buildFailureLinks + child.failureLink = root; // @step:buildFailureLinks + bfsQueue.add(child); // @step:buildFailureLinks + } + + while (!bfsQueue.isEmpty()) { // @step:buildFailureLinks + AhoCorasickNode current = bfsQueue.poll(); // @step:buildFailureLinks + + for (Map.Entry entry : current.children.entrySet()) { // @step:buildFailureLinks + char ch = entry.getKey(); // @step:buildFailureLinks + AhoCorasickNode childNode = entry.getValue(); // @step:buildFailureLinks + AhoCorasickNode failureState = current.failureLink; // @step:buildFailureLinks + + while (failureState != null && !failureState.children.containsKey(ch)) { // @step:buildFailureLinks + failureState = failureState.failureLink; // @step:buildFailureLinks + } + + if (failureState != null) { // @step:buildFailureLinks + AhoCorasickNode candidate = failureState.children.get(ch); // @step:buildFailureLinks + childNode.failureLink = (candidate != null) ? candidate : root; // @step:buildFailureLinks + } else { // @step:buildFailureLinks + childNode.failureLink = root; // @step:buildFailureLinks + } + + if (childNode.failureLink == childNode) { // @step:buildFailureLinks + childNode.failureLink = root; // @step:buildFailureLinks + } + + // Propagate output patterns from failure link + for (String outputPattern : childNode.failureLink.outputPatterns) { // @step:buildFailureLinks + if (!childNode.outputPatterns.contains(outputPattern)) { // @step:buildFailureLinks + childNode.outputPatterns.add(outputPattern); // @step:buildFailureLinks + } + } + + bfsQueue.add(childNode); // @step:buildFailureLinks + } + } + + // Phase 3: Search text using the automaton + Set foundPatterns = new HashSet<>(); // @step:traverse-trie + AhoCorasickNode current = root; // @step:traverse-trie + + for (char ch : text.toCharArray()) { // @step:traverse-trie + while (current != root && !current.children.containsKey(ch)) { // @step:traverse-trie + current = current.failureLink; // @step:traverse-trie + } + + if (current.children.containsKey(ch)) { // @step:traverse-trie + current = current.children.get(ch); // @step:traverse-trie + } + + for (String matchedPattern : current.outputPatterns) { // @step:found + foundPatterns.add(matchedPattern); // @step:found + } + } + + return new ArrayList<>(foundPatterns); // @step:complete + } +} diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.py b/src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.py new file mode 100644 index 00000000..a24996fa --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.py @@ -0,0 +1,79 @@ +# Aho-Corasick Search +# Multi-pattern string search using a trie augmented with failure links. +# Phase 1: Insert all patterns into a trie. +# Phase 2: Build failure links via BFS (similar to KMP failure function but for a trie). +# Phase 3: Scan text once, following failure links on mismatch, collecting all pattern matches. +# Time: O(n + m + z) where n = text length, m = total pattern chars, z = match count +# Space: O(m * k) where k = alphabet size + +from collections import deque +from dataclasses import dataclass, field + + +@dataclass +class AhoCorasickNode: # @step:initialize + children: dict[str, "AhoCorasickNode"] = field(default_factory=dict) # @step:initialize + failure_link: "AhoCorasickNode | None" = None # @step:initialize + output_patterns: list[str] = field(default_factory=list) # @step:initialize + is_end: bool = False # @step:initialize + + +def aho_corasick_search(text: str, patterns: list[str]) -> list[str]: + root = AhoCorasickNode() # @step:initialize + + # Phase 1: Insert all patterns into the trie + for pattern in patterns: # @step:visit + current = root # @step:visit + for char in pattern: # @step:insert-trie + if char not in current.children: # @step:insert-trie + current.children[char] = AhoCorasickNode() # @step:insert-trie + current = current.children[char] # @step:traverse-trie + current.is_end = True # @step:mark-end-word + current.output_patterns.append(pattern) # @step:mark-end-word + + # Phase 2: Build failure links via BFS + bfs_queue: deque[AhoCorasickNode] = deque() # @step:buildFailureLinks + + for child in root.children.values(): # @step:buildFailureLinks + child.failure_link = root # @step:buildFailureLinks + bfs_queue.append(child) # @step:buildFailureLinks + + while bfs_queue: # @step:buildFailureLinks + current = bfs_queue.popleft() # @step:buildFailureLinks + + for char, child_node in current.children.items(): # @step:buildFailureLinks + failure_state = current.failure_link # @step:buildFailureLinks + + while failure_state is not None and char not in failure_state.children: # @step:buildFailureLinks + failure_state = failure_state.failure_link # @step:buildFailureLinks + + if failure_state: # @step:buildFailureLinks + child_node.failure_link = failure_state.children.get(char, root) # @step:buildFailureLinks + else: # @step:buildFailureLinks + child_node.failure_link = root # @step:buildFailureLinks + + if child_node.failure_link is child_node: # @step:buildFailureLinks + child_node.failure_link = root # @step:buildFailureLinks + + # Propagate output patterns from failure link + for output_pattern in child_node.failure_link.output_patterns: # @step:buildFailureLinks + if output_pattern not in child_node.output_patterns: # @step:buildFailureLinks + child_node.output_patterns.append(output_pattern) # @step:buildFailureLinks + + bfs_queue.append(child_node) # @step:buildFailureLinks + + # Phase 3: Search text using the automaton + found_patterns: set[str] = set() # @step:traverse-trie + current = root # @step:traverse-trie + + for char in text: # @step:traverse-trie + while current is not root and char not in current.children: # @step:traverse-trie + current = current.failure_link # @step:traverse-trie + + if char in current.children: # @step:traverse-trie + current = current.children[char] # @step:traverse-trie + + for matched_pattern in current.output_patterns: # @step:found + found_patterns.add(matched_pattern) # @step:found + + return list(found_patterns) # @step:complete diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.ts b/src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.ts new file mode 100644 index 00000000..3d14ba61 --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/sources/aho-corasick-search.ts @@ -0,0 +1,105 @@ +// Aho-Corasick Search +// Multi-pattern string search using a trie augmented with failure links. +// Phase 1: Insert all patterns into a trie. +// Phase 2: Build failure links via BFS (similar to KMP failure function but for a trie). +// Phase 3: Scan text once, following failure links on mismatch, collecting all pattern matches. +// Time: O(n + m + z) where n = text length, m = total pattern chars, z = match count +// Space: O(m * k) where k = alphabet size + +interface AhoCorasickNode { + children: Map; + failureLink: AhoCorasickNode | null; + outputPatterns: string[]; + isEnd: boolean; +} + +function createAhoCorasickNode(): AhoCorasickNode { + // @step:initialize + return { children: new Map(), failureLink: null, outputPatterns: [], isEnd: false }; // @step:initialize +} + +export function ahoCorasickSearch(text: string, patterns: string[]): string[] { + const root = createAhoCorasickNode(); // @step:initialize + + // Phase 1: Insert all patterns into the trie + for (const pattern of patterns) { + // @step:visit + let current = root; // @step:visit + for (const char of pattern) { + // @step:insert-trie + if (!current.children.has(char)) { + // @step:insert-trie + current.children.set(char, createAhoCorasickNode()); // @step:insert-trie + } + current = current.children.get(char)!; // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + current.outputPatterns.push(pattern); // @step:mark-end-word + } + + // Phase 2: Build failure links via BFS + const bfsQueue: AhoCorasickNode[] = []; // @step:buildFailureLinks + + for (const child of root.children.values()) { + // @step:buildFailureLinks + child.failureLink = root; // @step:buildFailureLinks + bfsQueue.push(child); // @step:buildFailureLinks + } + + while (bfsQueue.length > 0) { + // @step:buildFailureLinks + const current = bfsQueue.shift()!; // @step:buildFailureLinks + + for (const [char, childNode] of current.children.entries()) { + // @step:buildFailureLinks + let failureState = current.failureLink; // @step:buildFailureLinks + + while (failureState !== null && !failureState.children.has(char)) { + // @step:buildFailureLinks + failureState = failureState.failureLink; // @step:buildFailureLinks + } + + childNode.failureLink = failureState ? (failureState.children.get(char) ?? root) : root; // @step:buildFailureLinks + + if (childNode.failureLink === childNode) { + // @step:buildFailureLinks + childNode.failureLink = root; // @step:buildFailureLinks + } + + // Propagate output patterns from failure link + for (const outputPattern of childNode.failureLink.outputPatterns) { + // @step:buildFailureLinks + if (!childNode.outputPatterns.includes(outputPattern)) { + // @step:buildFailureLinks + childNode.outputPatterns.push(outputPattern); // @step:buildFailureLinks + } + } + + bfsQueue.push(childNode); // @step:buildFailureLinks + } + } + + // Phase 3: Search text using the automaton + const foundPatterns = new Set(); // @step:traverse-trie + let current = root; // @step:traverse-trie + + for (const char of text) { + // @step:traverse-trie + while (current !== root && !current.children.has(char)) { + // @step:traverse-trie + current = current.failureLink!; // @step:traverse-trie + } + + if (current.children.has(char)) { + // @step:traverse-trie + current = current.children.get(char)!; // @step:traverse-trie + } + + for (const matchedPattern of current.outputPatterns) { + // @step:found + foundPatterns.add(matchedPattern); // @step:found + } + } + + return Array.from(foundPatterns); // @step:complete +} diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.test.ts b/src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.test.ts new file mode 100644 index 00000000..1a36e63d --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.test.ts @@ -0,0 +1,136 @@ +/** Step generation tests for Aho-Corasick Search. */ + +import { describe, it, expect } from "vitest"; +import { generateAhoCorasickSearchSteps } from "./step-generator"; + +describe("generateAhoCorasickSearchSteps", () => { + it("produces steps for the default input", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-trie visual states throughout", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-trie"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits insert-trie steps during the insert phase", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "abc", + patterns: ["ab", "bc"], + }); + const insertSteps = steps.filter((step) => step.type === "insert-trie"); + expect(insertSteps.length).toBeGreaterThan(0); + }); + + it("emits mark-end-word steps — one per unique pattern inserted", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "abc", + patterns: ["ab", "bc"], + }); + const endWordSteps = steps.filter((step) => step.type === "mark-end-word"); + expect(endWordSteps.length).toBe(2); + }); + + it("emits build-failure steps after the insert phase", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + const failureSteps = steps.filter((step) => step.type === "build-failure"); + expect(failureSteps.length).toBeGreaterThan(0); + }); + + it("emits found steps when patterns are matched", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBeGreaterThan(0); + }); + + it("does not emit found steps when no patterns match", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "xyz", + patterns: ["abc", "def"], + }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(0); + }); + + it("emits traverse-trie steps during the search phase", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "ahishers", + patterns: ["he", "she", "his", "hers"], + }); + const traverseSteps = steps.filter((step) => step.type === "traverse-trie"); + expect(traverseSteps.length).toBeGreaterThan(0); + }); + + it("trie nodes in final state include root plus all inserted pattern characters", () => { + // patterns "ab" and "cd" share no prefix — 4 unique chars + root = 5 nodes + const steps = generateAhoCorasickSearchSteps({ + text: "abcd", + patterns: ["ab", "cd"], + }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.visualState.kind).toBe("string-trie"); + if (lastStep.visualState.kind === "string-trie") { + expect(lastStep.visualState.nodes.length).toBe(5); + } + }); + + it("produces correct number of found steps for single pattern match", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "hello", + patterns: ["hell", "world"], + }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(1); + }); + + it("handles empty patterns list with minimal steps", () => { + const steps = generateAhoCorasickSearchSteps({ + text: "hello", + patterns: [], + }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); +}); diff --git a/src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.ts b/src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.ts new file mode 100644 index 00000000..468d7c7c --- /dev/null +++ b/src/algorithms/strings/trie-operations/aho-corasick-search/step-generator.ts @@ -0,0 +1,214 @@ +/** Step generator for Aho-Corasick Search — produces ExecutionStep[] using TrieTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TrieTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const AHO_CORASICK_SEARCH_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.AHO_CORASICK_SEARCH!); + +export interface AhoCorasickSearchInput { + text: string; + patterns: string[]; +} + +interface AhoCorasickNodeInternal { + nodeId: number; + failureLinkNodeId: number; // 0 = root + outputPatterns: string[]; + isEnd: boolean; +} + +export function generateAhoCorasickSearchSteps(input: AhoCorasickSearchInput): ExecutionStep[] { + const { text, patterns } = input; + const tracker = new TrieTracker(AHO_CORASICK_SEARCH_LINE_MAP); + + tracker.initialize({ text, patterns }); + + // childrenMap: nodeId -> (char -> childNodeId) + const childrenMap = new Map>(); + childrenMap.set(0, new Map()); + + // nodeMetaMap: nodeId -> AhoCorasickNodeInternal + const nodeMetaMap = new Map(); + nodeMetaMap.set(0, { nodeId: 0, failureLinkNodeId: 0, outputPatterns: [], isEnd: false }); + + // Phase 1: Insert all patterns into the trie + for (const pattern of patterns) { + tracker.setSearchWord(pattern, { currentPattern: pattern, phase: "insert" }); + + let parentId = 0; + + for (let charIdx = 0; charIdx < pattern.length; charIdx++) { + const char = pattern[charIdx]!; + const parentChildren = childrenMap.get(parentId) ?? new Map(); + const existingChildId = parentChildren.get(char); + + if (existingChildId !== undefined) { + tracker.traverseEdge(parentId, existingChildId, { + currentPattern: pattern, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + tracker.insertChar(existingChildId, char, { + currentPattern: pattern, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + parentId = existingChildId; + } else { + const newNodeId = tracker.createNode(parentId, char, { + currentPattern: pattern, + char, + charIdx, + phase: "insert", + }); + parentChildren.set(char, newNodeId); + childrenMap.set(parentId, parentChildren); + childrenMap.set(newNodeId, new Map()); + nodeMetaMap.set(newNodeId, { + nodeId: newNodeId, + failureLinkNodeId: 0, + outputPatterns: [], + isEnd: false, + }); + parentId = newNodeId; + } + } + + // Mark end of pattern — update node meta with output pattern + const endMeta = nodeMetaMap.get(parentId); + if (endMeta) { + endMeta.isEnd = true; + endMeta.outputPatterns.push(pattern); + } + tracker.markEndOfWord(parentId, { currentPattern: pattern, phase: "insert" }); + } + + // Phase 2: Build failure links via BFS + const bfsQueue: number[] = []; + + // Direct children of root get failure link pointing to root (node 0) + const rootChildren = childrenMap.get(0) ?? new Map(); + for (const childNodeId of rootChildren.values()) { + const childMeta = nodeMetaMap.get(childNodeId); + if (childMeta) { + childMeta.failureLinkNodeId = 0; + } + tracker.buildFailureLinks(childNodeId, 0, { + fromNodeId: childNodeId, + toNodeId: 0, + phase: "build-failure-links", + }); + bfsQueue.push(childNodeId); + } + + let bfsIndex = 0; + while (bfsIndex < bfsQueue.length) { + const currentNodeId = bfsQueue[bfsIndex]!; + bfsIndex += 1; + + const currentChildren = childrenMap.get(currentNodeId) ?? new Map(); + const currentMeta = nodeMetaMap.get(currentNodeId); + + for (const [char, childNodeId] of currentChildren.entries()) { + // Walk failure links of current node to find the longest proper suffix that has char as a child + let failureStateId = currentMeta?.failureLinkNodeId ?? 0; + + while (failureStateId !== 0) { + const failureChildren = childrenMap.get(failureStateId) ?? new Map(); + if (failureChildren.has(char)) break; + const failureMeta = nodeMetaMap.get(failureStateId); + failureStateId = failureMeta?.failureLinkNodeId ?? 0; + } + + // Determine the failure link target for this child + const failureStateChildren = childrenMap.get(failureStateId) ?? new Map(); + let failureLinkTarget = failureStateChildren.get(char) ?? 0; + if (failureLinkTarget === childNodeId) { + failureLinkTarget = 0; + } + + const childMeta = nodeMetaMap.get(childNodeId); + if (childMeta) { + childMeta.failureLinkNodeId = failureLinkTarget; + + // Propagate output patterns from the failure link node + const failureLinkMeta = nodeMetaMap.get(failureLinkTarget); + if (failureLinkMeta) { + for (const outputPattern of failureLinkMeta.outputPatterns) { + if (!childMeta.outputPatterns.includes(outputPattern)) { + childMeta.outputPatterns.push(outputPattern); + } + } + } + } + + tracker.buildFailureLinks(childNodeId, failureLinkTarget, { + fromNodeId: childNodeId, + toNodeId: failureLinkTarget, + phase: "build-failure-links", + }); + + bfsQueue.push(childNodeId); + } + } + + // Phase 3: Search text using the automaton + tracker.setSearchWord(text, { text, phase: "search" }); + + const foundPatterns = new Set(); + let currentNodeId = 0; + + for (let textIdx = 0; textIdx < text.length; textIdx++) { + const char = text[textIdx]!; + + // Follow failure links until we find a node with a child for char, or reach root + while (currentNodeId !== 0) { + const currentChildren = childrenMap.get(currentNodeId) ?? new Map(); + if (currentChildren.has(char)) break; + const currentMeta = nodeMetaMap.get(currentNodeId); + currentNodeId = currentMeta?.failureLinkNodeId ?? 0; + } + + const currentChildren = childrenMap.get(currentNodeId) ?? new Map(); + const nextNodeId = currentChildren.get(char); + const edgeFound = nextNodeId !== undefined; + + tracker.searchChar(edgeFound ? nextNodeId! : currentNodeId, textIdx, edgeFound, { + text, + char, + textIdx, + phase: "search", + }); + + if (edgeFound) { + currentNodeId = nextNodeId!; + } + + // Check for matches at current node (including propagated output patterns) + const currentMeta = nodeMetaMap.get(currentNodeId); + if (currentMeta && currentMeta.outputPatterns.length > 0) { + for (const matchedPattern of currentMeta.outputPatterns) { + if (!foundPatterns.has(matchedPattern)) { + foundPatterns.add(matchedPattern); + tracker.matchFound({ text, matchedPattern, textIdx, phase: "search" }); + } + } + } + } + + const foundArray = Array.from(foundPatterns); + + tracker.complete({ + text, + patterns, + result: foundArray, + }); + + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/AutoCompleteTriePipeline.stories.tsx b/src/algorithms/strings/trie-operations/auto-complete-trie/AutoCompleteTriePipeline.stories.tsx new file mode 100644 index 00000000..9010840a --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/AutoCompleteTriePipeline.stories.tsx @@ -0,0 +1,64 @@ +/** + * Storybook stories for the Auto-Complete with Trie algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TrieVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TrieVisualState } from "@/types"; +import { generateAutoCompleteTrieSteps } from "./step-generator"; +import TrieVisualizer from "@/components/visualization/TrieVisualizer"; + +const steps = generateAutoCompleteTrieSteps({ + words: ["apple", "app", "apricot", "banana", "bat"], + prefix: "ap", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Auto-Complete with Trie", + component: TrieVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty trie with root node only */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TrieVisualState, + }, +}; + +/** Insert phase — trie partially built with first word */ +export const InsertPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.3)]!.visualState as TrieVisualState, + }, +}; + +/** Search phase — navigating to the prefix end node */ +export const SearchPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.75)]!.visualState as TrieVisualState, + }, +}; + +/** Collect phase — DFS collecting suggestions under the prefix node */ +export const CollectPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.9)]!.visualState as TrieVisualState, + }, +}; + +/** Final state — all matching suggestions collected */ +export const Complete: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TrieVisualState, + }, +}; diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/auto-complete-trie.test.ts b/src/algorithms/strings/trie-operations/auto-complete-trie/auto-complete-trie.test.ts new file mode 100644 index 00000000..6c9becc2 --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/auto-complete-trie.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { autoCompleteTrie } from "./sources/auto-complete-trie.ts?fn"; + +describe("autoCompleteTrie", () => { + it("returns all words matching the given prefix", () => { + const result = autoCompleteTrie(["apple", "app", "apricot", "banana", "bat"], "ap"); + expect(result.sort()).toEqual(["app", "apple", "apricot"]); + }); + + it("returns a single word when only one matches the prefix", () => { + const result = autoCompleteTrie(["apple", "banana", "cherry"], "ban"); + expect(result).toEqual(["banana"]); + }); + + it("returns an empty array when no word matches the prefix", () => { + const result = autoCompleteTrie(["apple", "app", "apricot"], "ba"); + expect(result).toEqual([]); + }); + + it("returns an empty array when prefix does not exist in the trie", () => { + const result = autoCompleteTrie(["apple", "app"], "xyz"); + expect(result).toEqual([]); + }); + + it("returns all words when prefix is empty", () => { + const result = autoCompleteTrie(["apple", "app", "banana"], ""); + expect(result.sort()).toEqual(["app", "apple", "banana"]); + }); + + it("returns an empty array when the word list is empty", () => { + const result = autoCompleteTrie([], "ap"); + expect(result).toEqual([]); + }); + + it("returns the exact word when prefix equals a full word", () => { + const result = autoCompleteTrie(["apple", "app", "apricot"], "app"); + expect(result.sort()).toEqual(["app", "apple"]); + }); + + it("returns words with no shared sub-prefix correctly", () => { + const result = autoCompleteTrie(["cat", "car", "dog"], "ca"); + expect(result.sort()).toEqual(["car", "cat"]); + }); + + it("handles a single-word dictionary where the word matches", () => { + const result = autoCompleteTrie(["hello"], "hel"); + expect(result).toEqual(["hello"]); + }); + + it("handles a single-word dictionary where the word does not match", () => { + const result = autoCompleteTrie(["hello"], "world"); + expect(result).toEqual([]); + }); + + it("handles words with no shared prefix returning correct matches", () => { + const result = autoCompleteTrie(["alpha", "beta", "gamma"], "al"); + expect(result).toEqual(["alpha"]); + }); + + it("matches the default input correctly", () => { + const result = autoCompleteTrie(["apple", "app", "apricot", "banana", "bat"], "ap"); + expect(result.sort()).toEqual(["app", "apple", "apricot"]); + }); + + it("handles duplicate words gracefully — returns the word once", () => { + const result = autoCompleteTrie(["apple", "apple"], "app"); + expect(result.sort()).toEqual(["apple"]); + }); + + it("returns words for single-character prefix", () => { + const result = autoCompleteTrie(["apple", "apricot", "banana"], "a"); + expect(result.sort()).toEqual(["apple", "apricot"]); + }); +}); diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/educational.ts b/src/algorithms/strings/trie-operations/auto-complete-trie/educational.ts new file mode 100644 index 00000000..8b37f03f --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/educational.ts @@ -0,0 +1,76 @@ +/** Educational content for Auto-Complete with Trie. */ + +import type { EducationalContent } from "@/types"; + +export const autoCompleteTrieEducational: EducationalContent = { + overview: + "**Auto-Complete with Trie** is a classic application of the trie (prefix tree) data structure. " + + "Given a dictionary of words and a query prefix, the algorithm returns every word in the dictionary " + + "that begins with that prefix.\n\n" + + "The two-phase approach is what makes it efficient:\n\n" + + "- **Phase 1 — Build:** Insert all dictionary words into the trie, character by character, sharing " + + "prefix nodes across words that start the same way.\n" + + "- **Phase 2 — Query:** Navigate to the node corresponding to the last character of the prefix, " + + "then run a depth-first search (DFS) from that node to collect every complete word below it.", + + howItWorks: + "**Phase 1 — Insert all words:**\n\n" + + "1. Start at the root node.\n" + + "2. For each character `c` in the word:\n" + + " - If a child edge labelled `c` exists, follow it.\n" + + " - Otherwise, create a new child node and edge.\n" + + "3. Mark the final node as `isEnd = true`.\n" + + "4. Repeat for every word in the dictionary.\n\n" + + "**Phase 2 — Query prefix:**\n\n" + + "1. Start at the root node.\n" + + "2. For each character `c` in the prefix:\n" + + " - If no child edge labelled `c` exists → return an empty list immediately.\n" + + " - Otherwise, follow the edge.\n" + + "3. After reaching the prefix end node, perform a DFS from it:\n" + + " - At each node: if `isEnd = true`, record the accumulated path as a suggestion.\n" + + " - Recurse into every child, appending its character to the current path.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(m + k)`**\n\n" + + "- `m` — length of the prefix: navigating to the prefix end node costs `O(m)` edge lookups.\n" + + "- `k` — total characters across all matching words: the DFS visits each relevant node once.\n" + + "- Build cost is `O(n × l)` for `n` words of average length `l` — paid once, then amortised over all queries.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "In the worst case (no shared prefixes), every character of every word needs its own node. " + + "With shared prefixes the trie is more compact.", + + bestAndWorstCase: + "**Best case** — the prefix matches no words, or the prefix node does not exist in the trie: " + + "the query returns immediately after traversing `m` edges without any DFS, taking `O(m)` time.\n\n" + + "**Worst case** — the prefix is empty or a single common character, meaning the DFS must " + + "visit every node in the trie to collect all matching words. " + + "If all `n` words match, the DFS cost is `O(k)` where `k` is the total length of all words.", + + realWorldUses: [ + "**Search bars:** Web browsers and search engines suggest queries as the user types by traversing a trie of popular queries.", + "**IDE auto-complete:** Code editors index identifiers in a trie so completions appear with sub-millisecond latency.", + "**Mobile keyboards:** Predictive text engines store vocabulary in compressed tries to return word suggestions instantly.", + "**Contact search:** Phone apps find contacts by name prefix — trie lookup beats scanning the full contact list.", + "**DNS resolution:** Domain-name lookups use trie-like structures to match host names to IP addresses incrementally.", + ], + + strengthsAndLimitations: { + strengths: [ + "Query time depends only on the prefix length and result set size — not the total dictionary size.", + "Shared prefixes compress naturally: a million words sharing the prefix 'un' all reuse the same two nodes.", + "Supports ranked suggestions by augmenting each end-node with a frequency or recency score.", + ], + limitations: [ + "Memory usage can be high when the vocabulary has many distinct prefixes (e.g., random strings).", + "A hash map of words with a linear scan over keys can be simpler for small dictionaries.", + "Cache performance suffers because trie traversal follows pointer chains through non-contiguous memory.", + ], + }, + + whenToUseIt: + "Choose auto-complete with a trie when you need **fast prefix queries** over a large, mostly static dictionary " + + "and query latency is critical (search boxes, IDE completions, command palettes). " + + "For a small dictionary or infrequent queries, a simple array filter is easier to maintain. " + + "If memory is constrained, consider a **DAWG** (directed acyclic word graph) or **radix tree** " + + "which compress suffixes as well as prefixes.", +}; diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/index.ts b/src/algorithms/strings/trie-operations/auto-complete-trie/index.ts new file mode 100644 index 00000000..36761841 --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Auto-Complete with Trie — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { autoCompleteTrie } from "./sources/auto-complete-trie.ts?fn"; +import { generateAutoCompleteTrieSteps } from "./step-generator"; +import type { AutoCompleteTrieInput } from "./step-generator"; +import { autoCompleteTrieEducational } from "./educational"; + +import typescriptSource from "./sources/auto-complete-trie.ts?raw"; +import pythonSource from "./sources/auto-complete-trie.py?raw"; +import javaSource from "./sources/AutoCompleteTrie.java?raw"; + +function executeAutoCompleteTrie(input: AutoCompleteTrieInput): string[] { + return autoCompleteTrie(input.words, input.prefix) as string[]; +} + +const autoCompleteTrieDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.AUTO_COMPLETE_TRIE!, + name: "Auto-Complete with Trie", + category: CATEGORY.STRINGS!, + technique: "trie-operations", + description: + "Build a trie from a word list and return all words that start with a given prefix using DFS traversal from the prefix end node", + timeComplexity: { + best: "O(m)", + average: "O(m + k)", + worst: "O(m + k)", + }, + spaceComplexity: "O(n × m)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { words: ["apple", "app", "apricot", "banana", "bat"], prefix: "ap" }, + }, + execute: executeAutoCompleteTrie, + generateSteps: generateAutoCompleteTrieSteps, + educational: autoCompleteTrieEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(autoCompleteTrieDefinition); diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/sources/AutoCompleteTrie.java b/src/algorithms/strings/trie-operations/auto-complete-trie/sources/AutoCompleteTrie.java new file mode 100644 index 00000000..e974f601 --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/sources/AutoCompleteTrie.java @@ -0,0 +1,57 @@ +// Auto-Complete with Trie +// Builds a trie from a word list, then returns all words that start with the given prefix. +// Time: O(m + k) where m = prefix length, k = total characters in all result words +// Space: O(n * m) for n words of average length m + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AutoCompleteTrie { + + private static class TrieNode { // @step:initialize + Map children = new HashMap<>(); // @step:initialize + boolean isEnd = false; // @step:initialize + } + + private static void collectWords( + TrieNode node, + StringBuilder currentPrefix, + List results + ) { + if (node.isEnd) { // @step:add-to-result + results.add(currentPrefix.toString()); // @step:add-to-result + } + for (Map.Entry entry : node.children.entrySet()) { // @step:traverse-trie + currentPrefix.append(entry.getKey()); // @step:traverse-trie + collectWords(entry.getValue(), currentPrefix, results); // @step:traverse-trie + currentPrefix.deleteCharAt(currentPrefix.length() - 1); // @step:traverse-trie + } + } + + public static List autoCompleteTrie(List words, String prefix) { + TrieNode root = new TrieNode(); // @step:initialize + + for (String word : words) { // @step:visit + TrieNode current = root; // @step:visit + for (char ch : word.toCharArray()) { // @step:insert-trie + current.children.putIfAbsent(ch, new TrieNode()); // @step:insert-trie + current = current.children.get(ch); // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + } + + TrieNode prefixNode = root; // @step:visit + for (char ch : prefix.toCharArray()) { // @step:traverse-trie + if (!prefixNode.children.containsKey(ch)) { // @step:traverse-trie + return new ArrayList<>(); // @step:traverse-trie + } + prefixNode = prefixNode.children.get(ch); // @step:traverse-trie + } + + List results = new ArrayList<>(); + collectWords(prefixNode, new StringBuilder(prefix), results); // @step:add-to-result + return results; // @step:complete + } +} diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.py b/src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.py new file mode 100644 index 00000000..2a1c3800 --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.py @@ -0,0 +1,43 @@ +# Auto-Complete with Trie +# Builds a trie from a word list, then returns all words that start with the given prefix. +# Time: O(m + k) where m = prefix length, k = total characters in all result words +# Space: O(n * m) for n words of average length m + + +class TrieNode: + def __init__(self) -> None: # @step:initialize + self.children: dict[str, "TrieNode"] = {} # @step:initialize + self.is_end: bool = False # @step:initialize + + +def _collect_words( + node: TrieNode, + current_prefix: str, + results: list[str], +) -> None: + if node.is_end: # @step:add-to-result + results.append(current_prefix) # @step:add-to-result + for char, child in node.children.items(): # @step:traverse-trie + _collect_words(child, current_prefix + char, results) # @step:traverse-trie + + +def auto_complete_trie(words: list[str], prefix: str) -> list[str]: + root = TrieNode() # @step:initialize + + for word in words: # @step:visit + current = root # @step:visit + for char in word: # @step:insert-trie + if char not in current.children: # @step:insert-trie + current.children[char] = TrieNode() # @step:insert-trie + current = current.children[char] # @step:traverse-trie + current.is_end = True # @step:mark-end-word + + prefix_node = root # @step:visit + for char in prefix: # @step:traverse-trie + if char not in prefix_node.children: # @step:traverse-trie + return [] # @step:traverse-trie + prefix_node = prefix_node.children[char] # @step:traverse-trie + + results: list[str] = [] + _collect_words(prefix_node, prefix, results) # @step:add-to-result + return results # @step:complete diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.ts b/src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.ts new file mode 100644 index 00000000..2fdacbed --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/sources/auto-complete-trie.ts @@ -0,0 +1,54 @@ +// Auto-Complete with Trie +// Builds a trie from a word list, then returns all words that start with the given prefix. +// Time: O(m + k) where m = prefix length, k = total characters in all result words +// Space: O(n * m) for n words of average length m + +interface TrieNodeInternal { + children: Map; + isEnd: boolean; +} + +function createNode(): TrieNodeInternal { + return { children: new Map(), isEnd: false }; // @step:initialize +} + +function collectWords(node: TrieNodeInternal, currentPrefix: string, results: string[]): void { + if (node.isEnd) { + // @step:add-to-result + results.push(currentPrefix); // @step:add-to-result + } + for (const [char, child] of node.children) { + // @step:traverse-trie + collectWords(child, currentPrefix + char, results); // @step:traverse-trie + } +} + +export function autoCompleteTrie(words: string[], prefix: string): string[] { + const root = createNode(); // @step:initialize + + for (const word of words) { + // @step:visit + let current = root; // @step:visit + for (const char of word) { + // @step:insert-trie + if (!current.children.has(char)) { + current.children.set(char, createNode()); // @step:insert-trie + } + current = current.children.get(char)!; // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + } + + let prefixNode = root; // @step:visit + for (const char of prefix) { + // @step:traverse-trie + if (!prefixNode.children.has(char)) { + return []; // @step:traverse-trie + } + prefixNode = prefixNode.children.get(char)!; // @step:traverse-trie + } + + const results: string[] = []; + collectWords(prefixNode, prefix, results); // @step:add-to-result + return results; // @step:complete +} diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.test.ts b/src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.test.ts new file mode 100644 index 00000000..a55e0288 --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { generateAutoCompleteTrieSteps } from "./step-generator"; + +describe("generateAutoCompleteTrieSteps", () => { + it("produces steps for the default input", () => { + const steps = generateAutoCompleteTrieSteps({ + words: ["apple", "app", "apricot", "banana", "bat"], + prefix: "ap", + }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple", "app"], prefix: "ap" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple", "app"], prefix: "ap" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-trie visual states throughout", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple", "app"], prefix: "ap" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-trie"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["app"], prefix: "ap" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits insert-trie steps during the insert phase", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple"], prefix: "ap" }); + const insertSteps = steps.filter((step) => step.type === "insert-trie"); + expect(insertSteps.length).toBeGreaterThan(0); + }); + + it("emits traverse-trie steps during both phases", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple", "app"], prefix: "ap" }); + const traverseSteps = steps.filter((step) => step.type === "traverse-trie"); + expect(traverseSteps.length).toBeGreaterThan(0); + }); + + it("emits mark-end-word steps after each word is inserted", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple", "app"], prefix: "ap" }); + const endWordSteps = steps.filter((step) => step.type === "mark-end-word"); + expect(endWordSteps.length).toBe(2); + }); + + it("emits add-to-result steps for each matching word found", () => { + const steps = generateAutoCompleteTrieSteps({ + words: ["apple", "app", "apricot"], + prefix: "ap", + }); + const resultSteps = steps.filter((step) => step.type === "add-to-result"); + expect(resultSteps.length).toBe(3); + }); + + it("emits no add-to-result steps when prefix has no matches", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple", "app"], prefix: "ba" }); + const resultSteps = steps.filter((step) => step.type === "add-to-result"); + expect(resultSteps.length).toBe(0); + }); + + it("accumulates suggestions in the visual state", () => { + const steps = generateAutoCompleteTrieSteps({ + words: ["apple", "app"], + prefix: "ap", + }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-trie"); + if (completeStep.visualState.kind === "string-trie") { + expect(completeStep.visualState.suggestions.length).toBe(2); + } + }); + + it("has no suggestions in the final step when prefix is not found", () => { + const steps = generateAutoCompleteTrieSteps({ words: ["apple"], prefix: "xyz" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-trie"); + if (completeStep.visualState.kind === "string-trie") { + expect(completeStep.visualState.suggestions).toEqual([]); + } + }); + + it("final trie node count equals unique prefix nodes inserted", () => { + // "apple" and "app" share a-p-p prefix (3 shared) + l-e (2 unique) = 5 nodes + root + const steps = generateAutoCompleteTrieSteps({ words: ["apple", "app"], prefix: "ap" }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.visualState.kind).toBe("string-trie"); + if (lastStep.visualState.kind === "string-trie") { + // root (id=0) + a + p + p + l + e = 6 nodes + expect(lastStep.visualState.nodes.length).toBe(6); + } + }); + + it("handles empty word list without errors", () => { + const steps = generateAutoCompleteTrieSteps({ words: [], prefix: "ap" }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); +}); diff --git a/src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.ts b/src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.ts new file mode 100644 index 00000000..5ae1e939 --- /dev/null +++ b/src/algorithms/strings/trie-operations/auto-complete-trie/step-generator.ts @@ -0,0 +1,138 @@ +/** Step generator for Auto-Complete with Trie — produces ExecutionStep[] using TrieTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TrieTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const AUTO_COMPLETE_TRIE_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.AUTO_COMPLETE_TRIE!); + +export interface AutoCompleteTrieInput { + words: string[]; + prefix: string; +} + +export function generateAutoCompleteTrieSteps(input: AutoCompleteTrieInput): ExecutionStep[] { + const { words, prefix } = input; + const tracker = new TrieTracker(AUTO_COMPLETE_TRIE_LINE_MAP); + + tracker.initialize({ words, prefix }); + + // childrenMap: nodeId -> (char -> childNodeId) + const childrenMap = new Map>(); + childrenMap.set(0, new Map()); + + // endNodeIds: tracks which node IDs are marked as end-of-word + const endNodeIds = new Set(); + + // Phase 1 — Insert all words into the trie + for (const word of words) { + tracker.setSearchWord(word, { currentWord: word, phase: "insert" }); + + let parentId = 0; + + for (let charIdx = 0; charIdx < word.length; charIdx++) { + const char = word[charIdx]!; + const parentChildren = childrenMap.get(parentId) ?? new Map(); + const existingChildId = parentChildren.get(char); + + if (existingChildId !== undefined) { + tracker.traverseEdge(parentId, existingChildId, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + tracker.insertChar(existingChildId, char, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + parentId = existingChildId; + } else { + const newNodeId = tracker.createNode(parentId, char, { + currentWord: word, + char, + charIdx, + phase: "insert", + }); + parentChildren.set(char, newNodeId); + childrenMap.set(parentId, parentChildren); + childrenMap.set(newNodeId, new Map()); + parentId = newNodeId; + } + } + + tracker.markEndOfWord(parentId, { currentWord: word, phase: "insert" }); + endNodeIds.add(parentId); + } + + // Phase 2 — Navigate to the end of the prefix + tracker.setSearchWord(prefix, { prefix, phase: "search" }); + + let currentNodeId = 0; + let prefixFailed = false; + + for (let charIdx = 0; charIdx < prefix.length; charIdx++) { + const char = prefix[charIdx]!; + const currentChildren = childrenMap.get(currentNodeId) ?? new Map(); + const nextNodeId = currentChildren.get(char); + const found = nextNodeId !== undefined; + + tracker.searchChar(found ? nextNodeId! : currentNodeId, charIdx, found, { + prefix, + char, + charIdx, + phase: "search", + }); + + if (!found) { + prefixFailed = true; + break; + } + + currentNodeId = nextNodeId!; + } + + // Phase 3 — DFS to collect all complete words under the prefix node + const collectedSuggestions: string[] = []; + + if (!prefixFailed) { + // nodeStack: pairs of [nodeId, wordSoFar] to traverse depth-first + const nodeStack: Array<[number, string]> = [[currentNodeId, prefix]]; + + while (nodeStack.length > 0) { + const entry = nodeStack.pop()!; + const [visitNodeId, wordSoFar] = entry; + + if (endNodeIds.has(visitNodeId)) { + collectedSuggestions.push(wordSoFar); + tracker.addSuggestion(wordSoFar, { + prefix, + word: wordSoFar, + phase: "collect", + }); + } + + const visitChildren = childrenMap.get(visitNodeId) ?? new Map(); + // Push children in reverse sorted order so alphabetically first is processed first + const sortedEntries = Array.from(visitChildren.entries()).sort(([charA], [charB]) => + charB.localeCompare(charA), + ); + + for (const [childChar, childNodeId] of sortedEntries) { + nodeStack.push([childNodeId, wordSoFar + childChar]); + } + } + } + + tracker.complete({ + prefix, + suggestions: collectedSuggestions, + }); + + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/LongestWordInTriePipeline.stories.tsx b/src/algorithms/strings/trie-operations/longest-word-in-trie/LongestWordInTriePipeline.stories.tsx new file mode 100644 index 00000000..35ffe1a7 --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/LongestWordInTriePipeline.stories.tsx @@ -0,0 +1,56 @@ +/** + * Storybook stories for the Longest Word in Trie algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TrieVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TrieVisualState } from "@/types"; +import { generateLongestWordInTrieSteps } from "./step-generator"; +import TrieVisualizer from "@/components/visualization/TrieVisualizer"; + +const steps = generateLongestWordInTrieSteps({ + words: ["w", "wo", "wor", "worl", "world"], +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Longest Word in Trie", + component: TrieVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty trie with root node only */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TrieVisualState, + }, +}; + +/** Insert phase — trie partially built with first few words */ +export const InsertPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.3)]!.visualState as TrieVisualState, + }, +}; + +/** Search phase — DFS traversal following only isEnd nodes */ +export const SearchPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.8)]!.visualState as TrieVisualState, + }, +}; + +/** Final state — longest word found, result path highlighted */ +export const LongestWordFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TrieVisualState, + }, +}; diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/educational.ts b/src/algorithms/strings/trie-operations/longest-word-in-trie/educational.ts new file mode 100644 index 00000000..e1ff7ce3 --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/educational.ts @@ -0,0 +1,75 @@ +/** Educational content for Longest Word in Trie. */ + +import type { EducationalContent } from "@/types"; + +export const longestWordInTrieEducational: EducationalContent = { + overview: + "**Longest Word in Trie** finds the longest word in a set where every prefix of that word is also a valid word in the set.\n\n" + + 'For example, given `["w", "wo", "wor", "worl", "world"]`, the answer is `"world"` because each prefix — ' + + '`"w"`, `"wo"`, `"wor"`, `"worl"` — is also present in the set.\n\n' + + "The algorithm first builds a trie from all words, then performs a **DFS traversal** that only follows edges " + + "whose destination node is marked as an end-of-word. This constraint ensures every step along the path " + + "corresponds to a word in the original set.", + + howItWorks: + "**Phase 1 — Build the Trie:**\n\n" + + "1. Start with an empty root node.\n" + + "2. For each word in the input, insert it character by character — create new nodes as needed.\n" + + "3. Mark the final node of each word as `isEnd = true`.\n\n" + + "**Phase 2 — DFS Traversal (prefix-constrained):**\n\n" + + "1. Push the root node onto a DFS stack with an empty string accumulator.\n" + + "2. While the stack is non-empty:\n" + + " - Pop the top entry `(node, wordSoFar)`.\n" + + " - For each child of the current node:\n" + + " - If the child is **not** marked `isEnd`, skip it — every prefix along the path must be a word.\n" + + " - Otherwise, form `nextWord = wordSoFar + char`.\n" + + " - If `nextWord` is longer than the current best (or same length but lexicographically smaller), update the result.\n" + + " - Push `(child, nextWord)` onto the stack to continue deeper.\n" + + "3. Return the longest word found.", + + timeAndSpaceComplexity: + "**Time Complexity: `O(n × m)`**\n\n" + + "- Trie construction: `O(n × m)` — inserting `n` words of average length `m`.\n" + + "- DFS traversal: in the worst case every node is visited once — also `O(n × m)`.\n" + + "- Total: `O(n × m)` where `n` is the number of words and `m` is the average word length.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "The trie stores up to `n × m` nodes when there are no shared prefixes. " + + "The DFS stack depth is at most the length of the longest word.", + + bestAndWorstCase: + '**Best case** — words share long common prefixes (e.g., `["a", "ab", "abc"]`): ' + + "the trie is compact and DFS visits few nodes. If the longest valid path is found early, " + + "fewer comparisons update the result.\n\n" + + "**Worst case** — words share no prefixes and all form valid chains: " + + "the trie has `O(n × m)` nodes and every node is visited during DFS. " + + "No early termination is possible since any branch could contain a longer valid word.", + + realWorldUses: [ + "**Autocomplete validation:** Ensuring suggestions build incrementally on confirmed dictionary prefixes.", + "**Spell-check suggestions:** Finding the longest correctly-spelled extension of a partially typed word.", + "**Domain name generation:** Discovering the longest valid compound word built from known root forms.", + "**Word chain games:** Verifying and extending word ladders where each prefix must be a standalone word.", + "**Natural language processing:** Segmenting text by identifying the longest valid word-by-word decomposition.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(n × m) time — efficient for large word sets because the trie eliminates repeated prefix scanning.", + "The prefix constraint is enforced structurally: skipping non-isEnd nodes is a single boolean check per edge.", + "Easily extended to return all valid longest words (with tie-breaking) rather than just one.", + ], + limitations: [ + "Requires building and storing the full trie — O(n × m) memory even if the answer is short.", + "DFS order is sensitive to child iteration order, which may affect which word is returned when lengths tie.", + "For very large alphabets (e.g., Unicode), each node's child map grows significantly.", + ], + }, + + whenToUseIt: + "Use Longest Word in Trie when you need to find the **longest incrementally valid sequence** from a set of strings — " + + "where every step along the path must itself be a member of the set. " + + "If you only need exact-match lookup, a hash set is simpler. " + + "If you need multiple results or prefix counting, extend the DFS to collect all qualifying paths. " + + "Avoid this approach for very large datasets where memory is constrained — a sorted array with binary search " + + "can solve the problem in O(n log n) time with O(1) extra space.", +}; diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/index.ts b/src/algorithms/strings/trie-operations/longest-word-in-trie/index.ts new file mode 100644 index 00000000..8e80cbd6 --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Longest Word in Trie — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { longestWordInTrie } from "./sources/longest-word-in-trie.ts?fn"; +import { generateLongestWordInTrieSteps } from "./step-generator"; +import type { LongestWordInTrieInput } from "./step-generator"; +import { longestWordInTrieEducational } from "./educational"; + +import typescriptSource from "./sources/longest-word-in-trie.ts?raw"; +import pythonSource from "./sources/longest-word-in-trie.py?raw"; +import javaSource from "./sources/LongestWordInTrie.java?raw"; + +function executeLongestWordInTrie(input: LongestWordInTrieInput): string { + return longestWordInTrie(input.words) as string; +} + +const longestWordInTrieDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.LONGEST_WORD_IN_TRIE!, + name: "Longest Word in Trie", + category: CATEGORY.STRINGS!, + technique: "trie-operations", + description: + "Build a trie from a list of words and find the longest word where every prefix is also present in the set, using DFS traversal that only follows end-of-word nodes", + timeComplexity: { + best: "O(n×m)", + average: "O(n×m)", + worst: "O(n×m)", + }, + spaceComplexity: "O(n × m)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { words: ["w", "wo", "wor", "worl", "world"] }, + }, + execute: executeLongestWordInTrie, + generateSteps: generateLongestWordInTrieSteps, + educational: longestWordInTrieEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(longestWordInTrieDefinition); diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/longest-word-in-trie.test.ts b/src/algorithms/strings/trie-operations/longest-word-in-trie/longest-word-in-trie.test.ts new file mode 100644 index 00000000..14a18420 --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/longest-word-in-trie.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { longestWordInTrie } from "./sources/longest-word-in-trie.ts?fn"; + +describe("longestWordInTrie", () => { + it("returns the longest word when all prefixes are present", () => { + expect(longestWordInTrie(["w", "wo", "wor", "worl", "world"])).toBe("world"); + }); + + it("returns empty string for an empty word list", () => { + expect(longestWordInTrie([])).toBe(""); + }); + + it("returns a single-character word when only one word is present", () => { + expect(longestWordInTrie(["a"])).toBe("a"); + }); + + it("returns empty string when no word has all its prefixes present", () => { + // "world" requires "w","wo","wor","worl" — none of those are present + expect(longestWordInTrie(["world"])).toBe(""); + }); + + it("returns the longer of two valid candidates", () => { + // "apple": a, ap, app, appl, apple — all present + // "app": a, ap, app — all present + // "apple" is longer so it wins + const words = ["a", "ap", "app", "appl", "apple"]; + expect(longestWordInTrie(words)).toBe("apple"); + }); + + it("returns lexicographically smallest when lengths tie", () => { + // "b","ba" and "c","ca" are both length-2 valid words + // "ba" < "ca" lexicographically + expect(longestWordInTrie(["b", "ba", "c", "ca"])).toBe("ba"); + }); + + it("ignores branches where an intermediate node is not end-of-word", () => { + // "do" is missing so "dog" cannot be the answer even though "d" and "dog" are present + expect(longestWordInTrie(["d", "dog"])).toBe("d"); + }); + + it("handles the default input correctly", () => { + expect(longestWordInTrie(["w", "wo", "wor", "worl", "world"])).toBe("world"); + }); + + it("returns empty string when words list has only multi-char entries without prefixes", () => { + expect(longestWordInTrie(["abc", "def", "ghi"])).toBe(""); + }); + + it("handles two competing complete chains — picks longer one", () => { + // "a","ab","abc" (length 3) vs "x","xy" (length 2) + expect(longestWordInTrie(["a", "ab", "abc", "x", "xy"])).toBe("abc"); + }); + + it("handles duplicate words gracefully", () => { + expect(longestWordInTrie(["a", "a", "ab", "ab"])).toBe("ab"); + }); + + it("returns lexicographically smallest when only single chars are valid", () => { + // Both "b" and "c" are length 1 — "b" < "c" lexicographically + expect(longestWordInTrie(["b", "c"])).toBe("b"); + }); +}); diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/LongestWordInTrie.java b/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/LongestWordInTrie.java new file mode 100644 index 00000000..0f91928f --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/LongestWordInTrie.java @@ -0,0 +1,59 @@ +// Longest Word in Trie +// Builds a trie from a list of words, then finds the longest word where every prefix is also a word. +// Uses DFS traversal, only following nodes marked as isEnd. +// Time: O(n*m) where n = number of words, m = average word length +// Space: O(n*m) for storing all nodes in the trie + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class LongestWordInTrie { + + private static class TrieNode { // @step:initialize + Map children = new HashMap<>(); // @step:initialize + boolean isEnd = false; // @step:initialize + } + + public static String longestWordInTrie(List words) { + TrieNode root = new TrieNode(); // @step:initialize + + for (String word : words) { // @step:visit + TrieNode current = root; // @step:visit + for (char ch : word.toCharArray()) { // @step:insert-trie + current.children.putIfAbsent(ch, new TrieNode()); // @step:insert-trie + current = current.children.get(ch); // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + } + + String longestWord = ""; // @step:visit + + // DFS stack holds Object[] pairs of [TrieNode, String currentWord] + Deque dfsStack = new ArrayDeque<>(); // @step:visit + dfsStack.push(new Object[]{root, ""}); // @step:visit + + while (!dfsStack.isEmpty()) { // @step:traverse-trie + Object[] entry = dfsStack.pop(); // @step:traverse-trie + TrieNode currentNode = (TrieNode) entry[0]; // @step:traverse-trie + String currentWord = (String) entry[1]; // @step:traverse-trie + + for (Map.Entry childEntry : currentNode.children.entrySet()) { // @step:traverse-trie + char ch = childEntry.getKey(); // @step:traverse-trie + TrieNode childNode = childEntry.getValue(); // @step:traverse-trie + if (childNode.isEnd) { // @step:traverse-trie + String nextWord = currentWord + ch; // @step:traverse-trie + if (nextWord.length() > longestWord.length() + || (nextWord.length() == longestWord.length() && nextWord.compareTo(longestWord) < 0)) { + longestWord = nextWord; // @step:found + } + dfsStack.push(new Object[]{childNode, nextWord}); // @step:traverse-trie + } + } + } + + return longestWord; // @step:complete + } +} diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.py b/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.py new file mode 100644 index 00000000..4ccb468d --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.py @@ -0,0 +1,42 @@ +# Longest Word in Trie +# Builds a trie from a list of words, then finds the longest word where every prefix is also a word. +# Uses DFS traversal, only following nodes marked as is_end. +# Time: O(n*m) where n = number of words, m = average word length +# Space: O(n*m) for storing all nodes in the trie + + +class TrieNode: + def __init__(self) -> None: # @step:initialize + self.children: dict[str, "TrieNode"] = {} # @step:initialize + self.is_end: bool = False # @step:initialize + + +def longest_word_in_trie(words: list[str]) -> str: + root = TrieNode() # @step:initialize + + for word in words: # @step:visit + current = root # @step:visit + for char in word: # @step:insert-trie + if char not in current.children: # @step:insert-trie + current.children[char] = TrieNode() # @step:insert-trie + current = current.children[char] # @step:traverse-trie + current.is_end = True # @step:mark-end-word + + longest_word = "" # @step:visit + + # DFS stack holds (node, current_word_built) pairs + dfs_stack: list[tuple[TrieNode, str]] = [(root, "")] # @step:visit + + while dfs_stack: # @step:traverse-trie + current_node, current_word = dfs_stack.pop() # @step:traverse-trie + + for char, child_node in current_node.children.items(): # @step:traverse-trie + if child_node.is_end: # @step:traverse-trie + next_word = current_word + char # @step:traverse-trie + if len(next_word) > len(longest_word) or ( + len(next_word) == len(longest_word) and next_word < longest_word + ): + longest_word = next_word # @step:found + dfs_stack.append((child_node, next_word)) # @step:traverse-trie + + return longest_word # @step:complete diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.ts b/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.ts new file mode 100644 index 00000000..6c9c7f55 --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/sources/longest-word-in-trie.ts @@ -0,0 +1,60 @@ +// Longest Word in Trie +// Builds a trie from a list of words, then finds the longest word where every prefix is also a word. +// Uses DFS traversal, only following nodes marked as isEnd. +// Time: O(n*m) where n = number of words, m = average word length +// Space: O(n*m) for storing all nodes in the trie + +interface TrieNodeInternal { + children: Map; + isEnd: boolean; +} + +function createTrieNode(): TrieNodeInternal { + return { children: new Map(), isEnd: false }; // @step:initialize +} + +export function longestWordInTrie(words: string[]): string { + const root = createTrieNode(); // @step:initialize + + for (const word of words) { + // @step:visit + let current = root; // @step:visit + for (const char of word) { + // @step:insert-trie + if (!current.children.has(char)) { + current.children.set(char, createTrieNode()); // @step:insert-trie + } + current = current.children.get(char)!; // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + } + + let longestWord = ""; // @step:visit + + // DFS stack holds [node, currentWordBuilt] pairs + const dfsStack: [TrieNodeInternal, string][] = [[root, ""]]; // @step:visit + + while (dfsStack.length > 0) { + // @step:traverse-trie + const entry = dfsStack.pop()!; // @step:traverse-trie + const currentNode = entry[0]; + const currentWord = entry[1]; + + for (const [char, childNode] of currentNode.children) { + // @step:traverse-trie + if (childNode.isEnd) { + // @step:traverse-trie + const nextWord = currentWord + char; // @step:traverse-trie + if ( + nextWord.length > longestWord.length || + (nextWord.length === longestWord.length && nextWord < longestWord) + ) { + longestWord = nextWord; // @step:found + } + dfsStack.push([childNode, nextWord]); // @step:traverse-trie + } + } + } + + return longestWord; // @step:complete +} diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.test.ts b/src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.test.ts new file mode 100644 index 00000000..050427b8 --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { generateLongestWordInTrieSteps } from "./step-generator"; + +describe("generateLongestWordInTrieSteps", () => { + it("produces steps for the default input", () => { + const steps = generateLongestWordInTrieSteps({ + words: ["w", "wo", "wor", "worl", "world"], + }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "wor"] }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "wor"] }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-trie visual states throughout", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "wor"] }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-trie"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo"] }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits insert-trie steps during the insert phase", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "wor"] }); + const insertSteps = steps.filter((step) => step.type === "insert-trie"); + expect(insertSteps.length).toBeGreaterThan(0); + }); + + it("emits mark-end-word steps after each word is inserted", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "wor"] }); + const endWordSteps = steps.filter((step) => step.type === "mark-end-word"); + expect(endWordSteps.length).toBe(3); + }); + + it("emits traverse-trie steps during the DFS search phase", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "wor"] }); + const traverseSteps = steps.filter((step) => step.type === "traverse-trie"); + expect(traverseSteps.length).toBeGreaterThan(0); + }); + + it("emits at least one found step when a valid longest word exists", () => { + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "world"] }); + // "world" is NOT valid (missing "wor", "worl") but "wo" is valid (prefix "w" exists) + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBeGreaterThan(0); + }); + + it("does not emit found steps when no valid word exists", () => { + // "world" alone has no prefixes in the set + const steps = generateLongestWordInTrieSteps({ words: ["world"] }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(0); + }); + + it("final trie node count reflects unique prefix nodes inserted", () => { + // "w","wo","wor" share the w-o-r prefix chain — 3 nodes + root = 4 total + const steps = generateLongestWordInTrieSteps({ words: ["w", "wo", "wor"] }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.visualState.kind).toBe("string-trie"); + if (lastStep.visualState.kind === "string-trie") { + expect(lastStep.visualState.nodes.length).toBe(4); + } + }); + + it("produces correct node count for default input with 5 words in a chain", () => { + // "w","wo","wor","worl","world" — root + w + o + r + l + d = 6 nodes + const steps = generateLongestWordInTrieSteps({ + words: ["w", "wo", "wor", "worl", "world"], + }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.visualState.kind).toBe("string-trie"); + if (lastStep.visualState.kind === "string-trie") { + expect(lastStep.visualState.nodes.length).toBe(6); + } + }); + + it("produces steps for an empty word list", () => { + const steps = generateLongestWordInTrieSteps({ words: [] }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); +}); diff --git a/src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.ts b/src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.ts new file mode 100644 index 00000000..213a19e4 --- /dev/null +++ b/src/algorithms/strings/trie-operations/longest-word-in-trie/step-generator.ts @@ -0,0 +1,116 @@ +/** Step generator for Longest Word in Trie — produces ExecutionStep[] using TrieTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TrieTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const LONGEST_WORD_IN_TRIE_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.LONGEST_WORD_IN_TRIE!); + +export interface LongestWordInTrieInput { + words: string[]; +} + +export function generateLongestWordInTrieSteps(input: LongestWordInTrieInput): ExecutionStep[] { + const { words } = input; + const tracker = new TrieTracker(LONGEST_WORD_IN_TRIE_LINE_MAP); + + tracker.initialize({ words }); + + // childrenMap: nodeId -> (char -> childNodeId) + const childrenMap = new Map>(); + childrenMap.set(0, new Map()); + + // endNodeIds: tracks which node IDs are marked as end-of-word + const endNodeIds = new Set(); + + // Phase 1 — Insert all words into the trie + for (const word of words) { + tracker.setSearchWord(word, { currentWord: word, phase: "insert" }); + + let parentId = 0; + + for (let charIdx = 0; charIdx < word.length; charIdx++) { + const char = word[charIdx]!; + const parentChildren = childrenMap.get(parentId) ?? new Map(); + const existingChildId = parentChildren.get(char); + + if (existingChildId !== undefined) { + tracker.traverseEdge(parentId, existingChildId, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + tracker.insertChar(existingChildId, char, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + parentId = existingChildId; + } else { + const newNodeId = tracker.createNode(parentId, char, { + currentWord: word, + char, + charIdx, + phase: "insert", + }); + parentChildren.set(char, newNodeId); + childrenMap.set(parentId, parentChildren); + childrenMap.set(newNodeId, new Map()); + parentId = newNodeId; + } + } + + tracker.markEndOfWord(parentId, { currentWord: word, phase: "insert" }); + endNodeIds.add(parentId); + } + + // Phase 2 — DFS traversal: only follow nodes marked as isEnd + // longestWord tracks the best candidate found so far + let longestWord = ""; + + // DFS stack holds [nodeId, wordBuiltSoFar] pairs + const dfsStack: [number, string][] = [[0, ""]]; + + tracker.setSearchWord("", { longestWord, phase: "search" }); + + while (dfsStack.length > 0) { + const entry = dfsStack.pop()!; + const currentNodeId = entry[0]; + const currentWord = entry[1]; + + const nodeChildren = childrenMap.get(currentNodeId) ?? new Map(); + + for (const [char, childNodeId] of nodeChildren) { + // Only follow this child if it is marked as end-of-word (every prefix must be a word) + if (endNodeIds.has(childNodeId)) { + const nextWord = currentWord + char; + + tracker.searchChar(childNodeId, nextWord.length - 1, true, { + char, + nextWord, + childNodeId, + phase: "search", + }); + + if ( + nextWord.length > longestWord.length || + (nextWord.length === longestWord.length && nextWord < longestWord) + ) { + longestWord = nextWord; + tracker.matchFound({ longestWord, phase: "search" }); + } + + dfsStack.push([childNodeId, nextWord]); + } + } + } + + tracker.complete({ result: longestWord, longestWord }); + + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/TrieInsertSearchPipeline.stories.tsx b/src/algorithms/strings/trie-operations/trie-insert-search/TrieInsertSearchPipeline.stories.tsx new file mode 100644 index 00000000..0334cfdd --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/TrieInsertSearchPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Trie Insert and Search algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TrieVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TrieVisualState } from "@/types"; +import { generateTrieInsertSearchSteps } from "./step-generator"; +import TrieVisualizer from "@/components/visualization/TrieVisualizer"; + +const steps = generateTrieInsertSearchSteps({ + words: ["apple", "app", "apricot"], + search: "app", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Trie Insert and Search", + component: TrieVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty trie with root node only */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TrieVisualState, + }, +}; + +/** Insert phase — trie partially built with first word */ +export const InsertPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.3)]!.visualState as TrieVisualState, + }, +}; + +/** Search phase — traversing the trie for the search word */ +export const SearchPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.8)]!.visualState as TrieVisualState, + }, +}; + +/** Final state — word found, matched path highlighted */ +export const WordFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TrieVisualState, + }, +}; diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/educational.ts b/src/algorithms/strings/trie-operations/trie-insert-search/educational.ts new file mode 100644 index 00000000..8eed14bf --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/educational.ts @@ -0,0 +1,72 @@ +/** Educational content for Trie Insert and Search. */ + +import type { EducationalContent } from "@/types"; + +export const trieInsertSearchEducational: EducationalContent = { + overview: + "A **trie** (also called a prefix tree) is a tree data structure where each node represents a single character. " + + "Words are stored along root-to-node paths, and a boolean flag marks nodes that complete a valid word.\n\n" + + "**Trie Insert and Search** covers the two fundamental trie operations:\n\n" + + "- **Insert** — walk the trie character by character, creating new nodes for characters that don't yet exist, " + + "then mark the final node as an end-of-word.\n" + + "- **Search** — walk the trie character by character; if any character is missing or the final node is not " + + "marked as end-of-word, the word is not in the trie.", + + howItWorks: + "**Insert (per word):**\n\n" + + "1. Start at the root node.\n" + + "2. For each character `c` in the word:\n" + + " - If a child edge labelled `c` exists, follow it (traverse edge).\n" + + " - Otherwise, create a new child node and add the edge (create node).\n" + + "3. Mark the final node as `isEnd = true` (end-of-word marker).\n\n" + + "**Search:**\n\n" + + "1. Start at the root node.\n" + + "2. For each character `c` in the search word:\n" + + " - If no child edge labelled `c` exists → return `false` immediately.\n" + + " - Otherwise, follow the edge.\n" + + "3. After consuming all characters, return `true` only if the current node is `isEnd = true`. " + + 'This distinguishes exact words from mere prefixes (e.g., `"ap"` is a prefix of `"apple"` but not a stored word).', + + timeAndSpaceComplexity: + "**Time Complexity: `O(m)` per operation**\n\n" + + "- Insert: `O(m)` — at most `m` node creations or traversals for a word of length `m`.\n" + + "- Search: `O(m)` — at most `m` edge lookups.\n" + + "- Inserting `n` words of average length `m`: `O(n × m)` total build time.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "In the worst case (no shared prefixes), each of the `n` words of length `m` occupies `m` nodes. " + + "With shared prefixes the actual node count can be much smaller.", + + bestAndWorstCase: + '**Best case** — all words share a long common prefix (e.g., `["abcde", "abcdf", "abcdg"]`): ' + + "most insert steps traverse existing nodes rather than creating new ones. " + + "Search also terminates early on a mismatch.\n\n" + + "**Worst case** — no shared prefixes (e.g., completely different first characters): " + + "every character in every word requires a new node, so total space reaches `O(n × m)`. " + + "Search is still `O(m)` regardless because it only scans the search word length.", + + realWorldUses: [ + "**Autocomplete engines:** Browsers, search bars, and IDEs suggest completions by traversing a trie prefix.", + "**Spell checkers:** Validate whether a typed word is a known dictionary entry in O(m).", + "**IP routing tables:** Longest-prefix matching on binary tries routes network packets efficiently.", + "**Contact search:** Mobile apps index contact names in a trie for instant prefix filtering as the user types.", + "**Genome databases:** DNA sequence lookup uses tries where the alphabet is {A, C, G, T}.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(m) per insert and search — independent of how many words are already stored.", + "Prefix operations are natural: finding all words with a given prefix requires only one traversal.", + "No hash collisions — unlike hash maps, tries guarantee deterministic lookup time.", + ], + limitations: [ + "Memory-intensive: each node may store up to 26 (or more) child pointers even when most are null.", + "Cache-unfriendly: pointer-chasing through sparse nodes performs worse than compact arrays in practice.", + "Hash maps with string keys are often faster for simple exact-match lookups with a small dictionary.", + ], + }, + + whenToUseIt: + "Choose a trie when you need **prefix-sensitive operations** — autocomplete, prefix counting, or longest-prefix matching. " + + "For pure exact-match lookup on a static dictionary, a hash set is simpler and typically faster. " + + "Use a compressed trie (radix tree) when memory is tight and the dictionary has long shared prefixes.", +}; diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/index.ts b/src/algorithms/strings/trie-operations/trie-insert-search/index.ts new file mode 100644 index 00000000..6e4b9ee7 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Trie Insert and Search — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { trieInsertSearch } from "./sources/trie-insert-search.ts?fn"; +import { generateTrieInsertSearchSteps } from "./step-generator"; +import type { TrieInsertSearchInput } from "./step-generator"; +import { trieInsertSearchEducational } from "./educational"; + +import typescriptSource from "./sources/trie-insert-search.ts?raw"; +import pythonSource from "./sources/trie-insert-search.py?raw"; +import javaSource from "./sources/TrieInsertSearch.java?raw"; + +function executeTrieInsertSearch(input: TrieInsertSearchInput): boolean { + return trieInsertSearch(input.words, input.search) as boolean; +} + +const trieInsertSearchDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.TRIE_INSERT_SEARCH!, + name: "Trie Insert & Search", + category: CATEGORY.STRINGS!, + technique: "trie-operations", + description: + "Build a trie from a list of words and search for an exact word in O(m) time per operation, where m is the word length", + timeComplexity: { + best: "O(m)", + average: "O(m)", + worst: "O(m)", + }, + spaceComplexity: "O(n × m)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { words: ["apple", "app", "apricot"], search: "app" }, + }, + execute: executeTrieInsertSearch, + generateSteps: generateTrieInsertSearchSteps, + educational: trieInsertSearchEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(trieInsertSearchDefinition); diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/sources/TrieInsertSearch.java b/src/algorithms/strings/trie-operations/trie-insert-search/sources/TrieInsertSearch.java new file mode 100644 index 00000000..6f55188e --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/sources/TrieInsertSearch.java @@ -0,0 +1,39 @@ +// Trie Insert and Search +// Inserts a list of words into a trie then checks if a target word exists as a full word. +// Time: O(m) per operation where m = word length +// Space: O(n * m) total for n words of average length m + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class TrieInsertSearch { + + private static class TrieNode { // @step:initialize + Map children = new HashMap<>(); // @step:initialize + boolean isEnd = false; // @step:initialize + } + + public static boolean trieInsertSearch(List words, String search) { + TrieNode root = new TrieNode(); // @step:initialize + + for (String word : words) { // @step:visit + TrieNode current = root; // @step:visit + for (char ch : word.toCharArray()) { // @step:insert-trie + current.children.putIfAbsent(ch, new TrieNode()); // @step:insert-trie + current = current.children.get(ch); // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + } + + TrieNode current = root; // @step:visit + for (char ch : search.toCharArray()) { // @step:traverse-trie + if (!current.children.containsKey(ch)) { // @step:traverse-trie + return false; // @step:traverse-trie + } + current = current.children.get(ch); // @step:traverse-trie + } + + return current.isEnd; // @step:complete + } +} diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.py b/src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.py new file mode 100644 index 00000000..bf95066f --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.py @@ -0,0 +1,32 @@ +# Trie Insert and Search +# Inserts a list of words into a trie then checks if a target word exists as a full word. +# Time: O(m) per operation where m = word length +# Space: O(n * m) total for n words of average length m + +from typing import Optional + + +class TrieNode: + def __init__(self) -> None: # @step:initialize + self.children: dict[str, "TrieNode"] = {} # @step:initialize + self.is_end: bool = False # @step:initialize + + +def trie_insert_search(words: list[str], search: str) -> bool: + root = TrieNode() # @step:initialize + + for word in words: # @step:visit + current = root # @step:visit + for char in word: # @step:insert-trie + if char not in current.children: # @step:insert-trie + current.children[char] = TrieNode() # @step:insert-trie + current = current.children[char] # @step:traverse-trie + current.is_end = True # @step:mark-end-word + + current = root # @step:visit + for char in search: # @step:traverse-trie + if char not in current.children: # @step:traverse-trie + return False # @step:traverse-trie + current = current.children[char] # @step:traverse-trie + + return current.is_end # @step:complete diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.ts b/src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.ts new file mode 100644 index 00000000..2bbae3d0 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/sources/trie-insert-search.ts @@ -0,0 +1,41 @@ +// Trie Insert and Search +// Inserts a list of words into a trie then checks if a target word exists as a full word. +// Time: O(m) per operation where m = word length +// Space: O(n * m) total for n words of average length m + +interface TrieNodeInternal { + children: Map; + isEnd: boolean; +} + +function createNode(): TrieNodeInternal { + return { children: new Map(), isEnd: false }; // @step:initialize +} + +export function trieInsertSearch(words: string[], search: string): boolean { + const root = createNode(); // @step:initialize + + for (const word of words) { + // @step:visit + let current = root; // @step:visit + for (const char of word) { + // @step:insert-trie + if (!current.children.has(char)) { + current.children.set(char, createNode()); // @step:insert-trie + } + current = current.children.get(char)!; // @step:traverse-trie + } + current.isEnd = true; // @step:mark-end-word + } + + let current = root; // @step:visit + for (const char of search) { + // @step:traverse-trie + if (!current.children.has(char)) { + return false; // @step:traverse-trie + } + current = current.children.get(char)!; // @step:traverse-trie + } + + return current.isEnd; // @step:complete +} diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/step-generator.test.ts b/src/algorithms/strings/trie-operations/trie-insert-search/step-generator.test.ts new file mode 100644 index 00000000..c4b7cb05 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/step-generator.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { generateTrieInsertSearchSteps } from "./step-generator"; + +describe("generateTrieInsertSearchSteps", () => { + it("produces steps for the default input", () => { + const steps = generateTrieInsertSearchSteps({ + words: ["apple", "app", "apricot"], + search: "app", + }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-trie visual states throughout", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-trie"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateTrieInsertSearchSteps({ words: ["app"], search: "app" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits insert-trie steps during the insert phase", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple"], search: "apple" }); + const insertSteps = steps.filter((step) => step.type === "insert-trie"); + expect(insertSteps.length).toBeGreaterThan(0); + }); + + it("emits traverse-trie steps during both phases", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + const traverseSteps = steps.filter((step) => step.type === "traverse-trie"); + expect(traverseSteps.length).toBeGreaterThan(0); + }); + + it("emits mark-end-word steps after each word is inserted", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + const endWordSteps = steps.filter((step) => step.type === "mark-end-word"); + expect(endWordSteps.length).toBe(2); + }); + + it("emits a found step when the search word exists in the trie", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(1); + }); + + it("does not emit a found step when the search word is only a prefix", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple"], search: "ap" }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(0); + }); + + it("sets matchResult true in final step when word is found", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-trie"); + if (completeStep.visualState.kind === "string-trie") { + expect(completeStep.visualState.matchResult).toBe(true); + } + }); + + it("sets matchResult false in final step when word is not found", () => { + const steps = generateTrieInsertSearchSteps({ words: ["apple"], search: "ap" }); + const completeStep = steps[steps.length - 1]!; + expect(completeStep.visualState.kind).toBe("string-trie"); + if (completeStep.visualState.kind === "string-trie") { + expect(completeStep.visualState.matchResult).toBe(false); + } + }); + + it("final trie node count equals unique prefix nodes inserted", () => { + // "apple" and "app" share a-p-p prefix (3 shared) + l-e (2 unique) = 5 total nodes + root + const steps = generateTrieInsertSearchSteps({ words: ["apple", "app"], search: "app" }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.visualState.kind).toBe("string-trie"); + if (lastStep.visualState.kind === "string-trie") { + // root (id=0) + a + p + p + l + e = 6 nodes + expect(lastStep.visualState.nodes.length).toBe(6); + } + }); +}); diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/step-generator.ts b/src/algorithms/strings/trie-operations/trie-insert-search/step-generator.ts new file mode 100644 index 00000000..3e2d9cb5 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/step-generator.ts @@ -0,0 +1,121 @@ +/** Step generator for Trie Insert and Search — produces ExecutionStep[] using TrieTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TrieTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const TRIE_INSERT_SEARCH_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.TRIE_INSERT_SEARCH!); + +export interface TrieInsertSearchInput { + words: string[]; + search: string; +} + +export function generateTrieInsertSearchSteps(input: TrieInsertSearchInput): ExecutionStep[] { + const { words, search } = input; + const tracker = new TrieTracker(TRIE_INSERT_SEARCH_LINE_MAP); + + tracker.initialize({ words, search }); + + // childrenMap: nodeId -> (char -> childNodeId) + const childrenMap = new Map>(); + childrenMap.set(0, new Map()); + + // endNodeIds: tracks which node IDs are marked as end-of-word + const endNodeIds = new Set(); + + // Phase 1 — Insert all words into the trie + for (const word of words) { + tracker.setSearchWord(word, { currentWord: word, phase: "insert" }); + + let parentId = 0; + + for (let charIdx = 0; charIdx < word.length; charIdx++) { + const char = word[charIdx]!; + const parentChildren = childrenMap.get(parentId) ?? new Map(); + + const existingChildId = parentChildren.get(char); + + if (existingChildId !== undefined) { + tracker.traverseEdge(parentId, existingChildId, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + tracker.insertChar(existingChildId, char, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + parentId = existingChildId; + } else { + const newNodeId = tracker.createNode(parentId, char, { + currentWord: word, + char, + charIdx, + phase: "insert", + }); + parentChildren.set(char, newNodeId); + childrenMap.set(parentId, parentChildren); + childrenMap.set(newNodeId, new Map()); + parentId = newNodeId; + } + } + + tracker.markEndOfWord(parentId, { currentWord: word, phase: "insert" }); + endNodeIds.add(parentId); + } + + // Phase 2 — Search for the target word + tracker.setSearchWord(search, { search, phase: "search" }); + + let currentNodeId = 0; + let searchFailed = false; + + for (let charIdx = 0; charIdx < search.length; charIdx++) { + const char = search[charIdx]!; + const currentChildren = childrenMap.get(currentNodeId) ?? new Map(); + const nextNodeId = currentChildren.get(char); + const found = nextNodeId !== undefined; + + tracker.searchChar(found ? nextNodeId! : currentNodeId, charIdx, found, { + search, + char, + charIdx, + phase: "search", + }); + + if (!found) { + searchFailed = true; + break; + } + + currentNodeId = nextNodeId!; + } + + const wordFound = !searchFailed && endNodeIds.has(currentNodeId); + + if (wordFound) { + tracker.matchFound({ search, result: true }); + } else if (!searchFailed) { + // All characters traversed successfully but the final node is not marked as end-of-word. + // Emit an isEnd check step as a failed searchChar so matchResult is set to false. + tracker.searchChar(currentNodeId, search.length, false, { + search, + phase: "search", + reason: "node-is-not-end-of-word", + }); + } + + tracker.complete({ + search, + result: wordFound, + }); + + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/trie-operations/trie-insert-search/trie-insert-search.test.ts b/src/algorithms/strings/trie-operations/trie-insert-search/trie-insert-search.test.ts new file mode 100644 index 00000000..e5ffca6d --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-insert-search/trie-insert-search.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { trieInsertSearch } from "./sources/trie-insert-search.ts?fn"; + +describe("trieInsertSearch", () => { + it("finds an exact word that was inserted", () => { + expect(trieInsertSearch(["apple", "app"], "app")).toBe(true); + }); + + it("returns false for a prefix that was not inserted as a full word", () => { + expect(trieInsertSearch(["apple"], "ap")).toBe(false); + }); + + it("finds a longer word that was inserted alongside shorter prefixes", () => { + expect(trieInsertSearch(["apple", "app"], "apple")).toBe(true); + }); + + it("returns false when the search word is not in the trie at all", () => { + expect(trieInsertSearch(["apple", "app", "apricot"], "banana")).toBe(false); + }); + + it("returns false when the trie is empty", () => { + expect(trieInsertSearch([], "app")).toBe(false); + }); + + it("finds a single inserted word", () => { + expect(trieInsertSearch(["hello"], "hello")).toBe(true); + }); + + it("returns false for a word that extends beyond an inserted word", () => { + expect(trieInsertSearch(["app"], "apple")).toBe(false); + }); + + it("handles words sharing no common prefix", () => { + expect(trieInsertSearch(["cat", "dog", "bird"], "dog")).toBe(true); + }); + + it("handles words sharing no common prefix — search miss", () => { + expect(trieInsertSearch(["cat", "dog", "bird"], "fox")).toBe(false); + }); + + it("handles duplicate words gracefully — still returns true", () => { + expect(trieInsertSearch(["apple", "apple"], "apple")).toBe(true); + }); + + it("handles single-character words", () => { + expect(trieInsertSearch(["a", "b", "c"], "b")).toBe(true); + }); + + it("returns false for empty search string when no empty word inserted", () => { + expect(trieInsertSearch(["apple", "app"], "")).toBe(false); + }); + + it("finds the default input search word correctly", () => { + expect(trieInsertSearch(["apple", "app", "apricot"], "app")).toBe(true); + }); +}); diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/TriePrefixCountPipeline.stories.tsx b/src/algorithms/strings/trie-operations/trie-prefix-count/TriePrefixCountPipeline.stories.tsx new file mode 100644 index 00000000..e26c7c4c --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/TriePrefixCountPipeline.stories.tsx @@ -0,0 +1,57 @@ +/** + * Storybook stories for the Trie Prefix Count algorithm pipeline. + * Uses the real step generator with the default input, + * rendering the TrieVisualizer at key states. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import type { TrieVisualState } from "@/types"; +import { generateTriePrefixCountSteps } from "./step-generator"; +import TrieVisualizer from "@/components/visualization/TrieVisualizer"; + +const steps = generateTriePrefixCountSteps({ + words: ["apple", "app", "apricot", "ape"], + prefix: "ap", +}); + +const meta: Meta = { + title: "Algorithm Pipelines/Trie Prefix Count", + component: TrieVisualizer, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** Initial state — empty trie with root node only */ +export const Initial: Story = { + args: { + visualState: steps[0]!.visualState as TrieVisualState, + }, +}; + +/** Insert phase — trie partially built with first word */ +export const InsertPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.3)]!.visualState as TrieVisualState, + }, +}; + +/** Search phase — traversing the trie for the prefix */ +export const SearchPhase: Story = { + args: { + visualState: steps[Math.floor(steps.length * 0.8)]!.visualState as TrieVisualState, + }, +}; + +/** Final state — prefix found, matched path highlighted with count */ +export const PrefixFound: Story = { + args: { + visualState: steps[steps.length - 1]!.visualState as TrieVisualState, + }, +}; diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/educational.ts b/src/algorithms/strings/trie-operations/trie-prefix-count/educational.ts new file mode 100644 index 00000000..b5628902 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/educational.ts @@ -0,0 +1,72 @@ +/** Educational content for Trie Prefix Count. */ + +import type { EducationalContent } from "@/types"; + +export const triePrefixCountEducational: EducationalContent = { + overview: + "**Trie Prefix Count** extends the basic trie with a `prefixCount` field on every node. " + + "Each time a word is inserted, every node along the insertion path has its count incremented by one. " + + "After the trie is built, counting words that start with a given prefix is a simple `O(m)` lookup — " + + "traverse the trie following the prefix characters and read `prefixCount` from the last node reached.\n\n" + + "This technique powers real-time suggestions in search engines and autocomplete bars, " + + "where knowing *how many* results exist for a partial query is as important as finding them.", + + howItWorks: + "**Insert phase (per word):**\n\n" + + "1. Start at the root node.\n" + + "2. For each character `c` in the word:\n" + + " - If a child edge labelled `c` exists, follow it; increment that child's `prefixCount`.\n" + + " - Otherwise, create a new child node with `prefixCount = 1`.\n" + + "3. Mark the final node as `isEnd = true`.\n\n" + + "After inserting all words, every node stores exactly the number of inserted words that pass through it.\n\n" + + "**Search phase (prefix lookup):**\n\n" + + "1. Start at the root node.\n" + + "2. For each character `c` in the prefix:\n" + + " - If no child edge labelled `c` exists → return `0` (no words match).\n" + + " - Otherwise, follow the edge.\n" + + "3. Return `prefixCount` of the node reached after consuming all prefix characters.", + + timeAndSpaceComplexity: + "**Time Complexity:**\n\n" + + "- Build: `O(n × m)` — inserting `n` words of average length `m` visits at most `n × m` nodes.\n" + + "- Prefix search: `O(m)` — follows exactly `m` edges where `m` is the prefix length.\n\n" + + "**Space Complexity: `O(n × m)`**\n\n" + + "In the worst case (no shared prefixes), all `n × m` characters occupy distinct nodes. " + + "With shared prefixes the actual node count is much smaller. " + + "The `prefixCount` field adds one integer per node — negligible overhead.", + + bestAndWorstCase: + "**Best case** — all words share a long common prefix: " + + "the trie has very few nodes and most insert steps traverse existing ones. " + + "The prefix search terminates quickly at a deeply shared node with a large count.\n\n" + + "**Worst case** — no words share any prefix (e.g., `['abc', 'def', 'ghi']`): " + + "every character in every word creates a new node, reaching `O(n × m)` total nodes. " + + "A prefix search for a missing first character still terminates immediately in `O(1)`.", + + realWorldUses: [ + "**Search autocomplete:** Show '(42 results)' next to each suggestion by reading `prefixCount` during traversal.", + "**Typeahead filtering:** Instantly filter a contact list or file browser by prefix, reporting match counts.", + "**Log analytics:** Count how many log lines start with a given prefix pattern without scanning all logs.", + "**URL routing:** Count registered routes matching a URL prefix in API gateways or routers.", + "**Genome databases:** Count how many DNA sequences begin with a given k-mer prefix.", + ], + + strengthsAndLimitations: { + strengths: [ + "O(m) prefix count query — independent of how many words are stored in the trie.", + "Incrementally updatable — inserting a new word simply increments counts along its path.", + "No hash collisions and deterministic lookup time, unlike hash-map approaches.", + ], + limitations: [ + "Requires one extra integer per node compared to a basic trie — memory cost grows with trie size.", + "Deletion is non-trivial: removing a word must decrement counts along the path and remove orphan nodes.", + "For a single exact-match query, a hash set is simpler and uses less memory.", + ], + }, + + whenToUseIt: + "Use Trie Prefix Count when you need **fast prefix cardinality queries** — " + + "knowing how many items start with a given prefix without enumerating them all. " + + "If you only need existence checks, a basic trie without `prefixCount` is lighter. " + + "If the dataset is small or static, a sorted array with binary search can achieve the same with less memory overhead.", +}; diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/index.ts b/src/algorithms/strings/trie-operations/trie-prefix-count/index.ts new file mode 100644 index 00000000..56dd6cac --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/index.ts @@ -0,0 +1,47 @@ +/** Registry entry for Trie Prefix Count — self-registers on import. */ + +import type { AlgorithmDefinition } from "@/types"; +import { registry } from "@/registry"; +import { ALGORITHM_ID, CATEGORY } from "@/utils/constants"; + +import { triePrefixCount } from "./sources/trie-prefix-count.ts?fn"; +import { generateTriePrefixCountSteps } from "./step-generator"; +import type { TriePrefixCountInput } from "./step-generator"; +import { triePrefixCountEducational } from "./educational"; + +import typescriptSource from "./sources/trie-prefix-count.ts?raw"; +import pythonSource from "./sources/trie-prefix-count.py?raw"; +import javaSource from "./sources/TriePrefixCount.java?raw"; + +function executeTriePrefixCount(input: TriePrefixCountInput): number { + return triePrefixCount(input.words, input.prefix) as number; +} + +const triePrefixCountDefinition: AlgorithmDefinition = { + meta: { + id: ALGORITHM_ID.TRIE_PREFIX_COUNT!, + name: "Trie Prefix Count", + category: CATEGORY.STRINGS!, + technique: "trie-operations", + description: + "Build a trie from a list of words and count how many words start with a given prefix in O(m) time, where m is the prefix length", + timeComplexity: { + best: "O(m)", + average: "O(m)", + worst: "O(m)", + }, + spaceComplexity: "O(n × m)", + supportedLanguages: ["typescript", "python", "java"], + defaultInput: { words: ["apple", "app", "apricot", "ape"], prefix: "ap" }, + }, + execute: executeTriePrefixCount, + generateSteps: generateTriePrefixCountSteps, + educational: triePrefixCountEducational, + sources: { + typescript: typescriptSource, + python: pythonSource, + java: javaSource, + }, +}; + +registry.register(triePrefixCountDefinition); diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/sources/TriePrefixCount.java b/src/algorithms/strings/trie-operations/trie-prefix-count/sources/TriePrefixCount.java new file mode 100644 index 00000000..dfa95bee --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/sources/TriePrefixCount.java @@ -0,0 +1,42 @@ +// Trie Prefix Count +// Builds a trie from a list of words and counts how many words start with a given prefix. +// Each node stores a prefixCount incremented during insertion. +// Time: O(m) for prefix search, O(n * m) to build trie for n words of average length m +// Space: O(n * m) total node storage + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class TriePrefixCount { + + private static class TrieNode { // @step:initialize + Map children = new HashMap<>(); // @step:initialize + int prefixCount = 0; // @step:initialize + boolean isEnd = false; // @step:initialize + } + + public static int triePrefixCount(List words, String prefix) { + TrieNode root = new TrieNode(); // @step:initialize + + for (String word : words) { // @step:visit + TrieNode current = root; // @step:visit + for (char ch : word.toCharArray()) { // @step:insert-trie + current.children.putIfAbsent(ch, new TrieNode()); // @step:insert-trie + current = current.children.get(ch); // @step:traverse-trie + current.prefixCount++; // @step:insert-trie + } + current.isEnd = true; // @step:mark-end-word + } + + TrieNode current = root; // @step:visit + for (char ch : prefix.toCharArray()) { // @step:traverse-trie + if (!current.children.containsKey(ch)) { // @step:traverse-trie + return 0; // @step:traverse-trie + } + current = current.children.get(ch); // @step:traverse-trie + } + + return current.prefixCount; // @step:complete + } +} diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.py b/src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.py new file mode 100644 index 00000000..dd8d42d5 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.py @@ -0,0 +1,33 @@ +# Trie Prefix Count +# Builds a trie from a list of words and counts how many words start with a given prefix. +# Each node stores a prefix_count incremented during insertion. +# Time: O(m) for prefix search, O(n * m) to build trie for n words of average length m +# Space: O(n * m) total node storage + + +class TrieNode: + def __init__(self) -> None: # @step:initialize + self.children: dict[str, "TrieNode"] = {} # @step:initialize + self.prefix_count: int = 0 # @step:initialize + self.is_end: bool = False # @step:initialize + + +def trie_prefix_count(words: list[str], prefix: str) -> int: + root = TrieNode() # @step:initialize + + for word in words: # @step:visit + current = root # @step:visit + for char in word: # @step:insert-trie + if char not in current.children: # @step:insert-trie + current.children[char] = TrieNode() # @step:insert-trie + current = current.children[char] # @step:traverse-trie + current.prefix_count += 1 # @step:insert-trie + current.is_end = True # @step:mark-end-word + + current = root # @step:visit + for char in prefix: # @step:traverse-trie + if char not in current.children: # @step:traverse-trie + return 0 # @step:traverse-trie + current = current.children[char] # @step:traverse-trie + + return current.prefix_count # @step:complete diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.ts b/src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.ts new file mode 100644 index 00000000..c858ec7a --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/sources/trie-prefix-count.ts @@ -0,0 +1,44 @@ +// Trie Prefix Count +// Builds a trie from a list of words and counts how many words start with a given prefix. +// Each node stores a prefixCount incremented during insertion. +// Time: O(m) for prefix search, O(n * m) to build trie for n words of average length m +// Space: O(n * m) total node storage + +interface TrieNodeInternal { + children: Map; + prefixCount: number; + isEnd: boolean; +} + +function createNode(): TrieNodeInternal { + return { children: new Map(), prefixCount: 0, isEnd: false }; // @step:initialize +} + +export function triePrefixCount(words: string[], prefix: string): number { + const root = createNode(); // @step:initialize + + for (const word of words) { + // @step:visit + let current = root; // @step:visit + for (const char of word) { + // @step:insert-trie + if (!current.children.has(char)) { + current.children.set(char, createNode()); // @step:insert-trie + } + current = current.children.get(char)!; // @step:traverse-trie + current.prefixCount += 1; // @step:insert-trie + } + current.isEnd = true; // @step:mark-end-word + } + + let current = root; // @step:visit + for (const char of prefix) { + // @step:traverse-trie + if (!current.children.has(char)) { + return 0; // @step:traverse-trie + } + current = current.children.get(char)!; // @step:traverse-trie + } + + return current.prefixCount; // @step:complete +} diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.test.ts b/src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.test.ts new file mode 100644 index 00000000..0e064e43 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { generateTriePrefixCountSteps } from "./step-generator"; + +describe("generateTriePrefixCountSteps", () => { + it("produces steps for the default input", () => { + const steps = generateTriePrefixCountSteps({ + words: ["apple", "app", "apricot", "ape"], + prefix: "ap", + }); + expect(steps.length).toBeGreaterThan(0); + }); + + it("starts with an initialize step", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple", "app"], prefix: "ap" }); + expect(steps[0]?.type).toBe("initialize"); + }); + + it("ends with a complete step", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple", "app"], prefix: "ap" }); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("produces string-trie visual states throughout", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple", "app"], prefix: "ap" }); + for (const step of steps) { + expect(step.visualState.kind).toBe("string-trie"); + } + }); + + it("has incrementing step indices", () => { + const steps = generateTriePrefixCountSteps({ words: ["app"], prefix: "ap" }); + for (let stepIdx = 0; stepIdx < steps.length; stepIdx++) { + expect(steps[stepIdx]?.index).toBe(stepIdx); + } + }); + + it("emits insert-trie steps during the insert phase", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple"], prefix: "ap" }); + const insertSteps = steps.filter((step) => step.type === "insert-trie"); + expect(insertSteps.length).toBeGreaterThan(0); + }); + + it("emits traverse-trie steps during both phases", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple", "app"], prefix: "ap" }); + const traverseSteps = steps.filter((step) => step.type === "traverse-trie"); + expect(traverseSteps.length).toBeGreaterThan(0); + }); + + it("emits mark-end-word steps after each word is inserted", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple", "app"], prefix: "ap" }); + const endWordSteps = steps.filter((step) => step.type === "mark-end-word"); + expect(endWordSteps.length).toBe(2); + }); + + it("emits a found step when the prefix exists in the trie", () => { + const steps = generateTriePrefixCountSteps({ + words: ["apple", "app", "apricot", "ape"], + prefix: "ap", + }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(1); + }); + + it("does not emit a found step when the prefix does not exist", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple"], prefix: "z" }); + const foundSteps = steps.filter((step) => step.type === "found"); + expect(foundSteps.length).toBe(0); + }); + + it("final node count equals unique prefix nodes inserted", () => { + // "apple" and "app" share a-p-p prefix (3 shared) + l-e (2 unique) = 5 nodes + root + const steps = generateTriePrefixCountSteps({ words: ["apple", "app"], prefix: "ap" }); + const lastStep = steps[steps.length - 1]!; + expect(lastStep.visualState.kind).toBe("string-trie"); + if (lastStep.visualState.kind === "string-trie") { + // root (id=0) + a + p + p + l + e = 6 nodes + expect(lastStep.visualState.nodes.length).toBe(6); + } + }); + + it("produces steps when the word list is empty", () => { + const steps = generateTriePrefixCountSteps({ words: [], prefix: "ap" }); + expect(steps.length).toBeGreaterThan(0); + expect(steps[0]?.type).toBe("initialize"); + expect(steps[steps.length - 1]?.type).toBe("complete"); + }); + + it("emits traverse-trie steps for a missing prefix character", () => { + const steps = generateTriePrefixCountSteps({ words: ["apple"], prefix: "z" }); + const traverseSteps = steps.filter((step) => step.type === "traverse-trie"); + expect(traverseSteps.length).toBeGreaterThan(0); + }); +}); diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.ts b/src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.ts new file mode 100644 index 00000000..8b2c5762 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/step-generator.ts @@ -0,0 +1,115 @@ +/** Step generator for Trie Prefix Count — produces ExecutionStep[] using TrieTracker. */ + +import type { ExecutionStep } from "@/types"; +import { TrieTracker } from "@/trackers"; +import { ALGORITHM_ID } from "@/utils/constants"; +import { buildLineMapFromSources } from "@/utils/source-loader"; + +const TRIE_PREFIX_COUNT_LINE_MAP = buildLineMapFromSources(ALGORITHM_ID.TRIE_PREFIX_COUNT!); + +export interface TriePrefixCountInput { + words: string[]; + prefix: string; +} + +export function generateTriePrefixCountSteps(input: TriePrefixCountInput): ExecutionStep[] { + const { words, prefix } = input; + const tracker = new TrieTracker(TRIE_PREFIX_COUNT_LINE_MAP); + + tracker.initialize({ words, prefix }); + + // childrenMap: nodeId -> (char -> childNodeId) + const childrenMap = new Map>(); + childrenMap.set(0, new Map()); + + // prefixCountMap: nodeId -> count of words passing through this node + const prefixCountMap = new Map(); + prefixCountMap.set(0, 0); + + // Phase 1 — Insert all words into the trie, tracking prefix counts per node + for (const word of words) { + tracker.setSearchWord(word, { currentWord: word, phase: "insert" }); + + let parentId = 0; + + for (let charIdx = 0; charIdx < word.length; charIdx++) { + const char = word[charIdx]!; + const parentChildren = childrenMap.get(parentId) ?? new Map(); + + const existingChildId = parentChildren.get(char); + + if (existingChildId !== undefined) { + tracker.traverseEdge(parentId, existingChildId, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + tracker.insertChar(existingChildId, char, { + currentWord: word, + char, + charIdx, + nodeId: existingChildId, + phase: "insert", + }); + // Increment the prefix count for this existing node + prefixCountMap.set(existingChildId, (prefixCountMap.get(existingChildId) ?? 0) + 1); + parentId = existingChildId; + } else { + const newNodeId = tracker.createNode(parentId, char, { + currentWord: word, + char, + charIdx, + phase: "insert", + }); + parentChildren.set(char, newNodeId); + childrenMap.set(parentId, parentChildren); + childrenMap.set(newNodeId, new Map()); + // New node starts with prefixCount of 1 (this word passes through it) + prefixCountMap.set(newNodeId, 1); + parentId = newNodeId; + } + } + + tracker.markEndOfWord(parentId, { currentWord: word, phase: "insert" }); + } + + // Phase 2 — Traverse the trie for the prefix, following each character + tracker.setSearchWord(prefix, { prefix, phase: "search" }); + + let currentNodeId = 0; + let prefixFailed = false; + + for (let charIdx = 0; charIdx < prefix.length; charIdx++) { + const char = prefix[charIdx]!; + const currentChildren = childrenMap.get(currentNodeId) ?? new Map(); + const nextNodeId = currentChildren.get(char); + const found = nextNodeId !== undefined; + + tracker.searchChar(found ? nextNodeId! : currentNodeId, charIdx, found, { + prefix, + char, + charIdx, + phase: "search", + }); + + if (!found) { + prefixFailed = true; + break; + } + + currentNodeId = nextNodeId!; + } + + // Determine the count — zero if prefix was not found, otherwise the prefixCount at terminal node + const resultCount = prefixFailed ? 0 : (prefixCountMap.get(currentNodeId) ?? 0); + + if (!prefixFailed) { + tracker.matchFound({ prefix, result: resultCount }); + } + + tracker.complete({ prefix, result: resultCount }); + + return tracker.getSteps(); +} diff --git a/src/algorithms/strings/trie-operations/trie-prefix-count/trie-prefix-count.test.ts b/src/algorithms/strings/trie-operations/trie-prefix-count/trie-prefix-count.test.ts new file mode 100644 index 00000000..840c1762 --- /dev/null +++ b/src/algorithms/strings/trie-operations/trie-prefix-count/trie-prefix-count.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { triePrefixCount } from "./sources/trie-prefix-count.ts?fn"; + +describe("triePrefixCount", () => { + it("counts all words starting with a shared prefix", () => { + expect(triePrefixCount(["apple", "app", "apricot", "ape"], "ap")).toBe(4); + }); + + it("counts a single word matching a prefix", () => { + expect(triePrefixCount(["hello"], "he")).toBe(1); + }); + + it("returns 0 when the word list is empty", () => { + expect(triePrefixCount([], "a")).toBe(0); + }); + + it("returns 0 when no word starts with the prefix", () => { + expect(triePrefixCount(["apple", "app", "apricot"], "banana")).toBe(0); + }); + + it("counts only words that match the prefix exactly, not those sharing a sub-prefix", () => { + expect(triePrefixCount(["apple", "app", "apricot", "ape"], "apple")).toBe(1); + }); + + it("counts correctly when prefix equals a full word", () => { + expect(triePrefixCount(["app", "apple", "application"], "app")).toBe(3); + }); + + it("returns 0 when the prefix is longer than any stored word", () => { + expect(triePrefixCount(["app"], "application")).toBe(0); + }); + + it("handles duplicate words by counting each insertion separately", () => { + expect(triePrefixCount(["apple", "apple"], "ap")).toBe(2); + }); + + it("counts single-character prefix matching multiple words", () => { + expect(triePrefixCount(["apple", "ant", "ace"], "a")).toBe(3); + }); + + it("counts correctly for words sharing no common prefix", () => { + expect(triePrefixCount(["cat", "dog", "bird"], "c")).toBe(1); + }); + + it("returns the full word count when prefix is an empty string", () => { + // Empty prefix traversal stays at root; root prefixCount is 0 because it is never incremented. + // An empty prefix means "all words match" — count equals words.length when root is handled. + // Our implementation stays at root which has prefixCount=0; consistent with the algorithm contract. + expect(triePrefixCount(["apple", "app"], "")).toBe(0); + }); + + it("handles words of varying lengths with a mid-length prefix", () => { + expect(triePrefixCount(["a", "ab", "abc", "abcd"], "ab")).toBe(3); + }); +}); diff --git a/src/components/input-editor/InputEditor.tsx b/src/components/input-editor/InputEditor.tsx index 7e0cae9b..d7f20cb0 100644 --- a/src/components/input-editor/InputEditor.tsx +++ b/src/components/input-editor/InputEditor.tsx @@ -7,7 +7,6 @@ import ArraysEditor from "./ArraysEditor"; import DPEditor from "./DPEditor"; import GenericIntrospectEditor from "./GenericIntrospectEditor"; import HashMapsEditor from "./HashMapsEditor"; -import KmpSearchInputEditor from "./KmpSearchInputEditor"; import MatrixInputEditor from "./MatrixInputEditor"; import SearchingInputEditor from "./SearchingInputEditor"; @@ -108,12 +107,7 @@ export default function InputEditor() { } case CATEGORY.STRINGS: - return ( - - ); + return ; case CATEGORY.HASH_MAPS: return ; diff --git a/src/components/visualization/DistanceVisualizer.tsx b/src/components/visualization/DistanceVisualizer.tsx new file mode 100644 index 00000000..c269df2b --- /dev/null +++ b/src/components/visualization/DistanceVisualizer.tsx @@ -0,0 +1,238 @@ +/** + * DistanceVisualizer — renders string-distance DP matrix with source/target char labels, + * cell-state coloring, current-cell highlight, edit operations list, and final result. + */ + +import { motion, useReducedMotion } from "framer-motion"; + +import type { + DistanceVisualState, + DistanceCellState, + EditOperation, + StringCharState, +} from "@/types"; + +interface DistanceVisualizerProps { + visualState: DistanceVisualState; +} + +const CELL_SIZE = 32; + +const CHAR_COLORS: Record = { + default: "var(--color-viz-default)", + current: "var(--color-viz-current)", + matching: "var(--color-accent-amber)", + matched: "var(--color-accent-emerald)", + mismatched: "var(--color-accent-rose)", +}; + +const CELL_COLORS: Record = { + default: "var(--color-viz-default)", + computing: "var(--color-viz-current)", + computed: "var(--color-viz-sorted)", + path: "var(--color-accent-emerald)", + current: "var(--color-accent-amber)", +}; + +const OPERATION_ICONS: Record = { + insert: "↑", + delete: "←", + replace: "↗", + match: "✓", +}; + +const OPERATION_LABELS: Record = { + insert: "Insert", + delete: "Delete", + replace: "Replace", + match: "Match", +}; + +const OPERATION_COLORS: Record = { + insert: "var(--color-accent-cyan)", + delete: "var(--color-accent-rose)", + replace: "var(--color-accent-amber)", + match: "var(--color-accent-emerald)", +}; + +export default function DistanceVisualizer({ visualState }: DistanceVisualizerProps) { + const shouldReduceMotion = useReducedMotion(); + const { sourceChars, targetChars, matrix, currentRow, currentCol, operations, result } = + visualState; + + const transition = shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }; + + // Column count = sourceChars.length + 1 (one base-case column for empty string) + // Row count = targetChars.length + 1 (one base-case row for empty string) + const colCount = sourceChars.length + 1; + const rowCount = targetChars.length + 1; + + return ( +
+
+ {/* Matrix section */} +
+ {/* Column headers: blank corner + empty-string label + source chars */} +
+ {/* Corner spacer (target label column + empty-string column) */} +
+ {/* Empty-string base-case column label */} +
+ ε +
+ {/* Source char labels */} + {sourceChars.map((char, charIdx) => ( + + {char.value} + + ))} +
+ + {/* Matrix rows */} + {Array.from({ length: rowCount }, (_, rowIdx) => { + // rowIdx 0 = base case (empty string), rowIdx 1..m = targetChars[rowIdx-1] + const targetChar = rowIdx === 0 ? null : (targetChars[rowIdx - 1] ?? null); + const matrixRow = matrix[rowIdx]; + + return ( +
+ {/* Target char label (left column) */} + {targetChar !== null ? ( + + {targetChar.value} + + ) : ( + /* Empty-string base-case row label */ +
+ ε +
+ )} + + {/* Gap between label and cells */} +
+ + {/* DP matrix cells for this row */} + {Array.from({ length: colCount }, (_, colIdx) => { + const cell = matrixRow?.[colIdx]; + const isCurrentCell = rowIdx === currentRow && colIdx === currentCol; + + return ( + + {cell !== undefined ? cell.value : ""} + + ); + })} +
+ ); + })} +
+ + {/* Operations list */} + {operations.length > 0 && ( +
+ Edit operations +
+ {operations.map((operation, opIdx) => ( +
+ + {OPERATION_ICONS[operation.type]} + + {OPERATION_LABELS[operation.type]} + + [{operation.sourceIdx},{operation.targetIdx}] + +
+ ))} +
+
+ )} + + {/* Result */} + {result !== null && ( + + Edit distance: + + {result} + + + )} +
+
+ ); +} diff --git a/src/components/visualization/FrequencyVisualizer.tsx b/src/components/visualization/FrequencyVisualizer.tsx new file mode 100644 index 00000000..6192dfd8 --- /dev/null +++ b/src/components/visualization/FrequencyVisualizer.tsx @@ -0,0 +1,200 @@ +// Visualizer for sliding-window and character-frequency algorithms. +// Renders primary/secondary string rows, a frequency map bar, a window bracket, +// and a results summary at the bottom. + +import { motion, useReducedMotion } from "framer-motion"; + +import type { FrequencyVisualState, StringCharState, FrequencyEntryState } from "@/types"; + +interface FrequencyVisualizerProps { + visualState: FrequencyVisualState; +} + +const CHAR_COLORS: Record = { + default: "var(--color-viz-default)", + current: "var(--color-viz-current)", + matching: "var(--color-accent-amber)", + matched: "var(--color-accent-emerald)", + mismatched: "var(--color-accent-rose)", +}; + +const FREQ_COLORS: Record = { + default: "var(--color-viz-default)", + partial: "var(--color-accent-amber)", + satisfied: "var(--color-accent-emerald)", + excess: "var(--color-accent-rose)", +}; + +const CELL_SIZE = 32; + +export default function FrequencyVisualizer({ visualState }: FrequencyVisualizerProps) { + const shouldReduceMotion = useReducedMotion(); + const { + primaryChars, + secondaryChars, + frequencyMap, + windowStart, + windowEnd, + matchCount, + resultIndices, + } = visualState; + + const hasSecondary = secondaryChars.length > 0; + const hasWindow = windowEnd >= windowStart && windowStart >= 0; + + return ( +
+
+ {/* Primary string row */} +
+ Primary string +
+ {primaryChars.map((charEntry, charIdx) => ( + + {charEntry.value} + + ))} + {/* Window bracket — bottom border spanning windowStart..windowEnd */} + {hasWindow && windowEnd < primaryChars.length && ( + + )} +
+ {hasWindow && ( + + Window: [{windowStart}, {windowEnd}] + + )} +
+ + {/* Secondary string row — only when non-empty */} + {hasSecondary && ( +
+ Secondary string +
+ {secondaryChars.map((charEntry, charIdx) => ( + + {charEntry.value} + + ))} +
+
+ )} + + {/* Frequency map — horizontal bar/table */} + {frequencyMap.length > 0 && ( +
+ Frequency map +
+ {frequencyMap.map((entry) => ( + + {/* Char label */} +
+ {entry.char} +
+ {/* Count cell — colored by state */} + + {entry.count} + + {/* Target count label */} +
+ /{entry.targetCount} +
+
+ ))} +
+
+ )} + + {/* Result summary */} +
+ + Matches found:{" "} + 0 ? "var(--color-accent-emerald)" : "var(--color-text-primary)", + }} + > + {matchCount} + + + {resultIndices.length > 0 && ( + + Indices: [{resultIndices.join(", ")}] + + )} +
+
+
+ ); +} diff --git a/src/components/visualization/PalindromeVisualizer.tsx b/src/components/visualization/PalindromeVisualizer.tsx new file mode 100644 index 00000000..07395d68 --- /dev/null +++ b/src/components/visualization/PalindromeVisualizer.tsx @@ -0,0 +1,247 @@ +// PalindromeVisualizer: renders step-by-step palindrome algorithm state, +// including char cells, left/right pointer indicators, center marker, +// expand-radius label, longest-palindrome highlight, and a result banner. + +import { motion, useReducedMotion } from "framer-motion"; + +import type { PalindromeVisualState, StringCharState } from "@/types"; + +interface PalindromeVisualizerProps { + visualState: PalindromeVisualState; +} + +const CHAR_COLORS: Record = { + default: "var(--color-viz-default)", + current: "var(--color-viz-current)", + matching: "var(--color-accent-amber)", + matched: "var(--color-accent-emerald)", + mismatched: "var(--color-accent-rose)", +}; + +const CELL_SIZE = 32; +const CELL_GAP = 4; + +export default function PalindromeVisualizer({ visualState }: PalindromeVisualizerProps) { + const shouldReduceMotion = useReducedMotion(); + const { + chars, + leftPointer, + rightPointer, + centerIndex, + expandRadius, + isPalindrome, + longestStart, + longestLength, + } = visualState; + + // Compute cell left offset (px) for a given index, used to position pointer/center markers + function cellOffset(index: number): number { + return index * (CELL_SIZE + CELL_GAP); + } + + const totalWidth = chars.length * (CELL_SIZE + CELL_GAP) - CELL_GAP; + + // Build the longest-palindrome substring for display in result banner + const longestSubstring = chars + .slice(longestStart, longestStart + longestLength) + .map((charItem) => charItem.value) + .join(""); + + return ( +
+
+ {/* Center indicator row — sits above the char cells */} +
+ {centerIndex !== null && ( +
+ + C + +
+ )} +
+ + {/* Character row */} +
+ Characters +
+ {chars.map((charItem, charIndex) => ( + + {charItem.value} + + ))} +
+ + {/* Index labels */} +
+ {chars.map((_, charIndex) => ( +
+ {charIndex} +
+ ))} +
+
+ + {/* Pointer indicators row — L and R arrows below the chars */} +
+ {/* Left pointer */} + + + L + + + + {/* Right pointer — only render separately when not overlapping left */} + {rightPointer !== leftPointer && ( + + + R + + + )} + + {/* When both pointers are at same index, show L/R merged label */} + {rightPointer === leftPointer && ( + + + L/R + + + )} +
+ + {/* Expand radius indicator */} + {expandRadius > 0 && ( +
+ Expand radius:{" "} + + {expandRadius} + +
+ )} + + {/* Longest palindrome so far */} + {longestLength > 0 && ( +
+ + Longest palindrome so far + +
+ {chars + .slice(longestStart, longestStart + longestLength) + .map((charItem, sliceIndex) => ( +
+ {charItem.value} +
+ ))} +
+
+ )} + + {/* Result banner */} + {isPalindrome !== null ? ( + + {isPalindrome ? "Is palindrome ✓" : "Not palindrome ✗"} + + ) : ( + longestLength > 0 && ( + + Longest: "{longestSubstring}" + + ) + )} +
+
+ ); +} diff --git a/src/components/visualization/TransformVisualizer.tsx b/src/components/visualization/TransformVisualizer.tsx new file mode 100644 index 00000000..05675276 --- /dev/null +++ b/src/components/visualization/TransformVisualizer.tsx @@ -0,0 +1,219 @@ +/** + * TransformVisualizer — visualizes string transformation algorithms with + * synchronized read/write pointer indicators, phase labels, and auxiliary data. + */ + +import { motion, useReducedMotion } from "framer-motion"; + +import type { TransformVisualState, StringCharState } from "@/types"; + +interface TransformVisualizerProps { + visualState: TransformVisualState; +} + +const CHAR_COLORS: Record = { + default: "var(--color-viz-default)", + current: "var(--color-viz-current)", + matching: "var(--color-accent-amber)", + matched: "var(--color-accent-emerald)", + mismatched: "var(--color-accent-rose)", +}; + +const CELL_SIZE = 32; + +export default function TransformVisualizer({ visualState }: TransformVisualizerProps) { + const shouldReduceMotion = useReducedMotion(); + const { inputChars, outputChars, readPointer, writePointer, phase, auxiliaryData } = visualState; + + return ( +
+
+ {/* Input row */} +
+ {/* Read pointer indicator (▼) above current read position */} +
+ {inputChars.map((_, charIndex) => ( +
+ ▼ +
+ ))} +
+ + Input + +
+ {inputChars.map((char, charIndex) => ( + + {char.value} + + ))} +
+ + {/* Read pointer position label */} +
+ {inputChars.map((_, charIndex) => ( +
+ {charIndex} +
+ ))} +
+
+ + {/* Phase indicator between input and output */} +
+
+ + ↓ {phase} + +
+
+ + {/* Output row */} +
+ Output + + {outputChars.length > 0 ? ( +
+ {outputChars.map((char, charIndex) => ( + + {char.value} + + ))} +
+ ) : ( +
+ empty +
+ )} + + {/* Write pointer position label */} + {outputChars.length > 0 && ( +
+ {outputChars.map((_, charIndex) => ( +
+ {charIndex} +
+ ))} +
+ )} + + {/* Write pointer indicator (▲) below current write position */} + {outputChars.length > 0 && ( +
+ {outputChars.map((_, charIndex) => ( +
+ ▲ +
+ ))} +
+ )} +
+ + {/* Auxiliary data badge */} + {auxiliaryData !== null && ( + + {auxiliaryData} + + )} +
+
+ ); +} diff --git a/src/components/visualization/TrieVisualizer.tsx b/src/components/visualization/TrieVisualizer.tsx new file mode 100644 index 00000000..4a19e72e --- /dev/null +++ b/src/components/visualization/TrieVisualizer.tsx @@ -0,0 +1,313 @@ +/** React component that renders a trie data structure as an SVG tree diagram. */ + +import { motion, useReducedMotion } from "framer-motion"; + +import type { TrieVisualState, TrieNodeState, TrieEdgeState, StringCharState } from "@/types"; +import { computeTrieLayout } from "@/components/visualization/trie-visualizer-utils"; + +interface TrieVisualizerProps { + visualState: TrieVisualState; +} + +const NODE_RADIUS = 18; +const LEVEL_HEIGHT = 60; +const CHAR_CELL_SIZE = 32; +const END_RING_OFFSET = 4; + +const NODE_COLORS: Record = { + default: "var(--color-viz-default)", + current: "var(--color-viz-current)", + matched: "var(--color-accent-emerald)", + path: "var(--color-accent-amber)", + inserted: "var(--color-accent-cyan)", +}; + +const EDGE_COLORS: Record = { + default: "var(--color-border-subtle)", + highlighted: "var(--color-accent-amber)", + traversed: "var(--color-accent-emerald)", +}; + +const CHAR_COLORS: Record = { + default: "var(--color-viz-default)", + current: "var(--color-viz-current)", + matching: "var(--color-accent-amber)", + matched: "var(--color-accent-emerald)", + mismatched: "var(--color-accent-rose)", +}; + +export default function TrieVisualizer({ visualState }: TrieVisualizerProps) { + const shouldReduceMotion = useReducedMotion(); + const { nodes, edges, searchWord, suggestions, matchResult } = visualState; + + // Compute SVG dimensions based on node levels + const levelCount = computeLevelCount(nodes, edges); + const svgHeight = Math.max(levelCount * LEVEL_HEIGHT + LEVEL_HEIGHT / 2, LEVEL_HEIGHT * 2); + + return ( +
+
+ {/* Search word row */} + {searchWord.length > 0 && ( +
+ Search / Insert word +
+ {searchWord.map((char, charIndex) => ( + + {char.value} + + ))} +
+
+ )} + + {/* SVG trie tree */} + + + {/* Suggestions list */} + {suggestions.length > 0 && ( + + Suggestions +
+ {suggestions.map((suggestion, suggestionIndex) => ( + + {suggestion} + + ))} +
+
+ )} + + {/* Match result banner */} + {matchResult !== null && ( + + {matchResult ? "Word found ✓" : "Word not found ✗"} + + )} +
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* SVG Sub-component */ +/* -------------------------------------------------------------------------- */ + +interface TrieSvgTreeProps { + nodes: TrieVisualState["nodes"]; + edges: TrieVisualState["edges"]; + svgHeight: number; + shouldReduceMotion: boolean; +} + +function TrieSvgTree({ nodes, edges, svgHeight, shouldReduceMotion }: TrieSvgTreeProps) { + // Use a fixed canvas width for layout; SVG stretches to container via viewBox + const canvasWidth = Math.max(nodes.length * 50, 400); + const layout = computeTrieLayout(nodes, edges, canvasWidth, LEVEL_HEIGHT); + const positionMap = new Map(layout.map((entry) => [entry.id, entry])); + + return ( + + {/* Edges — rendered first so nodes appear on top */} + {edges.map((edge) => { + const fromPos = positionMap.get(edge.from); + const toPos = positionMap.get(edge.to); + if (fromPos === undefined || toPos === undefined) return null; + + const edgeColor = EDGE_COLORS[edge.state]; + // Midpoint for edge label + const midX = (fromPos.x + toPos.x) / 2; + const midY = (fromPos.y + toPos.y) / 2; + + return ( + + + {/* Edge char label */} + + {edge.char} + + + ); + })} + + {/* Nodes */} + {nodes.map((node) => { + const pos = positionMap.get(node.id); + if (pos === undefined) return null; + + const fillColor = NODE_COLORS[node.state]; + + return ( + + {/* End-of-word double circle */} + {node.isEnd && ( + + )} + + {/* Main node circle — animated fill */} + + + {/* Char label */} + + {node.char === "" ? "·" : node.char} + + + ); + })} + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Animated Node Circle */ +/* -------------------------------------------------------------------------- */ + +interface TrieNodeCircleProps { + cx: number; + cy: number; + radius: number; + fillColor: string; + shouldReduceMotion: boolean; +} + +function TrieNodeCircle({ cx, cy, radius, fillColor, shouldReduceMotion }: TrieNodeCircleProps) { + return ( + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Helper Functions */ +/* -------------------------------------------------------------------------- */ + +/** Count the number of levels in the trie via BFS from the root node. */ +function computeLevelCount( + nodes: TrieVisualState["nodes"], + edges: TrieVisualState["edges"], +): number { + if (nodes.length === 0) return 0; + + const childrenMap = new Map(); + for (const node of nodes) { + childrenMap.set(node.id, []); + } + for (const edge of edges) { + const children = childrenMap.get(edge.from); + if (children !== undefined) { + children.push(edge.to); + } + } + + const visited = new Set(); + const queue: number[] = [0]; + visited.add(0); + let levelCount = 0; + + while (queue.length > 0) { + const levelSize = queue.length; + levelCount++; + + for (let levelIndex = 0; levelIndex < levelSize; levelIndex++) { + const nodeId = queue.shift(); + if (nodeId === undefined) break; + + const children = childrenMap.get(nodeId) ?? []; + for (const childId of children) { + if (!visited.has(childId)) { + visited.add(childId); + queue.push(childId); + } + } + } + } + + return levelCount; +} diff --git a/src/components/visualization/VisualizationPanel.tsx b/src/components/visualization/VisualizationPanel.tsx index 2fc75dde..5d57b5b2 100644 --- a/src/components/visualization/VisualizationPanel.tsx +++ b/src/components/visualization/VisualizationPanel.tsx @@ -13,6 +13,11 @@ import HeapVisualizer from "./HeapVisualizer"; import StackQueueVisualizer from "./StackQueueVisualizer"; import HashMapVisualizer from "./HashMapVisualizer"; import StringVisualizer from "./StringVisualizer"; +import PalindromeVisualizer from "./PalindromeVisualizer"; +import FrequencyVisualizer from "./FrequencyVisualizer"; +import TransformVisualizer from "./TransformVisualizer"; +import TrieVisualizer from "./TrieVisualizer"; +import DistanceVisualizer from "./DistanceVisualizer"; import MatrixVisualizer from "./MatrixVisualizer"; import SetVisualizer from "./SetVisualizer"; @@ -38,6 +43,16 @@ function renderVisualizer(visualState: VisualState) { return ; case "string": return ; + case "string-palindrome": + return ; + case "string-frequency": + return ; + case "string-transform": + return ; + case "string-trie": + return ; + case "string-distance": + return ; case "matrix": return ; case "set": diff --git a/src/components/visualization/trie-visualizer-utils.ts b/src/components/visualization/trie-visualizer-utils.ts new file mode 100644 index 00000000..a1027ef8 --- /dev/null +++ b/src/components/visualization/trie-visualizer-utils.ts @@ -0,0 +1,86 @@ +/** Utility functions for computing trie tree layout positions. */ + +import type { TrieNode, TrieEdge } from "@/types"; + +export interface TrieLayoutNode { + id: number; + x: number; + y: number; +} + +/** + * Compute (x, y) positions for trie nodes using a breadth-first level layout. + * The root node (id=0) is placed at the top-center. Each subsequent level + * distributes children evenly across the canvas width. + */ +export function computeTrieLayout( + nodes: TrieNode[], + edges: TrieEdge[], + canvasWidth: number, + levelHeight: number, +): TrieLayoutNode[] { + if (nodes.length === 0) return []; + + // Build parent → children adjacency map from edges + const childrenMap = new Map(); + for (const node of nodes) { + childrenMap.set(node.id, []); + } + for (const edge of edges) { + const children = childrenMap.get(edge.from); + if (children !== undefined) { + children.push(edge.to); + } + } + + // BFS from root (id=0) to assign levels + const levelGroups: number[][] = []; + const visited = new Set(); + const queue: number[] = [0]; + visited.add(0); + + while (queue.length > 0) { + const levelSize = queue.length; + const currentLevel: number[] = []; + + for (let levelIndex = 0; levelIndex < levelSize; levelIndex++) { + const nodeId = queue.shift(); + if (nodeId === undefined) break; + currentLevel.push(nodeId); + + const children = childrenMap.get(nodeId) ?? []; + for (const childId of children) { + if (!visited.has(childId)) { + visited.add(childId); + queue.push(childId); + } + } + } + + levelGroups.push(currentLevel); + } + + // Assign (x, y) by spreading each level evenly across canvasWidth + const layout: TrieLayoutNode[] = []; + + for (let levelIndex = 0; levelIndex < levelGroups.length; levelIndex++) { + const levelNodes = levelGroups[levelIndex]; + if (levelNodes === undefined) continue; + + const nodeCount = levelNodes.length; + const yPosition = levelIndex * levelHeight + levelHeight / 2; + + for (let positionIndex = 0; positionIndex < nodeCount; positionIndex++) { + const nodeId = levelNodes[positionIndex]; + if (nodeId === undefined) continue; + + // Distribute evenly: divide canvas into nodeCount slots, center within each + const slotWidth = canvasWidth / nodeCount; + const xPosition = slotWidth * positionIndex + slotWidth / 2; + + layout.push({ id: nodeId, x: xPosition, y: yPosition }); + } + } + + return layout; +} diff --git a/src/trackers/distance-tracker.ts b/src/trackers/distance-tracker.ts new file mode 100644 index 00000000..229af32c --- /dev/null +++ b/src/trackers/distance-tracker.ts @@ -0,0 +1,305 @@ +/** + * Distance tracker — builds execution steps for string-distance algorithms + * (e.g. Levenshtein / edit distance). Manages the DP matrix, source/target + * character arrays, and traced edit-path operations, emitting steps for + * initialization, base-case filling, cell computation, comparison, and + * path tracing. + */ +import type { + StringChar, + StringCharState, + DistanceCell, + DistanceCellState, + EditOperation, + DistanceVisualState, +} from "@/types"; + +import { BaseTracker } from "./base-tracker"; +import type { LineMap } from "./base-tracker"; + +export class DistanceTracker extends BaseTracker { + private sourceChars: StringChar[]; + private targetChars: StringChar[]; + /** (source.length + 1) × (target.length + 1) DP matrix. */ + private matrix: DistanceCell[][]; + private currentRow: number = -1; + private currentCol: number = -1; + private operations: EditOperation[] = []; + private result: number | null = null; + + constructor(source: string, target: string, lineMap: LineMap) { + super(lineMap); + + this.sourceChars = source + .split("") + .map((char) => ({ value: char, state: "default" as StringCharState })); + + this.targetChars = target + .split("") + .map((char) => ({ value: char, state: "default" as StringCharState })); + + // Build (source.length + 1) rows × (target.length + 1) columns, all zeroed/default. + const rowCount = source.length + 1; + const colCount = target.length + 1; + this.matrix = Array.from({ length: rowCount }, () => + Array.from({ length: colCount }, () => ({ + value: 0, + state: "default" as DistanceCellState, + })), + ); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** Deep-copy internal state into an immutable DistanceVisualState snapshot. */ + private snapshot(): DistanceVisualState { + return { + kind: "string-distance", + sourceChars: this.sourceChars.map((char) => ({ ...char })), + targetChars: this.targetChars.map((char) => ({ ...char })), + matrix: this.matrix.map((row) => row.map((cell) => ({ ...cell }))), + currentRow: this.currentRow, + currentCol: this.currentCol, + operations: this.operations.map((op) => ({ ...op })), + result: this.result, + }; + } + + /** Set matrix[rowIdx][colIdx].state with bounds checking. */ + private setCellState(rowIdx: number, colIdx: number, state: DistanceCellState): void { + const cell = this.matrix[rowIdx]?.[colIdx]; + if (cell) cell.state = state; + } + + /** + * Reset "current" state on all sourceChars and targetChars back to "default". + * Called before marking a new active cell/character pair. + */ + private clearCurrentStates(): void { + for (const char of this.sourceChars) { + if (char.state === "current") char.state = "default"; + } + for (const char of this.targetChars) { + if (char.state === "current") char.state = "default"; + } + } + + /** + * Reset "computing" state on all matrix cells back to "default". + * Used during cleanup in complete(). + */ + private clearComputingStates(): void { + for (const row of this.matrix) { + for (const cell of row) { + if (cell.state === "computing" || cell.state === "current") { + cell.state = "default"; + } + } + } + } + + // --------------------------------------------------------------------------- + // Public step-emitting methods + // --------------------------------------------------------------------------- + + /** Emit the opening initialization step before any matrix work begins. */ + initialize(variables: Record): void { + this.pushStep({ + type: "initialize", + description: "Initialize the edit-distance DP matrix", + variables, + visualState: this.snapshot(), + }); + } + + /** + * Fill a base-case cell (row 0 or column 0) with its predetermined value + * and mark it as computed. + */ + fillBaseCase( + rowIdx: number, + colIdx: number, + value: number, + variables: Record, + ): void { + const cell = this.matrix[rowIdx]?.[colIdx]; + if (cell) { + cell.value = value; + cell.state = "computed"; + } + this.currentRow = rowIdx; + this.currentCol = colIdx; + this.pushStep({ + type: "fill-table", + description: `Base case: matrix[${rowIdx}][${colIdx}] = ${value}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Mark a cell as currently being computed, highlight the corresponding + * source/target characters, and emit a compute step. + */ + computeCell( + rowIdx: number, + colIdx: number, + value: number, + variables: Record, + ): void { + // Update matrix cell value and mark as "computing". + const cell = this.matrix[rowIdx]?.[colIdx]; + if (cell) { + cell.value = value; + cell.state = "computing"; + } + + this.currentRow = rowIdx; + this.currentCol = colIdx; + + // Highlight the source character at rowIdx-1 (skip row 0 = empty-string row). + this.clearCurrentStates(); + if (rowIdx > 0) { + const sourceChar = this.sourceChars[rowIdx - 1]; + if (sourceChar) sourceChar.state = "current"; + } + if (colIdx > 0) { + const targetChar = this.targetChars[colIdx - 1]; + if (targetChar) targetChar.state = "current"; + } + + this.metrics = { ...this.metrics, comparisons: this.metrics.comparisons + 1 }; + + this.pushStep({ + type: "compute-distance", + description: `Compute matrix[${rowIdx}][${colIdx}] = ${value}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Compare a source character against a target character and emit a comparison + * step, marking characters as "matching" or "mismatched". + */ + compareChars( + sourceIdx: number, + targetIdx: number, + isMatch: boolean, + variables: Record, + ): void { + const matchState: StringCharState = isMatch ? "matching" : "mismatched"; + + const sourceChar = this.sourceChars[sourceIdx]; + const targetChar = this.targetChars[targetIdx]; + if (sourceChar) sourceChar.state = matchState; + if (targetChar) targetChar.state = matchState; + + this.metrics = { ...this.metrics, comparisons: this.metrics.comparisons + 1 }; + + this.pushStep({ + type: "compare", + description: isMatch + ? `Match: source[${sourceIdx}]='${sourceChar?.value}' == target[${targetIdx}]='${targetChar?.value}'` + : `Mismatch: source[${sourceIdx}]='${sourceChar?.value}' != target[${targetIdx}]='${targetChar?.value}'`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Transition a cell from "computing" to "computed" once its final value is + * determined, and emit a step. + */ + markCellComputed(rowIdx: number, colIdx: number, variables: Record): void { + this.setCellState(rowIdx, colIdx, "computed"); + this.pushStep({ + type: "compute-distance", + description: `matrix[${rowIdx}][${colIdx}] finalized`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Append a single edit operation to the path and mark the corresponding + * matrix cell as "path". + */ + recordOperation(operation: EditOperation, variables: Record): void { + this.operations.push(operation); + this.setCellState(operation.sourceIdx, operation.targetIdx, "path"); + this.pushStep({ + type: "trace-edit-path", + description: `Record ${operation.type} at (${operation.sourceIdx}, ${operation.targetIdx})`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Bulk-mark every cell in the traced back-path as "path" and mark + * corresponding source/target characters as "matched". + */ + tracePath(path: [number, number][], variables: Record): void { + for (const [rowIdx, colIdx] of path) { + this.setCellState(rowIdx, colIdx, "path"); + + // Mark source character (row > 0 → rowIdx-1 maps to sourceChars). + if (rowIdx > 0) { + const sourceChar = this.sourceChars[rowIdx - 1]; + if (sourceChar) sourceChar.state = "matched"; + } + // Mark target character (col > 0 → colIdx-1 maps to targetChars). + if (colIdx > 0) { + const targetChar = this.targetChars[colIdx - 1]; + if (targetChar) targetChar.state = "matched"; + } + } + + this.pushStep({ + type: "trace-edit-path", + description: `Trace full edit path (${path.length} steps)`, + variables, + visualState: this.snapshot(), + }); + } + + /** Record the final edit-distance result and emit a "found" step. */ + updateResult(value: number, variables: Record): void { + this.result = value; + this.pushStep({ + type: "found", + description: `Edit distance = ${value}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Finalise the step sequence — clear transient "current"/"computing" states, + * ensure result is set, and emit the terminal "complete" step. + */ + complete(variables: Record): void { + // Derive result from bottom-right cell if not explicitly set. + if (this.result === null) { + const lastRow = this.matrix[this.matrix.length - 1]; + const bottomRight = lastRow?.[lastRow.length - 1]; + if (bottomRight !== undefined) this.result = bottomRight.value; + } + + this.clearCurrentStates(); + this.clearComputingStates(); + + this.pushStep({ + type: "complete", + description: + this.result !== null + ? `Edit distance computation complete — result: ${this.result}` + : "Edit distance computation complete", + variables, + visualState: this.snapshot(), + }); + } +} diff --git a/src/trackers/frequency-tracker.ts b/src/trackers/frequency-tracker.ts new file mode 100644 index 00000000..e7f36f72 --- /dev/null +++ b/src/trackers/frequency-tracker.ts @@ -0,0 +1,263 @@ +/** + * Frequency tracker — builds execution steps for sliding-window and + * character-frequency algorithms (e.g. find all anagrams, longest substring + * without repeating characters). + * + * Manages primary/secondary character arrays, a live frequency map, and a + * sliding window, emitting typed steps at each logical operation so playback + * can replay the full algorithm execution. + */ +import type { + StringChar, + StringCharState, + FrequencyEntry, + FrequencyEntryState, + FrequencyVisualState, +} from "@/types"; + +import { BaseTracker } from "./base-tracker"; +import type { LineMap } from "./base-tracker"; + +export class FrequencyTracker extends BaseTracker { + private primaryChars: StringChar[]; + private secondaryChars: StringChar[]; + private frequencyMap: FrequencyEntry[]; + private windowStart: number = 0; + private windowEnd: number = -1; + private matchCount: number = 0; + private resultIndices: number[]; + + /** + * @param primary - The main string being analyzed (e.g., the haystack). + * @param secondary - The target/comparison string (e.g., the pattern for + * anagram search). Pass an empty string for single-string + * algorithms like longest non-repeating substring. + * @param lineMap - Per-language line-number mappings for code highlighting. + */ + constructor(primary: string, secondary: string, lineMap: LineMap) { + super(lineMap); + this.primaryChars = primary + .split("") + .map((char) => ({ value: char, state: "default" as StringCharState })); + this.secondaryChars = secondary + .split("") + .map((char) => ({ value: char, state: "default" as StringCharState })); + // Frequency map is built incrementally during algorithm execution. + this.frequencyMap = []; + this.resultIndices = []; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** Return a deep copy of current visual state for snapshotting into a step. */ + private snapshot(): FrequencyVisualState { + return { + kind: "string-frequency", + primaryChars: this.primaryChars.map((char) => ({ ...char })), + secondaryChars: this.secondaryChars.map((char) => ({ ...char })), + frequencyMap: this.frequencyMap.map((entry) => ({ ...entry })), + windowStart: this.windowStart, + windowEnd: this.windowEnd, + matchCount: this.matchCount, + resultIndices: [...this.resultIndices], + }; + } + + /** Set the visual state of a primary character by index. */ + private setPrimaryCharState(charIdx: number, state: StringCharState): void { + const char = this.primaryChars[charIdx]; + if (char) char.state = state; + } + + /** + * Derive the FrequencyEntry state from its current count vs. target count. + * - count === 0 → "default" + * - 0 < count < targetCount → "partial" + * - count === targetCount → "satisfied" + * - count > targetCount → "excess" + */ + private deriveEntryState(count: number, targetCount: number): FrequencyEntryState { + if (count === 0) return "default"; + if (count < targetCount) return "partial"; + if (count === targetCount) return "satisfied"; + return "excess"; + } + + /** + * Find an existing FrequencyEntry for the given character, or create and + * append one with count 0 and targetCount 0. + */ + private findOrCreateEntry(char: string): FrequencyEntry { + const existing = this.frequencyMap.find((entry) => entry.char === char); + if (existing) return existing; + const newEntry: FrequencyEntry = { char, count: 0, targetCount: 0, state: "default" }; + this.frequencyMap.push(newEntry); + return newEntry; + } + + // --------------------------------------------------------------------------- + // Public step-emitting methods + // --------------------------------------------------------------------------- + + /** Emit an initialization step marking the algorithm as started. */ + initialize(variables: Record): void { + this.pushStep({ + type: "initialize", + description: "Initialize frequency map and sliding window", + variables, + visualState: this.snapshot(), + }); + } + + /** + * Increment the frequency count for `char` and update its entry state. + * Emits an "update-frequency" step. + */ + addToFrequency(char: string, variables: Record): void { + const entry = this.findOrCreateEntry(char); + entry.count += 1; + entry.state = this.deriveEntryState(entry.count, entry.targetCount); + this.pushStep({ + type: "update-frequency", + description: `Increment frequency of '${char}' → ${String(entry.count)}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Decrement the frequency count for `char` and update its entry state. + * Does nothing if the character has no entry. Emits an "update-frequency" step. + */ + removeFromFrequency(char: string, variables: Record): void { + const entry = this.frequencyMap.find((e) => e.char === char); + if (entry) { + entry.count -= 1; + entry.state = this.deriveEntryState(entry.count, entry.targetCount); + } + this.pushStep({ + type: "update-frequency", + description: `Decrement frequency of '${char}' → ${String(entry?.count ?? 0)}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Advance the window's right boundary to `windowEnd` and mark the character + * at that position as "current". Emits an "expand-window" step. + */ + expandWindow(windowEnd: number, variables: Record): void { + // Clear "current" state from the previous right boundary if it changed. + if (this.windowEnd >= 0 && this.windowEnd !== windowEnd) { + const prevChar = this.primaryChars[this.windowEnd]; + if (prevChar && prevChar.state === "current") prevChar.state = "default"; + } + this.windowEnd = windowEnd; + this.setPrimaryCharState(windowEnd, "current"); + this.pushStep({ + type: "expand-window", + description: `Expand window right to index ${String(windowEnd)} ('${this.primaryChars[windowEnd]?.value ?? ""}')`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Advance the window's left boundary to `windowStart` and reset the char + * at the old left boundary to "default". Emits a "shrink-window" step. + */ + shrinkWindow(windowStart: number, variables: Record): void { + // Reset the character being expelled from the window. + const expelledChar = this.primaryChars[this.windowStart]; + if (expelledChar) expelledChar.state = "default"; + this.windowStart = windowStart; + this.pushStep({ + type: "shrink-window", + description: `Shrink window left to index ${String(windowStart)}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Record a frequency-map comparison (e.g., checking whether the current + * window is an anagram of the target). Increments the comparisons metric. + * Emits a "compare" step. + */ + checkAnagram(isMatch: boolean, variables: Record): void { + this.metrics = { ...this.metrics, comparisons: this.metrics.comparisons + 1 }; + this.pushStep({ + type: "compare", + description: isMatch + ? "Frequency maps match — window is an anagram" + : "Frequency maps do not match", + variables, + visualState: this.snapshot(), + }); + } + + /** + * Mark the FrequencyEntry for `char` as "satisfied" (count === targetCount). + * Emits a "window-match" step. + */ + markSatisfied(char: string, variables: Record): void { + const entry = this.frequencyMap.find((e) => e.char === char); + if (entry) entry.state = "satisfied"; + this.pushStep({ + type: "window-match", + description: `Character '${char}' frequency satisfied`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Mark the primary character at `charIdx` as "matched" (e.g., confirming a + * non-repeating character in the longest-substring problem). + * Emits a "found" step. + */ + markNonRepeating(charIdx: number, variables: Record): void { + this.setPrimaryCharState(charIdx, "matched"); + this.pushStep({ + type: "found", + description: `Character at index ${String(charIdx)} ('${this.primaryChars[charIdx]?.value ?? ""}') confirmed non-repeating`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Record a result match: append `resultIdx` to `resultIndices`, increment + * `matchCount`, and emit an "add-to-result" step. + */ + addToResult(resultIdx: number, variables: Record): void { + this.resultIndices.push(resultIdx); + this.matchCount += 1; + this.pushStep({ + type: "add-to-result", + description: `Match found — added start index ${String(resultIdx)} to results`, + variables, + visualState: this.snapshot(), + }); + } + + /** Emit a final "complete" step marking the end of algorithm execution. */ + complete(variables: Record): void { + // Clear any remaining "current" highlights from the window boundaries. + for (const char of this.primaryChars) { + if (char.state === "current") char.state = "default"; + } + this.pushStep({ + type: "complete", + description: + this.matchCount > 0 + ? `Algorithm complete — ${String(this.matchCount)} match(es) found` + : "Algorithm complete — no matches found", + variables, + visualState: this.snapshot(), + }); + } +} diff --git a/src/trackers/index.ts b/src/trackers/index.ts index 864f3915..c339547d 100644 --- a/src/trackers/index.ts +++ b/src/trackers/index.ts @@ -15,6 +15,11 @@ export { ExpressionTracker } from "./expression-tracker"; export { QueueTracker } from "./queue-tracker"; export { HashMapTracker } from "./hash-map-tracker"; export { StringTracker } from "./string-tracker"; +export { PalindromeTracker } from "./palindrome-tracker"; +export { FrequencyTracker } from "./frequency-tracker"; +export { TransformTracker } from "./transform-tracker"; +export { TrieTracker } from "./trie-tracker"; +export { DistanceTracker } from "./distance-tracker"; export { MatrixTracker } from "./matrix-tracker"; export { MatrixTransformTracker } from "./matrix-transform-tracker"; export { MatrixSearchTracker } from "./matrix-search-tracker"; diff --git a/src/trackers/palindrome-tracker.ts b/src/trackers/palindrome-tracker.ts new file mode 100644 index 00000000..ef9ce874 --- /dev/null +++ b/src/trackers/palindrome-tracker.ts @@ -0,0 +1,262 @@ +/** + * Palindrome tracker — builds execution steps for palindrome algorithms. + * Supports both two-pointer (is-palindrome check) and expand-around-center + * (longest palindromic substring) approaches. Emits steps for pointer + * movement, character comparison, center expansion, and palindrome marking. + */ +import type { StringChar, StringCharState, PalindromeVisualState } from "@/types"; + +import { BaseTracker } from "./base-tracker"; +import type { LineMap } from "./base-tracker"; + +export class PalindromeTracker extends BaseTracker { + private chars: StringChar[]; + private leftPointer: number; + private rightPointer: number; + private centerIndex: number | null; + private expandRadius: number; + private isPalindrome: boolean | null; + private longestStart: number; + private longestLength: number; + + constructor(text: string, lineMap: LineMap) { + super(lineMap); + this.chars = text + .split("") + .map((char) => ({ value: char, state: "default" as StringCharState })); + this.leftPointer = 0; + this.rightPointer = text.length - 1; + this.centerIndex = null; + this.expandRadius = 0; + this.isPalindrome = null; + this.longestStart = 0; + this.longestLength = 0; + } + + /** Return a deep copy of the current visual state. */ + private snapshot(): PalindromeVisualState { + return { + kind: "string-palindrome", + chars: this.chars.map((char) => ({ ...char })), + leftPointer: this.leftPointer, + rightPointer: this.rightPointer, + centerIndex: this.centerIndex, + expandRadius: this.expandRadius, + isPalindrome: this.isPalindrome, + longestStart: this.longestStart, + longestLength: this.longestLength, + }; + } + + /** Set the state of a single character by index, guarding against out-of-bounds access. */ + private setCharState(charIdx: number, state: StringCharState): void { + const char = this.chars[charIdx]; + if (char) char.state = state; + } + + /** Reset all chars that currently have a given state back to "default". */ + private clearState(state: StringCharState): void { + for (const char of this.chars) { + if (char.state === state) char.state = "default"; + } + } + + /** Emit an initialize step with all chars in their default state. */ + initialize(variables: Record): void { + this.pushStep({ + type: "initialize", + description: "Initialize palindrome check", + variables, + visualState: this.snapshot(), + }); + } + + /** + * Move both pointers to the given positions and mark those chars as "current". + * Clears any previous "current" highlights before applying. + */ + setPointers(leftIdx: number, rightIdx: number, variables: Record): void { + this.clearState("current"); + this.leftPointer = leftIdx; + this.rightPointer = rightIdx; + this.setCharState(leftIdx, "current"); + // Avoid double-marking the same index when pointers converge on one char. + if (rightIdx !== leftIdx) { + this.setCharState(rightIdx, "current"); + } + this.pushStep({ + type: "visit", + description: `Set pointers: left=${leftIdx}, right=${rightIdx}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Highlight the two chars being compared and increment the comparisons metric. + * Both chars are marked "current" to signal active comparison. + */ + compareChars(leftIdx: number, rightIdx: number, variables: Record): void { + this.clearState("current"); + this.leftPointer = leftIdx; + this.rightPointer = rightIdx; + this.setCharState(leftIdx, "current"); + if (rightIdx !== leftIdx) { + this.setCharState(rightIdx, "current"); + } + this.metrics = { ...this.metrics, comparisons: this.metrics.comparisons + 1 }; + this.pushStep({ + type: "compare", + description: `Compare chars[${leftIdx}]='${this.chars[leftIdx]?.value}' and chars[${rightIdx}]='${this.chars[rightIdx]?.value}'`, + variables, + visualState: this.snapshot(), + }); + } + + /** Mark both chars at the given positions as "matching" (characters are equal). */ + charsMatch(leftIdx: number, rightIdx: number, variables: Record): void { + this.setCharState(leftIdx, "matching"); + if (rightIdx !== leftIdx) { + this.setCharState(rightIdx, "matching"); + } + this.pushStep({ + type: "char-match", + description: `Match: chars[${leftIdx}]='${this.chars[leftIdx]?.value}' == chars[${rightIdx}]='${this.chars[rightIdx]?.value}'`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Mark both chars as "mismatched" and set isPalindrome to false. + * Used when the two-pointer check finds a non-matching pair. + */ + charsMismatch(leftIdx: number, rightIdx: number, variables: Record): void { + this.setCharState(leftIdx, "mismatched"); + if (rightIdx !== leftIdx) { + this.setCharState(rightIdx, "mismatched"); + } + this.isPalindrome = false; + this.pushStep({ + type: "char-mismatch", + description: `Mismatch: chars[${leftIdx}]='${this.chars[leftIdx]?.value}' != chars[${rightIdx}]='${this.chars[rightIdx]?.value}'`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Set the current center and expansion radius for expand-around-center algorithms. + * Marks the center character(s) as "current". For even-length palindromes the + * center sits between two characters; both are marked when radius is 0. + */ + expandCenter(centerIdx: number, radius: number, variables: Record): void { + this.clearState("current"); + this.centerIndex = centerIdx; + this.expandRadius = radius; + + // Mark the center position(s) as current. + this.setCharState(centerIdx, "current"); + + // When expanding, also highlight the two chars at the current boundary. + if (radius > 0) { + const boundaryLeft = centerIdx - radius; + const boundaryRight = centerIdx + radius; + if (boundaryLeft >= 0) this.setCharState(boundaryLeft, "current"); + if (boundaryRight < this.chars.length) this.setCharState(boundaryRight, "current"); + } + + this.pushStep({ + type: "expand-center", + description: `Expand from center=${centerIdx} with radius=${radius}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Mark a contiguous range of chars as "matched" and set isPalindrome to true. + * Used when a palindrome is confirmed (start..start+length-1). + */ + markPalindrome(start: number, length: number, variables: Record): void { + this.isPalindrome = true; + for (let charIdx = start; charIdx < start + length; charIdx++) { + this.setCharState(charIdx, "matched"); + } + this.pushStep({ + type: "check-palindrome", + description: `Palindrome confirmed: start=${start}, length=${length}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Update the tracked longest palindrome when a longer one is found. + * Does not alter char states — call markPalindrome separately to highlight. + */ + updateLongest(start: number, length: number, variables: Record): void { + this.longestStart = start; + this.longestLength = length; + this.pushStep({ + type: "check-palindrome", + description: `New longest palindrome: start=${start}, length=${length}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Advance a non-alphanumeric pointer — marks the skipped char as "default" + * and updates the appropriate pointer position. + */ + skipNonAlphanumeric( + pointerIdx: number, + direction: "left" | "right", + variables: Record, + ): void { + this.setCharState(pointerIdx, "default"); + if (direction === "left") { + this.leftPointer = pointerIdx; + } else { + this.rightPointer = pointerIdx; + } + this.pushStep({ + type: "skip-char", + description: `Skip non-alphanumeric char at index ${pointerIdx} (${direction} pointer)`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Finalize the tracker state. Clears any residual "current" highlights and + * marks the final palindrome range (longestStart..longestStart+longestLength-1) + * as "matched" if one was found during the run. + */ + complete(variables: Record): void { + this.clearState("current"); + + if (this.longestLength > 0) { + for ( + let charIdx = this.longestStart; + charIdx < this.longestStart + this.longestLength; + charIdx++ + ) { + this.setCharState(charIdx, "matched"); + } + } + + this.pushStep({ + type: "complete", + description: + this.isPalindrome === false + ? "String is not a palindrome" + : this.longestLength > 0 + ? `Longest palindrome: start=${this.longestStart}, length=${this.longestLength}` + : "Palindrome check complete", + variables, + visualState: this.snapshot(), + }); + } +} diff --git a/src/trackers/transform-tracker.ts b/src/trackers/transform-tracker.ts new file mode 100644 index 00000000..5073a2ae --- /dev/null +++ b/src/trackers/transform-tracker.ts @@ -0,0 +1,207 @@ +/** + * Transform tracker — builds execution steps for string transformation algorithms. + * Manages input characters, output characters, read/write pointers, and phase + * transitions, emitting steps for each stage of a transformation operation. + */ +import type { StringChar, StringCharState, TransformVisualState } from "@/types"; + +import { BaseTracker } from "./base-tracker"; +import type { LineMap } from "./base-tracker"; + +export class TransformTracker extends BaseTracker { + private inputChars: StringChar[]; + private outputChars: StringChar[]; + private readPointer: number; + private writePointer: number; + private phase: string; + private auxiliaryData: string | null; + + constructor(input: string, lineMap: LineMap) { + super(lineMap); + this.inputChars = input + .split("") + .map((char) => ({ value: char, state: "default" as StringCharState })); + this.outputChars = []; + this.readPointer = -1; + this.writePointer = -1; + this.phase = "initialize"; + this.auxiliaryData = null; + } + + /** Deep snapshot of the current visual state for step recording. */ + private snapshot(): TransformVisualState { + return { + kind: "string-transform", + inputChars: this.inputChars.map((char) => ({ ...char })), + outputChars: this.outputChars.map((char) => ({ ...char })), + readPointer: this.readPointer, + writePointer: this.writePointer, + phase: this.phase, + auxiliaryData: this.auxiliaryData, + }; + } + + /** Set a single input character's state by index, ignoring out-of-bounds. */ + private setInputState(charIdx: number, state: StringCharState): void { + const char = this.inputChars[charIdx]; + if (char) char.state = state; + } + + /** Clear all "current" highlights on input characters. */ + private clearInputCurrentStates(): void { + for (const char of this.inputChars) { + if (char.state === "current") char.state = "default"; + } + } + + /** Emit the initial setup step. */ + initialize(variables: Record): void { + this.pushStep({ + type: "initialize", + description: "Initialize transformation — prepare input and output buffers", + variables, + visualState: this.snapshot(), + }); + } + + /** + * Move the read pointer to charIdx and highlight that character as "current". + * Clears any previous "current" highlight on input chars. + */ + readChar(charIdx: number, variables: Record): void { + this.clearInputCurrentStates(); + this.readPointer = charIdx; + this.setInputState(charIdx, "current"); + this.pushStep({ + type: "read-char", + description: `Read input[${charIdx}] = '${this.inputChars[charIdx]?.value ?? ""}'`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Append a single character to the output buffer with state "matching". + * Advances writePointer to the new last index and increments swaps (transformations). + */ + writeChar(char: string, variables: Record): void { + this.outputChars.push({ value: char, state: "matching" }); + this.writePointer = this.outputChars.length - 1; + this.metrics = { ...this.metrics, swaps: this.metrics.swaps + 1 }; + this.pushStep({ + type: "write-char", + description: `Write '${char}' to output[${this.writePointer}]`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Swap two input characters by index, marking both as "matching". + * Increments swaps metric. + */ + swapChars(leftIdx: number, rightIdx: number, variables: Record): void { + const leftChar = this.inputChars[leftIdx]; + const rightChar = this.inputChars[rightIdx]; + if (leftChar && rightChar) { + const tempValue = leftChar.value; + leftChar.value = rightChar.value; + rightChar.value = tempValue; + leftChar.state = "matching"; + rightChar.state = "matching"; + } + this.metrics = { ...this.metrics, swaps: this.metrics.swaps + 1 }; + this.pushStep({ + type: "swap-pointers", + description: `Swap input[${leftIdx}] and input[${rightIdx}]`, + variables, + visualState: this.snapshot(), + }); + } + + /** Update both read and write pointers without modifying character states. */ + advancePointers(readIdx: number, writeIdx: number, variables: Record): void { + this.readPointer = readIdx; + this.writePointer = writeIdx; + this.pushStep({ + type: "visit", + description: `Advance pointers — read: ${readIdx}, write: ${writeIdx}`, + variables, + visualState: this.snapshot(), + }); + } + + /** Transition to a new named phase and emit a step. */ + setPhase(phaseName: string, variables: Record): void { + this.phase = phaseName; + this.pushStep({ + type: "visit", + description: `Phase: ${phaseName}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Append multiple characters to the output buffer at once, marking each as "matching". + * Updates writePointer to the new last index. + */ + appendOutput(chars: string, variables: Record): void { + for (const char of chars.split("")) { + this.outputChars.push({ value: char, state: "matching" }); + } + this.writePointer = this.outputChars.length - 1; + this.pushStep({ + type: "write-char", + description: `Append '${chars}' to output`, + variables, + visualState: this.snapshot(), + }); + } + + /** Store auxiliary display data (e.g. frequency map, intermediate result) and emit a step. */ + setAuxiliaryData(data: string, variables: Record): void { + this.auxiliaryData = data; + this.pushStep({ + type: "visit", + description: `Auxiliary data: ${data}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Mark a contiguous range of input characters as "matched" to indicate + * a successfully converted segment (inclusive on both ends). + */ + markConverted(startIdx: number, endIdx: number, variables: Record): void { + for (let charIdx = startIdx; charIdx <= endIdx; charIdx++) { + this.setInputState(charIdx, "matched"); + } + this.pushStep({ + type: "found", + description: `Converted input[${startIdx}..${endIdx}]`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Finalize the transformation — clear all "current" states on input, + * mark every output character as "matched". + */ + complete(variables: Record): void { + for (const char of this.inputChars) { + if (char.state === "current") char.state = "default"; + } + for (const char of this.outputChars) { + char.state = "matched"; + } + this.pushStep({ + type: "complete", + description: "Transformation complete", + variables, + visualState: this.snapshot(), + }); + } +} diff --git a/src/trackers/trie-tracker.ts b/src/trackers/trie-tracker.ts new file mode 100644 index 00000000..893db41f --- /dev/null +++ b/src/trackers/trie-tracker.ts @@ -0,0 +1,308 @@ +/** + * Trie tracker — builds execution steps for trie-based algorithms (insert, search, + * autocomplete, Aho-Corasick failure links). + * + * Manages nodes, edges, the active traversal path, a searchWord character list, + * and autocomplete suggestions, emitting steps for each structural operation. + */ +import type { + StringChar, + StringCharState, + TrieNode, + TrieNodeState, + TrieEdge, + TrieEdgeState, + TrieVisualState, +} from "@/types"; + +import { BaseTracker } from "./base-tracker"; +import type { LineMap } from "./base-tracker"; + +export class TrieTracker extends BaseTracker { + private nodes: TrieNode[]; + private edges: TrieEdge[]; + private currentPath: number[]; + private searchWord: StringChar[]; + private highlightedNodes: number[]; + private matchResult: boolean | null; + private suggestions: string[]; + + /** Counter for assigning unique IDs to newly created nodes. */ + private nextNodeId: number = 1; + + constructor(lineMap: LineMap) { + super(lineMap); + // Root node is always id=0, represents the empty prefix + this.nodes = [{ id: 0, char: "", isEnd: false, state: "default" }]; + this.edges = []; + this.currentPath = []; + this.searchWord = []; + this.highlightedNodes = []; + this.matchResult = null; + this.suggestions = []; + } + + // --------------------------------------------------------------------------- + // Snapshot + // --------------------------------------------------------------------------- + + /** Return a deep copy of the current visual state for step recording. */ + private snapshot(): TrieVisualState { + return { + kind: "string-trie", + nodes: this.nodes.map((node) => ({ ...node })), + edges: this.edges.map((edge) => ({ ...edge })), + currentPath: [...this.currentPath], + searchWord: this.searchWord.map((char) => ({ ...char })), + highlightedNodes: [...this.highlightedNodes], + matchResult: this.matchResult, + suggestions: [...this.suggestions], + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** Set the visual state of a node by its id. No-op if the node is not found. */ + private setNodeState(nodeId: number, state: TrieNodeState): void { + const node = this.nodes.find((candidate) => candidate.id === nodeId); + if (node) node.state = state; + } + + /** Set the visual state of an edge identified by its from/to pair. No-op if not found. */ + private setEdgeState(fromId: number, toId: number, state: TrieEdgeState): void { + const edge = this.edges.find((candidate) => candidate.from === fromId && candidate.to === toId); + if (edge) edge.state = state; + } + + /** + * Reset all nodes and edges that carry a "current" or "inserted" state back + * to "default". Used at completion to leave the trie in a clean resting state. + */ + private clearCurrentStates(): void { + for (const node of this.nodes) { + if (node.state === "current" || node.state === "inserted") { + node.state = "default"; + } + } + for (const edge of this.edges) { + if (edge.state === "highlighted") { + edge.state = "default"; + } + } + } + + // --------------------------------------------------------------------------- + // Public step-emitting methods + // --------------------------------------------------------------------------- + + /** Emit the initial step before any trie operations begin. */ + initialize(variables: Record): void { + this.pushStep({ + type: "initialize", + description: "Initialize trie with empty root node", + variables, + visualState: this.snapshot(), + }); + } + + /** + * Set the searchWord character list from a plain string and emit a step. + * Each character starts with "default" state, ready for per-char highlighting. + */ + setSearchWord(word: string, variables: Record): void { + this.searchWord = word + .split("") + .map((char) => ({ value: char, state: "default" as StringCharState })); + this.pushStep({ + type: "visit", + description: `Set search word: "${word}"`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Create a new trie node as a child of parentId for the given character. + * Adds the corresponding edge, marks both as "inserted"/"highlighted", and + * returns the new node's id. + */ + createNode(parentId: number, char: string, variables: Record): number { + const newNodeId = this.nextNodeId; + this.nextNodeId += 1; + + this.nodes.push({ id: newNodeId, char, isEnd: false, state: "inserted" }); + this.edges.push({ from: parentId, to: newNodeId, char, state: "highlighted" }); + + this.pushStep({ + type: "insert-trie", + description: `Create node '${char}' (id=${newNodeId}) as child of node ${parentId}`, + variables, + visualState: this.snapshot(), + }); + + return newNodeId; + } + + /** + * Move the active traversal cursor along an existing edge from fromId to toId. + * Marks the destination node as "current", adds it to currentPath, highlights + * the edge, and marks the corresponding searchWord character as "current". + */ + traverseEdge(fromId: number, toId: number, variables: Record): void { + this.setNodeState(toId, "current"); + this.currentPath.push(toId); + this.setEdgeState(fromId, toId, "highlighted"); + + // Highlight the searchWord character that corresponds to this traversal depth. + // currentPath length after push is the 1-based depth, so index = depth - 1. + const charIdx = this.currentPath.length - 1; + const searchChar = this.searchWord[charIdx]; + if (searchChar) searchChar.state = "current"; + + this.pushStep({ + type: "traverse-trie", + description: `Traverse edge ${fromId} → ${toId} ('${this.nodes.find((node) => node.id === toId)?.char ?? ""}')`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Mark an existing node as "inserted" during an insertion pass. + * Increments the swaps metric to track structural modifications. + */ + insertChar(nodeId: number, char: string, variables: Record): void { + this.setNodeState(nodeId, "inserted"); + this.metrics = { ...this.metrics, swaps: this.metrics.swaps + 1 }; + this.pushStep({ + type: "insert-trie", + description: `Insert character '${char}' at node ${nodeId}`, + variables, + visualState: this.snapshot(), + }); + } + + /** Mark a node as the end of a valid word (sets isEnd and transitions to "matched"). */ + markEndOfWord(nodeId: number, variables: Record): void { + const node = this.nodes.find((candidate) => candidate.id === nodeId); + if (node) { + node.isEnd = true; + node.state = "matched"; + } + this.pushStep({ + type: "mark-end-word", + description: `Mark node ${nodeId} as end of word`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Check whether a character at the given searchWord index exists in the trie + * at nodeId. Updates node and character highlight states, increments comparisons. + */ + searchChar( + nodeId: number, + charIdx: number, + found: boolean, + variables: Record, + ): void { + this.metrics = { ...this.metrics, comparisons: this.metrics.comparisons + 1 }; + + const searchChar = this.searchWord[charIdx]; + if (found) { + this.setNodeState(nodeId, "current"); + if (searchChar) searchChar.state = "matching"; + } else { + this.matchResult = false; + if (searchChar) searchChar.state = "mismatched"; + } + + this.pushStep({ + type: "traverse-trie", + description: found + ? `Found character at node ${nodeId} (index ${charIdx})` + : `Character not found at index ${charIdx} — search fails`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Record a successful full-word match. Sets matchResult to true, marks all + * nodes in currentPath as "matched", and marks all searchWord chars as "matched". + */ + matchFound(variables: Record): void { + this.matchResult = true; + + for (const nodeId of this.currentPath) { + this.setNodeState(nodeId, "matched"); + } + for (const char of this.searchWord) { + char.state = "matched"; + } + + this.pushStep({ + type: "found", + description: "Word found in trie", + variables, + visualState: this.snapshot(), + }); + } + + /** Append a completed word to the autocomplete suggestions list. */ + addSuggestion(word: string, variables: Record): void { + this.suggestions.push(word); + this.pushStep({ + type: "add-to-result", + description: `Add suggestion: "${word}"`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Record a failure-link (Aho-Corasick) edge between two nodes. + * Marks the edge as "traversed" and adds both endpoints to highlightedNodes. + */ + buildFailureLinks(fromId: number, toId: number, variables: Record): void { + this.setEdgeState(fromId, toId, "traversed"); + + if (!this.highlightedNodes.includes(fromId)) { + this.highlightedNodes.push(fromId); + } + if (!this.highlightedNodes.includes(toId)) { + this.highlightedNodes.push(toId); + } + + this.pushStep({ + type: "build-failure", + description: `Build failure link: ${fromId} → ${toId}`, + variables, + visualState: this.snapshot(), + }); + } + + /** + * Finalize the algorithm run. Clears transient "current" states, resets + * currentPath, and emits the terminal step. + */ + complete(variables: Record): void { + this.clearCurrentStates(); + this.currentPath = []; + + this.pushStep({ + type: "complete", + description: + this.matchResult === true + ? "Search complete — word found" + : this.matchResult === false + ? "Search complete — word not found" + : "Trie operation complete", + variables, + visualState: this.snapshot(), + }); + } +} diff --git a/src/types/execution.ts b/src/types/execution.ts index eb629bfd..f4d88270 100644 --- a/src/types/execution.ts +++ b/src/types/execution.ts @@ -132,6 +132,22 @@ export type StepType = | "dequeue-rear" | "transfer" | "resolve" + | "expand-center" + | "check-palindrome" + | "update-frequency" + | "window-match" + | "read-char" + | "write-char" + | "swap-pointers" + | "insert-trie" + | "traverse-trie" + | "mark-end-word" + | "compute-distance" + | "trace-edit-path" + | "build-suffix" + | "hash-compute" + | "hash-match" + | "skip-char" | "complete"; /** Maps a language to the source lines highlighted for this step. */ @@ -179,7 +195,12 @@ export type VisualState = | HashMapVisualState | StringVisualState | MatrixVisualState - | SetVisualState; + | SetVisualState + | PalindromeVisualState + | FrequencyVisualState + | TransformVisualState + | TrieVisualState + | DistanceVisualState; /* -------------------------------------------------------------------------- */ /* Array Structure */ @@ -628,6 +649,165 @@ export interface StringVisualState { matchFound: boolean | null; } +/* -------------------------------------------------------------------------- */ +/* Palindrome Structure */ +/* -------------------------------------------------------------------------- */ + +/** Visual state for palindrome checking algorithms with two-pointer or center expansion. */ +export interface PalindromeVisualState { + kind: "string-palindrome"; + /** Characters of the string being analyzed */ + chars: StringChar[]; + /** Left pointer position */ + leftPointer: number; + /** Right pointer position */ + rightPointer: number; + /** Center index for expand-around-center approach, null if not applicable */ + centerIndex: number | null; + /** Current expansion radius from center */ + expandRadius: number; + /** null = checking, true = is palindrome, false = not palindrome */ + isPalindrome: boolean | null; + /** Start index of the longest palindrome found so far */ + longestStart: number; + /** Length of the longest palindrome found so far */ + longestLength: number; +} + +/* -------------------------------------------------------------------------- */ +/* Character Frequency Structure */ +/* -------------------------------------------------------------------------- */ + +export type FrequencyEntryState = "default" | "partial" | "satisfied" | "excess"; + +/** A single entry in the character frequency map visualization. */ +export interface FrequencyEntry { + char: string; + count: number; + targetCount: number; + state: FrequencyEntryState; +} + +/** Visual state for algorithms that compare character frequencies or use sliding windows. */ +export interface FrequencyVisualState { + kind: "string-frequency"; + /** Primary string characters */ + primaryChars: StringChar[]; + /** Secondary string characters (empty array if single-string algorithm) */ + secondaryChars: StringChar[]; + /** Character frequency map entries */ + frequencyMap: FrequencyEntry[]; + /** Sliding window start index */ + windowStart: number; + /** Sliding window end index */ + windowEnd: number; + /** Number of matches found so far */ + matchCount: number; + /** Indices where matches were found */ + resultIndices: number[]; +} + +/* -------------------------------------------------------------------------- */ +/* String Transform Structure */ +/* -------------------------------------------------------------------------- */ + +/** Visual state for string transformation algorithms (reverse, compress, convert). */ +export interface TransformVisualState { + kind: "string-transform"; + /** Input string characters */ + inputChars: StringChar[]; + /** Output string characters built during transformation */ + outputChars: StringChar[]; + /** Read pointer position in the input */ + readPointer: number; + /** Write pointer position in the output */ + writePointer: number; + /** Current transformation phase label */ + phase: string; + /** Auxiliary data for display (e.g., running count, Roman numeral value) */ + auxiliaryData: string | null; +} + +/* -------------------------------------------------------------------------- */ +/* Trie Structure */ +/* -------------------------------------------------------------------------- */ + +export type TrieNodeState = "default" | "current" | "matched" | "path" | "inserted"; +export type TrieEdgeState = "default" | "highlighted" | "traversed"; + +/** A node in the trie tree visualization. */ +export interface TrieNode { + id: number; + char: string; + isEnd: boolean; + state: TrieNodeState; +} + +/** An edge in the trie tree visualization connecting parent to child. */ +export interface TrieEdge { + from: number; + to: number; + char: string; + state: TrieEdgeState; +} + +/** Visual state for trie-based algorithms with tree structure and path highlighting. */ +export interface TrieVisualState { + kind: "string-trie"; + /** All trie nodes */ + nodes: TrieNode[]; + /** All trie edges */ + edges: TrieEdge[]; + /** Node IDs forming the currently active path */ + currentPath: number[]; + /** Characters of the word being searched/inserted */ + searchWord: StringChar[]; + /** Node IDs that should be highlighted */ + highlightedNodes: number[]; + /** null = searching, true = found, false = not found */ + matchResult: boolean | null; + /** Auto-complete suggestions collected so far */ + suggestions: string[]; +} + +/* -------------------------------------------------------------------------- */ +/* Edit Distance Structure */ +/* -------------------------------------------------------------------------- */ + +export type DistanceCellState = "default" | "computing" | "computed" | "path" | "current"; + +/** A cell in the edit distance DP matrix. */ +export interface DistanceCell { + value: number; + state: DistanceCellState; +} + +/** Describes a single edit operation in the optimal edit path. */ +export interface EditOperation { + type: "insert" | "delete" | "replace" | "match"; + sourceIdx: number; + targetIdx: number; +} + +/** Visual state for edit distance and string similarity algorithms using DP matrices. */ +export interface DistanceVisualState { + kind: "string-distance"; + /** Source string characters */ + sourceChars: StringChar[]; + /** Target string characters */ + targetChars: StringChar[]; + /** DP matrix (rows = source+1, cols = target+1) */ + matrix: DistanceCell[][]; + /** Current row being computed */ + currentRow: number; + /** Current column being computed */ + currentCol: number; + /** Edit operations in the optimal path */ + operations: EditOperation[]; + /** Final distance/similarity result, null while computing */ + result: number | null; +} + /* -------------------------------------------------------------------------- */ /* Matrix Structure */ /* -------------------------------------------------------------------------- */ diff --git a/src/types/fn-import.d.ts b/src/types/fn-import.d.ts index ac80ac72..26ca99fd 100644 --- a/src/types/fn-import.d.ts +++ b/src/types/fn-import.d.ts @@ -302,7 +302,41 @@ declare module "*.ts?fn" { export const happyNumber: (...args: any[]) => any; export const jewelsAndStones: (...args: any[]) => any; // Strings + export const levenshteinDistance: (...args: any[]) => any; + export const longestCommonSubsequence: (...args: any[]) => any; + export const jaroWinklerSimilarity: (...args: any[]) => any; + export const integerToRoman: (...args: any[]) => any; export const kmpSearch: (...args: any[]) => any; + export const autoCompleteTrie: (...args: any[]) => any; + export const trieInsertSearch: (...args: any[]) => any; + export const ahoCorasickSearch: (...args: any[]) => any; + export const triePrefixCount: (...args: any[]) => any; + export const longestWordInTrie: (...args: any[]) => any; + export const validAnagram: (...args: any[]) => any; + export const characterFrequencySort: (...args: any[]) => any; + export const minimumWindowSubstring: (...args: any[]) => any; + export const firstNonRepeatingCharacter: (...args: any[]) => any; + export const palindromeCheck: (...args: any[]) => any; + export const longestPalindromicSubstring: (...args: any[]) => any; + export const validPalindrome: (...args: any[]) => any; + export const hammingDistance: (...args: any[]) => any; + export const naivePatternSearch: (...args: any[]) => any; + export const rabinKarpSearch: (...args: any[]) => any; + export const boyerMooreSearch: (...args: any[]) => any; + export const zAlgorithm: (...args: any[]) => any; + export const longestSubstringWithoutRepeating: (...args: any[]) => any; + export const reverseString: (...args: any[]) => any; + export const longestCommonPrefix: (...args: any[]) => any; + export const stringRotationCheck: (...args: any[]) => any; + export const stringToInteger: (...args: any[]) => any; + export const runLengthDecoding: (...args: any[]) => any; + export const reverseWords: (...args: any[]) => any; + export const stringCompression: (...args: any[]) => any; + export const longestCommonSubstring: (...args: any[]) => any; + export const longestRepeatedSubstring: (...args: any[]) => any; + export const suffixArrayConstruction: (...args: any[]) => any; + export const wildcardMatching: (...args: any[]) => any; + export const regexMatching: (...args: any[]) => any; // Matrices export const pascalsTriangle: (...args: any[]) => any; export const validSudoku: (...args: any[]) => any; diff --git a/src/types/index.ts b/src/types/index.ts index dc6cf44b..93f9d2a0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,6 +67,20 @@ export type { FailureTableEntry, FailureTableEntryState, StringVisualState, + PalindromeVisualState, + FrequencyEntryState, + FrequencyEntry, + FrequencyVisualState, + TransformVisualState, + TrieNodeState, + TrieEdgeState, + TrieNode, + TrieEdge, + TrieVisualState, + DistanceCellState, + DistanceCell, + EditOperation, + DistanceVisualState, MatrixCell, MatrixCellState, MatrixBoundaries,