Up to here you have trained each muscle separately: analysis (06-01), design (06-02) and optimization (06-03). The three projects in this lesson use them all at once: they are complete pieces of RutaBus, end to end, like the ones you would build on a real team.

How to work through this lesson: each project comes with context, requirements, a phased plan (with the course module each phase applies), a starter skeleton and a self-assessment checklist. Implement the whole project before looking at the reference solution, and use the checklist as the criterion for "done". The reference solution is not "the" solution: it is one correct, reasoned solution to contrast yours against. If your approach differs but meets the checklist, it is just as valid.

Contents

  1. Project 1: Trip planner (M4 + M2).
  2. Project 2: Fleet dashboard (M2 + M4 + M5).
  3. Project 3: Shift and reinforcement planner (M3).
  4. Course conclusion.

Project 1: Trip planner

Difficulty: medium · Integrates: Module 4 (Dijkstra, Floyd-Warshall) + Module 2 (analysis to decide).

Context. The RutaBus app needs to answer "how do I get from A to B?" while accounting for transfers: changing lines at a stop costs 5 minutes. The network:

  • L1: Main Square —3— North Station —4— Central Hospital —6— River Park
  • L2: North Station —5— Old Market —4— University —3— River Park
  • L3: Main Square —7— University —4— South Sports Center

(the numbers are minutes between consecutive stops; all lines run in both directions).

Functional requirements.

  1. Model the network as a weighted graph that captures the transfers.
  2. best_trip(origin, destination): total minutes and the complete itinerary (stops and line changes).
  3. Precompute the all-pairs time table with Floyd-Warshall.
  4. Decide, with complexity arguments, which approach the app should use in production.

Phased plan.

Phase Task Applies
1 Model: the trick is that the node is not the stop, but the pair (stop, line) 01-02, 05-02 (dict vs matrix)
2 Dijkstra with heap + path reconstruction 04-05
3 Floyd-Warshall over the same nodes 04-06
4 Compare cost/memory of both and decide M2, 05-01

Starter skeleton.

import heapq

LINES = {
    "L1": [("Main Square", 3), ("North Station", 4),
           ("Central Hospital", 6), ("River Park", 0)],
    "L2": [("North Station", 5), ("Old Market", 4),
           ("University", 3), ("River Park", 0)],
    "L3": [("Main Square", 7), ("University", 4), ("South Sports Center", 0)],
}
TRANSFER = 5   # minutes for changing lines at the same stop

def build_network():
    """Returns an adjacency dict: {(stop, line): {(stop, line): minutes}}."""
    # TODO: edges between consecutive stops of each line (both directions)
    # TODO: transfer edges between the lines that share a stop

def best_trip(network, origin, destination):
    """Returns (minutes, [(stop, line), ...]) or None if there is no path."""
    # TODO: multi-source Dijkstra (the traveler starts at the stop, on any line)

def floyd_warshall(network):
    """Returns (nodes, matrix) with the all-pairs minimum times."""
    # TODO

Self-assessment checklist.

  • [ ] A trip without a transfer beats one with a transfer when the minutes justify it (Main Square → University must give 7 min via L3, not 17 via L1+L2).
  • [ ] The returned itinerary shows where the line change happens.
  • [ ] Dijkstra uses a heap and does not revisit settled nodes (04-05).
  • [ ] Floyd-Warshall and Dijkstra give the same times for all pairs (test it with a loop!).
  • [ ] The final decision cites concrete complexities, not impressions.

Reference solution

Phase 1 — Modeling. If the node were just the stop, there would be no way to charge for the transfer: the graph wouldn't know which line you "are on". The standard solution is to split: the node is (stop, line), with travel edges within each line and transfer edges (5 min) between the lines that share a stop.

from collections import defaultdict

def build_network():
    network = defaultdict(dict)
    for line, stops in LINES.items():
        for (s1, w), (s2, _) in zip(stops, stops[1:]):
            network[(s1, line)][(s2, line)] = w     # outbound
            network[(s2, line)][(s1, line)] = w     # return
    by_stop = defaultdict(list)
    for node in list(network):
        by_stop[node[0]].append(node)
    for nodes in by_stop.values():                  # transfers
        for a in nodes:
            for b in nodes:
                if a != b:
                    network[a][b] = TRANSFER
    return dict(network)

We choose an adjacency dict and not a matrix because the network is sparse: each node has 2–4 neighbors, and as we saw in 05-02, the matrix only pays off in dense graphs.

Phase 2 — Dijkstra with reconstruction. An important detail: the traveler starts "at the stop", with no line assigned. Instead of inventing a virtual node, we seed the heap with every node (origin, line) at distance 0 (multi-source Dijkstra: same algorithm, several seeds):

