Xray AI Lab

PRACTICE & IMPROVE

Python Exercises

Learn Python by solving practical programming problems. Start with the basics, improve your problem-solving skills, and progress towards data science.

BeginnerIntermediateAdvanced

FULL GUIDE

Python: concepts, examples, and 27 exercises

Functions, lambdas, recursion, classes, number problems, series, and patterns, with hints and solutions.

Open the complete guide →

LEARNING PATH

Choose a topic

Select a subject and practise one concept at a time.

START HERE

Beginner exercises

Exercise 01 · Variables

Calculate the sum of two numbers

Create two variables, a and b, assign them values, and print their sum.

Expected output:

15
Show solution
a = 7
b = 8
print(a + b)

Python adds the two values and prints the result.

Exercise 02 · Conditions

Check whether a number is even

Write a program that checks whether an integer is even or odd.

Show solution
number = 12

if number % 2 == 0:
    print("Even")
else:
    print("Odd")

The modulo operator (%) gives the remainder. A remainder of zero means the number is even.

Exercise 03 · Loops

Print numbers from 1 to 10

Use a for loop to print the integers from 1 to 10.

Show solution
for number in range(1, 11):
    print(number)

The upper limit of range() is excluded, so we use 11.

Exercise B1 · Beginner

Safe Sum of Two Numbers

Problem: Read two numbers from the user and print their sum. If either value is not a whole number, print Please enter whole numbers.

Example: input 12 and 30 gives Sum: 42.

Show hint

int() raises ValueError for text such as "abc". Catch it with try/except.

Show solution
def read_int(prompt):
                try:
                    return int(input(prompt))
                except ValueError:
                    return None

            a = read_int("First number: ")
            b = read_int("Second number: ")

            if a is None or b is None:
                print("Please enter whole numbers.")
            else:
                print("Sum:", a + b)
            

Output:

First number: 12
            Second number: 30
            Sum: 42
            

read_int returns None when conversion fails, so the main code can check both values at once. Typing abc for either prompt triggers the message.

Exercise B2 · Beginner

Greeting with Default Arguments

Problem: Write greet(name, greeting="Hello", punctuation="!") that returns a greeting string.

Example: greet("Aya") returns Hello, Aya!; greet("Omar", "Welcome", ".") returns Welcome, Omar.

Show hint

Use an f-string and keep defaulted parameters after name.

Show solution
def greet(name, greeting="Hello", punctuation="!"):
                return f"{greeting}, {name}{punctuation}"

            print(greet("Aya"))
            print(greet("Omar", "Welcome", "."))
            print(greet("Lina", punctuation="?"))
            

Output:

Hello, Aya!
            Welcome, Omar.
            Hello, Lina?
            

Keyword arguments let the caller skip greeting and set only punctuation.

Exercise B3 · Beginner

Keep Only the Strings

Problem: From a mixed list, return a new list containing only the strings.

Example: ["abc", 34.56, 32, "b", 55] gives ['abc', 'b'].

Show hint

isinstance(x, str) is true for strings. A list comprehension keeps the code short.

Show solution
def only_strings(items):
                return [x for x in items if isinstance(x, str)]

            print(only_strings(["abc", 34.56, 32, "b", 55, "90"]))
            print(only_strings([]))
            

Output:

['abc', 'b', '90']
            []
            

Unlike the integer case, there is no subclass trap here. The empty-list test confirms an empty input produces an empty result.

Exercise B4 · Beginner

Average of Any Number of Values

Problem: Write average(*numbers). Raise ValueError if no numbers are given.

Example: average(4, 8, 15) gives 9.0.

Show hint

numbers is a tuple. Check len(numbers) before dividing.

Show solution
def average(*numbers):
                if not numbers:
                    raise ValueError("average() needs at least one number")
                return sum(numbers) / len(numbers)

            print(average(4, 8, 15))
            try:
                average()
            except ValueError as error:
                print("ValueError:", error)
            

Output:

9.0
            ValueError: average() needs at least one number
            

Raising an error is better than returning 0, because an average of nothing is undefined, not zero.

Exercise B5 · Beginner

Right Triangle of Stars

Problem: Write star_triangle(n) that prints n rows, where row i has i stars separated by spaces.

Example (n = 4):

*
          * *
          * * *
          * * * *
          
Show hint

"*" repeated i times, joined by spaces: " ".join("*" * i).

Show solution
def star_triangle(n):
                for i in range(1, n + 1):
                    print(" ".join("*" * i))

            star_triangle(4)
            

Output:

*
            * *
            * * *
            * * * *
            

The row number equals the star count, so no inner loop is needed. For n = 0 nothing prints.

Exercise B6 · Beginner

Number Triangle

Problem: For n = 5, print rows 1, 1 2, 1 2 3, 1 2 3 4, 1 2 3 4 5.

Show hint

The inner loop runs j from 1 to i. Or unpack a range with print(*range(1, i + 1)).

Show solution
n = 5
            for i in range(1, n + 1):
                print(*range(1, i + 1))
            

Output:

1
            1 2
            1 2 3
            1 2 3 4
            1 2 3 4 5
            

*range(...) unpacks the numbers as separate arguments, and print places a space between them by default.

Exercise B7 · Beginner

Factors in Square-Root Time

Problem: Return the sorted factors of n while testing only up to √n.

Example: factors(36) gives [1, 2, 3, 4, 6, 9, 12, 18, 36].

Show hint

If d divides n, then n // d also divides n. Collect both in a set to avoid duplicating the square root of a perfect square.

