Memory Atlas Β· Data processing

Python

Build a durable mental model of how Spark plans, moves, and optimizes distributed workβ€”then retrieve it under interview pressure.

Chapters
06
Advanced
01
Mode
Recall

Read for structure. Pause at each memory map and answer before revealing the detail.

Foundation

Python Recall Atlas

#

Python Recall Atlas

60-second map

Answer First: Python interviews reward precise reasoning about references and mutability, fluent use of core collections and iteration, and the ability to choose a clear algorithm with explicit time-space tradeoffs.

Memory Map: values -> references -> collections -> iteration -> functions -> resources -> runtime. Predict identity and mutation first, select the data structure second, then explain edge cases and complexity.

Choose the owner

Interview signalCanonical owner
String scan, frequency, sliding window, two pointers, matrix puzzleStrings and coding puzzles
Identity, aliasing, scope, evaluation order, output predictionTricky output and semantics
Sets, counters, sorting, stacks, iterators, formattingData structures and patterns
Confusions, runtime behavior, cleanup, modules, mock interviewScenarios and labs
Prompt lookup and alternate wordingCanonical question index

Interview-solving loop

  1. State the input and output shapes, including empty input, duplicates, ordering, and tie behavior.
  2. Predict which names share an object before discussing mutation or output.
  3. Name the invariant and the fitting primitive: set, dict, Counter, deque, heap, iterator, context manager, or function wrapper.
  4. Start with a correct readable baseline; optimize only after stating measured time and space costs.
  5. Run the smallest counterexample that could break the idea.

Legacy question-bank scope

The original question bank is a quick-fire fundamentals set for data-engineering interviews. Its twenty complete explanations, examples, outputs, tips, and caveats now live beside their concept owners; the final chapter is a link-only index.

βœ… Pro Tip
Quick-fire questions that interviewers ask to check your Python fundamentals. Each question includes multiple code examples, clear outputs, and interview tips.
Intermediate

Python Strings and Coding Puzzles

#

Python Strings and Coding Puzzles

This chapter retains the complete legacy examples, outputs, caveats, comparisons, and interview tips for its owner domain.

Legacy source guide: Python Strings & Puzzles β€” Data Engineer Interview Prep

πŸ’‘ Interview Tip
Not testing you as a Python developer β€” testing if you THINK in Python. Data engineers use PySpark, but interviewers check: can you solve a quick string problem without Google?

Strings and puzzles source memory map

🧠 STRING PUZZLES β†’ DRALF
STRING PUZZLESDRALF
─────────────────────────
DDuplicates (find repeated chars)
RReverse (string, words, sentence)
AAnagram (same letters, diff order)
LLongest (substring without repeating)
FFrequency (char count, most common)

Answer First: Count each character once, then return characters whose frequency is greater than one; Counter is the clearest linear-time answer when extra space is allowed.

Memory Map: Duplicate characters -> build Counter -> keep counts above one -> sort for stable output.

Q01 β€” Find Duplicate Characters in a String

Question: Given a string, find all characters that appear more than once.

Sample Input: "programming" Expected Output: ['g', 'm', 'r'] (sorted)

Solution 1: Brute Force β€” Nested Loop (Easy to Understand) β€” Find Duplicate Characters in a String

python β€” editable
def find_duplicates_brute(s):
    # Compare every character with every other character
    # If they match and we haven't recorded it yet, it's a duplicate
    duplicates = []
    for i in range(len(s)):
        for j in range(i + 1, len(s)):
            if s[i] == s[j] and s[i] not in duplicates:
                duplicates.append(s[i])
    return sorted(duplicates)

print(find_duplicates_brute("programming"))
# Output: ['g', 'm', 'r']

Solution 2: Using a Set to Track Seen Characters (Intermediate) β€” Find Duplicate Characters in a String

python β€” editable
def find_duplicates_set(s):
    # Walk through the string one character at a time
    # Keep a 'seen' set β€” if a char is already in seen, it's a duplicate
    seen = set()
    duplicates = set()
    for char in s:
        if char in seen:
            duplicates.add(char)
        seen.add(char)
    return sorted(duplicates)

print(find_duplicates_set("programming"))
# Output: ['g', 'm', 'r']

Solution 3: Counter β€” Optimal / Pythonic (Interview Answer) β€” Find Duplicate Characters in a String

python β€” editable
from collections import Counter

def find_duplicates(s):
    # Step 1: Counter scans the string once and builds a dict of {char: count}
    counts = Counter(s)
    print("Counter output:", counts)
    # Output: Counter output: Counter({'g': 2, 'r': 2, 'm': 2, 'p': 1, 'o': 1, 'a': 1, 'i': 1, 'n': 1})
    # ↑ Now we can SEE: 'g', 'r', 'm' each appear 2 times, rest appear 1 time

    # Step 2: Filter β€” keep only chars where count > 1
    dupes = [char for char, count in counts.items() if count > 1]
    print("Before sorting:", dupes)
    # Output: Before sorting: ['g', 'r', 'm']

    # Step 3: Sort for consistent output (dicts don't guarantee order)
    return sorted(dupes)

print("Result:", find_duplicates("programming"))
# Output: Result: ['g', 'm', 'r']

Interview Tip: "Start with Counter for clarity, then offer the set-based approach β€” shows you know both library tools AND manual logic. Mention that Counter is O(n) time and O(k) space where k is unique characters."

What NOT to Say: "I'll use a nested loop to compare each char with every other char" β€” That's O(n^2). Use a hash set or Counter for O(n).

Answer First: Normalize the two strings, compare their frequency maps, and state whether spaces, punctuation, and case belong in the comparison.

Memory Map: Anagrams -> normalize case/punctuation policy -> compare character frequencies -> explain O(n) space.

Q02 β€” Check if Two Strings Are Anagrams

Question: Two strings are anagrams if they contain the exact same characters in the same frequency, just rearranged. Check if two given strings are anagrams.

Sample Input: s1 = "listen", s2 = "silent" Expected Output: True

Sample Input: s1 = "hello", s2 = "world" Expected Output: False

Solution 1: Sort and Compare (Easy to Understand) β€” Check if Two Strings Are Anagrams

python β€” editable
def is_anagram_sort(s1, s2):
    # If both strings, when sorted, produce the same sequence
    # then they must have the same characters in the same frequency
    return sorted(s1.lower()) == sorted(s2.lower())

print(is_anagram_sort("listen", "silent"))  # Output: True
print(is_anagram_sort("hello", "world"))    # Output: False

Solution 2: Manual Frequency Count with a Dictionary (Intermediate) β€” Check if Two Strings Are Anagrams

python β€” editable
def is_anagram_manual(s1, s2):
    # Build frequency maps for both strings manually
    # Then compare the two maps
    s1, s2 = s1.lower(), s2.lower()
    if len(s1) != len(s2):
        return False

    freq = {}
    for char in s1:
        freq[char] = freq.get(char, 0) + 1
    for char in s2:
        freq[char] = freq.get(char, 0) - 1

    # If all counts are zero, strings are anagrams
    return all(v == 0 for v in freq.values())

print(is_anagram_manual("listen", "silent"))  # Output: True
print(is_anagram_manual("hello", "world"))    # Output: False

Solution 3: Counter β€” Optimal / Pythonic (Interview Answer) β€” Check if Two Strings Are Anagrams

python β€” editable
from collections import Counter

def is_anagram(s1, s2):
    # Counter builds a frequency dict β€” comparing two Counters is O(n)
    c1 = Counter(s1.lower())
    c2 = Counter(s2.lower())
    print("Step 1 - Counter(s1):", c1)
    # Output: Step 1 - Counter(s1): Counter({'l': 1, 'i': 1, 's': 1, 't': 1, 'e': 1, 'n': 1})
    print("Step 2 - Counter(s2):", c2)
    # Output: Step 2 - Counter(s2): Counter({'s': 1, 'i': 1, 'l': 1, 'e': 1, 'n': 1, 't': 1})
    # ↑ Same keys, same counts β€” just different order. So they're equal!
    return c1 == c2

print(is_anagram("listen", "silent"))  # Output: True
print(is_anagram("hello", "world"))    # Output: False

Bonus: Group Anagrams (Common Follow-Up)

Question: Given a list of words, group them so that all anagrams are together.

Sample Input: ["eat", "tea", "tan", "ate", "nat", "bat"] Expected Output: [['ate', 'eat', 'tea'], ['bat'], ['nat', 'tan']]

Solution 1: Brute Force β€” Compare Every Pair (Easy to Understand) β€” Check if Two Strings Are Anagrams

python β€” editable
def group_anagrams_brute(words):
    # For each word, compare with every other word to see if they're anagrams
    # Two words are anagrams if sorting their characters gives the same result
    used = [False] * len(words)
    result = []

    for i in range(len(words)):
        if used[i]:
            continue
        group = [words[i]]
        used[i] = True

        for j in range(i + 1, len(words)):
            if not used[j] and sorted(words[i].lower()) == sorted(words[j].lower()):
                group.append(words[j])
                used[j] = True

        result.append(sorted(group))

    print(sorted(result))
    # Output: [['ate', 'eat', 'tea'], ['bat'], ['nat', 'tan']]
    return sorted(result)

group_anagrams_brute(["eat", "tea", "tan", "ate", "nat", "bat"])

Solution 2: Plain Dict β€” Manual Key Check (Intermediate) β€” Check if Two Strings Are Anagrams

python β€” editable
def group_anagrams_dict(words):
    # Same idea as brute force BUT smarter:
    # Sort each word β†’ use it as a dictionary key β†’ group words with same key
    groups = {}

    for word in words:
        key = ''.join(sorted(word.lower()))
        # Manual check: if key doesn't exist yet, create empty list
        if key not in groups:
            groups[key] = []
        groups[key].append(word)

    print("Groups dict:", groups)
    # Output: Groups dict: {'aet': ['eat', 'tea', 'ate'], 'ant': ['tan', 'nat'], 'abt': ['bat']}

    return sorted([sorted(g) for g in groups.values()])

print(group_anagrams_dict(["eat", "tea", "tan", "ate", "nat", "bat"]))
# Output: [['ate', 'eat', 'tea'], ['bat'], ['nat', 'tan']]

Solution 3: defaultdict β€” Optimal / Pythonic (Interview Answer) β€” Check if Two Strings Are Anagrams

python β€” editable
from collections import defaultdict

def group_anagrams(words):
    # defaultdict(list) auto-creates empty list for new keys β€” no if-check needed
    groups = defaultdict(list)

    for word in words:
        # Step 1: sorted() splits word into individual chars and sorts them
        sorted_chars = sorted(word.lower())
        print(f"  sorted('{word}') β†’ {sorted_chars}")
        # Output:   sorted('eat') β†’ ['a', 'e', 't']
        # Output:   sorted('tea') β†’ ['a', 'e', 't']   ← same as 'eat'!
        # Output:   sorted('tan') β†’ ['a', 'n', 't']
        # Output:   sorted('ate') β†’ ['a', 'e', 't']   ← same as 'eat'!
        # Output:   sorted('nat') β†’ ['a', 'n', 't']   ← same as 'tan'!
        # Output:   sorted('bat') β†’ ['a', 'b', 't']

        # Step 2: join() glues the sorted chars back into one string β€” becomes dict KEY
        key = ''.join(sorted_chars)
        print(f"  ''.join({sorted_chars}) β†’ '{key}'")
        # Output:   ''.join(['a', 'e', 't']) β†’ 'aet'

        # Step 3: Words with the same key land in the same bucket
        groups[key].append(word)

    # Let's see what the groups dict looks like inside
    print("\nGroups dict:", dict(sorted(groups.items())))
    # Output: Groups dict: {'abt': ['bat'], 'aet': ['eat', 'tea', 'ate'], 'ant': ['tan', 'nat']}
    # ↑ 'eat', 'tea', 'ate' all sorted to 'aet' β€” so they grouped together!

    # Step 4: Extract the grouped lists, sort each group and the overall result
    result = sorted([sorted(g) for g in groups.values()])
    print("Final result:", result)
    # Output: Final result: [['ate', 'eat', 'tea'], ['bat'], ['nat', 'tan']]
    return result

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

Interview Tip: "Start with the sorted-key insight, then show defaultdict for clean grouping. Sorting each word is O(k log k) where k is word length, total O(n Γ— k log k). Mention that Counter as key works too but sorted string is simpler to explain."

What NOT to Say: "I'll check if every character in s1 exists in s2" β€” This doesn't handle frequency. "aab" and "abb" would wrongly match.

Answer First: Use a sliding window and move its left edge past the previous occurrence of any repeated character for an O(n) scan.

Memory Map: Longest substring -> expand right -> jump left past duplicate -> track maximum window length.

Q03 β€” Longest Substring Without Repeating Characters

Question: Given a string, find the length of the longest contiguous substring where no character appears more than once.

Sample Input: "abcabcbb" Expected Output: 3 (the substring is "abc")

Sample Input: "bbbbb" Expected Output: 1 (the substring is "b")

Sample Input: "pwwkew" Expected Output: 3 (the substring is "wke")

Solution 1: Brute Force β€” Check All Substrings (Easy to Understand) β€” Longest Substring Without Repeating Characters

python β€” editable
def longest_unique_brute(s):
    # Generate every possible substring
    # Check if it has all unique characters
    # Track the longest one
    max_len = 0
    for i in range(len(s)):
        for j in range(i + 1, len(s) + 1):
            substring = s[i:j]
            if len(substring) == len(set(substring)):
                max_len = max(max_len, len(substring))
    return max_len

print(longest_unique_brute("abcabcbb"))  # Output: 3
print(longest_unique_brute("bbbbb"))     # Output: 1
print(longest_unique_brute("pwwkew"))    # Output: 3

Solution 2: Sliding Window with a Set (Intermediate) β€” Longest Substring Without Repeating Characters

python β€” editable
def longest_unique_set(s):
    # Use two pointers (left, right) to define a window
    # Expand right β€” if duplicate found, shrink from left
    char_set = set()
    left = 0
    max_len = 0

    for right in range(len(s)):
        # If char already in window, remove from left until it's gone
        while s[right] in char_set:
            char_set.remove(s[left])
            left += 1
        char_set.add(s[right])
        max_len = max(max_len, right - left + 1)

    return max_len

print(longest_unique_set("abcabcbb"))  # Output: 3
print(longest_unique_set("bbbbb"))     # Output: 1
print(longest_unique_set("pwwkew"))    # Output: 3

Solution 3: Sliding Window with a HashMap β€” Optimal (Interview Answer) β€” Longest Substring Without Repeating Characters

python β€” editable
def longest_unique_substring(s):
    # Store the last index of each character in a dict
    # When we see a duplicate, jump left pointer past the previous occurrence
    # This avoids the inner while-loop of Solution 2 β€” true O(n)
    char_index = {}
    left = 0
    max_len = 0

    for right, char in enumerate(s):
        if char in char_index and char_index[char] >= left:
            left = char_index[char] + 1
        char_index[char] = right
        max_len = max(max_len, right - left + 1)

    print("Step 1 - char_index map:", char_index)
    # Output: Step 1 - char_index map: {'a': 3, 'b': 7, 'c': 5}
    # ↑ Each char stores its LAST seen index β€” this is the jump-to table
    print("Step 2 - max window length:", max_len)
    # Output: Step 2 - max window length: 3

    return max_len

print(longest_unique_substring("abcabcbb"))  # Output: 3
print(longest_unique_substring("bbbbb"))     # Output: 1
print(longest_unique_substring("pwwkew"))    # Output: 3

Interview Tip: "This is the classic sliding window pattern. O(n) time, O(min(n, alphabet_size)) space. Mention 'two pointer' β€” interviewers love that term."

What NOT to Say: "I'll check every possible substring" β€” That's O(n^3). Sliding window is O(n).

Answer First: Use slicing for the idiomatic whole-string reversal and split/reverse/join when the unit to reverse is words.

Memory Map: Reverse string -> choose characters or words -> slice/reverse -> join immutable pieces.

Q04 β€” Reverse a String / Reverse Words in a Sentence

Question: Given a string, reverse its characters. Also given a sentence, reverse the order of words while keeping each word's characters intact.

Sample Input (reverse characters): "hello" Expected Output: "olleh"

Sample Input (reverse words): "Data Engineering is fun" Expected Output: "fun is Engineering Data"

Solution 1: Two-Pointer Swap β€” Manual Approach (Easy to Understand) β€” Reverse a String / Reverse Words in a Sentence

python β€” editable
def reverse_string_manual(s):
    # Convert to list since strings are immutable in Python
    # Swap characters from both ends moving inward
    chars = list(s)
    left, right = 0, len(chars) - 1
    while left < right:
        chars[left], chars[right] = chars[right], chars[left]
        left += 1
        right -= 1
    return ''.join(chars)

print(reverse_string_manual("hello"))
# Output: olleh

def reverse_words_manual(sentence):
    # Split into words, reverse the list, join back
    words = sentence.split()
    reversed_words = []
    for i in range(len(words) - 1, -1, -1):
        reversed_words.append(words[i])
    return ' '.join(reversed_words)

print(reverse_words_manual("Data Engineering is fun"))
# Output: fun is Engineering Data

Solution 2: Using Built-in reversed() Function (Intermediate) β€” Reverse a String / Reverse Words in a Sentence

python β€” editable
def reverse_string_builtin(s):
    # reversed() returns an iterator β€” join it back into a string
    return ''.join(reversed(s))

print(reverse_string_builtin("hello"))
# Output: olleh

def reverse_words_builtin(sentence):
    # Split into words, reverse the list with reversed(), rejoin
    return ' '.join(reversed(sentence.split()))

print(reverse_words_builtin("Data Engineering is fun"))
# Output: fun is Engineering Data

Solution 3: Slice Notation β€” Pythonic (Interview Answer) β€” Reverse a String / Reverse Words in a Sentence

python β€” editable
# Reverse characters β€” [::-1] is the idiomatic Python way
s = "hello"
print("Step 1 - Original:", s)
# Output: Step 1 - Original: hello
print("Step 2 - Reversed:", s[::-1])
# Output: Step 2 - Reversed: olleh

# Reverse words in a sentence
sentence = "Data Engineering is fun"
words = sentence.split()
print("Step 1 - split():", words)
# Output: Step 1 - split(): ['Data', 'Engineering', 'is', 'fun']
print("Step 2 - Reversed:", words[::-1])
# Output: Step 2 - Reversed: ['fun', 'is', 'Engineering', 'Data']
print("Step 3 - Joined:", ' '.join(words[::-1]))
# Output: Step 3 - Joined: fun is Engineering Data

# Bonus: reverse each word individually (keep word order)
print(' '.join(word[::-1] for word in sentence.split()))
# Output: ataD gnireenignE si nuf

Interview Tip: "Show the Pythonic way first (slicing), then the two-pointer approach to prove you understand the underlying algorithm. Mention that strings are immutable in Python so you must convert to a list for in-place operations."

What NOT to Say: "Strings are mutable in Python so I'll swap in-place" β€” Strings are IMMUTABLE in Python. You must convert to a list first.

Answer First: Build one frequency map, preserve source order when tie behavior matters, and use Counter.most_common only after stating the tie rule.

Memory Map: Character frequency -> tally once -> define tie order -> select the most common character.

Q05 β€” Count Character Frequency / Most Common Character

Question: Given a string, count how many times each character appears and find the most frequent character(s).

Sample Input: "data engineering" Expected Output (frequency): {'a': 2, 'd': 1, 'e': 2, 'g': 2, 'i': 2, 'n': 2, 'r': 1, 't': 1} (spaces excluded, sorted) Expected Output (most common): 'a' (first non-repeating: 'd')

Solution 1: Manual Dictionary Count (Easy to Understand) β€” Count Character Frequency / Most Common Character

python β€” editable
def char_frequency_manual(s):
    # Walk through each character, tally in a dictionary
    # Skip spaces since we only care about letters
    freq = {}
    for char in s:
        if char != ' ':
            freq[char] = freq.get(char, 0) + 1
    return dict(sorted(freq.items()))

print(char_frequency_manual("data engineering"))
# Output: {'a': 2, 'd': 1, 'e': 2, 'g': 2, 'i': 2, 'n': 2, 'r': 1, 't': 1}

# Find most common character manually
def most_common_manual(s):
    freq = char_frequency_manual(s)
    return sorted(freq.items(), key=lambda x: (-x[1], x[0]))[0]

print(most_common_manual("data engineering"))
# Output: ('a', 2)

Solution 2: Using defaultdict (Intermediate) β€” Count Character Frequency / Most Common Character

python β€” editable
from collections import defaultdict

def char_frequency_dd(s):
    # defaultdict(int) initializes missing keys to 0
    # Cleaner than using .get() with a default
    freq = defaultdict(int)
    for char in s:
        if char != ' ':
            freq[char] += 1
    return dict(sorted(freq.items()))

print(char_frequency_dd("data engineering"))
# Output: {'a': 2, 'd': 1, 'e': 2, 'g': 2, 'i': 2, 'n': 2, 'r': 1, 't': 1}

Solution 3: Counter β€” Optimal / Pythonic (Interview Answer) β€” Count Character Frequency / Most Common Character

python β€” editable
from collections import Counter

s = "data engineering"
counts = Counter(s.replace(' ', ''))
print("Step 1 - Raw Counter:", counts)
# Output: Step 1 - Raw Counter: Counter({'a': 2, 'n': 2, 'e': 2, 'g': 2, 'i': 2, 'd': 1, 't': 1, 'r': 1})
# ↑ Now we can SEE which chars appear once vs multiple times

# Full frequency sorted by key
print("Step 2 - Sorted:", dict(sorted(counts.items())))
# Output: Step 2 - Sorted: {'a': 2, 'd': 1, 'e': 2, 'g': 2, 'i': 2, 'n': 2, 'r': 1, 't': 1}

# Top 3 most common characters
print("Step 3 - Top 3:", counts.most_common(3))
# Output: Step 3 - Top 3: [('a', 2), ('n', 2), ('e', 2)]

# First non-repeating character
# Iterate the original string (preserves order) but look up counts in Counter
first_unique = next((c for c in s.replace(' ', '') if counts[c] == 1), None)
print("Step 4 - First unique:", first_unique)
# Output: Step 4 - First unique: d

Interview Tip: "Counter.most_common() is the go-to. For 'first non-repeating character', iterate the original string to preserve insertion order, but look up counts in the Counter. This is O(n)."

What NOT to Say: "I'll use s.count(c) inside a loop" β€” That's O(n^2) because count() scans the entire string each time. Build a Counter first (O(n)), then query it.

Answer First: Normalize comparable characters and compare inward with two pointers when you want O(1) auxiliary space.

Memory Map: Palindrome -> normalize comparable characters -> move two pointers inward -> stop on mismatch.

Q06 β€” Check if a String Is a Palindrome

Question: A palindrome reads the same forwards and backwards. Check if a given string is a palindrome, ignoring case and non-alphanumeric characters.

Sample Input: "A man, a plan, a canal: Panama" Expected Output: True

Sample Input: "race a car" Expected Output: False

Solution 1: Clean and Reverse with a Loop (Easy to Understand) β€” Check if a String Is a Palindrome

