Search…
DSA Foundations · Part 8

String manipulation patterns

In this series (8 parts)
  1. How computers store data
  2. Big-O and algorithm analysis
  3. Bits, bytes, and bitwise tricks
  4. Arrays: fixed-size and indexing
  5. Dynamic arrays: how lists grow
  6. 2D arrays and matrices
  7. Strings and character encoding
  8. String manipulation patterns

In the strings post, you learned what a string is: a sequence of characters stored in memory, plus the important catch that many languages make strings immutable. This post is about the next step: the recurring patterns interview problems keep reusing. Once you can spot “this is a two-pointer scan”, “this is frequency counting”, or “this is repeated copying”, a lot of string questions stop feeling like tricks.


Pattern 1: in-place reversal

Problem: reverse a string, or reverse a substring inside a larger string.

The naive instinct is often: “build a new string from the end to the front.” That works, but it uses O(n) extra space because every immutable concatenation creates a new string.

The better pattern is: convert the string to a mutable character array, then swap from both ends inward.

Why the char array matters:

  • In Python, strings are immutable, so you usually do chars = list(s).
  • In Java, String is immutable, so you often do char[] chars = s.toCharArray().
  • Once you have a mutable array, the swapping itself is O(n) time and O(1) extra space beyond the mutable buffer.

Concrete example: reverse "planet":

start:  p l a n e t
swap:   t l a n e p
swap:   t e a n l p
swap:   t e n a l p
done:   t e n a l p
def reverse_string(s: str) -> str:
    chars = list(s)
    left, right = 0, len(chars) - 1

    while left < right:
        chars[left], chars[right] = chars[right], chars[left]
        left += 1
        right -= 1

    return "".join(chars)


def reverse_substring(s: str, start: int, end: int) -> str:
    chars = list(s)

    while start < end:
        chars[start], chars[end] = chars[end], chars[start]
        start += 1
        end -= 1

    return "".join(chars)


print(reverse_string("planet"))              # tenalp
print(reverse_substring("datastructures", 4, 9))  # dataurtsctures
public static String reverseString(String s) {
    char[] chars = s.toCharArray();
    int left = 0, right = chars.length - 1;

    while (left < right) {
        char temp = chars[left];
        chars[left] = chars[right];
        chars[right] = temp;
        left++;
        right--;
    }

    return new String(chars);
}

public static String reverseSubstring(String s, int start, int end) {
    char[] chars = s.toCharArray();

    while (start < end) {
        char temp = chars[start];
        chars[start] = chars[end];
        chars[end] = temp;
        start++;
        end--;
    }

    return new String(chars);
}

Pattern to remember: when the question says “in place”, think mutable buffer + two pointers.


Pattern 2: palindrome checking

Problem: decide whether a string reads the same forward and backward.

The naive approach is to reverse the whole string and compare it to the original. That is easy, but it spends O(n) extra space making a copy.

The better approach is to compare mirrored characters directly with two pointers:

  • left starts at the beginning
  • right starts at the end
  • if characters match, move inward
  • if they do not, stop immediately

This is your first really important two-pointer pattern in the series. Step through it below — watch L and R converge toward the center:

Step 0 of 0
Output: —
Code

Case 1: plain palindrome

"racecar" works because every mirrored pair matches:

  • r == r
  • a == a
  • c == c
  • middle e does not matter

Case 2: ignore punctuation and case

Interview versions often ask whether "A man, a plan, a canal: Panama" is a palindrome. Now you must skip non-alphanumeric characters and compare lowercase forms.

Case 3: remove at most one character

A slightly harder variant: can "abca" become a palindrome by deleting at most one character? Yes — remove either b or c, and you get "aca" or "aba".

def is_palindrome(s: str) -> bool:
    left, right = 0, len(s) - 1

    while left < right:
        if s[left] != s[right]:
            return False
        left += 1
        right -= 1

    return True


def is_clean_palindrome(s: str) -> bool:
    left, right = 0, len(s) - 1

    while left < right:
        while left < right and not s[left].isalnum():
            left += 1
        while left < right and not s[right].isalnum():
            right -= 1

        if s[left].lower() != s[right].lower():
            return False
        left += 1
        right -= 1

    return True


def valid_palindrome_after_one_delete(s: str) -> bool:
    def is_range_palindrome(i: int, j: int) -> bool:
        while i < j:
            if s[i] != s[j]:
                return False
            i += 1
            j -= 1
        return True

    left, right = 0, len(s) - 1

    while left < right:
        if s[left] != s[right]:
            return (
                is_range_palindrome(left + 1, right) or
                is_range_palindrome(left, right - 1)
            )
        left += 1
        right -= 1

    return True
