Python Strings — Intermediate Exercises

Strengthen your string skills with exercises that combine loops, slicing, and data structures to solve more involved problems.

Exercise 1 Find All Substrings

Return every possible substring of a given string. A substring is any contiguous slice — for "abc" that includes "a", "ab", "abc", "b", "bc", and "c".

python
def all_substrings(text):
    result = []
    for start in range(len(text)):
        for end in range(start + 1, len(text) + 1):
            result.append(text[start:end])
    return result

print(all_substrings("abc"))
# ['a', 'ab', 'abc', 'b', 'bc', 'c']

print(all_substrings("hi"))
# ['h', 'hi', 'i']
Key Idea
Two nested loops over the index range let you generate every start/end pair. text[start:end] extracts each substring. The outer loop picks the starting index, and the inner loop extends from start + 1 up to len(text) + 1 because slice end indices are exclusive.
Complexity Warning
A string of length n has n(n+1)/2 substrings. For "abc" that is only 6, but for a 1000-character string it is nearly 500,000. This approach is fine for short strings but watch out at scale.

Exercise 2 Anagram Checker

Two strings are anagrams if they contain the same characters in a different order (e.g. "listen" and "silent"). This exercise shows two ways to solve the same problem with very different trade-offs.

The Quick Way — Sort and Compare

python
def is_anagram(a, b):
    return sorted(a.lower()) == sorted(b.lower())

print(is_anagram("listen", "silent"))   # True
print(is_anagram("Hello", "Olelh"))     # True
print(is_anagram("abc", "abd"))         # False

The Manual Way — Count Characters with a Dictionary

Instead of sorting, count each character's frequency. Increment for the first string, decrement for the second — if all counts end at zero, the strings are anagrams.

python
def is_anagram_manual(a, b):
    a, b = a.lower(), b.lower()
    if len(a) != len(b):
        return False
    counts = {}
    for char in a:
        counts[char] = counts.get(char, 0) + 1
    for char in b:
        counts[char] = counts.get(char, 0) - 1
    return all(v == 0 for v in counts.values())

print(is_anagram_manual("triangle", "integral"))  # True
print(is_anagram_manual("apple", "papel"))         # True
print(is_anagram_manual("rat", "car"))             # False
Sorting approach — O(n log n)
sorted("listen")
# ['e', 'i', 'l', 'n', 's', 't']
# Must sort all characters first
Dictionary approach — O(n)
counts.get(char, 0) + 1
# Single pass through each string
# Linear time, no sorting needed
Key Idea
sorted() turns a string into a sorted list of characters — two anagrams always produce the same sorted list. The dictionary approach avoids sorting and runs in linear time. dict.get(key, default) returns the default instead of raising a KeyError.

Exercise 3 Longest Common Prefix

Given a list of strings, find the longest prefix they all share. This exercise practices character-by-character comparison across multiple strings simultaneously.

python
def longest_common_prefix(words):
    if not words:
        return ""
    prefix = ""
    for i in range(len(words[0])):
        char = words[0][i]
        for word in words[1:]:
            if i >= len(word) or word[i] != char:
                return prefix
        prefix += char
    return prefix

print(longest_common_prefix(["flower", "flow", "flight"]))      # "fl"
print(longest_common_prefix(["dog", "racecar", "car"]))         # ""
print(longest_common_prefix(["inter", "interact", "internal"]))  # "inter"
Key Idea
Walk through the characters of the first word one by one and compare each position across all other words. Stop as soon as any word disagrees or runs out of characters. The guard i >= len(word) prevents an IndexError on shorter words.
Why use the first word as the reference?

The common prefix can never be longer than the shortest word in the list. Using words[0] as the reference is convenient — if it is longer than the shared prefix, the inner loop will bail out early when a shorter word disagrees. If it is the shortest word, the outer loop naturally ends at the right place.

Exercise 4 String Compression

Compress a string by replacing consecutive repeated characters with the character followed by a count (e.g. "aaabbc" becomes "a3b2c1"). Return the original string if the compressed version is not shorter.

python
def compress(text):
    if not text:
        return ""
    result = ""
    count = 1
    for i in range(1, len(text)):
        if text[i] == text[i - 1]:
            count += 1
        else:
            result += text[i - 1] + str(count)
            count = 1
    result += text[-1] + str(count)   # flush the last group
    return result if len(result) < len(text) else text

print(compress("aaabbbcc"))    # "a3b3c2"
print(compress("aabbaabb"))    # "aabbaabb"  (compressed is same length)
print(compress("abcdef"))      # "abcdef"    (no benefit)
print(compress("aaaaaaa"))     # "a7"
Forgetting the last group
for i in range(1, len(text)):
    if text[i] != text[i-1]:
        result += text[i-1] + str(count)
