Blog

Breaking Down Complex Problems Into Manageable Parts

To break down a complex problem, write down exactly what goes in and what comes out, solve a tiny example by hand, split the work into steps where each step has its own input and output, and build and test the steps one at a time. The rest of this post shows how to do that on a real algorithm problem and on a small feature, and which mistakes make decomposition backfire.

What decomposition is, and what it is not

Decomposition means cutting a problem into pieces that you can understand, implement and test separately, and that combine into the answer. A good piece has a clear input, a clear output, and a name that says what it does. If you cannot state a piece’s input and output in one line, it is not a piece yet, only a vague area of the problem.

It is different from listing topics. “Authentication, profiles, feed” is a list of areas. “Hash a password and compare it with the stored hash” is a piece you can write and test today.

A method that works for most problems

  1. Restate the problem precisely. Inputs, outputs, constraints, and what to do with odd cases (empty input, ties, invalid data). Most wasted effort comes from solving a slightly different problem.
  2. Solve a small example by hand. Write down every step you do. Those steps are your first draft of the pieces, because your brain already decomposes the problem when it has something concrete to work on.
  3. Name the pieces and their interfaces. For each step write its input and output type. Check that the output of one matches the input of the next.
  4. Find the dependencies. Which piece needs another one’s result? Pieces with no dependencies can be built first, or by different people.
  5. Build the smallest end-to-end version first. A rough version of every piece, wired together, beats a perfect first piece with nothing connected.
  6. Test each piece alone, then together. When something breaks, you will know which piece to look at.
  7. Improve the weakest piece. Optimise only where measurements, or the problem’s constraints, tell you to.

Worked example: the k most frequent words

Problem. Given a text and a number k, return the k most frequent words, most frequent first. Break ties alphabetically.

Restating it surfaces decisions: Is “The” the same word as “the”? What about punctuation and apostrophes? What if the text has fewer than k distinct words? Let us decide: ignore case, treat letters and apostrophes as word characters, and return fewer than k words if there are not enough.

Doing a tiny example by hand, “the cat saw the dog and the dog saw the cat”, gives these steps: cut the text into words, count each word, order the words by count then alphabet, take the first k. That is four pieces:

import re
from collections import Counter

def tokenize(text):                      # str -> list of words
    return re.findall(r"[a-z']+", text.lower())

def count_words(words):                  # list of words -> word counts
    return Counter(words)

def top_k(counts, k):                    # word counts -> k words, best first
    ranked = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
    return [word for word, _ in ranked[:k]]

def top_k_words(text, k):
    return top_k(count_words(tokenize(text)), k)

Each piece can be checked on its own before they are combined:

print(tokenize("The cat; the DOG. Don't!"))
# ['the', 'cat', 'the', 'dog', "don't"]

print(top_k(Counter({"b": 2, "a": 2, "c": 5}), 2))
# ['c', 'a']   (a and b tie at 2, so a comes first)

print(top_k_words("the cat saw the dog and the dog saw the cat", 3))
# ['the', 'cat', 'dog']

print(top_k_words("", 3))      # []
print(top_k_words("a b", 5))   # ['a', 'b']

The decomposition paid off in three ways. The tie rule lives in exactly one place. If the tokenizer turns out to be wrong for some input (say you later want to keep hyphenated words), you change one function. And once the structure is visible, an optimisation is easy to see: sorting all distinct words costs O(m log m), and for a huge number m of distinct words with a small k, a heap of size k would do better. You can swap top_k without touching the rest. That is the goal of a good decomposition: change one piece, leave the others alone.

Worked example: a recognised algorithm pattern

Problem. Find the longest palindromic substring of a string.

  1. A palindrome mirrors around its centre. The centre is one character (odd length) or the gap between two (even length).
  2. So the problem reduces to: for every possible centre, how far can you expand while both sides match?
  3. That gives two pieces: expand from a centre and try every centre and keep the longest.