python β€” editable
def is_palindrome_loop(s):
    # First strip out anything that isn't a letter or digit
    # Then compare character by character from both ends
    cleaned = ''
    for c in s:
        if c.isalnum():
            cleaned += c.lower()

    for i in range(len(cleaned) // 2):
        if cleaned[i] != cleaned[len(cleaned) - 1 - i]:
            return False
    return True

print(is_palindrome_loop("A man, a plan, a canal: Panama"))  # Output: True
print(is_palindrome_loop("race a car"))                      # Output: False

Solution 2: Two-Pointer Without Extra String (Intermediate) β€” Check if a String Is a Palindrome

python β€” editable
def is_palindrome_twoptr(s):
    # Use two pointers from both ends, skip non-alphanumeric chars
    # This avoids creating a cleaned copy of the string
    left, right = 0, len(s) - 1
    while left < right:
        while left < right and not s[left].isalnum():
            left += 1
        while left < right and not s[right].isalnum():
            right -= 1
        if s[left].lower() != s[right].lower():
            return False
        left += 1
        right -= 1
    return True

print(is_palindrome_twoptr("A man, a plan, a canal: Panama"))  # Output: True
print(is_palindrome_twoptr("race a car"))                      # Output: False

Solution 3: Slice Comparison β€” Pythonic (Interview Answer) β€” Check if a String Is a Palindrome

python β€” editable
def is_palindrome(s):
    # Clean: keep only alphanumeric, lowercase everything
    # Then compare string with its reverse using slicing
    cleaned = ''.join(c.lower() for c in s if c.isalnum())
    print("Step 1 - Cleaned:", cleaned)
    # Output: Step 1 - Cleaned: amanaplanacanalpanama
    print("Step 2 - Reversed:", cleaned[::-1])
    # Output: Step 2 - Reversed: amanaplanacanalpanama
    # ↑ Same forwards and backwards β€” it's a palindrome!
    return cleaned == cleaned[::-1]

print(is_palindrome("A man, a plan, a canal: Panama"))  # Output: True
print(is_palindrome("race a car"))                      # Output: False

Interview Tip: "Always clean the string first β€” remove non-alphanumeric characters and lowercase everything. Then compare with reversed. Mention the two-pointer approach uses O(1) extra space."

What NOT to Say: "madam is not a palindrome because M and m are different" β€” Normalize case first! Always clarify with the interviewer whether comparison is case-sensitive.

Answer First: Scan each run once, append count-symbol groups to a list, and join at the end to avoid quadratic string rebuilding.

Memory Map: String compression -> detect each run -> append count-symbol pair -> join once at the end.

Q07 β€” String Compression (Run Length Encoding)

Question: Implement basic string compression using the counts of repeated characters. If the compressed string is not shorter than the original, return the original.

Sample Input: "aabcccccaaa" Expected Output: "a2b1c5a3"

Sample Input: "abcdef" Expected Output: "abcdef" (compressed would be "a1b1c1d1e1f1", which is longer)

Solution 1: Index-Based Comparison (Easy to Understand) β€” String Compression (Run Length Encoding)

python β€” editable
def compress_basic(s):
    if not s:
        return s
    # Walk through string comparing each char with the next
    # When the character changes, record the char and its count
    result = ""
    count = 1
    for i in range(1, len(s)):
        if s[i] == s[i - 1]:
            count += 1
        else:
            result += s[i - 1] + str(count)
            count = 1
    # Don't forget the last group of characters
    result += s[-1] + str(count)
    return result if len(result) < len(s) else s

print(compress_basic("aabcccccaaa"))  # Output: a2b1c5a3
print(compress_basic("abcdef"))       # Output: abcdef

Solution 2: Using a List for Efficient Concatenation (Intermediate) β€” String Compression (Run Length Encoding)

python β€” editable
def compress_list(s):
    if not s:
        return s
    # String concatenation in a loop creates a new string each time β€” O(n^2)
    # Using a list and joining at the end is O(n)
    result = []
    count = 1

    for i in range(1, len(s)):
        if s[i] == s[i - 1]:
            count += 1
        else:
            result.append(f"{s[i - 1]}{count}")
            count = 1
    result.append(f"{s[-1]}{count}")

    compressed = ''.join(result)
    return compressed if len(compressed) < len(s) else s

print(compress_list("aabcccccaaa"))  # Output: a2b1c5a3
print(compress_list("abcdef"))       # Output: abcdef

Solution 3: Using itertools.groupby β€” Pythonic (Interview Answer) β€” String Compression (Run Length Encoding)

python β€” editable
from itertools import groupby

def compress(s):
    if not s:
        return s
    # groupby clusters consecutive identical characters together
    # Each group gives us the character and an iterator of occurrences
    groups = [(char, sum(1 for _ in group)) for char, group in groupby(s)]
    print("Step 1 - Groups:", groups)
    # Output: Step 1 - Groups: [('a', 2), ('b', 1), ('c', 5), ('a', 3)]
    # ↑ Each tuple = (character, consecutive count)

    compressed = ''.join(f"{char}{count}" for char, count in groups)
    print("Step 2 - Compressed:", compressed)
    # Output: Step 2 - Compressed: a2b1c5a3

    return compressed if len(compressed) < len(s) else s

print(compress("aabcccccaaa"))  # Output: a2b1c5a3
print(compress("abcdef"))       # Output: abcdef

Interview Tip: "Remember to handle the last group (after the loop ends in manual solutions). And always return the original if compressed isn't shorter. Mention that using a list instead of string concatenation is O(n) vs O(n^2)."

What NOT to Say: "I'll just concatenate strings in a loop" β€” String concatenation in Python creates a new string object each time, making it O(n^2). Use a list and join.

Answer First: Store each seen value by index and look up its complement, giving O(n) time and O(n) space.

Memory Map: Two sum -> compute complement -> probe seen-value map -> return the two stored indices.

Q08 β€” Two Sum Problem

Question: Given a list of integers and a target sum, find two numbers that add up to the target. Return their indices.

Sample Input: nums = [2, 7, 11, 15], target = 9 Expected Output: [0, 1] (because nums[0] + nums[1] = 2 + 7 = 9)

Sample Input: nums = [3, 2, 4], target = 6 Expected Output: [1, 2]

Solution 1: Brute Force β€” Nested Loops (Easy to Understand) β€” Two Sum Problem

python β€” editable
def two_sum_brute(nums, target):
    # Try every possible pair of numbers
    # If their sum equals the target, return both indices
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []

print(two_sum_brute([2, 7, 11, 15], 9))  # Output: [0, 1]
print(two_sum_brute([3, 2, 4], 6))        # Output: [1, 2]

Solution 2: Sort + Two Pointers (Intermediate) β€” Two Sum Problem

python β€” editable
def two_sum_sorted(nums, target):
    # Create index-value pairs, sort by value
    # Use two pointers from both ends
    indexed = sorted(enumerate(nums), key=lambda x: x[1])
    left, right = 0, len(indexed) - 1

    while left < right:
        current_sum = indexed[left][1] + indexed[right][1]
        if current_sum == target:
            # Return original indices, sorted for consistency
            return sorted([indexed[left][0], indexed[right][0]])
        elif current_sum < target:
            left += 1
        else:
            right -= 1
    return []

print(two_sum_sorted([2, 7, 11, 15], 9))  # Output: [0, 1]
print(two_sum_sorted([3, 2, 4], 6))        # Output: [1, 2]

Solution 3: Hash Map β€” Optimal (Interview Answer) β€” Two Sum Problem

python β€” editable
def two_sum(nums, target):
    # For each number, compute its complement (target - num)
    # If the complement is already in our hash map, we found the pair
    # Otherwise, store this number's index for future lookups
    seen = {}
    for i, num in enumerate(nums):
        complement = target - num
        print(f"Step {i+1} - num={num}, complement={complement}, seen={seen}")
        # Output (iteration 1): Step 1 - num=2, complement=7, seen={}
        # Output (iteration 2): Step 2 - num=7, complement=2, seen={2: 0}
        # ↑ complement 2 IS in seen β€” match found! Return [0, 1]
        if complement in seen:
            return [seen[complement], i]
        seen[num] = i
    return []

print(two_sum([2, 7, 11, 15], 9))  # Output: [0, 1]
print(two_sum([3, 2, 4], 6))        # Output: [1, 2]

Interview Tip: "One-pass hash map solution. O(n) time, O(n) space. Always mention the time-space tradeoff: brute force uses O(1) space but O(n^2) time, hash map uses O(n) space for O(n) time."

What NOT to Say: "I'll use two nested loops" β€” That's O(n^2). Hash map is O(n). The interviewer expects you to know the optimal approach.

Answer First: Test the combined divisibility case before the individual factors so multiples of both are not consumed early.

Memory Map: FizzBuzz -> test divisible by both first -> then three/five -> otherwise emit the number.

Q09 β€” FizzBuzz

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

Sample Input: n = 15 Expected Output: ['1', '2', 'Fizz', '4', 'Buzz', 'Fizz', '7', '8', 'Fizz', 'Buzz', '11', 'Fizz', '13', '14', 'FizzBuzz']

Solution 1: If-Elif Chain (Easy to Understand) β€” FizzBuzz

python β€” editable
def fizzbuzz_basic(n):
    # Check divisibility in the right order:
    # 15 first (both 3 and 5), then 3, then 5, then plain number
    result = []
    for i in range(1, n + 1):
        if i % 15 == 0:
            result.append("FizzBuzz")
        elif i % 3 == 0:
            result.append("Fizz")
        elif i % 5 == 0:
            result.append("Buzz")
        else:
            result.append(str(i))
    return result

print(fizzbuzz_basic(15))
# Output: ['1', '2', 'Fizz', '4', 'Buzz', 'Fizz', '7', '8', 'Fizz', 'Buzz', '11', 'Fizz', '13', '14', 'FizzBuzz']

Solution 2: String Concatenation β€” Avoids Hardcoding 15 (Intermediate) β€” FizzBuzz

python β€” editable
def fizzbuzz_concat(n):
    # Build the output string by concatenating "Fizz" and "Buzz" separately
    # This scales better if new rules are added (e.g., "Jazz" for 7)
    result = []
    for i in range(1, n + 1):
        output = ""
        if i % 3 == 0:
            output += "Fizz"
        if i % 5 == 0:
            output += "Buzz"
        result.append(output if output else str(i))
    return result

print(fizzbuzz_concat(15))
# Output: ['1', '2', 'Fizz', '4', 'Buzz', 'Fizz', '7', '8', 'Fizz', 'Buzz', '11', 'Fizz', '13', '14', 'FizzBuzz']

Solution 3: List Comprehension β€” Pythonic (Interview Answer) β€” FizzBuzz

python β€” editable
def fizzbuzz(n):
    # One-liner using conditional expressions in a list comprehension
    # Compact but still readable β€” shows Python fluency
    result = [
        'FizzBuzz' if i % 15 == 0
        else 'Fizz' if i % 3 == 0
        else 'Buzz' if i % 5 == 0
        else str(i)
        for i in range(1, n + 1)
    ]
    print("Step 1 - First 5:", result[:5])
    # Output: Step 1 - First 5: ['1', '2', 'Fizz', '4', 'Buzz']
    print("Step 2 - Last 3:", result[-3:])
    # Output: Step 2 - Last 3: ['13', '14', 'FizzBuzz']
    # ↑ 15 hits both % 3 and % 5, so it becomes 'FizzBuzz'
    return result

print(fizzbuzz(15))
# Output: ['1', '2', 'Fizz', '4', 'Buzz', 'Fizz', '7', '8', 'Fizz', 'Buzz', '11', 'Fizz', '13', '14', 'FizzBuzz']

Interview Tip: "Check 15 FIRST (not 3 then 5 separately with elif). Show the clean version first, then mention the concatenation approach scales better for additional rules. The one-liner shows Python fluency."

What NOT to Say: "if i % 3 == 0 and i % 5 == 0" as the LAST check β€” Order matters! If you check 3 first with elif, 15 prints "Fizz" instead of "FizzBuzz".

Answer First: Track membership in a set while appending first occurrences; dict.fromkeys is the concise Python 3.7+ form for hashable values.

Memory Map: Remove duplicates -> preserve first occurrence -> record in seen -> append only unseen values.

Q10 β€” Remove Duplicates from a List (Preserve Order)

Question: Remove duplicate elements from a list while keeping the first occurrence's order.

Sample Input: [1, 3, 2, 3, 1, 5, 2] Expected Output: [1, 3, 2, 5]

Solution 1: Nested Check with a New List (Easy to Understand) β€” Remove Duplicates from a List (Preserve Order)

python β€” editable
def remove_dupes_basic(lst):
    # Walk through the list and only add items we haven't seen yet
    result = []
    for item in lst:
        if item not in result:
            result.append(item)
    return result

print(remove_dupes_basic([1, 3, 2, 3, 1, 5, 2]))
# Output: [1, 3, 2, 5]

Solution 2: Using a Set for O(1) Lookups (Intermediate) β€” Remove Duplicates from a List (Preserve Order)

python β€” editable
def remove_dupes_set(lst):
    # 'item not in result' in Solution 1 is O(n) β€” making overall O(n^2)
    # Using a set for lookups makes each check O(1) β€” overall O(n)
    seen = set()
    result = []
    for item in lst:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result

print(remove_dupes_set([1, 3, 2, 3, 1, 5, 2]))
# Output: [1, 3, 2, 5]

Solution 3: dict.fromkeys β€” Pythonic (Interview Answer) β€” Remove Duplicates from a List (Preserve Order)

python β€” editable
def remove_dupes(lst):
    # dict.fromkeys preserves insertion order (Python 3.7+)
    # Keys are unique by definition, so duplicates are dropped
    as_dict = dict.fromkeys(lst)
    print("Step 1 - dict.fromkeys:", as_dict)
    # Output: Step 1 - dict.fromkeys: {1: None, 3: None, 2: None, 5: None}
    # ↑ Duplicates gone! Keys kept first-occurrence order. Values are None.
    print("Step 2 - Extract keys:", list(as_dict))
    # Output: Step 2 - Extract keys: [1, 3, 2, 5]
    return list(as_dict)

print(remove_dupes([1, 3, 2, 3, 1, 5, 2]))
# Output: [1, 3, 2, 5]

Interview Tip: "list(set(lst)) removes duplicates but DESTROYS order. Always mention you're preserving order β€” it shows attention to detail. The dict.fromkeys trick is clean and Pythonic."

What NOT to Say: "Just use set()" β€” Sets are unordered. The interviewer is testing if you know this distinction.

Answer First: Use recursion or an explicit stack for arbitrary depth and a generator when the flattened result should stay lazy.

Memory Map: Flatten nested list -> identify scalar versus list -> recurse or stack -> yield leaves in order.

Q11 β€” Flatten a Nested List

Question: Convert a nested list of arbitrary depth into a flat one-dimensional list.

Sample Input: [1, [2, [3, 4], 5], 6] Expected Output: [1, 2, 3, 4, 5, 6]

Solution 1: Recursive Approach (Easy to Understand) β€” Flatten a Nested List

python β€” editable
def flatten_recursive(lst):
    # Walk through each element
    # If it's a list, recursively flatten it and add all elements
    # If it's not a list, just add it directly
    result = []
    for item in lst:
        if isinstance(item, list):
            result.extend(flatten_recursive(item))
        else:
            result.append(item)
    return result

print(flatten_recursive([1, [2, [3, 4], 5], 6]))
# Output: [1, 2, 3, 4, 5, 6]

Solution 2: Iterative with a Stack (Intermediate) β€” Flatten a Nested List

python β€” editable
def flatten_iterative(lst):
    # Use a stack to avoid recursion (avoids stack overflow on deep nesting)
    # Process from right to left so output is in correct order
    stack = list(lst)
    result = []
    while stack:
        item = stack.pop(0)
        if isinstance(item, list):
            # Insert sub-items at the front of the stack to preserve order
            stack = item + stack
        else:
            result.append(item)
    return result

print(flatten_iterative([1, [2, [3, 4], 5], 6]))
# Output: [1, 2, 3, 4, 5, 6]

Solution 3: Generator-Based β€” Pythonic (Interview Answer) β€” Flatten a Nested List

python β€” editable
def flatten(lst):
    # Generator function yields items one at a time
    # Uses 'yield from' for recursive flattening β€” clean and memory-efficient
    for item in lst:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

data = [1, [2, [3, 4], 5], 6]
gen = flatten(data)
print("Step 1 - Generator object:", gen)
# Output: Step 1 - Generator object: <generator object flatten at 0x...>
# ↑ Nothing computed yet β€” it's lazy!
result = list(gen)
print("Step 2 - Consumed:", result)
# Output: Step 2 - Consumed: [1, 2, 3, 4, 5, 6]

# Bonus: one-level flatten with list comprehension (when depth = 1)
nested = [[1, 2], [3, 4], [5, 6]]
flat = [x for sublist in nested for x in sublist]
print(flat)
# Output: [1, 2, 3, 4, 5, 6]

Interview Tip: "Ask the interviewer: is it one level deep or arbitrarily nested? One-level = list comprehension. Arbitrary = recursion. Mentioning yield from shows you understand Python generators."

What NOT to Say: "I'll just use a list comprehension" β€” That only works for one-level nesting. Arbitrary depth requires recursion or a stack.

Answer First: Use the expected sum or XOR when the input guarantees exactly one missing value; validate those assumptions before choosing the shortcut.

Memory Map: Missing number -> confirm 1-to-N contract -> expected sum or XOR -> subtract/cancel observed values.

Q12 β€” Find Missing Number in a List (1 to N)

Question: Given a list of n-1 distinct numbers taken from the range 1 to n, find the one missing number.

Sample Input: nums = [1, 2, 4, 5, 6], n = 6 Expected Output: 3

Solution 1: Sort and Scan (Easy to Understand) β€” Find Missing Number in a List (1 to N)

python β€” editable
def find_missing_sort(nums, n):
    # Sort the list, then walk through looking for a gap
    # When nums[i] doesn't match expected value (i+1), that's the missing one
    nums_sorted = sorted(nums)
    for i in range(len(nums_sorted)):
        if nums_sorted[i] != i + 1:
            return i + 1
    # If no gap found, the missing number is n (the last one)
    return n

print(find_missing_sort([1, 2, 4, 5, 6], 6))
# Output: 3

Solution 2: Math Formula β€” Sum Difference (Intermediate) β€” Find Missing Number in a List (1 to N)

python β€” editable
def find_missing_math(nums, n):
    # The sum of 1 to n = n*(n+1)/2
    # Subtract the actual sum of the list β€” the difference is the missing number
    expected = n * (n + 1) // 2
    actual = sum(nums)
    return expected - actual

print(find_missing_math([1, 2, 4, 5, 6], 6))
# Output: 3

Solution 3: XOR β€” Optimal / No Overflow Risk (Interview Answer) β€” Find Missing Number in a List (1 to N)

python β€” editable
def find_missing_xor(nums, n):
    # XOR has the property: a ^ a = 0 and a ^ 0 = a
    # XOR all numbers from 1 to n, then XOR with all numbers in the list
    # Every number that appears in both cancels out, leaving only the missing one
    xor = 0
    for i in range(1, n + 1):
        xor ^= i
    print("Step 1 - XOR of 1..n:", xor)
    # Output: Step 1 - XOR of 1..n: 7
    # ↑ 1^2^3^4^5^6 = 7

    for num in nums:
        xor ^= num
    print("Step 2 - After XOR with list:", xor)
    # Output: Step 2 - After XOR with list: 3
    # ↑ All paired numbers cancelled out, only 3 (the missing one) remains

    return xor

print(find_missing_xor([1, 2, 4, 5, 6], 6))
# Output: 3

Bonus: Set Difference

python β€” editable
def find_missing_set(nums, n):
    # Create the full set and subtract the given numbers
    return (set(range(1, n + 1)) - set(nums)).pop()

print(find_missing_set([1, 2, 4, 5, 6], 6))
# Output: 3

Interview Tip: "Math formula (n*(n+1)/2) is the classic answer. Mention XOR as a bonus β€” it avoids integer overflow in languages like Java/C++ where that matters. In Python, integers have arbitrary precision so overflow isn't an issue, but mentioning it shows cross-language awareness."

What NOT to Say: "I'll sort and scan" β€” That's O(n log n). The math and XOR approaches are both O(n) time and O(1) space.

Answer First: Treat rows and columns explicitly: zip(*matrix) transposes, nested iteration flattens, and reversed rows plus transpose rotates clockwise.

Memory Map: Matrix operations -> establish row/column shape -> transpose with zip -> rotate by reverse-plus-transpose.

Q13 β€” Matrix / 2D List Operations

Question: Perform common operations on a 2D list: transpose, flatten, and rotate 90 degrees clockwise.

Sample Input:

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

Expected Output (transpose): [[1, 4, 7], [2, 5, 8], [3, 6, 9]] Expected Output (flatten): [1, 2, 3, 4, 5, 6, 7, 8, 9] Expected Output (rotate 90 CW): [[7, 4, 1], [8, 5, 2], [9, 6, 3]]

Solution 1: Manual Loops (Easy to Understand) β€” Matrix / 2D List Operations

python β€” editable
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Transpose: swap rows and columns using nested loops
def transpose_manual(m):
    rows, cols = len(m), len(m[0])
    # Create a new matrix where result[j][i] = m[i][j]
    result = []
    for j in range(cols):
        new_row = []
        for i in range(rows):
            new_row.append(m[i][j])
        result.append(new_row)
    return result

print(transpose_manual(matrix))
# Output: [[1, 4, 7], [2, 5, 8], [3, 6, 9]]

# Flatten: just iterate all rows and all elements
def flatten_manual(m):
    result = []
    for row in m:
        for val in row:
            result.append(val)
    return result

print(flatten_manual(matrix))
# Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Rotate 90 CW: transpose then reverse each row
def rotate_manual(m):
    t = transpose_manual(m)
    return [row[::-1] for row in t]

print(rotate_manual(matrix))
# Output: [[7, 4, 1], [8, 5, 2], [9, 6, 3]]

Solution 2: List Comprehension (Intermediate) β€” Matrix / 2D List Operations

python β€” editable
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Transpose using list comprehension
transposed = [[matrix[i][j] for i in range(len(matrix))]
              for j in range(len(matrix[0]))]
print(transposed)
# Output: [[1, 4, 7], [2, 5, 8], [3, 6, 9]]

# Flatten using list comprehension
flat = [x for row in matrix for x in row]
print(flat)
# Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]

Solution 3: zip(*matrix) β€” Pythonic (Interview Answer) β€” Matrix / 2D List Operations

python β€” editable
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Transpose: zip(*matrix) unpacks each row as an argument to zip
# zip groups the first elements, second elements, etc.
print("Step 1 - Raw zip tuples:", list(zip(*matrix)))
# Output: Step 1 - Raw zip tuples: [(1, 4, 7), (2, 5, 8), (3, 6, 9)]
# ↑ zip returns tuples β€” we convert each to a list
transposed = [list(row) for row in zip(*matrix)]
print("Step 2 - Transposed:", transposed)
# Output: Step 2 - Transposed: [[1, 4, 7], [2, 5, 8], [3, 6, 9]]

# Flatten
flat = [x for row in matrix for x in row]
print(flat)
# Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Rotate 90 clockwise: reverse the rows first, then transpose
print("Step 1 - Reversed rows:", matrix[::-1])
# Output: Step 1 - Reversed rows: [[7, 8, 9], [4, 5, 6], [1, 2, 3]]
# matrix[::-1] reverses row order, then zip groups columns
rotated = [list(row) for row in zip(*matrix[::-1])]
print("Step 2 - Rotated 90 CW:", rotated)
# Output: Step 2 - Rotated 90 CW: [[7, 4, 1], [8, 5, 2], [9, 6, 3]]

Interview Tip: "zip(*matrix) for transpose is a classic Python trick. The * unpacks each row as a separate argument to zip. For rotation, remember: 90 CW = reverse rows then transpose."

What NOT to Say: "I'll use numpy for this" β€” Unless the interviewer allows external libraries, stick to pure Python. Show you understand the underlying logic.

Answer First: Use modern merge operators and comprehensions, while calling out overwrite order and the requirement that inverted values be unique and hashable.

Memory Map: Dictionary manipulation -> merge overwrite order -> invert only unique hashable values -> sort explicit keys.

Q14 β€” Dictionary Manipulation (Merge, Invert, Sort)

Question: Perform common dictionary operations: merge two dictionaries, invert a dictionary (swap keys and values), and sort by value.

Sample Input (merge): d1 = {'a': 1, 'b': 2}, d2 = {'b': 3, 'c': 4} Expected Output (merge): {'a': 1, 'b': 3, 'c': 4} (d2 overwrites d1 on conflict)

Sample Input (invert): {'a': 1, 'b': 2, 'c': 3} Expected Output (invert): {1: 'a', 2: 'b', 3: 'c'}

Sample Input (sort by value): {'alice': 85, 'bob': 92, 'charlie': 78} Expected Output (sort desc): {'bob': 92, 'alice': 85, 'charlie': 78}

Solution 1: Manual Loops (Easy to Understand) β€” Dictionary Manipulation (Merge, Invert, Sort)

python β€” editable
# Merge: copy d1, then update with d2
def merge_manual(d1, d2):
    # Start with a copy of d1 so we don't mutate the original
    # Then add/overwrite with all key-value pairs from d2
    result = {}
    for k, v in d1.items():
        result[k] = v
    for k, v in d2.items():
        result[k] = v
    return result

print(merge_manual({'a': 1, 'b': 2}, {'b': 3, 'c': 4}))
# Output: {'a': 1, 'b': 3, 'c': 4}

# Invert: swap keys and values
def invert_manual(d):
    result = {}
    for k, v in d.items():
        result[v] = k
    return result

print(invert_manual({'a': 1, 'b': 2, 'c': 3}))
# Output: {1: 'a', 2: 'b', 3: 'c'}

# Sort by value descending
def sort_by_value_manual(d):
    # Convert to list of tuples, sort by second element, rebuild dict
    pairs = list(d.items())
    pairs.sort(key=lambda x: x[1], reverse=True)
    return dict(pairs)

print(sort_by_value_manual({'alice': 85, 'bob': 92, 'charlie': 78}))
# Output: {'bob': 92, 'alice': 85, 'charlie': 78}

Solution 2: Using dict.update and comprehensions (Intermediate) β€” Dictionary Manipulation (Merge, Invert, Sort)

python β€” editable
# Merge with update()
def merge_update(d1, d2):
    # dict.update modifies in place, so copy first
    result = d1.copy()
    result.update(d2)
    return result

print(merge_update({'a': 1, 'b': 2}, {'b': 3, 'c': 4}))
# Output: {'a': 1, 'b': 3, 'c': 4}

# Invert with dict comprehension
original = {'a': 1, 'b': 2, 'c': 3}
inverted = {v: k for k, v in original.items()}
print(inverted)
# Output: {1: 'a', 2: 'b', 3: 'c'}

# Sort by value with sorted()
scores = {'alice': 85, 'bob': 92, 'charlie': 78}
sorted_scores = dict(sorted(scores.items(), key=lambda x: x[1], reverse=True))
print(sorted_scores)
# Output: {'bob': 92, 'alice': 85, 'charlie': 78}

Solution 3: Modern Python Operators β€” Pythonic (Interview Answer) β€” Dictionary Manipulation (Merge, Invert, Sort)

python β€” editable
# Merge with | operator (Python 3.9+)
d1 = {'a': 1, 'b': 2}
d2 = {'b': 3, 'c': 4}
print("Step 1 - d1:", d1, "| d2:", d2)
# Output: Step 1 - d1: {'a': 1, 'b': 2} | d2: {'b': 3, 'c': 4}
merged = d1 | d2  # d2 values overwrite d1 on key conflict
print("Step 2 - Merged:", merged)
# Output: Step 2 - Merged: {'a': 1, 'b': 3, 'c': 4}
# ↑ Key 'b' conflict: d2's value (3) wins over d1's value (2)

# For Python 3.5+, use unpacking
merged_compat = {**d1, **d2}
print(merged_compat)
# Output: {'a': 1, 'b': 3, 'c': 4}

# Invert with comprehension
original = {'a': 1, 'b': 2, 'c': 3}
print("Step 1 - original.items():", list(original.items()))
# Output: Step 1 - original.items(): [('a', 1), ('b', 2), ('c', 3)]
inverted = {v: k for k, v in original.items()}
print("Step 2 - Inverted:", inverted)
# Output: Step 2 - Inverted: {1: 'a', 2: 'b', 3: 'c'}

# Sort by value
scores = {'alice': 85, 'bob': 92, 'charlie': 78}
print("Step 1 - Unsorted items:", list(scores.items()))
# Output: Step 1 - Unsorted items: [('alice', 85), ('bob', 92), ('charlie', 78)]
sorted_scores = dict(sorted(scores.items(), key=lambda x: x[1], reverse=True))
print("Step 2 - Sorted desc:", sorted_scores)
# Output: Step 2 - Sorted desc: {'bob': 92, 'alice': 85, 'charlie': 78}

# Bonus: Group by value using defaultdict
from collections import defaultdict
data = [('a', 1), ('b', 2), ('a', 3), ('b', 4)]
grouped = defaultdict(list)
for key, val in data:
    grouped[key].append(val)
print(dict(sorted(grouped.items())))
# Output: {'a': [1, 3], 'b': [2, 4]}

Interview Tip: "Know d1 | d2 for Python 3.9+ and {**d1, **d2} for older versions. Both merge with right-side precedence. For inverting, warn that duplicate values will cause key collisions β€” only the last one survives."

What NOT to Say: "Dictionaries are unordered" β€” Since Python 3.7, dictionaries maintain insertion order. This is guaranteed by the language spec, not just an implementation detail.

Answer First: Choose a comprehension for a reusable materialized collection and a generator for lazy, single-pass, memory-bounded processing.

Memory Map: List comprehension -> eager reusable list; generator expression -> lazy single-pass stream -> compare memory.

Q15 β€” List Comprehension vs Generator Expression

Question: Explain and demonstrate the difference between list comprehension and generator expression. When should you use each?

Sample Input: Compute squares of numbers 0 through 999999. Expected Output: List comprehension uses ~8 MB RAM; generator uses ~200 bytes.

Solution 1: Side-by-Side Comparison (Easy to Understand) β€” List Comprehension vs Generator Expression

python β€” editable
import sys

# List comprehension: uses square brackets []
# Creates the ENTIRE list in memory at once
squares_list = [x ** 2 for x in range(1000000)]
list_size = sys.getsizeof(squares_list)
print(f"List size: {list_size} bytes")
# Output: List size: 8448728 bytes

# Generator expression: uses parentheses ()
# Produces items ONE AT A TIME, on demand β€” lazy evaluation
squares_gen = (x ** 2 for x in range(1000000))
gen_size = sys.getsizeof(squares_gen)
print(f"Generator size: {gen_size} bytes")
# Output: Generator size: 200 bytes

# Both produce the same values
small_list = [x ** 2 for x in range(5)]
small_gen = (x ** 2 for x in range(5))
print(small_list)
# Output: [0, 1, 4, 9, 16]
print(list(small_gen))
# Output: [0, 1, 4, 9, 16]

Solution 2: Showing Lazy vs Eager Behavior (Intermediate) β€” List Comprehension vs Generator Expression

python β€” editable
# Generators are LAZY β€” they compute values only when asked
def noisy_square(x):
    # This function prints when called, so we can see when computation happens
    print(f"  Computing {x}^2")
    return x ** 2

# List comprehension: ALL computations happen immediately
print("List comprehension (eager):")
result_list = [noisy_square(x) for x in range(3)]
# Output:
#   Computing 0^2
#   Computing 1^2
#   Computing 2^2
print(f"Result: {result_list}")
# Output: Result: [0, 1, 4]

print()

# Generator: computations happen only when you iterate
print("Generator expression (lazy):")
result_gen = (noisy_square(x) for x in range(3))
print("Generator created, nothing computed yet.")
# Output: Generator created, nothing computed yet.
print("Now iterating:")
# Output: Now iterating:
for val in result_gen:
    print(f"  Got: {val}")
# Output:
#   Computing 0^2
#   Got: 0
#   Computing 1^2
#   Got: 1
#   Computing 2^2
#   Got: 4

Solution 3: Real-World Data Engineering Use Case (Interview Answer) β€” List Comprehension vs Generator Expression

python β€” editable
import sys

# WHEN TO USE LIST: need random access, len(), or iterate multiple times
data_list = [x ** 2 for x in range(10)]
print(data_list[5])     # Random access works
# Output: 25
print(len(data_list))   # len() works
# Output: 10

# WHEN TO USE GENERATOR: huge data, single pass, memory matters
# Real data engineering use: processing a large CSV without loading it all
def process_large_file(filepath):
    """Process a file line by line without loading it all into memory."""
    with open(filepath) as f:
        # Generator β€” yields one cleaned line at a time
        # Only 1 line in memory at any point
        valid_lines = (line.strip() for line in f
                       if not line.startswith('#'))
        for line in valid_lines:
            pass  # process each line here

# Key difference: generators are single-use
gen = (x for x in range(5))
first_pass = list(gen)
second_pass = list(gen)
print(f"First pass: {first_pass}")
# Output: First pass: [0, 1, 2, 3, 4]
print(f"Second pass: {second_pass}")
# Output: Second pass: []

# Chaining generators for ETL pipelines β€” each stage is lazy
raw_data = range(1, 11)
print("Step 1 - Raw data:", list(raw_data))
# Output: Step 1 - Raw data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print("Step 2 - After double:", [x * 2 for x in raw_data])
# Output: Step 2 - After double: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
print("Step 3 - After filter >10:", [x * 2 for x in raw_data if x * 2 > 10])
# Output: Step 3 - After filter >10: [12, 14, 16, 18, 20]
# ↑ These prints show each stage β€” but in production, use generators:

step1 = (x * 2 for x in raw_data)        # transform: double
step2 = (x for x in step1 if x > 10)     # filter: keep > 10
step3 = (f"val={x}" for x in step2)       # format: add label
# Nothing has been computed yet β€” only when we consume:
print(list(step3))
# Output: ['val=12', 'val=14', 'val=16', 'val=18', 'val=20']

Interview Tip: "In data engineering, generators are critical for ETL pipelines. Saying 'I process data lazily to avoid OOM errors' shows production awareness. Key rule: use a list when you need to access data multiple times; use a generator for single-pass processing of large datasets."

What NOT to Say: "They're the same thing" β€” List comprehension uses [] and stores everything in memory. Generator uses () and is lazy. Also, generators are single-use β€” once exhausted, they produce nothing.

Intermediate

Python Tricky Output and Language Semantics

#

Python Tricky Output and Language Semantics

This chapter retains the complete legacy examples, outputs, caveats, comparisons, and interview tips for its owner domain.

Legacy source guide: Python Tricky Output & Gotchas β€” "What Will This Print?"

"What will be the output?" β€” The question that catches even experienced engineers. These are NOT coding questions. These test if you truly UNDERSTAND Python internals.

Tricky-output source memory map

🧠 PYTHON GOTCHAS β†’ MISLED
PYTHON GOTCHASMISLED
─────────────────────────
MMutability trap (default mutable args, list aliasing)
IIdentity vs Equality (is vs β†’ β†’ )
SScope & closures (LEGB, late binding)
LList tricks (shallow copy, slicing, unpacking)
EEvaluation order (short-circuit, ternary, chaining)
DData type surprises (float precision, string interning, tuple)

Answer First: Default objects are created once at function definition, so use None as a sentinel and allocate a new mutable object inside the call.

Memory Map: Mutable default -> allocated at definition -> reused across calls -> replace with None sentinel.

Q01 β€” Mutable Default Argument Trap

What will this print?

python β€” editable
def add_item(item, lst=[]):
    lst.append(item)
    return lst

print(add_item("a"))
print(add_item("b"))
print(add_item("c"))

Think about it...

Common Wrong Answer: ['a'], ['b'], ['c']

Most people assume a fresh empty list is created on every call. That is how it works in most other languages.

Actual Output:

python β€” editable
# Output:
# ['a']
# ['a', 'b']
# ['a', 'b', 'c']

Why: The default list [] is created ONCE when the function is defined, not each time it is called. Every call shares the SAME list object in memory. Python evaluates default arguments at function definition time, so lst points to the same list across all calls.

The Fix:

python β€” editable
def add_item(item, lst=None):
    if lst is None:  # Create a NEW list each time β€” None is immutable, safe as default
        lst = []
    lst.append(item)
    return lst

# Output:
# ['a']
# ['b']
# ['c']

Interview Tip: "Never use mutable objects (list, dict, set) as default arguments. Use None and create inside the function. This is Python's most famous gotcha."

What NOT to Say: "I think Python creates a new list each call" β€” this shows you have not encountered one of the most fundamental Python traps.

Answer First: Assignment creates another reference to the same list; copy when independent mutation is required, and use deep copy only for nested independence.

Memory Map: List aliasing -> assignment shares identity -> shallow copy separates outer list -> mutate to verify.

Q02 β€” List Aliasing vs Copy

What will this print?

python β€” editable
a = [1, 2, 3]
b = a            # b is NOT a copy β€” it is the SAME object
b.append(4)
print(a)
print(b)

Think about it...

Common Wrong Answer: [1, 2, 3] and [1, 2, 3, 4]

People assume b = a creates a copy. It does not.

Actual Output:

python β€” editable
# Output:
# [1, 2, 3, 4]
# [1, 2, 3, 4]

Why: b = a creates an alias, not a copy. Both a and b point to the exact same list object in memory. Mutating through one name is visible through the other because there is only one list.

The Fix:

python β€” editable
a = [1, 2, 3]
b = a[:]          # Shallow copy via slicing β€” creates a NEW list
b.append(4)
print(a)          # Output: [1, 2, 3]   β€” original unchanged
print(b)          # Output: [1, 2, 3, 4]

# Other ways to shallow copy:
b = a.copy()      # Shallow copy via method
b = list(a)       # Shallow copy via constructor

# WARNING: shallow copy is NOT enough for nested lists!
import copy
a = [[1, 2], [3, 4]]
b = a.copy()          # Shallow copy β€” inner lists are still shared
b[0].append(5)
print(a)              # Output: [[1, 2, 5], [3, 4]] β€” CHANGED!
b = copy.deepcopy(a)  # Deep copy β€” everything is independent

Interview Tip: "Know the difference: assignment (same object), shallow copy (new outer, shared inner), deep copy (everything new). This is critical for data pipelines where you transform copies of data."

What NOT to Say: "b = a copies the list" β€” this is a fundamental misunderstanding of Python references.

Answer First: == compares values and is compares object identity; use identity for singletons such as None, never as a value shortcut.

Memory Map: Integer caching is an implementation detail -> is checks identity -> == checks value -> use is None.

Q03 β€” is vs == and Integer Caching

What will this print?

python β€” editable
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b)       # Comparing VALUES

