Core patterns · Lesson 25 of 62 · about 55 min
String algorithms
Build strings efficiently, find palindromes by expanding around centers, and search text in linear time with KMP's failure table and Rabin-Karp's rolling hash.
On this page · 9 sections
Building strings efficiently#
Go strings are immutable byte sequences (lesson 2). s += x therefore creates a brand-new string and copies everything so far. In a loop that is O(n²). strings.Builder keeps a growing byte buffer, like append, and produces the final string once:
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
b.Grow(64) // optional: reserve space when you know roughly how much you need
for i := range 5 {
fmt.Fprintf(&b, "%d,", i) // Builder is an io.Writer
}
b.WriteString("done")
fmt.Println(b.String(), b.Len())
// Common one-liners that avoid manual loops.
words := strings.Fields(" the quick brown fox ")
fmt.Printf("%q\n", words)
fmt.Println(strings.Join(words, "-"))
fmt.Println(strings.Repeat("ab", 3), strings.ToUpper("go"), strings.TrimSpace(" x "))
fmt.Println(strings.Count("cheese", "e"), strings.Index("chicken", "ken"), strings.Contains("seafood", "foo"))
fmt.Println(strings.Replace("oink oink oink", "k", "ky", 2), strings.Split("a,b,c", ","))
// Reverse by runes, so multi-byte characters survive.
r := []rune("héllo, 世界")
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
fmt.Println(string(r))
}0,1,2,3,4,done 14
["the" "quick" "brown" "fox"]
the-quick-brown-fox
ababab GO x
3 4 true
oinky oinky oink [a b c]
界世 ,olléhPalindromes: expand around centers#
A palindrome mirrors around its center. The center is either a character (odd length, "racecar") or the gap between two characters (even length, "abba"). For each of the 2n - 1 centers, expand outward while the two ends match. Each expansion is O(n), so the total is O(n²) with O(1) memory, which is simple and fast enough for n up to a few thousand.
package main
import "fmt"
// expand grows from s[l..r] outward while the ends match and returns the widest palindrome bounds.
func expand(s string, l, r int) (int, int) {
for l >= 0 r < len(s) s[l] == s[r] {
l--
r++
}
return l + 1, r // s[l+1:r] is the palindrome
}
func longestPalindrome(s string) string {
best := s[:0]
for i := range len(s) {
for _, gap := range []int{0, 1} { // odd center at i, even center between i and i+1
l, r := expand(s, i, i+gap)
if r-l > len(best) {
best = s[l:r]
}
}
}
return best
}
func countPalindromes(s string) int {
count := 0
for c := 0; c < 2*len(s)-1; c++ {
l, r := c/2, c/2+c%2
for l >= 0 r < len(s) s[l] == s[r] {
count++ // every successful step is one more palindrome
l--
r++
}
}
return count
}
func main() {
for _, s := range []string{"babad", "cbbd", "forgeeksskeegfor", "a"} {
fmt.Printf("%-18q longest=%q\n", s, longestPalindrome(s))
}
fmt.Println("palindromic substrings in \"aaa\":", countPalindromes("aaa"))
}"babad" longest="bab"
"cbbd" longest="bb"
"forgeeksskeegfor" longest="geeksskeeg"
"a" longest="a"
palindromic substrings in "aaa": 6"aaa" has 6: three a, two aa, and aaa. (Manacher's algorithm finds the longest palindrome in O(n) by reusing mirror information, but expand-around-center is what you should reach for first.)
Pattern search: why the naive way is slow#
"Find pattern in text." The naive approach tries every starting position and compares character by character. On text aaaa…ab and pattern aaab, nearly every attempt matches several characters before failing, so the work is O(n·m). The waste: after matching aaa and failing on b, we already know the next text characters are aa, yet the naive loop reads them again.
KMP: a table of what you already know#
Knuth-Morris-Pratt precomputes, for the pattern alone, an array lps ("longest proper prefix that is also a suffix"). lps[i] answers: "if I have matched p[:i+1] and then fail, how many characters at the start of the pattern are still matched?" The search then continues from there, and the text pointer never moves backward.
For the pattern ababc:
| i | p[:i+1] | longest proper prefix = suffix | lps[i] |
|---|---|---|---|
| 0 | a | (none) | 0 |
| 1 | ab | (none) | 0 |
| 2 | aba | a | 1 |
| 3 | abab | ab | 2 |
| 4 | ababc | (none) | 0 |
Step through the table build and the search. Watch the moment of the first mismatch: the pattern slides, but i in the text stays where it is.
package main
import (
"fmt"
"strings"
)
func buildLPS(p string) []int {
lps := make([]int, len(p))
for i, length := 1, 0; i < len(p); {
switch {
case p[i] == p[length]:
length++
lps[i] = length
i++
case length > 0:
length = lps[length-1] // fall back to a shorter border; i stays
default:
lps[i] = 0
i++
}
}
return lps
}
// searchAll returns every index where p occurs in text, overlaps included. O(n + m).
func searchAll(text, p string) []int {
lps := buildLPS(p)
var out []int
for i, j := 0, 0; i < len(text); {
switch {
case text[i] == p[j]:
i++
j++
if j == len(p) {
out = append(out, i-j)
j = lps[j-1]
}
case j > 0:
j = lps[j-1]
default:
i++
}
}
return out
}
func main() {
fmt.Println(buildLPS("ababc"), buildLPS("aabaaab"), buildLPS("abcabcabc"))
fmt.Println(searchAll("abababcabab", "ababc"))
fmt.Println(searchAll("aaaaa", "aa")) // overlapping matches
fmt.Println(strings.Index("abababcabab", "ababc"))
// A border trick: s is made of a repeated block if n is divisible by n - lps[n-1].
for _, s := range []string{"abcabcabc", "abcab"} {
lps := buildLPS(s)
n, b := len(s), lps[len(s)-1]
fmt.Printf("%s repeated block? %v\n", s, b > 0 n%(n-b) == 0)
}
}[0 0 1 2 0] [0 1 0 1 2 2 3] [0 0 0 1 2 3 4 5 6]
[2]
[0 1 2 3]
2
abcabcabc repeated block? true
abcab repeated block? falseRabin-Karp: compare hashes, not strings#
Rabin-Karp turns each length-m window of the text into a number (a hash) and compares it to the pattern's hash. Only when the hashes match does it compare characters, to rule out a collision. The key is the rolling update: treat the window as a number in base B, so sliding right by one character is:
hash = (hash - text[i-m] * B^(m-1)) * B + text[i] (all modulo a prime)Remove the leaving character's contribution, shift everything one place, add the new character: O(1) per step, O(n + m) on average.
package main
import "fmt"
const (
base = 256
mod = 1_000_000_007
)
func rabinKarp(text, p string) (matches []int, hashHits int) {
n, m := len(text), len(p)
if m > n {
return nil, 0
}
pow := 1 // base^(m-1) % mod: the weight of the leftmost character
for range m - 1 {
pow = pow * base % mod
}
hp, ht := 0, 0
for i := range m {
hp = (hp*base + int(p[i])) % mod
ht = (ht*base + int(text[i])) % mod
}
for i := 0; ; i++ {
if ht == hp {
hashHits++
if text[i:i+m] == p { // confirm: hashes can collide
matches = append(matches, i)
}
}
if i+m == n {
return matches, hashHits
}
ht = (ht - int(text[i])*pow%mod + mod) % mod // drop the leaving character
ht = (ht*base + int(text[i+m])) % mod // shift and add the entering one
}
}
func main() {
fmt.Println(rabinKarp("the cat sat on the mat with the hat", "the"))
fmt.Println(rabinKarp("aaaaaaaa", "aaa"))
fmt.Println(rabinKarp("gopher", "rust"))
}[0 15 28] 3
[0 1 2 3 4 5] 6
[] 0The rolling hash shines when you need to compare many substrings, not just search for one pattern: "longest duplicated substring" binary searches on the length and, for each length, puts every window's hash into a set. Detecting plagiarism between documents uses the same trick.
Complexity#
| Algorithm | Time | Space |
|---|---|---|
| strings.Builder, n appends | O(total length) | O(total length) |
| longest palindrome, expand around center | O(n²) | O(1) |
| naive search | O(n · m) worst case | O(1) |
| KMP | O(n + m) worst case | O(m) |
| Rabin-Karp | O(n + m) average, O(n · m) with many collisions | O(1) |
Summary#
- Build strings with
strings.Builder, never+=in a loop. - Palindromes: expand around all
2n - 1centers. - KMP's
lpstable records how much of the pattern still matches after a failure, so the text is read once. - Rabin-Karp compares rolling hashes and confirms matches; it generalizes to comparing many substrings.
- Handle negative remainders when you subtract under a modulus.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.Why is
s += pieceinside a loop slow for building a long string?Q2.Expanding around centers finds the longest palindrome in O(n²). How many centers are there in a string of length n?
Q3.In KMP, lps[i] is:
Q4.Why does Rabin-Karp compare the actual substrings when the hashes match?
Q5.What does the rolling hash do when the window slides one character to the right?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
Show hint
Expand around each of the 2n-1 centers.
- Palindromic Substrings ↗medium
Show hint
Same expansion; count every successful step.
Show hint
Implement KMP, then compare with strings.Index.
Show hint
With the KMP table: n % (n - lps[n-1]) == 0 and lps[n-1] > 0.
Show hint
Build s + "#" + reverse(s) and use the last value of its KMP table.
Show hint
Binary search on the length; check each length with a rolling hash set.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.