Module 3 · Control Flow & Logic ⏱ 14 min

Thinking Like an Engineer

By the end of this lesson you will be able to:
  • Decompose a problem into smaller, named steps before writing code
  • Choose a data structure based on how you will look things up
  • Reason about why a dict lookup beats scanning a list

You have the syntax now — variables, loops, functions, data structures, classes. Here is the part that lasts: how to think about a problem before you touch the keyboard.

In the age of AI, typing code is the cheap part. The durable edge is judgment — deciding what to build, how the pieces fit, and whether a result is correct. That judgment rests on three habits: decompose the problem, choose the right data structure, and keep an intuition for efficiency. Fundamentals plus this big-picture design work are exactly what AI tools cannot do for you.

flowchart LR
  P["Big problem"] --> D["decompose into steps"]
  D --> S["pick the right data structure"]
  S --> F["write one function per step"]
  F --> V["verify & measure"]
  style D fill:#3776ab,color:#fff
  style S fill:#3776ab,color:#fff
Engineering thinking: split the problem, pick structures, build small functions, then verify.

Decompose first

Take "analyze a block of text." Do not write one giant function that does everything. Split the job into steps you can name and test on their own:

  • count_words(text) — how often each word appears
  • top_word(freq) — given those counts, which word wins
  • report(...) — turn the answer into something a person can read

Each piece is small, obvious, and reusable elsewhere. If a step turns out to be hard, it becomes its own little sub-problem that you solve on its own. That act of breaking a blob of work into named pieces is decomposition, and it is the single highest-leverage habit in programming.

One function per step. Each could be tested on its own; together they form a report.
def count_words(text):
    freq = {}
    for word in text.split():
        freq[word] = freq.get(word, 0) + 1
    return freq

def top_word(freq):
    best = None
    for word, count in freq.items():
        if best is None or count > freq[best]:
            best = word
    return best

def report(text):
    freq = count_words(text)
    winner = top_word(freq)
    return winner + " appeared " + str(freq[winner]) + " times"

print(report("the cat the dog the bird the cat"))

Match the structure to the question

How you store data decides how fast you can answer questions about it, so pick the shape before you write the loop. The right container makes the question almost trivial:

  • Count how many times a word appeared? A dict mapping word to count answers immediately.
  • Ask whether something has been seen before? A set answers immediately.
  • Keep things in arrival order, with duplicates allowed? A list is built for exactly that.

Choosing well up front is far cheaper than rewriting later. A program that walks a ten-thousand-item list on every request feels slow for a reason no amount of clever code will fix.

flowchart LR
  Q["'fish' in collection?"] --> L["list: cat, dog, bird, fish ... checked one by one"]
  Q --> S["set: jump straight to the slot for 'fish'"]
  L --> R["same answer"]
  S --> R
  style L fill:#b45309,color:#fff
  style S fill:#3776ab,color:#fff
Same question, different cost: a list checks each item in turn, while a set hashes straight to the answer.

Why the structure changes the speed

The same question can be fast or slow depending on how you stored the data. Asking word in word_list makes Python scan every entry in turn until it finds a match — double the entries and you roughly double the work. People call that linear, or O(n).

Asking word in word_set or word in word_dict does something different: Python runs the key through a hash function to compute exactly which slot it lives in, then checks that one slot. Double the data and the work barely grows. That is constant, or O(1).

You do not need to memorize the notation — just feel the gap between a scan that grows and a lookup that barely notices.

A list and a set built from the same data. Both answer correctly; only the set is built for speed.
words = ["cat", "dog", "bird", "fish", "ant"]

word_list = words            # a list keeps every item in order
word_set = set(words)        # a set is built for fast membership checks

# Both answer the question correctly - was "fish" ever seen?
print("fish" in word_list)   # True, but the list checks each item in turn
print("fish" in word_set)    # True, and the set jumps straight to the answer

# A missing word is reported the same way by both
print("lion" in word_list)   # False
print("lion" in word_set)    # False

Verify, then trust nothing on faith

Decomposed functions are easy to check because each one is small. Once count_words works on its own, you can trust it inside report. Test the pieces: feed top_word a tiny frequency dict where you already know the winner, and confirm it returns the right key.

If it does not, the bug is local — not smeared across a hundred-line function you cannot hold in your head. This is why decomposition pays off twice: it makes code reusable and checkable. A function you cannot test in isolation is a function you will be afraid to change.

Exercise

You must check whether each of 10,000 IDs is valid, one lookup at a time. Which structure makes each lookup essentially instant (O(1))?

Exercise

Build a SeenTracker class that remembers words it has already processed, using a set so each check is O(1). .check(word) returns True if the word was seen before this call, and False if it is new — and it remembers new words.

class SeenTracker:
    def __init__(self):
        # a set to remember seen words
        pass

    def check(self, word):
        # return True if already seen, else remember it and return False
        pass
Exercise

This top_word(text) is meant to return the word that appears most often. It builds the counts correctly but then returns the first word instead of the most frequent one. Fix the return so it picks the word with the highest count.

def top_word(text):
    freq = {}
    for word in text.split():
        freq[word] = freq.get(word, 0) + 1
    # BUG: this returns the first word, not the most frequent one
    return text.split()[0]
Exercise

You have a list of (student, score) pairs from several rounds, and each student may appear many times. Write best_scores(records) that returns a dict mapping each student to their single highest score. Use a dict so each student is updated in one pass — do not sort.

def best_scores(records):
    # build a dict: student -> highest score seen so far
    pass

Recap

  • Decompose a big problem into small, named functions you can test one at a time.
  • Pick the container to fit the question: dicts count, sets track membership, lists keep order.
  • Hashing is why a dict or set finds a key without scanning, so get the shape right early.
  • Each small function is also a unit you can verify on its own, and that is what makes the whole program trustworthy.

Next you will assemble these habits into programs that read real input and produce real answers, and the choices you make here are what keep those programs fast as they grow.

Checkpoint quiz

Decomposing a problem means:

As a collection grows, how does membership in a dict or set compare to scanning a list?

Go deeper — technical resources