print(a is b)       # Comparing IDENTITY (same object in memory?)

x = 256
y = 256
print(x is y)       # Integer caching range: -5 to 256

p = int("257")      # int() forces a new object creation
q = int("257")      # Another new object
print(p is q)       # Outside caching range

Think about it...

Common Wrong Answer: True, True, True, True

People confuse equality with identity, and do not know about integer caching.

Actual Output:

python β€” editable
# Output:
# True    ← same values
# False   ← different objects in memory
# True    ← 256 is cached by Python (range -5 to 256)
# False   ← 257 is outside cache range, so two separate objects

Why:

  • == checks value equality β€” are the contents the same?
  • is checks identity β€” are they the exact same object in memory?
  • Python interns (caches) integers from -5 to 256 for performance. So 256 is 256 is True, but two separately created 257 objects are different. We use int("257") to prevent the compiler from reusing the same constant within one script.

The Fix:

python β€” editable
# ALWAYS use == for value comparison
if a == b:         # Correct β€” compares values
    print("equal")

# ONLY use 'is' for None checks
if x is None:      # Correct β€” None is a singleton
    print("missing")

# NEVER do this:
if x is 257:       # Wrong β€” identity check on integers is unreliable
    print("match")

Interview Tip: "Use == for value comparison. Use is only for None checks: if x is None. Never use is to compare numbers or strings β€” integer caching makes it unreliable."

What NOT to Say: "is and == are the same thing" β€” this shows a lack of understanding of Python's object model.

Answer First: String updates create new objects, while multiplying a nested mutable list repeats references to the same inner object.

Memory Map: String immutability creates a new value -> list multiplication repeats inner references -> copy per row.

Q04 β€” String Immutability and List Multiplication Trap

What will this print?

python β€” editable
s = "abc"
try:
    s[0] = "x"         # Strings are IMMUTABLE β€” cannot change in place
except TypeError as e:
    print(f"Error: {e}")

# Now the nested list trap:
matrix = [[0] * 3] * 3   # This creates 3 references to the SAME inner list
matrix[0][0] = 1
print(matrix)

Think about it...

Common Wrong Answer: [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

People expect only the first row to change.

Actual Output:

python β€” editable
# Output:
# Error: 'str' object does not support item assignment
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

Why: Two traps in one:

  1. Strings are immutable β€” you cannot modify individual characters.
  2. [[0]*3] * 3 creates 3 references to the SAME inner list. Changing one row changes all three because they are the same object.

The Fix:

python β€” editable
# Fix for string mutation:
s = "abc"
s = "x" + s[1:]           # Output: "xbc" β€” creates a NEW string

# Fix for matrix β€” use list comprehension to create INDEPENDENT rows:
matrix = [[0] * 3 for _ in range(3)]  # Each row is a separate list object
matrix[0][0] = 1
print(matrix)
# Output: [[1, 0, 0], [0, 0, 0], [0, 0, 0]] β€” only first row changed

Interview Tip: "[[0]*3]*3 creates 3 references to the SAME inner list. Use a list comprehension to create independent rows. This is the number one NumPy-free matrix bug."

What NOT to Say: "I would just use * to create a 2D list" β€” this shows you have fallen into the trap before without realizing it.

Answer First: Closures resolve free variables when called, so capture the loop value with a default argument or a factory when building functions in a loop.

Memory Map: Closure late binding -> loop name resolved at call time -> capture value in default or factory.

Q05 β€” Closure Late Binding Trap

What will this print?

python β€” editable
functions = []
for i in range(5):
    functions.append(lambda: i)  # lambda captures the VARIABLE i, not its current value

print([f() for f in functions])

Think about it...

Common Wrong Answer: [0, 1, 2, 3, 4]

People assume each lambda captures the value of i at the time it was created.

Actual Output:

python β€” editable
# Output:
# [4, 4, 4, 4, 4]

Why: Lambda captures the variable i, not its value at creation time. This is called late binding. By the time you call the functions, the loop is done and i = 4. All five lambdas look up i and find 4.

The Fix:

python β€” editable
# Fix 1: Default argument captures current value at definition time
functions = []
for i in range(5):
    functions.append(lambda i=i: i)  # i=i binds the current value as a default

print([f() for f in functions])
# Output: [0, 1, 2, 3, 4]

# Fix 2: Use functools.partial
from functools import partial
functions = [partial(lambda x: x, i) for i in range(5)]
print([f() for f in functions])
# Output: [0, 1, 2, 3, 4]

Interview Tip: "This is the classic late-binding closure trap. The fix lambda i=i: i captures the value at definition time via default argument. This applies to any closure in a loop, not just lambdas."

What NOT to Say: "Each lambda gets its own copy of i" β€” this is the exact misconception the question tests.

Answer First: The comma creates a tuple, not the parentheses, so a one-item tuple must be written (value,).

Memory Map: One-element tuple -> comma creates tuple -> (value,) -> parentheses alone only group.

Q06 β€” Tuple with One Element

What will this print?

python β€” editable
a = (1)       # Parentheses around an integer β€” just grouping
b = (1,)      # Trailing comma makes it a tuple
c = ()        # Empty tuple β€” no ambiguity here

print(type(a))
print(type(b))
print(type(c))

Think about it...

Common Wrong Answer: , ,

People assume parentheses always create a tuple.

Actual Output:

python β€” editable
# Output:
# <class 'int'>
# <class 'tuple'>
# <class 'tuple'>

Why: Parentheses in Python serve two purposes: grouping and tuple creation. (1) is just the integer 1 wrapped in grouping parentheses. A single-element tuple requires a trailing comma: (1,). The comma is what makes the tuple, not the parentheses.

The Fix:

python β€” editable
# Always use trailing comma for single-element tuples
single = (1,)         # Correct β€” this is a tuple
print(type(single))   # Output: <class 'tuple'>

# You can even omit parentheses β€” the comma is what matters
also_tuple = 1,
print(type(also_tuple))  # Output: <class 'tuple'>

# Multi-element tuples do not need trailing comma (but it is good style)
multi = (1, 2, 3)     # Output: tuple
multi = (1, 2, 3,)    # Also valid β€” trailing comma is optional

Interview Tip: "Trailing comma makes the tuple, not the parentheses. (1) is an int, (1,) is a tuple, 1, is also a tuple. This catches even senior developers."

What NOT to Say: "Parentheses create tuples" β€” this is only partially true and shows incomplete understanding.

Answer First: True, 1, and 1.0 compare equal and share compatible hashes, so they occupy one dictionary key slot.

Memory Map: Dictionary key overwrite -> equality plus equal hash -> True, 1, 1.0 share one slot.

Q07 β€” Dictionary Key Overwrite (True == 1 == 1.0)

What will this print?

python β€” editable
d = {
    True: 'yes',       # True has hash same as 1
    1: 'one',          # 1 == True, so this OVERWRITES the value
    1.0: 'float_one'   # 1.0 == 1 == True, overwrites again
}
print(d)
print(len(d))

Think about it...

Common Wrong Answer: {True: 'yes', 1: 'one', 1.0: 'float_one'} with length 3

People expect three separate keys.

Actual Output:

python β€” editable
# Output:
# {True: 'float_one'}
# 1

Why: In Python, True == 1 == 1.0 and hash(True) == hash(1) == hash(1.0). Since they are "equal" and have the same hash, they are treated as the same dictionary key. Each subsequent assignment overwrites the value but keeps the first key (True). So you end up with one entry: key True, value 'float_one'.

The Fix:

python β€” editable
# If you need them as separate keys, use different types or wrappers
d = {
    'bool_true': 'yes',
    'int_one': 'one',
    'float_one': 'float_one'
}
# Or be aware that True/1/1.0 collide and design accordingly

Interview Tip: "True, 1, and 1.0 are equal and have the same hash, so they collapse to one dict key. The key stays as True (first inserted), but the value gets overwritten to 'float_one' (last assigned)."

What NOT to Say: "True and 1 are different types so they would be different keys" β€” Python dicts use equality and hash, not type, for key identity.

Answer First: a < b < c evaluates like a < b and b < c, with the middle expression evaluated once.

Memory Map: Chained comparison -> expand to two comparisons with and -> evaluate middle operand once.

Q08 β€” Chained Comparison Surprise

What will this print?

python β€” editable
print(1 < 2 < 3)                  # Chained: (1 < 2) and (2 < 3)
print(1 < 2 > 0)                  # Chained: (1 < 2) and (2 > 0)
print(1 < 3 > 2)                  # Chained: (1 < 3) and (3 > 2)
print(False == False in [False])   # Tricky chaining with 'in' operator

Think about it...

Common Wrong Answer: True, True, True, False

The last one trips people up β€” they parse it as (False == False) in [False] which is True in [False] which is True. But the actual chaining behavior also gives True, just for a different reason.

Actual Output:

python β€” editable
# Output:
# True
# True
# True
# True

Why: Python chains ALL comparisons (including in):

  • 1 < 2 < 3 β†’ (1 < 2) and (2 < 3) β†’ True and True β†’ True
  • False == False in [False] β†’ (False == False) and (False in [False]) β†’ True and True β†’ True

The in operator is treated as a comparison operator, so it participates in chaining.

The Fix:

python β€” editable
# Use explicit parentheses to make intent clear
result = (False == False) in [False]   # True in [False] β†’ True
# vs
result = False == (False in [False])   # False == True β†’ False

# Chaining is great for range checks though:
x = 5
if 0 <= x <= 10:    # Clean and Pythonic β€” instead of: if x >= 0 and x <= 10
    print("valid")
# Output: valid

Interview Tip: "Python's comparison chaining includes in and is. a op1 b op2 c becomes (a op1 b) and (b op2 c). Use parentheses when mixing different operators to avoid confusion."

What NOT to Say: "Python evaluates left to right like (a < b) < c" β€” that would compare a boolean with c, which is NOT what Python does.

Answer First: *args collects extra positional arguments into a tuple and **kwargs collects extra keyword arguments into a dictionary; the same stars unpack at call sites.

Memory Map: *args gathers positional tuple -> **kwargs gathers keyword dict -> stars unpack on calls.

Q09 β€” *args and **kwargs Unpacking

What will this print?

python β€” editable
def show(a, b, *args, **kwargs):
    print(f"a={a}, b={b}")
    print(f"args={args}")                     # Extra positional β†’ tuple
    print(f"kwargs={sorted(kwargs.items())}")  # Extra keyword β†’ dict (sorted for determinism)

show(1, 2, 3, 4, 5, x=10, y=20)

Think about it...

Common Wrong Answer: People often confuse the types β€” thinking args is a list or kwargs is a list of tuples.

Actual Output:

python β€” editable
# Output:
# a=1, b=2
# args=(3, 4, 5)
# kwargs=[('x', 10), ('y', 20)]

Why:

  • a and b consume the first two positional args
  • *args collects remaining positional args as a tuple (not a list!)
  • **kwargs collects keyword args as a dict
  • The order is enforced: positional β†’ *args β†’ keyword-only β†’ **kwargs

The Fix:

python β€” editable
# Unpacking in function calls β€” the reverse operation
def add(a, b, c):
    return a + b + c

nums = [1, 2, 3]
print(add(*nums))         # Output: 6 β€” unpacks list as positional args

config = {'a': 1, 'b': 2, 'c': 3}
print(add(**config))      # Output: 6 β€” unpacks dict as keyword args

# Full signature order in Python 3.8+:
# def f(pos_only, /, normal, *, kw_only, **kwargs)

Interview Tip: "*args is a tuple, **kwargs is a dict. Order: def f(pos, /, normal, *args, kw_only, **kwargs). Know where / and * go for positional-only and keyword-only parameters."

What NOT to Say: "*args gives you a list" β€” it is a tuple. This is a common slip that interviewers notice.

Answer First: Python resolves names through Local, Enclosing, Global, then Built-in scopes; use nonlocal or global only when rebinding the corresponding outer name.

Memory Map: LEGB scope -> local -> enclosing -> global -> built-in; nonlocal/global rebind deliberately.

Q10 β€” Scope: Local vs Global (LEGB Rule)

What will this print?

python β€” editable
x = 10               # Global scope

def outer():
    x = 20            # Enclosing scope
    def inner():
        x = 30        # Local scope β€” shadows outer and global
        print("inner:", x)
    inner()
    print("outer:", x)  # Still 20 β€” inner's x was local to inner

outer()
print("global:", x)    # Still 10 β€” nothing modified global x

Think about it...

Common Wrong Answer: Some expect inner() to modify outer()'s x, giving inner: 30, outer: 30, global: 10.

Actual Output:

python β€” editable
# Output:
# inner: 30
# outer: 20
# global: 10

Why: Python follows the LEGB rule: Local β†’ Enclosing β†’ Global β†’ Built-in. Each x = ... inside a function creates a new local variable that shadows the outer one. Without nonlocal or global, assignment never modifies an outer scope.

The Fix:

python β€” editable
x = 10

def outer():
    x = 20
    def inner():
        nonlocal x    # Now refers to outer's x β€” not a new local
        x = 30
    inner()
    print("outer:", x)  # Output: 30 β€” changed by inner via nonlocal

outer()

def change_global():
    global x          # Now refers to module-level x
    x = 99

change_global()
print("global:", x)   # Output: 99 β€” changed by global keyword

Interview Tip: "LEGB = Local, Enclosing, Global, Built-in. Without nonlocal/global, assignment creates a NEW local variable. Reading works through LEGB, but writing always creates local unless you explicitly declare otherwise."

What NOT to Say: "Assignment in a function automatically modifies the global variable" β€” this shows confusion about Python's scoping rules.

Answer First: enumerate pairs items with indices and zip aligns iterables positionally; plain zip stops at the shortest input.

Memory Map: enumerate adds indices -> zip aligns positions -> shortest iterable ends the pairing.

Q11 β€” enumerate and zip Tricks

What will this print?

python β€” editable
names = ['Alice', 'Bob', 'Charlie']
scores = [85, 92, 78]

# enumerate starts at 0 by default β€” but you can change it
for i, name in enumerate(names, start=1):
    print(f"{i}. {name}")

print("---")

# zip pairs elements from multiple iterables
for name, score in zip(names, scores):
    print(f"{name}: {score}")

print("---")

# Unzip trick β€” zip(*iterable) transposes rows and columns
pairs = [('a', 1), ('b', 2), ('c', 3)]
letters, numbers = zip(*pairs)   # zip(('a',1), ('b',2), ('c',3))
print(letters)
print(numbers)

Think about it...

Common Wrong Answer: People often forget zip returns tuples, or expect enumerate to start at 1 by default.

Actual Output:

python β€” editable
# Output:
# 1. Alice
# 2. Bob
# 3. Charlie
# ---
# Alice: 85
# Bob: 92
# Charlie: 78
# ---
# ('a', 'b', 'c')
# (1, 2, 3)

Why:

  • enumerate(iterable, start=0) yields (index, element) pairs. start=1 shifts indices.
  • zip pairs elements position-by-position and stops at the shortest iterable.
  • zip(*pairs) is the unzip trick β€” it transposes rows into columns. The * unpacks the list so each tuple becomes a separate argument to zip.

The Fix:

python β€” editable
# If iterables have different lengths, zip silently truncates:
a = [1, 2, 3]
b = [10, 20]
print(list(zip(a, b)))    # Output: [(1, 10), (2, 20)] β€” 3 is dropped!

# Use itertools.zip_longest to keep all elements:
from itertools import zip_longest
print(list(zip_longest(a, b, fillvalue=0)))
# Output: [(1, 10), (2, 20), (3, 0)]

Interview Tip: "Know that enumerate starts at 0 by default but accepts start=. And zip(*pairs) is the unzip/transpose trick. Also know that zip truncates silently β€” use zip_longest if you need all elements."

What NOT to Say: "zip raises an error if lengths differ" β€” it silently truncates, which can cause subtle data loss bugs.

Answer First: Assignment expressions bind and return one computed value inside a larger expression, which is useful only when it removes repeated work without hiding intent.

Memory Map: Walrus operator -> compute once -> bind inside expression -> keep only when intent stays obvious.

Q12 β€” Walrus Operator := (Python 3.8+)

What will this print?

python β€” editable
# Walrus operator assigns AND returns the value in one expression
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# Without walrus β€” you compute x**2 twice (once to filter, once to keep)
results_old = [x**2 for x in numbers if x**2 > 25]

# With walrus β€” compute once, use twice
results_new = [y for x in numbers if (y := x**2) > 25]

print(results_old)
print(results_new)
print(results_old == results_new)

Think about it...

Common Wrong Answer: People sometimes think := only works in while loops, or that y is not accessible in the yield expression.

Actual Output:

python β€” editable
# Output:
# [36, 49, 64, 81, 100]
# [36, 49, 64, 81, 100]
# True

Why: The walrus operator := assigns a value to a variable and returns that value, all in a single expression. In the comprehension, (y := x**2) computes x**2, assigns it to y, and the result is used in the > 25 comparison. Then y is used as the element to include. This avoids computing x**2 twice.

The Fix:

python β€” editable
# Classic use case β€” avoid calling a function twice
import re
text = "Today is 2026-03-27"
if (match := re.search(r'\d{4}-\d{2}-\d{2}', text)):  # Assign and test in one line
    print(f"Found date: {match.group()}")
# Output: Found date: 2026-03-27

# While loop β€” cleaner than the two-call pattern
# Without walrus:
# line = input()
# while line != "quit":
#     process(line)
#     line = input()

# With walrus:
# while (line := input()) != "quit":
#     process(line)

Interview Tip: "Walrus operator assigns AND returns the value. It avoids repeated computation in comprehensions and cleans up while-loop patterns. Available since Python 3.8."

What NOT to Say: "I have never seen := before" β€” it has been in Python since 3.8 (2019) and is commonly used in modern codebases.

Answer First: any([]) is false and all([]) is true by identity rules; guard emptiness separately when vacuous truth is not valid business logic.

Memory Map: any empty identity is false -> all empty identity is true -> guard required non-emptiness.

Q13 β€” any() and all() with Empty Collections

What will this print?

python β€” editable
print(any([0, '', None, False, 42]))   # Is ANY element truthy?
print(all([1, 'a', True, [1]]))        # Are ALL elements truthy?
print(all([1, 'a', True, []]))         # [] is falsy!
print(any([]))                          # any of nothing?
print(all([]))                          # all of nothing?

Think about it...

Common Wrong Answer: Most people get the last one wrong β€” they expect all([]) to be False.

Actual Output:

python β€” editable
# Output:
# True     ← 42 is truthy, so any() returns True
# True     ← all elements are truthy
# False    ← [] is falsy, so all() returns False
# False    ← no elements to be truthy
# True     ← vacuous truth! no elements to be falsy

Why:

  • any() returns True if at least one element is truthy. Empty β†’ False (nothing is truthy).
  • all() returns True if no element is falsy. Empty β†’ True (nothing is falsy). This is called vacuous truth β€” a concept from formal logic.
  • Python's falsy values: 0, 0.0, '', None, False, [], {}, set(), ().

The Fix:

python β€” editable
# Guard against vacuous truth if it matters in your logic:
items = []
if items and all(validate(x) for x in items):  # Short-circuits on empty
    print("All valid")
else:
    print("No items or some invalid")
# Output: No items or some invalid

# Practical use in data engineering:
import os
files = ['data.csv', 'config.yaml']
if all(os.path.exists(f) for f in files):
    print("All files ready")

Interview Tip: "all([]) returns True β€” this is vacuous truth and surprises everyone. Guard against it with if items and all(...). Know all the falsy values: 0, '', None, False, [], {}, set()."

What NOT to Say: "all([]) returns False because there are no elements" β€” this is the most common wrong answer and shows you have not tested it.

Answer First: except handles matching failures, else runs only after a successful try, and finally always runs; never let a finally return suppress the real result or exception.

Memory Map: try risky work -> except matching failure -> else success -> finally unconditional cleanup.

Q14 β€” try/except/else/finally Flow

What will this print?

python β€” editable
def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Error!")
        return -1
    else:
        print("Success!")       # Only runs if NO exception
        return result
    finally:
        print("Cleanup!")       # ALWAYS runs β€” even after return!

print(divide(10, 2))
print("---")
print(divide(10, 0))

Think about it...

Common Wrong Answer: People often think finally does not run after a return, or that else always runs.

Actual Output:

python β€” editable
# Output:
# Success!
# Cleanup!
# 5.0
# ---
# Error!
# Cleanup!
# -1

Why:

  • else runs only if NO exception occurred in try
  • finally ALWAYS runs β€” even if there is a return statement in try, except, or else
  • finally runs after the return value is determined but before the function actually returns

The Fix:

python β€” editable
# DANGER: What if finally also has a return?
def tricky():
    try:
        return 1        # Return value is determined as 1
    finally:
        return 2        # But finally OVERRIDES it!

print(tricky())
# Output: 2  ← finally's return REPLACES try's return!

# Rule: NEVER put return in finally β€” it silently swallows exceptions too
def swallowed():
    try:
        raise ValueError("important error!")
    finally:
        return "oops"   # Exception is silently swallowed!

print(swallowed())
# Output: oops  ← the ValueError is gone!

Interview Tip: "finally always runs. If both try and finally have return statements, finally wins. Never put return in finally β€” it can silently swallow exceptions."

What NOT to Say: "finally only runs if there is no exception" β€” that is else, not finally. Confusing these is a red flag.

Answer First: Prefer comprehensions for local transformation/filter logic, use map with an existing callable, and reserve reduce for genuinely cumulative operations.

Memory Map: map callable transform -> filter predicate -> reduce accumulation -> prefer readable comprehensions.

Q15 β€” map, filter, reduce vs Comprehensions

What will this print?

python β€” editable
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# Functional style β€” map + filter
evens_squared_func = list(
    map(lambda x: x**2,                    # Step 2: square each
        filter(lambda x: x % 2 == 0,       # Step 1: keep evens
               numbers))
)

# Pythonic style β€” list comprehension (reads left to right)
evens_squared_comp = [x**2 for x in numbers if x % 2 == 0]

print(evens_squared_func)
print(evens_squared_comp)
print(evens_squared_func == evens_squared_comp)

# reduce β€” must be imported in Python 3
from functools import reduce
total = reduce(lambda a, b: a + b, numbers)  # Accumulates: ((((1+2)+3)+4)+...+10)
print(total)
print(total == sum(numbers))

Think about it...

Common Wrong Answer: People sometimes think reduce is a built-in, or that map/filter return lists directly.

Actual Output:

python β€” editable
# Output:
# [4, 16, 36, 64, 100]
# [4, 16, 36, 64, 100]
# True
# 55
# True

Why:

  • map() and filter() return lazy iterators in Python 3, not lists β€” you must wrap with list().
  • List comprehensions are more Pythonic and readable β€” they read left to right instead of inside out.
  • reduce was moved from built-in to functools in Python 3 β€” Guido considered it hard to read.
  • For simple aggregations, use built-ins: sum(), max(), min() instead of reduce.

The Fix:

python β€” editable
# When to use map/filter β€” passing an EXISTING function (no lambda needed)
names = ['alice', 'bob', 'charlie']
upper_names = list(map(str.upper, names))   # Cleaner than [x.upper() for x in names]
print(upper_names)
# Output: ['ALICE', 'BOB', 'CHARLIE']

# When to use comprehension β€” when you need a lambda anyway
squared = [x**2 for x in range(10)]        # Cleaner than list(map(lambda x: x**2, range(10)))

# Generator expression for memory efficiency with large data
total = sum(x**2 for x in range(1000000))   # No list created in memory

Interview Tip: "List comprehensions are more Pythonic than map/filter when you would need a lambda. Use map/filter when passing an existing function like str.upper. reduce is in functools in Python 3, but prefer sum/max/min for simple cases."

What NOT to Say: "reduce is a built-in function" β€” it was moved to functools in Python 3. Saying this suggests you are stuck in Python 2 thinking.

Intermediate

Python Data Structures and Coding Patterns

#

Python Data Structures and Coding Patterns

This chapter retains the complete legacy examples, outputs, caveats, comparisons, and interview tips for its owner domain.

Legacy source guide: Python Data Structures & Coding Patterns β€” Data Engineer Edition

πŸ’‘ Interview Tip
Interviewers don't expect LeetCode hard. They want to see you handle dicts, sets, sorting, and basic algorithms cleanly. If you can do these patterns, you can handle any Python question they throw.

Data-structures source memory map

🧠 PYTHON PATTERNS β†’ SHBCQ
PYTHON PATTERNSSHBCQ
──────────────────────────
SSets (intersection, union, difference)
HHash maps (Counter, defaultdict, grouping)
BBuilt-ins (sorted, zip, enumerate, any/all)
CCollections (deque, namedtuple, OrderedDict)
QQueue/Stack patterns (LIFO, FIFO, bracket matching)

Answer First: Convert hashable collections to sets, then use intersection, difference, union, and symmetric difference to express membership comparisons directly.

Memory Map: Set operations -> hashable inputs -> intersection/difference/union -> membership result without duplicates.

Q01 β€” Set Operations for Data Comparison

Question: Given two sets of team members, find (a) common members, (b) members only in team A, (c) members in either but not both, and (d) all members combined.

Sample Input:

team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}

