Python Coding and Data Structures Questions
Proficiency in Python, including arrays, dictionaries, linked lists, and basic algorithms. Ability to write efficient, clean code under time pressure. Understanding of time/space complexity and optimization.
HardTechnical
25 practiced
Design and implement a memory-efficient algorithm in Python to compute the top-k most frequent n-grams from a corpus stored on disk that is much larger than available RAM. Describe streaming extraction, approximate counting methods (Count-Min Sketch, lossy counting), and how to merge partial results when processing shards in parallel.
Sample Answer
Approach (summary)- Stream the corpus: read file(s) line-by-line, tokenize, produce n-grams on the fly to avoid full-file in-memory.- Use approximate counting to be memory-efficient: Count-Min Sketch (CMS) for frequency estimates and/or Lossy Counting / Space-Saving (frequent items) for stronger heavy-hitter guarantees.- Process shards (files or byte-ranges) in parallel: each worker builds a local CMS + Space-Saving top-candidates structure. Merge by summing CMS arrays and merging Space-Saving counters. Finally, compute top-k by querying merged CMS for candidate set from merged Space-Saving (or union of local candidate keys) and selecting highest estimates with a min-heap.Why CMS + Space-Saving- CMS: fixed small memory, sub-linear, mergeable (component-wise add). Provides epsilon error with probability delta.- Space-Saving / Lossy Counting: maintains explicit top-M candidate keys and exact counts for them; merging several small Space-Saving sketches is possible (merge counts then re-run space-saving pruning).- Combining: Space-Saving provides candidate set; CMS provides robust frequency estimate for candidates across shards.Python implementation (streaming, CMS, space-saving, merge, top-k)Key points and trade-offs- Memory: CMS size = O(1/eps * log(1/delta)). Space-Saving keeps M keys. Choose eps, delta, M to fit RAM and accuracy requirements.- Parallel/shard merge: CMS arrays sum element-wise; Space-Saving merge by reinserting counts into target structure (may cause additional approximation).- Lossy Counting alternative: maintains buckets and periodically prunes low-frequency items; easier to reason about error bound but more stateful and less naturally mergeable than CMS.- Final top-k correctness: results are approximate. To reduce false positives/ordering errors, increase CMS width (lower eps) and Space-Saving M. Optionally run a second pass over corpus restricted to top candidates to get exact counts if I/O budget allows.Edge cases- Very long documents: ensure tokenization/window logic handles newlines and sentence boundaries per requirement.- Rare n-grams: CMS may overestimate; Space-Saving ensures focus on heavy hitters.- Skewed data: choose M large enough to capture all heavy hitters across shards.This design is streaming, mergeable, memory-bounded and practical for large corpora.
python
import hashlib, math, heapq
from collections import defaultdict, Counter, deque
class CountMinSketch:
def __init__(self, eps=1e-3, delta=1e-3):
self.w = int(math.ceil(math.e / eps))
self.d = int(math.ceil(math.log(1/delta)))
self.count = [ [0]*self.w for _ in range(self.d) ]
self.seed = [i*131 for i in range(self.d)]
def _hash(self, x, i):
h = int(hashlib.md5((str(self.seed[i])+x).encode()).hexdigest(),16)
return h % self.w
def add(self, x, c=1):
for i in range(self.d):
self.count[i][self._hash(x,i)] += c
def estimate(self,x):
return min(self.count[i][self._hash(x,i)] for i in range(self.d))
def merge(self, other):
assert self.d==other.d and self.w==other.w
for i in range(self.d):
for j in range(self.w):
self.count[i][j] += other.count[i][j]
class SpaceSaving:
# keeps top-M candidates with approximate counts
def __init__(self, M):
self.M = M
self.c = {} # key -> count
def add(self, key, incr=1):
if key in self.c:
self.c[key] += incr
elif len(self.c) < self.M:
self.c[key] = incr
else:
# replace min element
kmin = min(self.c, key=lambda k: self.c[k])
self.c[key] = self.c.pop(kmin) + incr
def merge(self, other):
for k,v in other.c.items():
self.add(k,v)
def ngram_stream(fileobj, n=2):
window = deque()
for line in fileobj:
tokens = line.strip().split()
for t in tokens:
window.append(t)
if len(window) > n:
window.popleft()
if len(window) == n:
yield ' '.join(window)
# reset across lines if desired:
window.clear()
def process_shard(path, n=2, cms_params=(1e-3,1e-3), topM=1000):
cms = CountMinSketch(*cms_params)
ss = SpaceSaving(topM)
with open(path, 'r', encoding='utf8') as f:
for ng in ngram_stream(f, n=n):
cms.add(ng)
ss.add(ng)
return cms, ss
def merge_results(list_of_pairs):
cms0, ss0 = list_of_pairs[0][0], list_of_pairs[0][1]
for cms, ss in list_of_pairs[1:]:
cms0.merge(cms)
ss0.merge(ss)
return cms0, ss0
def top_k_from_merged(cms, ss, k):
# candidate keys from SpaceSaving
heap = []
for key in ss.c.keys():
est = cms.estimate(key)
if len(heap) < k:
heapq.heappush(heap, (est, key))
else:
if est > heap[0][0]:
heapq.heapreplace(heap, (est, key))
return sorted(heap, reverse=True)
# Example usage: process shards in parallel (e.g., multiprocessing) returning list of (cms,ss) pairs,
# then call merge_results and top_k_from_merged on merged objects.HardTechnical
26 practiced
Implement in Python a memory- and compute-efficient function to compute pairwise Euclidean distances between N high-dimensional points where N is large but each point fits in memory. Your function should support chunked processing, optional multiprocessing, and use symmetric property to reduce work. Provide code and discuss numerical stability and precision choices (float32 vs float64).
Sample Answer
Approach: compute squared Euclidean distances via ||x-y||^2 = ||x||^2 + ||y||^2 - 2 x·y to leverage fast BLAS (matrix multiply). Process rows in chunks and only compute upper triangle (i<=j) then mirror to exploit symmetry. Optionally parallelize independent chunk-pair computations.Key points and trade-offs:- Complexity: time O(N^2 * D) dominated by matrix multiplies; memory O(N^2) for full output. If full matrix doesn't fit, write chunk results to disk or compute only required pairs.- Chunking reduces peak intermediate memory and improves cache/BLAS efficiency.- Multiprocessing: best when CPU-bound and BLAS is single-threaded; if NumPy uses multithreaded BLAS (MKL/OpenBLAS), parallelism can hurt — set MKL_NUM_THREADS accordingly.Numerical stability and precision:- Use formula with dot to avoid repeated subtraction of large numbers when coordinates are large; still compute squared distances then clip negatives caused by floating error before sqrt.- float32 vs float64: float32 is faster and lower memory but can accumulate larger relative errors for high-D vectors or very small distances; prefer float64 when D is large, values vary widely, or result quality matters. Use float32 for memory-constrained or speed-critical inference where ~1e-6 relative error is acceptable.
python
import numpy as np
from multiprocessing import Pool
from functools import partial
def _chunk_pairs(n, chunk):
ranges = [(i, min(n, i+chunk)) for i in range(0, n, chunk)]
for a_idx, (ai, aj) in enumerate(ranges):
for b_idx, (bi, bj) in enumerate(ranges):
if b_idx < a_idx:
continue # only upper triangle including diagonal
yield (ai, aj, bi, bj)
def pairwise_euclidean(X, chunk=1024, workers=0, dtype=np.float32):
"""
X: (N, D) array (fits in memory)
chunk: rows per chunk
workers: 0 => no multiprocessing, >0 => number of worker processes
dtype: np.float32 or np.float64
returns: (N, N) distance matrix (dtype)
"""
X = np.asarray(X, dtype=dtype)
N = X.shape[0]
# precompute squared norms
norms = np.sum(X * X, axis=1, dtype=dtype) # shape (N,)
D = np.empty((N, N), dtype=dtype)
D.fill(0)
def process_pair(pair):
ai, aj, bi, bj = pair
A = X[ai:aj] # (a, D)
B = X[bi:bj] # (b, D)
# compute cross term using BLAS via dot
K = A.dot(B.T) # (a, b)
# broadcasting norms
sub = norms[ai:aj, None] + norms[bi:bj][None, :] - 2.0 * K
# numerical safety: clip tiny negatives to zero
np.clip(sub, 0, None, out=sub)
# sqrt to get distances
np.sqrt(sub, out=sub)
return (ai, aj, bi, bj, sub)
pairs = list(_chunk_pairs(N, chunk))
if workers and len(pairs)>0:
with Pool(workers) as p:
for ai, aj, bi, bj, sub in p.imap_unordered(process_pair, pairs):
D[ai:aj, bi:bj] = sub
if ai!=bi:
D[bi:bj, ai:aj] = sub.T
else:
for pair in pairs:
ai, aj, bi, bj, sub = process_pair(pair)
D[ai:aj, bi:bj] = sub
if ai!=bi:
D[bi:bj, ai:aj] = sub.T
return DMediumTechnical
30 practiced
Implement a memory-efficient function pairwise_cosine_similarity(A, B, batch_size=1024) in Python using NumPy that computes cosine similarity between two collections of vectors A (m x d) and B (n x d). Both m and n may be large, so your function should process data in chunks to avoid O(m*n) memory. Show code and explain numerical stability and complexity.
Sample Answer
Compute cosine similarities in chunks by normalizing vectors (with small eps) and using matrix multiply on batches so memory stays O(batch_size * d + n * d_precomputed) or O(batch_size * d) if you also batch B. Below is a memory-aware implementation that precomputes B norms and processes A in batches; it optionally also batches B if n is huge.Key points and numerical stability:- Normalize with an eps to avoid division by zero for zero vectors.- Use float64 accumulation if inputs are float32 to reduce rounding error during dot products.- Clip results to [-1,1] to correct tiny numerical overshoots.Complexity:- Time: O(m * n * d) — dominated by matrix multiplies.- Memory: If returning full matrix, output is O(m*n). Internally this function keeps O(n*d) for B_normalized and O(batch_size*d) for Ai; if n is too large, apply symmetric batching (also batch B) to keep memory ~O(batch_size_A*batch_size_B + d*(batch_size_A+batch_size_B)).
python
import numpy as np
def pairwise_cosine_similarity(A, B, batch_size=1024, eps=1e-12, dtype=None):
"""
Compute pairwise cosine similarity between A (m x d) and B (n x d) in a memory-efficient way.
Returns an (m x n) array if m*n fits memory; otherwise yields rows incrementally (generator).
By default returns full matrix; to avoid big output, iterate using yield_rows=True pattern.
"""
A = np.asarray(A)
B = np.asarray(B)
if dtype is not None:
A = A.astype(dtype, copy=False)
B = B.astype(dtype, copy=False)
m, d = A.shape
n, d2 = B.shape
if d != d2:
raise ValueError("Dimension mismatch between A and B")
# Precompute B norms in chosen dtype for stability
compute_dtype = np.float64 if A.dtype == np.float32 else A.dtype
B_ld = B.astype(compute_dtype, copy=False)
B_norms = np.linalg.norm(B_ld, axis=1)
B_norms = np.maximum(B_norms, eps) # avoid division by zero
B_normalized = (B_ld.T / B_norms).T # n x d
# Allocate output (be mindful of size; caller can set batch_size to avoid huge allocation)
result = np.empty((m, n), dtype=compute_dtype)
for i in range(0, m, batch_size):
Ai = A[i : i + batch_size].astype(compute_dtype, copy=False) # bs x d
A_norms = np.linalg.norm(Ai, axis=1)
A_norms = np.maximum(A_norms, eps)
Ai_normalized = (Ai.T / A_norms).T # bs x d
# bs x d dot d x n -> bs x n
sims = Ai_normalized.dot(B_normalized.T)
# numerical rounding can push values slightly beyond [-1,1]
np.clip(sims, -1.0, 1.0, out=sims)
result[i : i + batch_size] = sims
return resultHardSystem Design
24 practiced
Design a compact file format to store multiple large NumPy arrays and associated metadata in a single file with random access. In Python, sketch a serializer and deserializer that writes a header with offsets, allows memory-mapping of each array on demand (numpy.memmap), supports versioning, and optionally supports per-array compression. Focus on format outline and the key implementation steps.
Sample Answer
Requirements & constraints:- Store many large NumPy arrays + metadata in one file- Random access: memory-map individual arrays on demand (numpy.memmap)- Versioning and optional per-array compression- Compact header with offsets so arrays can be located without scanning fileHigh-level format (binary):- Magic (8 bytes) | Version (1 byte) | Header length (8 bytes)- Header (JSON or msgpack, compressed optional) containing list of entries: - name, dtype, shape, order, offset, length, compressed (bool), compressor, metadata (dict)- Followed by concatenated array blobs (raw bytes or compressed bytes)Serializer sketch (writer):- Reserve space for initial header pointer (write magic/version, 8-byte placeholder)- Build entries by iterating arrays: - For each array: serialize raw bytes with ndarray.tobytes(order=...), optionally compress (zstd/lz4) - Record offset = current file position, length = len(blob), dtype.str, shape, order, compressor info - Write blob to file- After blobs written, build header object (list of entries + global metadata)- Serialize header to JSON/msgpack, compute header_len, seek to header pointer location, write header_len then header bytes; or write header at end and write its offset into placeholder- Flush and closeDeserializer sketch (reader):- Open file in binary mode- Read magic/version, read header offset/length- Read header bytes, parse JSON/msgpack, validate version- To memory-map an array on demand: - If entry.compressed: need to decompress into temporary file or use streaming decompressor with mmap-like interface. For simplicity: store decompressed blob into a temp file and return numpy.memmap to that temp file; or support transparent on-the-fly access via memoryview if small. - If not compressed: use numpy.memmap(filename, dtype=entry.dtype, mode='r', offset=entry.offset, shape=entry.shape, order=entry.order)- Provide API: list(), open_array(name) -> numpy.memmap or object exposing arrayKey implementation notes:- Align array blobs to 4096 bytes to help mmap page alignment; pad as needed- Use dtype.str for endianness; version field to evolve header layout- Use msgpack for compactness and binary-safe metadata- Per-array compression: store compressor name and uncompressed byte-size; prefer block compressors (zstd, lz4)- Atomic writes: write to tmp file then rename- Concurrency: readers can mmap concurrently; writers should lock or use copy-on-write strategiesExample minimal code snippets:Trade-offs:- Storing header at end avoids precomputing offsets; requires a header pointer at fixed position- Compressed arrays break zero-copy mmap; mitigate with chunked compression or system-level FUSE to provide on-demand decompression- Using msgpack vs JSON: msgpack is more compact and handles binary safelyThis design balances compactness, random access, and practical implementation for large NumPy arrays.
python
import json, struct, numpy as np, tempfile, os
MAGIC=b'NPYBUNDLE'
VERSION=1
def write_bundle(path, arrays): # arrays: dict[name]=(ndarray, metadata, compressor=None)
with open(path+'.tmp','wb') as f:
f.write(MAGIC)
f.write(struct.pack('B', VERSION))
f.write(struct.pack('<Q', 0)) # placeholder for header offset
entries=[]
for name,(arr,meta,comp) in arrays.items():
offset=f.tell()
data=arr.tobytes(order='C')
if comp=='zstd':
import zstandard as zstd
data=zstd.ZstdCompressor().compress(data)
length=len(data)
f.write(data)
entries.append({'name':name,'dtype':arr.dtype.str,'shape':arr.shape,'order':'C',
'offset':offset,'length':length,'compressed': bool(comp),'compressor':comp,'meta':meta})
header_bytes=json.dumps({'entries':entries}).encode()
header_offset=f.tell()
f.write(header_bytes)
f.flush()
# write header_offset into placeholder
with open(path+'.tmp','r+b') as f:
f.seek(len(MAGIC)+1)
f.write(struct.pack('<Q', header_offset))
os.replace(path+'.tmp', path)python
def open_array(path, name):
import struct, json, numpy as np, tempfile
with open(path,'rb') as f:
magic=f.read(len(MAGIC)); assert magic==MAGIC
version=struct.unpack('B',f.read(1))[0]
header_off=struct.unpack('<Q',f.read(8))[0]
f.seek(header_off); header=json.load(f)
entry=next(e for e in header['entries'] if e['name']==name)
if not entry['compressed']:
return np.memmap(path, dtype=np.dtype(entry['dtype']), mode='r',
offset=entry['offset'], shape=tuple(entry['shape']), order=entry['order'])
else:
# decompress to temp file and memmap
with open(path,'rb') as f:
f.seek(entry['offset']); data=f.read(entry['length'])
if entry['compressor']=='zstd':
import zstandard as zstd
data=zstd.ZstdDecompressor().decompress(data)
tf=tempfile.NamedTemporaryFile(delete=False)
tf.write(data); tf.flush(); tf.close()
return np.memmap(tf.name, dtype=np.dtype(entry['dtype']), mode='r',
offset=0, shape=tuple(entry['shape']), order=entry['order'])MediumTechnical
26 practiced
Implement an LRUCache class in Python with O(1) get(key) and put(key, value) without using functools.lru_cache. The cache should have a capacity limit and evict the least recently used item when full. Optionally discuss thread-safety considerations if used in a model-serving process.
Sample Answer
To implement an LRU cache with O(1) get/put, use a hashmap for key→node lookup and a doubly-linked list to track recency (head = most recent, tail = least). On access move node to head; on insert, if capacity exceeded remove tail and its key from hashmap.Key points:- Time: O(1) for get and put (hashmap + constant list ops)- Space: O(capacity)- Edge cases: capacity <= 0 (should raise or treat as zero-capacity), updating existing keys, non-hashable keys- Thread-safety: In a multi-threaded model-serving process, wrap public methods with a threading.Lock (or RLock) to avoid race conditions for map/list mutation. For high concurrency, consider sharded caches or lock-free data structures, or use process-local caches plus an external consistent cache (Redis) to avoid contention.
python
class Node:
def __init__(self, k, v):
self.k = k; self.v = v
self.prev = None; self.next = None
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.map = {} # key -> Node
# dummy head/tail to simplify edge ops
self.head = Node(None, None)
self.tail = Node(None, None)
self.head.next = self.tail
self.tail.prev = self.head
# helper: remove node from list
def _remove(self, node):
p, n = node.prev, node.next
p.next = n; n.prev = p
# helper: insert node right after head (most recent)
def _add_to_head(self, node):
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, key):
node = self.map.get(key)
if not node:
return -1
# mark recently used
self._remove(node)
self._add_to_head(node)
return node.v
def put(self, key, value):
node = self.map.get(key)
if node:
node.v = value
self._remove(node)
self._add_to_head(node)
return
if len(self.map) >= self.capacity:
# evict least recently used (tail.prev)
lru = self.tail.prev
self._remove(lru)
del self.map[lru.k]
new = Node(key, value)
self.map[key] = new
self._add_to_head(new)Unlock Full Question Bank
Get access to hundreds of Python Coding and Data Structures interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.