The problem
You get two strings s and t of the same length. They are isomorphic if you can replace every character of s with some character so that s becomes t, under two rules: every copy of a character must map to the same character, and no two different characters may map to the same one. A character may map to itself.
Input: s = "egg", t = "add"
Output: True
Input: s = "foo", t = "bar"
Output: Falsee -> a and g -> d turns "egg" into "add". In "foo", both os would have to become a and r, which is impossible.
How to think about it
Start with brute force. You could try every possible renaming of the characters of s, but that is factorial in the alphabet size. Instead, notice that the renaming is forced: the first time you see s[i], it must map to t[i]. So there’s nothing to search, only something to check.
The first rule needs one map. Walk the strings together. Keep m1 from characters of s to characters of t. If s[i] already has a mapping, it must equal t[i]; otherwise record it. That catches "foo" / "bar".
The key insight: the second rule needs a second map. One map alone accepts "badc" / "baba": b -> b, a -> a, d -> b, c -> a. Each character of s is consistent, but b and d both map to b. To catch that, keep m2 from t back to s and apply the same check. A one-to-one mapping must hold in both directions, so you check both on every pair.
Which pattern is this? Hash map consistency checking. The tell is “same pattern / structure” between two sequences (see also Word Pattern, LeetCode 290).
Approach
- Create two empty maps:
m1(s to t) andm2(t to s). - For each pair
(a, b)fromzip(s, t):- if
ais already mapped to something other thanb, returnFalse; - if
bis already mapped to something other thana, returnFalse; - otherwise record
m1[a] = bandm2[b] = a.
- if
- If every pair passes, return
True.
Solution
def is_isomorphic(s, t):
m1, m2 = {}, {} # s->t and t->s
for a, b in zip(s, t):
if m1.get(a, b) != b or m2.get(b, a) != a: return False # conflict either way
m1[a] = b; m2[b] = a
return True
# is_isomorphic("egg", "add") -> True ; is_isomorphic("foo", "bar") -> Falsem1.get(a, b) returns b when a is not yet mapped, so a new character always passes the check.
Dry run
On s = "foo", t = "bar":
| Pair | m1 before |
m2 before |
Check |
|---|---|---|---|
f, b |
{} |
{} |
new on both sides, record |
o, a |
{f: b} |
{b: f} |
new on both sides, record |
o, r |
{f: b, o: a} |
{b: f, a: o} |
m1[o] is a, not r: return False |
On "badc" / "baba", the third pair (d, b) passes m1 (since d is new) but fails m2, because m2[b] is already b, not d. That’s exactly the case one map would miss.
Complexity
- Time: O(n). One pass with O(1) dictionary operations.
- Space: O(1). Each map holds at most one entry per distinct character (at most 256 for ASCII).
Edge cases and pitfalls
- One map only: the most common bug. It accepts
"badc" / "baba", which is wrong. - Different lengths:
zipsilently stops at the shorter string. The problem guarantees equal lengths; add a length check if it doesn’t. - Empty strings: trivially isomorphic, returns
True. - Self-mapping is allowed:
"paper" / "title"isTrue, and"ab" / "ab"is too. - Alternative: compare the “first-seen index” sequence of each string, for example
[s.index(c) for c in s]. It’s correct but O(n²) withindex; build the sequence with a dictionary to keep it O(n).
Related problems
- Group Anagrams (LeetCode 49): another problem about a canonical “shape” of a string.
- Valid Anagram (LeetCode 242): character-level hash maps across two strings.
- Two Sum (LeetCode 1): the basic “store as you scan, check on arrival” loop.
- 1.Two Sum (LeetCode 1)
- 2.Valid Anagram (LeetCode 242)
- 3.Contains Duplicate (LeetCode 217)
- 4.Ransom Note (LeetCode 383)
- 5.First Unique Character in a String (LeetCode 387)
- 6.Majority Element (LeetCode 169)
- 7.Isomorphic Strings (LeetCode 205)
- 8.Group Anagrams (LeetCode 49)
- 9.Top K Frequent Elements (LeetCode 347)
- 10.Longest Consecutive Sequence (LeetCode 128)
- 11.Subarray Sum Equals K (LeetCode 560)
- 12.Valid Sudoku (LeetCode 36)
- 13.LRU Cache (LeetCode 146)