title: Palindrome Partitioning
slug: palindrome-partitioning
difficulty: medium
leetcode_id: 131
leetcode_url: https://leetcode.com/problems/palindrome-partitioning/
categories:
  - strings
  - dynamic-programming
  - recursion
patterns:
  - backtracking
  - dynamic-programming

description: |
  Given a string `s`, partition `s` such that every substring of the partition is a **palindrome**.

  Return *all possible palindrome partitioning of* `s`.

  A **palindrome** is a string that reads the same forward and backward.

constraints: |
  - `1 <= s.length <= 16`
  - `s` contains only lowercase English letters

examples:
  - input: 's = "aab"'
    output: '[["a","a","b"],["aa","b"]]'
    explanation: "There are two valid partitions: split into individual characters ['a','a','b'], or combine the first two characters into the palindrome 'aa' plus 'b'."
  - input: 's = "a"'
    output: '[["a"]]'
    explanation: "A single character is always a palindrome, so the only partition is the string itself."

explanation:
  intuition: |
    Imagine you're trying to cut a string into pieces where each piece reads the same forwards and backwards. At each position, you have a choice: where should the next cut be?

    Think of it like standing at the start of the string and asking: "What's the longest palindrome I can take starting from here?" But since we need *all* valid partitions, we can't just be greedy — we need to explore every valid cut point.

    This is a classic **backtracking** problem. At each position `i`, we try extending a substring from `i` to every position `j >= i`. If that substring is a palindrome, we "take it" as part of our current partition and recursively solve for the remainder of the string.

    The key insight is that we're building a **decision tree**: at each node, we decide how much of the remaining string to consume as the next palindrome. Every path from root to leaf represents one valid partitioning.

  approach: |
    We solve this using **Backtracking with Palindrome Checking**:

    **Step 1: Set up the recursive structure**

    - Create a result list to store all valid partitions
    - Create a path list to track the current partition being built
    - Start recursion from index `0`

    &nbsp;

    **Step 2: Define the base case**

    - When our starting index equals the string length, we've successfully partitioned the entire string
    - Add a copy of the current path to our results

    &nbsp;

    **Step 3: Try all possible cuts at each position**

    - For each ending position `end` from `start` to `len(s)-1`:
      - Extract the substring `s[start:end+1]`
      - Check if it's a palindrome
      - If yes: add it to path, recurse with `end+1` as new start, then backtrack by removing it

    &nbsp;

    **Step 4: Palindrome checking**

    - A simple two-pointer approach works: compare characters from both ends moving inward
    - For optimization, you can precompute all palindrome substrings using dynamic programming

    &nbsp;

    The backtracking ensures we explore all possibilities while the palindrome check prunes invalid branches early.

  common_pitfalls:
    - title: Forgetting to Backtrack
      description: |
        A common mistake is adding a substring to the path but forgetting to remove it after the recursive call returns.

        Without proper backtracking, substrings from one branch of exploration contaminate other branches, leading to incorrect or duplicate results.

        Always pair each `path.append()` with a corresponding `path.pop()` after recursion.
      wrong_approach: "Append to path without removing after recursion"
      correct_approach: "Always pop from path after recursive call returns"

    - title: Copying References Instead of Values
      description: |
        When adding a valid partition to results, you must add a **copy** of the path list, not the path itself.

        In Python, `result.append(path)` appends a reference. As you continue modifying `path`, all previously added results change too — you end up with duplicates of the final state.

        Use `result.append(path[:])` or `result.append(list(path))` to add a snapshot.
      wrong_approach: "result.append(path)"
      correct_approach: "result.append(path[:]) or result.append(list(path))"

    - title: Inefficient Palindrome Checking
      description: |
        Checking if a substring is a palindrome takes O(n) time. If you're checking the same substrings repeatedly across different branches, this becomes wasteful.

        With `s.length <= 16`, the naive approach works fine. But for longer strings, consider **precomputing** a 2D table where `is_palindrome[i][j]` indicates whether `s[i:j+1]` is a palindrome. This DP preprocessing takes O(n^2) time but makes each lookup O(1).
      wrong_approach: "Recompute palindrome check for same substring multiple times"
      correct_approach: "Precompute palindrome table with DP for O(1) lookups"

  key_takeaways:
    - "**Backtracking template**: This problem follows the classic pattern — make a choice, recurse, undo the choice (backtrack)"
    - "**Decision tree thinking**: Visualize the problem as exploring all paths in a tree where each node represents a partitioning decision"
    - "**Copy vs reference**: When collecting results in backtracking, always copy the current path to avoid reference bugs"
    - "**Optimization opportunity**: Precomputing palindrome substrings with DP is a common optimization that extends to related problems"

  time_complexity: "O(n * 2^n). In the worst case (all characters identical), there are 2^(n-1) possible partitions, and generating each takes O(n) to copy."
  space_complexity: "O(n). The recursion depth is at most n, and the path list holds at most n substrings. Output storage is not counted."