# Bug: last group never added!
Flush after the loop ends
# After the loop:
result += text[-1] + str(count)
# Always flush the final group
Key Idea
Track a running count and flush it to the result whenever the character changes. Always handle the final group after the loop ends — this "flush the last item" pattern appears in many grouping problems.

Exercise 5 Valid Parentheses

Check whether a string of brackets — (), [], {} — is properly opened and closed in the correct order. This is a classic stack problem.

python
def is_valid_parentheses(text):
    stack = []
    pairs = {")": "(", "]": "[", "}": "{"}
    for char in text:
        if char in "([{":
            stack.append(char)
        elif char in ")]}":
            if not stack or stack[-1] != pairs[char]:
                return False
            stack.pop()
    return len(stack) == 0

print(is_valid_parentheses("()[]{}"))     # True
print(is_valid_parentheses("(]"))         # False
print(is_valid_parentheses("{[()]}"))     # True
print(is_valid_parentheses("((())"))      # False
print(is_valid_parentheses(""))           # True
How the Stack Works

Think of a stack like a pile of plates — you can only add or remove from the top. When you see an opener ((, [, {), push it on top. When you see a closer, check that the top of the stack is its matching opener, then pop it off. If the stack is empty at the end, every bracket was properly matched.

Forgetting to check empty stack
if stack[-1] != pairs[char]:
    return False
# IndexError if stack is empty!
Guard with not stack
if not stack or stack[-1] != pairs[char]:
    return False
# Safe: checks empty first
Key Idea
A stack (plain list used with append/pop) is the classic tool for matching nested pairs. Push openers, pop when you see a closer, and make sure it matches. The dictionary maps each closer to its expected opener.

Exercise 6 Caesar Cipher

Shift every letter in a string forward by a given number of positions in the alphabet, wrapping around from z back to a. Non-letter characters stay unchanged. This is one of the oldest encryption methods, used by Julius Caesar himself.

Encrypt

python
def caesar_encrypt(text, shift):
    result = ""
    for char in text:
        if char.isalpha():
            base = ord("A") if char.isupper() else ord("a")
            shifted = (ord(char) - base + shift) % 26 + base
            result += chr(shifted)
        else:
            result += char
    return result

print(caesar_encrypt("abc", 3))            # "def"
print(caesar_encrypt("xyz", 3))            # "abc"  (wraps around)
print(caesar_encrypt("Hello, World!", 5))  # "Mjqqt, Btwqi!"

Decrypt

Decryption is just encryption with the opposite shift — no need for a separate algorithm.

python
def caesar_decrypt(text, shift):
    return caesar_encrypt(text, -shift)

print(caesar_decrypt("Mjqqt, Btwqi!", 5))  # "Hello, World!"
Step-by-step: how the math works

For the character 'x' with a shift of 3:

python
ord('x')                 # 120
ord('x') - ord('a')      # 23   (position in alphabet: 0-25)
(23 + 3) % 26            # 0    (wraps past z back to a)
0 + ord('a')              # 97
chr(97)                  # 'a'

The key insight: subtract the base to get a 0-25 range, add the shift, use % 26 to wrap around, then add the base back.

Key Idea
ord() converts a character to its numeric code, chr() converts back. The modulo % 26 handles the wrap-around at the ends of the alphabet. Negative shifts work naturally because Python's modulo always returns a non-negative result.

Quick Reference — Useful String & Built-in Tools

Methods and functions used across these exercises. Keep this as a handy cheat sheet.

python
s = "Hello, World!"

sorted(s)                  # [' ', '!', ',', 'H', 'W', 'd', 'e', 'l', 'l', 'l', 'o', 'o', 'r']
ord("A")                   # 65
chr(65)                    # "A"
"abc".isalpha()            # True
"123".isdigit()            # True
"abc123".isalnum()         # True
s.index("World")           # 7   (like find, but raises ValueError if missing)
s.count("l")               # 3
"aabbc".replace("a", "")   # "bbc"

# dict.get with a default
d = {}
d.get("key", 0)            # 0  (returns default instead of KeyError)

# all() checks every element is truthy
all([True, True, False])    # False
all([1, 2, 3])              # True

Summary — Key Takeaways

Exercise Core Concept Key Method / Syntax
Find All Substrings Nested loops over index ranges text[start:end], range()
Anagram Checker Sorting vs dictionary counting sorted(), dict.get(), all()
Longest Common Prefix Character-by-character comparison words[0][i], early return
String Compression Run-length encoding & flushing str(), flush last group
Valid Parentheses Stack-based matching .append(), .pop(), stack[-1]
Caesar Cipher Character code arithmetic ord(), chr(), % 26