Filter concepts by levelShowing all levels.

Data Structures & Algorithms · Section 1

How to Think About a Problem

Level
beginner
Read
45 min
Concepts
8

The habits that decide whether a solution is designed or guessed at: restating the problem precisely, sizing it against its constraints, working a small example by hand, writing a brute force before optimizing, and tracing your own solution before trusting it.

What is true here

  1. A precise restatement fixes exact input/output types and every edge case before any code exists.
  2. n, the stated input size, tells you which complexity classes are even allowed.
  3. A brute-force solution is a trusted reference answer, not a rough draft to be embarrassed by.
  4. Tracing your own solution against a hand-worked example catches bugs a single passing run hides.

What you will be able to do

  • Turn a vague prompt into a precise function contract
  • Read a stated input size and name which complexity classes are ruled out
  • Write a brute-force solution first and use it to check a faster rewrite
  • Trace a solution line by line against a small example before trusting it

Before you write any code

Turning a prompt into a plan — before a single line of code exists.

Restate the problem in your own words

standardbeginner

Before writing any code, turn the prompt into a precise contract: exact inputs, exact output, and what happens on ties or missing matches. A vague restatement hides decisions you will otherwise make by accident while coding.

Think of it as

A vague prompt is a contract with blank clauses. 'Find two prices that add up to a budget' does not say what to return if several pairs work, what to return if none do, or whether the same price can be used twice. Restating the problem means filling in every blank BEFORE you write a line of code, so the function's behavior is a decision you made on purpose, not one the code happened to fall into.

python
def two_sum_indices(prices: list[int], target: int) -> tuple[int, int] | None:
    """Return the (i, j) indices, i < j, of the first pair of prices
    summing to target. Return None if no such pair exists."""

What we're doing: Turn "find two prices that add up to a budget" into a precise, checkable contract.

restate.pypython
def two_sum_indices_precise(prices: list[int], target: int) -> tuple[int, int] | None:
    """Return the (i, j) indices, i < j, of the first pair of prices summing to target; None if no pair exists."""


print(two_sum_indices_precise.__doc__)
1
The type hints alone answer two questions the vague prompt left open: prices is a list of numbers, and the answer is a pair of indices, not a pair of prices.
2
The docstring answers the remaining two: which pair, if several match (the first found, by index order), and what "no pair" means (None, not an exception).
Output
Return the (i, j) indices, i < j, of the first pair of prices summing to target; None if no pair exists.

Why this works: Every word in the docstring closes a gap the original prompt left open. Writing the contract down, before any implementation exists, means the first time you discover 'what if there are two valid pairs?' is now — a five-second decision — instead of mid-debugging, when it looks like a bug in code that is actually behaving exactly as (accidentally) written.

Remember: Write the restated problem as a function signature and docstring — if you cannot state the exact return type and edge-case behavior, you do not understand the problem yet.

Identify input size and constraints

standardbeginner

A problem's stated limit on n (the input size) tells you which time complexities will actually finish in time. n up to 10^5 usually rules out an O(n^2) solution; n up to 20 usually means even O(2^n) is fine.

Think of it as

A judge or interviewer rarely states "your solution must be O(n log n)" directly — they state n, and expect you to work the complexity back out from it. Roughly a billion simple operations run in about a second, so n and the time limit together bound how expensive your algorithm is allowed to be.

python
def choose_approach(n: int) -> str:
    if n <= 20:
        return "any correct approach is fine, even O(2^n)"
    if n <= 10_000:
        return "O(n^2) is safe"
    return "need O(n log n) or better"

What we're doing: Map three different stated input sizes to the complexity class each one permits.

constraints.pypython
def choose_approach(n: int) -> str:
    if n <= 20:
        return "any correct approach is fine, even O(2^n)"
    if n <= 10_000:
        return "O(n^2) is safe"
    return "need O(n log n) or better"


print(choose_approach(15))
print(choose_approach(5_000))
print(choose_approach(200_000))
9
n = 200,000 rules out O(n^2) — 200,000^2 is 4 * 10^10 operations, far past what runs in a few seconds — so the constraint alone tells you a hash map, two pointers, or sorting-based approach is expected before you write anything.
Output
any correct approach is fine, even O(2^n)
O(n^2) is safe
need O(n log n) or better

Why this works: The same problem statement can have three very different intended solutions depending only on the stated n — reading the constraint first tells you which one to look for, instead of designing an approach and discovering it times out.

Remember: Read n before you design an approach — it tells you which complexity classes are even allowed, before you write a line of code.

See also: brute force first

Work a small example by hand first

corebeginner

Before writing code, manually work through a small, concrete example — 3 or 4 elements, not 3 or 4 thousand. Doing this by hand exposes the actual steps your code will need before you have committed to any of them in syntax.

Think of it as

Code is a claim about a process; a hand-traced example is the first test of that claim, run before any code exists. If you cannot walk a 4-element list to the answer on paper, you do not yet have a process to translate into code — you would just be guessing at syntax.