def longest_palindrome(s):
    def expand(left, right):
        while left >= 0 and right < len(s) and s[left] == s[right]:
            left -= 1
            right += 1
        return s[left + 1:right]

    best = ""
    for i in range(len(s)):
        best = max(best, expand(i, i), expand(i, i + 1), key=len)
    return best

for t in ["babad", "cbbd", "a", "", "forgeeksskeegfor"]:
    print(repr(longest_palindrome(t)))
# 'bab'
# 'bb'
# 'a'
# ''
# 'geeksskeeg'

For “babad” both “bab” and “aba” are correct; max with key=len keeps the first one it sees, so the result is “bab”. If your problem says “return any”, that is fine; if it wants the leftmost, this is the leftmost. Know which one the problem needs. The work is O(n²) in the worst case, which is acceptable for a first solution; an interviewer may then ask about Manacher’s algorithm, which does the same job in O(n). For practice on the same idea, see AlgoCademy’s Palindrome Substrings lesson.

Decomposing a feature or system

The same method works at the scale of a feature. Take “users can reset their password by email”. A weak breakdown is “forms, emails, database”. A usable one is a sequence of pieces with interfaces:

  1. Request reset: input is an email address; output is always the same neutral message, whether or not the account exists, so you do not reveal who has an account.
  2. Create a token: input is a user ID; output is a random, single-use token with an expiry, of which you store only a hash.
  3. Send the email: input is an address and a link containing the token; output is a queued message. Failure here should be retried, not shown to the user.
  4. Verify the token: input is a token; output is a user ID or “invalid or expired”.
  5. Set the new password: input is user ID and password; output is success, with the token consumed and existing sessions revoked.

The dependencies are clear: 2 before 3, 4 before 5. Pieces 2 and 4 can be tested with no email server at all, and piece 3 can be replaced by a fake that prints the link during development. Dividing the feature along these lines also divides the risk: security-sensitive code is isolated in two small functions that deserve careful review.

Where decomposition goes wrong

  • Pieces too small. Ten one-line functions that exist only to be called once make the code harder to follow. Split when a piece has a name worth giving, or needs its own test.
  • Pieces too big. “Process the data” is still a problem, not a piece. Keep splitting until each piece has a plain input and output.
  • Hidden coupling. If two pieces both read and write the same global variable, you have not separated them. Pass data in and return it out.
  • Skipping the example. Planning in the abstract produces plausible-looking pieces that do not fit together. A hand-worked example catches that early.
  • Forgetting edge cases in the interfaces. Ask for each piece what happens with empty, single-item, duplicate and huge input. Write those as the first tests.
  • Optimising a piece before the whole works. Get a correct end-to-end version, then measure.
  • Never integrating. Pieces that pass alone can still fail together because of a mismatched format. Wire them up early.

In an interview

Decomposition is the most visible part of your thinking in an interview, so say it aloud. Restate the problem, ask about the edge cases, work through an example, then name the steps before you write them: “I will tokenize, count, sort, and slice.” Interviewers get to see a plan, can correct it before you invest ten minutes in the wrong direction, and can give hints at the level of a single piece. Writing steps as comments or pseudocode first is a cheap way to do this; our post on pseudocode in problem solving shows how, and the step-by-step approach to coding challenges post covers the same ground from the challenge side.

How to practise

  • Before you code any problem, write the steps in plain language and the input and output of each. Compare with the editorial or a solution afterwards.
  • Take a small app you use and write the five pieces you would build first, with interfaces.
  • Read a medium-sized open-source function and list the steps it performs. Often a 60-line function is four pieces that were never separated.
  • Use guided practice where problems come with step-by-step prompts, such as AlgoCademy’s interactive lessons, and focus on how each step feeds the next.
A lesson in AlgoCademy: the step to write, a picture of what it does, the line written in the editor, and the check: Step 2 passed

Before you go

Start thinking like a software engineer.

Learn to code and solve interview problems, one small, checked step at a time.

Start learning free

No account needed. You write real code in your browser.