Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Seminar 2: Python Basics I — Control Structures


Learning Objectives

By the end of this seminar you will be able to:

  • Understand what control flow is and why algorithms depend on it

  • Write if/elif/else branches and reason about truth tables

  • Use for loops with range(), enumerate(), and zip()

  • Use while loops safely, knowing when to prefer them over for

  • Control loop execution with break, continue, and pass

  • Recognise nested-loop complexity (informally: O(n²))

  • Write basic list comprehensions


Part 1: Theory

2.1 What Is Control Flow?

By default, Python executes statements top to bottom, one after another. Control flow changes that default order: it lets the program make decisions, repeat actions, and skip over code.

Every algorithm is ultimately just:

  1. Sequence — do A, then B, then C

  2. Selection — if condition X, do A; otherwise do B

  3. Iteration — repeat A until condition Y is met

These three constructs are all you need to express any computable function (this is called Turing completeness).

Why does it matter for algorithms?

The structure of your control flow directly determines:

  • Correctness — does the algorithm produce the right answer?

  • Efficiency — how many operations does it perform?

For example, linear search scans every element (one loop), while binary search halves the search space each time (loop + branch). Same problem, radically different performance.

Control Flow at a Glance
─────────────────────────────────────────────────
Sequence:     A → B → C
Selection:    condition? → A (yes) / B (no)
Iteration:    condition? → A → back to condition
─────────────────────────────────────────────────

2.2 if / elif / else — Branching

Syntax

if condition_1:
    # executed when condition_1 is True
elif condition_2:
    # executed when condition_1 is False AND condition_2 is True
else:
    # executed when ALL conditions above are False
  • The elif and else clauses are optional.

  • You can have multiple elif clauses.

  • Conditions are any expression that evaluates to a truthy or falsy value.

Truthiness in Python

Use this quick reference to understand which values Python treats as False and which ones it treats as True in conditions.

Falsy (treated as False)Truthy (treated as True)
False, NoneTrue
0, 0.0Any non-zero number
"", ''Any non-empty string
[], (), {}, set()Any non-empty container

Comparison operators

These operators let you compare values and form conditions that drive branching logic.

OperatorMeaning
==Equal to
!=Not equal to
<, >Less / greater than
<=, >=Less / greater than or equal
isIdentity (same object in memory)
inMembership test

Beginner tip: use == to compare values (e.g. x == 5). Use is for identity checks (most commonly value is None).

Boolean operators: truth table

This truth table summarizes how and, or, and not combine or invert boolean values.

ABA and BA or Bnot A
TTTTF
TFFTF
FTFTT
FFFFT
# --- Basic if/elif/else ---

def classify_grade(score):
    """Return a letter grade for a score in [0, 100]."""
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    elif score >= 60:
        return "D"
    else:
        return "F"


scores = [95, 83, 71, 64, 50, 100, 0]
for s in scores:
    print(f"Score {s:>3} → Grade {classify_grade(s)}")

print()

# --- Chained comparisons (Python-specific, very readable) ---
x = 15
if 10 <= x <= 20:   # equivalent to: 10 <= x and x <= 20
    print(f"{x} is between 10 and 20 (inclusive)")

# --- Ternary / conditional expression (one-liner if/else) ---
temperature = 22
weather = "warm" if temperature >= 20 else "cold"
print(f"Temperature {temperature}°C is {weather}")
# --- Interactive demo: which branch executes? ---
import ipywidgets as widgets
from IPython.display import display, HTML