What we're doing: Hand-trace a brute-force search for two prices summing to a target, printing every pair checked.

trace_by_hand.pypython
def two_sum_brute_force_traced(prices, target):
    for i in range(len(prices)):
        for j in range(i + 1, len(prices)):
            print(f"   check prices[{i}]={prices[i]} + prices[{j}]={prices[j]} -> {prices[i] + prices[j]}")
            if prices[i] + prices[j] == target:
                return (i, j)
    return None


small = [7, 2, 9, 3]
result = two_sum_brute_force_traced(small, 10)
print("result:", result)
4
Printing every pair checked is the hand-trace made literal in code — this is exactly the table you would draw on paper before writing any of this function.
5
The trace stops the moment a pair sums to target — walking through it by hand first is what tells you the loop needs an early return, rather than checking every remaining pair for no reason.
Output
   check prices[0]=7 + prices[1]=2 -> 9
   check prices[0]=7 + prices[2]=9 -> 16
   check prices[0]=7 + prices[3]=3 -> 10
result: (0, 3)

Why this works: Four elements is small enough to check by hand in seconds — (7,2)=9, (7,9)=16, (7,3)=10 — matches, stop. Walking through it this way, before writing the function, is what tells you the algorithm needs two nested positions and an early exit; the code above is just that same trace, typed out.

Skipping the hand trace and guessing at the loop bounds

Wrong

python
def two_sum_guessed(prices, target):
    for i in range(len(prices)):
        for j in range(len(prices)):   # forgot: should start after i
            if prices[i] + prices[j] == target:
                return (i, j)
    return None

Better

python
def two_sum_traced(prices, target):
    for i in range(len(prices)):
        for j in range(i + 1, len(prices)):   # traced by hand: j must come after i
            if prices[i] + prices[j] == target:
                return (i, j)
    return None

What you see: two_sum_guessed([4, 5, 6], 8) returns (0, 0) — it pairs index 0 with itself (4 + 4 = 8), which is not a valid pair of two different prices.

Why: Without hand-tracing a small example first, it is easy to write `range(len(prices))` for both loops out of habit. Tracing [4, 5, 6] by hand for a target of 8 immediately raises the question 'can i and j be the same index?' — a question the guessed version never asked, and answered wrong by accident.

Remember: If you cannot trace your approach on a 4-element example by hand, you do not have an algorithm yet — you have a guess about syntax.

See also: trace through your solution · brute force first

Identify the brute-force solution before optimizing

corebeginner

Write the slow, obvious solution first — usually checking every possibility — before trying to make it fast. It gives you a correct answer to compare against, even if it is too slow to ship.

Think of it as

A brute force is a reference answer key, not a rough draft to be embarrassed by. It is almost always easier to see WHY a fast approach works once you can compare its output, pair by pair, against a brute force you already trust — optimizing without one means you can't tell a faster wrong answer from a faster right one.

What we're doing: Write the brute-force two-sum-indices solution, note its cost, and see why a faster rewrite needs it as a reference.

brute_force.pypython
def two_sum_brute_force(prices, target):
    comparisons = 0
    for i in range(len(prices)):
        for j in range(i + 1, len(prices)):
            comparisons += 1
            if prices[i] + prices[j] == target:
                return (i, j), comparisons
    return None, comparisons


print(two_sum_brute_force([7, 2, 9, 3, 1], 10))
1
Checking every pair is the brute force — O(n^2) comparisons in the worst case, but it is obviously correct: it never skips a pair that could match.
10
Every faster rewrite of this function (a hash-map one-pass, §5) can now be checked against this exact call and expected to return the same pair.
Output
((0, 3), 3)

Why this works: Three comparisons were needed to find that prices[0]=7 and prices[3]=3 sum to 10 — checking every pair in order guarantees the first valid pair by index is found, which becomes the exact behavior a faster rewrite must match to be considered correct, not just fast.

Writing the "optimized" version first, with no brute force to check it against

Wrong

python
def two_sum_broken_one_pass(prices, target):
    seen = set()
    for num in prices:
        if num in seen:
            return True
        seen.add(target - num)
    return False


print(two_sum_broken_one_pass([7, 2, 9, 3, 1], 10))

Better

python
def two_sum_brute_force(prices, target):
    for i in range(len(prices)):
        for j in range(i + 1, len(prices)):
            if prices[i] + prices[j] == target:
                return (i, j)
    return None


print(two_sum_brute_force([7, 2, 9, 3, 1], 10))  # (0, 3) — the reference to check against

What you see: two_sum_broken_one_pass returns True — a bare boolean, not the (i, j) pair the problem actually asked for, and there is nothing to compare it against because no brute force was written first.

Why: Jumping straight to a hash-set, one-pass version is tempting because it looks fast and clever, but without a brute force written first there is no reference answer to notice that this version answers a different question (does a pair exist?) than the one asked (which indices?). The brute force's ((0, 3), 3) result makes that mismatch obvious immediately; skipping it lets the wrong return shape ship unnoticed.

