Consider this Python code that builds a binary search tree and performs a traversal:
```python
class Node:
def __init__(self, v):
self.v = v
self.left = None
self.right = None
def inorder(node, result):
if node:
inorder(node.left, result)
result.append(node.v)
inorder(node.right, result)
root = Node(8)
root.left = Node(3)
root.right = Node(10)
root.left.left = Node(1)
root.left.right = Node(6)
output = []
inorder(root, output)
```
What is the value of `output` after this code runs?
The output is [1, 3, 6, 8, 10], produced by an in-order traversal (left subtree, then node, then right subtree), which visits nodes in ascending sorted order for this binary search tree.
Pre-order traversal, which visits the node before its subtrees, produces [8, 3, 1, 6, 10] instead of the actual in-order output this code computes.
Post-order traversal, which visits both children before the node itself, yields [1, 6, 3, 10, 8], not the sequence this specific inorder function builds.
A level-by-level (breadth-first) traversal reads the tree row by row and gives [8, 3, 10, 1, 6], which is not what this recursive depth-first function produces.
Answer: A. The output is [1, 3, 6, 8, 10], produced by an in-order traversal (left subtree, then node, then right subtree), which visits nodes in ascending sorted order for this binary search tree.
ExplanationTracing the recursion: inorder(8) first fully explores the left subtree before touching the root, so it calls inorder(3). That call in turn explores its own left child first: inorder(1) has no children, so it immediately appends 1. Back in the call for node 3, the node's own value is appended next, giving 3, and then its right child 6 is explored and appended, giving 6. This finishes the entire left subtree of 8, so 8 is appended next, and finally the right subtree rooted at 10 is explored, appending 10. The full sequence built by result.append is [1, 3, 6, 8, 10]. This is exactly the sorted order of the five values, which is not a coincidence: in a binary search tree every left child holds a smaller value than its parent and every right child holds a larger one, and an in-order traversal (left, node, right) always visits nodes in that ascending order as a direct consequence of the BST ordering property.
Question 102 · data structures · hard
Analyze: `graph = {1: [2, 3], 2: [1, 4], 3: [1], 4: [2]}; result = graph[1]`. What is result, and how do adjacency lists represent graph structures?
The result is [2, 3]: graph is an adjacency list where each key is a node and its value is the list of that node's neighbors, so graph[1] performs a single dictionary lookup that returns node 1's neighbor list directly.
Python evaluates graph[1] as [1, 2, 3, 4] by merging the values from every key in the dictionary into one combined list of all graph nodes.
graph[1] returns the integer 1 itself, because square-bracket indexing on a dictionary retrieves the matching key rather than the value mapped to it.
graph[1] returns [2, 3, 1, 4], because it appends node 2's neighbor list [1, 4] onto node 1's own neighbor list [2, 3].
Answer: A. The result is [2, 3]: graph is an adjacency list where each key is a node and its value is the list of that node's neighbors, so graph[1] performs a single dictionary lookup that returns node 1's neighbor list directly.
ExplanationAn adjacency list represents a graph as a dictionary where each key is a node and its value is a list of that node's direct neighbors. Here, graph[1] performs one dictionary lookup and returns the list stored under key 1, which is [2, 3] — meaning node 1 connects to nodes 2 and 3. This differs from an adjacency matrix, where finding a node's neighbors would require scanning a full row of the matrix; the dictionary-based adjacency list gives O(1) access to any node's neighbor list and uses O(V + E) total space overall, which is why it is the standard choice for representing sparse graphs.
Question 103 · data structures · hard
Given: 'def dfs(node, graph, visited): visited.add(node); for neighbor in graph[node]: if neighbor not in visited: dfs(neighbor, graph, visited); graph = {1: [2,3], 2: [1,4], 3: [1], 4: [2]}; visited = set(); dfs(1, graph, visited); result = visited'. What is result, and analyze how DFS explores all reachable nodes?
The result is {1, 2, 3, 4} because DFS recursively explores neighbors: start at 1, visit 2 and 3, from 2 visit 4, marking visited nodes to avoid revisiting, achieving O(V+E) traversal of all reachable nodes from start node
The result is {1} because DFS only visits the starting node and never recurses into any of its neighbors.
The result is {1, 2} because DFS does not explore all neighbors
DFS raises an error because of the circular graph edges between node 1 and node 2.
Answer: A. The result is {1, 2, 3, 4} because DFS recursively explores neighbors: start at 1, visit 2 and 3, from 2 visit 4, marking visited nodes to avoid revisiting, achieving O(V+E) traversal of all reachable nodes from start node
ExplanationDFS (Depth-First Search) starts at a node and recursively explores each unvisited neighbor, marking nodes as visited to avoid revisiting them and to safely handle cycles. Trace: visit 1, explore 2 (recurse: visit 2, explore 4 (recurse: visit 4, no unvisited neighbors)), back to 1, explore 3 (recurse: visit 3, no unvisited neighbors). Every node reachable from 1 gets added to visited. Result: {1, 2, 3, 4}. Time complexity: O(V+E), since each vertex is visited once and each edge is examined once.
Question 104 · list comprehensions · hard
Given the Python code: 'x = [10, 20, 30]; y = [val*2 for val in x]; result = sum(y)'. What is the computed result, and evaluate how list comprehensions enable compact mathematical transformations without explicit loop construction?
The result is 120 because the comprehension transforms [10, 20, 30] into [20, 40, 60], and summing gives 20+40+60 = 120, showing how comprehensions replace a full for-loop with append calls in one line
The result is 60 because the *2 multiplication inside the comprehension is overlooked entirely, leaving y equal to x, so sum(y) just adds the original values: 10+20+30 = 60
The result is 1400 because each value is mistakenly squared instead of doubled (val**2 instead of val*2), giving 100, 400, and 900, which sum to 1400
The result is 180 because each value is mistakenly tripled instead of doubled (val*3 instead of val*2), giving 30, 60, and 90, which sum to 180
Answer: A. The result is 120 because the comprehension transforms [10, 20, 30] into [20, 40, 60], and summing gives 20+40+60 = 120, showing how comprehensions replace a full for-loop with append calls in one line
ExplanationThe result is 120. The comprehension applies val*2 to each element of x in turn: 10 becomes 20, 20 becomes 40, and 30 becomes 60, producing y = [20, 40, 60]. sum(y) then adds these together: 20 + 40 + 60 = 120. This shows why list comprehensions are useful for compact transformations: a single expression [val*2 for val in x] both transforms each element and collects the results into a new list, doing in one line what would otherwise take a for-loop with an explicit accumulator list and repeated append() calls.
Question 105 · dictionary operations · hard
A settings dictionary is defined as follows:
```python
settings = {'volume': 0, 'brightness': None, 'sound': False}
result = settings.get('volume', 50)
```
What value does `result` hold after this code runs?
0 is returned, because get() checks only whether the key itself exists in the dictionary — since 'volume' is present with the stored value 0, that value is returned instead of the default 50
50 is returned, because Python treats 0 as a falsy value equivalent to a missing key, so get() falls back to the default argument supplied in the call
None is returned, because get() scans the dictionary in insertion order and returns the value of 'brightness', the first key it encounters, before checking whether 'volume' matches
False is returned, because 'sound' is the last key defined in the dictionary, and get() defaults to returning the final key's value whenever the requested key holds a non-string type
Answer: A. 0 is returned, because get() checks only whether the key itself exists in the dictionary — since 'volume' is present with the stored value 0, that value is returned instead of the default 50
Explanation0 is returned. dict.get(key, default) works by checking whether the given key exists in the dictionary — it does not care whether the value stored at that key is truthy or falsy. Here, 'volume' is a key that exists in settings, mapped to the value 0, so get() returns that stored value directly and the default 50 is never used; the default only kicks in when the requested key is genuinely absent. The keys 'brightness' and 'sound' are irrelevant to this call — get() performs a direct hash-based lookup on the exact key name passed to it ('volume'), not a scan through the dictionary in order, so their values (None and False) never enter the picture. The deeper point is that Python's falsy values — 0, None, False, empty strings, and empty containers — are a separate concept from key presence; confusing "the value looks empty" with "the key is missing" is a common bug source when using get() for defensive programming.
Question 106 · string operations · hard
Given: 'text = 'Hello World'; index = text.find('World'); result = index'. What is result, and how does find() let you search a string without writing a manual loop?
The result is 6 because find() returns the starting index of the substring, so it locates the match without needing an explicit character-by-character loop
The result is 5 because find() counts word positions instead of character indices
The result is -1 because find() only works when the search term matches the very first characters of the string
The result raises TypeError because strings don't support find()
Answer: A. The result is 6 because find() returns the starting index of the substring, so it locates the match without needing an explicit character-by-character loop
ExplanationCounting characters from index 0: H=0, e=1, l=2, l=3, o=4, space=5, W=6 — so 'World' begins at index 6, making result equal to 6. find() scans the string internally and returns the starting index of the first match (or -1 if the substring isn't present), which is exactly why it saves you from writing a manual loop to search character by character.
Question 107 · recursion · hard
Consider the following Python code:
```python
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
result = factorial(4)
```
What is the value of `result`, and how does the recursion decompose the factorial calculation?
The result is 24, because factorial(4) = 4 * factorial(3) = 4 * 3 * factorial(2) = 4 * 3 * 2 * factorial(1) = 4 * 3 * 2 * 1 = 24, unwinding through 4 recursive calls before the base case n <= 1 stops the recursion
The result is 4, because recursion only uses the input parameter and never applies the multiplication
The result raises a RecursionError, because Python's call stack cannot handle a function calling itself more than three times
The result is undefined, because the base case n <= 1 is never checked until after the multiplication n * factorial(n - 1) completes
Answer: A. The result is 24, because factorial(4) = 4 * factorial(3) = 4 * 3 * factorial(2) = 4 * 3 * 2 * factorial(1) = 4 * 3 * 2 * 1 = 24, unwinding through 4 recursive calls before the base case n <= 1 stops the recursion
ExplanationThe result is 24. Tracing the recursion: factorial(4) calls factorial(3), which calls factorial(2), which calls factorial(1); factorial(1) hits the base case (n <= 1) and returns 1 immediately, with no further recursive call. The stack then unwinds: factorial(2) returns 2 * 1 = 2, factorial(3) returns 3 * 2 = 6, and factorial(4) returns 4 * 6 = 24. This uses only 4 levels of recursion, far below Python's default recursion limit of 1000, so no RecursionError occurs. The base case is also checked and reached before any multiplication takes place, not after — each call must finish evaluating n <= 1 first, and only the calls where that is false proceed to compute n * factorial(n - 1).
Question 108 · data structures · hard
Consider this Python code:
```python
from collections import deque
queue = deque()
queue.append("A")
queue.append("B")
queue.append("C")
queue.popleft()
queue.append("D")
result = list(queue)
```
What is the value of result, and why does using popleft() instead of pop() preserve the queue's first-in-first-out order?
The queue ends as ['B', 'C', 'D'] since popleft() removed the oldest entry 'A' from the front, so the FIFO order preserves the arrival sequence of the remaining letters
Removing from the back instead of the front means popleft() actually deletes 'D', leaving the queue as ['A', 'B', 'C'], since deques behave like stacks by default
Since popleft() only peeks at the front value without deleting it, all four letters stay queued, giving ['A', 'B', 'C', 'D'] as the final result
After the front element is removed, deque automatically reverses the remaining order to fill the gap, producing ['C', 'B', 'D'] as the result
Answer: A. The queue ends as ['B', 'C', 'D'] since popleft() removed the oldest entry 'A' from the front, so the FIFO order preserves the arrival sequence of the remaining letters
ExplanationTracing the operations one at a time: after the three append() calls the deque holds ['A', 'B', 'C'] in arrival order. Calling popleft() removes and discards the front element, 'A', leaving ['B', 'C']. Appending 'D' then adds to the back, giving ['B', 'C', 'D'], which is exactly what list(queue) returns. This demonstrates FIFO (first-in-first-out): the earliest-arrived item is always the one removed next, unlike a stack's pop() which removes from the back and behaves as LIFO (last-in-first-out). A common mix-up is treating popleft() as a back-removal operation or as a non-destructive peek, but deque.popleft() always removes and returns the leftmost element in O(1) time — which is why deque, not a plain list, is the standard choice for efficient queue implementations.
Question 109 · JSON serialization · hard
Consider this Python code:
```python
import json
record = {"name": "Asha", "marks": (88, 92, 79), "passed": True}
output = json.dumps(record)
```
What does `output` contain, and why does JSON represent the tuple and the boolean the way it does?
The output is the JSON string {"name": "Asha", "marks": [88, 92, 79], "passed": true} — json.dumps() converts the Python tuple into a JSON array because JSON has no tuple type, and converts True into JSON's lowercase boolean true.
Serialization raises a TypeError before producing any output, because json.dumps() can only convert dictionaries and lists, and a tuple is not among the types it knows how to serialize.
Only the outer dictionary syntax changes, leaving output as {"name": "Asha", "marks": (88, 92, 79), "passed": True}, since json.dumps() rewrites the top-level braces but leaves nested Python values like tuples and booleans in their original syntax.
JSON has no boolean type at all in this conversion, so output becomes {"name": "Asha", "marks": [88, 92, 79], "passed": 1}, because json.dumps() converts True to the integer 1 to stay compatible.
Answer: A. The output is the JSON string {"name": "Asha", "marks": [88, 92, 79], "passed": true} — json.dumps() converts the Python tuple into a JSON array because JSON has no tuple type, and converts True into JSON's lowercase boolean true.
Explanationjson.dumps() walks the dictionary and converts each value to its nearest JSON equivalent, not just the outer braces. A Python tuple has no matching JSON type, so (88, 92, 79) is written out as the JSON array [88, 92, 79] — tuples are perfectly serializable, json.dumps() only refuses types with no defined conversion, such as sets or custom class instances, so no TypeError occurs here. Python's True does have a JSON counterpart, the boolean literal true, but JSON keywords are lowercase, so True becomes true, not True and not the integer 1. Dictionary keys keep their insertion order during serialization, so the complete result is the string {"name": "Asha", "marks": [88, 92, 79], "passed": true}.
Question 110 · heap operations · hard
A min-heap is built by inserting values one at a time into an initially empty array, using the standard sift-up (bubble-up) rule: after appending a new value at the end of the array, repeatedly swap it with its parent — located at index floor((i-1)/2) for a node at index i — until the value is no longer smaller than its parent, or it becomes the root. Starting from an empty heap, you insert the sequence 12, 5, 18, 3, 9 in that order, with each insertion (append + sift-up) fully completed before the next begins. What is the resulting array representation of the heap, and how many parent-child swaps occur in total across all five insertions?
Stopping the sift-up after the very first swap gives the array [5, 3, 18, 12, 9], using only 2 total swaps across the five insertions.
Since a min-heap always keeps every element in fully sorted order, the final array must be [3, 5, 9, 12, 18] after all five insertions.
Sifting each new value all the way up through its actual parent chain gives the final array [3, 5, 18, 12, 9], using 3 total swaps across the five insertions.
Comparing each newly inserted value directly against the root rather than its immediate parent produces the final array [3, 12, 18, 5, 9], using 2 total swaps.
Answer: C. Sifting each new value all the way up through its actual parent chain gives the final array [3, 5, 18, 12, 9], using 3 total swaps across the five insertions.
ExplanationTracing each insertion step by step: inserting 12 gives [12] (no parent, no swap). Inserting 5 appends it at index 1; its parent at index 0 is 12, and since 5 < 12 they swap, giving [5, 12] after 1 swap. Inserting 18 appends it at index 2; its parent at index 0 is 5, and since 18 is not smaller than 5, no swap happens, leaving [5, 12, 18]. Inserting 3 appends it at index 3; its parent at index 1 is 12, and since 3 < 12 they swap to [5, 3, 18, 12] — but the sift-up isn't finished, because 3 is now at index 1, whose parent at index 0 is 5, and since 3 < 5 they swap again, giving [3, 5, 18, 12] after 2 swaps for this insertion. Inserting 9 appends it at index 4; its parent at index 1 is 5, and since 9 is not smaller than 5, no swap happens, leaving the final array [3, 5, 18, 12, 9]. Adding up the swaps (0 + 1 + 0 + 2 + 0) gives 3 total swaps, matching the array produced by walking the value up through its actual parent chain one level at a time. The claim that stopping after one swap for the insertion of 3 gives [5, 3, 18, 12, 9] misses that a sifted value must keep checking against each ancestor up to the root, not just its immediate parent at the level where it first stopped. The claim that the heap stays fully sorted as [3, 5, 9, 12, 18] confuses a min-heap, which only guarantees each parent is smaller than its own children, with a sorted array, where every element is smaller than everything to its right — a heap enforces no ordering between sibling subtrees. The claim that comparing 3 directly against the root produces [3, 12, 18, 5, 9] describes a different, incorrect algorithm — sift-up must move the value one level at a time, testing against its true parent at each step, never jumping straight to a comparison with the root.
Question 111 · decorator pattern · hard
A student decorates a recursive factorial function with a call-counting decorator:
def counter(fn):
calls = 0
def wrap(*a):
nonlocal calls
calls += 1
print(f"call #{calls}")
return fn(*a)
return wrap
@counter
def fact(n):
return 1 if n <= 1 else n * fact(n - 1)
fact(4)
How many times does "call #..." get printed, and why?
Once — the @counter decorator only wraps the single outermost call to fact(4); the recursive calls inside fact's own body run the plain, undecorated function.
Four times — because @counter rebinds the name fact (globally) to wrap, so every recursive call fact(n-1) inside fact's body actually calls wrap, re-triggering the counting logic at each of the 4 calls down to the base case.
Zero times — nonlocal calls resets to 0 on every recursive invocation of wrap, so the increment before the print never takes effect and the print statement is unreachable.
It causes infinite recursion and a stack overflow, because each call to fact(n-1) wraps fn in a new layer of wrap around wrap, growing the call stack without bound.
Answer: B. Four times — because @counter rebinds the name fact (globally) to wrap, so every recursive call fact(n-1) inside fact's body actually calls wrap, re-triggering the counting logic at each of the 4 calls down to the base case.
ExplanationDecorating fact with @counter executes fact = counter(fact), which rebinds the global name fact to wrap. Inside fact's own body, the call fact(n-1) looks up the name fact at call time (not at definition time) — and that name now points to wrap, not the original undecorated function. So calling fact(4) actually invokes wrap(4): calls becomes 1, it prints "call #1", then it runs the original body, which computes 4 * fact(3). That fact(3) again resolves to wrap, printing "call #2"; then fact(2) -> "call #3"; then fact(1) -> "call #4", where n<=1 stops further recursion. So "call #" prints exactly 4 times — once for each call from fact(4) down through fact(1) — even though the decorator appears in the source only once. The final return value is unaffected: fact(4) still correctly evaluates to 24, since every wrap layer just forwards to fn(*a) and returns its result unchanged. What multiplies is the decorator's side effect (the print/count), not the arithmetic. This is the classic pitfall of decorating a recursive function in Python: because name resolution happens at call time, every recursive self-call is silently routed back through the decorator.
Question 112 · itertools combinations · hard
A student writes this code: `from itertools import combinations; result = list(combinations([10, 20, 30, 40], 3))`. How many tuples does `result` contain, and what is the first tuple produced?
result has 4 tuples, and the first tuple is (10, 20, 30) — combinations() picks index positions in order (0,1,2), (0,1,3), (0,2,3), (1,2,3), giving C(4,3) = 4!/(3!·1!) = 4 groups
24 tuples appear in result, since combinations(n, r) = n!/(n-r)! = 4!/1! = 24, because rearranging the same 3 elements in a different order counts as a new tuple
6 tuples are produced in result, because the formula used is C(4,2) = 4!/(2!·2!) = 6, mistakenly selecting 2 elements at a time instead of 3
The result contains 4 tuples, but the first tuple is (20, 30, 40), since combinations() builds each group by leaving out one element starting from the front of the list
Answer: A. result has 4 tuples, and the first tuple is (10, 20, 30) — combinations() picks index positions in order (0,1,2), (0,1,3), (0,2,3), (1,2,3), giving C(4,3) = 4!/(3!·1!) = 4 groups
Explanationitertools.combinations(iterable, r) selects elements by their index positions in the original order, never rearranging or reordering them, and never repeating an index within one tuple. For [10, 20, 30, 40] with r=3, the index combinations in lexicographic order are (0,1,2), (0,1,3), (0,2,3), (1,2,3) — exactly C(4,3) = 4!/(3!·1!) = (4·3·2·1)/((3·2·1)·1) = 4 tuples. Mapping indices back to values gives (10,20,30), (10,20,40), (10,30,40), (20,30,40), so the very first tuple produced is (10, 20, 30). The choice claiming 24 tuples confuses combinations with permutations: permutations([10,20,30,40], 3) treats order as significant and does give P(4,3) = 4!/1! = 24 results, but combinations() ignores order entirely, so 24 is wrong here. The choice claiming 6 tuples applies the wrong r, computing C(4,2) = 6 instead of C(4,3) = 4 — the code clearly requests r=3, not r=2. The choice claiming the first tuple is (20, 30, 40) gets the correct count (4 tuples) but the wrong order: combinations() does not build groups by dropping the first element first: it advances the last index before backtracking, so the smallest elements (10,20,30) appear first, not (20,30,40).</explanation>
Question 113 · heap operations · hard
You insert the values 5, 3, 8, 1, 9, 2 one at a time into an initially empty min-heap stored as a 0-indexed array. After every single insertion you run sift-up (bubble up): compare the newly placed element with its parent at index floor((i-1)/2), and if the child is smaller, swap them and repeat the comparison one level higher, stopping as soon as the child is no longer smaller than its parent (or it reaches the root). What is the array representation of the heap after all six insertions are complete?
[1, 3, 2, 5, 9, 8] — after 2 is appended at the end, it is smaller than its parent 8, so they swap, and the swap chain stops there because 2 is not smaller than the new parent 1 at the root
[1, 2, 3, 5, 8, 9] — a min-heap array is always the fully sorted sequence of its elements, so the final array lists all six values from smallest to largest
[5, 3, 8, 1, 9, 2] — sift-up is only needed when an element is removed from the heap, so every value simply stays at the array position where it was first appended
[2, 3, 1, 5, 9, 8] — once 2 is smaller than its parent 8 it keeps swapping upward through every remaining ancestor regardless of the comparison, so it swaps again with the root 1 and ends up on top
Answer: A. [1, 3, 2, 5, 9, 8] — after 2 is appended at the end, it is smaller than its parent 8, so they swap, and the swap chain stops there because 2 is not smaller than the new parent 1 at the root
ExplanationTrace it step by step, appending each value and sifting it up to its correct position: insert 5 -> [5]; insert 3 -> append gives [5,3], index1's parent is index0 (5), and 3<5 so swap -> [3,5]; insert 8 -> append gives [3,5,8], index2's parent is index0 (3), and 8 is not less than 3 so no swap -> [3,5,8]; insert 1 -> append gives [3,5,8,1], index3's parent is index1 (5), 1<5 so swap -> [3,1,8,5], then index1's parent is index0 (3), 1<3 so swap again -> [1,3,8,5]; insert 9 -> append gives [1,3,8,5,9], index4's parent is index1 (3), 9 is not less than 3 so no swap -> [1,3,8,5,9]; insert 2 -> append gives [1,3,8,5,9,2], index5's parent is index2 (8), 2<8 so swap -> [1,3,2,5,9,8], then index2's parent is index0 (1), 2 is not less than 1 so the sift-up stops. The final array is [1, 3, 2, 5, 9, 8]. The claim that the array is fully sorted low to high confuses a min-heap (which only guarantees each parent is smaller than its children) with a fully sorted array — a heap only needs to be sorted along root-to-leaf paths, not level by level. The claim that every value simply stays where it was first appended forgets that sift-up must run after every insertion to restore the heap property, not just before a removal. The claim that 2 keeps swapping upward through every remaining ancestor regardless of the comparison misapplies sift-up by ignoring its stopping condition: the swap chain halts the moment the child is no longer smaller than its parent, so 2 never swaps with the root 1 once it lands correctly at index 2.
Question 114 · decorator pattern · hard
A teacher demonstrates the decorator pattern in Python with this code:
```python
def shout(fn):
def wrapper(*args):
result = fn(*args)
return result.upper()
return wrapper
@shout
def greet(name):
return f"hello {name}"
```
What does print(greet("aditi")) output?
None, because wrapper() calls fn(*args) but never sends the returned value back through greet()
hello aditi, because decorators like @shout only add extra behaviour such as logging and cannot change what a function returns
HELLO ADITI, because wrapper() captures fn("aditi")'s return value in result and then returns result.upper() as the final output of greet("aditi")
A TypeError, because wrapper(*args) cannot correctly forward a named parameter like name to the original greet function
Answer: C. HELLO ADITI, because wrapper() captures fn("aditi")'s return value in result and then returns result.upper() as the final output of greet("aditi")
ExplanationWhen @shout decorates greet, the name greet is rebound to point at wrapper instead of the original function. So calling greet("aditi") actually calls wrapper("aditi"), where args becomes ("aditi",). Inside wrapper, the line result = fn(*args) calls the original (undecorated) greet("aditi"), which returns the string "hello aditi" and stores it in result. Wrapper then runs return result.upper(), turning "hello aditi" into "HELLO ADITI" — and this uppercase string is what flows back out of greet("aditi") to print(), so the output is HELLO ADITI. The claim that the output is None is wrong because it ignores that wrapper explicitly returns result.upper() rather than discarding it. The claim that the output stays "hello aditi" is wrong because it wrongly assumes a decorator can only add side-effect behaviour (like logging) around a call and can never touch the return value — in fact wrapper is free to transform result however it wants before handing it back. The claim that a TypeError occurs is wrong because *args collects positional arguments regardless of what the original function called its parameter (name); a decorator does not need to know the original parameter names to forward arguments correctly.
Question 115 · context manager protocol · hard
Trace the execution order of this Python context-manager code:
class A:
def __enter__(self):
print("A enter")
return self
def __exit__(self, *args):
print("A exit")
class B:
def __enter__(self):
print("B enter")
return self
def __exit__(self, *args):
print("B exit")
with A(), B():
print("body")
What is printed, in order?
The output is A enter, B enter, body, A exit, B exit, because each context manager's __exit__ runs in the same order its __enter__ was called.
The output is A enter, B enter, body, B exit, A exit, because __exit__ methods run in reverse order of entry — the last context manager entered is the first one exited.
The output is A enter, A exit, B enter, B exit, body, because Python fully sets up and tears down each context manager one at a time before starting the next.
The output is B enter, A enter, body, A exit, B exit, because Python evaluates the context managers listed in a with statement from right to left.
Answer: B. The output is A enter, B enter, body, B exit, A exit, because __exit__ methods run in reverse order of entry — the last context manager entered is the first one exited.
ExplanationA `with A(), B():` statement is equivalent to nesting them: `with A(): with B():`. So __enter__ calls happen in the order written — A enter, then B enter — and the body runs, printing "body". When the block ends, Python unwinds the context managers like nested function calls: the most recently entered one is exited first, so B's __exit__ runs before A's. That gives the sequence A enter, B enter, body, B exit, A exit. This LIFO (last-in, first-out) exit order is a defining property of how nested/multiple context managers work in Python, analogous to how nested try/finally blocks unwind from the inside out.
Question 116 · itertools combinations · hard
You run this Python code:
from itertools import combinations
result = list(combinations('PQRST', 3))
How many 3-element tuples are in `result`, and why?
10 tuples, because combinations() picks 3 elements out of 5 without regard to order, giving C(5,3) = 5!/(3!*2!) = 10
60 tuples, because combinations() treats different orderings of the same 3 letters as separate results, matching P(5,3) = 5!/2! = 60
125 tuples, because each of the 3 output positions can independently be any of the 5 letters, giving 5^3 = 125
5 tuples, because combinations() returns only one group for each starting letter, so the count simply matches the number of input letters
Answer: A. 10 tuples, because combinations() picks 3 elements out of 5 without regard to order, giving C(5,3) = 5!/(3!*2!) = 10
Explanationitertools.combinations('PQRST', 3) generates every 3-element subset of the 5 letters without regard to order and without repeating any element, so the count is C(5,3) = 5!/(3!*(5-3)!) = 120/(6*2) = 10. Written out, result is exactly: ('P','Q','R'), ('P','Q','S'), ('P','Q','T'), ('P','R','S'), ('P','R','T'), ('P','S','T'), ('Q','R','S'), ('Q','R','T'), ('Q','S','T'), ('R','S','T') — 10 tuples in total, each listed only once regardless of letter order. Option B applies the permutations formula instead: P(5,3) = 5!/2! = 60 counts ('P','Q','R') and ('P','R','Q') as different results, which is what itertools.permutations does, not combinations. Option C's 5^3 = 125 would be the count if repeats were allowed and order mattered (like itertools.product('PQRST', repeat=3)), which is a different operation entirely. Option D confuses combinations with a simpler one-group-per-letter scheme; it ignores that each starting letter actually pairs with multiple later letters (e.g., 'P' alone starts 6 different triples: PQR, PQS, PQT, PRS, PRT, PST).
Question 117 · heap operations · hard
A min-heap is built from scratch by inserting six values, one at a time in this exact order, using the standard array-based (0-indexed) insertion algorithm: append the new value at the end of the array, then repeatedly swap it with its parent at index (i-1)//2 as long as it is smaller than that parent. The insertion order is 5, 3, 8, 1, 9, 2. What is the array representation of the min-heap after all six insertions are complete?
The final heap array is [1, 3, 2, 5, 9, 8], reached because the last inserted value 2 sifts past index 2 (value 8) but stops below the root 1.
Using the parent formula index divided by 2 instead of the correct (index minus 1) divided by 2 yields the array [1, 2, 3, 5, 9, 8] after the same six insertions.
Skipping the sift-up step for the sixth insertion leaves the array as [1, 3, 8, 5, 9, 2], which is not actually a valid min-heap.
Applying max-heap sift-up logic instead of min-heap logic to the same insertion sequence produces the array [9, 8, 5, 1, 3, 2].
Answer: A. The final heap array is [1, 3, 2, 5, 9, 8], reached because the last inserted value 2 sifts past index 2 (value 8) but stops below the root 1.
ExplanationTrace the insertions with parent index (i-1)//2, swapping while the new value is smaller than its parent. Inserting 5 gives [5]. Inserting 3 appends to [5,3]; since 3 is smaller than parent 5, it swaps to [3,5]. Inserting 8 appends to [3,5,8]; 8 is not smaller than parent 3, so no swap. Inserting 1 appends to [3,5,8,1]; 1 is smaller than parent 5, swap to [3,1,8,5], then 1 is smaller than parent 3, swap again to [1,3,8,5]. Inserting 9 appends to [1,3,8,5,9]; 9 is not smaller than parent 3, so no swap. Inserting 2 appends to [1,3,8,5,9,2]; 2 is smaller than its parent 8 (at index 2), so they swap to [1,3,2,5,9,8]; 2 is then compared with the root 1 and is not smaller, so sifting stops. The final array [1, 3, 2, 5, 9, 8] satisfies the min-heap property at every level: root 1 is smaller than its children 3 and 2, node 3 is smaller than its children 5 and 9, and node 2 is smaller than its child 8. Using the wrong parent formula i//2 instead of (i-1)//2 would incorrectly swap 2 past 3 as well, giving [1, 2, 3, 5, 9, 8]. Stopping the algorithm before the last sift-up would leave [1, 3, 8, 5, 9, 2], which violates the heap property because 8 sits above the smaller value 2. Applying max-heap logic, where a child swaps up whenever it is larger than its parent, produces an entirely different array, [9, 8, 5, 1, 3, 2], with the largest value at the root.
Question 118 · decorator pattern · hard
A counting decorator tracks how many times a function has been called, using a list created inside the decorator so its state survives across calls via closure:
```python
def counter(fn):
calls = [0]
def wrapper(*args):
calls[0] += 1
print(f"Call #{calls[0]}")
return fn(*args)
return wrapper
@counter
def square(x):
return x * x
square(3)
square(5)
print(square(2))
```
What exact output does this program print to the console, in order?
Call #1, Call #2, Call #3, then 4 — because the calls list is created only once, when @counter wraps square, so it persists across all three calls, and the final print() displays square(2)'s return value only after wrapper has already incremented and printed the count.
Call #1, Call #1, Call #1, then 4 — because each call to square secretly re-runs counter(fn) from scratch, which resets calls back to [0] every single time square is invoked.
Call #1, then 4 — because square(3) and square(5) are never passed to print(), so wrapper's increment and its internal print statement never actually execute for those two calls.
Call #0, Call #1, Call #2, then 4 — because the print(f"Call #{calls[0]}") statement runs before calls[0] += 1 has updated the counter, so each call displays the count from before it was incremented.
Answer: A. Call #1, Call #2, Call #3, then 4 — because the calls list is created only once, when @counter wraps square, so it persists across all three calls, and the final print() displays square(2)'s return value only after wrapper has already incremented and printed the count.
Explanationcounter(square) runs exactly once, at the moment @counter is applied — this single call creates one calls = [0] list that lives in the closure shared by every future call to square; it is never recreated. square(3): wrapper increments calls[0] to 1, prints "Call #1", computes 3*3=9 and returns it (discarded, since it isn't wrapped in print()). square(5): same shared list, calls[0] becomes 2, prints "Call #2", returns 25 (discarded). print(square(2)): Python must fully evaluate square(2) before print() can run on its result — wrapper increments calls[0] to 3, prints "Call #3", computes 2*2=4, and returns 4; only then does the outer print() display that returned value, 4, on its own line. So the console shows, in order: Call #1, Call #2, Call #3, 4 — matching the choice that keeps one shared, ever-incrementing counter across all three calls. The claim that counter(fn) secretly re-runs on every call wrongly assumes the decorator re-initializes its state each time, which would defeat the entire purpose of using a closure for persistent state. The claim that square(3) and square(5) never execute their internal print confuses "the return value is printed" with "the function executed" — wrapper's own print("Call #...") runs on every call regardless of what the caller does with the return value. The claim that each printed count reflects the value before incrementing misorders the two statements inside wrapper: the increment (calls[0] += 1) executes before the print, so every printed count reflects the already-updated value, never the count from before that call.
Question 119 · context manager protocol · hard
Consider this Python context manager:
```python
class Resource:
def __enter__(self):
print("open")
return self
def __exit__(self, exc_type, exc_val, tb):
print("close")
return True
with Resource() as r:
print("before")
raise ValueError("boom")
print("after")
print("done")
```
What gets printed when this code runs, and why?
open, before, close, done — __exit__ is invoked as soon as the ValueError propagates out of the with-block (so "after" never runs), and since __exit__ returns True, the exception is suppressed and execution continues normally with the print("done") after the with statement
open, before, after, close, done — Python always finishes executing every remaining line inside the with-block before calling __exit__, so the raised exception is queued until the block completes naturally
open, before, close — then the program crashes with an unhandled ValueError, because __exit__ only suppresses an exception when it returns None, and returning True has no effect on exception propagation
open, close, done — __enter__ and __exit__ always execute back-to-back as a matched pair before the with-block body runs, so "before" and the raise never actually execute
Answer: A. open, before, close, done — __exit__ is invoked as soon as the ValueError propagates out of the with-block (so "after" never runs), and since __exit__ returns True, the exception is suppressed and execution continues normally with the print("done") after the with statement
ExplanationTrace it step by step. __enter__ runs first, printing "open", and returns self as r. Inside the with-block, print("before") runs, printing "before". Then raise ValueError("boom") executes — this immediately transfers control out of the block, so the line print("after") is skipped entirely; it never runs. Because an exception is propagating, Python calls __exit__(exc_type, exc_val, tb) with the ValueError's details filled in. __exit__ prints "close" and then returns True. In the context manager protocol, __exit__'s return value controls what happens to the exception: a truthy return value (like True) tells Python to suppress it — treat it as handled — while a falsy return value (False, None, or no explicit return) lets it propagate normally. Since True was returned here, the ValueError is swallowed, and execution resumes right after the with statement, printing "done". Final output: open, before, close, done. This is exactly why guaranteed-cleanup behavior (close() always running) is separate from exception-suppression behavior (controlled solely by __exit__'s return value) — mixing these two ideas up is the most common error when reasoning about context managers.
Question 120 · itertools combinations · hard
A student runs this Python code: `from itertools import combinations; result = list(combinations(range(5), 3))`. Using the formula C(n,r) = n! / (r!(n-r)!) for n=5, r=3, which statement correctly describes `result`?
result contains 60 tuples, because combinations() treats (0,1,2) and (1,0,2) as different orderings — the same logic that gives permutations(range(5),3) its 5!/(5-3)! = 60 results
result contains 5 tuples, one for each starting number in range(5), because combinations() only groups each element with its two immediate neighbours
result contains exactly 10 tuples, each a strictly increasing 3-element tuple of distinct numbers from range(5) — e.g. (0,1,2) through (2,3,4) — with no combination repeated in a different order
result contains 10 tuples, but elements can repeat within a tuple, such as (0,0,1), since combinations() does not enforce distinctness by default
Answer: C. result contains exactly 10 tuples, each a strictly increasing 3-element tuple of distinct numbers from range(5) — e.g. (0,1,2) through (2,3,4) — with no combination repeated in a different order
ExplanationC(5,3) = 5! / (3! × 2!) = 120 / (6 × 2) = 10, so len(result) = 10. Working it by hand, itertools.combinations(range(5), 3) enumerates every 3-element subset of {0,1,2,3,4} in lexicographic order, each written with indices strictly increasing left to right: (0,1,2), (0,1,3), (0,1,4), (0,2,3), (0,2,4), (0,3,4), (1,2,3), (1,2,4), (1,3,4), (2,3,4) — that's exactly 10 tuples, matching the formula. Two key properties follow directly from how combinations() is defined: (1) it never repeats an element within a tuple (it picks each index at most once, so something like (0,0,1) can never appear — that's what combinations_with_replacement would do instead), and (2) it never outputs the same set of elements twice in a different order (it only advances indices forward, so (1,0,2) is never generated separately from (0,1,2) — that behavior belongs to permutations, not combinations). The 60-tuple option is the permutations count P(5,3) = 5!/(5-3)! = 60, a common mix-up between the two functions. The 5-tuple option invents a "neighbours only" rule that doesn't match how combinations() actually works — it considers every subset of size r, not just adjacent elements.