Show solution
import math

            def factors(n):
                found = set()
                for d in range(1, math.isqrt(n) + 1):
                    if n % d == 0:
                        found.add(d)
                        found.add(n // d)
                return sorted(found)

            print(factors(36))
            print(factors(45))
            print(factors(1))
            

Output:

[1, 2, 3, 4, 6, 9, 12, 18, 36]
            [1, 3, 5, 9, 15, 45]
            [1]
            

Time complexity drops from O(n) to O(√n). The set matters for 36, where 6 * 6 would otherwise list 6 twice.

BUILD YOUR SKILLS

Intermediate exercises

Number problems, series, and pattern printing.

Exercise I1 · Intermediate

Floyd's Triangle

Problem: Print Floyd's triangle with n rows.

Example (n = 5): rows 1, 2 3, 4 5 6, 7 8 9 10, 11 12 13 14 15.

Show hint

Row i has i numbers. Row i starts right after the last number of row i - 1, which is i * (i - 1) // 2.

Show solution
n = 5
            for i in range(1, n + 1):
                start = i * (i - 1) // 2 + 1
                print(*range(start, start + i))
            

Output:

1
            2 3
            4 5 6
            7 8 9 10
            11 12 13 14 15
            

This version replaces the running counter by a formula (the number of items printed before the row is 1 + 2 + ... + (i - 1)). Both approaches work; the counter is easier to read, the formula avoids shared state.

Exercise I2 · Intermediate

Primes with the Sieve of Eratosthenes

Problem: List all primes up to N without testing each number separately.

Example: N = 30 gives [2, 3, 5, 7, 11, 13, 17, 19, 23, 29].

Show hint

Start with a list of True flags. For each prime p, cross out its multiples starting at p * p.

Show solution
def primes_up_to(limit):
                if limit < 2:
                    return []
                is_prime = [True] * (limit + 1)
                is_prime[0] = is_prime[1] = False
                p = 2
                while p * p <= limit:
                    if is_prime[p]:
                        for multiple in range(p * p, limit + 1, p):
                            is_prime[multiple] = False
                    p += 1
                return [n for n, flag in enumerate(is_prime) if flag]

            print(primes_up_to(30))
            print(primes_up_to(1))
            

Output:

[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
            []
            

Multiples smaller than p * p were already crossed out by smaller primes. Time complexity is about O(n log log n), much faster than trial division when many numbers are checked.

Exercise I3 · Intermediate

Perfect, Abundant, or Deficient

Problem: Add up the proper divisors of n (divisors smaller than n). If the sum equals n the number is perfect; if greater, abundant; if smaller, deficient.

Example: 6 is perfect (1 + 2 + 3), 12 is abundant (16), 34 is deficient (20).

Show hint

range(1, n) excludes n, which is exactly the proper-divisor range.

Show solution
def classify(n):
                total = sum(d for d in range(1, n) if n % d == 0)
                if total == n:
                    return "perfect"
                return "abundant" if total > n else "deficient"

            for n in (6, 12, 28, 34):
                print(n, classify(n))
            

Output:

6 perfect
            12 abundant
            28 perfect
            34 deficient
            

For n = 1 the sum is 0, so 1 is deficient. Time is O(n) per number.

Exercise I4 · Intermediate

Pronic Numbers

Problem: A pronic number is the product of two consecutive integers, such as 7 * 8 = 56. List the pronic numbers from 1 to 100.

Show hint

If n = k * (k + 1), then k * k <= n < (k + 1) * (k + 1), so k = math.isqrt(n).

Show solution
import math

            def is_pronic(n):
                k = math.isqrt(n)
                return k * (k + 1) == n

            print([n for n in range(1, 101) if is_pronic(n)])
            print(is_pronic(56), is_pronic(57))
            

Output:

[2, 6, 12, 20, 30, 42, 56, 72, 90]
            True False
            

Computing k directly avoids a loop and makes each check O(1). Mathematically 0 = 0 * 1 is also pronic; the range starts at 1 here.

Exercise I5 · Intermediate

Strong (Krishnamurthy) Numbers

Problem: A number is strong when it equals the sum of the factorials of its digits (for example 145 = 1! + 4! + 5!). Find all strong numbers below 50,000.

Show hint

Peel digits with % 10 and // 10 and add math.factorial(digit). Precompute factorials of 0-9 for speed.

Show solution
import math

            FACT = [math.factorial(d) for d in range(10)]

            def is_strong(n):
                total, m = 0, n
                while m:
                    total += FACT[m % 10]
                    m //= 10
                return total == n

            print([n for n in range(1, 50000) if is_strong(n)])
            print(is_strong(145))
            

Output:

[1, 2, 145, 40585]
            True
            

The results are 1, 2, 145, and 40585. A frequent testing mistake is trusting one printed answer: always check a known strong number such as 145 and a known non-member.

Exercise I6 · Intermediate

Arithmetic Series with a Formula Check

Problem: Compute 9 + 13 + 17 + ... for N terms with a loop, and check it against the closed form.

Example: N = 20 gives 940.

Show hint

The closed form is N * (2 * first + (N - 1) * step) // 2.

Show solution
def loop_sum(first, step, n):
                total, term = 0, first
                for _ in range(n):
                    total += term
                    term += step
                return total

            def formula_sum(first, step, n):
                return n * (2 * first + (n - 1) * step) // 2

            for n in (1, 5, 20):
                a, b = loop_sum(9, 4, n), formula_sum(9, 4, n)
                print(n, a, b, a == b)
            

Output:

1 9 9 True
            5 85 85 True
            20 940 940 True
            

Comparing two independent methods is a strong way to catch off-by-one mistakes. The loop is O(N), the formula O(1).

Exercise I7 · Intermediate

Geometric and Alternating Series

Problem: Compute (a) 5 + 25 + 125 + ... for 10 terms, and (b) the alternating series 2 - 6 + 18 - 54 + ... for 20 terms.

Show hint

For (a) the ratio is 5. For (b) the ratio is -3; negative ratios flip the sign automatically.

Show solution
def geometric_sum(first, ratio, terms):
                total, term = 0, first
                for _ in range(terms):
                    total += term
                    term *= ratio
                return total

            print(geometric_sum(5, 5, 10))
            print(geometric_sum(2, -3, 20))
            print(5 * (5 ** 10 - 1) // 4)             # formula check for (a)
            print(2 * (1 - (-3) ** 20) // (1 + 3))    # formula check for (b)
            

Output:

12207030
            -1743392200
            12207030
            -1743392200
            

Treating the alternating series as a geometric series with ratio -3 removes the need for an if on even and odd positions.

Exercise I8 · Intermediate

Triangles from a Word

Problem: For the word CODER, print (a) one triangle where row i is the first i + 1 letters, and (b) a triangle where row i repeats the i-th letter i + 1 times.

Show hint

Use slicing for (a): word[:i + 1]. For (b) repeat a one-letter list.

Show solution
word = "CODER"
            n = len(word)

            for i in range(n):
                print(" ".join(word[:i + 1]))

            print()

            for i in range(n):
                print(" ".join([word[i]] * (i + 1)))
            

Output:

C
            C O
            C O D
            C O D E
            C O D E R

            C
            O O
            D D D
            E E E E
            R R R R R
            

" ".join("COD") treats the string as a sequence of characters. Using len(word) keeps the code working for any word.

Exercise I9 · Intermediate

Odd-Number Pyramid

Problem: Print a pyramid where row i contains 1, 2, ..., 2i - 1.

Example (n = 5):

        1
                1 2 3
              1 2 3 4 5
            1 2 3 4 5 6 7
          1 2 3 4 5 6 7 8 9
          
Show hint

Indent by 2 * (n - i) spaces, then print the numbers 1 to 2i - 1.

Show solution
n = 5
            for i in range(1, n + 1):
                indent = " " * (2 * (n - i))
                print(indent + " ".join(str(k) for k in range(1, 2 * i)))
            

Output:

        1
                  1 2 3
                1 2 3 4 5
              1 2 3 4 5 6 7
            1 2 3 4 5 6 7 8 9
            

range(1, 2 * i) stops at 2i - 1. str(k) is required because join accepts only strings; for numbers above 9 the alignment shifts, which is a good extension challenge.

Exercise I10 · Intermediate

Right-Aligned Countdown Triangles

Problem: Print these right-aligned triangles for n = 5: (a) 54321, 4321, 321, 21, 1; (b) EDCBA, EDCB, EDC, ED, E; (c) EDCBA, DCBA, CBA, BA, A; (d) the word CODER reversed, losing its first letter on each row (REDOC, EDOC, DOC, OC, C).

Show hint

Every row is either a slice from the end or a slice from the start of one sequence, indented by 2 * i spaces. Write one helper and feed it different sequences.

Show solution
def right_aligned(sequence, keep="end"):
                n = len(sequence)
                for i in range(n):
                    part = sequence[i:] if keep == "end" else sequence[:n - i]
                    print(" " * (2 * i) + " ".join(part))

            right_aligned("54321")
            print()
            right_aligned("EDCBA", keep="start")
            print()
            right_aligned("EDCBA")
            print()
            right_aligned("CODER"[::-1])
            

Output:

5 4 3 2 1
              4 3 2 1
                3 2 1
                  2 1
                    1

            E D C B A
              E D C B
                E D C
                  E D
                    E

            E D C B A
              D C B A
                C B A
                  B A
                    A

            R E D O C
              E D O C
                D O C
                  O C
                    C
            

One function covers four patterns because they differ only in the sequence and in which end the slice keeps. Strings are sequences, so " ".join(part) works on slices of them directly. For numbers above 9 build a list of strings instead of a digit string.

GO FURTHER

Advanced exercises

Lambdas, recursion, object-oriented programming, and algorithmic puzzles.

Exercise A1 · Advanced

Sort Records with a Lambda Key

Problem: Sort students by score from highest to lowest; break ties alphabetically by name.

Example: [("Omar", 14), ("Aya", 17), ("Lina", 14)] gives [('Aya', 17), ('Lina', 14), ('Omar', 14)].

Show hint

A key can return a tuple. Negate the score to sort it descending.

Show solution
students = [("Omar", 14), ("Aya", 17), ("Lina", 14)]
            ranked = sorted(students, key=lambda s: (-s[1], s[0]))
            print(ranked)
            

Output:

[('Aya', 17), ('Lina', 14), ('Omar', 14)]
            

Tuples compare element by element, so the name only matters when scores tie. Negation works for numbers; for descending strings use reverse=True in two passes.

Exercise A2 · Advanced

Euclid's GCD, LCM, and Step Count

Problem: Write a recursive Euclid GCD, then use it to compute the least common multiple. Count how many calls the subtraction method and Euclid's method need for (235, 875).

Show hint

gcd(a, b) = gcd(b, a % b), with base case b == 0. And lcm(a, b) = a * b // gcd(a, b).

Show solution
def gcd_euclid(a, b, calls=1):
                if b == 0:
                    return a, calls
                return gcd_euclid(b, a % b, calls + 1)

            def gcd_subtract(a, b, calls=1):
                if a == 0 or a == b:
                    return b, calls
                if b == 0:
                    return a, calls
                if a > b:
                    return gcd_subtract(a - b, b, calls + 1)
                return gcd_subtract(a, b - a, calls + 1)

            g1, c1 = gcd_euclid(235, 875)
            g2, c2 = gcd_subtract(235, 875)
            print("Euclid:", g1, "in", c1, "calls")
            print("Subtraction:", g2, "in", c2, "calls")
            print("LCM:", 235 * 875 // g1)
            

Output:

Euclid: 5 in 10 calls
            Subtraction: 5 in 12 calls
            LCM: 41125
            

Both give 5, but Euclid's remainder step collapses many subtractions into one. Complexity is O(log min(a, b)) for Euclid versus up to O(max / min) for subtraction.

Exercise A3 · Advanced

Palindromic Letter Pyramids

Problem: Print (a) the pyramid A, ABA, ABCBA, ... and (b) the mirrored pyramid E, DED, CDEDC, BCDEDCB, ABCDEDCBA, both for n = 5.

Show hint

For (a), row i is the letters A..i followed by the letters i-1..A. For (b), row i starts i letters earlier than E, runs up to E, then back down.

Show solution
n = 5
            letters = [chr(65 + k) for k in range(n)]      # A..E

            for i in range(n):                              # (a)
                row = letters[:i + 1] + letters[:i][::-1]
                print(" " * (2 * (n - 1 - i)) + " ".join(row))

            print()

            for i in range(n):                              # (b)
                start = n - 1 - i
                row = letters[start:] + letters[start:n - 1][::-1]
                print(" " * (2 * (n - 1 - i)) + " ".join(row))
            

Output:

        A
                  A B A
                A B C B A
              A B C D C B A
            A B C D E D C B A

                    E
                  D E D
                C D E D C
              B C D E D C B
            A B C D E D C B A
            

Part (a) is an ascending slice plus the reverse of a shorter slice. Part (b) reuses the same idea with a slice that grows from the end. Printing the slices on paper first makes the indexes obvious.

Exercise A4 · Advanced

Number Diamond

Problem: Print a diamond where row k repeats the number k exactly 2k - 1 times, growing to n and shrinking back to 1.

Example (n = 3):

    1
            2 2 2
          3 3 3 3 3
            2 2 2
              1
          
Show hint

Build the row numbers 1, 2, ..., n, n-1, ..., 1, then print each row with indent 2 * (n - k).

Show solution
def number_diamond(n):
                rows = list(range(1, n + 1)) + list(range(n - 1, 0, -1))
                for k in rows:
                    print(" " * (2 * (n - k)) + " ".join([str(k)] * (2 * k - 1)))

            number_diamond(3)
            print()
            number_diamond(5)
            

Output:

    1
              2 2 2
            3 3 3 3 3
              2 2 2
                1

                    1
                  2 2 2
                3 3 3 3 3
              4 4 4 4 4 4 4
            5 5 5 5 5 5 5 5 5
              4 4 4 4 4 4 4
                3 3 3 3 3
                  2 2 2
                    1
            

Describing the diamond as one list of row values avoids two separate loops. The [str(k)] * (2k - 1) form also works when k has two digits, unlike joining a repeated string.

Exercise A5 · Advanced

Decode a Numbered String

Problem: Using the encoding from the strings section, write a decode function that rejects invalid input, and prove it works with a round trip.

Show hint

Round trip: decode(encode(text)) == text for any text made of letters and spaces. Test many strings automatically.

Show solution
import random
            import string

            def encode(text):
                return "".join(str(ord(ch)) for ch in text)[::-1]

            def decode(encoded):
                digits = encoded[::-1]
                out, i = [], 0
                while i < len(digits):
                    two = int(digits[i:i + 2])
                    if two == 32 or 65 <= two <= 90 or 97 <= two <= 99:
                        out.append(chr(two))
                        i += 2
                    else:
                        three = int(digits[i:i + 3])
                        if not 100 <= three <= 122:
                            raise ValueError("invalid encoded string")
                        out.append(chr(three))
                        i += 3
                return "".join(out)

            random.seed(1)
            alphabet = string.ascii_letters + " "
            for _ in range(1000):
                text = "".join(random.choice(alphabet) for _ in range(random.randint(0, 20)))
                assert decode(encode(text)) == text
            print("1000 random round trips passed")

            try:
                decode("999")
            except ValueError as error:
                print("ValueError:", error)
            

Output:

1000 random round trips passed
            ValueError: invalid encoded string
            

A randomized round-trip test checks thousands of inputs in milliseconds, far more than manual examples. The assert stops the program on the first failure.

Exercise A6 · Advanced

A Complete Vector Class

Problem: Extend the Vector class with subtraction, equality, absolute value (length), and a readable repr.

Example: Vector(3, 4) has abs equal to 5.0, and Vector(5, 7) - Vector(2, 3) equals Vector(3, 4).

Show hint

Use __sub__, __eq__, and __abs__. In __eq__, return NotImplemented for other types.

Show solution
import math

            class Vector:
                def __init__(self, x=0, y=0):
                    self.x = x
                    self.y = y

                def __add__(self, other):
                    return Vector(self.x + other.x, self.y + other.y)

                def __sub__(self, other):
                    return Vector(self.x - other.x, self.y - other.y)

                def __mul__(self, k):
                    return Vector(self.x * k, self.y * k)

                __rmul__ = __mul__

                def __eq__(self, other):
                    if not isinstance(other, Vector):
                        return NotImplemented
                    return (self.x, self.y) == (other.x, other.y)

                def __abs__(self):
                    return math.hypot(self.x, self.y)

                def __repr__(self):
                    return f"Vector({self.x}, {self.y})"

            print(abs(Vector(3, 4)))
            print(Vector(5, 7) - Vector(2, 3))
            print(Vector(5, 7) - Vector(2, 3) == Vector(3, 4))
            print(Vector(1, 1) == "not a vector")
            print(2 * Vector(1, 2))
            

Output:

5.0
            Vector(3, 4)
            True
            False
            Vector(2, 4)
            

Returning NotImplemented lets Python try the other operand or fall back to False, instead of crashing. Defining __eq__ without __hash__ makes instances unhashable, which is acceptable for mutable objects.

Exercise A7 · Advanced

Cooperative Multiple Inheritance

Problem: Build classes Base, Logging, Validation, and Service(Logging, Validation), where each class adds a step to process(data) through super(). Show the order of the steps.

Show hint

Each class appends its label and calls super().process(...). The Base class ends the chain.

Show solution
class Base:
                def process(self, steps):
                    steps.append("Base")
                    return steps

            class Logging(Base):
                def process(self, steps):
                    steps.append("Logging")
                    return super().process(steps)

            class Validation(Base):
                def process(self, steps):
                    steps.append("Validation")
                    return super().process(steps)

            class Service(Logging, Validation):
                def process(self, steps):
                    steps.append("Service")
                    return super().process(steps)

            print(Service().process([]))
            print([c.__name__ for c in Service.__mro__])
            

Output:

['Service', 'Logging', 'Validation', 'Base']
            ['Service', 'Logging', 'Validation', 'Base', 'object']
            

The order matches the MRO: Service, Logging, Validation, Base. Every class in the chain must call super() for cooperative inheritance to work; if one forgets, later classes are skipped.

Exercise A8 · Advanced

Fraction Series

Problem: Compute (a) 2/10 + 4/9 + 6/8 + ... + 20/1, and (b) (x+2)/10 + (x+4)/30 + (x+6)/90 + ... for N terms with x = 2.

Show hint

For (a), term i is 2i / (11 - i). For (b), term i is (x + 2i) / (10 * 3 ** (i - 1)).

Show solution
total_a = sum((2 * i) / (11 - i) for i in range(1, 11))
            print(f"(a) {total_a:.6f}")

            def series_b(x, n):
                return sum((x + 2 * i) / (10 * 3 ** (i - 1)) for i in range(1, n + 1))

            print(f"(b) {series_b(2, 10):.6f}")
            

Output:

(a) 44.437302
            (b) 0.749936
            

Deriving each term from i with a formula is less error-prone than updating several running variables. Printing with a fixed number of decimals avoids floating-point noise in the last digits.

Exercise A9 · Advanced

Automorphic Numbers

Problem: A number is automorphic when its square ends with the number itself (25 * 25 = 625). Find all automorphic numbers below 1000.

Show hint

Compare n * n % 10 ** len(str(n)) with n.

Show solution
def is_automorphic(n):
                digits = len(str(n))
                return n * n % 10 ** digits == n

            print([n for n in range(1, 1000) if is_automorphic(n)])
            print(is_automorphic(9))
            

Output:

[1, 5, 6, 25, 76, 376, 625]
            False
            

Taking the square modulo 10 ** digits keeps only the last digits digits of the square. The same result can be reached by comparing digits one at a time with % 10 and // 10.

Exercise A10 · Advanced

Pyramid That Continues the Alphabet

Problem: Print a centered pyramid where row i holds 2i + 1 consecutive letters and the alphabet continues from one row to the next (A, then BCD, then EFGHI, ...), for n = 5.

Show hint

Keep a counter outside the outer loop, as in Floyd's triangle, and use chr(65 + counter % 26).

Show solution
n = 5
            counter = 0
            for i in range(n):
                row = []
                for _ in range(2 * i + 1):
                    row.append(chr(65 + counter % 26))
                    counter += 1
                print(" " * (2 * (n - 1 - i)) + " ".join(row))
            

Output:

        A
                  B C D
                E F G H I
              J K L M N O P
            Q R S T U V W X Y
            

The five rows use 1 + 3 + 5 + 7 + 9 = 25 letters, so the pyramid ends at Y. The % 26 keeps larger pyramids valid by wrapping around to A.

Lists & Dictionaries

Exercise L1 · Lists & Dictionaries

Second Largest Value

Problem: Return the second largest distinct value in a list. Raise ValueError if there are fewer than two distinct values.

Example: [4, 9, 1, 9, 7] gives 7.

Show hint

A set removes duplicates. Sort the distinct values in descending order and take index 1.

Show solution
def second_largest(values):
                distinct = sorted(set(values), reverse=True)
                if len(distinct) < 2:
                    raise ValueError("need at least two distinct values")
                return distinct[1]

            print(second_largest([4, 9, 1, 9, 7]))
            print(second_largest([-5, -1, -3]))
            try:
                second_largest([2, 2, 2])
            except ValueError as error:
                print("ValueError:", error)
            

Output:

7
            -3
            ValueError: need at least two distinct values
            

Sorting costs O(n log n). A single pass that tracks the two largest values is O(n), but the sorted version is shorter and easier to get right. The edge cases (all equal, negatives) are the ones to test.

Exercise L2 · Lists & Dictionaries

Remove Duplicates and Keep the Order

Problem: Remove duplicates from a list but keep the first occurrence of each item in its original position.

Example: ["b", "a", "b", "c", "a"] gives ['b', 'a', 'c'].

Show hint

Since Python 3.7, dictionaries remember insertion order. dict.fromkeys(items) creates keys in order and ignores repeats.

Show solution
def unique_in_order(items):
                return list(dict.fromkeys(items))

            print(unique_in_order(["b", "a", "b", "c", "a"]))
            print(unique_in_order([3, 1, 3, 2, 1]))
            print(unique_in_order([]))

            # Using set() alone does NOT keep the order of first appearance reliably:
            seen = set()
            result = []
            for item in ["b", "a", "b", "c", "a"]:
                if item not in seen:
                    seen.add(item)
                    result.append(item)
            print(result)
            

Output:

['b', 'a', 'c']
            [3, 1, 2]
            []
            ['b', 'a', 'c']
            

Both versions are O(n) because set and dictionary lookups are constant time on average. Items must be hashable, so a list containing lists would raise TypeError.

Exercise L3 · Lists & Dictionaries

Word Frequencies

Problem: Count how many times each word appears in a sentence (ignoring case) and print the words sorted by count, highest first, then alphabetically.

Example: "the cat and the hat and the bat" gives the: 3, and: 2, bat: 1, cat: 1, hat: 1.

Show hint

Use counts[word] = counts.get(word, 0) + 1. Sort items with the key lambda pair: (-pair[1], pair[0]).

Show solution
text = "the cat and the hat and the bat"

            counts = {}
            for word in text.lower().split():
                counts[word] = counts.get(word, 0) + 1

            for word, count in sorted(counts.items(), key=lambda pair: (-pair[1], pair[0])):
                print(f"{word}: {count}")
            

Output:

the: 3
            and: 2
            bat: 1
            cat: 1
            hat: 1
            

dict.get(key, default) avoids a KeyError for new words. The standard library has collections.Counter for the same job, but writing it by hand shows how dictionaries work. Real text needs punctuation removed first, for example with word.strip(".,!?").

Exercise L4 · Lists & Dictionaries

Group Names by First Letter

Problem: Build a dictionary that maps each first letter to the list of names starting with it.

Example: ["Aya", "Omar", "Amine", "Lina", "Laila", "Oussama"] gives {'A': ['Aya', 'Amine'], 'O': ['Omar', 'Oussama'], 'L': ['Lina', 'Laila']}.

Show hint

groups.setdefault(letter, []) returns the existing list or creates an empty one.

Show solution
names = ["Aya", "Omar", "Amine", "Lina", "Laila", "Oussama"]

            groups = {}
            for name in names:
                groups.setdefault(name[0].upper(), []).append(name)

            print(groups)
            

Output:

{'A': ['Aya', 'Amine'], 'O': ['Omar', 'Oussama'], 'L': ['Lina', 'Laila']}
            

setdefault replaces the pattern "if the key is missing, create a list, then append". Calling name[0] on an empty string would raise IndexError, so clean the input first if empty names are possible.

Exercise L5 · Lists & Dictionaries

Rotate a List

Problem: Rotate a list to the right by k positions. k may be larger than the list length.

Example: [1, 2, 3, 4, 5] with k = 2 gives [4, 5, 1, 2, 3].

Show hint

Rotating by len(items) returns the original list, so use k % len(items). Then combine two slices.

Show solution
def rotate_right(items, k):
                if not items:
                    return []
                k %= len(items)
                return items[len(items) - k:] + items[:len(items) - k]

            print(rotate_right([1, 2, 3, 4, 5], 2))
            print(rotate_right([1, 2, 3, 4, 5], 7))
            print(rotate_right([1, 2, 3, 4, 5], 0))
            print(rotate_right([], 3))
            

Output:

[4, 5, 1, 2, 3]
            [4, 5, 1, 2, 3]
            [1, 2, 3, 4, 5]
            []
            

The code uses len(items) - k rather than negative indexes because items[-0:] means "the whole list", which is a classic trap. Guarding the empty list avoids a division by zero in k %= len(items).

Exercise L6 · Lists & Dictionaries

Merge Two Dictionaries and Add Common Values

Problem: Merge two dictionaries of quantities. If a key exists in both, add the values.

Example: {"apples": 3, "pears": 2} and {"pears": 5, "kiwis": 1} give {'apples': 3, 'pears': 7, 'kiwis': 1}.

Show hint

Copy the first dictionary, then loop through the second and use get(key, 0).

Show solution
def merge_add(a, b):
                result = dict(a)              # copy, so the inputs are not modified
                for key, value in b.items():
                    result[key] = result.get(key, 0) + value
                return result

            stock_a = {"apples": 3, "pears": 2}
            stock_b = {"pears": 5, "kiwis": 1}
            print(merge_add(stock_a, stock_b))
            print(stock_a)                    # unchanged
            

Output:

{'apples': 3, 'pears': 7, 'kiwis': 1}
            {'apples': 3, 'pears': 2}
            

Copying with dict(a) matters: writing into a directly would change the caller's data. The | operator (Python 3.9+) merges dictionaries too, but keeps only the right-hand value for common keys, so it does not add them.

Exercise L7 · Lists & Dictionaries

Invert a Dictionary

Problem: Swap keys and values. If several keys share a value, collect them in a list.

Example: {"a": 1, "b": 2, "c": 1} gives {1: ['a', 'c'], 2: ['b']}.

Show hint

A plain {v: k for k, v in d.items()} would lose "a", because the later key overwrites the earlier one.

Show solution
def invert(mapping):
                inverted = {}
                for key, value in mapping.items():
                    inverted.setdefault(value, []).append(key)
                return inverted

            print(invert({"a": 1, "b": 2, "c": 1}))
            print({v: k for k, v in {"a": 1, "b": 2, "c": 1}.items()})   # loses "a"
            

Output:

{1: ['a', 'c'], 2: ['b']}
            {1: 'c', 2: 'b'}
            

Values become keys, so they must be hashable. Returning lists makes the inversion lossless; the one-line comprehension silently drops data when values repeat.

Exercise L8 · Lists & Dictionaries

Transpose and Flatten a Matrix

Problem: Given a matrix as a list of rows, return (a) its transpose and (b) all its elements in one flat list.

Example: [[1, 2, 3], [4, 5, 6]] gives [[1, 4], [2, 5], [3, 6]] and [1, 2, 3, 4, 5, 6].

Show hint

zip(*rows) pairs the first elements of every row, then the second elements, and so on.

Show solution
matrix = [[1, 2, 3], [4, 5, 6]]

            transposed = [list(column) for column in zip(*matrix)]
            flat = [value for row in matrix for value in row]

            print(transposed)
            print(flat)

            ragged = [[1, 2, 3], [4, 5]]
            print([list(c) for c in zip(*ragged)])   # the extra 3 is silently dropped
            

Output:

[[1, 4], [2, 5], [3, 6]]
            [1, 2, 3, 4, 5, 6]
            [[1, 4], [2, 5]]
            

*matrix unpacks the rows as separate arguments to zip. zip stops at the shortest row, so ragged input loses data; check that all rows have equal length first. In the nested comprehension, the loops read in the same order as nested for statements.

Functions

Exercise F1 · Functions

Return Several Values

Problem: Write stats(values) that returns the minimum, the maximum, and the average of a non-empty list.

Example: stats([4, 8, 15, 16, 23, 42]) gives (4, 42, 18.0).

Show hint

A function can return a tuple. The caller unpacks it: lo, hi, avg = stats(data).

Show solution
def stats(values):
                if not values:
                    raise ValueError("stats() needs at least one value")
                return min(values), max(values), sum(values) / len(values)

            lo, hi, avg = stats([4, 8, 15, 16, 23, 42])
            print(lo, hi, avg)
            print(stats([5]))
            

Output:

4 42 18.0
            (5, 5, 5.0)
            

Python does not return "several values", it returns one tuple, and unpacking splits it. Checking for an empty list gives a clear error instead of a confusing ZeroDivisionError.

Exercise F2 · Functions

Palindrome Checker

Problem: Write is_palindrome(text) that ignores case, spaces, and punctuation.

Example: "A man, a plan, a canal: Panama" is a palindrome; "Python" is not.

Show hint

Keep only characters where c.isalnum() is true, lowercase them, and compare the result with its reverse.

Show solution
def is_palindrome(text):
                cleaned = [c.lower() for c in text if c.isalnum()]
                return cleaned == cleaned[::-1]

            print(is_palindrome("A man, a plan, a canal: Panama"))
            print(is_palindrome("Python"))
            print(is_palindrome(""))
            print(is_palindrome("12321"))
            

Output:

True
            False
            True
            True
            

[::-1] reverses a list or string. The empty string counts as a palindrome by convention. Time and memory are O(n).

Exercise F3 · Functions

Fibonacci, Three Ways

Problem: Compute the n-th Fibonacci number (F(0) = 0, F(1) = 1) (a) with a loop, (b) with plain recursion while counting the calls, and (c) with recursion plus memoization.

Show hint

Plain recursion recomputes the same values again and again. functools.lru_cache stores results of earlier calls.

Show solution
from functools import lru_cache

            def fib_loop(n):
                a, b = 0, 1
                for _ in range(n):
                    a, b = b, a + b
                return a

            calls = 0
            def fib_plain(n):
                global calls
                calls += 1
                return n if n < 2 else fib_plain(n - 1) + fib_plain(n - 2)

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

            print(fib_loop(50))
            print(fib_memo(50), "computed", fib_memo.cache_info().misses, "values")
            print(fib_plain(20), "needed", calls, "calls")
            

Output:

12586269025
            12586269025 computed 51 values
            6765 needed 21891 calls
            

Plain recursion is exponential, about O(2^n): 20 needs thousands of calls. The loop and the memoized version are O(n). The loop also avoids the recursion depth limit for large n.

Exercise F4 · Functions

Functions as Arguments

Problem: Write apply_n(func, x, n) that applies func to x exactly n times, and compose(f, g) that returns a new function computing f(g(x)).

Example: apply_n(lambda v: v * 2, 3, 4) gives 48.

Show hint

Functions are objects: you can pass them as arguments and return them from other functions.

Show solution
def apply_n(func, x, n):
                for _ in range(n):
                    x = func(x)
                return x

            def compose(f, g):
                return lambda x: f(g(x))

            print(apply_n(lambda v: v * 2, 3, 4))
            print(apply_n(lambda v: v + 1, 10, 0))

            add_one_then_square = compose(lambda v: v * v, lambda v: v + 1)
            print(add_one_then_square(4))
            

Output:

48
            10
            25
            

With n = 0 the loop never runs, so x comes back unchanged. In compose, g runs first, so the order of the arguments follows mathematical notation.

Exercise F5 · Functions

Closures

Problem: Write make_multiplier(k) that returns a function multiplying its input by k, and make_counter() that returns a function giving 1, 2, 3, ... on successive calls.

Show hint

An inner function remembers variables of the enclosing function. To change such a variable, declare it nonlocal.

Show solution
def make_multiplier(k):
                def multiply(x):
                    return x * k
                return multiply

            def make_counter():
                count = 0
                def next_value():
                    nonlocal count
                    count += 1
                    return count
                return next_value

            double, triple = make_multiplier(2), make_multiplier(3)
            print(double(7), triple(7))

            counter_a, counter_b = make_counter(), make_counter()
            print(counter_a(), counter_a(), counter_a())
            print(counter_b())
            

Output:

14 21
            1 2 3
            1
            

Each call to make_counter creates its own count, so counter_b starts again at 1. Without nonlocal, count += 1 would raise UnboundLocalError.

Exercise F6 · Functions

A Logging Decorator

Problem: Write a decorator log_calls that prints the arguments of each call and the returned value, without changing the function's name.

Show hint

A decorator takes a function and returns a wrapper. Use *args, **kwargs so it works with any signature, and functools.wraps to keep the name.

Show solution
from functools import wraps

            def log_calls(func):
                @wraps(func)
                def wrapper(*args, **kwargs):
                    print(f"calling {func.__name__} with args={args}, kwargs={kwargs}")
                    result = func(*args, **kwargs)
                    print(f"{func.__name__} returned {result}")
                    return result
                return wrapper

            @log_calls
            def add(a, b=0):
                return a + b

            add(2, b=3)
            print(add.__name__)
            

Output:

calling add with args=(2,), kwargs={'b': 3}
            add returned 5
            add
            

@log_calls above a function is shorthand for add = log_calls(add). Without @wraps, add.__name__ would be "wrapper", which makes debugging and documentation confusing.

Exercise F7 · Functions

Keyword-Only Parameters

Problem: Write power(base, *, exponent=2) so that the exponent can only be passed by name. Show what happens when it is passed by position.

Show hint

Every parameter after a bare * must be given as a keyword argument.

Show solution
def power(base, *, exponent=2):
                return base ** exponent

            print(power(3))
            print(power(2, exponent=10))
            try:
                power(2, 10)
            except TypeError as error:
                print("TypeError:", error)
            

Output:

9
            1024
            TypeError: power() takes 1 positional argument but 2 were given
            

Keyword-only parameters make calls self-explanatory: power(2, exponent=10) is clearer than power(2, 10), and callers cannot swap arguments by mistake.

Exercise F8 · Functions

Generators

Problem: Write a generator fib_gen() that yields Fibonacci numbers forever, take the first 10, and show that a generator can be consumed only once.

Show hint

Use yield inside an infinite loop and itertools.islice to take a limited number of values.

Show solution
from itertools import islice

            def fib_gen():
                a, b = 0, 1
                while True:
                    yield a
                    a, b = b, a + b

            print(list(islice(fib_gen(), 10)))

            squares = (x * x for x in range(1000))      # generator expression, nothing computed yet
            print(sum(squares))
            print(sum(squares))                          # already exhausted
            

Output:

[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
            332833500
            0
            

A generator produces values on demand, so an infinite sequence needs no memory. The second sum prints 0 because the generator is used up; create a new one if you need the values again.

Python for Data Science

Exercise D1 · Python for Data Science

NumPy Arrays and Axes

Problem: Create the numbers 1 to 12 as a 3 by 4 array. Compute the sum of each column, the sum of each row, and the overall mean.

Show hint

Use np.arange(1, 13).reshape(3, 4). axis=0 works down the rows (one result per column); axis=1 works across the columns (one result per row).

Show solution
import numpy as np

            a = np.arange(1, 13).reshape(3, 4)
            print(a)
            print("shape:", a.shape)
            print("column sums:", a.sum(axis=0))
            print("row sums:", a.sum(axis=1))
            print("mean:", a.mean())
            

Output:

[[ 1  2  3  4]
             [ 5  6  7  8]
             [ 9 10 11 12]]
            shape: (3, 4)
            column sums: [15 18 21 24]
            row sums: [10 26 42]
            mean: 6.5
            

The axis is the dimension that gets collapsed. The result has one fewer dimension, which explains why axis=0 returns four values for a 3 by 4 array.

Exercise D2 · Python for Data Science

Standardize Data Without a Loop

Problem: Convert the values [62, 70, 81, 90, 55, 74] to z-scores, (x - mean) / std, and check that the result has mean 0 and standard deviation 1.

Show hint

NumPy applies arithmetic to a whole array at once (vectorization), so no loop is needed.

Show solution
import numpy as np

            data = np.array([62, 70, 81, 90, 55, 74])
            z = (data - data.mean()) / data.std()

            print(np.round(z, 3))
            print(np.isclose(z.mean(), 0), np.isclose(z.std(), 1))
            

Output:

[-0.865 -0.173  0.778  1.557 -1.47   0.173]
            True True
            

Vectorized code is shorter and much faster than a Python loop on large arrays. np.std uses ddof=0 (population standard deviation) by default; pandas' .std() uses ddof=1, so the same data can give slightly different results in the two libraries. Use np.isclose instead of == for floating-point checks.

Exercise D3 · Python for Data Science

Boolean Masks

Problem: From the temperatures [18.5, 22.1, 30.4, 28.9, 15.2, 33.0], select the values above 28, count them, and cap every value above 30 at 30.

Show hint

A comparison on an array returns a boolean array. Use it as an index, or pass it to np.where.

Show solution
import numpy as np

            temps = np.array([18.5, 22.1, 30.4, 28.9, 15.2, 33.0])

            hot = temps > 28
            print(hot)
            print(temps[hot])
            print(hot.sum())
            print(np.where(temps > 30, 30, temps))
            

Output:

[False False  True  True False  True]
            [30.4 28.9 33. ]
            3
            [18.5 22.1 30.  28.9 15.2 30. ]
            

True counts as 1, so hot.sum() counts the matches. np.where(condition, a, b) builds a new array and leaves the original unchanged.

Exercise D4 · Python for Data Science

Build and Sort a DataFrame

Problem: Create a DataFrame with the columns name, math, and physics. Add an average column, sort by it in descending order, and print the mean of each subject.

Show hint

Create columns from a dictionary of lists. New columns are made by assignment: df["average"] = ....

Show solution
import pandas as pd

            df = pd.DataFrame({
                "name": ["Aya", "Omar", "Lina", "Yassine"],
                "math": [14, 11, 17, 9],
                "physics": [12, 15, 16, 10],
            })
            df["average"] = (df["math"] + df["physics"]) / 2

            print(df.sort_values("average", ascending=False))
            print(df[["math", "physics"]].mean())
            

Output:

      name  math  physics  average
            2     Lina    17       16     16.5
            0      Aya    14       12     13.0
            1     Omar    11       15     13.0
            3  Yassine     9       10      9.5
            math       12.75
            physics    13.25
            dtype: float64
            

Column arithmetic is vectorized, like NumPy. sort_values returns a new DataFrame; the original keeps its order unless you reassign it.

Exercise D5 · Python for Data Science

Handle Missing Values

Problem: A DataFrame has missing scores. Count the missing values per column, fill the missing scores with the column mean, and also show the result of dropping incomplete rows.

Show hint

Use isna().sum(), fillna(value), and dropna(). Missing numbers are NaN (np.nan).

Show solution
import numpy as np
            import pandas as pd

            df = pd.DataFrame({
                "name": ["Aya", "Omar", "Lina", "Yassine"],
                "score": [14.0, np.nan, 17.0, np.nan],
            })

            print(df.isna().sum())
            filled = df.assign(score=df["score"].fillna(df["score"].mean()))
            print(filled)
            print(df.dropna())
            

Output:

name     0
            score    2
            dtype: int64
                  name  score
            0      Aya   14.0
            1     Omar   15.5
            2     Lina   17.0
            3  Yassine   15.5
               name  score
            0   Aya   14.0
            2  Lina   17.0
            

The mean ignores NaN by default, so it is 15.5 here. Filling with the mean keeps all rows but shrinks the variance; dropping rows loses data. Which one is right depends on how many values are missing and why.

Exercise D6 · Python for Data Science

Group and Aggregate

Problem: From sales records, compute the total and average sales per city, sorted by total descending.

Show hint

df.groupby("city")["sales"].agg(["sum", "mean"]), then sort_values.

Show solution
import pandas as pd

            df = pd.DataFrame({
                "city": ["Fes", "Rabat", "Fes", "Casablanca", "Rabat", "Fes"],
                "sales": [120, 200, 80, 340, 150, 100],
            })

            summary = df.groupby("city")["sales"].agg(["sum", "mean"])
            print(summary.sort_values("sum", ascending=False))
            

Output:

            sum   mean
            city                  
            Rabat       350  175.0
            Casablanca  340  340.0
            Fes         300  100.0
            

groupby follows split, apply, combine: it splits rows by city, aggregates each group, and combines the results into one table indexed by city.

Exercise D7 · Python for Data Science

Filter Rows and Create Categories

Problem: Add a grade column to a DataFrame (fail up to 49, pass 50 to 69, good 70 and above), then keep only the students who did not fail.

Show hint

pd.cut turns numbers into categories using bin edges. Filter with a boolean condition inside df[...].

Show solution
import pandas as pd

            df = pd.DataFrame({
                "name": ["Aya", "Omar", "Lina", "Yassine", "Sara"],
                "score": [82, 45, 67, 70, 49],
            })
            df["grade"] = pd.cut(df["score"], bins=[-1, 49, 69, 100], labels=["fail", "pass", "good"])

            print(df)
            print(df[df["grade"] != "fail"])
            

Output:

      name  score grade
            0      Aya     82  good
            1     Omar     45  fail
            2     Lina     67  pass
            3  Yassine     70  good
            4     Sara     49  fail
                  name  score grade
            0      Aya     82  good
            2     Lina     67  pass
            3  Yassine     70  good
            

The bins are right-inclusive, so (-1, 49] contains 49 and (49, 69] starts at 50. Testing the boundary values 49, 50, 69 and 70 is the best way to catch off-by-one errors in bins.

Exercise D8 · Python for Data Science

Merge Two Tables

Problem: Combine a table of students with a table of grades using the id column. Keep every student, and find the students without a grade.

Show hint

pd.merge(left, right, on="id", how="left") keeps all rows from the left table. Missing matches become NaN.

Show solution
import pandas as pd

            students = pd.DataFrame({"id": [1, 2, 3, 4], "name": ["Aya", "Omar", "Lina", "Yassine"]})
            grades = pd.DataFrame({"id": [1, 2, 4], "score": [14, 11, 9]})

            merged = pd.merge(students, grades, on="id", how="left")
            print(merged)
            print(merged[merged["score"].isna()]["name"].tolist())
            

Output:

   id     name  score
            0   1      Aya   14.0
            1   2     Omar   11.0
            2   3     Lina    NaN
            3   4  Yassine    9.0
            ['Lina']
            

A left join answers "who is missing?" because unmatched rows survive with NaN. An inner join (how="inner") would silently drop Lina. Always check the row count after a merge, because duplicate keys can multiply rows.