def show_branch(number):
    """Show which if/elif/else branch is taken for a given number."""
    output_lines = [f"<b>Input number: {number}</b><br>"]
    output_lines.append("<pre style='font-family:monospace; font-size:13px;'>")

    # We manually trace through each condition
    if number > 0:
        branch = "POSITIVE"
        colour = "#2ecc71"
    elif number < 0:
        branch = "NEGATIVE"
        colour = "#e74c3c"
    else:
        branch = "ZERO"
        colour = "#3498db"

    # Build a visual "code trace"
    conditions = [
        (f"if {number} > 0:",     number > 0),
        (f"elif {number} < 0:",   number < 0),
        ("else:",                 not (number > 0 or number < 0)),
    ]
    for code_line, taken in conditions:
        marker = " ← TAKEN" if taken else ""
        style = f"color:{colour}; font-weight:bold;" if taken else "color:#888;"
        output_lines.append(
            f"<span style='{style}'>{code_line}{marker}</span><br>"
        )

    output_lines.append("</pre>")
    output_lines.append(
        f"<p style='color:{colour}; font-size:15px;'>Result: <b>{branch}</b></p>"
    )
    display(HTML("".join(output_lines)))


slider = widgets.IntSlider(
    value=0, min=-20, max=20, step=1,
    description="Number:",
    style={"description_width": "initial"},
    layout=widgets.Layout(width="55%")
)

out = widgets.interactive_output(show_branch, {"number": slider})
display(widgets.Label("Move the slider and watch which branch is taken:"), slider, out)

2.3 for Loops — Definite Iteration

A for loop iterates over any iterable (list, string, range, dict, file, …). You know in advance (conceptually) how many iterations will occur.

range()

CallValues produced
range(5)0, 1, 2, 3, 4
range(2, 7)2, 3, 4, 5, 6
range(0, 10, 2)0, 2, 4, 6, 8
range(5, 0, -1)5, 4, 3, 2, 1

enumerate() — loop with index

Use enumerate() instead of range(len(...)) — it is more Pythonic and less error-prone.

# BAD (C-style, avoid in Python):
for i in range(len(items)):
    print(items[i])

# GOOD:
for i, item in enumerate(items):
    print(i, item)

zip() — loop over multiple iterables

names = ["Alice", "Bob"]
scores = [95, 87]
for name, score in zip(names, scores):
    print(f"{name}: {score}")
# --- range() examples ---
print("range(5):",        list(range(5)))
print("range(2, 7):",     list(range(2, 7)))
print("range(0,10,2):",   list(range(0, 10, 2)))
print("range(5,0,-1):",   list(range(5, 0, -1)))

print()

# --- Iterating over a list ---
fruits = ["apple", "banana", "cherry", "date"]

print("Direct iteration:")
for fruit in fruits:
    print(f"  {fruit}")

print()

# --- enumerate() ---
print("With enumerate():")
for index, fruit in enumerate(fruits):
    print(f"  [{index}] {fruit}")

# enumerate() with a start index
print("\nStarting from index 1:")
for rank, fruit in enumerate(fruits, start=1):
    print(f"  #{rank}: {fruit}")

print()

# --- zip() ---
names = ["Alice", "Bob", "Carol"]
scores = [95, 87, 72]
grades = ["A", "B", "C"]

print("With zip():")
for name, score, grade in zip(names, scores, grades):
    print(f"  {name:>6}: {score} ({grade})")

print()

# --- Iterating over a string (strings are iterables!) ---
word = "Python"
print(f"Letters in '{word}': ", end="")
for ch in word:
    print(ch, end=" ")
print()

# --- Iterating over a dict ---
capitals = {"France": "Paris", "Japan": "Tokyo", "Brazil": "Brasília"}
print("\nCountry capitals:")
for country, capital in capitals.items():
    print(f"  {country}: {capital}")

2.4 while Loops — Indefinite Iteration

Use a while loop when you do not know in advance how many iterations you need — only the stopping condition.

Core: understand the basic counter-style while loop first.

Bonus: Collatz and Newton’s method are enrichment examples; skip on first pass if needed.

while condition:
    # body — runs as long as condition is True

When to choose while over for

SituationUse
Known number of iterationsfor
Iterating over a collectionfor
Waiting for user input / eventwhile
Algorithm converges (e.g. Newton’s method)while
Game loopwhile

Danger: infinite loops

If the condition never becomes False, the loop runs forever. Always ensure:

  1. The loop variable is updated inside the body.

  2. The condition will eventually be met.

  3. You have a break as an emergency exit if needed.

