Palindrome Pairs — Reverse Trie + Palindrome Tail Check

Sanjeev SharmaSanjeev Sharma
8 min read

Advertisement

Problem Statement

LeetCode 336 — Palindrome Pairs | Difficulty: Hard

Given a list of unique words, return all the pairs of distinct indices (i, j) in the given list such that the concatenation of the two words words[i] + words[j] is a palindrome.

You must write an algorithm with O((sum of word lengths)^2) time complexity at worst, but better solutions exist.

Constraints:

  • 1 <= words.length <= 5000
  • 0 <= words[i].length <= 300
  • words[i] consists of lowercase English letters.

Examples:

Input: words = ["abcd","dcba","lls","s","sssll"]
Output: [[0,1],[1,0],[3,2],[2,4]]
Explanation: Palindromes formed are
  ["abcddcba","dcbaabcd","slls","llssssll"]
Input: words = ["bat","tab","cat"]
Output: [[0,1],[1,0]]
Explanation: "battab" and "tabbat" are palindromes.
Input: words = ["a",""]
Output: [[0,1],[1,0]]

Why This Problem Matters

Palindrome Pairs is the classic "trie + palindromes" hard problem. Google, Amazon, and Microsoft use it as a litmus test because the elegant solution requires three insights stacked on top of each other — reverse storage, palindrome-suffix tagging, and three case decomposition. Solving it cold demonstrates mature data-structure thinking.

The naive O(N^2 times K) approach (concatenate every pair, check palindrome) handles small inputs but TLEs at 5000 words of length 300. The trie solution drops to O(N times K^2) — typically 100x faster — and forms the basis of efficient string-matching engines.

The Core Insight

For a pair (i, j) such that words[i] + words[j] is a palindrome, exactly one of three cases holds. Let A = words[i], B = words[j], where len(A) >= len(B) (without loss of generality, similar cases for the reverse):

  1. Equal length — A is the reverse of B (A + B reversed = A + reverse(A) = palindrome).
  2. A is longer — A = X + Y where Y is a palindrome and B = reverse(X). Then A + B = X + Y + reverse(X), which is a palindrome.
  3. B is longer — B = reverse(Y) + X where Y is a palindrome and A = reverse(X). Then A + B = reverse(X) + reverse(Y) + X = palindrome.

Strategy: insert every word reversed into a trie. Tag each node with the indices of the words whose remaining suffix (after this point) is a palindrome. For each query word, walk the trie with the original characters, splitting into the three cases based on where the walk ends.

Visual Dry Run

Words: ["abcd", "dcba", "lls", "s", "sssll"]. Insert each reversed:

"abcd" reversed: "dcba"  → root → d → c → b → a
"dcba" reversed: "abcd"  → root → a → b → c → d
"lls"  reversed: "sll"   → root → s → l → l
"s"    reversed: "s"     → root → s
"sssll"reversed: "llsss" → root → l → l → s → s → s

At each node we also tag indices of words for which the remaining reversed suffix is a palindrome. Querying "lls":

Walk trie: l → ?  No 'l' child of root in this trie.
Wait: actually we insert REVERSED, so root has children {d, a, s, l}.
Walk 'l': root has 'l'? yes (from "llsss")
Walk 'l': child 'l' yes
Walk 's': child 's' yes
End of query word — at this node we look up "indices for which the remaining tail is palindromic".

If at the end-of-walk node we have a stored index j, and words[j][len(query):] is itself a palindrome, then (i, j) is a valid pair (case 3). Symmetrically for the other cases.

Solution (Optimal) — Reverse Trie + Palindrome Tags

Python

class TrieNode:
    __slots__ = ("children", "word_idx", "palindrome_below")
    def __init__(self):
        self.children = {}
        self.word_idx = -1                # index if word ends here
        self.palindrome_below: list[int] = []  # words whose remaining tail is palindrome
 
class Solution:
    def palindromePairs(self, words: list[str]) -> list[list[int]]:
        def is_palindrome(s: str, lo: int, hi: int) -> bool:
            while lo < hi:
                if s[lo] != s[hi]:
                    return False
                lo += 1; hi -= 1
            return True
 
        root = TrieNode()
 
        # Insert reversed words; tag palindrome_below at each node
        for idx, w in enumerate(words):
            node = root
            n = len(w)
            for k in range(n - 1, -1, -1):
                # If the prefix w[0..k] is a palindrome, this index "fits" at the current node
                if is_palindrome(w, 0, k):
                    node.palindrome_below.append(idx)
                ch = w[k]
                node = node.children.setdefault(ch, TrieNode())
            node.word_idx = idx
            node.palindrome_below.append(idx)  # empty tail is palindromic
 
        result: list[list[int]] = []
 
        # For each word, walk the trie with original chars
        for idx, w in enumerate(words):
            node = root
            n = len(w)
            for k, ch in enumerate(w):
                # Case 2: trie has a word that ends at current node and the rest of w is palindrome
                if node.word_idx >= 0 and node.word_idx != idx and is_palindrome(w, k, n - 1):
                    result.append([idx, node.word_idx])
                if ch not in node.children:
                    break
                node = node.children[ch]
            else:
                # Walked whole word — case 1 and case 3
                for j in node.palindrome_below:
                    if j != idx:
                        result.append([idx, j])
        return result