def best_trip(network, origin, destination):
    starts = [n for n in network if n[0] == origin]
    dist = {n: 0 for n in starts}
    previous = {n: None for n in starts}
    heap = [(0, n) for n in starts]
    heapq.heapify(heap)
    settled = set()
    while heap:
        d, node = heapq.heappop(heap)
        if node in settled:
            continue
        settled.add(node)
        if node[0] == destination:                # first time we settle
            path = []                             #   the destination: it is optimal
            while node is not None:               #   (weights >= 0, 04-05)
                path.append(node)
                node = previous[node]
            return d, list(reversed(path))
        for neighbor, w in network[node].items():
            nd = d + w
            if neighbor not in dist or nd < dist[neighbor]:
                dist[neighbor] = nd
                previous[neighbor] = node
                heapq.heappush(heap, (nd, neighbor))
    return None

network = build_network()
print(best_trip(network, "Main Square", "University"))
# (7, [('Main Square', 'L3'), ('University', 'L3')])
print(best_trip(network, "Central Hospital", "University"))
# (14, [('Central Hospital', 'L1'), ('River Park', 'L1'),
#       ('River Park', 'L2'), ('University', 'L2')])

Look at the second example: the itinerary makes the transfer visible (River Park L1 → River Park L2, +5 min), and Dijkstra chose to transfer at River Park (6+5+3 = 14) and not at North Station (4+5+5+4 = 18). Being able to stop as soon as the destination is settled is the property from 04-05: with non-negative weights, a settled node already has its definitive distance.

Phase 3 — Floyd-Warshall. Over the same nodes, the version from 04-06:

def floyd_warshall(network):
    nodes = list(network)
    idx = {n: i for i, n in enumerate(nodes)}
    INF = float("inf")
    n = len(nodes)
    D = [[INF] * n for _ in range(n)]
    for i in range(n):
        D[i][i] = 0
    for a, neighbors in network.items():
        for b, w in neighbors.items():
            D[idx[a]][idx[b]] = w
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if D[i][k] + D[k][j] < D[i][j]:
                    D[i][j] = D[i][k] + D[k][j]
    return nodes, D

Phase 4 — Decision. Our network has V = 11 nodes: Floyd-Warshall does 11³ ≈ 1,300 operations and its matrix takes up 121 cells — all trivial. But the app aspires to real urban networks; let's scale with M2's analysis to V ≈ 5,000 nodes (2,000 stops, several lines):

Approach Cost Memory Query
Precomputed Floyd-Warshall Θ(V³) ≈ 1.25·10¹¹ ops (minutes or hours) Θ(V²) ≈ 200 MB O(1)
Dijkstra on demand Θ(V + E) (the graph) O((V+E) log V) ≈ milliseconds

Recommendation: Dijkstra on demand. A query in milliseconds is amply fast for a mobile app, the memory is minimal, and — decisively — the network changes (closures, reinforcements): recomputing the whole Θ(V³) for every incident is unviable, whereas Dijkstra always works on the current graph. Floyd-Warshall remains for small, static networks or for offline analysis (e.g. detecting the worst-connected stop pairs in the whole network, where you want the complete table anyway). And if one day queries dominate, the middle road is caching the frequent pairs — measuring first (05-01) will tell whether it is needed.

Project 2: Fleet dashboard

Difficulty: medium-high · Integrates: Module 2 (costs) + Module 4 (merge, top-k) + Module 5 (memory).

Context. At the end of the day, each bus dumps a CSV file of tap records, sorted by time: time;stop;line;amount (e.g. 07:41:03;North Station;L2;1.20). With hundreds of buses and millions of lines per day, operations wants a daily dashboard that currently blows up the server's memory.

Functional requirements.

  1. Read the files without loading them whole into memory.
  2. Compute in a single pass: tap records per line, tap records per stop and total revenue.
  3. Combine the k daily files (each already sorted by time) into a single chronological stream, also without materializing it.
  4. Get the top-10 stops by number of tap records without sorting all the stops.
  5. Justify the memory of each piece and the tool you would verify it with.

Phased plan.

Phase Task Applies
1 Ingestion with generators 05-02
2 Streaming aggregates with a dict (O(1) cost per access) 02-01
3 Chronological merge of k sorted streams (merge_k with a heap) 04-03, 04-05
4 Top-k with a heap (and why not quickselect here) 04-04
5 Verification: tracemalloc and timeit 05-01, 05-02

Starter skeleton.

import heapq

def read_tap_records(path):
    """Generator that yields tuples (time, stop, line, amount)."""
    # TODO: do not use readlines()