public static boolean isPalindrome(String s) {
    int left = 0, right = s.length() - 1;

    while (left < right) {
        if (s.charAt(left) != s.charAt(right)) {
            return false;
        }
        left++;
        right--;
    }

    return true;
}

public static boolean isCleanPalindrome(String s) {
    int left = 0, right = s.length() - 1;

    while (left < right) {
        while (left < right && !Character.isLetterOrDigit(s.charAt(left))) left++;
        while (left < right && !Character.isLetterOrDigit(s.charAt(right))) right--;

        if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
            return false;
        }
        left++;
        right--;
    }

    return true;
}

Complexity: all three versions are O(n) time. The direct two-pointer check is O(1) extra space.


Pattern 3: anagram detection with frequency counting

Problem: decide whether two strings contain the same letters in different orders.

The naive solution is to sort both strings and compare:

  • "listen""eilnst"
  • "silent""eilnst"

That works, but sorting costs O(n log n).

The better pattern is frequency counting:

  • count how many times each letter appears in the first string
  • subtract counts using the second string
  • if every count ends at zero, they are anagrams

For lowercase English letters, a fixed array of length 26 is ideal. Watch how the frequency array fills up as we scan through the string:

Step 0 of 0
Output: —
Code
Approach Time Extra space When to use it
Sort both strings O(n log n) O(n) or O(1), language-dependent Quick to write, fine when sorting is already acceptable
Hash map counts O(n) O(k) General Unicode / mixed character sets
Fixed array of 26 O(n) O(1) Lowercase a-z only; fastest and simplest
def is_anagram(s: str, t: str) -> bool:
    if len(s) != len(t):
        return False

    counts = [0] * 26

    for ch in s:
        counts[ord(ch) - ord('a')] += 1
    for ch in t:
        counts[ord(ch) - ord('a')] -= 1

    return all(count == 0 for count in counts)


def group_anagrams(words: list[str]) -> dict[tuple[int, ...], list[str]]:
    groups = {}

    for word in words:
        counts = [0] * 26
        for ch in word:
            counts[ord(ch) - ord('a')] += 1
        key = tuple(counts)
        groups.setdefault(key, []).append(word)

    return groups
public static boolean isAnagram(String s, String t) {
    if (s.length() != t.length()) return false;

    int[] counts = new int[26];

    for (char ch : s.toCharArray()) counts[ch - 'a']++;
    for (char ch : t.toCharArray()) counts[ch - 'a']--;

    for (int count : counts) {
        if (count != 0) return false;
    }
    return true;
}

That tuple(counts) / int[26] signature is also the key idea behind group anagrams. You are getting a preview of hash-map thinking here: represent the important structure of the string, then use that representation as a lookup key.


Pattern 4: string rotation with the doubling trick

Problem: is s2 a rotation of s1?

Example:

  • s1 = "waterbottle"
  • s2 = "erbottlewat"

The naive idea is to generate every rotation and compare them one by one. That is O(n²) if you literally build all rotations.

The better trick is:

len(s1) == len(s2) and s2 in (s1 + s1)

Why it works: every rotation is just the original string cut at some point and wrapped around. Doubling s1 places every possible wraparound substring next to each other.

s1      = waterbottle
s1 + s1 = waterbottlewaterbottle
                 erbottlewat  ✓ appears contiguously

Complexity: building s1 + s1 takes O(n) space, and substring search is typically O(n) average in modern libraries.

def is_rotation(s1: str, s2: str) -> bool:
    return len(s1) == len(s2) and s2 in (s1 + s1)
public static boolean isRotation(String s1, String s2) {
    return s1.length() == s2.length() && (s1 + s1).contains(s2);
}

Pattern 5: the O(n²) concatenation trap, revisited

Problem: build a result string one character at a time.

The naive version looks harmless:

result = ""
for ch in chars:
    result = f"{result}{ch}"

But strings are immutable. Each step creates a brand-new string and copies all previous characters into it again. If the final string has length n, the total copied work is:

1 + 2 + 3 + ... + n = O(n²)

The better approach is:

  • Python: append pieces to a list, then "".join(parts)
  • Java: use StringBuilder
n characters Repeated immutable concatenation List/join or StringBuilder
1,000 ~500,500 character copies ~1,000 character writes + final join
10,000 ~50,005,000 character copies ~10,000 writes + final join
100,000 ~5,000,050,000 character copies ~100,000 writes + final join