JavaScript

var palindromePairs = function (words) {
  const isPal = (s, lo, hi) => {
    while (lo < hi) {
      if (s[lo] !== s[hi]) return false;
      lo++; hi--;
    }
    return true;
  };
 
  const root = { children: {}, wordIdx: -1, palBelow: [] };
 
  for (let idx = 0; idx < words.length; idx++) {
    const w = words[idx];
    let node = root;
    for (let k = w.length - 1; k >= 0; k--) {
      if (isPal(w, 0, k)) node.palBelow.push(idx);
      const ch = w[k];
      if (!node.children[ch]) node.children[ch] = { children: {}, wordIdx: -1, palBelow: [] };
      node = node.children[ch];
    }
    node.wordIdx = idx;
    node.palBelow.push(idx);
  }
 
  const result = [];
  for (let idx = 0; idx < words.length; idx++) {
    const w = words[idx];
    let node = root, broke = false;
    for (let k = 0; k < w.length; k++) {
      if (node.wordIdx >= 0 && node.wordIdx !== idx && isPal(w, k, w.length - 1)) {
        result.push([idx, node.wordIdx]);
      }
      const ch = w[k];
      if (!node.children[ch]) { broke = true; break; }
      node = node.children[ch];
    }
    if (!broke) {
      for (const j of node.palBelow) {
        if (j !== idx) result.push([idx, j]);
      }
    }
  }
  return result;
};

Complexity

  • Time: O(N times K^2) where N = number of words, K = max word length. Each insert is O(K^2) due to palindrome checks; each query is O(K^2).
  • Space: O(N times K) for the trie plus O(N times K) for palindrome_below lists in the worst case.

Common Mistakes

  1. Forgetting the empty string"" pairs with every palindrome word in the list. Handle by tagging the root if "" is present.
  2. Not excluding self-pairs(i, i) is invalid even if word + word is a palindrome.
  3. Confusing the two directions — pairs are ordered: (i, j) and (j, i) are both valid if both concatenations are palindromes.
  4. Storing indices only at terminals — case 3 needs indices at every prefix node where the remaining tail is palindromic, not just at the end.
  5. Building the trie without reversing — defeats the purpose; you must reverse at insert so query walks naturally.
  6. Using brute force only when N is small — borderline acceptable but not what hard problems test; the trie pattern is the expected answer.

Interview Tips

  • Walk through the three cases on a whiteboard before coding; the case decomposition is the hardest sell to interviewers.
  • Insert reversed words to keep the query walk simple — explain why this maps query case 3 to "walked entire query, find palindrome below."
  • Use an explicit helper isPalindrome(s, lo, hi) to keep palindrome checks inline cost obvious.
  • Mention the alternative hash-map approach (split each word, look up reversed prefixes) — same complexity but more cases to manage.
  • Be ready to discuss handling duplicates if the constraint were relaxed.

Follow-up Questions

  • What if word lengths can vary wildly? Build per-length buckets to keep palindrome checks bounded.
  • Streaming inserts of new words? Update the trie incrementally; queries against existing words must be recomputed.
  • Largest palindrome of length 3+ formed by concatenation? Filter pairs where the resulting length exceeds 2.
  • Allow same word twice? Drop the j != idx guard — but then the empty string self-pairs.
  • Manacher's algorithm here? Manacher gives O(K) palindrome checks for each prefix/suffix, dropping the K^2 factor; rarely required in interviews.

Key Takeaways

  • Three-case palindrome decomposition: equal length, A longer, B longer.
  • Insert reversed words into the trie so query walks line up with concatenation.
  • Tag every node along the insertion path where the remaining reversed tail is a palindrome — this enables case 3 in O(1) at the terminal.
  • Always exclude i == j and consider the empty string as a special case.
  • Complexity drops from O(N^2 K) to O(N K^2) — usually 100x speedup.
  • This is the gold-standard hard trie problem; mastering it sets the bar for any FAANG hard string question.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading