Home Projects Blog About Contact
Download CV
Back to Blog

Python Exercises to Sharpen Your Programming Logic

A hands-on collection of Python exercises to train your problem-solving mindset — from basic logic to recursion, data structures, and algorithms. With full solutions and explanations.

Python Exercises to Sharpen Your Programming Logic

Writing Python code every day is not the same as thinking like a programmer. Logic, pattern recognition, and algorithmic thinking are muscles — they need deliberate training. This post is a curated set of exercises, from beginner to intermediate, with real code and explanations. Work through them at your own pace.


Why Exercises Matter More Than Tutorials

Tutorials give you comfort. Exercises give you strength.

When you watch someone solve a problem, your brain says “I get it.” When you solve it, your brain says “I own it.” There’s no shortcut. The only way to get better at problem-solving is to solve problems.

Let’s get to it.


🟢 Level 1 — Foundations

Exercise 1: FizzBuzz (Classic, but don’t skip it)

Print numbers from 1 to 100. For multiples of 3 print "Fizz", for multiples of 5 print "Buzz", for multiples of both print "FizzBuzz".

The naive version:

for i in range(1, 101):
    if i % 15 == 0:
        print("FizzBuzz")
    elif i % 3 == 0:
        print("Fizz")
    elif i % 5 == 0:
        print("Buzz")
    else:
        print(i)

The Pythonic version:

for i in range(1, 101):
    print("Fizz" * (i % 3 == 0) + "Buzz" * (i % 5 == 0) or i)

Key insight: Boolean values in Python are integers (True == 1, False == 0). Multiplying a string by 0 gives an empty string, which is falsy — so or i kicks in.


Exercise 2: Palindrome Checker

Write a function that returns True if a string is a palindrome (reads the same forwards and backwards), ignoring spaces and case.

def is_palindrome(s: str) -> bool:
    cleaned = s.replace(" ", "").lower()
    return cleaned == cleaned[::-1]


# Tests
assert is_palindrome("racecar") == True
assert is_palindrome("A man a plan a canal Panama") == True
assert is_palindrome("hello") == False
print("All tests passed ✅")

Challenge variant: What if you can’t use slicing? Implement it with two pointers.

def is_palindrome_two_pointers(s: str) -> bool:
    cleaned = s.replace(" ", "").lower()
    left, right = 0, len(cleaned) - 1

    while left < right:
        if cleaned[left] != cleaned[right]:
            return False
        left += 1
        right -= 1

    return True

Why two pointers? This is O(n) time and O(1) extra space — useful when working with very large strings or when learning for interviews.


Exercise 3: Count Word Frequency

Given a sentence, return a dictionary with each word and how many times it appears.

from collections import Counter

def word_frequency(sentence: str) -> dict:
    words = sentence.lower().split()
    return dict(Counter(words))


text = "the cat sat on the mat the cat"
print(word_frequency(text))
# {'the': 3, 'cat': 2, 'sat': 1, 'on': 1, 'mat': 1}

Without Counter (manual approach):

def word_frequency_manual(sentence: str) -> dict:
    frequency = {}
    for word in sentence.lower().split():
        frequency[word] = frequency.get(word, 0) + 1
    return frequency

Lesson: dict.get(key, default) is cleaner than checking if key in dict. Learn it, love it.


Exercise 4: Find Duplicates in a List

Given a list of integers, return only the elements that appear more than once.

def find_duplicates(nums: list) -> list:
    seen = set()
    duplicates = set()

    for num in nums:
        if num in seen:
            duplicates.add(num)
        seen.add(num)

    return list(duplicates)


print(find_duplicates([1, 2, 3, 2, 4, 3, 5]))  # [2, 3]
print(find_duplicates([1, 1, 1, 2]))            # [1]
print(find_duplicates([1, 2, 3]))               # []

Why sets? Lookup in a set is O(1). If you used a list for seen, each lookup would be O(n), making the whole function O(n²).


🟡 Level 2 — Intermediate Logic

Exercise 5: Flatten a Nested List

Write a function that takes an arbitrarily nested list and returns a flat list.

def flatten(nested: list) -> list:
    result = []
    for item in nested:
        if isinstance(item, list):
            result.extend(flatten(item))
        else:
            result.append(item)
    return result


print(flatten([1, [2, 3], [4, [5, 6]], 7]))
# [1, 2, 3, 4, 5, 6, 7]

print(flatten([[[1]], [2, [3, [4]]]]))
# [1, 2, 3, 4]

One-liner with generators:

def flatten_gen(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten_gen(item)
        else:
            yield item

print(list(flatten_gen([1, [2, [3, [4]]]])))  # [1, 2, 3, 4]

Recursion mindset: When a problem contains itself (a list inside a list), recursion is your friend. The base case is “item is not a list”.


Implement binary search from scratch. Given a sorted list and a target value, return the index of the target or -1 if not found.

def binary_search(arr: list, target: int) -> int:
    left, right = 0, len(arr) - 1

    while left <= right:
        mid = (left + right) // 2

        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1   # target is in the right half
        else:
            right = mid - 1  # target is in the left half

    return -1


nums = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]

