Top 30 Python Coding Challenges with Solutions [2026]
September 29, 2026
9 min read
TL;DR: Python coding rounds in 2026 test the same core patterns as other languages: hash maps, sliding windows, heaps, BFS and dynamic programming. What sets Python answers apart is knowing the standard library, such as Counter, deque, heapq, bisect and functools.cache. Python 3.14, released on October 7, 2025, is the current version.
Python 3.14 added template strings, except clauses without brackets and max-heap functions in heapq, and it made the free-threaded build officially supported. None of that changes the classic interview problems.
It does change what a sharp candidate writes: fewer hand-rolled helpers, more standard library, and answers that say which Python version they assume.
Key takeaways
1The newest patch release is Python 3.14.7. Python 3.13, from October 7, 2024, added copy.replace() and a strict flag for itertools.batched().
2On the free-threaded 3.14 build, single-threaded code runs roughly 5 to 10% slower, per the 3.14 release notes, in exchange for real thread parallelism.
3CPython's default recursion limit is 1,000 frames, so recursive DFS on a large grid fails where an iterative BFS does not.
4Dicts have kept insertion order as a language guarantee since Python 3.7, which several solutions below rely on.
Beginner Challenges
These test strings, lists and dicts. Watch for the idiomatic version: slicing, comprehensions and collections instead of index loops.
1. Check whether a sentence is a palindrome.
Task: ignore case and anything that is not a letter or digit. "A man, a plan, a canal: Panama" returns True.
def is_palindrome(s: str) -> bool:
cleaned = [c.lower() for c in s if c.isalnum()]
return cleaned == cleaned[::-1]
O(n) time and O(n) memory. str.isalnum() is Unicode-aware, so accented letters count as letters. A two-pointer loop brings memory down to O(1); ask for it as a follow-up.
2. Return the k most common words in a text.
Task: "the cat and the hat and the bat" with k = 3 returns [('the', 3), ('and', 2), ('cat', 1)].
import re
from collections import Counter
def top_words(text: str, k: int = 3) -> list[tuple[str, int]]:
words = re.findall(r"[a-z']+", text.lower())
return Counter(words).most_common(k)
most_common orders ties by first appearance, which is why cat beats hat. Splitting on spaces alone would count "the," and "the" as different words.
3. Find the index of the first character that does not repeat.
from collections.abc import Iterator
def flatten(items: list) -> Iterator:
for item in items:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
# list(flatten([1, [2, [3, [4]], 5]])) returns [1, 2, 3, 4, 5]
A generator yields values lazily, so it never builds lists in between. The trap is checking for any iterable instead of list: a one-letter string is iterable and yields itself, so the recursion never ends.
6. Rotate a list to the right by k steps.
Task: [1, 2, 3, 4, 5, 6, 7] with k = 3 returns [5, 6, 7, 1, 2, 3, 4]. k may be larger than the list.
def rotate(nums: list[int], k: int) -> list[int]:
if not nums:
return []
k %= len(nums)
return nums[-k:] + nums[:-k]
The modulo handles k larger than the list and the empty check avoids dividing by zero. When k is 0, nums[-0:] is the whole list and nums[:-0] is empty, so the result is still correct. collections.deque.rotate(k) does the same in place.
Intermediate Challenges
Mid-level rounds are about naming the right pattern and the right standard library tool. A candidate who writes a manual binary search when bisect fits should at least say why.
7. Two Sum: find two indices whose values add up to a target.
def two_sum(nums: list[int], target: int) -> list[int]:
seen: dict[int, int] = {}
for i, n in enumerate(nums):
if target - n in seen:
return [seen[target - n], i]
seen[n] = i
return []
One pass, O(n). Checking before inserting stops a number from pairing with itself.
8. Check that brackets are balanced.
Task: "([]{})" returns True; "(]" and "((" return False.
def is_valid(s: str) -> bool:
pairs = {")": "(", "]": "[", "}": "{"}
stack = []
for c in s:
if c in pairs:
if not stack or stack.pop() != pairs[c]:
return False
else:
stack.append(c)
return not stack
A plain list is the idiomatic stack: append and pop at the end are O(1). The final not stack catches unclosed openers.
9. Group words that are anagrams of each other.
Task: ["eat", "tea", "tan", "ate", "nat", "bat"] returns three groups: eat/tea/ate, tan/nat and bat.
from collections import defaultdict
def group_anagrams(words: list[str]) -> list[list[str]]:
groups: defaultdict[str, list[str]] = defaultdict(list)
for w in words:
groups["".join(sorted(w))].append(w)
return list(groups.values())
O(n k log k) for n words of length k. The key must be hashable, which is why it is a string and not the list sorted() returns.
10. Find the longest substring without repeating characters.
Task: "abcabcbb" returns 3; "abba" returns 2.
def longest_unique(s: str) -> int:
last: dict[str, int] = {}
start = best = 0
for end, c in enumerate(s):
if last.get(c, -1) >= start:
start = last[c] + 1
last[c] = end
best = max(best, end - start + 1)
return best
A sliding window, O(n). The >= start check stops the window moving backwards on "abba", the input that breaks most first attempts.
def merge(intervals: list[list[int]]) -> list[list[int]]:
out: list[list[int]] = []
for start, end in sorted(intervals):
if out and start <= out[-1][1]:
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
O(n log n) for the sort. sorted() returns a new list and each output interval is new, so the caller's data is untouched.
12. Return the k most frequent values.
Task: [1, 1, 1, 2, 2, 3] with k = 2 returns [1, 2].
heapq.nlargest keeps a heap of size k, so this is O(n log k). Sorting all counts is O(n log n), which matters once there are millions of distinct values.
13. Find the first and last position of a value in a sorted list.
Task: [5, 7, 7, 8, 8, 10] with 8 returns [3, 4]; with 6 it returns [-1, -1].
from bisect import bisect_left, bisect_right
def search_range(nums: list[int], target: int) -> list[int]:
lo = bisect_left(nums, target)
if lo == len(nums) or nums[lo] != target:
return [-1, -1]
return [lo, bisect_right(nums, target) - 1]
Two binary searches, O(log n). bisect_left returns where the value would go, not whether it exists, so the check after it is required.
def rotate_matrix(m: list[list[int]]) -> list[list[int]]:
return [list(row) for row in zip(*m[::-1])]
Reverse the rows, then zip(*...) reads the columns. That is O(n2) and returns a new matrix. For an in-place version, transpose by swapping m[i][j] and m[j][i], then reverse each row.
15. Traverse a binary tree level by level.
Task: for the tree 3, then 9 and 20, then 15 and 7, return [[3], [9, 20], [15, 7]].
from collections import deque
def level_order(root: TreeNode | None) -> list[list[int]]:
if root is None:
return []
levels, queue = [], deque([root])
while queue:
level = []
for _ in range(len(queue)):
node = queue.popleft()
level.append(node.val)
queue.extend(child for child in (node.left, node.right) if child)
levels.append(level)
return levels
Breadth-first search, O(n). deque.popleft() is O(1), while list.pop(0) shifts every element and is O(n).
list
append and pop() at the end are fast
pop(0) and insert(0, x) are O(n)
O(1) indexing anywhere: the right stack
collections.deque
Appends and pops at either end are O(1)
Indexing slows to O(n) in the middle
The right queue for BFS
16. Count the islands in a grid.
Task: "1" is land and "0" is water. Land cells that touch up, down, left or right form one island.
from collections import deque
def num_islands(grid: list[list[str]]) -> int:
rows, cols = len(grid), len(grid[0])
islands = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] != "1":
continue
islands += 1
grid[r][c] = "0"
queue = deque([(r, c)])
while queue:
y, x = queue.popleft()
for ny, nx in ((y + 1, x), (y - 1, x), (y, x + 1), (y, x - 1)):
if 0 <= ny < rows and 0 <= nx < cols and grid[ny][nx] == "1":
grid[ny][nx] = "0"
queue.append((ny, nx))
return islands
O(rows x cols). A recursive DFS hits RecursionError once an island has more than about 1,000 cells in a chain. Raising the limit with sys.setrecursionlimit only moves the crash.
Advanced Challenges
Senior rounds add design, decorators, heaps and graphs. The follow-ups (thread safety, memory, what breaks at scale) matter more than the first draft.
17. Build an LRU cache with O(1) get and put.
Task: get returns -1 for a missing key; put evicts the least recently used key when the cache is full.
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity: int) -> None:
self.capacity = capacity
self.data: OrderedDict[int, int] = OrderedDict()
def get(self, key: int) -> int:
if key not in self.data:
return -1
self.data.move_to_end(key)
return self.data[key]
def put(self, key: int, value: int) -> None:
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False)
move_to_end and popitem(last=False) are both O(1). To cache a function's results, functools.lru_cache(maxsize=...) already does this. The class is not thread-safe; ask how the candidate would add a lock.
18. Write a retry decorator that takes arguments.
Task: @retry(times=3, exceptions=(ConnectionError,)) retries a failing call with growing delays, then re-raises the last error.
import functools
import time
def retry(times: int = 3, delay: float = 0.1, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
time.sleep(delay * 2 ** (attempt - 1))
return wrapper
return decorator
@retry(times=3, exceptions=(ConnectionError,))
def fetch_price(symbol: str) -> float: ...
A decorator with arguments is a function that returns a decorator, hence three levels. functools.wraps keeps the wrapped function's name and docstring. A bare raise re-raises the original error with its traceback.
19. Merge k sorted linked lists.
Task: merge 1-4-5, 1-3-4 and 2-6 into 1-1-2-3-4-4-5-6.
import heapq
def merge_k_lists(lists: list[ListNode | None]) -> ListNode | None:
heap = [(node.val, i, node) for i, node in enumerate(lists) if node]
heapq.heapify(heap)
dummy = tail = ListNode(0)
while heap:
_, i, node = heapq.heappop(heap)
tail.next = node
tail = node
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
O(N log k) for N nodes in k lists. The index i is the trap: without it, two equal values make Python compare two ListNode objects, which raises TypeError. For plain sorted lists, heapq.merge(*lists) does the whole job.
20. Keep the running median of a stream.
Task: after adding 1 and 2 the median is 1.5; after adding 3 it is 2.
add is O(log n) and median is O(1). Before 3.14, heapq only had min-heap functions, so the lower half is stored negated. Challenge 32 shows the 3.14 version.
21. Find the fewest coins that make an amount.
Task: coins [1, 2, 5] and amount 11 return 3. If no combination works, return -1.
def coin_change(coins: list[int], amount: int) -> int:
dp = [0] + [float("inf")] * amount
for a in range(1, amount + 1):
for coin in coins:
if coin <= a:
dp[a] = min(dp[a], dp[a - coin] + 1)
return -1 if dp[amount] == float("inf") else int(dp[amount])
O(amount x coins). Greedy fails for coins [1, 3, 4] and amount 6: it picks 4 + 1 + 1, but 3 + 3 uses two coins.
22. Can a string be split into dictionary words?
Task: "leetcode" with ["leet", "code"] returns True; "catsandog" with ["cats", "dog", "sand", "and", "cat"] returns False.
from functools import cache
def word_break(s: str, words: list[str]) -> bool:
vocab = set(words)
@cache
def can_split(i: int) -> bool:
if i == len(s):
return True
return any(s[i:j] in vocab and can_split(j) for j in range(i + 1, len(s) + 1))
return can_split(0)
functools.cache turns the recursive search into top-down dynamic programming, O(n2) substring checks. Without it the same suffixes are solved again and again, which is exponential.
The recursion depth equals the string length, so a bottom-up table suits very long inputs.
23. Find the shortest distance from one node to every other.
Task: edge weights are positive. From A, with A to B = 4, A to C = 1, C to B = 2, C to D = 5 and B to D = 1, return A 0, C 1, B 3, D 4.
import heapq
def shortest_paths(graph: dict[str, list[tuple[str, int]]], start: str) -> dict[str, int]:
dist = {start: 0}
heap = [(0, start)]
while heap:
d, node = heapq.heappop(heap)
if d > dist[node]:
continue # stale entry, already improved
for nxt, weight in graph.get(node, []):
nd = d + weight
if nd < dist.get(nxt, float("inf")):
dist[nxt] = nd
heapq.heappush(heap, (nd, nxt))
return dist
Dijkstra's algorithm, O((V + E) log V). heapq cannot lower a key in place, so the code pushes a new entry and skips stale ones when they come out. Negative weights break it; Bellman-Ford handles those.
24. Order build steps so every dependency comes first.
Task: app needs lib and config, and lib needs config. Return a valid order, or None if there is a cycle.
graphlib has been in the standard library since Python 3.9. A strong candidate uses it, then writes Kahn's algorithm by hand when asked: count incoming edges, and repeatedly take nodes whose count is zero.
25. Build a sliding-window rate limiter.
Task: allow at most limit calls in any window seconds. With limit 2 and a 1-second window, calls at 0.0, 0.1 and 0.2 return True, True, False, and a call at 1.05 returns True.
import time
from collections import deque
class RateLimiter:
def __init__(self, limit: int, window: float) -> None:
self.limit = limit
self.window = window
self.calls: deque[float] = deque()
def allow(self, now: float | None = None) -> bool:
now = time.monotonic() if now is None else now
while self.calls and now - self.calls[0] >= self.window:
self.calls.popleft()
if len(self.calls) < self.limit:
self.calls.append(now)
return True
return False
Each call is amortized O(1), and memory is O(limit). time.monotonic() matters: time.time() can jump when the system clock changes. Passing now in makes the class testable without sleeping.
What Changed Recently
These challenges need Python 3.13 or 3.14. Tell candidates which version the editor runs before you start.
itertools.batched arrived in 3.12, and the strict flag came in Python 3.13. Batches are tuples. The older idiom, [items[i:i + n] for i in range(0, len(items), n)], only works on sequences, while batched takes any iterable.
27. Return an updated copy of a frozen dataclass.
Task: an Order is immutable. Write mark_paid so it returns a new order with status "paid" and leaves the original alone.
copy.replace() is new in Python 3.13. It works the same way on dataclasses, named tuples, datetime objects and SimpleNamespace.
Any class can support it by defining __replace__(), so callers no longer need dataclasses.replace for one type and _replace for another.
28. Parse a port number and catch two error types without brackets.
Task: return the port as an int if it is between 1 and 65535, otherwise None. "8080" returns 8080; "http", "70000" and None return None.
def parse_port(raw: str | None) -> int | None:
try:
port = int(raw)
if not 0 < port < 65536:
raise ValueError(port)
return port
except ValueError, TypeError:
return None
PEP 758, new in 3.14, lets except list several types without brackets. Brackets are still required with as: except (ValueError, TypeError) as e. In Python 2, except A, B meant "catch A and bind it to the name B".
The PEP allows the form again without bringing back that meaning.
29. Write an HTML renderer that escapes user input with a t-string.
Task: html(t"<p>Hello, {name}</p>") must escape name but not the literal markup around it.
from html import escape
from string.templatelib import Interpolation, Template
def html(template: Template) -> str:
parts = []
for part in template:
if isinstance(part, Interpolation):
parts.append(escape(str(part.value)))
else:
parts.append(part)
return "".join(parts)
name = "<script>alert(1)</script>"
print(html(t"<p>Hello, {name}</p>"))
# <p>Hello, <script>alert(1)</script></p>
Template strings (PEP 750, Python 3.14) look like f-strings but return a Template object, not a string. Iterating over it yields the literal parts as strings and each {...} as an Interpolation.
That split lets a library escape only user values. This version ignores !r conversions and format specs; ask the candidate how they would handle them.
30. Count primes in parallel with threads, and say when it gets faster.
Task: split the range below 200,000 across four threads and count the primes. The answer is 17,984.
import sys
from concurrent.futures import ThreadPoolExecutor
def count_primes(lo: int, hi: int) -> int:
return sum(
1 for n in range(max(lo, 2), hi)
if all(n % d for d in range(2, int(n ** 0.5) + 1))
)
def parallel_count(limit: int, workers: int = 4) -> int:
step = limit // workers + 1
ranges = [(lo, min(lo + step, limit)) for lo in range(0, limit, step)]
with ThreadPoolExecutor(workers) as pool:
return sum(pool.map(lambda r: count_primes(*r), ranges))
print(parallel_count(200_000), "GIL enabled:", sys._is_gil_enabled())
On the standard build this is no faster than one thread, because the GIL lets only one thread run Python code at a time. On the free-threaded build (python3.14t), the threads run on separate cores.
PEP 779 made that build officially supported in 3.14, though it is still not the default. sys._is_gil_enabled(), added in 3.13, reports which mode is running.
Hiring for backend work? These challenges test algorithms and the standard library. Our Python backend interview questions cover the other half: async, the GIL, ORMs, APIs and testing.
Signs of a Strong Answer
They reach for Counter, defaultdict, deque, heapq and bisect first, and can write the manual version when asked.
They use deque.popleft() for queues and can say why list.pop(0) is O(n).
They add a tie-breaker to heap tuples before the TypeError happens.
They pick BFS or an explicit stack on large inputs because of the 1,000-frame recursion limit.
They state which Python version a feature needs, such as copy.replace (3.13) or t-strings (3.14).
They know threads speed up CPU-bound Python only on the free-threaded build.