Expected Output:

Common: ['Bob', 'Diana']
Only in A: ['Alice', 'Charlie']
Symmetric difference: ['Alice', 'Charlie', 'Eve', 'Frank']
All members: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']

Solution 1 β€” Manual loop approach β€” Set Operations for Data Comparison

python β€” editable
team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}

# Find common members by checking each member of A in B
common = []
for member in team_a:
    if member in team_b:
        common.append(member)
print("Common:", sorted(common))
# Output: Common: ['Bob', 'Diana']

# Find members only in team A
only_a = []
for member in team_a:
    if member not in team_b:
        only_a.append(member)
print("Only in A:", sorted(only_a))
# Output: Only in A: ['Alice', 'Charlie']

# Find symmetric difference (in one but not both)
sym_diff = []
for member in team_a:
    if member not in team_b:
        sym_diff.append(member)
for member in team_b:
    if member not in team_a:
        sym_diff.append(member)
print("Symmetric difference:", sorted(sym_diff))
# Output: Symmetric difference: ['Alice', 'Charlie', 'Eve', 'Frank']

# Combine all members using a helper set
all_members = set()
for member in team_a:
    all_members.add(member)
for member in team_b:
    all_members.add(member)
print("All members:", sorted(all_members))
# Output: All members: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']

Solution 2 β€” List comprehension approach β€” Set Operations for Data Comparison

python β€” editable
team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}

# Use list comprehensions instead of explicit loops
common = sorted([m for m in team_a if m in team_b])
print("Common:", common)
# Output: Common: ['Bob', 'Diana']

only_a = sorted([m for m in team_a if m not in team_b])
print("Only in A:", only_a)
# Output: Only in A: ['Alice', 'Charlie']

sym_diff = sorted([m for m in team_a if m not in team_b] +
                  [m for m in team_b if m not in team_a])
print("Symmetric difference:", sym_diff)
# Output: Symmetric difference: ['Alice', 'Charlie', 'Eve', 'Frank']

all_members = sorted(set(list(team_a) + list(team_b)))
print("All members:", all_members)
# Output: All members: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']

Solution 3 β€” Optimal: Set operators (Pythonic) β€” Set Operations for Data Comparison

python β€” editable
team_a = {"Alice", "Bob", "Charlie", "Diana"}
team_b = {"Bob", "Diana", "Eve", "Frank"}

# Step 1: Let's SEE what these sets look like
print("Team A:", sorted(team_a))
# Output: Team A: ['Alice', 'Bob', 'Charlie', 'Diana']
print("Team B:", sorted(team_b))
# Output: Team B: ['Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
# ↑ Sets are unordered β€” sorted() just makes output readable

# Step 2: & operator = intersection (common members)
common = team_a & team_b
print("Common (A & B):", sorted(common))
# Output: Common (A & B): ['Bob', 'Diana']
# ↑ Only Bob and Diana are in BOTH sets

# Step 3: - operator = difference (only in A, not in B)
only_a = team_a - team_b
print("Only in A (A - B):", sorted(only_a))
# Output: Only in A (A - B): ['Alice', 'Charlie']
# ↑ Alice and Charlie are in A but NOT in B β€” this is like a LEFT ANTI JOIN

# Step 4: ^ operator = symmetric difference (in one but not both)
sym_diff = team_a ^ team_b
print("In one but not both (A ^ B):", sorted(sym_diff))
# Output: In one but not both (A ^ B): ['Alice', 'Charlie', 'Eve', 'Frank']
# ↑ Excludes Bob and Diana (they're in both) β€” like a FULL OUTER minus INNER

# Step 5: | operator = union (all combined, deduplicated)
all_members = team_a | team_b
print("All members (A | B):", sorted(all_members))
# Output: All members (A | B): ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
# ↑ 6 unique members β€” Bob and Diana appear once, not twice

# Bonus: Subset check
small = {"Bob", "Diana"}
print("Is small βŠ† team_a?", small <= team_a)
# Output: Is small βŠ† team_a? True
# ↑ <= checks if every element in small exists in team_a

Interview Tip: "Set operations are O(min(len(a), len(b))). Much faster than list comparison loops which are O(n*m). In data engineering, use sets to compare column lists, find missing columns, and deduplicate."

What NOT to Say: "I would loop through both lists to find common elements." -- This shows you do not know set operations, which are fundamental in Python and data engineering.

Answer First: Use defaultdict when missing keys need a factory and Counter when the domain is frequency arithmetic.

Memory Map: defaultdict supplies missing-value factory -> Counter tallies frequencies -> choose by intent.

Q02 β€” defaultdict and Counter Patterns

Question: Given a list of words, count the frequency of each word. Then, given a list of transactions with categories and amounts, group the amounts by category.

Sample Input:

words = ["hello", "world", "hello", "foo", "world", "hello"]
transactions = [("food", 50), ("transport", 30), ("food", 25),
("entertainment", 100), ("transport", 45)]

Expected Output:

Word counts: {'foo': 1, 'hello': 3, 'world': 2}
Grouped: {'entertainment': [100], 'food': [50, 25], 'transport': [30, 45]}

Solution 1 β€” Manual dict approach β€” defaultdict and Counter Patterns

python β€” editable
words = ["hello", "world", "hello", "foo", "world", "hello"]

# Count words using a plain dict with key existence check
word_count = {}
for word in words:
    if word not in word_count:
        word_count[word] = 0   # initialize key if missing
    word_count[word] += 1

print("Word counts:", dict(sorted(word_count.items())))
# Output: Word counts: {'foo': 1, 'hello': 3, 'world': 2}

# Group transactions using plain dict
transactions = [("food", 50), ("transport", 30), ("food", 25),
                ("entertainment", 100), ("transport", 45)]

by_category = {}
for category, amount in transactions:
    if category not in by_category:
        by_category[category] = []   # initialize empty list
    by_category[category].append(amount)

print("Grouped:", dict(sorted(by_category.items())))
# Output: Grouped: {'entertainment': [100], 'food': [50, 25], 'transport': [30, 45]}

Solution 2 β€” defaultdict approach β€” defaultdict and Counter Patterns

python β€” editable
from collections import defaultdict

words = ["hello", "world", "hello", "foo", "world", "hello"]

# defaultdict(int) auto-creates missing keys with value 0
word_count = defaultdict(int)
for word in words:
    word_count[word] += 1   # no need to check if key exists

print("Word counts:", dict(sorted(word_count.items())))
# Output: Word counts: {'foo': 1, 'hello': 3, 'world': 2}

# defaultdict(list) auto-creates missing keys with empty list
transactions = [("food", 50), ("transport", 30), ("food", 25),
                ("entertainment", 100), ("transport", 45)]

by_category = defaultdict(list)
for category, amount in transactions:
    by_category[category].append(amount)   # no init needed

print("Grouped:", dict(sorted(by_category.items())))
# Output: Grouped: {'entertainment': [100], 'food': [50, 25], 'transport': [30, 45]}

Solution 3 β€” Optimal: Counter (for counting) β€” defaultdict and Counter Patterns

python β€” editable
from collections import Counter

words = ["hello", "world", "hello", "foo", "world", "hello"]

# Step 1: Counter scans the list once and builds {element: count}
word_count = Counter(words)
print("Step 1 - Raw Counter:", word_count)
# Output: Step 1 - Raw Counter: Counter({'hello': 3, 'world': 2, 'foo': 1})
# ↑ Counter is a dict subclass β€” keys are elements, values are their counts

# Step 2: Sort by key for consistent display
print("Step 2 - Sorted:", dict(sorted(word_count.items())))
# Output: Step 2 - Sorted: {'foo': 1, 'hello': 3, 'world': 2}
# ↑ dict(sorted(...)) gives us alphabetical order for readability

# Step 3: most_common(n) returns top n as list of (element, count) tuples
top_2 = word_count.most_common(2)
print("Step 3 - Top 2:", top_2)
# Output: Step 3 - Top 2: [('hello', 3), ('world', 2)]
# ↑ Already sorted by count descending β€” no need to sort manually!

# Step 4: Counter arithmetic β€” useful for comparing frequency distributions
c1 = Counter("aabbc")
c2 = Counter("abbcc")
print("Step 4a - c1:", c1)
# Output: Step 4a - c1: Counter({'a': 2, 'b': 2, 'c': 1})
print("Step 4b - c2:", c2)
# Output: Step 4b - c2: Counter({'b': 2, 'c': 2, 'a': 1})

print("c1 + c2:", c1 + c2)   # add counts element-wise
# Output: c1 + c2: Counter({'b': 4, 'a': 3, 'c': 3})
# ↑ 'b' had 2+2=4, 'a' had 2+1=3, 'c' had 1+2=3

print("c1 - c2:", c1 - c2)   # subtract, drops zero/negative
# Output: c1 - c2: Counter({'a': 1})
# ↑ Only 'a' has more in c1 (2) than c2 (1) β†’ keeps 2-1=1

print("c1 & c2:", c1 & c2)   # min of each count (intersection)
# Output: c1 & c2: Counter({'b': 2, 'a': 1, 'c': 1})
# ↑ min(2,1)=1 for 'a', min(2,2)=2 for 'b', min(1,2)=1 for 'c'

Interview Tip: "Counter supports arithmetic: +, -, & (min), | (max). These are incredibly useful for frequency analysis in ETL. defaultdict eliminates boilerplate key-existence checks."

What NOT to Say: "I would use .setdefault() for everything." -- setdefault works but is less readable than defaultdict. Know both, but prefer defaultdict in interviews.

Answer First: Pass a key that represents the complete sort order; tuple keys make multi-criteria sorting explicit and stable.

Memory Map: Custom sorting -> encode priority in key -> tuple for multiple criteria -> rely on stable ties.

Q03 β€” Sorting with Custom Keys

Question: Given a list of student tuples (name, grade), sort them by grade descending. If grades are equal, sort by name ascending.

Sample Input:

students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]

Expected Output:

[('Charlie', 92), ('Alice', 85), ('Bob', 85), ('Diana', 78)]

Solution 1 β€” Manual: Bubble sort with custom comparison β€” Sorting with Custom Keys

python β€” editable
students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]

# Copy the list so we do not mutate original
result = students[:]

# Bubble sort: compare grade desc, then name asc
for i in range(len(result)):
    for j in range(i + 1, len(result)):
        name_i, grade_i = result[i]
        name_j, grade_j = result[j]
        # Swap if j should come before i
        should_swap = False
        if grade_j > grade_i:               # higher grade first
            should_swap = True
        elif grade_j == grade_i and name_j < name_i:  # same grade, alpha by name
            should_swap = True
        if should_swap:
            result[i], result[j] = result[j], result[i]

print(result)
# Output: [('Charlie', 92), ('Alice', 85), ('Bob', 85), ('Diana', 78)]

Solution 2 β€” sorted() with lambda key β€” Sorting with Custom Keys

python β€” editable
students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]

# Sort by grade descending using reverse=True
by_grade_desc = sorted(students, key=lambda x: x[1], reverse=True)
print(by_grade_desc)
# Output: [('Charlie', 92), ('Alice', 85), ('Bob', 85), ('Diana', 78)]
# Note: Alice and Bob both have 85, but order may not be alphabetical

Solution 3 β€” Optimal: Tuple key with negation for multi-criteria β€” Sorting with Custom Keys

python β€” editable
students = [("Alice", 85), ("Bob", 85), ("Charlie", 92), ("Diana", 78)]

# Step 1: Let's see what the lambda key produces for each student
for s in students:
    print(f"  {s[0]:>8} β†’ key = ({-s[1]}, '{s[0]}') = {(-s[1], s[0])}")
# Output:     Alice β†’ key = (-85, 'Alice') = (-85, 'Alice')
# Output:       Bob β†’ key = (-85, 'Bob') = (-85, 'Bob')
# Output:   Charlie β†’ key = (-92, 'Charlie') = (-92, 'Charlie')
# Output:     Diana β†’ key = (-78, 'Diana') = (-78, 'Diana')
# ↑ Negating the grade means -92 < -85 < -78, so highest grade sorts FIRST

# Step 2: Python compares tuples left to right
# -92 comes first (smallest). For tied grades (-85), 'Alice' < 'Bob' alphabetically
result = sorted(students, key=lambda x: (-x[1], x[0]))
print("Step 2 - Sorted:", result)
# Output: Step 2 - Sorted: [('Charlie', 92), ('Alice', 85), ('Bob', 85), ('Diana', 78)]
# ↑ Charlie (92) first, then Alice before Bob (both 85, but A < B alphabetically)

# Bonus: Sort a dict by values
scores = {'alice': 85, 'bob': 92, 'charlie': 78}
print("Step 3 - Dict keys:", list(scores.keys()))
# Output: Step 3 - Dict keys: ['alice', 'bob', 'charlie']
# ↑ sorted(scores) iterates over KEYS, but sorts them by scores.get (the values)

sorted_names = sorted(scores, key=scores.get, reverse=True)
print("Step 4 - Sorted by value desc:", sorted_names)
# Output: Step 4 - Sorted by value desc: ['bob', 'alice', 'charlie']
# ↑ bob(92) > alice(85) > charlie(78) β€” keys sorted by their values!

Interview Tip: "For multi-criteria sorting, use a tuple in the key function. Negate numeric values for reverse order on specific fields. Python's sort is stable, so you can also do two passes β€” but the tuple key is cleaner."

What NOT to Say: "I would sort twice β€” once by name, then by grade." -- While stable sort makes this technically correct, it shows you do not know the tuple-key trick, which is the standard approach.

Answer First: Use a list for LIFO stack operations and collections.deque for O(1) operations at both queue ends.

Memory Map: Stack uses list append/pop -> queue uses deque append/popleft -> both preserve end semantics.

Q04 β€” Stack and Queue Patterns

Question: Implement a function that checks whether a string of brackets is balanced. Valid pairs are (), [], {}.

Sample Input:

s1 = "({[]})"
s2 = "({[})"
s3 = "((()))"

Expected Output:

True
False
True

Solution 1 β€” Manual: Repeated string replacement β€” Stack and Queue Patterns

python β€” editable
def is_balanced(s):
    # Keep removing innermost pairs until nothing is left
    while "()" in s or "[]" in s or "{}" in s:
        s = s.replace("()", "")
        s = s.replace("[]", "")
        s = s.replace("{}", "")
    # If empty, all brackets were matched
    return len(s) == 0

print(is_balanced("({[]})"))   # Output: True
print(is_balanced("({[})"))    # Output: False
print(is_balanced("((()))"))   # Output: True

Solution 2 β€” Stack with list β€” Stack and Queue Patterns

python β€” editable
def is_balanced(s):
    stack = []
    # Map each closing bracket to its opening counterpart
    pairs = {')': '(', ']': '[', '}': '{'}

    for char in s:
        if char in '([{':
            stack.append(char)         # push opening bracket
        elif char in ')]}':
            if not stack:              # nothing to match against
                return False
            if stack[-1] != pairs[char]:  # top of stack must match
                return False
            stack.pop()                # matched β€” remove from stack

    return len(stack) == 0             # stack must be empty at end

print(is_balanced("({[]})"))   # Output: True
print(is_balanced("({[})"))    # Output: False
print(is_balanced("((()))"))   # Output: True

Solution 3 β€” Optimal: Stack with deque (O(1) operations) β€” Stack and Queue Patterns

python β€” editable
from collections import deque

def is_balanced(s):
    # deque gives O(1) append and pop from both ends
    stack = deque()
    pairs = {')': '(', ']': '[', '}': '{'}
    openers = set('([{')

    for char in s:
        if char in openers:
            stack.append(char)
        elif char in pairs:
            # If stack empty or top does not match, it is unbalanced
            if not stack or stack[-1] != pairs[char]:
                return False
            stack.pop()

    return len(stack) == 0

print(is_balanced("({[]})"))   # Output: True
print(is_balanced("({[})"))    # Output: False
print(is_balanced("((()))"))   # Output: True

# Bonus: Stack vs Queue demonstration
# Stack = LIFO (Last In, First Out) β€” use append/pop
stack = []
stack.append(1); stack.append(2); stack.append(3)
print(stack.pop())   # Output: 3

# Queue = FIFO (First In, First Out) β€” use deque with append/popleft
queue = deque()
queue.append(1); queue.append(2); queue.append(3)
print(queue.popleft())  # Output: 1

Interview Tip: "Never use list.pop(0) for queues -- it is O(n) because every element shifts. Use deque.popleft() which is O(1). For stacks, list is fine since append/pop from the end are both O(1)."

What NOT to Say: "I would use a list for the queue." -- This reveals you do not understand time complexity. list.pop(0) is O(n), while deque.popleft() is O(1).

Answer First: Keep lambdas to one small expression and prefer a comprehension when it makes the data flow easier to read.

Memory Map: Lambda one-liner -> map transforms -> filter selects -> switch to comprehension when clearer.

Q05 β€” Lambda, Map, Filter in One-Liners

Question: Given a list of integers, filter out the negative numbers and zero, then return the squares of the remaining positive numbers.

Sample Input:

numbers = [-3, -1, 0, 2, 4, 7]

Expected Output:

[4, 16, 49]

