Python Interview Guide
Comprehensive interview questions and answers to help you prepare for technical interviews.
๐ Day 1: Python Basics & NumPy Foundations
Here are all the answers formatted for direct copy-paste:
Q1. Explain the difference between mutable and immutable data types in Python with one example of each. [3 Marks]
Answer:
| Feature | Mutable | Immutable |
|---|---|---|
| Can be changed after creation | โ Yes โ in place | โ No โ a new object is created |
| Memory address changes on modification | โ No โ same object | โ Yes โ new object at new address |
| Examples | list, dict, set, bytearray |
int, float, str, tuple, bool, frozenset |
| Safe as dictionary key | โ No | โ Yes |
Mutable example โ list (modified in place, same memory address):
my_list = [1, 2, 3]
print(id(my_list)) # e.g., 140234567890
print(my_list) # [1, 2, 3]
my_list.append(4) # modifies the original object
print(id(my_list)) # SAME address โ same object in memory
print(my_list) # [1, 2, 3, 4]
my_list[0] = 99 # element-level modification also allowed
print(my_list) # [99, 2, 3, 4]
Immutable example โ string (cannot be modified, new object created):
my_str = "hello"
print(id(my_str)) # e.g., 140234111111
my_str = my_str + " world" # looks like modification โ actually creates a NEW string
print(id(my_str)) # DIFFERENT address โ new object
print(my_str) # "hello world"
# Attempting direct character modification raises an error:
try:
my_str[0] = "H" # โ TypeError: 'str' object does not support item assignment
except TypeError as e:
print(e)
Why it matters โ aliasing risk with mutable types:
# Mutable: both variables point to the SAME object
a = [1, 2, 3]
b = a # b is NOT a copy โ it's the same list
b.append(4)
print(a) # [1, 2, 3, 4] โ a is affected too!
# Safe copy:
b = a.copy() # or list(a) or a[:]
b.append(5)
print(a) # [1, 2, 3, 4] โ a is unaffected now
# Immutable: no aliasing risk
x = "hello"
y = x
y = y + "!"
print(x) # "hello" โ x is unaffected
Q2. What is the output of: print([1,2,3] * 3) and print('ab' * 3)? Explain why. [2 Marks]
Answer:
print([1, 2, 3] * 3) # Output: [1, 2, 3, 1, 2, 3, 1, 2, 3]
print('ab' * 3) # Output: ababab
Explanation:
The * operator when used with a sequence (list, string, tuple) and an integer n performs repetition โ it creates a new sequence by concatenating the original sequence with itself n times.
List repetition:
original = [1, 2, 3]
result = [1, 2, 3] * 3
# Internally equivalent to:
# [1, 2, 3] + [1, 2, 3] + [1, 2, 3]
# = [1, 2, 3, 1, 2, 3, 1, 2, 3]
print(result) # [1, 2, 3, 1, 2, 3, 1, 2, 3]
print(len(result)) # 9 (3 elements ร 3 repetitions)
String repetition:
result = 'ab' * 3
# Internally equivalent to:
# 'ab' + 'ab' + 'ab'
# = 'ababab'
print(result) # ababab
print(len(result)) # 6 (2 characters ร 3 repetitions)
Important caveat โ shallow copy warning with nested mutable objects:
# โ Dangerous: repetition creates shallow copies โ inner lists share references
nested = [[0] * 3] * 3
print(nested) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
nested[0][0] = 99
print(nested) # [[99, 0, 0], [99, 0, 0], [99, 0, 0]] โ all rows changed!
# โ
Safe: use list comprehension for independent inner lists
nested = [[0] * 3 for _ in range(3)]
nested[0][0] = 99
print(nested) # [[99, 0, 0], [0, 0, 0], [0, 0, 0]] โ only first row changed
Q3. Write a function that takes a list of numbers and returns a new list with only the even numbers, using list comprehension. [3 Marks]
Answer:
def filter_even_numbers(numbers):
"""
Returns a new list containing only the even numbers from the input list.
Args:
numbers (list): A list of integers or floats.
Returns:
list: A new list with only even numbers.
"""
return [num for num in numbers if num % 2 == 0]
# --- Test cases ---
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print(filter_even_numbers(numbers)) # [2, 4, 6, 8, 10]
mixed = [-4, -3, -2, -1, 0, 1, 2, 3]
print(filter_even_numbers(mixed)) # [-4, -2, 0, 2]
empty = []
print(filter_even_numbers(empty)) # []
floats = [1.0, 2.0, 3.5, 4.0]
print(filter_even_numbers(floats)) # [2.0, 4.0] (works for floats too)
Equivalent forms โ showing the progression:
# Form 1: Traditional for loop (most explicit)
def filter_even_loop(numbers):
result = []
for num in numbers:
if num % 2 == 0:
result.append(num)
return result
# Form 2: List comprehension (concise โ preferred Pythonic style)
def filter_even_comprehension(numbers):
return [num for num in numbers if num % 2 == 0]
# Form 3: Using filter() + lambda (functional style)
def filter_even_functional(numbers):
return list(filter(lambda num: num % 2 == 0, numbers))
# Form 4: Generator expression (memory-efficient for large lists)
def filter_even_generator(numbers):
return list(num for num in numbers if num % 2 == 0)
# All four produce identical results:
data = [1, 2, 3, 4, 5, 6]
print(filter_even_loop(data)) # [2, 4, 6]
print(filter_even_comprehension(data)) # [2, 4, 6]
print(filter_even_functional(data)) # [2, 4, 6]
print(filter_even_generator(data)) # [2, 4, 6]
Q4. Explain the difference between range() and enumerate(). When would you use enumerate? [3 Marks]
Answer:
| Feature | range() |
enumerate() |
|---|---|---|
| Purpose | Generates a sequence of integers | Adds an index counter to any iterable |
| Works on | Numbers only | Any iterable (list, string, tuple, etc.) |
| Returns | range object (lazy sequence of ints) |
enumerate object (lazy pairs of index + value) |
| Output per iteration | Single integer | Tuple (index, value) |
| Starting index | Configurable with start param |
Configurable with start param (default 0) |
range() โ when you only need a sequence of numbers:
# Generate numbers 0 to 4
for i in range(5):
print(i) # 0, 1, 2, 3, 4
# Generate with start, stop, step
for i in range(2, 10, 2):
print(i) # 2, 4, 6, 8
# Access list by index using range (less Pythonic)
fruits = ['apple', 'banana', 'cherry']
for i in range(len(fruits)):
print(i, fruits[i]) # 0 apple, 1 banana, 2 cherry โ verbose โ
enumerate() โ when you need both index AND value:
# Clean, Pythonic way to get index + value simultaneously
fruits = ['apple', 'banana', 'cherry']
for index, fruit in enumerate(fruits):
print(index, fruit)
# 0 apple
# 1 banana
# 2 cherry
# Custom start index (e.g., 1-based numbering)
for index, fruit in enumerate(fruits, start=1):
print(f"{index}. {fruit}")
# 1. apple
# 2. banana
# 3. cherry
# Works on strings
for i, char in enumerate("hello"):
print(f"Position {i}: {char}")
# Position 0: h
# Position 1: e
# ...
When to use enumerate โ practical scenarios:
# 1. Numbering output for display
students = ['Alice', 'Bob', 'Carol']
for rank, name in enumerate(students, start=1):
print(f"Rank {rank}: {name}")
# 2. Modifying a list while tracking position
scores = [45, 82, 91, 38, 67]
for i, score in enumerate(scores):
if score < 50:
scores[i] = 50 # replace failing scores with minimum pass mark
# 3. Finding index of items meeting a condition
words = ['cat', 'elephant', 'dog', 'rhinoceros']
long_words = [(i, w) for i, w in enumerate(words) if len(w) > 4]
print(long_words) # [(1, 'elephant'), (3, 'rhinoceros')]
Rule of thumb: If you only need numbers, use
range(). If you need to loop over a sequence AND know each element's position, always preferenumerate()overrange(len(sequence))โ it's more Pythonic and readable.
Q5. What is the difference between append() and extend() for lists? Give examples. [2 Marks]
Answer:
| Feature | append(x) |
extend(iterable) |
|---|---|---|
| Adds | One item as a single element (even if it's a list) | Each element of an iterable individually |
| List length increase | Always +1 | +len(iterable) |
| Argument type | Any object | Must be iterable |
| Nesting effect | Can create nested lists | Flattens one level |
# --- append(): adds the WHOLE argument as ONE element ---
my_list = [1, 2, 3]
my_list.append(4)
print(my_list) # [1, 2, 3, 4] โ added integer
print(len(my_list)) # 4
my_list.append([5, 6]) # adds the list as a single element
print(my_list) # [1, 2, 3, 4, [5, 6]] โ nested list created!
print(len(my_list)) # 5
# --- extend(): unpacks and adds EACH element from the iterable ---
my_list = [1, 2, 3]
my_list.extend([4, 5, 6]) # each element of [4,5,6] added individually
print(my_list) # [1, 2, 3, 4, 5, 6]
print(len(my_list)) # 6
my_list.extend("abc") # strings are iterable โ each character added
print(my_list) # [1, 2, 3, 4, 5, 6, 'a', 'b', 'c']
my_list.extend((7, 8)) # tuples work too
print(my_list) # [1, 2, 3, 4, 5, 6, 'a', 'b', 'c', 7, 8]
Side-by-side comparison:
list_a = [1, 2, 3]
list_b = [1, 2, 3]
list_a.append([4, 5]) # [1, 2, 3, [4, 5]] โ length = 4, nested
list_b.extend([4, 5]) # [1, 2, 3, 4, 5] โ length = 5, flat
# extend() is equivalent to using += operator
list_c = [1, 2, 3]
list_c += [4, 5] # same as extend([4, 5])
print(list_c) # [1, 2, 3, 4, 5]
Q6. Write a Python function to check if a given string is a palindrome. [2 Marks]
Answer:
def is_palindrome(text):
"""
Checks if the given string is a palindrome.
Case-insensitive and ignores spaces and punctuation.
Args:
text (str): The string to check.
Returns:
bool: True if palindrome, False otherwise.
"""
# Normalize: lowercase and remove non-alphanumeric characters
cleaned = ''.join(char.lower() for char in text if char.isalnum())
# A string is a palindrome if it reads the same forwards and backwards
return cleaned == cleaned[::-1]
# --- Test cases ---
print(is_palindrome("racecar")) # True
print(is_palindrome("hello")) # False
print(is_palindrome("A man a plan a canal Panama")) # True (ignores spaces/case)
print(is_palindrome("Was it a car or a cat I saw")) # True
print(is_palindrome("No lemon, no melon")) # True (ignores punctuation)
print(is_palindrome("")) # True (empty string is trivially palindrome)
print(is_palindrome("a")) # True (single char)
print(is_palindrome("Ab")) # False
Alternative implementations:
# Method 1: Simple version (no cleaning โ exact character match)
def is_palindrome_simple(text):
return text == text[::-1]
print(is_palindrome_simple("racecar")) # True
print(is_palindrome_simple("Racecar")) # False (case-sensitive)
# Method 2: Using two-pointer technique (no extra space)
def is_palindrome_two_pointer(text):
cleaned = ''.join(c.lower() for c in text if c.isalnum())
left, right = 0, len(cleaned) - 1
while left < right:
if cleaned[left] != cleaned[right]:
return False
left += 1
right -= 1
return True
# Method 3: Using reversed() iterator
def is_palindrome_reversed(text):
cleaned = ''.join(c.lower() for c in text if c.isalnum())
return cleaned == ''.join(reversed(cleaned))
Q7. Write code to count the frequency of each character in a string without using Counter. [3 Marks]
Answer:
def count_character_frequency(text):
"""
Counts the frequency of each character in a string
without using collections.Counter.
Args:
text (str): Input string.
Returns:
dict: Dictionary mapping each character to its frequency.
"""
frequency = {}
for char in text:
if char in frequency:
frequency[char] += 1 # increment existing key
else:
frequency[char] = 1 # initialize new key
return frequency
# --- Test ---
text = "hello world"
freq = count_character_frequency(text)
print(freq)
# {'h': 1, 'e': 1, 'l': 3, 'o': 2, ' ': 1, 'w': 1, 'r': 1, 'd': 1}
Alternative approaches:
# Method 2: Using dict.get() โ more concise, no if/else
def count_freq_get(text):
frequency = {}
for char in text:
frequency[char] = frequency.get(char, 0) + 1
return frequency
# Method 3: Using setdefault()
def count_freq_setdefault(text):
frequency = {}
for char in text:
frequency.setdefault(char, 0)
frequency[char] += 1
return frequency
# Method 4: Sorted output โ display results cleanly
def count_and_display(text):
frequency = {}
for char in text:
frequency[char] = frequency.get(char, 0) + 1
print(f"\nCharacter frequencies in: '{text}'")
print("-" * 30)
# Sort by frequency descending, then alphabetically
for char, count in sorted(frequency.items(), key=lambda x: (-x[1], x[0])):
bar = 'โ' * count
label = repr(char) # shows spaces as ' '
print(f" {label:>4} : {bar} ({count})")
return frequency
count_and_display("programming")
# Output:
# Character frequencies in: 'programming'
# ------------------------------
# 'g' : โโ (2)
# 'm' : โโ (2)
# 'r' : โโ (2)
# 'a' : โ (1)
# 'i' : โ (1)
# 'n' : โ (1)
# 'o' : โ (1)
# 'p' : โ (1)
Q8. Explain the difference between break, continue, and pass statements. [2 Marks]
Answer:
| Statement | Effect on loop | Use case |
|---|---|---|
break |
Exits the loop entirely โ no more iterations | Stop searching when target is found |
continue |
Skips current iteration โ jumps to next | Skip unwanted items while continuing the loop |
pass |
Does nothing โ placeholder, loop continues normally | Satisfy syntax requirement where a statement is needed but no action is desired |
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# --- BREAK: exits the loop as soon as condition is met ---
print("BREAK example:")
for num in numbers:
if num == 5:
print(f" Found 5 โ stopping loop")
break # loop ends here, 6-10 never visited
print(f" Checking: {num}")
# Output: Checking 1, 2, 3, 4 โ then "Found 5 โ stopping loop"
# --- CONTINUE: skips current iteration, moves to next ---
print("\nCONTINUE example:")
for num in numbers:
if num % 2 == 0:
continue # skip even numbers
print(f" Odd: {num}")
# Output: Odd: 1, 3, 5, 7, 9 (even numbers skipped)
# --- PASS: does nothing โ placeholder ---
print("\nPASS example:")
for num in numbers:
if num % 2 == 0:
pass # placeholder โ no action for evens
else:
print(f" Odd: {num}")
# Output: Odd: 1, 3, 5, 7, 9 (same as above, but pass didn't skip โ else handled it)
# Common pass use cases:
class MyClass:
pass # empty class โ valid syntax
def my_function():
pass # stub function to implement later
if True:
pass # empty if block (avoids IndentationError)
Key distinction โ continue vs pass:
# continue: skips the REST of the loop body for this iteration
for i in range(5):
if i == 2:
continue
print(i) # prints: 0, 1, 3, 4 (2 is skipped)
# pass: does nothing โ execution continues to the next line normally
for i in range(5):
if i == 2:
pass # does nothing
print(i) # prints: 0, 1, 2, 3, 4 (2 is NOT skipped)
Q9. Write a function that returns the factorial of a number using both iteration and recursion. [3 Marks]
Answer:
# ============================================================
# METHOD 1: ITERATIVE (using a for loop)
# ============================================================
def factorial_iterative(n):
"""
Calculates n! using a loop.
Time: O(n)
Space: O(1) โ no call stack growth
"""
if n < 0:
raise ValueError(f"Factorial is not defined for negative numbers. Got: {n}")
if n == 0 or n == 1:
return 1
result = 1
for i in range(2, n + 1): # multiply 2 ร 3 ร 4 ร ... ร n
result *= i
return result
# ============================================================
# METHOD 2: RECURSIVE
# ============================================================
def factorial_recursive(n):
"""
Calculates n! using recursion.
Time: O(n)
Space: O(n) โ each call adds a frame to the call stack
Base case: 0! = 1 and 1! = 1
Recursive case: n! = n ร (n-1)!
"""
if n < 0:
raise ValueError(f"Factorial is not defined for negative numbers. Got: {n}")
if n == 0 or n == 1: # base case โ stops recursion
return 1
return n * factorial_recursive(n - 1) # recursive case
# ============================================================
# TEST BOTH
# ============================================================
test_values = [0, 1, 2, 5, 7, 10]
print(f"{'n':>4} | {'Iterative':>12} | {'Recursive':>12}")
print("-" * 35)
for n in test_values:
it = factorial_iterative(n)
rec = factorial_recursive(n)
print(f"{n:>4} | {it:>12} | {rec:>12}")
# Output:
# n | Iterative | Recursive
# -----------------------------------
# 0 | 1 | 1
# 1 | 1 | 1
# 2 | 2 | 2
# 5 | 120 | 120
# 7 | 5040 | 5040
# 10 | 3628800 | 3628800
Recursive call stack visualization for factorial(5):
factorial(5)
โโ 5 ร factorial(4)
โโ 4 ร factorial(3)
โโ 3 ร factorial(2)
โโ 2 ร factorial(1)
โโ returns 1 โ base case
returns 2 ร 1 = 2
returns 3 ร 2 = 6
returns 4 ร 6 = 24
returns 5 ร 24 = 120
Q10. What is string slicing? Write code to reverse a string using slicing. [2 Marks]
Answer: String slicing is a technique to extract a portion (substring) of a string by specifying a start index, stop index, and optional step. The syntax is string[start:stop:step].
# Slicing syntax: string[start : stop : step]
# start: index to begin (inclusive), default = 0
# stop: index to end (exclusive), default = len(string)
# step: how many positions to jump, default = 1 (negative = reverse direction)
text = "Hello, World!"
# 0123456789...
# Basic slicing examples
print(text[0:5]) # "Hello" โ chars from index 0 to 4
print(text[7:12]) # "World" โ chars from index 7 to 11
print(text[:5]) # "Hello" โ omit start โ begins at 0
print(text[7:]) # "World!" โ omit stop โ goes to end
print(text[::2]) # "Hlo ol!" โ every second character
print(text[-6:-1]) # "World" โ negative indexing
Reversing a string using slicing:
# The slice [::-1] means:
# start = end of string (default when reversed)
# stop = beginning of string (default when reversed)
# step = -1 (move backwards one step at a time)
def reverse_string(text):
"""Reverses a string using slicing."""
return text[::-1]
# Test cases
print(reverse_string("hello")) # "olleh"
print(reverse_string("Python")) # "nohtyP"
print(reverse_string("racecar")) # "racecar" (palindrome)
print(reverse_string("12345")) # "54321"
print(reverse_string("")) # "" (empty string safe)
print(reverse_string("A man a plan")) # "nalp a nam A"
# Alternative reversal methods (for comparison)
text = "hello"
# Method 2: Using reversed() + join
print(''.join(reversed(text))) # "olleh"
# Method 3: Using a loop
result = ''
for char in text:
result = char + result # prepend each char
print(result) # "olleh"
# Slicing is the most Pythonic and fastest of the three
Q11. Create a 1D NumPy array of 20 evenly spaced numbers between 0 and 10. Explain which function you used. [3 Marks]
Answer:
import numpy as np
# np.linspace(start, stop, num)
# start: first value in the array
# stop: last value in the array (INCLUSIVE by default)
# num: total number of evenly spaced values to generate
arr = np.linspace(0, 10, 20)
print(arr)
# [ 0. 0.52631579 1.05263158 1.57894737 2.10526316
# 2.63157895 3.15789474 3.68421053 4.21052632 4.73684211
# 5.26315789 5.78947368 6.31578947 6.84210526 7.36842105
# 7.89473684 8.42105263 8.94736842 9.47368421 10. ]
print(f"Shape: {arr.shape}") # (20,)
print(f"Number of items: {len(arr)}") # 20
print(f"First element: {arr[0]}") # 0.0
print(f"Last element: {arr[-1]}") # 10.0
print(f"Step size: {arr[1] - arr[0]:.6f}") # 0.526316 (10/19)
print(f"Data type: {arr.dtype}") # float64
Why linspace and not arange:
# np.linspace: specify NUMBER OF POINTS โ guaranteed exactly n elements
arr_linspace = np.linspace(0, 10, 20) # exactly 20 elements โ
print(len(arr_linspace)) # 20
# np.arange: specify STEP SIZE โ number of elements can vary
arr_arange = np.arange(0, 10, 0.5) # step 0.5 โ how many elements?
print(len(arr_arange)) # 20 (but fragile with floating-point steps)
arr_arange2 = np.arange(0, 10.1, 0.5) # tiny change โ different element count!
print(len(arr_arange2)) # 21 (includes 10.0)
# Key difference:
# linspace โ "give me exactly N evenly spaced points between A and B"
# arange โ "give me points from A to B stepping by S" (unreliable for floats)
Additional linspace options:
# Exclude the endpoint
arr_excl = np.linspace(0, 10, 20, endpoint=False)
print(arr_excl[0], arr_excl[-1]) # 0.0 9.5 (10 excluded)
print(len(arr_excl)) # 20
# Get the step size as well
arr, step = np.linspace(0, 10, 20, retstep=True)
print(f"Step size: {step:.6f}") # 0.526316
Q12. What are the main differences between a Python list and a NumPy array? [3 Marks]
Answer:
| Feature | Python List | NumPy Array |
|---|---|---|
| Data types | Can hold mixed types ([1, "a", True]) |
Homogeneous โ all elements same dtype |
| Memory | More memory (each element is a Python object) | Much less memory (raw C-type data, no object overhead) |
| Speed | Slow for numerical operations (Python loops) | Very fast โ vectorized C/Fortran operations |
| Operations | No element-wise math (must use loops) | Element-wise math out of the box |
| Dimensions | 1D only (nested lists for 2D+) | Native n-dimensional arrays |
| Broadcasting | โ Not supported | โ Supported โ operates on different-shaped arrays |
| Built-in math | No (need manual loops or list comprehensions) | Rich: np.sum, np.mean, np.dot, np.sqrt, etc. |
import numpy as np
# ---- MIXED TYPES: List allows, NumPy converts ---
py_list = [1, "two", 3.0, True]
print(py_list) # [1, 'two', 3.0, True] โ mixed โ
np_array = np.array([1, "two", 3.0, True])
print(np_array) # ['1' 'two' '3.0' 'True'] โ all converted to str โ ๏ธ
print(np_array.dtype) # <U32 (Unicode string โ widest common type)
# ---- SPEED: NumPy is dramatically faster for math ---
import time
size = 1_000_000
# Python list: manual loop required
py = list(range(size))
start = time.time()
result_list = [x * 2 for x in py]
print(f"List time: {time.time() - start:.4f}s") # ~0.10s
# NumPy array: vectorized C operation
np_arr = np.arange(size)
start = time.time()
result_np = np_arr * 2 # no loop needed
print(f"NumPy time: {time.time() - start:.4f}s") # ~0.001s โ ~100x faster
# ---- ELEMENT-WISE OPERATIONS ---
a = [1, 2, 3]
b = [4, 5, 6]
# List: + concatenates, does NOT add element-wise
print(a + b) # [1, 2, 3, 4, 5, 6] โ concatenation
# NumPy: + adds element-wise
na = np.array([1, 2, 3])
nb = np.array([4, 5, 6])
print(na + nb) # [5, 7, 9] โ element-wise addition โ
print(na * nb) # [4, 10, 18]
print(na ** 2) # [1, 4, 9]
# ---- MEMORY COMPARISON ---
import sys
py_list_mem = sys.getsizeof([0] * 1000)
np_array_mem = np.zeros(1000, dtype=np.int64).nbytes
print(f"List memory: {py_list_mem} bytes")
print(f"Array memory: {np_array_mem} bytes") # significantly smaller
Q13. Write code to create a 4x4 identity matrix and then add 5 to every element. [3 Marks]
Answer:
import numpy as np
# Step 1: Create a 4x4 identity matrix using np.eye()
# np.eye(n) creates an nรn matrix with 1s on the main diagonal, 0s elsewhere
identity = np.eye(4)
print("4x4 Identity Matrix:")
print(identity)
# [[1. 0. 0. 0.]
# [0. 1. 0. 0.]
# [0. 0. 1. 0.]
# [0. 0. 0. 1.]]
print(f"Shape: {identity.shape}") # (4, 4)
print(f"DType: {identity.dtype}") # float64
# Step 2: Add 5 to every element using broadcasting (scalar + array)
result = identity + 5
print("\nAfter adding 5 to every element:")
print(result)
# [[6. 5. 5. 5.]
# [5. 6. 5. 5.]
# [5. 5. 6. 5.]
# [5. 5. 5. 6.]]
Extended operations on the identity matrix:
import numpy as np
# Create identity matrix as integer type
identity_int = np.eye(4, dtype=int) # dtype=int for integer 1s and 0s
print("Integer identity matrix:")
print(identity_int)
# [[1 0 0 0]
# [0 1 0 0]
# [0 0 1 0]
# [0 0 0 1]]
# Add 5 (scalar broadcasting applies to every element)
result = identity_int + 5
print("\nAfter adding 5:")
print(result)
# [[6 5 5 5]
# [5 6 5 5]
# [5 5 6 5]
# [5 5 5 6]]
# Other operations on identity matrix
print("\nMultiply every element by 3:")
print(identity_int * 3)
# [[3 0 0 0]
# [0 3 0 0]
# [0 0 3 0]
# [0 0 0 3]]
# np.identity() is an alternative to np.eye()
identity_v2 = np.identity(4, dtype=int)
print("\nnp.identity() produces the same result:")
print(np.array_equal(identity_int, identity_v2)) # True
# Verify specific properties
print(f"\nSum of all elements (original): {identity_int.sum()}") # 4 (trace)
print(f"Sum of all elements (+5 added): {result.sum()}") # 4 + 5*16 = 84
print(f"Diagonal elements: {np.diag(result)}") # [6 6 6 6]
Q14. Explain array slicing in NumPy. How do you select the second column of a 2D array? [3 Marks]
Answer: NumPy array slicing uses the syntax array[row_slice, col_slice] for 2D arrays, where each dimension can be independently sliced using start:stop:step โ exactly like Python list slicing but extended to multiple dimensions simultaneously.
import numpy as np
# Create a sample 4x5 2D array for demonstration
arr = np.array([
[10, 20, 30, 40, 50], # row 0
[60, 70, 80, 90, 100], # row 1
[11, 22, 33, 44, 55], # row 2
[66, 77, 88, 99, 111] # row 3
])
# col0 col1 col2 col3 col4
print(arr.shape) # (4, 5)
# ---- BASIC 1D SLICING REVIEW ----
row0 = arr[0] # entire row 0: [10 20 30 40 50]
row0_slice = arr[0, 1:4] # row 0, cols 1-3: [20 30 40]
# ---- SELECTING THE SECOND COLUMN (index 1) ----
# Syntax: arr[all_rows, column_1]
second_column = arr[:, 1] # : means "all rows", 1 = column index
print("Second column:", second_column) # [20 70 22 77]
print("Shape:", second_column.shape) # (4,) โ 1D array of 4 elements
Comprehensive slicing examples:
import numpy as np
arr = np.arange(1, 26).reshape(5, 5) # 5x5 matrix with values 1-25
print("Original array:")
print(arr)
# [[ 1 2 3 4 5]
# [ 6 7 8 9 10]
# [11 12 13 14 15]
# [16 17 18 19 20]
# [21 22 23 24 25]]
# Select entire column by index
print("\nColumn 0 (first): ", arr[:, 0]) # [ 1 6 11 16 21]
print("Column 1 (second): ", arr[:, 1]) # [ 2 7 12 17 22]
print("Column 4 (last): ", arr[:, -1]) # [ 5 10 15 20 25]
# Select entire row by index
print("\nRow 0 (first): ", arr[0, :]) # [1 2 3 4 5]
print("Row 2 (third): ", arr[2, :]) # [11 12 13 14 15]
print("Row -1 (last): ", arr[-1, :]) # [21 22 23 24 25]
# Select a submatrix (rows 1-3, columns 1-3)
print("\nSubmatrix [1:4, 1:4]:")
print(arr[1:4, 1:4])
# [[ 7 8 9]
# [12 13 14]
# [17 18 19]]
# Select every other row and column
print("\nEvery other row and column [::2, ::2]:")
print(arr[::2, ::2])
# [[ 1 3 5]
# [11 13 15]
# [21 23 25]]
# Select multiple specific columns using fancy indexing
print("\nColumns 0, 2, 4 using fancy indexing:")
print(arr[:, [0, 2, 4]])
# [[ 1 3 5]
# [ 6 8 10]
# [11 13 15]
# [16 18 20]
# [21 23 25]]
Q15. What does np.reshape(-1, 3) do? Explain the meaning of -1 in reshape. [3 Marks]
Answer: np.reshape(-1, 3) reshapes an array into a 2D array with 3 columns, where the -1 tells NumPy to automatically calculate the number of rows needed โ based on the total number of elements and the specified dimensions.
The -1 is a placeholder meaning: "figure this out for me."
import numpy as np
# Rule: total elements = rows ร columns must remain constant
# If total = 12 and columns = 3, then rows = 12/3 = 4
# -1 means: "I don't care what this dimension is โ calculate it"
arr = np.arange(1, 13) # 12 elements: [1, 2, 3, ..., 12]
print("Original:", arr)
print("Shape:", arr.shape) # (12,)
# reshape(-1, 3): 3 columns, rows = 12/3 = 4 (auto-calculated)
reshaped = arr.reshape(-1, 3)
print("\nAfter reshape(-1, 3):")
print(reshaped)
# [[ 1 2 3]
# [ 4 5 6]
# [ 7 8 9]
# [10 11 12]]
print("Shape:", reshaped.shape) # (4, 3) โ NumPy calculated 4 rows automatically
-1 in different positions:
import numpy as np
arr = np.arange(24) # 24 elements
print(f"Total elements: {arr.size}") # 24
# -1 as rows: calculate rows automatically given columns
print(arr.reshape(-1, 4).shape) # (6, 4) โ 24/4 = 6 rows
print(arr.reshape(-1, 6).shape) # (4, 6) โ 24/6 = 4 rows
print(arr.reshape(-1, 8).shape) # (3, 8) โ 24/8 = 3 rows
# -1 as columns: calculate columns automatically given rows
print(arr.reshape(4, -1).shape) # (4, 6) โ 24/4 = 6 cols
print(arr.reshape(3, -1).shape) # (3, 8) โ 24/3 = 8 cols
print(arr.reshape(6, -1).shape) # (6, 4) โ 24/6 = 4 cols
# -1 to flatten back to 1D
print(arr.reshape(4, 6).reshape(-1).shape) # (24,) โ back to 1D
# Error: -1 used in more than one dimension
try:
arr.reshape(-1, -1)
except ValueError as e:
print(f"Error: {e}") # "can only specify one unknown dimension"
# Error: incompatible total size
try:
arr.reshape(-1, 7) # 24 / 7 = 3.43 โ not a whole number
except ValueError as e:
print(f"Error: {e}") # "cannot reshape array of size 24 into shape..."
Practical use case โ preparing data for machine learning:
import numpy as np
# Common pattern: flatten a 2D image batch to 1D feature vectors
# Each image is 28ร28 pixels, 100 images
images = np.random.randint(0, 256, size=(100, 28, 28))
print(f"Images shape: {images.shape}") # (100, 28, 28)
# Flatten each image to 1D: (100, 28*28) = (100, 784)
# -1 calculates 28*28 = 784 automatically
flat = images.reshape(100, -1)
print(f"Flattened shape: {flat.shape}") # (100, 784)
# Or reshape a 1D array to a single-column 2D (for sklearn)
x = np.array([1, 2, 3, 4, 5])
print(x.reshape(-1, 1).shape) # (5, 1) โ column vector
Q16. Write a program to find the largest and smallest number in a list without using min() and max(). [4 Marks]
Answer:
def find_min_max(numbers):
"""
Finds the smallest and largest number in a list
without using built-in min() or max() functions.
Args:
numbers (list): A non-empty list of numbers.
Returns:
tuple: (smallest, largest)
Raises:
ValueError: If the list is empty.
"""
if not numbers:
raise ValueError("Cannot find min/max of an empty list.")
# Initialize both with the first element as baseline
smallest = numbers[0]
largest = numbers[0]
# Scan remaining elements and update accordingly
for num in numbers[1:]:
if num < smallest:
smallest = num
if num > largest:
largest = num
return smallest, largest
# --- Test cases ---
data = [34, 7, 23, 32, 5, 62, 78, 1, 45, 99, 3]
smallest, largest = find_min_max(data)
print(f"List: {data}")
print(f"Smallest: {smallest}") # 1
print(f"Largest: {largest}") # 99
# Verify against built-ins
print(f"Correct: {smallest == min(data) and largest == max(data)}") # True
# Edge cases
print(find_min_max([42])) # (42, 42) โ single element
print(find_min_max([-5, -1, -9, -2])) # (-9, -5) โ all negatives
print(find_min_max([3, 3, 3])) # (3, 3) โ all same
print(find_min_max([0, 0.5, -0.5])) # (-0.5, 0.5) โ floats
# Empty list error handling
try:
find_min_max([])
except ValueError as e:
print(f"Error: {e}") # Cannot find min/max of an empty list.
Extended version with position tracking:
def find_min_max_with_index(numbers):
"""Also returns the index of the min and max values."""
if not numbers:
raise ValueError("List is empty.")
smallest_val = numbers[0]
largest_val = numbers[0]
smallest_idx = 0
largest_idx = 0
for i, num in enumerate(numbers[1:], start=1):
if num < smallest_val:
smallest_val = num
smallest_idx = i
if num > largest_val:
largest_val = num
largest_idx = i
return {
"smallest": smallest_val, "smallest_index": smallest_idx,
"largest": largest_val, "largest_index": largest_idx
}
data = [34, 7, 23, 32, 5, 62, 78, 1, 45, 99]
result = find_min_max_with_index(data)
print(result)
# {'smallest': 1, 'smallest_index': 7, 'largest': 99, 'largest_index': 9}
Q17. Write a function that takes two lists and returns their intersection (common elements) without duplicates. [3 Marks]
Answer:
def list_intersection(list1, list2):
"""
Returns a list of unique elements that appear in both list1 and list2.
Does not use set intersection operator directly.
Args:
list1 (list): First list.
list2 (list): Second list.
Returns:
list: Sorted list of common unique elements.
"""
# Convert list2 to a set for O(1) membership lookup
set2 = set(list2)
# Use a set to collect common elements (automatically handles duplicates)
common = set()
for item in list1:
if item in set2:
common.add(item)
return sorted(list(common)) # sorted for consistent, readable output
# --- Test cases ---
a = [1, 2, 3, 4, 5, 2, 3]
b = [3, 4, 5, 6, 7, 3, 4]
print(list_intersection(a, b)) # [3, 4, 5]
x = [10, 20, 30, 40]
y = [50, 60, 70, 80]
print(list_intersection(x, y)) # [] โ no common elements
p = [1, 2, 3]
q = [1, 2, 3]
print(list_intersection(p, q)) # [1, 2, 3] โ identical lists
# String elements
words1 = ['apple', 'banana', 'cherry', 'apple']
words2 = ['banana', 'date', 'cherry', 'banana']
print(list_intersection(words1, words2)) # ['banana', 'cherry']
Multiple implementation approaches:
# Method 1: Manual loop with set (shown above โ best balance of clarity + speed)
def intersection_manual(list1, list2):
set2 = set(list2)
common = set()
for item in list1:
if item in set2:
common.add(item)
return sorted(list(common))
# Method 2: List comprehension (concise, but O(nยฒ) without set conversion)
def intersection_comprehension(list1, list2):
set2 = set(list2)
return sorted(list({item for item in list1 if item in set2}))
# Method 3: Using set & operator (most Pythonic โ if using sets is acceptable)
def intersection_set_operator(list1, list2):
return sorted(list(set(list1) & set(list2)))
# Method 4: Pure loop โ no set, O(nยฒ) (slowest, for understanding)
def intersection_pure_loop(list1, list2):
common = []
for item in list1:
if item in list2 and item not in common:
common.append(item)
return sorted(common)
# All produce identical results
a, b = [1, 2, 3, 4, 3, 2], [2, 4, 6, 2, 4]
print(intersection_manual(a, b)) # [2, 4]
print(intersection_comprehension(a, b)) # [2, 4]
print(intersection_set_operator(a, b)) # [2, 4]
print(intersection_pure_loop(a, b)) # [2, 4]
Q18. Given a list of numbers, write code to separate positive and negative numbers into two different lists. [3 Marks]
Answer:
def separate_positive_negative(numbers):
"""
Separates a list of numbers into positives and negatives.
Zero is neither positive nor negative โ placed in a third list.
Args:
numbers (list): List of integers or floats.
Returns:
tuple: (positives, negatives, zeros)
"""
positives = []
negatives = []
zeros = []
for num in numbers:
if num > 0:
positives.append(num)
elif num < 0:
negatives.append(num)
else:
zeros.append(num)
return positives, negatives, zeros
# --- Test ---
data = [10, -3, 0, 7, -8, 15, -1, 0, 22, -9, 4, -6]
pos, neg, zer = separate_positive_negative(data)
print(f"Original: {data}")
print(f"Positives: {pos}") # [10, 7, 15, 22, 4]
print(f"Negatives: {neg}") # [-3, -8, -1, -9, -6]
print(f"Zeros: {zer}") # [0, 0]
Alternative implementations:
# Method 2: List comprehension (concise)
def separate_comprehension(numbers):
positives = [n for n in numbers if n > 0]
negatives = [n for n in numbers if n < 0]
zeros = [n for n in numbers if n == 0]
return positives, negatives, zeros
# Method 3: Using filter() with lambda
def separate_filter(numbers):
positives = list(filter(lambda n: n > 0, numbers))
negatives = list(filter(lambda n: n < 0, numbers))
zeros = list(filter(lambda n: n == 0, numbers))
return positives, negatives, zeros
# Method 4: NumPy vectorized approach (fastest for large arrays)
import numpy as np
def separate_numpy(numbers):
arr = np.array(numbers)
positives = arr[arr > 0].tolist()
negatives = arr[arr < 0].tolist()
zeros = arr[arr == 0].tolist()
return positives, negatives, zeros
# --- Verify all methods produce the same result ---
data = [10, -3, 0, 7, -8, 15, -1, 0, 22, -9]
print(separate_comprehension(data))
print(separate_filter(data))
print(separate_numpy(data))
# All output: ([10, 7, 15, 22], [-3, -8, -1, -9], [0, 0])
# --- Useful statistics after separation ---
pos, neg, zer = separate_positive_negative(data)
print(f"\nCount โ Positive: {len(pos)}, Negative: {len(neg)}, Zero: {len(zer)}")
print(f"Sum โ Positive: {sum(pos)}, Negative: {sum(neg)}")
print(f"Largest positive: {max(pos) if pos else 'N/A'}")
print(f"Smallest negative: {min(neg) if neg else 'N/A'}")