How to Approach an Unfamiliar Coding Problem
A repeatable process for problems you have never seen: restate the task, pin down constraints, work examples by hand, start simple, then improve and test.
Every engineer regularly meets problems they have not seen before: an interview question, a bug in a codebase they didn’t write, a feature that doesn’t fit any existing pattern. Progress on those problems rarely comes from a sudden flash of insight. It comes from a process that turns a vague task into a series of small, checkable steps.
The process below is an adaptation, for code, of the four phases the mathematician George Pólya described in How to Solve It: understand the problem, devise a plan, carry out the plan, and look back.
Restate the problem in your own words
Before writing any code, write one or two sentences that describe what the program must do, in your own words and without the jargon of the original prompt. If you can’t, you don’t understand the problem yet, and code written now will solve the wrong one.
Answer three questions explicitly:
- What exactly is the input? Its type, its size, and whether it can be empty.
- What exactly is the output? A value, a list, an index, a boolean — and what to return when there is no answer.
- What counts as correct? Any valid answer, or one specific answer? Does order matter?
In an interview, say your restatement aloud and ask the interviewer to confirm it. At work, write it in the ticket or the pull request description. Either way, misunderstandings surface before they cost you an hour.
Pin down the constraints
Constraints tell you which solutions are even possible, and input size is usually the most important one. If a list can hold 100,000 numbers, an algorithm that compares every pair does about 5 × 10⁹ comparisons — far too slow — while sorting it takes on the order of 100,000 × log₂ 100,000 ≈ 1.7 million steps.
As a rough guide (the real limits depend on the language, the machine and the time allowed):
| Input size n | Approaches that usually fit | Steps at the upper end |
|---|---|---|
| up to about 10 | Trying every ordering, O(n!) | 10! ≈ 3.6 million |
| up to about 1,000 | Comparing every pair, O(n²) | 1,000² = 1 million |
| up to about 1,000,000 | One pass or a sort, O(n) or O(n log n) | about 20 million for n log n |
Look for the other constraints too: the range of values (can a sum overflow a fixed-size integer?), whether the input can contain duplicates or negative numbers, whether it arrives sorted, and how much memory you have.
Work through examples by hand
Take the example in the prompt and compute its output on paper, step by step. Then invent your own examples:
- the smallest possible input, and an empty one;
- inputs with duplicates, negative numbers or zero;
- the largest values allowed.
Write down the expected output for each before you write code. These become your first test cases. Working examples by hand also tends to reveal the algorithm: the steps you take on paper are a first draft of the program.
Start with a simple, correct solution
Here is a running example. Given a list of integers and a target, return True if two different elements of the list add up to the target.
The most direct solution checks every pair:
def has_pair_with_sum(numbers, target):
"""Return True if two different elements of numbers add up to target."""
for i in range(len(numbers)):
for j in range(i + 1, len(numbers)):
if numbers[i] + numbers[j] == target:
return True
return False
For n numbers it makes n(n − 1) / 2 comparisons, so it runs in O(n²) time. That may be too slow for the real constraints, but it is short and obviously correct — which makes it valuable. A correct slow solution gives you something to test faster versions against, and in an interview it shows you can solve the problem before you optimise it.
Find the bottleneck and improve it
Ask what work the simple solution repeats. For each number x, the inner loop searches the rest of the list for target - x. Searching a list takes time proportional to its length, but checking whether a value is in a Python set takes constant time on average. So remember the numbers already seen, and look the complement up instead of searching for it:
def has_pair_with_sum(numbers, target):
"""Return True if two different elements of numbers add up to target."""
seen = set()
for x in numbers:
if target - x in seen:
return True
seen.add(x)
return False
This makes one pass over the list: O(n) time on average, using O(n) extra memory for the set. The order of the two lines inside the loop matters. Checking before adding means a single element is never paired with itself, so has_pair_with_sum([5], 10) is False, while has_pair_with_sum([5, 5], 10) is True.
Most improvements come from a short list of ideas:
- Look things up instead of searching — a hash set or dictionary.
- Sort first, so binary search or two pointers become possible. For this problem, sorting and walking two pointers inwards gives O(n log n) time without a set.
- Precompute what you would otherwise recompute, such as running totals.
- Remember results of overlapping subproblems (memoisation and dynamic programming).
Test as if you are trying to break it
Run the examples you wrote down earlier, especially the edge cases: an empty list, a single element, duplicates, negative numbers and a target of zero.
Then use the simple solution as a reference. Generate many small random inputs and check that both versions agree:
import random
def has_pair_brute_force(numbers, target):
return any(
numbers[i] + numbers[j] == target
for i in range(len(numbers))
for j in range(i + 1, len(numbers))
)
for _ in range(1000):
numbers = [random.randint(-10, 10) for _ in range(random.randint(0, 8))]
target = random.randint(-20, 20)
assert has_pair_with_sum(numbers, target) == has_pair_brute_force(numbers, target), (numbers, target)
print("All random cases agree.")
Small value ranges make collisions and duplicates likely, which is where bugs hide. When an assertion fails, it prints the exact input that broke the fast version.
When you are stuck
Being stuck is a normal part of the process, not a sign that you are bad at it. Some ways to get moving again:
- Solve a smaller or more specific version. What if the list were sorted? What if it had only two elements?
- Change the representation. Draw the data, make a table of intermediate values, or sketch it as a graph.
- Explain it out loud. The Pragmatic Programmer calls this “rubber ducking”: explaining the code, line by line, to someone — or something — that cannot help, often reveals the flaw.
- Name the pattern, not the answer. “This is a shortest-path problem” or “these are overlapping intervals” points you to techniques you can look up.
- Set a time limit, then ask. When you ask for help, say what you are trying to do, what you tried, what you expected and what happened instead.
Look back
Once the solution works, spend five more minutes on it. Can it be simpler? What was the key idea — here, “look up the complement instead of searching for it”? Where else would that idea apply?
Pólya observed that even good students tend to close the book as soon as they have an answer, and miss one of the most instructive parts of the work. Looking back is what turns a single solved problem into a technique you keep. Keeping a short log of problems and the idea that cracked each one makes the next unfamiliar problem a little more familiar.
A checklist to keep nearby
- Restate the problem and confirm it.
- List inputs, outputs and constraints, especially the input size.
- Work small examples and edge cases by hand; write down the expected outputs.
- Write a simple, correct solution.
- Find the repeated work and remove it.
- Test with your examples, then against the simple solution on random inputs.
- Look back: what was the key idea?
References and further reading
- How to Solve It, George Pólya, Princeton University Press, 1945
- The Pragmatic Programmer, 20th Anniversary Edition, David Thomas and Andrew Hunt
- TimeComplexity, Python Wiki