Remember: Write the obviously-correct brute force first — it is the reference answer every faster rewrite has to match, not just outrun.

See also: work a small example by hand · input size and constraints

Name the pattern the problem resembles

referencebeginner

Most problems are a known shape in disguise — a sorted-array pair search is two pointers, a "contiguous subarray satisfying X" is sliding window. Naming the pattern before coding tells you which technique to reach for.

Remember: State the pattern out loud before coding — "this is a sliding window problem" — even before you know the exact code, naming it narrows the search for how to solve it.

Decide on a data structure before writing code

referencebeginner

Choose the data structure a solution needs — list, set, dict, heap — as part of planning, not partway through typing the function. Switching midway is a sign the plan was never finished.

python
prices = [7, 2, 9, 3, 1]
seen_prices = set(prices)      # decided up front: membership checks need O(1), a list gives O(n)
print(9 in seen_prices, 9 in prices)   # True True — same answer, different cost

Remember: If you catch yourself mid-function realizing "this needs to be a set, not a list" — stop and finish the plan before continuing to type.

See also: work a small example by hand

Advertisement

While and after you code

Checking the plan actually held up once real code exists.

Consider edge cases

standardbeginner

Decide what your solution does on an empty input, a single-element input, duplicate values, and negative numbers — before you finish writing it, not after a test fails.

Think of it as

The main path of an algorithm is usually the easy 90%. Edge cases are the other 10% that decide whether it ships correct or ships with a landmine — an empty list, one element too few to form a pair, a duplicate that breaks an assumption of uniqueness.

What we're doing: Run a brute-force pair search against an empty list and a single-element list, without changing the function.

edge_cases.pypython
def two_sum_brute_force(prices, target):
    for i in range(len(prices)):
        for j in range(i + 1, len(prices)):
            if prices[i] + prices[j] == target:
                return (i, j)
    return None


print(two_sum_brute_force([], 10))
print(two_sum_brute_force([5], 10))
10
range(1) never reaches a j (there is no second element to pair with), so the inner loop body never runs for a single-element list — the same code path as an empty list, verified rather than assumed.
Output
None
None

Why this works: Both edge cases fall through to the same `return None` without a crash, but that is worth confirming, not assuming — a version indexing prices[0] before checking length would raise on the empty case instead.

Remember: Before calling a solution done, run it against empty input, a single element, duplicates, and negatives — the main path being correct says nothing about these.

Trace through your own solution before trusting it

corebeginner

After writing a solution, manually walk it through your own example, line by line, instead of just running it once and moving on. Tracing catches bugs that a single passing run can hide.

Think of it as

Running code once and seeing a plausible answer is not the same as knowing the code is correct — it only tells you this one input happened to work. Tracing means playing computer: stepping through each line with real values, the same discipline as the hand-trace in step 3, but now applied to code that already exists, to catch what "it ran without crashing" cannot.

What we're doing: Trace a two-sum-indices solution that looks reasonable, and catch a self-pairing bug a single run would miss if the test case were different.

trace_before_trusting.pypython
def two_sum_buggy(nums, target):
    for i in range(len(nums)):
        for j in range(len(nums)):
            if nums[i] + nums[j] == target:
                return (i, j)
    return None


print(two_sum_buggy([4, 5, 6], 8))
3
Tracing i=0, j=0 first: nums[0] + nums[0] == 4 + 4 == 8 == target — the function returns (0, 0) on its very first comparison, before ever comparing two DIFFERENT indices.
Output
(0, 0)

Why this works: A single run just sees "it returned something" and might not notice the (0, 0) is suspicious. Tracing forces you to state, out loud, what i and j hold at each step — and stating "we are pairing index 0 with itself" immediately reads as wrong for a two-DIFFERENT-prices problem.

Trusting a run that returned a plausible-looking value

Wrong

python
def two_sum_buggy(nums, target):
    for i in range(len(nums)):
        for j in range(len(nums)):          # bug: j should start after i
            if nums[i] + nums[j] == target:
                return (i, j)
    return None

Better

python
def two_sum_fixed(nums, target):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):   # traced: j must never equal i
            if nums[i] + nums[j] == target:
                return (i, j)
    return None


print(two_sum_fixed([4, 5, 6], 8))  # None — correctly, no valid pair exists

What you see: two_sum_buggy([4, 5, 6], 8) returns (0, 0), silently pairing an index with itself; two_sum_fixed on the same input correctly returns None, since no two DIFFERENT prices in [4, 5, 6] sum to 8.

Why: (0, 0) is a tuple of two numbers, exactly what a caller expects to see, so a quick glance at the output does not flag anything — only tracing which index values i and j actually held reveals they were equal. This is precisely why step 3's hand trace and this step are not the same thing done twice: one is done on paper before code exists, this one is done against the real, already-written function.

Remember: A single run that returns something plausible is not proof of correctness — trace i, j and every intermediate value against your own example before you trust the result.

See also: work a small example by hand

Advertisement