Exercise 01 · Variables
Calculate the sum of two numbers
Create two variables, a and b, assign them values, and print their sum.
Expected output:
15Show solution
a = 7
b = 8
print(a + b)Python adds the two values and prints the result.
PRACTICE & IMPROVE
Learn Python by solving practical programming problems. Start with the basics, improve your problem-solving skills, and progress towards data science.
FULL GUIDE
Functions, lambdas, recursion, classes, number problems, series, and patterns, with hints and solutions.
Open the complete guide →LEARNING PATH
Select a subject and practise one concept at a time.
Practise variables, numbers, strings, and type conversion.
10 exercisesExplore →BeginnerSolve problems using if, elif, and else.
10 exercisesExplore →BeginnerMaster for loops, while loops, and nested loops.
15 exercisesExplore →IntermediatePractise collections, searching, sorting, and transformations.
8 exercisesExplore →IntermediateWork with parameters, return values, and reusable code.
8 exercisesExplore →AdvancedPractise NumPy, Pandas, and basic data analysis.
8 exercisesExplore →START HERE
Exercise 01 · Variables
Create two variables, a and b, assign them values, and print their sum.
Expected output:
15a = 7
b = 8
print(a + b)Python adds the two values and prints the result.
Exercise 02 · Conditions
Write a program that checks whether an integer is even or odd.
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
Use a for loop to print the integers from 1 to 10.
for number in range(1, 11):
print(number)The upper limit of range() is excluded, so we use 11.
Exercise B1 · Beginner
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.
int() raises ValueError for text such as "abc". Catch it with try/except.
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
Problem: Write greet(name, greeting="Hello", punctuation="!") that returns a greeting string.
Example: greet("Aya") returns Hello, Aya!; greet("Omar", "Welcome", ".") returns Welcome, Omar.
Use an f-string and keep defaulted parameters after name.
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
Problem: From a mixed list, return a new list containing only the strings.
Example: ["abc", 34.56, 32, "b", 55] gives ['abc', 'b'].
isinstance(x, str) is true for strings. A list comprehension keeps the code short.
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
Problem: Write average(*numbers). Raise ValueError if no numbers are given.
Example: average(4, 8, 15) gives 9.0.
numbers is a tuple. Check len(numbers) before dividing.
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
Problem: Write star_triangle(n) that prints n rows, where row i has i stars separated by spaces.
Example (n = 4):
*
* *
* * *
* * * *
"*" repeated i times, joined by spaces: " ".join("*" * i).
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
Problem: For n = 5, print rows 1, 1 2, 1 2 3, 1 2 3 4, 1 2 3 4 5.
The inner loop runs j from 1 to i. Or unpack a range with print(*range(1, i + 1)).
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
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].
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.
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
Number problems, series, and pattern printing.
Exercise I1 · Intermediate
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.
Row i has i numbers. Row i starts right after the last number of row i - 1, which is i * (i - 1) // 2.
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
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].
Start with a list of True flags. For each prime p, cross out its multiples starting at p * p.
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
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).
range(1, n) excludes n, which is exactly the proper-divisor range.
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
Problem: A pronic number is the product of two consecutive integers, such as 7 * 8 = 56. List the pronic numbers from 1 to 100.
If n = k * (k + 1), then k * k <= n < (k + 1) * (k + 1), so k = math.isqrt(n).
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
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.
Peel digits with % 10 and // 10 and add math.factorial(digit). Precompute factorials of 0-9 for speed.
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
Problem: Compute 9 + 13 + 17 + ... for N terms with a loop, and check it against the closed form.
Example: N = 20 gives 940.
The closed form is N * (2 * first + (N - 1) * step) // 2.
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
Problem: Compute (a) 5 + 25 + 125 + ... for 10 terms, and (b) the alternating series 2 - 6 + 18 - 54 + ... for 20 terms.
For (a) the ratio is 5. For (b) the ratio is -3; negative ratios flip the sign automatically.
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
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.
Use slicing for (a): word[:i + 1]. For (b) repeat a one-letter list.
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
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
Indent by 2 * (n - i) spaces, then print the numbers 1 to 2i - 1.
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
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).
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.
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
Lambdas, recursion, object-oriented programming, and algorithmic puzzles.
Exercise A1 · Advanced
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)].
A key can return a tuple. Negate the score to sort it descending.
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
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).
gcd(a, b) = gcd(b, a % b), with base case b == 0. And lcm(a, b) = a * b // gcd(a, b).
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
Problem: Print (a) the pyramid A, ABA, ABCBA, ... and (b) the mirrored pyramid E, DED, CDEDC, BCDEDCB, ABCDEDCBA, both for n = 5.
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.
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
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
Build the row numbers 1, 2, ..., n, n-1, ..., 1, then print each row with indent 2 * (n - k).
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
Problem: Using the encoding from the strings section, write a decode function that rejects invalid input, and prove it works with a round trip.
Round trip: decode(encode(text)) == text for any text made of letters and spaces. Test many strings automatically.
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
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).
Use __sub__, __eq__, and __abs__. In __eq__, return NotImplemented for other types.
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
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.
Each class appends its label and calls super().process(...). The Base class ends the chain.
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
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.
For (a), term i is 2i / (11 - i). For (b), term i is (x + 2i) / (10 * 3 ** (i - 1)).
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
Problem: A number is automorphic when its square ends with the number itself (25 * 25 = 625). Find all automorphic numbers below 1000.
Compare n * n % 10 ** len(str(n)) with n.
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
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.
Keep a counter outside the outer loop, as in Floyd's triangle, and use chr(65 + counter % 26).
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.
Exercise L1 · Lists & Dictionaries
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.
A set removes duplicates. Sort the distinct values in descending order and take index 1.
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
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'].
Since Python 3.7, dictionaries remember insertion order. dict.fromkeys(items) creates keys in order and ignores repeats.
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
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.
Use counts[word] = counts.get(word, 0) + 1. Sort items with the key lambda pair: (-pair[1], pair[0]).
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
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']}.
groups.setdefault(letter, []) returns the existing list or creates an empty one.
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
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].
Rotating by len(items) returns the original list, so use k % len(items). Then combine two slices.
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
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}.
Copy the first dictionary, then loop through the second and use get(key, 0).
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
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']}.
A plain {v: k for k, v in d.items()} would lose "a", because the later key overwrites the earlier one.
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
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].
zip(*rows) pairs the first elements of every row, then the second elements, and so on.
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.
Exercise F1 · Functions
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).
A function can return a tuple. The caller unpacks it: lo, hi, avg = stats(data).
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
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.
Keep only characters where c.isalnum() is true, lowercase them, and compare the result with its reverse.
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
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.
Plain recursion recomputes the same values again and again. functools.lru_cache stores results of earlier calls.
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
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.
Functions are objects: you can pass them as arguments and return them from other functions.
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
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.
An inner function remembers variables of the enclosing function. To change such a variable, declare it nonlocal.
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
Problem: Write a decorator log_calls that prints the arguments of each call and the returned value, without changing the function's name.
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.
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
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.
Every parameter after a bare * must be given as a keyword argument.
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
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.
Use yield inside an infinite loop and itertools.islice to take a limited number of values.
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.
Exercise D1 · Python for Data Science
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.
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).
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
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.
NumPy applies arithmetic to a whole array at once (vectorization), so no loop is needed.
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
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.
A comparison on an array returns a boolean array. Use it as an index, or pass it to np.where.
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
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.
Create columns from a dictionary of lists. New columns are made by assignment: df["average"] = ....
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
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.
Use isna().sum(), fillna(value), and dropna(). Missing numbers are NaN (np.nan).
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
Problem: From sales records, compute the total and average sales per city, sorted by total descending.
df.groupby("city")["sales"].agg(["sum", "mean"]), then sort_values.
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
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.
pd.cut turns numbers into categories using bin edges. Filter with a boolean condition inside df[...].
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
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.
pd.merge(left, right, on="id", how="left") keeps all rows from the left table. Missing matches become NaN.
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.