import math

# --- CORE: Basic while loop ---
counter = 0
while counter < 5:
    print(f"  counter = {counter}")
    counter += 1   # IMPORTANT: increment to avoid infinite loop
print("Loop finished.")

print()

# --- BONUS: Collatz conjecture (enrichment) ---
# Start with any positive integer n.
# If n is even: divide by 2. If odd: multiply by 3 and add 1.
# Conjecture: it always reaches 1 (unproven for all numbers!).

def collatz(n):
    """Return the Collatz sequence starting at n."""
    sequence = [n]
    while n != 1:
        if n % 2 == 0:
            n = n // 2
        else:
            n = 3 * n + 1
        sequence.append(n)
    return sequence


for start in [6, 11, 27]:
    seq = collatz(start)
    print(f"Collatz({start}): {seq}")
    print(f"  Steps to reach 1: {len(seq) - 1}")

print()

# --- BONUS: Newton's method (enrichment) ---
# Uses while loop because convergence time is not known in advance.

def sqrt_newton(n, tolerance=1e-10):
    """Approximate sqrt(n) using Newton-Raphson iteration."""
    if n < 0:
        raise ValueError("Cannot take square root of a negative number")
    guess = n / 2.0       # initial guess
    iterations = 0
    while abs(guess * guess - n) > tolerance:
        guess = (guess + n / guess) / 2  # Newton step
        iterations += 1
    return guess, iterations



for num in [2, 9, 100, 12345]:
    approx, iters = sqrt_newton(num)
    print(f"sqrt({num:>5}): approx={approx:.8f}, math.sqrt={math.sqrt(num):.8f}, iters={iters}")

2.5 break, continue, pass

StatementEffect
breakExit the innermost loop immediately
continueSkip the rest of the current iteration; go to the next one
passDo nothing — a syntactic placeholder (empty block)

else clause on loops

Bonus: this pattern is useful, but optional on your first read.

Python has a unique feature: for/while loops can have an else clause that runs only if the loop completed normally (i.e., was not terminated by break).

for item in collection:
    if condition:
        break      # skip the else
else:
    # runs only if break was never hit
    print("No item matched")

This is very useful for search algorithms.

# --- break: stop the loop early ---
print("=== break ===")
for i in range(10):
    if i == 5:
        print(f"  Found 5! Breaking out of loop.")
        break
    print(f"  i = {i}")

print()

# --- continue: skip even numbers ---
print("=== continue (printing only odd numbers) ===")
for i in range(10):
    if i % 2 == 0:
        continue    # skip even
    print(f"  {i}")

print()

# --- pass: placeholder in an empty branch ---
print("=== pass ===")
for i in range(5):
    if i == 2:
        pass   # placeholder — do nothing for 2 (no error without a body)
    else:
        print(f"  Processing {i}")

print()

# --- for/else: prime checking ---
print("=== for...else: prime checking ===")

def is_prime(n):
    """Return True if n is prime, False otherwise."""
    if n < 2:
        return False
    for divisor in range(2, int(n ** 0.5) + 1):
        if n % divisor == 0:
            return False  # found a divisor — not prime
    return True            # loop completed without break → prime


primes = [n for n in range(2, 30) if is_prime(n)]
print(f"  Primes below 30: {primes}")

print()

# Version explicitly using for/else:
def is_prime_explicit(n):
    """Same as above but uses for...else explicitly to illustrate the pattern."""
    if n < 2:
        return False
    for divisor in range(2, int(n ** 0.5) + 1):
        if n % divisor == 0:
            break       # composite — break skips the else
    else:
        return True     # else only runs if no break occurred
    return False


test_nums = [2, 7, 9, 13, 25, 29]
for num in test_nums:
    print(f"  is_prime({num}) = {is_prime_explicit(num)}")

2.6 Nested Loops and Complexity

A loop inside another loop is called a nested loop. They are necessary for working with 2D data (matrices, grids) or for certain algorithms (bubble sort, matrix multiplication).