The exact constants differ by language and runtime, but the growth pattern is the important part.

Here is a tiny benchmark you can run locally:

import time

chars = ["x"] * 40000

start = time.perf_counter()
result = ""
for ch in chars:
    result = f"{result}{ch}"   # repeated copying
print("concat:", time.perf_counter() - start)

start = time.perf_counter()
parts = []
for ch in chars:
    parts.append(ch)
result = "".join(parts)
print("join:  ", time.perf_counter() - start)
char[] chars = new char[40000];
Arrays.fill(chars, 'x');

long start = System.nanoTime();
String result = "";
for (char ch : chars) {
    result += ch;   // repeated copying
}
System.out.println("concat: " + (System.nanoTime() - start));

start = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (char ch : chars) {
    sb.append(ch);
}
result = sb.toString();
System.out.println("builder: " + (System.nanoTime() - start));

On a local CPython run, this exact Python benchmark took about 11.9 ms with repeated formatted concatenation versus 1.2 ms with join at 40,000 characters. The ratio gets worse as the string grows.


Pattern 6: reversal-based rotation

There is another rotation pattern worth knowing because it generalizes from strings to arrays.

Problem: rotate left by k positions without allocating another full array.

For "algorithm" and k = 3, the answer should be "orithmalg".

The three-reversal trick:

  1. reverse the first k characters
  2. reverse the remaining n-k
  3. reverse the whole array
algorithm
lagorithm   reverse first 3
lagmhtiro   reverse the rest
orithmalg   reverse all

Why this works: reversing twice restores each block’s internal order, while the final reversal swaps the blocks themselves.

def rotate_left(s: str, k: int) -> str:
    chars = list(s)
    n = len(chars)
    k %= n

    def reverse(i: int, j: int) -> None:
        while i < j:
            chars[i], chars[j] = chars[j], chars[i]
            i += 1
            j -= 1

    reverse(0, k - 1)
    reverse(k, n - 1)
    reverse(0, n - 1)
    return "".join(chars)
public static String rotateLeft(String s, int k) {
    char[] chars = s.toCharArray();
    int n = chars.length;
    k %= n;

    reverse(chars, 0, k - 1);
    reverse(chars, k, n - 1);
    reverse(chars, 0, n - 1);
    return new String(chars);
}

private static void reverse(char[] chars, int i, int j) {
    while (i < j) {
        char temp = chars[i];
        chars[i] = chars[j];
        chars[j] = temp;
        i++;
        j--;
    }
}

This is the same “reverse a range with two pointers” primitive reused three times. Pattern recognition matters more than memorizing the final code.


Pattern 7: fixed-size frequency arrays

This is really the reusable idea behind the anagram section, but it deserves to be called out on its own.

When the alphabet is small and fixed — for example lowercase English letters — an array beats a hash map:

  • [0] * 26 in Python
  • new int[26] in Java

Use a fixed array when:

  • the character set is known in advance
  • mapping from character to index is simple
  • you care about speed and constant factors

Use a hash map when:

  • the character set is large or unknown
  • case, punctuation, Unicode, or emojis matter
  • you need sparse counts for many possible symbols
Representation Best for Tradeoff
int[26] Lowercase a-z Fastest, but only works for a fixed alphabet
int[52] or int[128] Letters / ASCII Still constant-size, slightly larger
Hash map / dictionary General Unicode text Flexible, but more overhead per update

One more preview: these same count arrays power sliding window string problems.

Example idea:

  • build counts for the pattern "abc"
  • slide a window of length 3 over "cbaebabacd"
  • update counts as one character enters and one leaves
  • compare the 26-length count arrays

That is a bridge to the upcoming sliding-window post, but the mental model starts here.


Key takeaways

  1. In-place string work usually means converting to a mutable character array first.
  2. Two pointers are the main pattern for reversal and palindrome questions.
  3. Frequency counting beats sorting for anagram-style problems when you only need counts.
  4. s2 in (s1 + s1) is the cleanest way to test string rotation.
  5. Repeated immutable concatenation can silently turn linear work into O(n²) work.
  6. The three-reversal trick rotates arrays and strings without an extra full buffer.
  7. Fixed-size count arrays are a high-value optimization when the alphabet is small and known.

Practice problems


What’s next

This post kept using two pointers informally: one from the left, one from the right, each moving according to a rule. The next post will make that technique explicit and show how it powers much more than string problems: deduplication, partitioning, pair sums, and fast scans over sorted arrays.

Start typing to search across all content
navigate Enter open Esc close