print(binary_search(nums, 23))   # 5
print(binary_search(nums, 10))   # -1
print(binary_search(nums, 2))    # 0
print(binary_search(nums, 91))   # 9

Why does this matter? Linear search is O(n). Binary search is O(log n). On a list of 1 million items, binary search takes at most ~20 comparisons. Linear search could take 1,000,000.


Exercise 7: Anagram Groups

Given a list of words, group words that are anagrams of each other.

from collections import defaultdict

def group_anagrams(words: list[str]) -> list[list[str]]:
    groups = defaultdict(list)

    for word in words:
        key = tuple(sorted(word))  # "eat" → ('a', 'e', 't')
        groups[key].append(word)

    return list(groups.values())


words = ["eat", "tea", "tan", "ate", "nat", "bat"]
result = group_anagrams(words)

for group in result:
    print(group)
# ['eat', 'tea', 'ate']
# ['tan', 'nat']
# ['bat']

Key insight: Two words are anagrams if and only if their sorted characters are identical. Sorting acts as a canonical form (a “fingerprint”) for the group.


Exercise 8: Valid Parentheses

Given a string containing only (, ), {, }, [, ], determine if the input string is valid. A string is valid if every open bracket has a matching close bracket in the correct order.

def is_valid_parentheses(s: str) -> bool:
    stack = []
    mapping = {')': '(', '}': '{', ']': '['}

    for char in s:
        if char in mapping:
            # It's a closing bracket
            top = stack.pop() if stack else '#'
            if mapping[char] != top:
                return False
        else:
            # It's an opening bracket
            stack.append(char)

    return len(stack) == 0


print(is_valid_parentheses("()"))        # True
print(is_valid_parentheses("()[]{}"))    # True
print(is_valid_parentheses("(]"))        # False
print(is_valid_parentheses("([)]"))      # False
print(is_valid_parentheses("{[]}"))      # True

Stack intuition: A stack is a Last-In-First-Out structure. Every time you see an opening bracket, push it. Every time you see a closing bracket, check if it matches the last opened one (the top of the stack). This is the classic pattern for bracket validation, HTML parsing, and expression evaluation.


Exercise 9: Caesar Cipher

Implement a Caesar cipher — shift each letter in a string by n positions in the alphabet. Wrap around from z back to a.

def caesar_cipher(text: str, shift: int) -> str:
    result = []

    for char in text:
        if char.isalpha():
            base = ord('A') if char.isupper() else ord('a')
            shifted = (ord(char) - base + shift) % 26 + base
            result.append(chr(shifted))
        else:
            result.append(char)  # Keep spaces, punctuation as-is

    return "".join(result)


print(caesar_cipher("Hello, World!", 3))   # "Khoor, Zruog!"
print(caesar_cipher("Khoor, Zruog!", -3))  # "Hello, World!"
print(caesar_cipher("xyz", 3))             # "abc"  (wraps around)

ord() and chr(): ord('a') gives you the ASCII integer value of a character (97). chr(97) gives you back 'a'. The modulo % 26 ensures you wrap around the alphabet. This pattern is essential for any character manipulation.


🔴 Level 3 — Algorithms & Data Structures

Exercise 10: Fibonacci — Four Ways

Calculate the nth Fibonacci number. Each approach teaches something different.

# 1. Naive recursion — O(2^n) — beautiful but slow
def fib_recursive(n: int) -> int:
    if n <= 1:
        return n
    return fib_recursive(n - 1) + fib_recursive(n - 2)


# 2. Memoization — O(n) time, O(n) space
from functools import lru_cache

@lru_cache(maxsize=None)
def fib_memo(n: int) -> int:
    if n <= 1:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)


# 3. Bottom-up dynamic programming — O(n) time, O(n) space
def fib_dp(n: int) -> int:
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]


# 4. Space-optimized — O(n) time, O(1) space ✅ Best
def fib_optimized(n: int) -> int:
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b


# All produce the same result
for i in range(10):
    print(fib_optimized(i), end=" ")
# 0 1 1 2 3 5 8 13 21 34

Why learn four ways? Each version teaches a concept: naive recursion → the problem structure; memoization → caching subproblems; DP table → building from the bottom; space optimization → only keeping what you need. This progression is exactly how you think through any DP problem.


Exercise 11: Linked List — Build from Scratch

Implement a basic singly linked list with append, prepend, delete, and print methods.

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None