Core: focus on the multiplication-table nested loop example first.

Bonus: the growth-rate comparison (O(n), O(n log n), O(n²)) is conceptual enrichment.

Informally: O(n²)

If both the outer and inner loops run n times each, the total number of operations is approximately n × n = n². We write this as O(n²) (Big-O notation — covered formally later in the course).

nn (single loop)n² (nested loop)
1010100
10010010,000
1,0001,0001,000,000
10,00010,000100,000,000

This is why algorithms like bubble sort (O(n²)) become impractical for large inputs, while merge sort (O(n log n)) scales much better.

# --- CORE: Nested loop: 5×5 multiplication table ---
print("5 × 5 Multiplication Table:")
print("   ", end="")
for j in range(1, 6):
    print(f"{j:>4}", end="")
print()
print("   " + "-" * 20)

for i in range(1, 6):          # outer loop: rows
    print(f"{i:>2} |", end="")
    for j in range(1, 6):      # inner loop: columns
        print(f"{i * j:>4}", end="")
    print()   # newline after each row

print()

# --- BONUS: Count operations to visualise O(n²) growth ---
import matplotlib.pyplot as plt

ns = list(range(1, 51))
ops_linear = ns                          # O(n)
ops_quadratic = [n ** 2 for n in ns]     # O(n²)
ops_nlogn = [n * (n.bit_length()) for n in ns]  # rough O(n log n)

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(ns, ops_linear,    label="O(n)      — linear",    linewidth=2)
ax.plot(ns, ops_nlogn,     label="O(n log n) — merge sort", linewidth=2, linestyle="--")
ax.plot(ns, ops_quadratic, label="O(n²)     — nested loop", linewidth=2, linestyle=":")
ax.set_xlabel("Input size n")
ax.set_ylabel("Number of operations (approx.)")
ax.set_title("Growth of Algorithm Complexity", fontweight="bold")
ax.legend()
ax.set_xlim(1, 50)
ax.set_ylim(0)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()

2.7 List Comprehensions — Concise Iteration

A list comprehension creates a new list by applying an expression to each element of an iterable, optionally filtering with a condition:

Core: learn the plain form and the single if filter first.

Bonus: flattening patterns, dict/set comprehensions, and performance timing are optional for first pass.

[expression  for variable in iterable  if condition]

Equivalent to a for loop with append(), but more concise and often faster.

# Classic loop approach:
squares = []
for x in range(10):
    squares.append(x ** 2)

# List comprehension (equivalent, preferred in Python):
squares = [x ** 2 for x in range(10)]

Rule of thumb: if the comprehension fits on one line and is readable, use it. If it requires multiple lines or complex logic, use a regular loop.

# --- CORE: Basic list comprehension ---
squares = [x ** 2 for x in range(1, 11)]
print(f"Squares 1..10: {squares}")

# --- CORE: With condition (filter) ---
even_squares = [x ** 2 for x in range(1, 11) if x % 2 == 0]
print(f"Even squares:  {even_squares}")

# --- BONUS: Transforming strings ---
words = ["hello", "world", "python", "algorithmics"]
upper_long = [w.upper() for w in words if len(w) > 5]
print(f"Uppercased long words: {upper_long}")

# --- BONUS: Flattening a 2D list ---
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [elem for row in matrix for elem in row]
print(f"Flattened matrix: {flat}")

# --- BONUS: Dictionary comprehension ---
word_lengths = {word: len(word) for word in words}
print(f"Word lengths: {word_lengths}")

# --- BONUS: Set comprehension ---
unique_lengths = {len(word) for word in words}
print(f"Unique lengths: {unique_lengths}")

# --- BONUS: Performance note ---
# Comprehensions are generally faster than equivalent loops because
# the iteration is optimised at the C level in CPython.
import timeit

loop_time = timeit.timeit(
    "result = []\nfor x in range(1000):\n    result.append(x**2)",
    number=10000
)
comp_time = timeit.timeit(
    "result = [x**2 for x in range(1000)]",
    number=10000
)

