Python exercises

Practice tasks for the concepts in Python essentials. Each task lists the problem, then a worked solution.

Lists

Rotate a list k places to the right

rotate([1,2,3,4,5], 2) -> [4,5,1,2,3]. Slicing with negative indices: nums[-k:] is the tail, nums[:-k] the rest.

def rotate(nums, k):
    k = k % len(nums)          # handles k > len
    return nums[-k:] + nums[:-k]

Remove duplicates preserving order

list(set(...)) loses order; use a seen set for O(1) checks (or list(dict.fromkeys(items))).

def dedup(items):
    seen, out = set(), []
    for x in items:
        if x not in seen:
            seen.add(x); out.append(x)
    return out

Flatten a list of lists (one level)

The comprehension reads left-to-right like nested for loops.

def flatten(lists):
    return [item for sub in lists for item in sub]

Dicts

Group by first letter

defaultdict(list) creates the empty list on first access.

from collections import defaultdict
def group_by_letter(words):
    groups = defaultdict(list)
    for w in words:
        groups[w[0]].append(w)
    return dict(groups)

Invert a dict

def invert(d):
    return {v: k for k, v in d.items()}

Merge keeping the larger value

Dict keys behave like sets, so | gives the union; .get(k, 0) avoids KeyError.

def merge_max(d1, d2):
    return {k: max(d1.get(k, 0), d2.get(k, 0)) for k in d1.keys() | d2.keys()}

Strings

Palindrome (ignoring case and punctuation)

def is_palindrome(s):
    cleaned = "".join(c.lower() for c in s if c.isalnum())
    return cleaned == cleaned[::-1]

Most frequent character

from collections import Counter
def most_frequent(s):
    return Counter(s.replace(" ", "")).most_common(1)[0][0]

Functions

A @timer decorator

A decorator is a function that wraps another. @wraps preserves the original name/docstring.

import time
from functools import wraps
def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time()-start:.2f}s")
        return result
    return wrapper

Call a function with only the kwargs it accepts

inspect.signature introspection - the pattern behind dependency injection.

import inspect
def call_with_applicable_args(func, **kwargs):
    params = inspect.signature(func).parameters
    return func(**{k: v for k, v in kwargs.items() if k in params})

Classes

A simple stack

class Stack:
    def __init__(self): self._items = []
    def push(self, x): self._items.append(x)
    def pop(self):
        if not self._items: raise IndexError("pop from empty stack")
        return self._items.pop()
    def peek(self): return self._items[-1]
    def is_empty(self): return not self._items
    def size(self): return len(self._items)

__eq__ and __hash__

Objects that are equal must hash equal. Define __hash__ from the same fields used in __eq__; defining __eq__ without __hash__ makes the class unhashable.

class Build:
    def __init__(self, stream, arch, version):
        self.stream, self.arch, self.version = stream, arch, version
    def __eq__(self, o):
        if not isinstance(o, Build): return NotImplemented
        return (self.stream, self.arch) == (o.stream, o.arch)
    def __hash__(self):
        return hash((self.stream, self.arch))

Error handling

Retry with exponential backoff

A decorator factory - retry(...) returns the decorator, which returns the wrapper. Backoff doubles each attempt; the last attempt re-raises.

import time
from functools import wraps
def retry(max_attempts=3, base_delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == max_attempts: raise
                    time.sleep(base_delay * 2 ** (attempt - 1))
        return wrapper
    return decorator

Interview algorithms

Two sum (hash map, O(n))

For each number, ask “have I seen its complement?” - the dict answers in O(1).

def two_sum(nums, target):
    seen = {}
    for i, n in enumerate(nums):
        if target - n in seen: return [seen[target - n], i]
        seen[n] = i

Valid parentheses (stack)

def is_valid(s):
    stack, pairs = [], {")": "(", "]": "[", "}": "{"}
    for c in s:
        if c in "([{": stack.append(c)
        elif c in pairs:
            if not stack or stack.pop() != pairs[c]: return False
    return not stack

Merge intervals (sort + linear scan)

After sorting by start, overlapping intervals are adjacent.

def merge(intervals):
    intervals.sort(key=lambda x: x[0])
    merged = [intervals[0]]
    for start, end in intervals[1:]:
        if start <= merged[-1][1]:
            merged[-1][1] = max(merged[-1][1], end)
        else:
            merged.append([start, end])
    return merged

BFS shortest path in a grid

BFS guarantees the shortest path in an unweighted grid. Mark cells visited when adding to the queue, not when popping.

from collections import deque
def shortest_path(grid):
    if not grid or grid[0][0] == 1: return -1
    rows, cols = len(grid), len(grid[0])
    q, seen = deque([(0, 0, 1)]), {(0, 0)}
    while q:
        r, c, dist = q.popleft()
        if (r, c) == (rows - 1, cols - 1): return dist
        for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 0 \
                    and (nr, nc) not in seen:
                seen.add((nr, nc)); q.append((nr, nc, dist + 1))
    return -1

Binary search for a boundary (first >= target)

Record a candidate on a match and keep searching left. This is what bisect.bisect_left does internally.

def first_ge(nums, target):
    lo, hi, result = 0, len(nums) - 1, -1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] >= target:
            result = mid; hi = mid - 1
        else:
            lo = mid + 1
    return result

File I/O

Top words in a file

import re
from collections import Counter
def top_words(path, n=5):
    text = open(path).read().lower()
    return Counter(re.findall(r'[a-z]+', text)).most_common(n)

Parse a key=value config

split("=", 1) splits on the first = only, so values may contain =.

def parse_config(path):
    config = {}
    for line in open(path):
        line = line.strip()
        if not line or line.startswith("#"): continue
        key, value = line.split("=", 1)
        config[key.strip()] = value.strip()
    return config