solutions:
  - approach_name: Backtracking
    is_optimal: true
    code: |
      def partition(s: str) -> list[list[str]]:
          result = []

          def is_palindrome(sub: str) -> bool:
              # Two-pointer check: compare from both ends
              left, right = 0, len(sub) - 1
              while left < right:
                  if sub[left] != sub[right]:
                      return False
                  left += 1
                  right -= 1
              return True

          def backtrack(start: int, path: list[str]) -> None:
              # Base case: partitioned the entire string
              if start == len(s):
                  result.append(path[:])  # Add a copy, not reference
                  return

              # Try all possible end positions for current substring
              for end in range(start, len(s)):
                  substring = s[start:end + 1]
                  # Only proceed if this substring is a palindrome
                  if is_palindrome(substring):
                      path.append(substring)  # Make choice
                      backtrack(end + 1, path)  # Explore
                      path.pop()  # Undo choice (backtrack)

          backtrack(0, [])
          return result
    explanation: |
      **Time Complexity:** O(n * 2^n) — In the worst case, every partition is valid (e.g., "aaa"), giving 2^(n-1) partitions. Each partition takes O(n) to copy.

      **Space Complexity:** O(n) — Recursion stack depth plus the path list.

      This approach explores all valid partitions by trying every possible cut point at each position. The palindrome check prunes invalid branches early, but in the worst case (all identical characters), we still explore all 2^(n-1) possibilities.

  - approach_name: Backtracking with DP Precomputation
    is_optimal: true
    code: |
      def partition(s: str) -> list[list[str]]:
          n = len(s)
          result = []

          # Precompute palindrome table using DP
          # is_pal[i][j] = True if s[i:j+1] is a palindrome
          is_pal = [[False] * n for _ in range(n)]

          # Single characters are palindromes
          for i in range(n):
              is_pal[i][i] = True

          # Check substrings of length 2+
          for length in range(2, n + 1):
              for i in range(n - length + 1):
                  j = i + length - 1
                  if s[i] == s[j]:
                      # Two chars or inner substring is palindrome
                      is_pal[i][j] = (length == 2) or is_pal[i + 1][j - 1]

          def backtrack(start: int, path: list[str]) -> None:
              if start == n:
                  result.append(path[:])
                  return

              for end in range(start, n):
                  # O(1) palindrome lookup instead of O(n) check
                  if is_pal[start][end]:
                      path.append(s[start:end + 1])
                      backtrack(end + 1, path)
                      path.pop()

          backtrack(0, [])
          return result
    explanation: |
      **Time Complexity:** O(n^2 + n * 2^n) — O(n^2) for DP precomputation, plus O(n * 2^n) for backtracking. The overall complexity remains O(n * 2^n).

      **Space Complexity:** O(n^2) — The palindrome lookup table dominates.

      This optimization precomputes which substrings are palindromes using dynamic programming. Each palindrome check becomes O(1) instead of O(n). While this doesn't improve worst-case time complexity (since the number of partitions dominates), it speeds up the palindrome checks significantly in practice.