print(f"\nPerformance (10000 runs of squaring 1000 elements):")
print(f"  for loop:          {loop_time:.4f}s")
print(f"  list comprehension:{comp_time:.4f}s")
print(f"  Speedup: {loop_time / comp_time:.2f}x")

Part 2: Exercises


Exercise 1: Maximum of Three Numbers

Write a function max_of_three(a, b, c) that returns the largest of three numbers using if/elif/else (do not use the built-in max()).

Examples:

  • max_of_three(3, 7, 5) -> 7

  • max_of_three(-2, -9, -1) -> -1

Hint: compare one candidate maximum step by step, then return it.

# Exercise 1 — Maximum of three numbers

def max_of_three(a, b, c):
    # TODO: implement with if/elif/else
    pass


# Quick checks
print(max_of_three(3, 7, 5))
print(max_of_three(-2, -9, -1))

Exercise 2: FizzBuzz

Print numbers from 1 to n, but:

  • Print "Fizz" for multiples of 3

  • Print "Buzz" for multiples of 5

  • Print "FizzBuzz" for multiples of both 3 and 5

  • Print the number itself otherwise

Example for n = 16 (start): 1, 2, Fizz, 4, Buzz, ... , 14, FizzBuzz, 16

Hint: use % (modulo) and check the combined case (i % 3 == 0 and i % 5 == 0) first.

Important: check the combined condition (FizzBuzz) first, otherwise it will never be reached.

# Exercise 2 — FizzBuzz

def fizzbuzz(n):
    # TODO: implement
    pass


# Quick check
fizzbuzz(16)

Exercise 3: Leap Year Checker

A year is a leap year if:

  • It is divisible by 4 AND

  • It is not divisible by 100, unless it is also divisible by 400.

Examples:

  • is_leap_year(2000) -> True

  • is_leap_year(1900) -> False

  • is_leap_year(2024) -> True

  • is_leap_year(2023) -> False

Hint: translate the rule into nested checks or into one combined boolean expression.

Write is_leap_year(year) and test it.

# Exercise 3 — Leap year checker

def is_leap_year(year):
    # TODO: implement
    pass


# Quick checks
for y in [2000, 1900, 2024, 2023]:
    print(y, is_leap_year(y))

Exercise 4: Interactive Calculator (while loop)

Build a calculator that repeatedly asks the user for two numbers and an operator, then prints the result. The loop continues until the user types quit.

Supported operations: +, -, *, /

Example interaction:

  • user enters +, 8, 3 -> output 11

  • user enters /, 8, 2 -> output 4.0

  • user enters quit -> program stops

Hint: use while True: and break when operator is quit. Handle division by zero with an if check.

# Exercise 4 — Interactive calculator with while loop

while True:
    op = input("Operator (+, -, *, /) or 'quit': ").strip()
    if op == "quit":
        print("Goodbye!")
        break

    # TODO: read two numbers and apply operation
    # a = float(input("First number: "))
    # b = float(input("Second number: "))
    # ...

Exercise 5: Multiplication Table

Write a function multiplication_table(n) that prints the multiplication table for a given number n (from 1 × n to 12 × n).

Example for n = 4 (start):

  • 1 x 4 = 4

  • 2 x 4 = 8

  • ...

  • 12 x 4 = 48

Hint: a for loop over range(1, 13) is enough.

Extension: print a full n × n grid.

# Exercise 5 — Multiplication table

def multiplication_table(n):
    # TODO: implement
    pass


# Quick check
multiplication_table(4)

Summary

ConceptWhen to use
if/elif/elseDecision making based on conditions
forKnown number of iterations / iterating over a collection
whileUnknown iterations, event-driven, convergence
breakExit loop early (search found / error)
continueSkip current item, continue with next
passSyntactic placeholder for empty blocks
List comprehensionConcise list creation from iteration

Next seminar: Data structures — lists, tuples, dictionaries, sets, functions, and classes.