Topics › Strings › Basic and Easy String Problems
Isomorphic String
LeetCode ↗ Java source ↗ Edit this article ↗
Jump to code:
Problem
Two strings s and t are isomorphic if the characters of s can be replaced to obtain t, subject to two rules: every occurrence of a character must be replaced by the same character (the replacement is a function), and no two different characters may be replaced by the same character (the function is one-to-one). A character may map to itself. Return true if s and t are isomorphic and false otherwise.
Put differently, s and t have the same “shape”: wherever s repeats a character, t repeats a character at exactly the same positions, and vice versa.
Constraints: 0 ≤ |s|, |t| ≤ 5 × 10^4; the strings consist of printable ASCII characters. Strings of different lengths are never isomorphic.
Example 1
s = "egg", t = "add"
answer = true // e -> a, g -> d
Example 2
s = "foo", t = "bar"
answer = false // o would have to become both a and r
Example 3
s = "badc", t = "baba"
answer = false // b -> b and then d -> b: two characters with the same image
Approach 1: Brute force, compare every pair of positions
Intuition. Forget about building the mapping and look at what it implies. If positions i and j hold the same character in s, the function sends both to the same character, so t[i] == t[j]. Conversely, if t[i] == t[j] then, because the function is one-to-one, s[i] == s[j]. So isomorphism is equivalent to: for every pair of positions, “equal in s” and “equal in t” are either both true or both false. This is a complete characterisation, and checking it needs no data structure at all.
Why is the condition sufficient? If it holds, define f(s[i]) = t[i]. It is well defined (equal s characters get equal t characters) and injective (different s characters get different t characters), which is exactly what the problem demands.
Algorithm.
- If the lengths differ, return
false. - For every pair
i < j: computesameInS = s[i] == s[j]andsameInT = t[i] == t[j]. - If
sameInS != sameInT, returnfalse. - If every pair agrees, return
true.
static boolean bruteForce(String s, String t) {
if (s.length() != t.length()) return false;
int n = s.length();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
boolean sameInS = s.charAt(i) == s.charAt(j);
boolean sameInT = t.charAt(i) == t.charAt(j);
if (sameInS != sameInT) return false;
}
}
return true;
}
Complexity. Time O(n^2): n(n-1)/2 pairs, constant work each. Space O(1).
At n = 5 × 10^4 that is about 1.25 × 10^9 comparisons, too slow. But the pairwise view explains why the linear solutions below are correct: each of them is a way to check all pairs implicitly.
Approach 2: Better, two hash maps
Intuition. Build the mapping as you go. A map forward records what each character of s has been sent to. When we meet a = s[i] paired with b = t[i], there are two cases. If a has been seen, its recorded image must be b, otherwise the function would be ill defined. If a is new, we would like to record a -> b, but first we must make sure b is not already the image of some other character: that is what the second map backward (from t characters to s characters) is for. Without it "ab" and "aa" would be accepted, because a -> a and b -> a are each individually consistent.
Algorithm.
- If the lengths differ, return
false. - For each index
i, leta = s[i],b = t[i].- If
forwardhas no entry fora: ifbackwardalready containsb, returnfalse; otherwise recorda -> binforwardandb -> ainbackward. - If
forwardmapsato something other thanb, returnfalse.
- If
- Return
true.
static boolean better(String s, String t) {
if (s.length() != t.length()) return false;
Map<Character, Character> forward = new HashMap<>();
Map<Character, Character> backward = new HashMap<>();
for (int i = 0; i < s.length(); i++) {
char a = s.charAt(i), b = t.charAt(i);
Character mappedA = forward.get(a);
if (mappedA == null) {
if (backward.containsKey(b)) return false; // b is already the image of another character
forward.put(a, b);
backward.put(b, a);
} else if (mappedA != b) {
return false; // a was mapped to a different character earlier
}
}
return true;
}
Complexity. Time O(n): one pass with constant-time map operations on average. Space O(k) where k is the number of distinct characters, at most 128 for ASCII, so effectively constant.
A common shortcut is to keep only one of the two checks: either the backward.containsKey(b) test for a new a, or the mappedA != b consistency test. Both are needed. The first rejects "ab"/"aa" (two sources, one image) and the second rejects "foo"/"bar" (one source, two images).
Note the comparison mappedA != b: mappedA is a Character and b is a char, so Java unboxes mappedA and compares the primitive values. Comparing two Character objects with != would compare references and is a classic bug; comparing a Character with a char is safe.
Approach 3: Optimal, compare the last-seen positions
Intuition. The two maps store characters, but all we ever ask is “have these two characters been paired with each other before, and only with each other?” There is a neater invariant. Record, for each character, the index at which it was last seen, separately for s and t. At position i, a = s[i] and b = t[i] are consistent exactly when lastS[a] == lastT[b]:
- If
aandbare both new, both values are the sentinel0and they agree. - If
awas last seen at positionp, then under a valid mappingt[p]must have beenb, sobwas seen atp. Andbcannot have appeared again betweenpandi, because injectivity would forcesto holdathere too, contradicting “last seen atp”. SolastT[b]is alsop. If it is anything else, the pair is inconsistent. - Symmetrically with the roles of
sandtswapped.
So “the last positions agree at every step” is equivalent to the pairwise condition from Approach 1, but it is checked in O(1) per step. Storing i + 1 instead of i lets 0 mean “never seen” without a separate boolean array.
Algorithm.
- If the lengths differ, return
false. - Create
lastS[256]andlastT[256], all zero. - For each
i: iflastS[s[i]] != lastT[t[i]], returnfalse; otherwise set both toi + 1. - Return
true.
static boolean optimal(String s, String t) {
if (s.length() != t.length()) return false;
int[] lastS = new int[256], lastT = new int[256]; // last position + 1; 0 means not seen yet
for (int i = 0; i < s.length(); i++) {
int a = s.charAt(i), b = t.charAt(i);
if (lastS[a] != lastT[b]) return false;
lastS[a] = i + 1;
lastT[b] = i + 1;
}
return true;
}
Dry run on s = "badc", t = "baba":
| i | a = s[i] | b = t[i] | lastS[a] | lastT[b] | result |
|---|---|---|---|---|---|
| 0 | b | b | 0 | 0 | agree, set both to 1 |
| 1 | a | a | 0 | 0 | agree, set both to 2 |
| 2 | d | b | 0 | 1 | differ, return false |
And on s = "paper", t = "title":
| i | a | b | lastS[a] | lastT[b] | result |
|---|---|---|---|---|---|
| 0 | p | t | 0 | 0 | set both to 1 |
| 1 | a | i | 0 | 0 | set both to 2 |
| 2 | p | t | 1 | 1 | agree, set both to 3 |
| 3 | e | l | 0 | 0 | set both to 4 |
| 4 | r | e | 0 | 0 | set both to 5; loop ends, return true |
Complexity. Time O(n). Space O(1): two arrays of 256 integers regardless of the input size.
Why 256 rather than 128? The problem promises printable ASCII, so 128 would do; 256 covers every char value that fits in a byte and costs nothing. For arbitrary Unicode input the arrays would have to grow to 65 536 entries or be replaced by the hash maps of Approach 2.
Edge cases and pitfalls
- Different lengths. Return
falseimmediately. Every method starts with this check, and it is also what keepst.charAt(i)in bounds. - Empty strings. Two empty strings are isomorphic: there is nothing to map. All three methods return
truebecause their loops do not run. - A character mapping to itself. Allowed.
"a"/"a"and"abc"/"abc"returntrue. - One direction only.
"ab"/"aa"passes a one-map check (a -> a,b -> a) but is not isomorphic. Always verify injectivity, either with thebackwardmap or by comparing last positions in both strings. - Reference comparison of boxed characters.
Character x != Character ycompares object identity, and only the values0..127are cached byCharacter.valueOf. Inbetterthe comparison isCharacteragainstchar, which unboxes; keep it that way. - Sentinel confusion. In
optimal, position0is a real index, so storingiwould make “seen at index 0” indistinguishable from “never seen”. Storingi + 1fixes it. Alternatively initialise both arrays to-1and storei, which is the same idea with a different sentinel. - Symmetry. Isomorphism is symmetric:
isIsomorphic(s, t) == isIsomorphic(t, s). If your solution gives different answers for the two orders, it is missing the injectivity check.
Related problems
- Check if two strings are anagram of each other compares strings by character counts rather than by position pattern.
- Longest Common Prefix also walks several strings column by column.
- Rotate String is the next problem on the sheet.
- Basic Hashing covers the fixed-size array technique that the optimal approach relies on.