|
| 1 | +""" |
| 2 | +Suffix Array construction and Kasai's LCP (Longest Common Prefix) algorithm. |
| 3 | +
|
| 4 | +A suffix array of a string is a sorted array of the starting indices of all |
| 5 | +its suffixes. It allows many string problems (substring search, longest |
| 6 | +repeated substring, etc.) to be solved efficiently. |
| 7 | +
|
| 8 | +The construction below uses the prefix-doubling technique: suffixes are |
| 9 | +sorted by their first character, then by their first 2, 4, 8, ... characters |
| 10 | +until the order is fully determined. Each round doubles the known prefix |
| 11 | +length, so O(log n) sorting rounds are needed. |
| 12 | +
|
| 13 | +Time Complexity: O(n log^2 n) where n is the length of the string |
| 14 | + (O(log n) rounds, each sorting n items) |
| 15 | +Space Complexity: O(n) |
| 16 | +
|
| 17 | +References: |
| 18 | +- https://en.wikipedia.org/wiki/Suffix_array |
| 19 | +- https://cp-algorithms.com/string/suffix-array.html |
| 20 | +- https://en.wikipedia.org/wiki/LCP_array |
| 21 | +""" |
| 22 | + |
| 23 | + |
| 24 | +def build_suffix_array(text: str) -> list[int]: |
| 25 | + """ |
| 26 | + Build the suffix array of `text` using the prefix-doubling algorithm. |
| 27 | +
|
| 28 | + Returns a list `suffix_array` of length len(text) such that |
| 29 | + `text[suffix_array[i]:]` is the i-th lexicographically smallest suffix |
| 30 | + of `text`. |
| 31 | +
|
| 32 | + >>> build_suffix_array("banana") |
| 33 | + [5, 3, 1, 0, 4, 2] |
| 34 | + >>> build_suffix_array("abracadabra") |
| 35 | + [10, 7, 0, 3, 5, 8, 1, 4, 6, 9, 2] |
| 36 | + >>> build_suffix_array("aaaa") |
| 37 | + [3, 2, 1, 0] |
| 38 | + >>> build_suffix_array("a") |
| 39 | + [0] |
| 40 | + >>> build_suffix_array("") |
| 41 | + Traceback (most recent call last): |
| 42 | + ... |
| 43 | + ValueError: Input string must not be empty. |
| 44 | + >>> build_suffix_array(123) |
| 45 | + Traceback (most recent call last): |
| 46 | + ... |
| 47 | + TypeError: Input must be a string. |
| 48 | + """ |
| 49 | + if not isinstance(text, str): |
| 50 | + raise TypeError("Input must be a string.") |
| 51 | + if not text: |
| 52 | + raise ValueError("Input string must not be empty.") |
| 53 | + |
| 54 | + length = len(text) |
| 55 | + suffix_array = list(range(length)) |
| 56 | + # rank[i] is the equivalence class of the prefix starting at index i. |
| 57 | + rank = [ord(character) for character in text] |
| 58 | + |
| 59 | + step = 1 |
| 60 | + while step < length: |
| 61 | + # Sort suffixes by the pair (rank[i], rank[i + step]), i.e. by their |
| 62 | + # first `2 * step` characters. A missing second half ranks lowest. |
| 63 | + suffix_array.sort( |
| 64 | + key=lambda index: ( |
| 65 | + rank[index], |
| 66 | + rank[index + step] if index + step < length else -1, |
| 67 | + ) |
| 68 | + ) |
| 69 | + |
| 70 | + # Re-rank: suffixes that compared equal share the same class. |
| 71 | + new_rank = [0] * length |
| 72 | + for position in range(1, length): |
| 73 | + previous = suffix_array[position - 1] |
| 74 | + current = suffix_array[position] |
| 75 | + previous_key = ( |
| 76 | + rank[previous], |
| 77 | + rank[previous + step] if previous + step < length else -1, |
| 78 | + ) |
| 79 | + current_key = ( |
| 80 | + rank[current], |
| 81 | + rank[current + step] if current + step < length else -1, |
| 82 | + ) |
| 83 | + new_rank[current] = new_rank[previous] + (current_key > previous_key) |
| 84 | + |
| 85 | + rank = new_rank |
| 86 | + # Every suffix has a unique rank, so the order is fully determined. |
| 87 | + if rank[suffix_array[-1]] == length - 1: |
| 88 | + break |
| 89 | + step *= 2 |
| 90 | + |
| 91 | + return suffix_array |
| 92 | + |
| 93 | + |
| 94 | +def longest_common_prefix_array(text: str, suffix_array: list[int]) -> list[int]: |
| 95 | + """ |
| 96 | + Build the LCP array of `text` for a given `suffix_array` using Kasai's |
| 97 | + algorithm, which runs in O(n) time. |
| 98 | +
|
| 99 | + Returns a list `lcp` of length len(text) where `lcp[i]` is the length of |
| 100 | + the longest common prefix between the suffixes starting at |
| 101 | + `suffix_array[i - 1]` and `suffix_array[i]`. `lcp[0]` is always 0. |
| 102 | +
|
| 103 | + >>> longest_common_prefix_array("banana", [5, 3, 1, 0, 4, 2]) |
| 104 | + [0, 1, 3, 0, 0, 2] |
| 105 | + >>> longest_common_prefix_array("banana", build_suffix_array("banana")) |
| 106 | + [0, 1, 3, 0, 0, 2] |
| 107 | + >>> longest_common_prefix_array("aaaa", [3, 2, 1, 0]) |
| 108 | + [0, 1, 2, 3] |
| 109 | + >>> longest_common_prefix_array("abcde", [0, 1, 2, 3, 4]) |
| 110 | + [0, 0, 0, 0, 0] |
| 111 | + >>> longest_common_prefix_array("", []) |
| 112 | + Traceback (most recent call last): |
| 113 | + ... |
| 114 | + ValueError: Input string must not be empty. |
| 115 | + >>> longest_common_prefix_array("abc", [0, 1]) |
| 116 | + Traceback (most recent call last): |
| 117 | + ... |
| 118 | + ValueError: suffix_array must be a permutation of range(len(text)). |
| 119 | + """ |
| 120 | + if not isinstance(text, str): |
| 121 | + raise TypeError("Input must be a string.") |
| 122 | + length = len(text) |
| 123 | + if length == 0: |
| 124 | + raise ValueError("Input string must not be empty.") |
| 125 | + if sorted(suffix_array) != list(range(length)): |
| 126 | + raise ValueError("suffix_array must be a permutation of range(len(text)).") |
| 127 | + |
| 128 | + # rank[start] is the position in suffix_array of the suffix at `start`. |
| 129 | + rank = [0] * length |
| 130 | + for position, start in enumerate(suffix_array): |
| 131 | + rank[start] = position |
| 132 | + |
| 133 | + lcp = [0] * length |
| 134 | + common = 0 |
| 135 | + for start in range(length): |
| 136 | + position = rank[start] |
| 137 | + if position == 0: |
| 138 | + # Lexicographically smallest suffix has no predecessor. |
| 139 | + common = 0 |
| 140 | + continue |
| 141 | + neighbor = suffix_array[position - 1] |
| 142 | + while ( |
| 143 | + start + common < length |
| 144 | + and neighbor + common < length |
| 145 | + and text[start + common] == text[neighbor + common] |
| 146 | + ): |
| 147 | + common += 1 |
| 148 | + lcp[position] = common |
| 149 | + # The next suffix shares at least `common - 1` characters with its |
| 150 | + # own predecessor, so the comparison can resume from there. |
| 151 | + common = max(common - 1, 0) |
| 152 | + |
| 153 | + return lcp |
| 154 | + |
| 155 | + |
| 156 | +if __name__ == "__main__": |
| 157 | + import doctest |
| 158 | + |
| 159 | + doctest.testmod() |
0 commit comments