Solution 1 β€” Manual loop β€” Lambda, Map, Filter in One-Liners

python β€” editable
numbers = [-3, -1, 0, 2, 4, 7]

result = []
for x in numbers:
    if x > 0:             # filter: keep only positive numbers
        result.append(x ** 2)  # transform: square each one

print(result)
# Output: [4, 16, 49]

Solution 2 β€” map() and filter() with lambda β€” Lambda, Map, Filter in One-Liners

python β€” editable
numbers = [-3, -1, 0, 2, 4, 7]

# filter() keeps elements where the lambda returns True
# map() applies the lambda to each remaining element
result = list(map(lambda x: x ** 2,
                  filter(lambda x: x > 0, numbers)))

print(result)
# Output: [4, 16, 49]

# Other useful lambda patterns:
names = ["alice", "BOB", "Charlie"]
normalized = list(map(str.title, names))
print(normalized)
# Output: ['Alice', 'Bob', 'Charlie']

# Sort dicts with lambda
data = [{"name": "Alice", "age": 30},
        {"name": "Bob", "age": 25}]
sorted_data = sorted(data, key=lambda d: d["age"])
print(sorted_data)
# Output: [{'name': 'Bob', 'age': 25}, {'name': 'Alice', 'age': 30}]

Solution 3 β€” Optimal: List comprehension (Pythonic) β€” Lambda, Map, Filter in One-Liners

python β€” editable
numbers = [-3, -1, 0, 2, 4, 7]

# Combines filter and transform in one readable expression
result = [x ** 2 for x in numbers if x > 0]

print(result)
# Output: [4, 16, 49]

# More one-liner patterns:
# Conditional expression in comprehension
classified = ["even" if x % 2 == 0 else "odd" for x in range(5)]
print(classified)
# Output: ['even', 'odd', 'even', 'odd', 'even']

# Filter even numbers
evens = [x for x in range(20) if x % 2 == 0]
print(evens)
# Output: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

Interview Tip: "Prefer list comprehensions over map/filter with lambdas -- they are more readable and more Pythonic. But know both styles because you will encounter map/filter in legacy codebases."

What NOT to Say: "I always use map and filter because they are more functional." -- In Python, list comprehensions are considered more idiomatic. Guido van Rossum himself prefers them.

Answer First: Use a dataclass for typed records with defaults and behavior, a named tuple for small immutable tuple-compatible records, and a dict for truly dynamic shape.

Memory Map: Namedtuple -> immutable tuple record; dataclass -> typed defaults/behavior; dict -> dynamic fields.

Q06 β€” namedtuple and dataclass

Question: Create a structured record for an Employee with name, department, and salary. Show how to create instances and access fields. Demonstrate both immutable and mutable approaches.

Sample Input:

name = "Alice", department = "Data Engineering", salary = 95000

Expected Output:

Immutable: Employee(name='Alice', department='Data Engineering', salary=95000)
Mutable after update: Employee(name='Alice', department='Data Engineering', salary=100000)

Solution 1 β€” Plain dictionary (simplest, no structure enforcement) β€” namedtuple and dataclass

python β€” editable
# Use a dict β€” flexible but no field validation
emp = {
    "name": "Alice",
    "department": "Data Engineering",
    "salary": 95000
}

print(f"Name: {emp['name']}, Dept: {emp['department']}, Salary: {emp['salary']}")
# Output: Name: Alice, Dept: Data Engineering, Salary: 95000

# Mutable β€” just update the key
emp["salary"] = 100000
print(f"Updated salary: {emp['salary']}")
# Output: Updated salary: 100000

# Downside: no field validation, typos are silent
emp["salry"] = 99999  # typo creates a NEW key, no error

Solution 2 β€” namedtuple (immutable, tuple-like) β€” namedtuple and dataclass

python β€” editable
from collections import namedtuple

# Define the structure β€” fields are fixed at creation
Employee = namedtuple('Employee', ['name', 'department', 'salary'])

emp = Employee("Alice", "Data Engineering", 95000)
print(f"Immutable: {emp}")
# Output: Immutable: Employee(name='Alice', department='Data Engineering', salary=95000)

# Access by name or index
print(emp.name)    # Output: Alice
print(emp[0])      # Output: Alice

# Immutable β€” cannot change after creation
# emp.salary = 100000  # Raises: AttributeError: can't set attribute

# To "update", use _replace() which returns a NEW namedtuple
updated = emp._replace(salary=100000)
print(f"Mutable after update: {updated}")
# Output: Mutable after update: Employee(name='Alice', department='Data Engineering', salary=100000)

Solution 3 β€” Optimal: dataclass (Python 3.7+, mutable with defaults) β€” namedtuple and dataclass

python β€” editable
from dataclasses import dataclass, asdict, astuple

@dataclass
class Employee:
    name: str
    department: str
    salary: float = 50000.0   # default value

# Step 1: Create instance β€” @dataclass auto-generates __init__ from type hints
emp = Employee("Alice", "Data Engineering", 95000)
print("Step 1 - Created:", emp)
# Output: Step 1 - Created: Employee(name='Alice', department='Data Engineering', salary=95000)
# ↑ __repr__ is auto-generated too β€” no need to write it yourself!

# Step 2: Mutable by default β€” direct field assignment works
emp.salary = 100000
print("Step 2 - After mutation:", emp)
# Output: Step 2 - After mutation: Employee(name='Alice', department='Data Engineering', salary=100000)
# ↑ Unlike namedtuple, you CAN modify fields in-place

# Step 3: Convert to dict/tuple β€” useful for serialization and DB inserts
print("Step 3a - As dict:", asdict(emp))
# Output: Step 3a - As dict: {'name': 'Alice', 'department': 'Data Engineering', 'salary': 100000}
print("Step 3b - As tuple:", astuple(emp))
# Output: Step 3b - As tuple: ('Alice', 'Data Engineering', 100000)
# ↑ asdict() is perfect for JSON serialization or DataFrame row creation

# Step 4: Auto-generated __eq__ compares all fields
emp2 = Employee("Alice", "Data Engineering", 100000)
print("Step 4 - emp == emp2:", emp == emp2)
# Output: Step 4 - emp == emp2: True
# ↑ Compares by value, not identity β€” no need to write __eq__ yourself

# Step 5: For immutable dataclass, use frozen=True
@dataclass(frozen=True)
class ImmutableEmployee:
    name: str
    department: str
    salary: float

frozen_emp = ImmutableEmployee("Bob", "Analytics", 80000)
# frozen_emp.salary = 90000  # Raises: FrozenInstanceError
print("Step 5 - Frozen:", frozen_emp)
# Output: Step 5 - Frozen: ImmutableEmployee(name='Bob', department='Analytics', salary=80000)
# ↑ frozen=True also makes it hashable β€” can use as dict key or set member

Interview Tip: "Use namedtuple for simple immutable records (like a row from a query). Use dataclass when you need mutability, defaults, or methods. dataclass also gives you __eq__, __repr__ for free."

What NOT to Say: "I would just use a regular class with __init__." -- This shows you are not aware of modern Python. dataclass eliminates boilerplate and is the standard for structured data since Python 3.7.

Answer First: A decorator receives a callable and returns a wrapped callable; preserve metadata with functools.wraps and keep cross-cutting behavior outside business logic.

Memory Map: Decorators -> accept callable -> wrap cross-cutting behavior -> return result -> preserve with wraps.

Q07 β€” Decorators (Simplified)

Question: Write a decorator called timer that measures and prints how long a function takes to execute. Then show how @lru_cache can speed up a recursive Fibonacci function.

Sample Input:

Call slow_function() which sleeps for 1 second
Call fibonacci(10)

Expected Output:

slow_function took 1.001s
fibonacci(10) = 55 (instant with cache)

Solution 1 β€” Manual timing without a decorator β€” Decorators (Simplified)

python β€” editable
import time

def slow_function():
    time.sleep(1)
    return "done"

# Manually measure time around the function call
start = time.time()
result = slow_function()
elapsed = time.time() - start
print(f"slow_function took {elapsed:.3f}s")
# Output: slow_function took 1.001s

# Fibonacci without cache β€” exponential time
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

start = time.time()
print(f"fibonacci(10) = {fibonacci(10)}")
elapsed = time.time() - start
print(f"Took {elapsed:.6f}s")
# Output: fibonacci(10) = 55
# Output: Took 0.000XXXs (small n is fast, but try n=35 and it crawls)

Solution 2 β€” Custom decorator with @wraps β€” Decorators (Simplified)

python β€” editable
import time
from functools import wraps

# A decorator is a function that takes a function and returns a wrapper
def timer(func):
    @wraps(func)  # preserves original function's name and docstring
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)   # call the original function
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

# Apply decorator with @ syntax β€” equivalent to: slow_function = timer(slow_function)
@timer
def slow_function():
    time.sleep(1)
    return "done"

slow_function()
# Output: slow_function took 1.001s

# The function's identity is preserved thanks to @wraps
print(slow_function.__name__)
# Output: slow_function  (without @wraps, it would say "wrapper")

Solution 3 β€” Optimal: Built-in decorators (@lru_cache, @property, etc.) β€” Decorators (Simplified)

python β€” editable
from functools import lru_cache

# @lru_cache memoizes results β€” stores return values for given arguments
@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# Step 1: Call fibonacci(10) β€” internally computes fib(0) through fib(10)
print(f"Step 1 - fibonacci(10) = {fibonacci(10)}")
# Output: Step 1 - fibonacci(10) = 55

# Step 2: Inspect the cache β€” see what's stored
print(f"Step 2 - Cache info: {fibonacci.cache_info()}")
# Output: Step 2 - Cache info: CacheInfo(hits=8, misses=11, maxsize=128, currsize=11)
# ↑ 11 unique calls (fib(0)..fib(10)), 8 cache hits (fib reuses previous results)
# Without cache: fib(10) would make 177 recursive calls. With cache: only 11!

# Step 3: Now call fibonacci(100) β€” fib(0)..fib(10) are already cached!
print(f"Step 3 - fibonacci(100) = {fibonacci(100)}")
# Output: Step 3 - fibonacci(100) = 354224848179261915075

print(f"Step 4 - Cache after fib(100): {fibonacci.cache_info()}")
# Output: Step 4 - Cache after fib(100): CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
# ↑ Only 90 new computations (fib(11)..fib(100)) β€” the first 11 were cache hits!

# Step 5: You can clear the cache if needed
fibonacci.cache_clear()
print(f"Step 5 - After clear: {fibonacci.cache_info()}")
# Output: Step 5 - After clear: CacheInfo(hits=0, misses=0, maxsize=128, currsize=0)

# Common built-in decorators to know:
# @staticmethod β€” no self/cls, just a function inside a class
# @classmethod  β€” gets cls instead of self, used for factory methods
# @property     β€” makes a method look like an attribute (getter)
# @lru_cache    β€” memoization (caches return values by arguments)

Interview Tip: "Always use @wraps(func) in custom decorators -- it preserves the original function's name and docstring, which matters for debugging and logging."

What NOT to Say: "A decorator modifies the function." -- It WRAPS, not modifies. The original function is unchanged. The decorator returns a new function that calls the original.

Answer First: A context manager pairs acquisition with guaranteed cleanup through __enter__/__exit__ or contextlib.contextmanager.

Memory Map: Context manager -> acquire in enter -> run with body -> release in exit even on error.

Q08 β€” Context Managers (with statement)

Question: Write a custom context manager called Timer that measures the elapsed time of a code block. Show both the class-based and the contextlib approach.

Sample Input:

with Timer():
time.sleep(1)

Expected Output:

Elapsed: 1.001s

Solution 1 β€” Manual try/finally (no context manager) β€” Context Managers (with statement)

python β€” editable
import time

# Without a context manager, you must handle cleanup manually
start = time.time()
try:
    time.sleep(1)   # simulate work
finally:
    elapsed = time.time() - start
    print(f"Elapsed: {elapsed:.3f}s")
# Output: Elapsed: 1.001s

# Same pattern for file handling
f = open("example.txt", "w")
try:
    f.write("hello")
finally:
    f.close()   # must close manually, even if exception occurs

Solution 2 β€” Class-based context manager (enter / exit) β€” Context Managers (with statement)

python β€” editable
import time

class Timer:
    def __enter__(self):
        # Setup: record the start time
        self.start = time.time()
        return self   # the object bound to "as" variable

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Cleanup: calculate and print elapsed time
        self.elapsed = time.time() - self.start
        print(f"Elapsed: {self.elapsed:.3f}s")
        return False   # False = do not suppress exceptions

with Timer() as t:
    time.sleep(1)
# Output: Elapsed: 1.001s

# File handling β€” the classic context manager use case
with open("example.txt", "w") as f:
    f.write("hello")
# File is automatically closed, even if exception occurs

Solution 3 β€” Optimal: contextlib.contextmanager (Pythonic) β€” Context Managers (with statement)

python β€” editable
import time
from contextlib import contextmanager

@contextmanager
def timer(label="Block"):
    # Everything before yield is __enter__
    start = time.time()
    yield   # control passes to the with-block here
    # Everything after yield is __exit__
    elapsed = time.time() - start
    print(f"{label} elapsed: {elapsed:.3f}s")

with timer("My task"):
    time.sleep(1)
# Output: My task elapsed: 1.001s

# Real-world example: database connection manager
@contextmanager
def database_connection(db_url):
    conn = connect(db_url)   # setup: open connection
    try:
        yield conn           # give connection to the with-block
    finally:
        conn.close()         # cleanup: always close, even on error

# Usage:
# with database_connection("postgres://...") as conn:
#     conn.execute("SELECT * FROM users")
# Connection is automatically closed

Interview Tip: "Context managers are critical in data engineering -- database connections, file handles, Spark sessions. Always use with to prevent resource leaks. The contextlib.contextmanager decorator is the simplest way to write one."

What NOT to Say: "I close files manually with f.close()." -- This leaks resources if an exception occurs before close(). Always use with for automatic cleanup.

Answer First: Use lazy itertools primitives to compose streaming transformations without materializing intermediate collections.

Memory Map: Itertools -> lazy source -> chain/slice/group combinators -> consume without intermediate lists.

Q09 β€” Itertools for Data Processing

Question: Given multiple lists of data, (a) merge them into one, (b) group records by a key, and (c) generate all combinations of sizes and colors.

Sample Input:

list_a = [1, 2], list_b = [3, 4], list_c = [5, 6]
data = [("NY", 100), ("NY", 200), ("CA", 300), ("CA", 400)]
sizes = ['S', 'M', 'L'], colors = ['red', 'blue']

Expected Output:

Chained: [1, 2, 3, 4, 5, 6]
Grouped: NY -> [('NY', 100), ('NY', 200)], CA -> [('CA', 300), ('CA', 400)]
Product: [('S', 'red'), ('S', 'blue'), ('M', 'red'), ('M', 'blue'), ('L', 'red'), ('L', 'blue')]

Solution 1 β€” Manual loops (no itertools) β€” Itertools for Data Processing

python β€” editable
# Merge multiple lists manually
list_a, list_b, list_c = [1, 2], [3, 4], [5, 6]
merged = []
for lst in [list_a, list_b, list_c]:
    for item in lst:
        merged.append(item)
print("Chained:", merged)
# Output: Chained: [1, 2, 3, 4, 5, 6]

# Group by key manually (data must be sorted by key)
data = [("NY", 100), ("NY", 200), ("CA", 300), ("CA", 400)]
groups = {}
for state, value in data:
    if state not in groups:
        groups[state] = []
    groups[state].append((state, value))
for state in sorted(groups):
    print(f"  {state} -> {groups[state]}")
# Output:
#   CA -> [('CA', 300), ('CA', 400)]
#   NY -> [('NY', 100), ('NY', 200)]

# Cartesian product manually
sizes = ['S', 'M', 'L']
colors = ['red', 'blue']
combos = []
for s in sizes:
    for c in colors:
        combos.append((s, c))
print("Product:", combos)
# Output: Product: [('S', 'red'), ('S', 'blue'), ('M', 'red'), ('M', 'blue'), ('L', 'red'), ('L', 'blue')]

Solution 2 β€” List comprehension and unpacking β€” Itertools for Data Processing

python β€” editable
# Merge with unpacking
list_a, list_b, list_c = [1, 2], [3, 4], [5, 6]
merged = [*list_a, *list_b, *list_c]
print("Chained:", merged)
# Output: Chained: [1, 2, 3, 4, 5, 6]

# Cartesian product with nested comprehension
sizes = ['S', 'M', 'L']
colors = ['red', 'blue']
combos = [(s, c) for s in sizes for c in colors]
print("Product:", combos)
# Output: Product: [('S', 'red'), ('S', 'blue'), ('M', 'red'), ('M', 'blue'), ('L', 'red'), ('L', 'blue')]

# Combinations with comprehension
items = [1, 2, 3]
pairs = [(items[i], items[j]) for i in range(len(items)) for j in range(i + 1, len(items))]
print("Combos:", pairs)
# Output: Combos: [(1, 2), (1, 3), (2, 3)]

Solution 3 β€” Optimal: itertools (memory-efficient, lazy) β€” Itertools for Data Processing

python β€” editable
from itertools import chain, groupby, islice, product, combinations

# chain β€” merge multiple iterables lazily
merged = list(chain([1, 2], [3, 4], [5, 6]))
print("Chained:", merged)
# Output: Chained: [1, 2, 3, 4, 5, 6]

# groupby β€” group CONSECUTIVE items by a key (data must be sorted!)
data = [("CA", 300), ("CA", 400), ("NY", 100), ("NY", 200)]  # sorted by state
for state, group in groupby(data, key=lambda x: x[0]):
    print(f"  {state} -> {list(group)}")
# Output:
#   CA -> [('CA', 300), ('CA', 400)]
#   NY -> [('NY', 100), ('NY', 200)]

# product β€” cartesian product (replaces nested loops)
combos = list(product(['S', 'M', 'L'], ['red', 'blue']))
print("Product:", combos)
# Output: Product: [('S', 'red'), ('S', 'blue'), ('M', 'red'), ('M', 'blue'), ('L', 'red'), ('L', 'blue')]

# combinations β€” choose k items from n without replacement
print("Combos:", list(combinations([1, 2, 3], 2)))
# Output: Combos: [(1, 2), (1, 3), (2, 3)]

# islice β€” slice an iterator lazily (no memory for huge files)
# with open("huge_file.csv") as f:
#     first_10 = list(islice(f, 10))  # reads only first 10 lines

Interview Tip: "groupby requires SORTED data -- it only groups CONSECUTIVE items. This is the number one mistake candidates make. Always sort before groupby."

What NOT to Say: "groupby works like SQL GROUP BY." -- It does NOT. SQL GROUP BY processes all rows regardless of order. Python's groupby only groups consecutive identical keys.

Answer First: Catch the narrowest exception you can handle, add domain context, preserve the original cause with raise ... from ..., and never swallow failures silently.

Memory Map: Exception handling -> catch narrow type -> add domain context -> chain cause -> never swallow silently.

Q10 β€” Exception Handling Best Practices

Question: Write a function that safely converts a string to an integer. Handle invalid input with specific exceptions, and show how to create a custom exception.

Sample Input:

convert("42") -> 42
convert("abc") -> "Error: 'abc' is not a valid number"
convert(None) -> "Error: Input cannot be None"

Expected Output:

42
Error: 'abc' is not a valid number
Error: Input cannot be None

Solution 1 β€” Basic try/except with specific exceptions β€” Exception Handling Best Practices

python β€” editable
def convert(value):
    try:
        return int(value)
    except ValueError:
        # Raised when the string cannot be parsed as int
        return f"Error: '{value}' is not a valid number"
    except TypeError:
        # Raised when value is None or non-string type
        return f"Error: Input cannot be None"

print(convert("42"))
# Output: 42
print(convert("abc"))
# Output: Error: 'abc' is not a valid number
print(convert(None))
# Output: Error: Input cannot be None

Solution 2 β€” Custom exception class β€” Exception Handling Best Practices

python β€” editable
class DataValidationError(Exception):
    """Custom exception for data validation failures."""
    def __init__(self, column, value, message):
        self.column = column
        self.value = value
        # Call parent __init__ with a formatted message
        super().__init__(f"Column '{column}': {message} (got: {value})")

def validate_age(data):
    age = data.get("age")
    if age is None:
        raise DataValidationError("age", age, "must not be None")
    if not isinstance(age, int):
        raise DataValidationError("age", age, "must be an integer")
    if age < 0:
        raise DataValidationError("age", age, "must be positive")
    return age

# Test with valid data
try:
    result = validate_age({"age": 25})
    print(f"Valid age: {result}")
except DataValidationError as e:
    print(f"Validation failed: {e}")
# Output: Valid age: 25

# Test with invalid data
try:
    result = validate_age({"age": -5})
    print(f"Valid age: {result}")
except DataValidationError as e:
    print(f"Validation failed: {e}")
# Output: Validation failed: Column 'age': must be positive (got: -5)

Solution 3 β€” Optimal: Exception chaining and best practices β€” Exception Handling Best Practices

python β€” editable
class DataValidationError(Exception):
    def __init__(self, column, value, message):
        self.column = column
        self.value = value
        super().__init__(f"Column '{column}': {message} (got: {value})")

def safe_convert(value):
    try:
        return int(value)
    except (ValueError, TypeError) as e:
        # Chain exceptions with "from e" to preserve the original traceback
        raise DataValidationError("input", value, "must be a valid integer") from e

# Test
try:
    result = safe_convert("abc")
except DataValidationError as e:
    print(f"Error: {e}")
    print(f"  Column: {e.column}")
    print(f"  Value: {e.value}")
# Output: Error: Column 'input': must be a valid integer (got: abc)
# Output:   Column: input
# Output:   Value: abc

# Anti-patterns to AVOID:

# BAD β€” bare except catches EVERYTHING including KeyboardInterrupt
# try:
#     result = process(data)
# except:
#     pass

# BAD β€” too broad, hides real bugs
# try:
#     result = process(data)
# except Exception:
#     print("something went wrong")

# GOOD β€” always catch SPECIFIC exceptions
try:
    result = int("abc")
except ValueError:
    print("Caught specific ValueError")
# Output: Caught specific ValueError

Interview Tip: "Always catch specific exceptions. except Exception hides bugs. Use raise ... from e to chain exceptions and preserve the traceback. In data pipelines, custom exceptions make debugging much easier."

What NOT to Say: "I use try/except with bare except." -- This catches SystemExit and KeyboardInterrupt, making the program unkillable. Always catch specific exception types.

Answer First: Use f-strings for readable modern interpolation, including format specifications; recognize older styles when maintaining legacy code.

Memory Map: String formatting -> f-string expression -> format spec -> recognize .format and percent legacy.

Q11 β€” String Formatting (f-strings, format, %)

Question: Given variables for name, age, and salary, format them in three different ways: (a) basic string with variables, (b) salary with commas and 2 decimal places, (c) aligned columns.

Sample Input:

name = "Alice", age = 30, salary = 95432.567

Expected Output:

Alice is 30 years old
Salary: $95,432.57
left | center | right

Solution 1 β€” %-formatting (old style, know it but do not use it) β€” String Formatting (f-strings, format, %)

python β€” editable
name = "Alice"
age = 30
salary = 95432.567

# Basic substitution with %s (string) and %d (integer)
print("%s is %d years old" % (name, age))
# Output: Alice is 30 years old

# Float formatting with %.2f (2 decimal places)
print("Salary: $%.2f" % salary)
# Output: Salary: $95432.57
# Note: %-formatting does NOT support comma separators easily

Solution 2 β€” .format() method β€” String Formatting (f-strings, format, %)

python β€” editable
name = "Alice"
age = 30
salary = 95432.567

# Positional arguments
print("{} is {} years old".format(name, age))
# Output: Alice is 30 years old

# Named arguments
print("{name} is {age} years old".format(name=name, age=age))
# Output: Alice is 30 years old

# Format spec: comma separator and 2 decimal places
print("Salary: ${:,.2f}".format(salary))
# Output: Salary: $95,432.57

# Alignment: < left, ^ center, > right (within 20 chars)
print("{:<20}|{:^20}|{:>20}".format("left", "center", "right"))
# Output: left                |       center       |               right

Solution 3 β€” Optimal: f-strings (Python 3.6+, always use this) β€” String Formatting (f-strings, format, %)

python β€” editable
name = "Alice"
age = 30
salary = 95432.567

# Basic f-string β€” variables directly in curly braces
print(f"{name} is {age} years old")
# Output: Alice is 30 years old

# Format spec after colon: comma separator, 2 decimals
print(f"Salary: ${salary:,.2f}")
# Output: Salary: $95,432.57

# Alignment: < left, ^ center, > right
print(f"{'left':<20}|{'center':^20}|{'right':>20}")
# Output: left                |       center       |               right

# Zero-padded numbers
print(f"{age:05d}")
# Output: 00030

# Debug syntax (Python 3.8+) β€” shows variable name AND value
x = 42
print(f"{x = }")
# Output: x = 42
print(f"{x**2 = }")
# Output: x**2 = 1764

Interview Tip: "Always use f-strings -- they are the fastest and most readable. The f'{x = }' debug syntax (Python 3.8+) is a great trick for quick debugging, as it shows both the variable name and its value."

What NOT to Say: "I use %-formatting because it is like C." -- This is outdated since Python 3.6. f-strings are faster, more readable, and the modern standard.

Answer First: Reach for built-ins and comprehensions when they state the operation directly, but expand dense expressions when readability or error handling suffers.

Memory Map: Data-engineering one-liners -> built-in or comprehension -> state operation directly -> expand dense logic.

Q12 β€” Common One-Liners Data Engineers Should Know

Question: Demonstrate essential Python one-liners: (a) flatten a nested list, (b) transpose a matrix, (c) get unique items preserving order, (d) merge two dicts, (e) create a dict from two lists.

Sample Input:

nested = [[1, 2], [3, 4], [5, 6]]
matrix = [[1, 2, 3], [4, 5, 6]]
items = [3, 1, 2, 1, 3, 4]
dict1 = {'a': 1, 'b': 2}; dict2 = {'b': 3, 'c': 4}
keys = ['a', 'b', 'c']; values = [1, 2, 3]

