Strengthen your string skills with exercises that combine loops, slicing, and data structures to solve more involved problems.
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".
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']
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.
"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.
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.
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
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.
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
sorted("listen") # ['e', 'i', 'l', 'n', 's', 't'] # Must sort all characters first
counts.get(char, 0) + 1 # Single pass through each string # Linear time, no sorting needed
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.
Given a list of strings, find the longest prefix they all share. This exercise practices character-by-character comparison across multiple strings simultaneously.
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"
i >= len(word) prevents an IndexError on shorter words.
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.
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.
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"
for i in range(1, len(text)): if text[i] != text[i-1]: result += text[i-1] + str(count) # Bug: last group never added!
# After the loop: result += text[-1] + str(count) # Always flush the final group
Check whether a string of brackets — (), [], {} — is properly opened and closed in the correct order. This is a classic stack problem.
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
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.
if stack[-1] != pairs[char]: return False # IndexError if stack is empty!
not stackif not stack or stack[-1] != pairs[char]: return False # Safe: checks empty first
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.
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.
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!"
Decryption is just encryption with the opposite shift — no need for a separate algorithm.
def caesar_decrypt(text, shift): return caesar_encrypt(text, -shift) print(caesar_decrypt("Mjqqt, Btwqi!", 5)) # "Hello, World!"
For the character 'x' with a shift of 3:
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.
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.
Methods and functions used across these exercises. Keep this as a handy cheat sheet.
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
| 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 |