class LinkedList:
    def __init__(self):
        self.head = None

    def append(self, data):
        """Add to the end — O(n)"""
        new_node = Node(data)
        if not self.head:
            self.head = new_node
            return
        current = self.head
        while current.next:
            current = current.next
        current.next = new_node

    def prepend(self, data):
        """Add to the beginning — O(1)"""
        new_node = Node(data)
        new_node.next = self.head
        self.head = new_node

    def delete(self, data):
        """Remove first occurrence of data — O(n)"""
        if not self.head:
            return
        if self.head.data == data:
            self.head = self.head.next
            return
        current = self.head
        while current.next:
            if current.next.data == data:
                current.next = current.next.next
                return
            current = current.next

    def to_list(self) -> list:
        result = []
        current = self.head
        while current:
            result.append(current.data)
            current = current.next
        return result

    def __repr__(self):
        return " → ".join(str(x) for x in self.to_list())


ll = LinkedList()
ll.append(1)
ll.append(2)
ll.append(3)
ll.prepend(0)
print(ll)         # 0 → 1 → 2 → 3

ll.delete(2)
print(ll)         # 0 → 1 → 3

Why linked lists? Understanding pointers and node traversal is fundamental for trees, graphs, and interview problems. Python’s list hides all of this complexity — building it yourself makes it click.


Exercise 12: Two Sum

Given a list of integers and a target, return the indices of two numbers that add up to the target. Assume exactly one solution exists.

def two_sum(nums: list[int], target: int) -> tuple[int, int]:
    seen = {}  # value → index

    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return (seen[complement], i)
        seen[num] = i

    return (-1, -1)


print(two_sum([2, 7, 11, 15], 9))   # (0, 1)  → 2 + 7 = 9
print(two_sum([3, 2, 4], 6))        # (1, 2)  → 2 + 4 = 6
print(two_sum([3, 3], 6))           # (0, 1)

The pattern: For every number, you need target - num. Store what you’ve seen so far in a dictionary. First time you find a complement that you’ve already seen — you’re done. This reduces a brute-force O(n²) to O(n).


Exercise 13: Matrix Spiral Traversal

Given an m × n matrix, return all elements in spiral order (clockwise from the outside in).

def spiral_order(matrix: list[list[int]]) -> list[int]:
    result = []
    if not matrix:
        return result

    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1

    while top <= bottom and left <= right:
        # Move right across the top row
        for col in range(left, right + 1):
            result.append(matrix[top][col])
        top += 1

        # Move down the right column
        for row in range(top, bottom + 1):
            result.append(matrix[row][right])
        right -= 1

        # Move left across the bottom row
        if top <= bottom:
            for col in range(right, left - 1, -1):
                result.append(matrix[bottom][col])
            bottom -= 1

        # Move up the left column
        if left <= right:
            for row in range(bottom, top - 1, -1):
                result.append(matrix[row][left])
            left += 1

    return result


matrix = [
    [1,  2,  3,  4],
    [5,  6,  7,  8],
    [9, 10, 11, 12]
]

print(spiral_order(matrix))
# [1, 2, 3, 4, 8, 12, 11, 10, 9, 5, 6, 7]

Shrinking boundaries: Use four pointers (top, bottom, left, right) that converge inward after each traversal. This pattern of “shrinking bounds” also applies to other matrix problems.


🧠 The Mental Models Behind These Exercises

Each exercise targets a specific thinking pattern:

ExercisePatternUse when…
FizzBuzzModular arithmeticDivisibility, cycles
PalindromeTwo pointersComparing two ends
Word FrequencyHash map countingFrequencies, counts
Find DuplicatesSet membershipSeen/not-seen tracking
FlattenRecursionProblem contains itself
Binary SearchDivide and conquerSorted data, O(log n)
Anagram GroupsCanonical keysGrouping by signature
Valid ParenthesesStackMatching pairs, nesting
Caesar CipherModular arithmetic + ord/chrCharacter encoding
FibonacciDynamic programmingOverlapping subproblems
Linked ListPointer manipulationManual data structures
Two SumHash map complementPair-finding
SpiralShrinking boundariesMatrix traversal

How to Practice Effectively

  1. Try before looking at the solution. Even 10 minutes of struggle beats reading immediately.
  2. Solve it multiple ways. The brute-force first, then optimize.
  3. Verbalize your thinking. Pretend you’re explaining it to someone. This forces clarity.
  4. Test edge cases manually. Empty input, single element, all duplicates, negative numbers.
  5. Revisit problems you failed. After 48 hours, try again from scratch.

“The first time you solve a problem, you learn the solution. The second time, you learn the pattern. The third time, it’s yours.”


Once you’re comfortable with all the exercises above, move toward:

  • LeetCode Easy → Medium (start with arrays, hashmaps, strings)
  • Sorting algorithms: implement bubble, merge, and quicksort yourself
  • Tree traversal: BFS, DFS on binary trees
  • Graph problems: connected components, shortest path

The goal isn’t to memorize solutions. It’s to train your brain to see patterns and reach for the right tool instinctively.

Happy coding. 🐍