Expected Output:

Flat: [1, 2, 3, 4, 5, 6]
Transposed: [(1, 4), (2, 5), (3, 6)]
Unique: [3, 1, 2, 4]
Merged: {'a': 1, 'b': 3, 'c': 4}
Zipped: {'a': 1, 'b': 2, 'c': 3}

Solution 1 β€” Manual loops for each operation β€” Common One-Liners Data Engineers Should Know

python β€” editable
# Flatten nested list
nested = [[1, 2], [3, 4], [5, 6]]
flat = []
for sublist in nested:
    for item in sublist:
        flat.append(item)
print("Flat:", flat)
# Output: Flat: [1, 2, 3, 4, 5, 6]

# Transpose matrix
matrix = [[1, 2, 3], [4, 5, 6]]
transposed = []
for col in range(len(matrix[0])):
    row = []
    for r in range(len(matrix)):
        row.append(matrix[r][col])
    transposed.append(tuple(row))
print("Transposed:", transposed)
# Output: Transposed: [(1, 4), (2, 5), (3, 6)]

# Unique items preserving order
items = [3, 1, 2, 1, 3, 4]
seen = set()
unique = []
for item in items:
    if item not in seen:
        seen.add(item)
        unique.append(item)
print("Unique:", unique)
# Output: Unique: [3, 1, 2, 4]

# Merge two dicts
dict1 = {'a': 1, 'b': 2}
dict2 = {'b': 3, 'c': 4}
merged = {}
for k, v in dict1.items():
    merged[k] = v
for k, v in dict2.items():
    merged[k] = v      # dict2 values overwrite dict1
print("Merged:", merged)
# Output: Merged: {'a': 1, 'b': 3, 'c': 4}

# Dict from two lists
keys = ['a', 'b', 'c']
values = [1, 2, 3]
d = {}
for i in range(len(keys)):
    d[keys[i]] = values[i]
print("Zipped:", d)
# Output: Zipped: {'a': 1, 'b': 2, 'c': 3}

Solution 2 β€” Built-in functions β€” Common One-Liners Data Engineers Should Know

python β€” editable
from itertools import chain
from collections import Counter

# Flatten with itertools.chain
nested = [[1, 2], [3, 4], [5, 6]]
flat = list(chain.from_iterable(nested))
print("Flat:", flat)
# Output: Flat: [1, 2, 3, 4, 5, 6]

# Transpose with zip and unpacking
matrix = [[1, 2, 3], [4, 5, 6]]
transposed = list(zip(*matrix))
print("Transposed:", transposed)
# Output: Transposed: [(1, 4), (2, 5), (3, 6)]

# Unique preserving order with dict.fromkeys
items = [3, 1, 2, 1, 3, 4]
unique = list(dict.fromkeys(items))
print("Unique:", unique)
# Output: Unique: [3, 1, 2, 4]

# Merge dicts with unpacking (Python 3.5+)
dict1 = {'a': 1, 'b': 2}
dict2 = {'b': 3, 'c': 4}
merged = {**dict1, **dict2}
print("Merged:", merged)
# Output: Merged: {'a': 1, 'b': 3, 'c': 4}

# Dict from two lists with zip
keys = ['a', 'b', 'c']
values = [1, 2, 3]
d = dict(zip(keys, values))
print("Zipped:", d)
# Output: Zipped: {'a': 1, 'b': 2, 'c': 3}

Solution 3 β€” Optimal: Pythonic one-liners β€” Common One-Liners Data Engineers Should Know

python β€” editable
from collections import Counter

# Flatten β€” list comprehension (no import needed)
nested = [[1, 2], [3, 4], [5, 6]]
flat = [x for sub in nested for x in sub]
print("Flat:", flat)
# Output: Flat: [1, 2, 3, 4, 5, 6]

# Transpose β€” zip unpacking (cleanest)
matrix = [[1, 2, 3], [4, 5, 6]]
transposed = list(zip(*matrix))
print("Transposed:", transposed)
# Output: Transposed: [(1, 4), (2, 5), (3, 6)]

# Unique preserving order β€” dict.fromkeys (3.7+ guarantees order)
items = [3, 1, 2, 1, 3, 4]
unique = list(dict.fromkeys(items))
print("Unique:", unique)
# Output: Unique: [3, 1, 2, 4]

# Merge dicts β€” pipe operator (Python 3.9+)
dict1 = {'a': 1, 'b': 2}
dict2 = {'b': 3, 'c': 4}
merged = dict1 | dict2
print("Merged:", merged)
# Output: Merged: {'a': 1, 'b': 3, 'c': 4}

# Dict from two lists
keys = ['a', 'b', 'c']
values = [1, 2, 3]
d = dict(zip(keys, values))
print("Zipped:", d)
# Output: Zipped: {'a': 1, 'b': 2, 'c': 3}

# Bonus one-liners:
# Swap two variables
a, b = 1, 2
a, b = b, a
print(a, b)
# Output: 2 1

# Most common element
items = ["a", "b", "a", "c", "a", "b"]
most_common = Counter(items).most_common(1)[0][0]
print("Most common:", most_common)
# Output: Most common: a

# Check all items satisfy a condition
numbers = [1, 2, 3, 4, 5]
all_positive = all(x > 0 for x in numbers)
print("All positive:", all_positive)
# Output: All positive: True

# Conditional assignment
x = 42 if True else 0
print(x)
# Output: 42

Interview Tip: "These one-liners show Python fluency. Interviewers love seeing clean, idiomatic Python. Know dict.fromkeys for order-preserving dedup, zip(*matrix) for transpose, and dict1 | dict2 for merging."

What NOT to Say: "I would use a for loop to flatten a list." -- While correct, it signals you are not comfortable with Pythonic patterns. Always show the comprehension version first, then mention the loop if asked.

Fundamental answer owners from the legacy question bank

Answer First: Lists are mutable; tuples are immutable, usually smaller, and can be dictionary keys only when every contained value is hashable.

Memory Map: List is mutable sequence -> tuple is immutable record -> hashability depends on every element.

Q01 β€” What is the difference between a list and a tuple?

Question: Explain the key differences between lists and tuples in Python. When would you use each?

Quick Answer: Lists are mutable (changeable), tuples are immutable (fixed). Tuples are faster, use less memory, and can be used as dictionary keys.

python β€” editable
# Example 1: Mutability difference
my_list = [1, 2, 3]
my_list[0] = 99        # Works fine β€” lists are mutable
print(my_list)
# Output: [99, 2, 3]

my_tuple = (1, 2, 3)
# my_tuple[0] = 99     # Raises: TypeError: 'tuple' object does not support item assignment
print(my_tuple)
# Output: (1, 2, 3)
python β€” editable
# Example 2: Tuples as dictionary keys (lists cannot do this)
# Tuples are hashable because they are immutable
coords = {(10, 20): "New York", (40, 50): "London"}
print(coords[(10, 20)])
# Output: New York

# Lists are NOT hashable β€” this would fail:
# bad_dict = {[10, 20]: "New York"}
# Raises: TypeError: unhashable type: 'list'
python β€” editable
# Example 3: Memory and performance comparison
import sys

my_list = [1, 2, 3, 4, 5]
my_tuple = (1, 2, 3, 4, 5)

print(f"List size:  {sys.getsizeof(my_list)} bytes")
# Output: List size:  120 bytes
print(f"Tuple size: {sys.getsizeof(my_tuple)} bytes")
# Output: Tuple size: 80 bytes
# Tuples use less memory because they don't need resize overhead

🎯 Tip: "Tuples are hashable so they can be dict keys. Lists cannot because they're mutable. I use tuples for fixed data like DB row results or coordinates."

Answer First: A list comprehension evaluates eagerly into memory; a generator expression yields lazily and is normally consumed once.

Memory Map: Comprehension materializes list now -> generator expression yields later -> single-pass consumption.

Q03 β€” Explain list comprehension vs generator expression

Question: What is the difference between [x for x in range(n)] and (x for x in range(n))? When would you use each?

Quick Answer: List comprehension creates the full list in memory. Generator expression produces values lazily, one at a time. For large data, generators save memory.

python β€” editable
# Example 1: List comprehension β€” entire list stored in memory
squares_list = [x ** 2 for x in range(6)]
print(squares_list)
# Output: [0, 1, 4, 9, 16, 25]
print(type(squares_list))
# Output: <class 'list'>
python β€” editable
# Example 2: Generator expression β€” lazy, one value at a time
squares_gen = (x ** 2 for x in range(6))
print(type(squares_gen))
# Output: <class 'generator'>

# Must iterate or convert to see values
print(next(squares_gen))
# Output: 0
print(next(squares_gen))
# Output: 1
print(list(squares_gen))   # Remaining values
# Output: [4, 9, 16, 25]
python β€” editable
# Example 3: Memory comparison β€” generators win for large data
import sys

list_comp = [x for x in range(10000)]
gen_expr = (x for x in range(10000))

print(f"List memory:      {sys.getsizeof(list_comp)} bytes")
# Output: List memory:      87624 bytes
print(f"Generator memory: {sys.getsizeof(gen_expr)} bytes")
# Output: Generator memory: 200 bytes
# Generator uses constant memory regardless of data size

🎯 Tip: "In data pipelines, I use generators to avoid OOM on large datasets. If I need to iterate only once, a generator is always better than a list."

Answer First: A lambda is a small anonymous single-expression function, best used as a short key or callback rather than hidden complex logic.

Memory Map: Lambda function -> anonymous single expression -> short key/callback -> avoid concealed complex logic.

Q11 β€” What is a lambda function?

Question: What is a lambda function? How is it different from a regular function? When would you use it?

Quick Answer: A lambda is an anonymous, single-expression function. Used for short operations, especially with sorted(), map(), filter(). Cannot contain statements or multiple expressions.

python β€” editable
# Example 1: Lambda vs regular function β€” equivalent code
# Lambda version
square = lambda x: x ** 2
print(square(5))
# Output: 25

# Equivalent regular function
def square_func(x):
    return x ** 2

print(square_func(5))
# Output: 25
python β€” editable
# Example 2: Lambda with sorted() β€” most common real-world use
# Sort list of dicts by a specific key
employees = [
    {"name": "Charlie", "salary": 75000},
    {"name": "Alice", "salary": 90000},
    {"name": "Bob", "salary": 60000},
]

# Sort by salary ascending
by_salary = sorted(employees, key=lambda emp: emp["salary"])
for emp in by_salary:
    print(f"{emp['name']}: {emp['salary']}")
# Output: Bob: 60000
# Output: Charlie: 75000
# Output: Alice: 90000

# Sort by name descending
by_name_desc = sorted(employees, key=lambda emp: emp["name"], reverse=True)
for emp in by_name_desc:
    print(emp["name"])
# Output: Charlie
# Output: Bob
# Output: Alice
python β€” editable
# Example 3: Lambda with map() and filter()
numbers = [1, 2, 3, 4, 5, 6, 7, 8]

# filter: keep only even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)
# Output: [2, 4, 6, 8]

# map: double each number
doubled = list(map(lambda x: x * 2, numbers))
print(doubled)
# Output: [2, 4, 6, 8, 10, 12, 14, 16]

# Note: List comprehensions are usually more Pythonic
evens_lc = [x for x in numbers if x % 2 == 0]
doubled_lc = [x * 2 for x in numbers]
print(evens_lc)
# Output: [2, 4, 6, 8]
print(doubled_lc)
# Output: [2, 4, 6, 8, 10, 12, 14, 16]

🚫 What NOT to Say: "Lambda can have multiple statements." -- Lambda is ONE expression only. No assignments, no loops, no if/else blocks (only ternary x if cond else y).

Answer First: append adds one object as one element, whereas extend iterates its argument and adds each element.

Memory Map: append adds argument as one element -> extend iterates argument -> compare resulting list shape.

Q12 β€” Difference between append() and extend()?

Question: What is the difference between append() and extend() on a list? What about +=?

Quick Answer: append() adds one item (even if it's a list, it becomes a nested element). extend() adds each element individually from an iterable. += behaves like extend().

python β€” editable
# Example 1: append() adds ONE item β€” even if that item is a list
a = [1, 2]
a.append([3, 4])       # Adds the entire list as a single element
print(a)
# Output: [1, 2, [3, 4]]
print(len(a))
# Output: 3             (the inner list counts as one element)
python β€” editable
# Example 2: extend() unpacks the iterable and adds each element
b = [1, 2]
b.extend([3, 4])       # Adds 3 and 4 individually
print(b)
# Output: [1, 2, 3, 4]
print(len(b))
# Output: 4

# extend works with any iterable, not just lists
c = [1, 2]
c.extend("abc")        # Strings are iterable β€” adds each character
print(c)
# Output: [1, 2, 'a', 'b', 'c']
python β€” editable
# Example 3: += is equivalent to extend() (not append!)
d = [1, 2]
d += [3, 4]             # Same as d.extend([3, 4])
print(d)
# Output: [1, 2, 3, 4]

# Comparison side by side
append_result = [1, 2]
extend_result = [1, 2]
pluseq_result = [1, 2]

append_result.append([3, 4])
extend_result.extend([3, 4])
pluseq_result += [3, 4]

print(f"append: {append_result}")
# Output: append: [1, 2, [3, 4]]
print(f"extend: {extend_result}")
# Output: extend: [1, 2, 3, 4]
print(f"+=:     {pluseq_result}")
# Output: +=:     [1, 2, 3, 4]

🎯 Tip: "A common bug: using append when you meant extend, causing nested lists. In data pipelines, extend is usually what you want when combining batches of records."

Answer First: Generators suspend at yield, retain execution state, and produce a lazy single-pass stream.

Memory Map: Python generators -> yield value -> suspend frame state -> resume once -> exhaust stream.

Q13 β€” What are Python generators?

Question: What is a generator in Python? How does yield work? Why are generators useful in data engineering?

Quick Answer: Generators are functions that use yield instead of return. They produce values lazily -- one at a time -- and pause between yields. Memory efficient for large data.

python β€” editable
# Example 1: Simple generator with yield
def count_up_to(n):
    """Generator that yields numbers from 1 to n"""
    i = 1
    while i <= n:
        yield i          # Pause here, return value, resume on next call
        i += 1

gen = count_up_to(5)
print(next(gen))
# Output: 1
print(next(gen))
# Output: 2
print(list(gen))        # Remaining values
# Output: [3, 4, 5]
python β€” editable
# Example 2: Generator for processing data in chunks
def chunked_range(start, end, chunk_size):
    """Yield data in chunks β€” simulates batch processing"""
    for i in range(start, end, chunk_size):
        chunk = list(range(i, min(i + chunk_size, end)))
        yield chunk       # Only one chunk in memory at a time

for batch in chunked_range(0, 10, 3):
    print(f"Processing batch: {batch}")
# Output: Processing batch: [0, 1, 2]
# Output: Processing batch: [3, 4, 5]
# Output: Processing batch: [6, 7, 8]
# Output: Processing batch: [9]
python β€” editable
# Example 3: Generator pipeline β€” chaining generators for ETL
def generate_rows():
    """Extract: produce raw rows"""
    data = [
        {"name": "Alice", "score": 85},
        {"name": "Bob", "score": 45},
        {"name": "Charlie", "score": 92},
    ]
    for row in data:
        yield row

def filter_passing(rows, threshold=60):
    """Transform: keep only passing scores"""
    for row in rows:
        if row["score"] >= threshold:
            yield row

def format_output(rows):
    """Transform: format for display"""
    for row in rows:
        yield f"{row['name']}: {row['score']}"

# Chain generators β€” no intermediate lists created
pipeline = format_output(filter_passing(generate_rows()))
for record in pipeline:
    print(record)
# Output: Alice: 85
# Output: Charlie: 92

🎯 Tip: "In ETL pipelines, generators let me process millions of rows without loading everything into memory. I chain them together like Unix pipes."

Answer First: A dictionary comprehension builds key-value pairs from an iterable with optional filtering, while retaining normal overwrite semantics for duplicate keys.

Memory Map: Dictionary comprehension -> derive key/value pairs -> optional filter -> later duplicate key overwrites.

Q16 β€” What is a dictionary comprehension?

Question: What is a dictionary comprehension? How does it compare to building a dict with a loop? Give practical examples.

Quick Answer: A dictionary comprehension creates a dict in a single expression: {key: value for item in iterable}. It's more concise and often faster than a loop.

python β€” editable
# Example 1: Basic dict comprehension β€” creating a mapping
# Square mapping
squares = {x: x ** 2 for x in range(6)}
print(squares)
# Output: {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# Equivalent loop (more verbose)
squares_loop = {}
for x in range(6):
    squares_loop[x] = x ** 2
print(squares_loop)
# Output: {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
python β€” editable
# Example 2: Filtering with dict comprehension
scores = {"Alice": 85, "Bob": 55, "Charlie": 92, "Diana": 48}

# Keep only passing scores (>= 60)
passed = {k: v for k, v in sorted(scores.items()) if v >= 60}
print(passed)
# Output: {'Alice': 85, 'Charlie': 92}

# Invert a dictionary (swap keys and values)
inverted = {v: k for k, v in sorted(scores.items())}
print(inverted)
# Output: {48: 'Diana', 55: 'Bob', 85: 'Alice', 92: 'Charlie'}
python β€” editable
# Example 3: Real-world use β€” transforming data structures
# Convert list of tuples to dict
raw_data = [("host", "localhost"), ("port", "5432"), ("db", "analytics")]
config = {k: v for k, v in raw_data}
print(config)
# Output: {'host': 'localhost', 'port': '5432', 'db': 'analytics'}

# Create a lookup table from a list of records
employees = [
    {"id": 101, "name": "Alice"},
    {"id": 102, "name": "Bob"},
    {"id": 103, "name": "Charlie"},
]
lookup = {emp["id"]: emp["name"] for emp in employees}
print(lookup)
# Output: {101: 'Alice', 102: 'Bob', 103: 'Charlie'}
print(lookup[102])
# Output: Bob

🎯 Tip: "Dict comprehensions are great for building lookup tables. In data engineering, I use them to create column mappings, config transforms, and ID-to-name lookups."

Answer First: enumerate yields (index, item) pairs directly, avoiding manual range(len(...)) indexing.

Memory Map: enumerate -> lazy (index, item) pairs -> optional start offset -> avoid range(len()).

Q17 β€” What is enumerate() and why use it?

Question: What does enumerate() do? Why is it better than using range(len(...))?

Quick Answer: enumerate() adds a counter to an iterable, returning (index, value) pairs. It's cleaner, more Pythonic, and less error-prone than manual indexing.

python β€” editable
# Example 1: Bad vs Good β€” manual index vs enumerate
names = ["Alice", "Bob", "Charlie"]

# BAD β€” manual indexing with range(len(...))
for i in range(len(names)):
    print(f"{i}: {names[i]}")
# Output: 0: Alice
# Output: 1: Bob
# Output: 2: Charlie

# GOOD β€” enumerate is cleaner and more Pythonic
for i, name in enumerate(names):
    print(f"{i}: {name}")
# Output: 0: Alice
# Output: 1: Bob
# Output: 2: Charlie
python β€” editable
# Example 2: Custom start index
tasks = ["Extract", "Transform", "Load"]

# Start counting from 1 instead of 0
for step, task in enumerate(tasks, start=1):
    print(f"Step {step}: {task}")
# Output: Step 1: Extract
# Output: Step 2: Transform
# Output: Step 3: Load
python β€” editable
# Example 3: Practical use β€” finding positions of matching elements
scores = [72, 85, 91, 45, 88, 55, 93]
threshold = 80

# Find indices of scores above threshold
high_scorers = [(i, score) for i, score in enumerate(scores) if score > threshold]
print(high_scorers)
# Output: [(1, 85), (2, 91), (4, 88), (6, 93)]

# Convert enumerate to a dict
index_map = dict(enumerate(["zero", "one", "two", "three"]))
print(index_map)
# Output: {0: 'zero', 1: 'one', 2: 'two', 3: 'three'}

🚫 What NOT to Say: "I use for i in range(len(list)) to iterate with indices." -- That's unpythonic. Always use enumerate().

Answer First: zip aligns iterables into tuples and stops at the shortest; unpacking into zip(*pairs) transposes the pairs back into columns.

Memory Map: zip -> align columns to shortest input -> tuples out -> zip(*pairs) unzips/transposes.

Q20 β€” What is zip() and how do you unzip?

Question: What does zip() do? How do you unzip? What happens when iterables have different lengths?

Quick Answer: zip() pairs elements from multiple iterables into tuples. Unzip with zip(*zipped). In Python 3, zip() returns a lazy iterator and stops at the shortest iterable.

python β€” editable
# Example 1: Basic zip β€” pairing two lists
names = ["Alice", "Bob", "Charlie"]
scores = [85, 92, 78]

paired = list(zip(names, scores))
print(paired)
# Output: [('Alice', 85), ('Bob', 92), ('Charlie', 78)]

# Create a dict from two lists
score_map = dict(zip(names, scores))
print(score_map)
# Output: {'Alice': 85, 'Bob': 92, 'Charlie': 78}
python β€” editable
# Example 2: Unzipping with zip(*)
pairs = [("Alice", 85), ("Bob", 92), ("Charlie", 78)]

# Unzip β€” separate back into individual tuples
names_back, scores_back = zip(*pairs)
print(names_back)
# Output: ('Alice', 'Bob', 'Charlie')
print(scores_back)
# Output: (85, 92, 78)

# Convert back to lists if needed
print(list(names_back))
# Output: ['Alice', 'Bob', 'Charlie']
python β€” editable
# Example 3: Unequal lengths and zip_longest
from itertools import zip_longest

names = ["Alice", "Bob", "Charlie"]
scores = [85, 92]                      # Shorter!

# Regular zip β€” stops at shortest
print(list(zip(names, scores)))
# Output: [('Alice', 85), ('Bob', 92)]
# Charlie is DROPPED silently!

# zip_longest β€” fills missing values with a default
print(list(zip_longest(names, scores, fillvalue=0)))
# Output: [('Alice', 85), ('Bob', 92), ('Charlie', 0)]

# Practical use: iterate multiple lists in parallel
columns = ["id", "name", "score"]
values = [101, "Alice", 85]

for col, val in zip(columns, values):
    print(f"  {col} = {val}")
# Output:   id = 101
# Output:   name = Alice
# Output:   score = 85

🚫 What NOT to Say: "zip returns a list." -- In Python 3, zip() returns a lazy iterator. Wrap in list() if you need a list. Also beware: regular zip silently drops extra elements from longer iterables.

Advanced

Python Scenarios, Labs, and Interview Fundamentals

#

Python Scenarios, Labs, and Interview Fundamentals

This chapter retains the complete legacy examples, outputs, caveats, comparisons, and interview tips for its owner domain.

Legacy source guide: Python β€” Confusions, Labs, Gotchas & Mock Interview

πŸ’‘ Interview Tip
Goal: After this page, you should NEVER struggle with Python interview concepts. Where to run: any Python 3.9+ REPL, Jupyter, or https://pythontutor.com (visualizer).

Scenarios and labs source memory map

🧠 PYTHON MASTERY β†’ MUTABLE-SCOPE-ITER
PYTHON MASTERYMUTABLE-SCOPE-ITER
───────────────────────────────────
MMutable vs Immutable (list/dict/set vs tuple/str/int)
UUnpacking (*, **, walrus, tuple/list)
TTruthiness ([] is falsy, None is falsy, 0 is falsy)
AArguments (default, *args, **kwargs, positional vs keyword)
BBinding (closures, late-binding, nonlocal, global)
LList vs Dict vs Set complexity (O(n) vs O(1))
EEquality (is vs β†’ β†’ , hash, __eq__)

SECTION 0: TOP 8 PYTHON CONFUSIONS β€” Cleared Forever

Answer First: == asks whether values compare equal; is asks whether two names reference the same object, so reserve identity checks for singletons such as None or deliberate alias tests.

Memory Map: is asks same object -> == asks equal value -> reserve identity for None and alias checks.

Confusion 1: is vs ==

python β€” editable
a = [1, 2, 3]
b = [1, 2, 3]
a == b     # True  β€” values are equal
a is b     # False β€” different objects in memory!

c = a
c is a     # True  β€” same object (c is a reference)

Rule:

  • == β†’ VALUES equal (uses __eq__)
  • is β†’ SAME OBJECT in memory (identity check)

When to use is:

python β€” editable
if x is None:       # βœ… correct β€” None is a singleton
    ...
if x == None:       # ❌ works but discouraged (triggers __eq__)
    ...

Gotcha β€” small-int caching:

python β€” editable
a = 256
b = 256
a is b     # True (CPython caches -5 to 256)

a = 257
b = 257
a is b     # False! (or True, depending on context β€” DO NOT RELY ON THIS)

Interview answer: "Use is only for None, True, False, or intentional identity checks. Use == for value equality."

Answer First: A mutable default is allocated once when the function is defined and reused by later calls; use None as a sentinel and allocate inside the function.

Memory Map: Mutable default bug -> one object at definition -> state leaks across calls -> allocate after None.

Confusion 2: Mutable Default Arguments (THE classic bug)

python β€” editable
def add_item(item, target=[]):      # ❌ DANGEROUS
    target.append(item)
    return target

print(add_item(1))   # [1]
print(add_item(2))   # [1, 2]       ← WAIT WHAT?
print(add_item(3))   # [1, 2, 3]    ← default is SHARED across calls!

Why? Default values are evaluated ONCE, when the function is DEFINED. The same list object is reused forever.

Fix:

python β€” editable
def add_item(item, target=None):
    if target is None:              # βœ… create a fresh list each call
        target = []
    target.append(item)
    return target

Interview trap: "Why is def f(x, y=[]): dangerous?" β€” ace this and you sound senior.

Answer First: A shallow copy owns a new outer container but shares nested objects, while a deep copy recursively separates the nested object graph.

Memory Map: Shallow copy separates outer container -> deep copy separates nested graph -> mutate child to test.

Confusion 3: Shallow vs Deep Copy

python β€” editable
import copy

original = [[1, 2], [3, 4]]

shallow = copy.copy(original)    # or original[:] or list(original)
deep    = copy.deepcopy(original)

# Modify inner list
original[0].append(99)

print(original)   # [[1, 2, 99], [3, 4]]
print(shallow)    # [[1, 2, 99], [3, 4]]  ← ALSO changed! (inner list is shared)
print(deep)       # [[1, 2], [3, 4]]      ← independent copy

Rule:

  • Shallow copy β†’ new outer container, SHARED inner objects
  • Deep copy β†’ everything copied recursively

When to use deep copy: when the nested structure will be mutated independently. When shallow is fine: when inner objects won't change (or are immutable).

Answer First: list.sort() mutates one list and returns None; sorted() accepts any iterable and returns a new list, leaving the input untouched.

Memory Map: list.sort() mutates and returns None -> sorted() creates a new list from any iterable.

Confusion 4: list.sort() vs sorted(list)

python β€” editable
a = [3, 1, 2]

a.sort()           # MUTATES a in place, returns None
print(a)           # [1, 2, 3]
result = a.sort()  # ❌ result is None!

b = [3, 1, 2]
result = sorted(b) # RETURNS new list, b unchanged
print(b)           # [3, 1, 2]
print(result)      # [1, 2, 3]

Memory trick:

  • Methods that MUTATE return None (e.g., .sort(), .append(), .reverse())
  • Built-in functions return NEW objects (e.g., sorted(), reversed())

Interview trap: a = [3,1,2].sort() β†’ a is None, not [1,2,3].

Answer First: A list comprehension eagerly materializes a reusable result; a generator expression computes lazily as a normally single-pass iterator.

Memory Map: List comprehension computes eagerly -> generator expression yields lazily -> reuse versus one pass.

Confusion 5: List Comprehension vs Generator Expression

python β€” editable
squares_list = [x*x for x in range(1000000)]    # list β€” all values in memory
squares_gen  = (x*x for x in range(1000000))    # generator β€” lazy, one at a time

import sys
sys.getsizeof(squares_list)   # ~8 MB
sys.getsizeof(squares_gen)    # ~200 bytes ← generator is tiny!

When to use:

  • List comp [...] β†’ need to iterate multiple times or need random access
  • Generator (...) β†’ one-time iteration, large datasets, pipelines

Gotcha: Generators are exhaustible:

python β€” editable
gen = (x for x in range(3))
list(gen)    # [0, 1, 2]
list(gen)    # []  ← empty! generator already consumed

Answer First: *args gathers positional arguments into a tuple and **kwargs gathers keyword arguments into a dictionary; the same markers unpack at a call site.

Memory Map: *args collects positional values -> **kwargs collects named values -> same stars unpack.

Confusion 6: *args vs **kwargs

python β€” editable
def func(*args, **kwargs):
    print("args:", args)        # tuple of positional args
    print("kwargs:", kwargs)    # dict of keyword args

func(1, 2, 3, name="Alice", age=30)
# args: (1, 2, 3)
# kwargs: {'name': 'Alice', 'age': 30}

Unpacking (reverse):

python β€” editable
args_tuple = (1, 2, 3)
kwargs_dict = {"name": "Alice"}
func(*args_tuple, **kwargs_dict)     # unpacks into positional + keyword
# Same as: func(1, 2, 3, name="Alice")

Rule: * unpacks/packs iterables into positional args. ** unpacks/packs dicts into keyword args.

Answer First: Closures retain access to enclosing names, but loop variables are looked up when the closure runs; capture the current value with a default argument or factory.

Memory Map: Closures retain enclosing names -> late binding reads loop value later -> capture per iteration.

Confusion 7: Closures + Late Binding (trips EVERYONE)

python β€” editable
funcs = []
for i in range(3):
    funcs.append(lambda: i)        # ❌ all 3 lambdas reference the SAME `i`

for f in funcs:
    print(f())
# Prints: 2, 2, 2    ← all show final value of i!

Why? Closures capture variables by reference, not by value. By the time you call f(), the loop is done and i == 2.

Fix:

python β€” editable
funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)    # βœ… bind i as default arg (evaluated now)