def summary(tap_records):
    """One single pass: (by_line, by_stop, revenue)."""
    # TODO

def merge_days(routes, route_out):
    """Merges k files sorted by time into one globally sorted file."""
    # TODO: O(k) memory, not O(n)

def top_stops(by_stop, k=10):
    """Top-k stops by tap records, without sorting them all."""
    # TODO

Self-assessment checklist.

  • [ ] read_tap_records can process a 10 GB file on an 8 GB machine (reason it out; you don't need the file).
  • [ ] summary makes one pass and no access inside the loop is O(n) (review 06-01, exercise 2).
  • [ ] merge_days keeps at most one record per open file in memory.
  • [ ] top_stops is Θ(p log k), not Θ(p log p).
  • [ ] You can state the theoretical memory peak of each function and how to confirm it.

Reference solution

Phases 1 and 2 — Ingestion and streaming aggregates.

def read_tap_records(path):
    with open(path, encoding="utf-8") as f:
        for record in f:                      # the file is already an iterator
            time, stop, line, amount = record.rstrip("\n").split(";")
            yield time, stop, line, float(amount)

def summary(tap_records):
    by_line, by_stop, revenue = {}, {}, 0.0
    for time, stop, line, amount in tap_records:
        by_line[line] = by_line.get(line, 0) + 1
        by_stop[stop] = by_stop.get(stop, 0) + 1
        revenue += amount
    return by_line, by_stop, revenue

Memory: the generator retains one record at a time (O(1) with respect to the file, 05-02); the accumulators grow with the number of distinct lines and stops (dozens), not with the millions of tap records. Each dict access is O(1) — with lists and in it would be the quadratic disaster of exercise 2 in 06-01. A 10 GB file passes through an 8 GB machine without flinching, because it is never in memory all at once.

Phase 3 — Chronological merge. It is the k-sorted-lists merge from 04-03 (merge_k), with the heap holding the minimum of the k heads. The standard library ships it ready-made, and it accepts lazy iterables:

def merge_days(routes, route_out):
    flows = [read_tap_records(r) for r in routes]
    with open(route_out, "w", encoding="utf-8") as out:
        for time, stop, line, amount in heapq.merge(*flows):
            out.write(f"{time};{stop};{line};{amount:.2f}\n")

Only k records live in memory (one per stream) plus the heap of size k: O(k), with k = number of buses, no matter if each file measures gigabytes. Time: Θ(n log k) for n total records. This is, literally, the merge phase of the external sorting we sketched in 04-03. (Fixed-width HH:MM:SS times compare fine as strings; if the format were variable, they would have to be converted to a comparable key.) And if an individual file arrived almost sorted but with a straggler? sorted() would repair it in near-linear time thanks to Timsort (04-02/04-03)… at the price of materializing it; for files that fit in memory it is the simple option, for those that don't, external sorting by chunks.

Phase 4 — Top-k.

def top_stops(by_stop, k=10):
    return heapq.nlargest(k, by_stop.items(), key=lambda kv: kv[1])

Θ(p log k) with p stops: for p = 2,000 and k = 10, about 2,000 comparisons against a heap of 10, versus the Θ(p log p) of sorting everything. And quickselect (04-04, k_earliest)? It is Θ(p) on average, asymptotically better… but it demands the complete list in memory and reorderable, it does not return the top sorted, and its worst case is Θ(p²). With small p and lazy streams, the heap is the natural tool; quickselect shines when p is huge, already in memory, and k is large.

Phase 5 — Verification. Theoretical memory peak: read_tap_records O(1), summary O(lines + distinct stops), merge_days O(k), top_stops O(k). Confirmation: tracemalloc around each phase with a synthetic file (05-02) and timeit for the times (05-01). If the real peak does not match the theoretical one, there is almost always a treacherous list(...) or sorted(...) materializing a generator — the number-one silent mistake in this style of code.

Project 3: Shift and reinforcement planner

Difficulty: high · Integrates: all of Module 3 (backtracking, greedy with justification, DP with reconstruction).

Context. Operations plans Saturday: cover the driver shifts, serve the saturation slots with the only reinforcement bus available, and decide which infrastructure upgrades to spend the annual budget on.

Functional requirements.

  1. Shifts: assign a driver to each of the 6 shifts (L1/L2/L3 × morning/night). Availability: Anna (morning and night), Bruno (morning only), Carla (night only), Diego (morning and night). A maximum of 2 shifts per driver and, due to mandatory rest, nobody may do morning and night on the same day. Backtracking with at least one pruning.
  2. Reinforcements: given the forecast saturation slots [(9, 11), (10, 12), (11, 13), (14, 15), (15, 17), (16, 18)] (hours), choose the maximum number of slots the reinforcement bus can cover without overlapping, justifying that the chosen criterion is optimal.
  3. Budget: with €10k, choose among a new bus shelter (€3k, benefit 4), information screens (€4k, benefit 5), wifi at stops (€2k, benefit 3) and a reserved lane (€7k, benefit 9), maximizing benefit. DP with a table and reconstruction of which upgrades to buy. Check whether the greedy by benefit/cost ratio would have gotten it right.

Phased plan.

Phase Task Applies
1 Shifts: choose → recurse → undo, with capacity pruning 03-04
2 Reinforcements: greedy criterion + exchange argument 03-02
3 Budget: 0/1 knapsack with table and reconstruction 03-03

Starter skeleton.

SHIFTS = [("L1", "Morning"), ("L1", "Night"), ("L2", "Morning"),
          ("L2", "Night"), ("L3", "Morning"), ("L3", "Night")]
AVAILABILITY = {"Anna": {"Morning", "Night"}, "Bruno": {"Morning"},
                "Carla": {"Night"}, "Diego": {"Morning", "Night"}}

def assign_shifts():
    """Dict shift -> driver, or None if there is no valid assignment."""
    # TODO: backtracking with pruning

def cover_slots(slots):
    """Maximum set of non-overlapping slots for the reinforcement bus."""
    # TODO: justified greedy

def upgrade_plan(upgrades, budget):
    """(max_benefit, list_of_chosen_upgrades)."""
    # TODO: DP with reconstruction

Self-assessment checklist.

  • [ ] assign_shifts always undoes its decisions when backtracking (test: call it twice; it must give the same result).
  • [ ] There is a pruning that cuts before descending a level, not a final check.
  • [ ] The greedy justification is an exchange argument, not "I tried it and it works".
  • [ ] upgrade_plan returns what to buy, not just how much benefit.
  • [ ] You have compared DP against the greedy by ratio and know which one wins and why.

Reference solution

Phase 1 — Shifts with backtracking.

def assign_shifts():
    assignment, shifts_of = {}, {d: [] for d in AVAILABILITY}

    def can_take(driver, slot):
        if slot not in AVAILABILITY[driver]:
            return False
        if len(shifts_of[driver]) >= 2:
            return False
        previous_slots = {s for _, s in shifts_of[driver]}
        return not (previous_slots and slot not in previous_slots)  # rest rule

    def solve(i):
        if i == len(SHIFTS):
            return True
        line, slot = SHIFTS[i]
        # PRUNING: is there enough capacity left for this slot?
        pending = sum(1 for l, s in SHIFTS[i:] if s == slot)
        capacity = sum(2 - len(shifts_of[d]) for d in AVAILABILITY
                       if can_take(d, slot))
        if capacity < pending:
            return False
        for d in AVAILABILITY:
            if can_take(d, slot):
                assignment[(line, slot)] = d             # choose
                shifts_of[d].append((line, slot))
                if solve(i + 1):
                    return True
                shifts_of[d].pop()                       # undo
                del assignment[(line, slot)]
        return False

    return dict(assignment) if solve(0) else None

print(assign_shifts())
# {('L1','Morning'): 'Anna', ('L1','Night'): 'Carla', ('L2','Morning'): 'Anna',
#  ('L2','Night'): 'Carla', ('L3','Morning'): 'Bruno', ('L3','Night'): 'Diego'}

The pattern is that of assign_shifts from 03-04: choose, recurse, undo. The rest rule is implemented as an incremental constraint (the driver's previous slots must match the new one), and the capacity pruning cuts the branch when the pending shifts of a slot exceed the remaining capacity of the drivers who can still take it — the impossibility is detected levels before stumbling into it. Look at the solution: Anna and Carla absorb the "double" mornings and nights and nobody mixes slots.

Phase 2 — Reinforcements with justified greedy. It is structurally the activity selection from 03-02: same value per slot, maximize how many fit. Criterion: always cover the slot that ends earliest among the compatible ones.

def cover_slots(slots):
    chosen, current_end = [], float("-inf")
    for start, end in sorted(slots, key=lambda x: x[1]):
        if start >= current_end:
            chosen.append((start, end))
            current_end = end
    return chosen

print(cover_slots([(9, 11), (10, 12), (11, 13), (14, 15), (15, 17), (16, 18)]))
# [(9, 11), (11, 13), (14, 15), (15, 17)]  -> 4 slots

Justification (exchange): let g be the first slot the greedy chooses (the one with the earliest end) and O any optimal solution. The first slot of O ends at some f ≥ end(g); if we replace that slot with g, no later slot of O overlaps (they all start after f ≥ end(g)), so O remains valid and of the same size. Iterating the argument over the rest, the greedy reaches the optimal size. Cost: Θ(n log n) for the sort. Note that "start with the longest" or "the one that starts earliest" both fail — (9,11),(10,12),(11,13) is a counterexample to the second if (10,12) is chosen.

Phase 3 — Budget with DP and reconstruction. 0/1 knapsack (03-03) with capacity 10:

def upgrade_plan(upgrades, budget):
    n = len(upgrades)
    dp = [[0] * (budget + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        name, cost, benefit = upgrades[i - 1]
        for c in range(budget + 1):
            dp[i][c] = dp[i - 1][c]                       # don't buy
            if cost <= c:
                dp[i][c] = max(dp[i][c], dp[i - 1][c - cost] + benefit)
    chosen, c = [], budget                                 # reconstruction
    for i in range(n, 0, -1):
        if dp[i][c] != dp[i - 1][c]:                       # upgrade i was bought
            name, cost, _ = upgrades[i - 1]
            chosen.append(name)
            c -= cost
    return dp[n][budget], list(reversed(chosen))

UPGRADES = [("bus shelter", 3, 4), ("screens", 4, 5),
            ("wifi", 2, 3), ("reserved lane", 7, 9)]
print(upgrade_plan(UPGRADES, 10))
# (13, ['bus shelter', 'reserved lane'])

Optimum: bus shelter + reserved lane = €10k and benefit 13. The greedy by benefit/cost ratio (wifi 1.5 → bus shelter 1.33 → lane 1.29 → screens 1.25) would buy wifi + bus shelter (€5k) and then screens (€9k in total), benefit 12, and can no longer afford the lane: it falls one short of the optimum. The failure is the usual one (03-02): the local criterion fills the budget with "efficient" pieces that block the good combination — exactly what motivated upgrade_knapsack in 03-03. The reconstruction (comparing dp[i][c] with dp[i-1][c] backwards) is the part real projects do not forgive: the purchasing department is not satisfied with "the maximum benefit is 13"; it needs the list.

Course Conclusion

The syllabus is done. It is worth looking back and seeing the whole building:

  • In Module 1 you laid the foundations: what an algorithm is, how to express it (pseudocode, diagrams, Python), the difference between iterative and recursive, between exact and heuristic, and the language that runs through it all: asymptotic notation and its hierarchy, from O(1) to O(2ⁿ).
  • In Module 2 you learned to analyze: deriving time complexity line by line (Python's hidden costs included), measuring auxiliary and total space, and refining with best case, worst case, average and amortized cost.
  • In Module 3 you learned to design: divide and conquer, greedy (and its obligation to prove or refute), dynamic programming and backtracking with pruning — and, above all, to diagnose which one each problem calls for.
  • In Module 4 you took on the classics — binary search, insertion sort, merge sort, quicksort, Dijkstra, Floyd-Warshall — not as recipes, but as pieces whose workings and limits you understand and know how to adapt.
  • In Module 5 you learned to optimize with method: measure first, attack in order (algorithm → code → memory → parallelize) and know when to stop.
  • And in this Module 6 you trained it all together, right up to building complete pieces of RutaBus: a trip planner, a dashboard that processes gigabytes without breaking a sweat, and a planner for shifts, reinforcements and budget.

What you can do now, said plainly: look at code and see its cost; look at a problem and recognize its strategy; look at a slow system and know where to start. That triad — analyze, design, optimize — cuts across any language and any domain: today it was Python and a bus app; tomorrow it will be another stack and another business, and the reasoning will be the same.

Suggestions for growing from here:

  • Advanced data structures: balanced trees, tries, hash tables from the inside, union-find, graphs with A* — they are the natural complement to this course, because many "better algorithms" are really "better structures".
  • Deliberate practice: problem platforms (LeetCode, Codeforces, Advent of Code) keep the diagnosis skill from exercise 5 of 06-02 in shape; a few per week are enough.
  • Your own projects: the next time you write a loop over real data, run the 06-01 analysis on it before hitting run. That habit, sustained for a few months, is what turns the content of this course into instinct.
  • Reading: when you want formal depth, the usual reference texts (Cormen et al., Introduction to Algorithms; Skiena, The Algorithm Design Manual) rigorously expand everything presented here with a practical focus.

The fluency we promised at the start of this module never fully arrives — there is always a problem that resists — but it no longer starts from zero: it starts from a method. Thank you for coming this far, and enjoy the ride. At RutaBus they would say: end of the line; we hope it has been a good trip.

© Copyright 2026. All rights reserved