โ† Back to Interview Preparation
๐Ÿ

Python Interview Guide

Comprehensive interview questions and answers to help you prepare for technical interviews.

๐Ÿ“‚ Programming ๐Ÿ’ผ 10 Related Roles

๐Ÿ“… Day 1: Python Basics & NumPy Foundations

Beginner 50 Marks 90 Min

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 prefer enumerate() over range(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'}")

๐ŸŽค Start Mock Interview ๐Ÿ“š View All Skills

๐Ÿ’ผ Related Job Roles

โš™๏ธ Backend Developer ๐Ÿ”„ Full Stack Developer ๐Ÿ“Š Data Analyst ๐Ÿงช Data Scientist ๐Ÿš€ DevOps Engineer โœ… QA Engineer ๐Ÿค– Machine Learning Engineer โ˜๏ธ Cloud Engineer ๐Ÿ Python Developer ๐Ÿ”ง Data Engineer