for f in funcs:
    print(f())
# Prints: 0, 1, 2

Interview trap: "What does this print?" β†’ almost always the "late binding" bug. Recognize it instantly.

Answer First: Indexing requires a key, get returns a fallback without changing the mapping, and setdefault both inserts a missing default and returns the stored value.

Memory Map: dict[key] requires presence -> get reads fallback -> setdefault inserts and returns default.

Confusion 8: dict.get() vs dict[key] vs dict.setdefault()

python β€” editable
d = {"name": "Alice"}

d["name"]              # "Alice"
d["age"]               # ❌ KeyError: 'age'
d.get("age")           # None (no error)
d.get("age", 0)        # 0 (with default)
d.setdefault("age", 0) # adds age=0 if missing, returns 0

Use:

  • d[key] β†’ when you KNOW the key exists
  • d.get(key, default) β†’ safe lookup
  • d.setdefault(key, default) β†’ lookup AND insert if missing (great for building nested dicts)

APPENDIX: LEARN BY DOING

Answer First: Compare identities while assigning, copying, mutating, and rebinding to see that Python passes object references by assignment: mutation is shared, rebinding is local.

Memory Map: Mutability lab -> record id -> assign alias -> copy -> mutate -> distinguish rebinding.

LAB 1 β€” Visualize Mutability with ID

python β€” editable
# STEP 1: See that variables hold REFERENCES, not values
a = [1, 2, 3]
b = a                      # NOT a copy β€” b points to same list
print(id(a), id(b))        # same ID!

b.append(4)
print(a)                   # [1, 2, 3, 4]  ← a also changed!
print(b)                   # [1, 2, 3, 4]
print(a is b)              # True

# STEP 2: Copy breaks the link
import copy
c = copy.copy(a)
print(id(a), id(c))        # different IDs
c.append(5)
print(a)                   # [1, 2, 3, 4]        unchanged
print(c)                   # [1, 2, 3, 4, 5]

# STEP 3: Immutable objects are safe
x = 10
y = x
y = 20
print(x)                   # 10 (unchanged β€” ints are immutable)

# STEP 4: Function arguments pass references
def append_one(lst):
    lst.append(1)          # mutates original!

my_list = [0]
append_one(my_list)
print(my_list)             # [0, 1]  ← original modified

# STEP 5: But reassignment inside function doesn't affect outside
def reassign(lst):
    lst = [99]             # rebinds LOCAL name only

my_list = [0]
reassign(my_list)
print(my_list)             # [0]  ← outer list unchanged

🎯 Key insight: Functions get a COPY of the reference, not the object. Mutating via the reference affects the original. Rebinding the reference doesn't.

Answer First: Measure both allocation and one-pass consumption: a generator keeps memory bounded by yielding values, while a list pays upfront to retain every result.

Memory Map: Generator performance -> measure allocation -> consume one pass -> contrast bounded memory with list.

LAB 2 β€” Generator Performance vs List

python β€” editable
import time, sys

# STEP 1: Memory difference
list_comp = [x * x for x in range(10_000_000)]
gen_exp   = (x * x for x in range(10_000_000))

print(f"List size: {sys.getsizeof(list_comp):>12,} bytes")
print(f"Gen  size: {sys.getsizeof(gen_exp):>12,} bytes")
# EXPECTED:
# List size:    89,095,160 bytes    (~85 MB)
# Gen  size:           200 bytes    (~0 MB)

# STEP 2: Iteration speed
t0 = time.time()
total = sum([x * x for x in range(10_000_000)])
print(f"List comp: {time.time() - t0:.3f}s")

t0 = time.time()
total = sum(x * x for x in range(10_000_000))
print(f"Generator: {time.time() - t0:.3f}s")
# EXPECTED:
# List comp: 1.2s
# Generator: 0.9s  ← often FASTER (no list allocation)

🎯 Key insight: Generators are better when iterating once. Use them in pipelines.

Answer First: nonlocal rebinds a name in the nearest enclosing function scope; use the lab to contrast retained closure state with late-bound loop variables.

Memory Map: Closure nonlocal lab -> retain enclosing state -> rebind counter -> contrast late-bound loop name.

LAB 3 β€” Closures with nonlocal

python β€” editable
# STEP 1: A closure that captures outer variable
def counter():
    count = 0
    def increment():
        nonlocal count      # ← without this, count = count + 1 would be a LOCAL var
        count += 1
        return count
    return increment

c = counter()
print(c())   # 1
print(c())   # 2
print(c())   # 3

# STEP 2: Without nonlocal β€” breaks
def broken_counter():
    count = 0
    def increment():
        count += 1          # ❌ UnboundLocalError
        return count
    return increment

# STEP 3: Late binding demo
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])   # [2, 2, 2]  ← the trap!

# Fix 1: default argument
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])   # [0, 1, 2]  βœ…

# Fix 2: explicit closure factory
def make_f(i):
    return lambda: i
funcs = [make_f(i) for i in range(3)]
print([f() for f in funcs])   # [0, 1, 2]  βœ…

Answer First: Assignment binds a name to an object, a second assignment can create an alias, mutation is visible through every alias, and rebinding changes only one name.

Memory Map: Assign name to object -> alias shares mutation -> rebinding moves one name -> compare identities.

VISUAL ANIMATION 1 β€” What happens when you assign

πŸ“ Architecture Diagram
Step 1: x = [1, 2, 3]
          β”‚
          β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”
     β”‚  x    β”‚ ───→  [1, 2, 3]     ← x points to list object
     β””β”€β”€β”€β”€β”€β”€β”€β”˜

Step 2: y = x
          β”‚
          β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”
     β”‚  x    β”‚ ───→  [1, 2, 3]     ← both x and y
     β”‚  y    β”‚ ───→  (same obj)       point to SAME list
     β””β”€β”€β”€β”€β”€β”€β”€β”˜

Step 3: y.append(4)    (mutation)
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”
     β”‚  x    β”‚ ───→  [1, 2, 3, 4]
     β”‚  y    β”‚ ───→  (same obj)    ← both see the change
     β””β”€β”€β”€β”€β”€β”€β”€β”˜

Step 4: y = [9, 9]     (rebinding)
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”
     β”‚  x    β”‚ ───→  [1, 2, 3, 4]
     β”‚  y    β”‚ ───→  [9, 9]        ← y now points to NEW list
     β””β”€β”€β”€β”€β”€β”€β”€β”˜                       x is unchanged!

Answer First: The CPython GIL permits one thread at a time to execute Python bytecode in a process; use threads for overlapping I/O and processes or GIL-releasing native work for CPU parallelism.

Memory Map: GIL animation -> one CPython bytecode thread -> threads overlap I/O -> processes handle CPU work.

VISUAL ANIMATION 2 β€” Python's GIL

🧠 GIL = Global Interpreter Lock
GILGlobal Interpreter Lock
One Python process, multiple threads:
───────────────────────────────────
Thread 1 ──GIL── run for a few ms ──release GIL──
β”‚
β–Ό
Thread 2 ──────── wait for GIL ──GIL── run for a few ms ──release──
β”‚
β–Ό
Thread 3 ──────── wait for GIL ────────── wait ──GIL── run ───
🧠 KEY: Only ONE thread runs Python bytecode at a time.
This is why multithreading doesn't speed up CPU-bound code.
WORKAROUNDS
CPU-bound work→use multiprocessing (separate processes, no GIL share)
I/O-bound work→multithreading is fine (GIL released during I/O)
Async I/O→asyncio with async/await
Python 3.13+β†’experimental "free-threaded" mode removes GIL

Answer First: Choose a list for ordered positional access, a dictionary for keyed lookup, and a set for uniqueness or membership; dictionary and set lookup are O(1) on average.

Memory Map: Complexity choice -> list positional access -> dict keyed lookup -> set membership -> average O(1) hash.

VISUAL ANIMATION 3 β€” List vs Dict vs Set Complexity

🧠 Memory Map
LIST DICT SET
───────── ────────── ─────────
x in container O(n) O(1) avg O(1) avg
container[key] O(1) O(1) avg N/A
append / add O(1) O(1) O(1)
remove value O(n) O(1) O(1)
iteration O(n) O(n) O(n)
RULE OF THUMB
‒ Lookup by key→dict
‒ Membership check→set (not list!)
‒ Ordered, positional→list
‒ Unique, unordered→set
COMMON MISTAKE
# ❌ SLOW: O(N Γ— M)
seen = []
for item in big_list:
if item not in seen: # O(N) lookup each time!
seen.append(item)
# βœ… FAST: O(N)
seen = set()
for item in big_list:
if item not in seen: # O(1) lookup!
seen.add(item)

GOTCHAS β€” Python Traps

Answer First: Python comparison chains share the middle operand: a < b < c means a < b and b < c, with b evaluated once.

Memory Map: Chained comparisons -> share middle operand -> expand to a < b and b < c -> evaluate b once.

Gotcha 1: Chained comparisons

python β€” editable
x = 5
1 < x < 10        # True
1 < x and x < 10  # same thing (chained)

1 < x < 3         # False (evaluates as 1 < x AND x < 3)

Answer First: Equality can cross compatible numeric types (1 == True == 1.0) but does not coerce unrelated containers or strings into equal values.

Memory Map: Equality across types -> compatible numbers can match -> unrelated string/container values do not coerce.

Gotcha 2: == on different types

python β€” editable
1 == 1.0       # True  (numeric equality)
1 == True      # True  (True is 1)
0 == False     # True  (False is 0)
"1" == 1       # False (different types)
[1] == (1,)    # False (list vs tuple)

Answer First: Python integers expand to arbitrary precision until memory is exhausted, so they do not wrap at a fixed machine-word boundary.

Memory Map: Integer overflow -> Python arbitrary precision -> value grows instead of wrapping -> memory is the limit.

Gotcha 3: Integer overflow β€” there is none

python β€” editable
x = 10 ** 1000        # no overflow! Python ints are arbitrary precision
print(x)              # huge number, prints fine

Answer First: any([]) is false because no element is truthy; all([]) is true by vacuous truth, so validate non-emptiness separately when required.

Memory Map: Empty iterables -> any has no truthy item so false -> all has no counterexample so true.

Gotcha 4: any() and all() on empty iterables

python β€” editable
any([])       # False   (no truthy items)
all([])       # True    ← surprising! (vacuous truth)

Answer First: Modern dictionaries preserve insertion order as a language guarantee, which is different from automatically sorting keys.

Memory Map: Dict ordering -> insertion order guaranteed since 3.7 -> updates retain position -> order is not sorting.

Gotcha 5: Dict ordering (since Python 3.7)

python β€” editable
d = {"b": 1, "a": 2, "c": 3}
list(d)       # ['b', 'a', 'c']  ← insertion order preserved (3.7+)

Interview trap: "Is a Python dict ordered?" β†’ "Yes since Python 3.7 β€” insertion-ordered." For versions earlier, use collections.OrderedDict.

Answer First: Because strings are immutable, repeated concatenation can rebuild growing prefixes; accumulate pieces and call "".join(...) once.

Memory Map: String concatenation loop -> immutable prefixes rebuild -> collect pieces -> one final join.

Gotcha 6: String concatenation in a loop

python β€” editable
# ❌ SLOW: O(nΒ²) β€” creates new string each iteration
s = ""
for x in items:
    s += str(x)

# βœ… FAST: O(n) β€” join builds once
s = "".join(str(x) for x in items)

MOCK INTERVIEW β€” 5 Python Questions

Answer First: Lists are mutable variable-length sequences; tuples are immutable fixed records and are hashable only when all of their elements are hashable.

Memory Map: List versus tuple -> mutable sequence versus immutable record -> tuple hashability is element-dependent.

Q1: "What's the difference between a list and a tuple?"

βœ… GOOD ANSWER:

"Lists are mutable β€” you can append, remove, sort in place. Tuples are immutable β€” once created, you can't change them. Performance: tuples are slightly faster and use less memory. Semantics: tuples usually represent heterogeneous, fixed-length records like (name, age, email); lists represent variable-length homogeneous sequences. Also, tuples can be dict keys and set elements because they're hashable; lists can't."

Answer First: In CPython the GIL serializes Python bytecode execution within one process; threads still overlap I/O, while CPU-bound parallel work generally needs processes or native code that releases it.

Memory Map: Explain the GIL -> one bytecode thread per CPython process -> I/O threads -> CPU processes/native code.

Q2: "Explain the GIL."

βœ… GOOD ANSWER:

βœ… Pro Tip
"The Global Interpreter Lock is a mutex in CPython that ensures only one thread executes Python bytecode at a time. It's needed because CPython's memory management is not thread-safe. The GIL means multithreading doesn't speed up CPU-bound code β€” you need multiprocessing for that. For I/O-bound workloads it's fine because the GIL is released during I/O operations. Python 3.13 has an experimental free-threaded mode that removes the GIL."

Answer First: Wrap the callable, measure with time.perf_counter(), return its original result, and apply functools.wraps so metadata and introspection survive.

Memory Map: Timing decorator -> wrap callable -> perf_counter before/after -> return result -> preserve metadata.

Q3: "Write a decorator that times a function."

βœ… GOOD ANSWER:

python β€” editable
import time
from functools import wraps

def timer(func):
    @wraps(func)                    # preserves func's name/docstring
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__}: {time.perf_counter() - t0:.3f}s")
        return result
    return wrapper

@timer
def slow():
    time.sleep(1)

slow()   # slow: 1.001s

Follow-up: "Why @wraps(func)?" β†’ "Without it, wrapper.__name__ would be 'wrapper' β€” it preserves introspection."

Answer First: For hashable values, list(dict.fromkeys(items)) keeps first-occurrence order; use an explicit seen set when you need custom keys or unhashable handling.

Memory Map: Order-preserving deduplication -> first occurrence wins -> dict.fromkeys or seen set -> hashability check.

Q4: "How do you handle deduplication while preserving order?"

βœ… GOOD ANSWER:

python β€” editable
# Python 3.7+: dicts preserve insertion order
items = [3, 1, 2, 1, 3, 4]
deduped = list(dict.fromkeys(items))   # [3, 1, 2, 4]

Alternative (if order doesn't matter): list(set(items)).

Why dict.fromkeys? Dict keys are unique and ordered since 3.7 β€” combines both properties in one line.

Answer First: Use __slots__ when many instances need a fixed attribute shape and memory savings justify reduced dynamism and inheritance complexity.

Memory Map: __slots__ -> fixed instance attributes -> lower per-object memory -> trade away dynamic __dict__.

Q5: "When would you use __slots__?"

βœ… GOOD ANSWER:

βœ… Pro Tip
"__slots__ restricts the attributes a class can have, stored in a fixed-size array instead of a per-instance dict. Two benefits: memory savings (important when creating millions of instances) and faster attribute access (no dict lookup). The tradeoff: no dynamic attributes, no multiple inheritance without care, and no __dict__ unless you add it explicitly. I'd use it for data classes that are instantiated at massive scale β€” like representing rows in a dataset β€” but not for general-purpose classes where flexibility matters more."

FINAL READINESS CHECKLIST

  • Explain is vs == with example
  • Explain the mutable-default-argument trap
  • Write a closure + explain late binding
  • Explain the GIL and when it matters
  • Write a decorator with @wraps
  • List/dict/set complexity from memory
  • Explain shallow vs deep copy
  • Write dedup-preserving-order one-liner
  • List 5 common Python "gotchas" interviewers test
  • Walk through what happens when you assign, reassign, mutate

If yes β†’ you're Python-interview ready.

Runtime and application answer owners from the legacy question bank

Answer First: Mutable objects can change in place, while operations on immutable objects produce another object; aliasing makes that distinction observable.

Memory Map: Mutable types change in place -> immutable operations create objects -> aliases reveal the difference.

Q02 β€” What are Python's mutable and immutable types?

Question: Name Python's mutable and immutable types. What happens when you modify an immutable object?

Quick Answer: Immutable types (int, float, str, tuple, frozenset, bytes) create a new object on modification. Mutable types (list, dict, set, bytearray) change in place.

python β€” editable
# Example 1: Immutable strings β€” "modification" creates a new object
a = "hello"
b = a                # b points to the same object as a
print(id(a) == id(b))
# Output: True

a = a + " world"     # a now points to a NEW string object
print(a)
# Output: hello world
print(b)
# Output: hello
print(id(a) == id(b))
# Output: False
# b still points to the original "hello" β€” it was never modified
python β€” editable
# Example 2: Mutable lists β€” modification changes the SAME object
x = [1, 2, 3]
y = x                # y points to the same list object
x.append(4)          # Modifies the list in place
print(x)
# Output: [1, 2, 3, 4]
print(y)
# Output: [1, 2, 3, 4]
# Both x and y see the change because they share the same object
print(id(x) == id(y))
# Output: True
python β€” editable
# Example 3: Immutable integers β€” reassignment creates a new object
a = 10
print(id(a))
# Output: 4344024144  (some memory address)
a = a + 5            # Creates a brand new int object 15
print(a)
# Output: 15
# The integer 10 still exists (until garbage collected)
# Python caches small integers (-5 to 256), so id() for those may be reused

🎯 Tip: "Understanding mutability prevents aliasing bugs. In data pipelines, I'm careful with mutable default arguments β€” def f(lst=[]) is a classic trap because the default list is shared across calls."

Answer First: A shallow copy duplicates only the outer container, while a deep copy recursively duplicates nested objects.

Memory Map: deepcopy duplicates nested objects -> shallow copy shares children -> choose required independence.

Q04 β€” What is the difference between deepcopy and shallow copy?

Question: Explain shallow copy vs deep copy. When does each matter? Provide an example where shallow copy causes a bug.

Quick Answer: Shallow copy creates a new outer object but shares inner objects. Deep copy creates new copies of everything recursively. Matters when you have nested structures.

python β€” editable
# Example 1: Shallow copy β€” inner lists are shared
import copy

a = [[1, 2], [3, 4]]
b = copy.copy(a)       # Shallow copy
a[0].append(999)       # Modify an inner list

print(a)
# Output: [[1, 2, 999], [3, 4]]
print(b)
# Output: [[1, 2, 999], [3, 4]]
# BUG: b was affected because inner lists are shared references
python β€” editable
# Example 2: Deep copy β€” completely independent
import copy

a = [[1, 2], [3, 4]]
c = copy.deepcopy(a)   # Deep copy β€” all nested objects are cloned
a[0].append(999)

print(a)
# Output: [[1, 2, 999], [3, 4]]
print(c)
# Output: [[1, 2], [3, 4]]
# c is fully independent β€” no shared references
python β€” editable
# Example 3: Multiple ways to shallow copy a list
original = [1, 2, 3]

# Method 1: copy module
copy1 = copy.copy(original)

# Method 2: list slicing
copy2 = original[:]

# Method 3: list() constructor
copy3 = list(original)

# All produce independent shallow copies for flat lists
original.append(4)
print(original)
# Output: [1, 2, 3, 4]
print(copy1)
# Output: [1, 2, 3]
print(copy2)
# Output: [1, 2, 3]
print(copy3)
# Output: [1, 2, 3]
# For flat lists (no nesting), shallow copy is safe and sufficient

🎯 Tip: "If your data has nested structures (list of lists, dict of dicts), always use deepcopy. For flat structures, shallow copy or slicing is fine and faster."

Answer First: It runs script-only entry-point code when a file is executed directly while leaving reusable definitions available when the file is imported.

Memory Map: __main__ guard -> executed file gets __main__ -> imported module skips script entry point.

Q05 β€” What does if __name__ == '__main__' do?

Question: What is the purpose of if __name__ == '__main__' in Python? Why is it important?

Quick Answer: It checks if the file is being run directly (not imported). Code inside this block only executes when the file is the entry point.

python β€” editable
# Example 1: Basic usage in a module file
# File: utils.py
def helper():
    return "I'm a helper function"

def add(a, b):
    return a + b

if __name__ == '__main__':
    # Only runs when: python utils.py
    # Does NOT run when: from utils import helper
    print(helper())
    # Output: I'm a helper function
    print(add(3, 4))
    # Output: 7
python β€” editable
# Example 2: Understanding __name__ value
# When run directly:  __name__ == '__main__'
# When imported:      __name__ == 'utils' (the module name)

# File: demo.py
print(f"__name__ is: {__name__}")

# Running: python demo.py
# Output: __name__ is: __main__

# Importing: import demo
# Output: __name__ is: demo
python β€” editable
# Example 3: Real-world pattern β€” ETL script with reusable functions
# File: etl_pipeline.py
def extract(source):
    """Extract data from source β€” reusable when imported"""
    return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]

def transform(data):
    """Transform data β€” reusable when imported"""
    return [row for row in data if row["id"] > 0]

def load(data):
    """Load data β€” reusable when imported"""
    print(f"Loaded {len(data)} rows")

if __name__ == '__main__':
    # Orchestration only runs when script is executed directly
    raw = extract("db://source")
    clean = transform(raw)
    load(clean)
    # Output: Loaded 2 rows

🎯 Tip: "This pattern makes ETL code both runnable as a script AND importable as a module. Without it, import side effects would trigger pipeline runs unintentionally."

Answer First: *args gathers extra positional arguments and **kwargs gathers extra named arguments, enabling wrappers and flexible APIs.

Memory Map: Flexible arguments -> *args extra positional -> **kwargs extra named -> wrappers forward both.

Q06 β€” What are *args and **kwargs?

Question: Explain *args and **kwargs. When would you use them? Show the order of parameters.

Quick Answer: *args collects extra positional arguments as a tuple. **kwargs collects extra keyword arguments as a dict. Order: def f(positional, *args, **kwargs).

python β€” editable
# Example 1: Basic *args β€” collects positional arguments into a tuple
def add_all(*args):
    # args is a tuple of all positional arguments
    print(f"args = {args}")
    return sum(args)

result = add_all(1, 2, 3, 4, 5)
# Output: args = (1, 2, 3, 4, 5)
print(result)
# Output: 15
python β€” editable
# Example 2: Basic **kwargs β€” collects keyword arguments into a dict
def create_user(**kwargs):
    # kwargs is a dict of all keyword arguments
    for key in sorted(kwargs.keys()):    # sorted for deterministic output
        print(f"  {key}: {kwargs[key]}")

create_user(name="Alice", age=30, role="engineer")
# Output:   age: 30
# Output:   name: Alice
# Output:   role: engineer
python β€” editable
# Example 3: Combined usage with correct ordering
def func(required, *args, **kwargs):
    print(f"required: {required}")
    print(f"args: {args}")
    # Sort kwargs keys for deterministic output
    print(f"kwargs: {dict(sorted(kwargs.items()))}")

func(1, 2, 3, x=4, y=5)
# Output: required: 1
# Output: args: (2, 3)
# Output: kwargs: {'x': 4, 'y': 5}

# Real-world use: wrapper functions, decorators, flexible APIs
def log_call(func_name, *args, **kwargs):
    """Log function calls in a data pipeline"""
    sorted_kw = dict(sorted(kwargs.items()))
    print(f"Calling {func_name} with args={args}, kwargs={sorted_kw}")

log_call("extract", "table_a", limit=100, format="parquet")
# Output: Calling extract with args=('table_a',), kwargs={'format': 'parquet', 'limit': 100}

🚫 What NOT to Say: "args and kwargs are special keywords." They are just conventions -- you could use *numbers and **options, but *args/**kwargs is the standard everyone follows.

Answer First: A decorator replaces a callable with another callable that adds behavior; @name is assignment through the decorator at definition time.

Memory Map: Decorator syntax -> function enters decorator at definition -> replacement callable adds behavior.

Q07 β€” What is a decorator?

Question: What is a decorator in Python? How does it work internally? Give a practical example.

Quick Answer: A decorator is a function that wraps another function to add behavior without modifying the original. It takes a function as input and returns a new function.

python β€” editable
# Example 1: Simple decorator β€” timing function execution
import time

def timer(func):
    """Decorator that measures execution time"""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)           # Call the original function
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result                             # Return the original result
    return wrapper

@timer
def process_data():
    total = sum(range(1000000))    # Some computation
    return total

result = process_data()
# Output: process_data took 0.0312s  (approximate)
print(result)
# Output: 499999500000
python β€” editable
# Example 2: Decorator with arguments β€” retry logic
import time

def retry(max_attempts=3):
    """Decorator factory β€” returns a decorator configured with max_attempts"""
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt} failed: {e}")
                    if attempt == max_attempts:
                        raise
            return None
        return wrapper
    return decorator

@retry(max_attempts=2)
def fetch_data():
    print("Fetching...")
    return {"status": "ok"}

result = fetch_data()
# Output: Fetching...
print(result)
# Output: {'status': 'ok'}
python β€” editable
# Example 3: Understanding decorator syntax β€” @ is syntactic sugar
def shout(func):
    def wrapper():
        result = func()
        return result.upper()
    return wrapper

# These two are IDENTICAL:
@shout
def greet():
    return "hello world"

# Same as: greet = shout(greet)

print(greet())
# Output: HELLO WORLD

🎯 Tip: "I use decorators for timing ETL steps, retrying failed API calls, and caching expensive computations with @functools.lru_cache."

Answer First: In standard CPython, one thread executes Python bytecode at a time; threads still suit I/O, while CPU-bound parallelism generally needs processes or native code that releases the GIL.

Memory Map: Global Interpreter Lock -> serialize CPython bytecode -> threads for I/O -> processes for CPU parallelism.

Q08 β€” What is the GIL (Global Interpreter Lock)?

Question: What is the GIL? How does it affect multi-threaded Python programs? How do you work around it?

Quick Answer: The GIL allows only one thread to execute Python bytecode at a time. CPU-bound tasks don't benefit from multi-threading. Use multiprocessing for CPU work, threading for I/O work.

python β€” editable
# Example 1: Threading for I/O-bound work (GIL is released during I/O)
import threading
import time

results = []
lock = threading.Lock()

def fetch_url(url, delay):
    """Simulate an I/O-bound task (API call)"""
    time.sleep(delay)          # GIL is released during sleep/I/O
    with lock:
        results.append(f"Done: {url}")

threads = [
    threading.Thread(target=fetch_url, args=("api/users", 0.1)),
    threading.Thread(target=fetch_url, args=("api/orders", 0.1)),
]

start = time.time()
for t in threads:
    t.start()
for t in threads:
    t.join()

elapsed = time.time() - start
print(sorted(results))
# Output: ['Done: api/orders', 'Done: api/users']
print(f"Time: {elapsed:.1f}s (parallel, not 0.2s)")
# Output: Time: 0.1s (parallel, not 0.2s)
python β€” editable
# Example 2: Multiprocessing for CPU-bound work (bypasses GIL)
from multiprocessing import Pool

def square(n):
    """CPU-bound computation"""
    return n * n

# Each process has its own Python interpreter and GIL
with Pool(processes=2) as pool:
    results = pool.map(square, [1, 2, 3, 4, 5])

print(results)
# Output: [1, 4, 9, 16, 25]
python β€” editable
# Example 3: When to use what β€” quick reference
# I/O-bound (network, disk, DB) --> threading or asyncio
#   - API calls, file reads, database queries
#   - GIL is released during I/O operations

# CPU-bound (math, data processing) --> multiprocessing
#   - Number crunching, image processing, data transformation
#   - Each process has its own GIL

# Data engineering --> PySpark / Dask
#   - Distributed computing across machines
#   - Each executor runs a separate Python process

print("Threading:       best for I/O-bound tasks")
print("Multiprocessing: best for CPU-bound tasks")
print("PySpark/Dask:    best for distributed big data")
# Output: Threading:       best for I/O-bound tasks
# Output: Multiprocessing: best for CPU-bound tasks
# Output: PySpark/Dask:    best for distributed big data

🎯 Tip: "PySpark avoids the GIL by running Python in separate processes per executor. That's why it scales for big data workloads."

Answer First: is tests identity and == tests equality; None checks use identity, ordinary values use equality.

Memory Map: Identity is -> equality == -> use is None -> use value equality everywhere else.

Q09 β€” Difference between is and ==?

Question: What is the difference between is and == in Python? When should you use each?

Quick Answer: == checks value equality (are the contents the same?). is checks identity (are they the exact same object in memory?). Use is only for None checks.

python β€” editable
# Example 1: Value equality vs identity
a = [1, 2, 3]
b = [1, 2, 3]    # Same values, different object

print(a == b)
# Output: True    (same values)
print(a is b)
# Output: False   (different objects in memory)

c = a             # c points to the SAME object as a
print(a is c)
# Output: True    (same object)
python β€” editable
# Example 2: The correct way to check for None
value = None

# CORRECT β€” use 'is' for None checks
if value is None:
    print("Value is None")
# Output: Value is None

# ALSO CORRECT β€” 'is not' for the opposite
value = 42
if value is not None:
    print(f"Value is {value}")
# Output: Value is 42

# WHY? None is a singleton β€” there's only ONE None object in Python
# So 'is' is both correct and faster than ==
python β€” editable
# Example 3: Python's integer caching β€” a common gotcha
# Python caches small integers from -5 to 256
a = 256
b = 256
print(a is b)
# Output: True    (cached β€” same object)

a = 257
b = 257
print(a is b)
# Output: False   (not cached β€” different objects)

# LESSON: Never use 'is' to compare integers or strings
# Always use == for value comparison
print(a == b)
# Output: True    (correct way to compare values)

🚫 What NOT to Say: "I use is to compare strings or numbers." -- Only use is for None checks. For everything else, use ==.

Answer First: CPython primarily uses reference counting and supplements it with a cyclic garbage collector; object lifetime and process memory are related but not identical.

Memory Map: Memory management -> CPython reference counts -> cyclic GC handles loops -> allocator affects RSS.

Q10 β€” How does Python handle memory management?

Question: Explain how Python manages memory. What is reference counting? What is garbage collection?

Quick Answer: Python uses reference counting (tracks how many variables point to each object) plus a cyclic garbage collector for circular references. When reference count hits 0, memory is freed immediately.

python β€” editable
# Example 1: Reference counting in action
import sys

a = [1, 2, 3]
print(sys.getrefcount(a))
# Output: 2   (one for 'a', one for the getrefcount argument)

b = a                        # Another reference to the same list
print(sys.getrefcount(a))
# Output: 3   (a + b + getrefcount arg)

del b                        # Remove one reference
print(sys.getrefcount(a))
# Output: 2   (back to a + getrefcount arg)
python β€” editable
# Example 2: Circular references β€” garbage collector handles these
import gc

class Node:
    def __init__(self, name):
        self.name = name
        self.ref = None        # Will create circular reference

# Create circular reference
a = Node("A")
b = Node("B")
a.ref = b                     # A points to B
b.ref = a                     # B points to A (circular!)

# Delete external references
del a
del b
# Reference count for both is still 1 (they point to each other)
# But Python's garbage collector detects and cleans circular references

collected = gc.collect()       # Force garbage collection
print(f"Garbage collector cleaned up {collected} objects")
# Output: Garbage collector cleaned up 0 objects
# (may vary; objects might already be collected)
python β€” editable
# Example 3: Checking memory usage of objects
import sys

# Different types use different amounts of memory
print(f"int(0):      {sys.getsizeof(0)} bytes")
# Output: int(0):      28 bytes
print(f"int(1):      {sys.getsizeof(1)} bytes")
# Output: int(1):      28 bytes
print(f"str(''):     {sys.getsizeof('')} bytes")
# Output: str(''):     49 bytes
print(f"str('hello'):{sys.getsizeof('hello')} bytes")
# Output: str('hello'):54 bytes
print(f"list([]):    {sys.getsizeof([])} bytes")
# Output: list([]):    56 bytes
print(f"dict({{}}):    {sys.getsizeof({})} bytes")
# Output: dict({}):    64 bytes

🎯 Tip: "In data pipelines, I watch for circular references in custom classes and use weakref when needed. For large DataFrames, I del them explicitly and call gc.collect() to free memory sooner."

Answer First: A static method receives no automatic class or instance argument; a class method receives cls and supports class-aware factories and polymorphism.

Memory Map: staticmethod gets no implicit receiver -> classmethod gets cls -> class-aware factory polymorphism.

Q14 β€” What is the difference between @staticmethod and @classmethod?

Question: Explain @staticmethod and @classmethod. When would you use each? How do they differ from regular methods?

Quick Answer: @staticmethod has no access to class or instance (just a function inside a class). @classmethod gets the class (cls) as its first argument and can access/modify class state.

python β€” editable
# Example 1: All three method types compared
class DataProcessor:
    default_format = "csv"          # Class variable

    def __init__(self, name):
        self.name = name            # Instance variable

    def process(self):
        """Regular method β€” has access to instance (self)"""
        return f"{self.name} processing as {self.default_format}"

    @classmethod
    def set_format(cls, fmt):
        """Class method β€” has access to class (cls), not instance"""
        cls.default_format = fmt
        return f"Format set to {fmt}"

    @staticmethod
    def validate_extension(filename):
        """Static method β€” no access to class or instance"""
        return filename.endswith((".csv", ".json", ".parquet"))

# Regular method β€” needs an instance
dp = DataProcessor("Pipeline-1")
print(dp.process())
# Output: Pipeline-1 processing as csv

# Class method β€” can be called on the class itself
print(DataProcessor.set_format("parquet"))
# Output: Format set to parquet

# Static method β€” utility function, no class/instance needed
print(DataProcessor.validate_extension("data.csv"))
# Output: True
print(DataProcessor.validate_extension("data.exe"))
# Output: False
python β€” editable
# Example 2: @classmethod as alternative constructor
class Config:
    def __init__(self, host, port, db):
        self.host = host
        self.port = port
        self.db = db

    @classmethod
    def from_string(cls, config_str):
        """Alternative constructor β€” parses a config string"""
        host, port, db = config_str.split(":")
        return cls(host, int(port), db)   # cls() creates a new instance

    def __repr__(self):
        return f"Config({self.host}:{self.port}/{self.db})"

# Standard constructor
c1 = Config("localhost", 5432, "mydb")
print(c1)
# Output: Config(localhost:5432/mydb)

# Alternative constructor via classmethod
c2 = Config.from_string("prod-server:5432:analytics")
print(c2)
# Output: Config(prod-server:5432/analytics)
python β€” editable
# Example 3: When to use which β€” decision guide
class DateUtils:
    date_format = "%Y-%m-%d"

    @staticmethod
    def is_weekend(day_number):
        """No class or instance data needed β€” pure utility"""
        return day_number >= 5     # 5=Sat, 6=Sun

    @classmethod
    def get_format(cls):
        """Needs access to class-level configuration"""
        return cls.date_format

print(DateUtils.is_weekend(6))
# Output: True
print(DateUtils.get_format())
# Output: %Y-%m-%d

🎯 Tip: "Use @classmethod for factory methods (alternative constructors) and @staticmethod for utility functions that logically belong to the class but don't need class/instance data."

Answer First: Use a context manager for deterministic close, iterate line by line for large text, and read bounded chunks for large binary streams.

Memory Map: File reading -> with open guarantees close -> iterate text lines -> chunk large binary data.

Q15 β€” How do you handle file reading in Python?

Question: What is the best practice for reading files in Python? How do you handle large files efficiently?

Quick Answer: Always use the with statement (context manager) to ensure files are closed properly, even if exceptions occur. For large files, read line by line instead of loading everything.

python β€” editable
# Example 1: Reading a file with context manager
import json

# CORRECT β€” 'with' ensures file is closed even if an exception occurs
data = '{"name": "Alice", "role": "engineer"}'

# Simulating file read with json.loads (no external file needed)
config = json.loads(data)
print(config["name"])
# Output: Alice
print(config["role"])
# Output: engineer

# Pattern for real file reading:
# with open("config.json") as f:
#     config = json.load(f)

# BAD β€” file may not be closed if an exception occurs
# f = open("config.json")
# config = json.load(f)
# f.close()              # Skipped if exception happens above!
python β€” editable
# Example 2: Reading large files β€” line by line (memory efficient)
import io

# Simulate a large CSV file
csv_content = "id,name,score\n1,Alice,85\n2,Bob,92\n3,Charlie,78\n"
fake_file = io.StringIO(csv_content)

# Line-by-line reading β€” only ONE line in memory at a time
row_count = 0
for line in fake_file:
    row_count += 1
    print(line.strip())
# Output: id,name,score
# Output: 1,Alice,85
# Output: 2,Bob,92
# Output: 3,Charlie,78
print(f"Total lines: {row_count}")
# Output: Total lines: 4
python β€” editable
# Example 3: Reading in chunks β€” for binary or very large files
import io

# Simulate a large file
large_content = "A" * 100    # 100 characters

fake_file = io.StringIO(large_content)
chunk_size = 30
chunks_read = 0

while True:
    chunk = fake_file.read(chunk_size)
    if not chunk:
        break
    chunks_read += 1
    print(f"Chunk {chunks_read}: {len(chunk)} chars")
# Output: Chunk 1: 30 chars
# Output: Chunk 2: 30 chars
# Output: Chunk 3: 30 chars
# Output: Chunk 4: 10 chars
print(f"Total chunks: {chunks_read}")
# Output: Total chunks: 4

🚫 What NOT to Say: "I use f = open(...) without with." -- That's a resource leak if an exception occurs. Always use context managers.

Answer First: A module is an importable file, a package organizes importable modules, and a library is the broader reusable distribution or collection exposed to users.

Memory Map: Module is importable file -> package groups modules -> library is the reusable public collection.

Q18 β€” What is the difference between a module, package, and library?

Question: Explain the difference between a module, a package, and a library in Python. How does __init__.py work?

Quick Answer: A module is a single .py file. A package is a directory with __init__.py containing multiple modules. A library is a collection of packages (e.g., pandas, numpy).

python β€” editable
# Example 1: Structure visualization
# myproject/                    <- Project root
#   main.py                     <- Script
#   mypackage/                  <- Package (directory with __init__.py)
#     __init__.py               <- Makes this directory a package
#     utils.py                  <- Module (single .py file)
#     models.py                 <- Module
#     subpackage/               <- Sub-package
#       __init__.py
#       helpers.py              <- Module

# Import a module from a package
# from mypackage import utils
# from mypackage.subpackage import helpers
python β€” editable
# Example 2: Creating and using a simple module
# Simulating what a module looks like

# --- File: math_utils.py (this would be a module) ---
def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

PI = 3.14159

# --- File: main.py (importing the module) ---
# import math_utils
# result = math_utils.add(3, 4)

# Or selective import:
# from math_utils import add, PI
# result = add(3, 4)

# Demonstrating the concept inline
print(add(3, 4))
# Output: 7
print(f"PI = {PI}")
# Output: PI = 3.14159
python β€” editable
# Example 3: __init__.py controls what gets exported
# --- File: mypackage/__init__.py ---
# This file runs when you do: import mypackage

# You can expose specific items at the package level:
# from .utils import helper_function
# from .models import DataModel

# Then users can do:
# from mypackage import helper_function   (clean import)
# Instead of:
# from mypackage.utils import helper_function   (longer path)

# Check if something is a module, package, or built-in
import json
import os

print(type(json))
# Output: <class 'module'>
print(hasattr(json, '__path__'))
# Output: False   (json is a module, not a package in this context)
print(hasattr(os, '__path__'))
# Output: True    (os is a package β€” it's a directory with __init__.py)

🎯 Tip: "In data engineering projects, I organize code into packages: etl/extract.py, etl/transform.py, etl/load.py with an __init__.py that exposes the main pipeline function."

Answer First: Put risky work in try, expected recovery in narrow except blocks, success-only work in else, and unconditional cleanup in finally.

Memory Map: Error handling -> risky try -> narrow except -> success else -> cleanup finally.

Q19 β€” How does error handling work with try/except/else/finally?

Question: Explain Python's error handling. What is the purpose of else and finally in a try block? Give practical examples.

Quick Answer: try runs risky code. except catches specific exceptions. else runs only if no exception occurred. finally always runs (cleanup). Order matters.

python β€” editable
# Example 1: Full try/except/else/finally structure
def divide(a, b):
    try:
        result = a / b                    # Risky operation
    except ZeroDivisionError:
        print("Error: Cannot divide by zero!")
        return None
    except TypeError as e:
        print(f"Error: Wrong types β€” {e}")
        return None
    else:
        print(f"Success: {a} / {b} = {result}")   # Only if NO exception
        return result
    finally:
        print("Cleanup: division attempt complete")  # ALWAYS runs

print(divide(10, 3))
# Output: Success: 10 / 3 = 3.3333333333333335
# Output: Cleanup: division attempt complete
# Output: 3.3333333333333335

print(divide(10, 0))
# Output: Error: Cannot divide by zero!
# Output: Cleanup: division attempt complete
# Output: None
python β€” editable
# Example 2: Catching multiple exception types
def parse_config(value):
    """Parse a config value β€” handle various errors"""
    try:
        # Try converting to int
        result = int(value)
        return result
    except ValueError:
        print(f"'{value}' is not a valid integer")
        return None
    except TypeError:
        print(f"Expected string or number, got {type(value).__name__}")
        return None

print(parse_config("42"))
# Output: 42
print(parse_config("abc"))
# Output: 'abc' is not a valid integer
# Output: None
print(parse_config(None))
# Output: Expected string or number, got NoneType
# Output: None
python β€” editable
# Example 3: Custom exceptions for data pipelines
class DataValidationError(Exception):
    """Custom exception for data quality issues"""
    def __init__(self, column, message):
        self.column = column
        self.message = message
        super().__init__(f"Column '{column}': {message}")

def validate_row(row):
    if row.get("age") is not None and row["age"] < 0:
        raise DataValidationError("age", "negative value not allowed")
    if not row.get("name"):
        raise DataValidationError("name", "cannot be empty")
    return True

# Test validation
test_rows = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": -5},
    {"name": "", "age": 25},
]

for row in test_rows:
    try:
        validate_row(row)
        print(f"Valid: {row}")
    except DataValidationError as e:
        print(f"Invalid: {e}")
# Output: Valid: {'name': 'Alice', 'age': 30}
# Output: Invalid: Column 'age': negative value not allowed
# Output: Invalid: Column 'name': cannot be empty

🎯 Tip: "else runs only on success, finally always runs -- even if there's a return statement. In ETL, I use finally for closing DB connections and else for logging success."

Foundation

Canonical Python Question Index

#

Canonical Python Question Index

Canonical Q-PY index

Answer First: This chapter is a routing index: each prompt points to the one concept owner that contains the complete answer, runnable examples, output reasoning, traps, and caveats.

Memory Map: prompt -> exact owner -> code -> edge case -> spoken answer. Alternate source wording stays visible as an alias without duplicating the answer.

Q-PY-001: What is the difference between a list and a tuple?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L7.

Q-PY-002: What are Python's mutable and immutable types?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L56.

Q-PY-003: Explain list comprehension vs generator expression

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L109.

Q-PY-004: What is the difference between deepcopy and shallow copy?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L157.

Q-PY-005: What does if __name__ == '__main__' do?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L223.

Q-PY-006: What are *args and **kwargs?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L289.

Q-PY-007: What is a decorator?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L348.

Q-PY-008: What is the GIL (Global Interpreter Lock)?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L432.

Q-PY-009: Difference between is and ==?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L512.

Q-PY-010: How does Python handle memory management?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L575.

Q-PY-011: What is a lambda function?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L648.

Q-PY-012: Difference between append() and extend()?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L722.

Q-PY-013: What are Python generators?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L782.

Q-PY-014: What is the difference between @staticmethod and @classmethod?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L857.

Q-PY-015: How do you handle file reading in Python?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L955.

Q-PY-016: What is a dictionary comprehension?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L1035.

Q-PY-017: What is enumerate() and why use it?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L1096.

Q-PY-018: What is the difference between a module, package, and library?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L1153.

Q-PY-019: How does error handling work with try/except/else/finally?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L1234.

Q-PY-020: What is zip() and how do you unzip?

Answer owner: Complete concept, examples, outputs, and caveats

Source wording: Python_04_Question_Bank.md#L1331.

Alternate source wording

Alias PY-A-001: Find Duplicate Characters in a String

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L26.

Alias PY-A-002: Check if Two Strings Are Anagrams

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L102.

Alias PY-A-003: Longest Substring Without Repeating Characters

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L281.

Alias PY-A-004: Reverse a String / Reverse Words in a Sentence

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L379.

Alias PY-A-005: Count Character Frequency / Most Common Character

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L471.

Alias PY-A-006: Check if a String Is a Palindrome

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L557.

Alias PY-A-007: String Compression (Run Length Encoding)

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L637.

Alias PY-A-008: Two Sum Problem

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L732.

Alias PY-A-009: FizzBuzz

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L815.

Alias PY-A-010: Remove Duplicates from a List (Preserve Order)

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L898.

Alias PY-A-011: Flatten a Nested List

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L966.

Alias PY-A-012: Find Missing Number in a List (1 to N)

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L1051.

Alias PY-A-013: Matrix / 2D List Operations

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L1136.

Alias PY-A-014: Dictionary Manipulation (Merge, Invert, Sort)

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L1260.

Alias PY-A-015: List Comprehension vs Generator Expression

Alias owner: Exact concept owner

Alternate source wording: Python_01_Strings_Puzzles.md#L1391.

Alias PY-A-016: Mutable Default Argument Trap

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L23.

Alias PY-A-017: List Aliasing vs Copy

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L75.

Alias PY-A-018: is vs == and Integer Caching

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L131.

Alias PY-A-019: String Immutability and List Multiplication Trap

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L194.

Alias PY-A-020: Closure Late Binding Trap

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L249.

Alias PY-A-021: Tuple with One Element

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L300.

Alias PY-A-022: Dictionary Key Overwrite (True == 1 == 1.0)

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L353.

Alias PY-A-023: Chained Comparison Surprise

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L401.

Alias PY-A-024: *args and **kwargs Unpacking

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L455.

Alias PY-A-025: Scope: Local vs Global (LEGB Rule)

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L510.

Alias PY-A-026: enumerate and zip Tricks

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L573.

Alias PY-A-027: Walrus Operator := (Python 3.8+)

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L645.

Alias PY-A-028: any() and all() with Empty Collections

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L707.

Alias PY-A-029: try/except/else/finally Flow

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L763.

Alias PY-A-030: map, filter, reduce vs Comprehensions

Alias owner: Exact concept owner

Alternate source wording: Python_02_Tricky_Output.md#L837.

Alias PY-A-031: Set Operations for Data Comparison

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L22.

Alias PY-A-032: defaultdict and Counter Patterns

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L161.

Alias PY-A-033: Sorting with Custom Keys

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L288.

Alias PY-A-034: Stack and Queue Patterns

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L382.

Alias PY-A-035: Lambda, Map, Filter in One-Liners

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L489.

Alias PY-A-036: namedtuple and dataclass

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L576.

Alias PY-A-037: Decorators (Simplified)

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L697.

Alias PY-A-038: Context Managers (with statement)

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L825.

Alias PY-A-039: Itertools for Data Processing

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L932.

Alias PY-A-040: Exception Handling Best Practices

Alias owner: Exact concept owner

Alternate source wording: Python_03_Data_Structures_Patterns.md#L1050.

Alias PY-A-041: "What's the difference between a list and a tuple?"

Alias owner: Exact concept owner

Alternate source wording: Python_05_Confusions_Labs_MockInterview.md#L531.

Alias PY-A-042: "Explain the GIL."

Alias owner: Exact concept owner

Alternate source wording: Python_05_Confusions_Labs_MockInterview.md#L538.

Alias PY-A-043: "Write a decorator that times a function."

Alias owner: Exact concept owner

Alternate source wording: Python_05_Confusions_Labs_MockInterview.md#L545.

Alias PY-A-044: "How do you handle deduplication while preserving order?"

Alias owner: Exact concept owner

Alternate source wording: Python_05_Confusions_Labs_MockInterview.md#L572.

Alias PY-A-045: "When would you use __slots__?"

Alias owner: Exact concept owner

Alternate source wording: Python_05_Confusions_Labs_MockInterview.md#L587.

90 seconds

Practice sprint

Close the atlas. Rebuild the map.

Name the path from API to files, then explain where shuffle, skew, and serialization enter the system.

Open interview prompts