Reddit's actual interview questions
The verbatim question bank Reddit is asking in 2025–2026, with full worked solutions. Captured from darkinterview.com and independently cross-validated against 1Point3Acres (一亩三分地) — both list the identical set, which is strong signal these are real and current. Coding and ML get full treatment; pure system-design is kept brief (you're on the ML track).
These are candidate-reported questions aggregated by darkinterview and 1Point3Acres. They are not official, and exact phrasing/follow-ups vary by interviewer. Treat them as a high-probability study set, not a guarantee. The solutions here are worked references — make sure you can derive each one cold, then push the follow-ups. Pair this with the pattern drills in Coding guide, the framework in ML system design, and the cram in ML fundamentals.
The 1p3a "面经" threads (login-walled for full text) confirm the round structure for ML roles: reports titled "Reddit Senior MLE Onsite Coding", "Senior MLE Onsite ML Coding", "L6 MLE Tech Phone Screen", "ML Infrastructure SWE Onsite", and "ML Platform role onsite" (2025–2026). The phone screen is consistently the Tennis Score Game or a tree/report-chain problem; onsites mix the coding bank below with ML fundamentals, an ML coding notebook, and an ML system design round.
A thorough pass through the 1Point3Acres (一亩三分地) Chinese forum 面经 (logged-in, May 2026): ~35 Reddit threads mapped, ~30 read in full incl. replies, spanning phone screens, full onsites, MLE/ML-infra/ML-platform/Staff-MLE/Senior-DS reports from 2021–2026. They independently and verbatim cross-validate the darkinterview set and reveal the senior/staff loop structure you won't get from a question list. Candidate-reported, points-gated, translated — high-probability intel, not gospel.
From a Staff MLE offer in the Search/Recommendations org (the candidate went to work on Reddit Answers, their RAG product), with explicit confirmation of how levels differ:
| Round | Staff MLE | Senior & below MLE |
|---|---|---|
| HM screening | ✓ resume, projects, BQ, leadership, reverse-BQ | ✓ |
| Coding | ✓ (often non-LeetCode, e.g. build a search-indexing engine) | ✓ (the OOD/LC bank below) |
| ML system design | ✓ e.g. "design a watch-next recommender for Reddit's mobile video browser" | ✓ (sometimes lighter / occasionally skipped) |
| ML Fundamentals | ✓ (woven into other rounds) | ✓ dedicated round |
| ML practitioner (train a model w/ libraries) | ✗ dropped at staff | ✓ #13 Post Click Prediction notebook |
| Cross-functional (XFN) w/ a PM | ✓ staff-only: alignment, conflict, improving product via tech | ✗ |
| Domain deep-dive (bar-raiser) w/ an L8 | ✓ staff-only: deep project dive, why-this-design, alternatives, decisions | ✗ (or lighter) |
| ML case study (modeling-only) | ✓ e.g. "design Reddit's ad-click modeling" — almost no system building, pure modeling | sometimes |
Net: as a staff/sr-staff candidate, expect the XFN round, the bar-raiser domain deep-dive with an L8, and a modeling-only ML case study on top of coding + ML-system-design + HM — and you likely skip the "train-a-classifier" notebook. Prepare to defend design decisions to a very senior engineer and to talk cross-functional leadership with a PM.
- Background-match is weighted heavily. Multiple HM rejections were "algos were positive, but background didn't fit the role." HMs explicitly value ads experience and deep-learning experience. Tailor your story to the exact org (Search/Recsys vs Ads).
- The coding bar is unusually harsh for senior. Candidates with working code still failed; "can have bugs, but not too many or too basic"; communicate constantly, take few hints. Every question has multiple levels (1–3) — manage time to reach level 3.
- Reddit reuses a question bank. One candidate's 2nd coding interviewer opened with the same question as round 1. Review every 面经 — the set below is stable.
- ML Fundamentals always probes feature selection and the overlap-distributions question (below). "Linearly separable, pick a threshold" is not enough — they want Bayes/irreducible error + "what features would reduce the overlap."
- Coding is often "traditional, no AI tools"; sometimes you must hand-parse JSON (though many interviewers allow
json.loads+ Googling syntax). CoderPad / CodeSignal.
New senior/staff questions the forum revealed (beyond the 19)
S1 · Search Indexing Engine Staff coding (non-LeetCode)
From the Staff MLE onsite. Build an in-memory search index supporting four capabilities, each a level:
- Add documents to the index (
add(doc_id, text)). - Single-word search — return doc ids containing the word.
- Multi-word search — return docs containing all the words (set intersection of postings).
- Whole-sentence (phrase) search — docs where the words appear in order, adjacent (this is the hard level; word order within the sentence matters).
Approach: inverted index word → {doc_id: sorted[positions]}. Single-word = postings lookup. Multi-word = intersect the doc-id sets. Phrase = for docs in the intersection, check that there exist positions p, p+1, p+2, … across the consecutive words (positional intersection / merge of position lists). Discuss tokenization, stop-words, and how you'd scale the index (sharding by term, compression). This is "build a tiny Lucene"; ties to the design-coding patterns + search ranking.
S2 · Page-views aggregation / Hit-Counter variant Phone · ML platform
Recurs in ML-platform and sr/staff chat-org phone screens. Given unsorted page-view data as a nested dict {date: {client: {pageViewCount, unique}}}:
- L1: given a date, return the platform with the most page-views that date.
- L2: total
pageViewCount(or most-unique platform) across the last 7 days, counted from the most recent date present — sliding window. - L3: "now it's at scale — how do you design it?" (streaming aggregation + Redis rollups + cache; the design-hit-counter LC 362 mental model).
Often paired with quick "八股" knowledge checks: GET vs POST, what's a hash table + when is it not O(1), what's XSS, "design a system to collect user click-through." Approach via hashmap/prefix patterns + caching primer.
S3 · Tennis — Best-of-5 extension · Alien Dictionary · k-means phone
- Tennis (#1) Part 2 variant: wrap the game in a
GameSetthat plays best-of-5 (first to 3 games wins); a singleplay()loop creatingGameobjects. Be ready to extend your class cleanly. - Alien Dictionary (LC 269, topological sort): a "full set" onsite used a simplified L1 ("does this list of words follow a given alphabet order? T/F") then full alien-dict L2. Pattern, not Reddit-exclusive — but it appeared. See graphs.
- ML-project + k-means phone: an MLE phone opened with "tell me about an ML project; how did you choose the number of k-means clusters?" then a Python data-munging CoderPad task (parse cloud-instance records into a nested dict). Be ready to defend modeling choices from your own work, then code clean Python.
- Intro + walk an ML project you delivered (how it shipped).
- Given X (real-valued) and Y∈{A,B}: what model, and how do you decide which model? (talk it through, ask questions.)
- Overlap-distributions (#12): X|A and X|B are two normals with means apart and an overlapping middle. "What does this plot tell you? What would you change? For points in the overlap, how do you predict?" → linear-separability + a threshold is the shallow answer; they want irreducible/Bayes error, threshold-by-cost, and "add features to reduce overlap."
- Now X has 1000 features, not 1 — "what's the same, what changes, what's new?"
- Train acc 0.91 / test 0.85 — what problem? (overfitting; remedies.)
- Design a model to predict post popularity — how? (mini system+modeling.)
Drill the full chain in ML fundamentals.
Round structure by track + org / comp intel
| Track | Reported loop |
|---|---|
| Staff MLE | HM → XFN (PM) → Domain deep-dive (L8 bar-raiser) → ML system design (watch-next rec) → coding (search-index engine) → ML case study (ad-click modeling). |
| Senior / IC3 MLE | Recruiter → ML-practitioner phone (#13 notebook) OR OOD coding (Tennis/Billing) → VO: coding (report chain/mod list) + ML Fundamentals + ML system design (Feature Store/comment ranking) + HM. |
| ML Infra / platform | Coding (LC 214 / LC 328, no AI) + ML system design (comment ranking) + Product Sense ("improve Reddit onboarding") + behavioral. Phone: hit-counter/page-views variant. |
| SWE / full-stack | HR → 1-hr phone → VO: 3 algorithm (rate limiter / mod list / report chain) + HM. Sometimes no system design. |
- Process: recruiter screen tells you exactly what each round covers; ~4-week timeline; Hiring Committee (HC) review then offer call ~1–2 days later. Small company → "team-based hiring," no formal team-match (you can pass and still not place — happened to a senior candidate who matched 4 orgs and got none).
- Orgs hiring: Search / Recommendations (openly knows its RecSys is weak, hiring senior/staff aggressively, pays up; → Reddit Answers RAG) and Ads (lots of HC; rougher tooling, more political). If your background is ranking/retrieval, target Search/Rec and mirror it in your deep-dive.
- Comp / structure: Staff MLE ~\$700K remote; Senior ~\$350–400K; IC3 ~\$245K base + \$85K RSU. RSU vests fully within year 1, quarterly; year-2+ is performance-based refresh (stable; "normal performance ≈ matches last year"). Fully remote (incl. outside the US, not China); layoff risk currently low.
- Open ML question raised in the org AMA: is Ads ranking on a DNN yet or still tree models? — be ready to discuss DNN vs GBDT for ad CTR.
Faithful translations of actual candidate 面经 (interview write-ups) from the 1Point3Acres forum, round-by-round, with outcomes. These are the real, detailed loops — not a summary. Candidate-reported and translated from Chinese; orgs and levels are as the posters stated.
EXP-1 · STAFF MLE · Search/Recommendations · OFFER the key thread
Cold/loop into the Search & Recommendations org; passed 3 days after the VO; went to work on Reddit Answers (Reddit's RAG product). This is the most complete senior/staff MLE account on the forum.
The full loop (6 rounds):
- HM screening — walk the resume, walk a project, BQ, leadership, and reverse-BQ (your questions to them).
- Cross-functional (XFN) round — with a PM. Talked through cross-functional experience: how you coordinate and align across teams, how you resolve conflict, and how you improve the product experience through technology.
- Domain deep-dive — the BAR-RAISER — with an L8 senior technical expert. They deep-dived a past project and asked in extremely granular detail ("问的巨细" — asked about every little thing). You must clearly explain: why you designed it the way you did, what other designs you considered, your decision process, and your improvement plans. This round is the level-decider.
- ML system design — "Design a watch-next recommender system for Reddit's own mobile-app video browser."
- Coding (NOT LeetCode) — write a search indexing engine meeting four requirements: (a) add documents to the index; (b) single-word search; (c) multi-word search; (d) whole-sentence (phrase) search — where the order of words within the sentence must be respected.
- ML case study — "How would you design Reddit's ad-click modeling?" Unlike round 4, this needs almost no system-building — it's pure modeling.
Level structure (verbatim from the poster): "I interviewed for staff. It seems below senior they add one more ML-practitioner round (use libraries to train a simple classification model), but they drop the domain deep-dive and the XFN round (and may not even do the system design)."
Color / tips: the interviewers were very chill and helpful — the Nordic coding interviewer spotted a typo bug and fixed it himself, saying that kind of thing shouldn't be used to trip up a candidate. Diverse panel (Taiwan, Korea, US×2, Sweden, India) — "less 卷 [grindy] than Meta/Google." Reddit openly admits its tech stack is behind big tech. Outcome: passed in 3 days → offer negotiation → Reddit Answers.
Synthesizing the staff account above plus other reports, the bar-raiser is a 45–60 min round run by a very senior engineer (L8) whose job is to find your ceiling. They pick one of your projects and drill relentlessly into the "why." Concretely, be ready for:
- Why this design? Justify every major choice (model, architecture, data store, serving path) against the constraints at the time.
- What alternatives did you consider, and why did you reject them? They want to hear 2–3 real alternatives per decision and the trade-off that killed each.
- What was your decision process? How you de-risked, what data you gathered, who you aligned with, how you knew it worked.
- What would you improve / do differently today? Honest self-critique + what changed in your engineering judgment.
- Your specific contribution vs the team's. "I" vs "we" — own the hardest 20% and be precise about it.
- Granular technical depth: loss function and why; offline vs online eval; the biggest failure/incident and the systemic fix; quantified impact.
How to win it: pick a project with multi-team scope where you personally designed the hardest part; pre-write the decision log (each decision → alternatives → why); rehearse defending it three "why?" layers deep; tie it to the target org (Search/Recsys or Ads). Full template + worked stories on the Domain deep-dive page.
EXP-2 · IC3 MLE · 4-round VO (cold apply)
- Coding — Report Chain. Interviewer first asked how you'd approach it and which data structure (dict vs list — compare and choose; they value communication). Q1: print the whole org tree. Q2: print every (manager, indirect-employee-2-levels-down) pair, e.g. A→B→C yields (A,C). Asked time complexity (O(n) — stops at level 2, no deep recursion; candidate mistakenly thought it was DFS). Got nervous in round 1.
- ML Fundamentals. Self-intro, past project (candidate's was research-leaning — felt like a weak fit). Chinese interviewer, gave feedback/hints. The overlap-distributions question: X's class-conditional distributions are two curves that overlap; candidate only said "looks linearly separable, pick a threshold in the overlap" — interviewer was NOT satisfied. Lots of the round was about how to choose features.
- Billing Status. Q1: many edge-case questions (sort by time? do the monetary columns change?). Q2: added overwrite — output was wrong, the long log was hard to debug under time pressure; interviewer hinted the bug was a point not handled in Q1 (time-sorting), fixed it. Q3: only 5 min left, just described the approach; interviewer kept affirming.
- HM. Indian interviewer; values ads-related and deep-learning experience; arrived 3 min late and ended 15 min early; broad questions, little BQ; felt disengaged.
Takeaway: every question was from the forum bank; the candidate had prepared but still felt the HM round went poorly (background-fit signal).
EXP-3 · Full set · IC3 · tech screen + 4 VO
- Tech screen: a LeetCode-style problem; finished with 10 min to spare; interviewer mentioned a follow-up but no time; passed.
- VO Coding 1 — Moderator List: a log of user add/remove mod-access. Implement
can_remove_mod(u1, u2)(an earlier-added mod outranks a later one and can remove it) andget_mod_list(). L2 adds a community column. L3 addsdemote(user)which moves a user down one position in the ordering (so user1→user2→user3, afterdemote(user1)becomes user2→user1→user3) and this changescan_remove_modresults. Hint that worked: maintain an increasing order and binary-insert on each op, or a PQ; for demote, just swap with the next element. - VO Coding 2 — Report Chain with a third part: given a person, print the chain containing them (all their managers AND all their subordinates), e.g. for d: a / ....b / ........d / ............e.
- System design: design a subreddit chatroom.
- HM: chat + BQ.
Tip (important): Reddit reuses a question bank — the coding-2 interviewer initially opened with the same question as round 1. Every question has multiple levels; manage time and aim for level 3.
EXP-4 · Full set · recruiter + tech screen + 4 VO
- Recruiter call: current job, why leaving, expectations, comp.
- Tech screen: ML modeling (the Post Click Prediction notebook).
- HM: previous ML projects; what you expect from the role; "what would you [do to] convince someone."
- VO Coding 1: a "cold/rare" Billing Status (with undo/redo edge cases discussed in the thread).
- VO Coding 2: part 1 = simplified Alien Dictionary (given a list of words, check whether they follow a given alphabet order → T/F); part 2 = full Alien Dictionary (topological sort).
- VO ML Fundamentals — the full probe chain: self-intro → walk an ML project and how it shipped → given X (real-valued) and Y∈{A,B}, what model and how do you decide → the overlap-distributions extension (two normals with overlap: what does it tell you, what would you change, how do you predict points in the overlap) → "now X has 1000 features, not 1 — what's the same, what changes, what's new?" → "train acc 0.91 / test 0.85 — what problem?" (overfitting) → "design a model to predict post popularity."
Note: HR/HM said they were actively hiring several mid-level + senior, but pulled the JD once they had enough candidates (≈1–2 HC slots left).
EXP-5 · Full onsite (Canada) · level-2, 3 YOE · REJECT — useful for the failure signals
Three big rounds; surprisingly no system design. HR → 1-hr phone (standard LeetCode-ish; passed, next-day VO invite) → VO (3 algorithm + 1 HM):
- HM: simple BQ + resume. "They care a lot about whether your past experience matches the role."
- Algo 1: the rate-limiter question.
- Algo 2: Moderator List (finished 2 of the parts).
- Algo 3: Report Chain (finished 2 of the parts).
Outcome: rejected 2 days later; HR gave feedback — VO round 1 "answers were so-so, needed hints, communication was light," and the HM thought "background didn't quite fit." Algo 2 & 3 were positive. "Bar felt pretty high; remote is great." Lesson: at Reddit's senior bar, partial solutions + light communication + a background-fit miss can sink an otherwise-positive loop.
EXP-6 · ML Infra (feature-eng) · REJECT — the Product Sense round
Traditional coding, no AI tools allowed.
- Coding 1: LC 214 Shortest Palindrome (interviewer did not require the KMP solution).
- Coding 2: LC 328 Odd Even Linked List.
- System design: design a post comment ranking system.
- Product Sense: "How would you improve Reddit's onboarding experience?"
- Behavioral.
The Product Sense round is a real surprise for an infra role — prep a metric + hypotheses + experiment, tied to ML levers.
EXP-7 · Sr/Staff phone (Chat org) · EXP-8 MLE phone · EXP-9 ML-platform phone
- Sr/Staff phone (Chat org): knowledge Qs + a CodeSignal 3-parter. (1) HTTP POST vs GET. (2) What's a hash table; when is it not O(1)? (3) How would you design a system to collect user click-through? (4) CodeSignal: JSON of
date → platform(ios/android/other) → {pageviews, uniqueviews}— Q1 given a date, the platform with most pageviews; Q2 most uniqueviews over the last 7 days (sliding window); Q3 at scale, design it (queue + cache). Relaxed and chatty; the chat team has good WLB, infra is more intense. - MLE tech phone — PASS: the Post Click Prediction notebook (hours reading A/B/C + current category → click). Jupyter, JSON→DataFrame, clean data, dummy/LR/RF/XGBoost; follow-ups on model choice, metric choice, trade-offs, "what with more time." Allowed Googling syntax; no deploy/monitor. The poster's OOP phone was the Tennis problem + "logs sorted by time" (Moderator List). Recruiter gave positive feedback. (IC2.)
- MLE phone — Tennis: the 2-player game (score, reset at tie ≥3-3 if equal, winner needs ≥5 and a 2-point lead) → Part 2 best-of-5
GameSet(first to 3 games) wrapped in a class with a singleplay()loop. - ML-platform phone: a Design-Hit-Counter variant — "return total pageViewCount across all clients in the last 7 days" from an unsorted nested dict — plus quick knowledge checks (GET vs POST, hash table, XSS).
Full problem statements with worked Python solutions and follow-ups. Reddit leans toward practical / OOD / multi-part coding (Tennis, Report Chain, Moderator List, Billing Replay) plus a few classic LeetCode (Word Search, Word Ladder, Shortest Palindrome, Odd-Even List). Drill the matching patterns in the Coding guide.
Problem Overview
Design a two-player game with three core methods:
add_score(player)get_score()get_result()
The interview then adds a follow-up: convert the score into a human-readable tennis-style format such as love, 15-30, and deuce.
In Part 1, scores are stored as raw point counts. In Part 2, those raw counts are rendered using standard tennis terminology.
This is the classic tennis scoring question with one explicit normalization rule:
- A player must lead by 2 points to win.
- If the game reaches a tie after 3-3, collapse it back to 3-3 immediately.
- 4-4 becomes 3-3.
- That means later tied states such as 5-5 never persist as observable scores.
You can assume there are exactly two players.
Part 1: Numeric Game API
Problem Statement
Implement a Game class with these methods:
class Game:
def add_score(self, player: str) -> None:
pass
def get_score(self) -> tuple[int, int]:
pass
def get_result(self) -> str | None:
pass
Requirements
- Only two players exist.
add_score(player)increments the specified player's score.get_score()returns the current numeric score for both players.get_result()returns the winner once someone is ahead by at least 2 points and has at least 4 points.- When the score becomes tied after 3-3, normalize it back to 3-3 immediately.
Example
game = Game("player1", "player2")
game.add_score("player1") # 1-0
game.add_score("player2") # 1-1
game.add_score("player1") # 2-1
game.add_score("player1") # 3-1
game.get_score() # (3, 1)
game.get_result() # None
game.add_score("player2") # 3-2
game.add_score("player2") # 3-3
game.add_score("player1") # 4-3
game.add_score("player2") # 4-4 -> normalize to 3-3
game.get_score() # (3, 3)
game.get_result() # None
Part 1 Solution
class Game:
def __init__(self, player1: str, player2: str):
self.player1 = player1
self.player2 = player2
self.scores = {
player1: 0,
player2: 0,
}
def add_score(self, player: str) -> None:
if player not in self.scores:
raise ValueError(f"Unknown player: {player}")
if self.get_result() is not None:
raise ValueError("Game already has a winner")
self.scores[player] += 1
self._normalize_deuce()
def get_score(self) -> tuple[int, int]:
return (self.scores[self.player1], self.scores[self.player2])
def get_result(self) -> str | None:
p1 = self.scores[self.player1]
p2 = self.scores[self.player2]
if max(p1, p2) >= 4 and abs(p1 - p2) >= 2:
return self.player1 if p1 > p2 else self.player2
return None
def _normalize_deuce(self) -> None:
p1 = self.scores[self.player1]
p2 = self.scores[self.player2]
if p1 >= 4 and p1 == p2:
self.scores[self.player1] = 3
self.scores[self.player2] = 3
Complexity
add_score: O(1)get_score: O(1)get_result: O(1)- Space: O(1)
Part 2: Match Wrapper + Human Score
Problem Statement
Now write a Match class that builds a Game object internally and exposes a human-readable score using tennis terms.
class Match:
def __init__(self, player1: str, player2: str):
pass
def point_won_by(self, player: str) -> None:
pass
def score(self) -> str:
pass
def result(self) -> str | None:
pass
Human Score Rules
- 0 maps to love
- 1 maps to 15
- 2 maps to 30
- 3 maps to 40
- Tied scores below 3-3 are rendered normally, such as love-love, 15-15, and 30-30
- Raw 3-3 corresponds to tennis 40-40, which is rendered as deuce
- Raw 4-3 or 3-4 is advantage <player>
- If a player wins, return winner <player>
Because tied scores above 3-3 collapse back to 3-3, deuce naturally reappears after advantage is lost.
Example
match = Match("alice", "bob")
match.point_won_by("alice")
match.score() # "15-love"
match.point_won_by("bob")
match.point_won_by("alice")
match.point_won_by("bob")
match.point_won_by("alice")
match.point_won_by("bob")
match.score() # "deuce"
match.point_won_by("alice")
match.score() # "advantage alice"
match.point_won_by("bob")
match.score() # "deuce"
match.point_won_by("bob")
match.score() # "advantage bob"
match.point_won_by("bob")
match.result() # "winner bob"
Part 2 Solution
class Match:
SCORE_LABELS = {
0: "love",
1: "15",
2: "30",
3: "40",
}
def __init__(self, player1: str, player2: str):
self.game = Game(player1, player2)
def point_won_by(self, player: str) -> None:
self.game.add_score(player)
def score(self) -> str:
winner = self.game.get_result()
if winner is not None:
return f"winner {winner}"
p1, p2 = self.game.get_score()
player1 = self.game.player1
player2 = self.game.player2
if p1 >= 3 and p2 >= 3:
if p1 == p2:
return "deuce"
leader = player1 if p1 > p2 else player2
return f"advantage {leader}"
return f"{self.SCORE_LABELS[p1]}-{self.SCORE_LABELS[p2]}"
def result(self) -> str | None:
winner = self.game.get_result()
if winner is None:
return None
return f"winner {winner}"
This is a pure design/OOP problem dressed up as scorekeeping: the real signal is whether you separate the raw state machine (Game) from its presentation layer (Match) instead of cramming tennis strings into the increment logic. The classic trap is normalizing deuce in the wrong place — collapse 4-4 back to 3-3 right after each add_score so that "advantage lost → deuce reappears" falls out for free rather than being special-cased. Watch the win guard too: get_result must require both a 2-point lead and at least 4 points, or 2-0 reads as a win. Drill this pattern.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Overview
You are given an org chart as a list[list[str]].
Each inner list has the form:
[manager, report_1, report_2, ...]
For example:
[
["A", "B", "C"],
["B", "E"],
["C", "D"],
]
means:
- A manages B and C
- B manages E
- C manages D
This produces the report chain:
A
....B
........E
....C
........D
This interview commonly has three main parts, and some interviewers add a fourth follow-up.
Assume:
- The input represents a valid management tree
- There is exactly one top-level manager
- Child order should match the order given in the input
- Four dots (....) represent one indentation level
Part 1: Print The Full Report Chain
Problem Statement
Build the org tree and print the full hierarchy using the indentation format above.
Example
relations = [
["A", "B", "C"],
["B", "E"],
["C", "D"],
]
Output:
A
....B
........E
....C
........D
Part 1 Solution
Build a children adjacency list and find the root, then do a DFS render.
from collections import defaultdict
def build_children(relations: list[list[str]]) -> tuple[dict[str, list[str]], str]:
children: dict[str, list[str]] = defaultdict(list)
parent: dict[str, str] = {}
all_people = set()
for row in relations:
manager, *reports = row
all_people.add(manager)
children[manager]
for report in reports:
all_people.add(report)
children[manager].append(report)
children[report]
parent[report] = manager
roots = [person for person in all_people if person not in parent]
if len(roots) != 1:
raise ValueError("Expected exactly one root")
return children, roots[0]
def render_full_chain(relations: list[list[str]]) -> str:
children, root = build_children(relations)
lines: list[str] = []
def dfs(node: str, depth: int) -> None:
lines.append(f"{'....' * depth}{node}")
for child in children[node]:
dfs(child, depth + 1)
dfs(root, 0)
return "\n".join(lines)
Part 2: Emit All Skip-Level Pairs
Problem Statement
As a manager of managers, you may want to hold skip-level meetings with employees who are exactly two levels below you.
Return all valid (manager, employee) skip-level pairs.
Using the same tree:
- A can skip over B to meet E
- A can skip over C to meet D
Output can be returned in any convenient format as long as the pairs are correct.
Example
[("A", "E"), ("A", "D")]
Part 2 Solution
Once you have the tree, every valid skip-level pair is just a (manager, grandchild) relationship.
def all_skip_level_pairs(relations: list[list[str]]) -> list[tuple[str, str]]:
children, root = build_children(relations)
pairs: list[tuple[str, str]] = []
def dfs(node: str) -> None:
for child in children[node]:
for grandchild in children[child]:
pairs.append((node, grandchild))
dfs(child)
dfs(root)
return pairs
Part 3: Given A Person, Print Only Their Chain
Problem Statement
Given a target employee, print:
- the single management path from the root down to that employee
- all descendants under that target employee
The output must use the same indentation format as Part 1.
Example
For target B, using:
relations = [
["A", "B", "C"],
["B", "E"],
["C", "D"],
]
output:
A
....B
........E
If the target is C, the output is:
A
....C
........D
Part 3 Solution
This is the part that usually trips people up. You need both:
- the path from the root down to the target
- the full subtree under the target
The cleanest way is to keep a parent map so you can reconstruct the upward chain, then render only the target's descendants afterward.
The key detail is that the target should appear exactly once:
- print the target as part of the root-to-target path
- then DFS only the target's children, not the target again
from collections import defaultdict
def build_graph(
relations: list[list[str]],
) -> tuple[dict[str, list[str]], dict[str, str], str]:
children: dict[str, list[str]] = defaultdict(list)
parent: dict[str, str] = {}
all_people = set()
for row in relations:
manager, *reports = row
all_people.add(manager)
children[manager]
for report in reports:
all_people.add(report)
children[manager].append(report)
children[report]
parent[report] = manager
roots = [person for person in all_people if person not in parent]
if len(roots) != 1:
raise ValueError("Expected exactly one root")
return children, parent, roots[0]
def render_chain_for(relations: list[list[str]], target: str) -> str:
children, parent, _root = build_graph(relations)
if target not in children:
raise ValueError(f"Unknown employee: {target}")
path = [target]
current = target
while current in parent:
current = parent[current]
path.append(current)
path.reverse()
lines: list[str] = []
for depth, name in enumerate(path):
lines.append(f"{'....' * depth}{name}")
def dfs_subtree(node: str, depth: int) -> None:
lines.append(f"{'....' * depth}{node}")
for child in children[node]:
dfs_subtree(child, depth + 1)
for child in children[target]:
dfs_subtree(child, len(path))
return "\n".join(lines)
Follow-Up: Lowest Common Manager
Some interviewers extend the problem and ask:
Given two employees, return their lowest common manager.
For the same tree:
lowest_common_manager("C", "E") == "A"
Follow-Up Solution
The parent map also makes the lowest-common-manager follow-up straightforward:
def lowest_common_manager(
relations: list[list[str]], employee1: str, employee2: str
) -> str:
children, parent, _root = build_graph(relations)
if employee1 not in children or employee2 not in children:
raise ValueError("Unknown employee")
ancestors = set()
current = employee1
ancestors.add(current)
while current in parent:
current = parent[current]
ancestors.add(current)
current = employee2
while current not in ancestors:
current = parent[current]
return current
Full Reference Implementation
If the interviewer keeps adding follow-ups, it is usually better to unify everything into one reusable OrgChart class.
from collections import defaultdict
class OrgChart:
INDENT = "...."
def __init__(self, relations: list[list[str]]):
self.children: dict[str, list[str]] = defaultdict(list)
self.parent: dict[str, str] = {}
self._appearance_order: list[str] = []
seen = set()
for row in relations:
if not row:
continue
manager, *reports = row
self._remember(manager, seen)
self.children[manager]
for report in reports:
self._remember(report, seen)
self.children[manager].append(report)
self.children[report]
self.parent[report] = manager
roots = [name for name in self._appearance_order if name not in self.parent]
if len(roots) != 1:
raise ValueError("Input must contain exactly one root")
self.root = roots[0]
def _remember(self, name: str, seen: set[str]) -> None:
if name not in seen:
seen.add(name)
self._appearance_order.append(name)
def render_full_chain(self) -> str:
lines: list[str] = []
self._render_subtree(self.root, 0, lines)
return "\n".join(lines)
def all_skip_level_pairs(self) -> list[tuple[str, str]]:
pairs: list[tuple[str, str]] = []
def dfs(node: str) -> None:
for child in self.children[node]:
for grandchild in self.children[child]:
pairs.append((node, grandchild))
dfs(child)
dfs(self.root)
return pairs
def render_chain_for(self, target: str) -> str:
if target not in self.children:
raise ValueError(f"Unknown employee: {target}")
path = self._path_from_root(target)
lines: list[str] = []
for depth, name in enumerate(path):
lines.append(f"{self.INDENT * depth}{name}")
target_depth = len(path) - 1
for child in self.children[target]:
self._render_subtree(child, target_depth + 1, lines)
return "\n".join(lines)
def lowest_common_manager(self, employee1: str, employee2: str) -> str:
if employee1 not in self.children or employee2 not in self.children:
raise ValueError("Unknown employee")
ancestors = set()
current = employee1
ancestors.add(current)
while current in self.parent:
current = self.parent[current]
ancestors.add(current)
current = employee2
while current not in ancestors:
current = self.parent[current]
return current
def _path_from_root(self, target: str) -> list[str]:
path = [target]
current = target
while current in self.parent:
current = self.parent[current]
path.append(current)
path.reverse()
return path
def _render_subtree(self, node: str, depth: int, lines: list[str]) -> None:
lines.append(f"{self.INDENT * depth}{node}")
for child in self.children[node]:
self._render_subtree(child, depth + 1, lines)
Example Usage
relations = [
["A", "B", "C"],
["B", "E"],
["C", "D"],
]
chart = OrgChart(relations)
print(chart.render_full_chain())
# A
# ....B
# ........E
# ....C
# ........D
print(chart.all_skip_level_pairs())
# [('A', 'E'), ('A', 'D')]
print(chart.render_chain_for("B"))
# A
# ....B
# ........E
print(chart.lowest_common_manager("C", "E"))
# A
Complexity
Let n be the number of employees.
- Build graph: O(n)
- Part 1 render: O(n)
- Part 2 skip-level pairs: O(n)
- Part 3 target chain render: O(h + s)
- h is the height from root to target
- s is the size of the target's subtree
- Lowest common manager: O(h)
- Space: O(n)
The whole question hinges on one setup decision: build both a children adjacency map and a parent map in O(n) up front, and every part becomes a short traversal. The recurring trap in Part 3 is double-printing the target — render it once as the tail of the root-to-target path, then DFS only its children at the next depth. The LCM follow-up is the same parent map walked upward into an ancestor set; note this is tree-LCA via parent pointers, not the binary-lifting variant. Find the root by appearance order (not set iteration) to keep child ordering deterministic. Drill this pattern.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Overview
Implement a Reddit-style moderator list from a newline-delimited log string.
The interview usually comes in three parts:
- A single mod list
- Multiple communities
- Demotions that reorder the current mod list
The core rule is:
- A moderator can remove another moderator only if they are above that moderator in the current mod list.
- Higher in the list means the moderator has an earlier effective access timestamp.
In the first part, each log line has four comma-separated fields:
target, action, actor, timestamp
- target: the moderator receiving the action
- action: add or remove in Parts 1 and 2, plus demote in Part 3
- actor: the moderator who performed the action
- timestamp: integer timestamp, increasing over time
Assume:
- The logs are already sorted by timestamp
- In Part 1, timestamps are unique
- In Parts 2 and 3, timestamps are unique within each community
- Every logged action is valid
- SYSTEM may appear as an actor to seed the first moderator, but SYSTEM is not part of the returned mod list unless explicitly added
- If a removed moderator is added again later, they get a new access timestamp
Each part asks for the same three operations:
- Constructor: build the state from the log string
can_remove_mod(...)get_mod_list(...)
Part 1: Single Moderator List
Problem Statement
Implement a class for a single moderator list:
class ModList:
def __init__(self, logs: str):
pass
def can_remove_mod(self, actor: str, target: str) -> bool:
pass
def get_mod_list(self) -> list[str]:
pass
Rules
- add means target becomes an active moderator at timestamp
- remove means target is no longer an active moderator
can_remove_mod(actor, target)returns True only if:- both moderators are currently active
- actor != target
- actor has an earlier effective access timestamp than target
get_mod_list()returns active moderators from top to bottom of the mod list
Example
logs = """
alice,add,SYSTEM,1
bob,add,alice,2
carol,add,alice,3
dave,add,bob,4
carol,remove,alice,5
""".strip()
mod_list = ModList(logs)
mod_list.can_remove_mod("alice", "bob") # True
mod_list.can_remove_mod("bob", "alice") # False
mod_list.can_remove_mod("bob", "dave") # True
mod_list.get_mod_list() # ["alice", "bob", "dave"]
Part 1 Solution
Replay the log once and store each active moderator's effective access timestamp.
class ModList:
def __init__(self, logs: str):
self.active_since: dict[str, int] = {}
for raw_line in logs.splitlines():
line = raw_line.strip()
if not line:
continue
target, action, actor, ts = [part.strip() for part in line.split(",")]
timestamp = int(ts)
if action == "add":
self.active_since[target] = timestamp
elif action == "remove":
self.active_since.pop(target, None)
else:
raise ValueError(f"Unsupported action: {action}")
def can_remove_mod(self, actor: str, target: str) -> bool:
if actor == target:
return False
if actor not in self.active_since or target not in self.active_since:
return False
return self.active_since[actor] < self.active_since[target]
def get_mod_list(self) -> list[str]:
return [
mod
for mod, _ in sorted(
self.active_since.items(),
key=lambda item: (item[1], item[0]),
)
]
Complexity
- Constructor: O(n)
- can_remove_mod: O(1)
- get_mod_list: O(m log m), where m is the number of active moderators
- Space: O(m)
Part 2: Add Community Support
Problem Statement
Now extend the problem to multiple communities. Each log line becomes:
community, target, action, actor, timestamp
Implement:
class CommunityModList:
def __init__(self, logs: str):
pass
def can_remove_mod(self, community: str, actor: str, target: str) -> bool:
pass
def get_mod_list(self, community: str) -> list[str]:
pass
Rules
- Moderator membership is tracked independently for each community
- The same username can appear in multiple communities with different access timestamps
- can_remove_mod only considers the given community
Example
logs = """
python,alice,add,SYSTEM,1
python,bob,add,alice,2
python,carol,add,alice,3
datascience,maya,add,SYSTEM,4
datascience,bob,add,maya,5
python,carol,remove,alice,6
""".strip()
mod_list = CommunityModList(logs)
mod_list.get_mod_list("python") # ["alice", "bob"]
mod_list.get_mod_list("datascience") # ["maya", "bob"]
mod_list.can_remove_mod("python", "alice", "bob") # True
mod_list.can_remove_mod("datascience", "bob", "maya") # False
Part 2 Solution
Use a nested dictionary keyed by community, then by moderator.
from collections import defaultdict
class CommunityModList:
def __init__(self, logs: str):
self.active_since: dict[str, dict[str, int]] = defaultdict(dict)
for raw_line in logs.splitlines():
line = raw_line.strip()
if not line:
continue
community, target, action, actor, ts = [
part.strip() for part in line.split(",")
]
timestamp = int(ts)
if action == "add":
self.active_since[community][target] = timestamp
elif action == "remove":
self.active_since[community].pop(target, None)
else:
raise ValueError(f"Unsupported action: {action}")
def can_remove_mod(self, community: str, actor: str, target: str) -> bool:
mods = self.active_since.get(community, {})
if actor == target:
return False
if actor not in mods or target not in mods:
return False
return mods[actor] < mods[target]
def get_mod_list(self, community: str) -> list[str]:
mods = self.active_since.get(community, {})
return [
mod
for mod, _ in sorted(
mods.items(),
key=lambda item: (item[1], item[0]),
)
]
Complexity
- Constructor: O(n)
- can_remove_mod: O(1)
- get_mod_list(community): O(m log m)
- Space: O(total active moderators across all communities)
Part 3: Support Moderator Demotion
Problem Statement
Extend the multi-community version to support a third action:
community, target, demote, actor, timestamp
Demotion means the target moderator stays active, but their effective access timestamp becomes the demotion timestamp. In other words, they move down in that community's mod list.
Implement the same three functions:
class ReorderableCommunityModList:
def __init__(self, logs: str):
pass
def can_remove_mod(self, community: str, actor: str, target: str) -> bool:
pass
def get_mod_list(self, community: str) -> list[str]:
pass
Example
logs = """
python,alice,add,SYSTEM,1
python,bob,add,alice,2
python,carol,add,alice,3
python,bob,demote,alice,4
""".strip()
mod_list = ReorderableCommunityModList(logs)
mod_list.get_mod_list("python") # ["alice", "carol", "bob"]
mod_list.can_remove_mod("python", "carol", "bob") # True
mod_list.can_remove_mod("python", "bob", "carol") # False
Part 3 Solution
The only real change is handling demote by overwriting the target moderator's effective timestamp.
from collections import defaultdict
class ReorderableCommunityModList:
def __init__(self, logs: str):
self.active_since: dict[str, dict[str, int]] = defaultdict(dict)
for raw_line in logs.splitlines():
line = raw_line.strip()
if not line:
continue
community, target, action, actor, ts = [
part.strip() for part in line.split(",")
]
timestamp = int(ts)
mods = self.active_since[community]
if action == "add":
mods[target] = timestamp
elif action == "remove":
mods.pop(target, None)
elif action == "demote":
if target in mods:
mods[target] = timestamp
else:
raise ValueError(f"Unsupported action: {action}")
def can_remove_mod(self, community: str, actor: str, target: str) -> bool:
mods = self.active_since.get(community, {})
if actor == target:
return False
if actor not in mods or target not in mods:
return False
return mods[actor] < mods[target]
def get_mod_list(self, community: str) -> list[str]:
mods = self.active_since.get(community, {})
return [
mod
for mod, _ in sorted(
mods.items(),
key=lambda item: (item[1], item[0]),
)
]
Follow-Up Discussion
- How would you support
get_mod_list()in O(m) without sorting every time? - How would you validate invalid logs instead of assuming all actions are valid?
- What if timestamps are not unique?
- How would you support querying historical mod-list state at a past timestamp?
Resist building a literal ordered list with shifting indices — the whole problem collapses if you model rank as an "effective access timestamp" in a hashmap and derive order by sorting on demand. That single insight makes every action O(1): add sets the timestamp, remove pops, and demote just overwrites the timestamp to the demotion time (which is why a demoted mod falls below everyone added before them). The senior move on the Part 2/3 evolution is choosing the nested defaultdict per community up front so the API barely changes; for the O(m) follow-up, mention a balanced BST or an ordered structure keyed by timestamp. Drill this pattern.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
I have all three files read. Note q_04 and q_06 only contain the Solution tab content (the problem statement title is in metadata). I'll construct the HTML faithfully, reconstructing problem statements from the LeetCode-standard problems these clearly are, while preserving all code verbatim.Problem. Design a Logger system that receives a stream of (timestamp, message) pairs in non-decreasing timestamp order. shouldPrintMessage(timestamp, message) returns True only if the same message has not been printed within the last 10 seconds — i.e., the last accepted print was at some time t and the current timestamp >= t + 10. Reddit interviewers extend this into: memory bounding (unbounded dict is a red flag), rate-limiting taxonomy (sliding vs fixed window, token bucket, leaky bucket), thread safety, and distributed rate limiting with Redis.
Approach 1 — HashMap (O(1) time / O(M) space, unbounded)
Store, per message, the earliest timestamp at which it is allowed to print again. On each call, look up that threshold: if the current timestamp is below it, suppress; otherwise allow and advance the threshold by 10. This is the baseline every Reddit interviewer starts with — and immediately asks "what happens after 10 million distinct messages?".
class Logger:
"""
Approach 1: simple hashmap storing next-allowed timestamp per message.
Time: O(1) average per call (dict lookup/insert)
Space: O(M) where M = number of distinct messages ever seen — grows forever.
"""
def __init__(self) -> None:
# message -> earliest timestamp at which printing is again allowed
self._next_allowed: dict[str, int] = {}
def shouldPrintMessage(self, timestamp: int, message: str) -> bool:
allowed_at = self._next_allowed.get(message, 0)
if timestamp < allowed_at:
return False
# Allow and set the next allowed time
self._next_allowed[message] = timestamp + 10
return True
# ── driver ──────────────────────────────────────────────────────────────────
if __name__ == "__main__":
logger = Logger()
calls = [
(1, "foo"), # True — first time
(2, "bar"), # True — first time
(3, "foo"), # False — too soon (next allowed = 11)
(8, "bar"), # False — too soon (next allowed = 12)
(10, "foo"), # False — 10 < 11
(11, "foo"), # True — 11 >= 11, allowed; next = 21
]
expected = [True, True, False, False, False, True]
for (ts, msg), exp in zip(calls, expected):
result = logger.shouldPrintMessage(ts, msg)
status = "OK" if result == exp else "FAIL"
print(f"t={ts:2d} msg={msg!r:5s} got={result} exp={exp} [{status}]")
next_allowed = timestamp + 10 and checking current >= next_allowed handles this correctly — do NOT use timestamp - last_print > 10 (off-by-one).
Approach 2 — Eviction with a Queue + HashMap (O(1) amortized / O(W) space)
The critical follow-up: the hashmap grows without bound. If messages are highly varied (log lines with UUIDs, request IDs) you will OOM. The fix: track entries in a deque ordered by when they expire (i.e., last_print + 10). On every call, sweep expired entries off the front of the deque before inserting the new entry. Space is now bounded by the maximum number of distinct messages active within any 10-second window — call that W.
from collections import deque
class LoggerWithEviction:
"""
Approach 2: bounded memory via lazy eviction using a deque of (expire_time, message).
Invariant: the deque is sorted by expire_time (naturally, since timestamps
are non-decreasing and expire_time = timestamp + 10).
Time: O(1) amortized — each message is enqueued and dequeued at most once.
Space: O(W) where W = max distinct messages in any 10-second window.
"""
def __init__(self) -> None:
self._allowed: dict[str, int] = {} # msg -> next_allowed_time
self._expiry_queue: deque[tuple[int, str]] = deque() # (expire_at, msg)
def _evict_expired(self, current_timestamp: int) -> None:
"""Remove messages whose rate-limit window has fully expired."""
while self._expiry_queue:
expire_at, msg = self._expiry_queue[0]
if expire_at <= current_timestamp:
# This entry is stale — but only delete from dict if the dict
# still points to THIS expiry (a later print may have updated it).
if self._allowed.get(msg) == expire_at:
del self._allowed[msg]
self._expiry_queue.popleft()
else:
break # queue is sorted; nothing further is expired
def shouldPrintMessage(self, timestamp: int, message: str) -> bool:
self._evict_expired(timestamp)
allowed_at = self._allowed.get(message, 0)
if timestamp < allowed_at:
return False
# Accept: record new expiry and push to queue
new_expiry = timestamp + 10
self._allowed[message] = new_expiry
self._expiry_queue.append((new_expiry, message))
return True
# ── driver ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
logger = LoggerWithEviction()
calls = [
(1, "foo"),
(2, "bar"),
(3, "foo"),
(8, "bar"),
(10, "foo"),
(11, "foo"),
]
expected = [True, True, False, False, False, True]
for (ts, msg), exp in zip(calls, expected):
result = logger.shouldPrintMessage(ts, msg)
status = "OK" if result == exp else "FAIL"
print(f"t={ts:2d} msg={msg!r:5s} got={result} exp={exp} [{status}]")
# Demonstrate eviction: after t=20 all old entries should be gone
logger.shouldPrintMessage(25, "baz")
print(f"Dict size after t=25: {len(logger._allowed)}") # should be 1 (just 'baz')
(11, "foo") and (25, "foo"). During eviction at t=12, we pop (11, "foo"). We must NOT blindly delete _allowed["foo"] — it now holds 25. The guard if self._allowed.get(msg) == expire_at handles this: 25 != 11, so we skip the delete and only pop the deque entry.
Approach 3 — Thread-Safe Logger (locks)
A common Reddit follow-up: "What if multiple threads call shouldPrintMessage concurrently?" The naive dict is not thread-safe in CPython under heavy writes (the GIL helps with simple dict ops but not with compound check-then-set). The correct answer is either a global lock (simple, serializes all calls) or per-key locking (higher throughput, more complex).
import threading
class ThreadSafeLogger:
"""
Thread-safe Logger using a global RLock.
Trade-off: global lock serializes all calls — easy to reason about,
correct, but contended under high concurrency.
For higher throughput: use a dict of per-key locks (key-sharded locking).
"""
def __init__(self) -> None:
self._next_allowed: dict[str, int] = {}
self._lock = threading.RLock() # RLock allows reentrant acquisition
def shouldPrintMessage(self, timestamp: int, message: str) -> bool:
with self._lock:
allowed_at = self._next_allowed.get(message, 0)
if timestamp < allowed_at:
return False
self._next_allowed[message] = timestamp + 10
return True
class ShardedLockLogger:
"""
Higher-throughput thread-safe Logger using N sharded locks.
Messages are hashed into one of NUM_SHARDS buckets. Concurrent calls
for messages in different shards proceed in parallel; only same-shard
calls contend.
Time: O(1) per call
Space: O(M) for the dict + O(NUM_SHARDS) for the locks
"""
NUM_SHARDS = 64
def __init__(self) -> None:
self._next_allowed: dict[str, int] = {}
self._locks = [threading.Lock() for _ in range(self.NUM_SHARDS)]
def _shard(self, message: str) -> int:
return hash(message) % self.NUM_SHARDS
def shouldPrintMessage(self, timestamp: int, message: str) -> bool:
lock = self._locks[self._shard(message)]
with lock:
allowed_at = self._next_allowed.get(message, 0)
if timestamp < allowed_at:
return False
self._next_allowed[message] = timestamp + 10
return True
# ── concurrent stress test ───────────────────────────────────────────────────
if __name__ == "__main__":
import random
logger = ShardedLockLogger()
results = []
errors = []
def worker(tid: int) -> None:
for i in range(100):
ts = random.randint(1, 50)
msg = random.choice(["foo", "bar", "baz"])
try:
r = logger.shouldPrintMessage(ts, msg)
results.append(r)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Total calls: {len(results)}, errors: {len(errors)}")
Lock unless the same thread might call shouldPrintMessage recursively (unlikely here) — then RLock. threading.local is NOT the right tool here: it gives per-thread storage, so different threads would maintain independent rate-limit state, violating the requirement that a global cooldown is enforced.
Approach 4 — Rate Limiting Theory: Sliding Window, Token Bucket, Leaky Bucket
Reddit and Anthropic interviews often pivot from the LC problem to "how would you build this for a real API gateway?" This requires knowing the four canonical algorithms and their trade-offs.
"""
Rate Limiting Algorithm Taxonomy
=================================
1. FIXED WINDOW COUNTER
- Divide time into fixed buckets (e.g., [0,10), [10,20), ...).
- Count events per bucket; reject if count exceeds limit.
- Pro: trivial, O(1) time and space per key.
- Con: boundary burst — a user can fire 2x the limit by straddling
two windows (N at t=9.9, N at t=10.1).
2. SLIDING WINDOW LOG (what LC 359 essentially is for 1-request limit)
- Store the exact timestamps of all recent events in a sorted list.
- On each request, evict entries older than (now - window) then check count.
- Pro: perfectly accurate sliding window.
- Con: O(N) space per key (N = requests in window); expensive to purge.
3. SLIDING WINDOW COUNTER (hybrid, used in practice)
- Keep counters for current and previous fixed windows.
- Estimate current sliding count = prev_count * overlap_ratio + curr_count.
- Pro: O(1) space, very close to sliding accuracy.
- Con: estimate, not exact; off by at most ~0.003% in practice.
4. TOKEN BUCKET
- Each key has a bucket of capacity C that refills at rate R tokens/second.
- A request costs 1 token; if bucket is empty, reject.
- Pro: allows bursting up to C; smooth average rate R.
- Con: state per key (current tokens, last refill time).
5. LEAKY BUCKET
- Requests enter a queue (bucket); a worker drains at fixed rate R/sec.
- Pro: perfectly smooth output, no bursting.
- Con: adds latency; a steady stream at 2R causes queue to grow unbounded.
"""
import time
class TokenBucketRateLimiter:
"""
Per-key token bucket rate limiter.
capacity: maximum burst
refill_rate: tokens added per second
"""
def __init__(self, capacity: float, refill_rate: float) -> None:
self._capacity = capacity
self._refill_rate = refill_rate
self._buckets: dict[str, tuple[float, float]] = {}
# key -> (current_tokens, last_refill_wall_time)
def allow(self, key: str) -> bool:
now = time.monotonic()
tokens, last_time = self._buckets.get(key, (self._capacity, now))
# Refill tokens based on elapsed time
elapsed = now - last_time
tokens = min(self._capacity, tokens + elapsed * self._refill_rate)
if tokens < 1.0:
self._buckets[key] = (tokens, now)
return False
self._buckets[key] = (tokens - 1.0, now)
return True
class SlidingWindowLogRateLimiter:
"""
Sliding window log: exact but O(N) space per key.
window_seconds: length of the sliding window
max_requests: allowed requests in that window
"""
def __init__(self, window_seconds: int, max_requests: int) -> None:
from collections import deque
self._window = window_seconds
self._limit = max_requests
self._logs: dict[str, deque] = {}
def allow(self, key: str, timestamp: int) -> bool:
from collections import deque
if key not in self._logs:
self._logs[key] = deque()
log = self._logs[key]
# Evict requests outside the window
cutoff = timestamp - self._window
while log and log[0] <= cutoff:
log.popleft()
if len(log) < self._limit:
log.append(timestamp)
return True
return False
# ── demo ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
tb = TokenBucketRateLimiter(capacity=3, refill_rate=1)
for i in range(5):
print(f"Token bucket request {i+1}: {tb.allow('user_A')}")
# First 3 allowed (burst), 4th and 5th rejected (no tokens)
sw = SlidingWindowLogRateLimiter(window_seconds=10, max_requests=2)
for ts in [1, 5, 9, 11, 15]:
print(f"Sliding window t={ts}: {sw.allow('user_B', ts)}")
# t=1: True, t=5: True, t=9: False (2 in [0,9]), t=11: True (evicts t=1), t=15: True
Payment processing (no bursts): leaky bucket — smooth rate enforcement.
Gaming/CDN (bursty traffic allowed): token bucket — burst up to capacity.
Log deduplication (this problem): sliding window log capped at 1 request — simplest and exact.
Approach 5 — Distributed Rate Limiting with Redis
The hardest Reddit follow-up: "Your service has 50 pods. Each pod runs this logger in memory. How do you enforce the global 10-second dedup across all pods?" The answer is to move state into Redis, which provides atomic operations and expiry-based key management.
"""
Distributed Logger Rate Limiter using Redis.
Pattern A: Redis SET with NX + EXPIRY (simplest, best for this exact problem)
- SET msg timestamp NX EX 10
- NX = only set if key does not exist
- EX 10 = auto-expire after 10 seconds
- Atomic: no race conditions.
Pattern B: Redis sorted set sliding window (for N-per-window, not just dedup)
- Key = "ratelimit:{user_id}"
- ZADD key timestamp timestamp (score=timestamp, member=timestamp)
- ZREMRANGEBYSCORE key -inf (now - window)
- ZCARD key to check count
- Wrap in MULTI/EXEC for atomicity, or use a Lua script.
Pattern C: Redis + Lua script (atomic sliding window)
- Lua executes atomically on the Redis server — no round trips, no races.
"""
# ── Pattern A: SET NX EX (pseudo-code with redis-py) ─────────────────────────
#
# import redis
# r = redis.Redis(host="localhost", port=6379)
#
# def should_print_message_distributed(timestamp: int, message: str) -> bool:
# key = f"logger:{message}"
# # SET key value NX EX seconds
# # Returns True if key was newly set, None if it already existed
# result = r.set(key, timestamp, nx=True, ex=10)
# return result is not None
#
# Limitation: Redis TTL is wall-clock seconds, not logical timestamps.
# For production log dedup where "timestamp" is logical, use Pattern B/C.
# ── Pattern B: Sorted-set sliding window (simulated, no real Redis) ──────────
from collections import defaultdict
import bisect
class FakeRedis:
"""Minimal sorted-set simulation for interview illustration."""
def __init__(self) -> None:
self._zsets: dict[str, list[float]] = defaultdict(list)
def zadd(self, key: str, score: float) -> None:
bisect.insort(self._zsets[key], score)
def zremrangebyscore(self, key: str, min_score: float, max_score: float) -> None:
zset = self._zsets[key]
lo = bisect.bisect_left(zset, min_score)
hi = bisect.bisect_right(zset, max_score)
del zset[lo:hi]
def zcard(self, key: str) -> int:
return len(self._zsets[key])
class DistributedSlidingWindowLogger:
"""
Distributed rate limiter using a sorted-set sliding window in Redis.
Each logical-timestamp unit represents 1 second.
window: size of the rate-limit window in seconds
max_count: max allowed prints in that window (1 for dedup)
"""
def __init__(self, window: int = 10, max_count: int = 1) -> None:
self._redis = FakeRedis()
self._window = window
self._max_count = max_count
def shouldPrintMessage(self, timestamp: int, message: str) -> bool:
key = f"logger:{message}"
# 1. Evict entries older than the window
cutoff = timestamp - self._window # exclusive lower bound
self._redis.zremrangebyscore(key, float("-inf"), cutoff)
# 2. Count remaining entries
count = self._redis.zcard(key)
if count >= self._max_count:
return False
# 3. Add current timestamp (in real Redis this + steps 1-2 wrapped in Lua)
self._redis.zadd(key, float(timestamp))
return True
# ── Lua script for atomic execution on real Redis ───────────────────────────
SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
end
return 0
"""
# Usage with redis-py:
# script = r.register_script(SLIDING_WINDOW_LUA)
# allowed = script(keys=[f"logger:{message}"], args=[timestamp, 10, 1])
# ── driver ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
logger = DistributedSlidingWindowLogger(window=10, max_count=1)
calls = [(1, "foo"), (2, "bar"), (3, "foo"), (8, "bar"), (10, "foo"), (11, "foo")]
expected = [True, True, False, False, False, True]
for (ts, msg), exp in zip(calls, expected):
result = logger.shouldPrintMessage(ts, msg)
status = "OK" if result == exp else "FAIL"
print(f"t={ts:2d} msg={msg!r:5s} got={result} exp={exp} [{status}]")
MULTI/EXEC (Redis transactions) executes a batch atomically but does NOT allow you to branch on intermediate results — you can't check ZCARD inside the transaction and conditionally abort. A Lua script runs atomically on the Redis server as a single command, so read-check-write is race-free. This is the standard pattern used by Stripe, GitHub, and rate-limiting libraries like redis-rate-limiter.
ZREMRANGEBYSCORE rather than deque pops. Out-of-order tolerance costs O(N) space per key but is correct. In the simple in-memory case you can also fall back to storing the max seen timestamp per message instead of the last-accepted one — but this may over-suppress.cooldown parameter to shouldPrintMessage and store the window per key: next_allowed[message] = timestamp + cooldown. For the eviction variant, the deque entries become (expire_at, message, cooldown) — but since cooldowns vary, the deque is no longer sorted by expire_at; you'd need a min-heap (priority queue) keyed on expire_at instead of a deque.count = prev_count * (1 - elapsed/window) + curr_count.11 >= 1+10 → True. If you write timestamp - last > 10 you require a gap of more than 10, which fails for t=11. Always store next_allowed = last + 10 and test timestamp >= next_allowed.Unbounded memory. Not mentioning the eviction follow-up is the most common drop in Reddit loops. Interviewers specifically wait to see if you volunteer it. Lead with "this grows without bound — in production I'd add eviction" before they have to ask.
Concurrency correctness. The check-then-set (
get + set) is not atomic. Under concurrency, two threads can both read allowed_at = 0 for a new message and both return True. Correct answer: a lock (or Redis SET NX / Lua script) makes this atomic.Stale deque entries. In the eviction variant, the deque can hold outdated entries for a message that was printed again before its old entry expired. The stale-entry guard (
if _allowed.get(msg) == expire_at) is the subtle detail that separates a passing from a failing implementation.Distributed state. Never say "I'd just run this on a single node" for a distributed system question. The expected answer sequence: in-memory first → Redis SET NX EX → Redis sorted set + Lua for general N/window → mention Redis Cluster for HA and Lua for atomicity.
Algorithm vocabulary. Know "token bucket allows bursting; leaky bucket smooths output; sliding window log is exact but O(N); sliding window counter is approximate but O(1)." Mixing these up or confusing token and leaky bucket is an instant signal.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Overview
You are given an existing API:
def get_chat_messages(message_id: int) -> list[Message]:
...
For a given message_id, it returns the surrounding chat context:
- up to 5 messages before the target
- the target message itself
- up to 5 messages after the target
Assume the returned list is already sorted by increasing message.id.
Now implement:
def merge_messages(ids: list[int]) -> list[Message]:
...
Given a list of message IDs, call get_chat_messages(...) for each ID, merge all returned windows, remove duplicates, and return the final messages in order.
The interviewer hint is important:
- do not solve this by dumping everything into a set and sorting afterward
- instead, use the fact that each API response is already ordered
Assume:
Message.idis uniqueidsmay contain duplicates- windows may overlap heavily
- at the beginning or end of a conversation, fewer than 11 messages may be returned
Part 1: Merge Ordered Message Windows
Problem Statement. Implement merge_messages(ids) and return the merged, deduplicated message list in increasing ID order.
Example. If:
ids = [1, 3, 5]
and each call returns the surrounding context, then the merged result should be:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
because the windows overlap and should collapse into one ordered result.
Part 1 Solution
The intended approach is a merge-style scan, not dedupe-then-sort.
- Sort the requested IDs.
- Fetch each ordered window.
- Maintain the largest message ID already appended.
- While scanning the next window, skip any message whose ID is already covered.
Because each window is sorted, once msg.id > last_id, everything after it in that same window is also in the correct order.
from dataclasses import dataclass
@dataclass(frozen=True)
class Message:
id: int
text: str
def merge_messages(ids: list[int]) -> list[Message]:
if not ids:
return []
merged: list[Message] = []
last_id: int | None = None
for message_id in sorted(ids):
window = get_chat_messages(message_id)
for message in window:
if last_id is not None and message.id <= last_id:
continue
merged.append(message)
last_id = message.id
return merged
Why This Works
- Sorting
idsensures we process earlier windows before later ones. - Every
get_chat_messages(...)response is already sorted. - Overlap only appears as a prefix of a later window.
- So a single
last_idcheck is enough to remove duplicates while preserving order.
This is the key insight the interviewer usually wants instead of:
- gather all messages
- deduplicate with set
- sort everything again
Complexity
- Sorting IDs: O(k log k), where k = len(ids)
- Scanning returned messages: O(T), where T is the total number of messages returned across all API calls
- Extra space: O(R) for the output
Since each API call returns at most 11 messages, T is at most 11k.
Part 2: Follow-Up If Messages Can Be Edited
Problem Statement. Suppose messages are editable and you now need to preserve version history instead of overwriting content in place. How would you model that?
Example. Suppose message 42 is created and later edited twice:
message = VersionedMessage.create(
message_id=42,
content="hello",
created_at=100,
)
message.edit("hello world", edited_at=120)
message.edit("hello world!", edited_at=150)
message.latest().content # "hello world!"
message.as_of(110).content # "hello"
message.as_of(130).content # "hello world"
If get_chat_messages(42, as_of=130) includes message 42, it should return the version whose content is "hello world", not the latest edit from time 150.
Part 2 Solution
The main design change is to separate:
- the stable logical message identity
- the immutable versions of that message over time
That means:
message_idstays constant for the life of the message- each edit creates a new version record
- the message tracks which version is current for fast latest reads
- edits are appended in chronological order so snapshot lookup can binary search version history
from dataclasses import dataclass, field
@dataclass(frozen=True)
class MessageVersion:
version: int
content: str
edited_at: int
@dataclass
class VersionedMessage:
id: int
current_version: int
versions: list[MessageVersion] = field(default_factory=list)
@classmethod
def create(cls, message_id: int, content: str, created_at: int) -> "VersionedMessage":
return cls(
id=message_id,
current_version=1,
versions=[
MessageVersion(
version=1,
content=content,
edited_at=created_at,
)
],
)
def edit(self, new_content: str, edited_at: int) -> None:
if self.versions and edited_at < self.versions[-1].edited_at:
raise ValueError("edited_at must be non-decreasing")
next_version = self.current_version + 1
self.versions.append(
MessageVersion(
version=next_version,
content=new_content,
edited_at=edited_at,
)
)
self.current_version = next_version
def latest(self) -> MessageVersion:
return self.versions[self.current_version - 1]
def as_of(self, timestamp: int) -> MessageVersion | None:
left = 0
right = len(self.versions) - 1
answer = -1
while left <= right:
mid = (left + right) // 2
if self.versions[mid].edited_at <= timestamp:
answer = mid
left = mid + 1
else:
right = mid - 1
if answer == -1:
return None
return self.versions[answer]
def get_chat_messages(message_id: int, as_of: int | None = None) -> list[Message]:
...
def resolve_message(
versioned_message: VersionedMessage,
as_of: int | None,
) -> Message | None:
version = (
versioned_message.latest()
if as_of is None
else versioned_message.as_of(as_of)
)
if version is None:
return None
return Message(
id=versioned_message.id,
text=version.content,
)
In a real system, you would usually store this as:
messages(message_id, current_version, author_id, created_at, ...)
message_versions(message_id, version, content, edited_at, editor_id, ...)
Why This Works
- Old content is never overwritten, so audit history and rollback remain possible.
- A stable
message_idmeans the merge logic from Part 1 still deduplicates correctly. - The latest version stays available in O(1) time.
as_of(...)supports historical snapshots without changing the merge algorithm itself.
The important rule is still: deduplicate by message_id, not by version number.
Then decide whether get_chat_messages(...) should return:
- the latest version of each message
- the version visible at a snapshot time such as
as_of
Complexity
edit: O(1) appendlatest: O(1)as_of: O(log v) with binary search over v versions- Extra space: O(v) per message to store version history
The whole point is recognizing this as a k-way merge of pre-sorted streams — the same machinery as merging sorted lists — where the overlap collapses to a one-variable last_id high-water mark because duplicates can only appear as a prefix of a later window. The trap that fails the signal is the brute-force "set + sort" the interviewer explicitly warns against; it throws away the ordering invariant you were handed. Part 2 is really a system-design pivot disguised as a follow-up: separate identity from versions and let as_of binary-search an append-only log, which is exactly how temporal tables and event sourcing work. drill this pattern.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem. Given an m × n grid of characters board and a string word, return True if word exists as a path of horizontally/vertically adjacent cells in the grid — no cell may be reused within a single path. This is LeetCode 79 "Word Search" and appears constantly in Reddit-level phone screens and virtual onsites.
Approach 1 — Brute-force DFS with a visited set (O(m·n·4L) time, O(m·n + L) space)
The conceptually simplest version: for every cell that matches word[0], launch a depth-first search. At each recursive step, mark the current cell in a visited set, recurse into the four neighbours, then unmark. The visited set prevents cell-reuse within one path. This is correct but wastes memory because the boolean matrix is O(m·n).
from typing import List
class SolutionBrute:
def exist(self, board: List[List[str]], word: str) -> bool:
rows, cols = len(board), len(board[0])
visited = set()
def dfs(r: int, c: int, idx: int) -> bool:
# All characters matched — path found
if idx == len(word):
return True
# Out of bounds
if r < 0 or c < 0 or r >= rows or c >= cols:
return False
# Already used in this path
if (r, c) in visited:
return False
# Character mismatch
if board[r][c] != word[idx]:
return False
visited.add((r, c))
found = (
dfs(r + 1, c, idx + 1) or
dfs(r - 1, c, idx + 1) or
dfs(r, c + 1, idx + 1) or
dfs(r, c - 1, idx + 1)
)
visited.discard((r, c))
return found
for r in range(rows):
for c in range(cols):
if dfs(r, c, 0):
return True
return False
# --- smoke tests ---
if __name__ == "__main__":
sol = SolutionBrute()
board1 = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]
assert sol.exist(board1, "ABCCED") is True
assert sol.exist(board1, "SEE") is True
assert sol.exist(board1, "ABCB") is False
print("Brute-force: all tests passed")
Time: O(m·n·4L) — at most m·n starting cells, and from each cell the DFS branches into at most 4 directions for each of L characters. In practice the branching factor is at most 3 (we cannot go back where we came from) but the asymptotic bound is written as 4L.
Space: O(m·n) for the visited set + O(L) call-stack depth = O(m·n + L).
Approach 2 — In-place marking / state restoration (O(m·n·4L) time, O(L) space)
We can eliminate the visited set entirely by temporarily overwriting the current cell with a sentinel character (e.g. '#') before recursing, and restoring it after. Because the sentinel never matches any letter in word, any recursive call that lands on a marked cell will fail the character check immediately. This turns the visited set from O(m·n) to O(1) extra memory — only the call stack (depth L) remains. This is the canonical interview answer for this problem.
'#' (ASCII 35) is outside that set, so it can never match any character in word. After backtracking we restore the original letter — the mutation is invisible outside each DFS path.
from typing import List
class Solution:
def exist(self, board: List[List[str]], word: str) -> bool:
rows, cols = len(board), len(board[0])
def dfs(r: int, c: int, idx: int) -> bool:
# Base case: every character has been matched
if idx == len(word):
return True
# Bounds check
if r < 0 or c < 0 or r >= rows or c >= cols:
return False
# In-place visited check + character match combined:
# '#' never equals word[idx], and wrong letter also fails here
if board[r][c] != word[idx]:
return False
# Mark cell as visited by overwriting in-place
tmp = board[r][c]
board[r][c] = '#'
# Explore all four neighbors
found = (
dfs(r + 1, c, idx + 1) or
dfs(r - 1, c, idx + 1) or
dfs(r, c + 1, idx + 1) or
dfs(r, c - 1, idx + 1)
)
# Restore cell — critical for correctness of other search paths
board[r][c] = tmp
return found
for r in range(rows):
for c in range(cols):
if dfs(r, c, 0):
return True
return False
# --- comprehensive tests ---
if __name__ == "__main__":
sol = Solution()
board1 = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]
assert sol.exist(board1, "ABCCED") is True, "standard path"
assert sol.exist(board1, "SEE") is True, "shorter path"
assert sol.exist(board1, "ABCB") is False, "backtrack required — must not revisit"
# Single-cell edge cases
assert sol.exist([["A"]], "A") is True
assert sol.exist([["A"]], "B") is False
# Word longer than board
assert sol.exist([["A","B"],["C","D"]], "ABCD") is False # can't make a valid path
assert sol.exist([["A","B"],["C","D"]], "ABDC") is True
# Repeated character trap
board2 = [["A","A"]]
assert sol.exist(board2, "AAA") is False # only two A's
assert sol.exist(board2, "AA") is True
print("Optimal: all tests passed")
Time: O(m·n·4L). Every cell is a potential start (m·n factor); the DFS tree has branching factor ≤ 4 and depth ≤ L.
Space: O(L) for the recursion stack only. The in-place trick removes the visited set, so no additional O(m·n) structure is needed.
Approach 3 — Pruned DFS with frequency pre-check and reversed-word trick (practical speedup)
Two independent optimizations layer on top of Approach 2 and can cut runtime dramatically on large grids or adversarial inputs. They do not change the worst-case complexity class but make a huge practical difference:
Optimization A — frequency pre-check. Before starting any DFS, count character frequencies in the board and in the word. If any character appears more times in the word than in the board, the word cannot possibly exist — return False immediately. This runs in O(m·n + L) and avoids launching thousands of fruitless searches.
Optimization B — search from the rarer end. If the last character of the word is rarer in the board than the first character, reverse the word before searching. The DFS starts at a rarer letter, so there are fewer starting cells and the search tree is pruned earlier. Example: searching for "AAAAB" on a board filled with A's has a huge branching factor from each start; searching for reversed "BAAAA" cuts the starts to just the B cells.
from typing import List
from collections import Counter
class SolutionPruned:
def exist(self, board: List[List[str]], word: str) -> bool:
rows, cols = len(board), len(board[0])
# --- Optimization A: frequency feasibility check ---
board_count = Counter(c for row in board for c in row)
word_count = Counter(word)
for ch, need in word_count.items():
if board_count[ch] < need:
return False # not enough of this character on the board
# --- Optimization B: search from the rarer end ---
# Compare how often the first vs last character appears on the board.
# If the last char is rarer, reversing means we start from fewer cells.
if board_count[word[0]] > board_count[word[-1]]:
word = word[::-1] # reverse the target word
# --- Core DFS with in-place marking (same as Approach 2) ---
def dfs(r: int, c: int, idx: int) -> bool:
if idx == len(word):
return True
if r < 0 or c < 0 or r >= rows or c >= cols:
return False
if board[r][c] != word[idx]:
return False
tmp = board[r][c]
board[r][c] = '#'
found = (
dfs(r + 1, c, idx + 1) or
dfs(r - 1, c, idx + 1) or
dfs(r, c + 1, idx + 1) or
dfs(r, c - 1, idx + 1)
)
board[r][c] = tmp
return found
for r in range(rows):
for c in range(cols):
if dfs(r, c, 0):
return True
return False
# --- tests including pruning paths ---
if __name__ == "__main__":
sol = SolutionPruned()
board1 = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]
assert sol.exist(board1, "ABCCED") is True
assert sol.exist(board1, "SEE") is True
assert sol.exist(board1, "ABCB") is False
# Frequency prune: 'Z' does not appear on board at all
assert sol.exist(board1, "ABCZ") is False
# Reversed-word optimization: word ending in a rare character
board3 = [["A","A","A","A"],
["A","A","A","A"],
["A","A","A","B"]]
# "AAAAB" — 'B' is rare (1 cell); reverse to "BAAAA"
assert sol.exist(board3, "AAAAB") is True
assert sol.exist(board3, "AAAAAB") is False # only one B
print("Pruned: all tests passed")
Time: O(m·n + L) for the pre-check, then O(k·4L) for DFS where k is the count of cells matching the starting character (k ≤ m·n). Worst-case still O(m·n·4L) but dramatically faster on typical inputs.
Space: O(L) stack + O(Σ) = O(L) for the counter (constant alphabet).
board[r][c] = tmp after the recursive call is load-bearing. If you forget it, the board is permanently mutated — other DFS branches starting from different cells see corrupted data, causing false negatives. This one-line restoration is what makes in-place backtracking work, and interviewers will probe it directly: "what happens if you remove the restore line?"
idx == len(word) before the bounds check. If the last character matched at position (r, c), the DFS will be called with idx == len(word) — at this point r and c are the coordinates of the just-matched cell (still in bounds), but we want to return True immediately without checking bounds. Placing the success check first is cleaner and avoids a subtle off-by-one confusion.
Detailed walkthrough: board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"
Grid indices (row, col):
(0,0)A (0,1)B (0,2)C (0,3)E
(1,0)S (1,1)F (1,2)C (1,3)S
(2,0)A (2,1)D (2,2)E (2,3)E
DFS starts at (0,0)='A' matches word[0]='A' → mark (0,0)='#'
→ try (1,0)='S' != 'B', fail
→ try (-1,0) out of bounds, fail
→ try (0,1)='B' matches word[1]='B' → mark (0,1)='#'
→ try (0,2)='C' matches word[2]='C' → mark (0,2)='#'
→ try (0,3)='E' != 'C', fail
→ try (1,2)='C' matches word[3]='C' → mark (1,2)='#'
→ try (2,2)='E' matches word[4]='E' → mark (2,2)='#'
→ try (2,1)='D' matches word[5]='D' → mark (2,1)='#'
→ dfs(_, _, 6): idx==len(word)==6 → return True ✓
restore (2,2)='E'
restore (1,2)='C'
restore (0,2)='C'
restore (0,1)='B'
restore (0,0)='A'
Result: True
node.word = None) to avoid duplicates without an extra seen-set.sys.setrecursionlimit if the word is long. (3) Rewrite the DFS iteratively using an explicit stack; each frame stores (r, c, idx, temp_char, restore_needed). (4) Use Python's functools.lru_cache — but note this problem has mutable state (the board), so caching is not directly applicable. (5) In a real system you'd use a compiled extension or NumPy for character matching. The interviewer usually accepts the frequency check + reversed-word trick as sufficient for an interview context.1. Forgetting to restore the cell. The single most penalized bug. If you write the mark but not the restore, your first test case may pass (one path exists) but "ABCB" (requires backtrack) will fail. Interviewers know this and will run a test that requires backtracking.
2. Wrong base-case order. Checking bounds before checking idx == len(word) is functionally correct (the final recursive call always has valid r, c) but causes confusion during interviews. Canonical style: success check first, bounds check second, character check third.
3. Off-by-one on the success check. Using if i == len(word) - 1 and board[r][c] == word[i] instead of if i == len(word) is a common attempt to avoid recursing past the last character. It works but it conflates the boundary check with the match check, making the code harder to reason about and easier to get wrong in edge cases (single-character word).
4. Mutating the input permanently. If the caller expects board to be unchanged after the call, in-place marking requires perfect restoration. An interviewer may ask: "Is this function side-effect-free?" — the answer is yes, because the sentinel is always removed before any return path (both the return found line and the implicit fallthrough).
5. Not handling single-character words. If word = "A", the loop finds board[r][c] == 'A', calls dfs(r, c, 0), checks idx == 1 == len(word) in the first nested call — but that first nested call is triggered with idx=0. The idx == len(word) check at the top of dfs catches the return from the very next call (idx=1 after a match), so it is handled correctly without special-casing.
6. Ignoring the complexity question. Reddit/FAANG interviewers will always ask for complexity. State: "Time O(m·n·4L), space O(L) for the call stack with in-place marking." If you use a visited set, space becomes O(m·n + L).
7. Missing the frequency prune in a performance discussion. If the interviewer asks "how would you optimize this for a large board", the frequency check is the first answer — it is free (O(m·n)), easy to code, and demonstrates you think about pruning before DFS even starts.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem. Given an m × n character grid and a list of words, return every word that can be found on the board. A word is valid when you can trace it through sequentially adjacent (up/down/left/right) cells without reusing any cell. This is LeetCode 212 — the step-up from Word Search I (single word) to a dictionary of words, which is a known Reddit Sr/Staff MLE ask.
Approach 1 — Naive: Run Word Search I per word O(W · m · n · 4L)
The obvious baseline: for each of the W words, run the classic single-word DFS across the entire board. This correctly solves the problem but scales terribly. If you have 30 000 words (as the LC constraint allows) each of length L = 10, you repeat the full board traversal 30 000 separate times. Every call starts from scratch, sharing nothing between words.
from typing import List
class SolutionNaive:
"""
Baseline: run Word Search I independently for every word.
Time: O(W * m * n * 4^L) where W = #words, L = max word length
Space: O(L) recursion stack per word
Works, but TLEs on large inputs.
"""
def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
rows, cols = len(board), len(board[0])
result = []
def exists(word: str) -> bool:
"""Word Search I — can `word` be found on this board?"""
def dfs(r: int, c: int, idx: int) -> bool:
if idx == len(word):
return True
if r < 0 or r >= rows or c < 0 or c >= cols:
return False
if board[r][c] != word[idx]:
return False
tmp, board[r][c] = board[r][c], "#" # mark visited
found = (
dfs(r + 1, c, idx + 1) or
dfs(r - 1, c, idx + 1) or
dfs(r, c + 1, idx + 1) or
dfs(r, c - 1, idx + 1)
)
board[r][c] = tmp # restore
return found
return any(
dfs(r, c, 0)
for r in range(rows)
for c in range(cols)
)
for word in words:
if exists(word):
result.append(word)
return result
# --- quick sanity check ---
if __name__ == "__main__":
board = [
["o","a","a","n"],
["e","t","a","e"],
["i","h","k","r"],
["i","f","l","v"],
]
words = ["oath","pea","eat","rain"]
print(SolutionNaive().findWords(board, words)) # ['eat', 'oath']
Approach 2 — Trie + Single-pass DFS (Optimal) O(m · n · 4L) with pruning
Build a Trie from all words, then do a single DFS sweep of the board. At every board cell you walk a Trie edge instead of comparing against all words independently. The moment a Trie node has no children matching the next character, you backtrack immediately — pruning entire subtrees. This means work on shared prefixes is done only once, and the total DFS budget is proportional to the Trie, not W times the board.
from typing import List
# ── Trie Node ──────────────────────────────────────────────────────────────────
class TrieNode:
__slots__ = ("children", "word", "refs")
def __init__(self):
self.children: dict[str, "TrieNode"] = {}
self.word: str | None = None # non-None at end-of-word nodes
self.refs: int = 0 # how many words pass through / end here
# ── Main Solution ──────────────────────────────────────────────────────────────
class Solution:
"""
Trie + in-place DFS with dead-branch pruning.
Time (worst): O(m * n * 4^L)
The 4^L factor comes from a single DFS path; the Trie prunes most of it.
In practice, with a real word list the constant factor is dramatically
smaller than the naive O(W * m * n * 4^L).
Space: O(total_chars_in_all_words) for the Trie
O(L) recursion stack (L = longest word)
"""
# ── Phase 1: build Trie ────────────────────────────────────────────────────
def _build_trie(self, words: List[str]) -> TrieNode:
root = TrieNode()
for word in words:
node = root
node.refs += 1
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.refs += 1
node.word = word
return root
# ── Phase 2: prune a branch that is no longer needed ──────────────────────
def _prune(self, root: TrieNode, word: str) -> None:
"""
Walk `word` down from root, decrementing `refs`.
Delete a child pointer whenever refs drops to 0.
This shrinks the Trie as words are collected, so future DFS calls
skip dead branches immediately.
"""
node = root
node.refs -= 1
path: list[tuple[TrieNode, str]] = [] # (parent, char)
for ch in word:
path.append((node, ch))
node = node.children[ch]
node.refs -= 1
# walk back and delete zero-ref nodes
for parent, ch in reversed(path):
if parent.children[ch].refs == 0:
del parent.children[ch]
else:
break # ancestors still have live words, stop
# ── Phase 3: DFS ──────────────────────────────────────────────────────────
def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
root = self._build_trie(words)
rows, cols = len(board), len(board[0])
result: list[str] = []
def dfs(r: int, c: int, node: TrieNode) -> None:
# ① Out-of-bounds check
if r < 0 or r >= rows or c < 0 or c >= cols:
return
ch = board[r][c]
# ② Cell was already visited on this path, or no Trie edge
if ch == "#" or ch not in node.children:
return
nxt = node.children[ch]
# ③ Collect word if we've reached an end-of-word node
if nxt.word:
result.append(nxt.word)
nxt.word = None # prevent duplicate collection
self._prune(root, result[-1]) # remove dead branch
# Note: after pruning, nxt may no longer be reachable via
# node.children[ch]. We must NOT use nxt below if pruned.
# Re-check whether the edge still exists:
if ch not in node.children:
return
nxt = node.children[ch] # re-fetch (same object, not deleted)
# ④ Mark visited in-place (O(1), avoids a separate visited matrix)
board[r][c] = "#"
dfs(r + 1, c, nxt)
dfs(r - 1, c, nxt)
dfs(r, c + 1, nxt)
dfs(r, c - 1, nxt)
board[r][c] = ch # ⑤ restore
for r in range(rows):
for c in range(cols):
if board[r][c] in root.children: # fast early-exit
dfs(r, c, root)
return result
# ── Tests ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
sol = Solution()
# Example 1 (from LC)
board1 = [
["o","a","a","n"],
["e","t","a","e"],
["i","h","k","r"],
["i","f","l","v"],
]
words1 = ["oath","pea","eat","rain"]
out1 = sorted(sol.findWords(board1, words1))
assert out1 == ["eat","oath"], out1
# Example 2 (LC)
board2 = [["a","b"],["c","d"]]
words2 = ["abcb"]
out2 = sol.findWords(board2, words2)
assert out2 == [], out2 # 'b' cannot be visited twice
# Single-cell board
board3 = [["a"]]
out3 = sol.findWords(board3, ["a"])
assert out3 == ["a"], out3
# All same letter — stress test for pruning
board4 = [["a"]*4 for _ in range(4)]
words4 = ["aaa","aaaa","aaaaa"]
out4 = sorted(sol.findWords(board4, words4))
# "aaa", "aaaa", "aaaaa" can all be formed on a 4x4 board of 'a'
assert set(out4) == {"aaa","aaaa","aaaaa"}, out4
print("All tests passed.")
- Early Trie miss — if
board[r][c]is not a child key of the current Trie node, return immediately. No need to explore all 4 neighbours. - Word deduplication via
nxt.word = None— once a word is collected, clear it so a second path leading to the same node doesn't add it again. This is O(1) and avoids a separate seen-set. - Dead-branch pruning (
_prune/refsdecrement) — after collecting a word, if its Trie leaf has no remaining children and no other words pass through it, delete it. As words are found, the Trie shrinks. Late-game DFS calls find shorter Tries and return even faster. This is the differentiator for a high score.
Approach 3 — Trie + Iterative DFS (stack-safe variant) Same complexity
Python's default recursion limit is 1 000. A 12 × 12 board with a 10-letter word already uses 10 stack frames. In the worst case (long snaking words) you can hit the limit. An iterative DFS with an explicit stack avoids this. The trade-off is slightly more bookkeeping: you must push restore actions onto the stack alongside explore actions.
from typing import List
class TrieNode:
__slots__ = ("children", "word")
def __init__(self):
self.children: dict[str, "TrieNode"] = {}
self.word: str | None = None
class SolutionIterative:
"""
Same Trie build, but DFS is iterative to avoid hitting Python's
recursion limit on large boards.
Time / Space: identical to Approach 2.
"""
def _build_trie(self, words: List[str]) -> TrieNode:
root = TrieNode()
for word in words:
node = root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.word = word
return root
def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
root = self._build_trie(words)
rows, cols = len(board), len(board[0])
result: list[str] = []
found_set: set[str] = set()
for start_r in range(rows):
for start_c in range(cols):
ch = board[start_r][start_c]
if ch not in root.children:
continue
# Stack item: (r, c, trie_node, restore_list)
# restore_list accumulates (r, c, original_char) to undo on pop.
# We use a sentinel (None, None, None) to trigger restoration.
stack: list = [(start_r, start_c, root, [])]
while stack:
item = stack[-1]
if item is None:
# Sentinel — restore the cell above this frame
stack.pop()
r_restore, c_restore, orig_ch = stack.pop()
board[r_restore][c_restore] = orig_ch
continue
r, c, node = item
stack.pop()
cur_ch = board[r][c]
if cur_ch == "#" or cur_ch not in node.children:
continue
nxt = node.children[cur_ch]
if nxt.word and nxt.word not in found_set:
found_set.add(nxt.word)
result.append(nxt.word)
nxt.word = None
# Mark visited
board[r][c] = "#"
# Push restore sentinel AFTER the children so it fires last
stack.append((r, c, cur_ch)) # restore data
stack.append(None) # sentinel
for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols:
stack.append((nr, nc, nxt))
return result
if __name__ == "__main__":
sol = SolutionIterative()
board = [
["o","a","a","n"],
["e","t","a","e"],
["i","h","k","r"],
["i","f","l","v"],
]
words = ["oath","pea","eat","rain"]
print(sorted(sol.findWords(board, words))) # ['eat', 'oath']
Time — O(m · n · 4L) where L is the max word length.
- We start DFS from each of the m·n cells: that's the outer loop.
- From each cell, the DFS explores a path of length at most L (capped by the deepest Trie node). At each step there are at most 4 directions — but one is always blocked (where we came from), so effectively 3. This gives 4 · 3L-1 paths per start cell, which is O(4L) as a loose upper bound.
- The Trie prunes any direction where the next character doesn't match a Trie edge. In practice this slashes the real constant dramatically — the worst case only occurs when the board is full of the same character and the word list contains all permutations.
Space — O(total_chars) for the Trie, where total_chars = sum of all word lengths. The recursion stack is O(L). The in-place board marking means no extra visited matrix.
board[r][c] = "#") is O(1) extra space per DFS frame and is the standard interview answer. A separate boolean matrix is equally correct but uses O(m·n) extra space. The catch with in-place: you must restore the cell on backtrack — a common bug is forgetting the restore step when a word is found.
words = list(set(words)) before building the Trie. Otherwise two entries for the same word would both be inserted, and even after nxt.word = None clears the first, a second pass through the same Trie path might attempt to add it again (or leave a stale pointer). Deduplication is a one-liner and eliminates the problem entirely.nr = (r + dr) % rows. You must still mark cells visited per path since you can re-enter the same cell via a different wrap-around path, but the Trie structure and DFS skeleton remain identical.- Forgetting to restore the cell.
board[r][c] = "#"must always be undone after DFS, even when a word is found mid-path. Missing this corrupts subsequent searches from other starting cells. - Duplicate words in result. Two different board paths can spell the same word. Clear
nxt.word = Noneimmediately upon collection — don't rely on a seen-set (though a seen-set is a valid backup). - Not pruning dead Trie branches. Correct but gets a ding. Experienced interviewers will explicitly ask "can you make this faster after words are found?" — know the
refscounter trick cold. - Off-by-one in bounds check.
r < 0 or r >= rows— must be>=not>. A common slip under pressure. - Checking
board[r][c]before the bounds check. Always guard bounds before indexing; doing it in the wrong order causes an IndexError. - Recursion limit on large boards. Mention
sys.setrecursionlimitor the iterative variant. Reddit SWE and MLE loops both flag this. - Complexity statement. Say "O(m · n · 4L) worst case, but the Trie prunes most of it — the naive per-word approach is O(W) times slower." Quantifying the speedup over the brute force signals deeper understanding.
- Trie build complexity. State it explicitly — O(sum of word lengths) — interviewers want to see you account for the preprocessing step.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Overview
You are given:
- a start word
- a target word
- a dictionary of allowed words
Determine whether the words can be connected by a valid transformation sequence.
Each step in the sequence must move from the current word to another word of the same length. The interviewer usually starts with the standard one-character version, then adds a follow-up where a move may change one or two characters.
This is essentially a Word Ladder style graph problem:
- each word is a node
- an edge exists when two words differ by an allowed number of character positions
- the task is to search for a path from start to target
Assume:
- all usable words have the same length
- start and target are lowercase strings
- the dictionary may contain duplicates; treat it as a set
- start does not need to be in the dictionary
- target should still be considered a valid destination even if it is not already in the dictionary
- words should not be revisited once already explored
Part 1: Reachability With One-Character Transforms
Problem Statement
Implement:
def has_path_one_edit(start: str, target: str, words: list[str]) -> bool:
...
Return True if there exists a sequence from start to target such that:
- every step changes exactly one character
- every intermediate word is in the dictionary
- target may be used as the final word even if it is not in the dictionary
Example
start = "hit"
target = "cog"
words = ["hot", "dot", "dog", "lot", "log", "cog"]
has_path_one_edit(start, target, words) # True
One valid path is:
hit -> hot -> dot -> dog -> cog
Part 1 Solution
For plain reachability, either DFS or BFS works. BFS is a good default because it extends naturally to the shortest-path follow-up later.
from collections import deque
def hamming_distance(a: str, b: str) -> int:
return sum(char_a != char_b for char_a, char_b in zip(a, b))
def has_path_one_edit(start: str, target: str, words: list[str]) -> bool:
if len(start) != len(target):
return False
if start == target:
return True
remaining = {word for word in words if len(word) == len(start)}
remaining.add(target)
queue = deque([start])
visited = {start}
while queue:
word = queue.popleft()
for candidate in list(remaining):
if candidate in visited:
continue
if hamming_distance(word, candidate) == 1:
if candidate == target:
return True
visited.add(candidate)
queue.append(candidate)
remaining -= visited
return False
Complexity
Let n be the number of candidate words and m be the word length
Time: O(n^2 * m) in the straightforward implementation
Space: O(n)
Part 2: Reachability With One- Or Two-Character Transforms
Problem Statement
Now extend the rule:
- a valid move may change either exactly one character or exactly two characters
Implement:
def has_path_one_or_two_edits(start: str, target: str, words: list[str]) -> bool:
...
Example
start = "code"
target = "math"
words = ["coda", "cada", "mata"]
has_path_one_or_two_edits(start, target, words) # True
One valid path is:
code -> coda -> cada -> mata -> math
The key jump is:
cada -> mata
which changes two character positions.
Part 2 Solution
The search logic stays the same. The main change is the adjacency rule.
One reason this follow-up is trickier than standard Word Ladder is that the classic single-wildcard bucket trick is built for one-character neighbors. Once two-character jumps are allowed, the simplest interview-safe solution is often to keep the graph implicit and compare candidate words directly.
from collections import deque
def hamming_distance(a: str, b: str) -> int:
return sum(char_a != char_b for char_a, char_b in zip(a, b))
def has_path_one_or_two_edits(start: str, target: str, words: list[str]) -> bool:
if len(start) != len(target):
return False
if start == target:
return True
remaining = {word for word in words if len(word) == len(start)}
remaining.add(target)
queue = deque([start])
visited = {start}
while queue:
word = queue.popleft()
for candidate in list(remaining):
if candidate in visited:
continue
distance = hamming_distance(word, candidate)
if distance == 1 or distance == 2:
if candidate == target:
return True
visited.add(candidate)
queue.append(candidate)
remaining -= visited
return False
Complexity
Time: O(n^2 * m)
Space: O(n)
The asymptotic complexity is unchanged from Part 1. Only the edge predicate changed.
Part 3: Return An Actual Path
Problem Statement
Now return one shortest valid path instead of only a boolean.
Implement:
def shortest_path_one_or_two_edits(
start: str,
target: str,
words: list[str],
) -> list[str]:
...
Return:
- a shortest valid transformation sequence from start to target, inclusive
[]if no path exists
Example
start = "code"
target = "math"
words = ["coda", "cada", "mata"]
shortest_path_one_or_two_edits(start, target, words)
# ["code", "coda", "cada", "mata", "math"]
Part 3 Solution
Once the interviewer asks for a shortest path, BFS is the right tool. Keep a parent map so you can reconstruct the path after reaching target.
from collections import deque
def hamming_distance(a: str, b: str) -> int:
return sum(char_a != char_b for char_a, char_b in zip(a, b))
def shortest_path_one_or_two_edits(
start: str,
target: str,
words: list[str],
) -> list[str]:
if len(start) != len(target):
return []
if start == target:
return [start]
remaining = {word for word in words if len(word) == len(start)}
remaining.add(target)
queue = deque([start])
parent: dict[str, str | None] = {start: None}
while queue:
word = queue.popleft()
if word == target:
break
next_words: list[str] = []
for candidate in remaining:
distance = hamming_distance(word, candidate)
if distance == 1 or distance == 2:
next_words.append(candidate)
for candidate in next_words:
if candidate in parent:
continue
parent[candidate] = word
queue.append(candidate)
remaining -= set(next_words)
if target not in parent:
return []
path: list[str] = []
current: str | None = target
while current is not None:
path.append(current)
current = parent[current]
path.reverse()
return path
Complexity
Time: O(n^2 * m)
Space: O(n)
Discussion Points
Interviewers often use this question to check whether the candidate can:
- model the problem as a graph instead of getting stuck on string manipulation
- explain when DFS is enough versus when BFS is the better choice
- handle the two-character follow-up without overcomplicating the solution too early
- reason about neighbor-generation tradeoffs
Good follow-up discussion:
- DFS is sufficient if the task is only "does any path exist?"
- BFS is better if the task changes to "find the shortest path"
- for large dictionaries, precomputed neighbor indexes can help Part 1
- Part 2 is less friendly to the standard Word Ladder wildcard optimization, so a direct Hamming-distance scan is often the cleanest baseline answer
This is implicit-graph BFS: never materialize the adjacency list — define neighbors via a Hamming-distance predicate and let BFS discover them lazily. The senior move is recognizing that "does a path exist" (DFS or BFS, either fine) and "shortest path" (BFS with a parent map for reconstruction) are different asks, and not over-engineering Part 1 with machinery you only need in Part 3. The trap: candidates reach for the single-character wildcard-bucket optimization out of habit, but it silently breaks once two-character jumps are legal in Part 2 — a clean O(n²·m) Hamming scan is the correct baseline there. Drill this pattern.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Overview
We accidentally dropped the database that stored the current billing state for advertisers. The old transaction logs still exist, so the task is to replay those transactions and rebuild one BillingStatus object per user.
This interview is usually framed as an object-oriented coding problem. The core idea is to model a single account as a BillingStatus class that can ingest transactions over time, then build a dictionary like:
{
user_id: BillingStatus(...),
user_id_2: BillingStatus(...),
}
The interviewer typically adds three layers:
- Basic additive aggregation
- Overwrite transactions
- undo_last and redo_last
Assume:
- replay order is increasing transaction_timestamp
- if two transactions have the same timestamp, break ties by transaction_id for deterministic replay
- monetary_columns is known in advance
- missing monetary fields mean "no change" for that column
- undo_last and redo_last only affect the history of the same user
- overwrite, undo_last, and redo_last are control fields, not monetary columns
- if a transaction uses undo_last or redo_last, treat it as a command row and ignore any monetary fields on that same row
- redo_last follows standard stack semantics: it reapplies the most recently undone regular transaction
- each transaction uses at most one of overwrite, undo_last, or redo_last as its behavioral option, except that plain regular transactions may omit all of them
Part 1: Rebuild Billing Statuses
Problem Statement
Implement a BillingStatus class with two starting monetary columns:
ad_delivery_pennies = 0
payment_pennies = 0
Each transaction may contain one or more monetary columns. When ingesting a transaction, add the transaction values into the current billing status.
Then implement a function that replays a collection of transactions and returns one BillingStatus per user.
Example
monetary_columns = ("ad_delivery_pennies", "payment_pennies")
transactions = {
"ff8bc1c2-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000001,
},
"ff8bc2e4-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000002,
},
"ff8bc4ec-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"payment_pennies": 500,
"transaction_timestamp": 1500000003,
},
"fv24z4ec-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"payment_pennies": 500,
"transaction_timestamp": 1500000004,
},
}
# Expected result:
{
1: BillingStatus(
ad_delivery_pennies=3000,
payment_pennies=1000,
)
}
Part 1 Solution
Use a per-user object and replay the log in timestamp order.
class BillingStatus:
def __init__(self, monetary_columns: tuple[str, ...]):
self.monetary_columns = tuple(monetary_columns)
self.amounts = {column: 0 for column in self.monetary_columns}
def ingest(self, transaction_id: str, transaction: dict) -> None:
for column in self.monetary_columns:
self.amounts[column] += transaction.get(column, 0)
def as_dict(self) -> dict[str, int]:
return dict(self.amounts)
def rebuild_billing_statuses(
transactions: dict[str, dict],
monetary_columns: tuple[str, ...],
) -> dict[int, BillingStatus]:
statuses: dict[int, BillingStatus] = {}
ordered_transactions = sorted(
transactions.items(),
key=lambda item: (item[1]["transaction_timestamp"], item[0]),
)
for transaction_id, transaction in ordered_transactions:
user_id = transaction["user_id"]
status = statuses.setdefault(user_id, BillingStatus(monetary_columns))
status.ingest(transaction_id, transaction)
return statuses
Complexity
Sorting: O(n log n)
Replay: O(n * c), where c is the number of monetary columns
Space: O(u * c), where u is the number of users
Part 2: Add Overwrite Transactions
Problem Statement
Now support a control flag:
"overwrite": True
If overwrite is True, any monetary column present in that transaction should replace the current value for that column instead of being added to it.
Important detail:
- overwrite only applies to columns present in the transaction
- columns not mentioned in the transaction should remain unchanged
Example
monetary_columns = ("ad_delivery_pennies", "payment_pennies")
transactions = {
"ff8ba98a-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000001,
"overwrite": False,
},
"ff8bad4a-8d45-11e9-bc42-526af7764f64": {
"user_id": 2,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000004,
},
"ff8baea8-8d45-11e9-bc42-526af7764f64": {
"user_id": 2,
"payment_pennies": 600,
"transaction_timestamp": 1500000007,
"overwrite": False,
},
"ff8bb4ac-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000002,
"overwrite": False,
},
"ff8bb600-8d45-11e9-bc42-526af7764f64": {
"user_id": 2,
"ad_delivery_pennies": 1000,
"payment_pennies": 500,
"transaction_timestamp": 1500000003,
"overwrite": False,
},
"ff8bb89e-8d45-11e9-bc42-526af7764f64": {
"user_id": 2,
"payment_pennies": 2000,
"transaction_timestamp": 1500000005,
"overwrite": True,
},
"ff8bb9c0-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"payment_pennies": 500,
"transaction_timestamp": 1500000003,
"overwrite": False,
},
"ff8bbf74-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"payment_pennies": 500,
"transaction_timestamp": 1500000004,
"overwrite": True,
},
"ff8bc0a0-8d45-11e9-bc42-526af7764f64": {
"user_id": 2,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000001,
},
"ff8bc1c2-8d45-11e9-bc42-526af7764f64": {
"user_id": 2,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000002,
},
"ff923488-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"payment_pennies": 100,
"transaction_timestamp": 1500000013,
},
}
# Expected result:
{
1: BillingStatus(ad_delivery_pennies=1000, payment_pennies=600),
2: BillingStatus(ad_delivery_pennies=4000, payment_pennies=2600),
}
Part 2 Solution
The replay loop does not change. Only BillingStatus.ingest(...) changes:
class BillingStatus:
def __init__(self, monetary_columns: tuple[str, ...]):
self.monetary_columns = tuple(monetary_columns)
self.amounts = {column: 0 for column in self.monetary_columns}
def ingest(self, transaction_id: str, transaction: dict) -> None:
overwrite = transaction.get("overwrite", False)
for column in self.monetary_columns:
if column not in transaction:
continue
if overwrite:
self.amounts[column] = transaction[column]
else:
self.amounts[column] += transaction[column]
def as_dict(self) -> dict[str, int]:
return dict(self.amounts)
Why This Matches The Example
For user 1:
- ad += 1000
- ad += 1000
- payment += 500
- overwrite both columns to ad = 1000, payment = 500
- payment += 100
Final state:
{
"ad_delivery_pennies": 1000,
"payment_pennies": 600,
}
For user 2, the overwrite only touches payment_pennies, so ad_delivery_pennies keeps its accumulated value.
Part 3: Add undo_last And redo_last
Problem Statement
Now add two more control flags:
"undo_last": True
"redo_last": True
Rules:
- undo_last=True undoes the most recent regular transaction for the same user
- redo_last=True reapplies the most recently undone regular transaction for the same user
- if there is nothing to undo, discard the operation
- if there is nothing to redo, discard the operation
- a regular transaction is:
- a transaction with no control flags, or
- a transaction that only uses overwrite
- assume a transaction never sets both undo_last and redo_last
- undo_last and redo_last transactions are commands, not regular transactions
- if a new regular transaction is applied after an undo, the redo history should be cleared
- if a command row also contains monetary columns, ignore those monetary columns and only execute the command
To remove ambiguity, assume standard editor-style undo/redo semantics:
- undo pops from the applied-history stack
- redo pops from the undone-history stack
- any newly applied regular transaction invalidates redo history
Example
monetary_columns = ("ad_delivery_pennies", "payment_pennies")
transactions = {
"ff8bc1c2-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"transaction_timestamp": 1500000001,
},
"ff8bc2e4-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"undo_last": True,
"transaction_timestamp": 1500000002,
},
"ff8bc4ec-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"payment_pennies": 500,
"transaction_timestamp": 1500000003,
},
"fv24z4ec-8d45-11e9-bc42-526af7764f64": {
"user_id": 1,
"ad_delivery_pennies": 1000,
"payment_pennies": 500,
"transaction_timestamp": 1500000004,
},
}
# Expected result:
{
1: BillingStatus(ad_delivery_pennies=1000, payment_pennies=1000),
}
Part 3 Solution
Store enough history to reverse and reapply the effect of each regular transaction.
from dataclasses import dataclass
@dataclass
class ReplayRecord:
columns: tuple[str, ...]
before: dict[str, int]
after: dict[str, int]
class BillingStatus:
def __init__(self, monetary_columns: tuple[str, ...]):
self.monetary_columns = tuple(monetary_columns)
self.amounts = {column: 0 for column in self.monetary_columns}
self._applied: list[ReplayRecord] = []
self._undone: list[ReplayRecord] = []
def ingest(self, transaction_id: str, transaction: dict) -> None:
if transaction.get("undo_last"):
self._undo_last()
return
if transaction.get("redo_last"):
self._redo_last()
return
touched_columns = tuple(
column
for column in self.monetary_columns
if column in transaction
)
if not touched_columns:
return
overwrite = transaction.get("overwrite", False)
before = {column: self.amounts[column] for column in touched_columns}
for column in touched_columns:
value = transaction[column]
if overwrite:
self.amounts[column] = value
else:
self.amounts[column] += value
after = {column: self.amounts[column] for column in touched_columns}
self._applied.append(
ReplayRecord(
columns=touched_columns,
before=before,
after=after,
)
)
self._undone.clear()
def _undo_last(self) -> None:
if not self._applied:
return
record = self._applied.pop()
for column in record.columns:
self.amounts[column] = record.before[column]
self._undone.append(record)
def _redo_last(self) -> None:
if not self._undone:
return
record = self._undone.pop()
for column in record.columns:
self.amounts[column] = record.after[column]
self._applied.append(record)
def as_dict(self) -> dict[str, int]:
return dict(self.amounts)
def rebuild_billing_statuses(
transactions: dict[str, dict],
monetary_columns: tuple[str, ...],
) -> dict[int, BillingStatus]:
statuses: dict[int, BillingStatus] = {}
ordered_transactions = sorted(
transactions.items(),
key=lambda item: (item[1]["transaction_timestamp"], item[0]),
)
for transaction_id, transaction in ordered_transactions:
user_id = transaction["user_id"]
status = statuses.setdefault(user_id, BillingStatus(monetary_columns))
status.ingest(transaction_id, transaction)
return statuses
Why This Design Works
- BillingStatus owns the mutable state for one user
- each regular transaction stores the exact before and after values for the touched columns
- undo_last restores before
- redo_last restores after
- overwrite transactions work naturally because the history captures absolute values, not only deltas
This is usually the main OOD discussion point: do you store only deltas, or do you store reversible command history? Once overwrite exists, storing before and after snapshots for touched columns keeps the implementation simple and correct.
Complexity
Let c be the number of monetary columns.
- regular ingest: O(c)
- undo_last: O(k)
- redo_last: O(k)
where k is the number of columns touched by that transaction, and k <= c.
Overall replay remains:
- Sorting: O(n log n)
- Replay: O(n * c)
- Space: O(u * c + h), where h is the per-user history retained for undo/redo
Common Follow-Ups
Interviewers may push on a few practical extensions:
Idempotency
- What if the logs contain duplicate transaction IDs?
- One answer is to keep a per-user or global seen_transaction_ids set.
Streaming ingestion
- The same BillingStatus.ingest(...) API works for both historical replay and live traffic.
Checkpointing
- If the logs are huge, periodically snapshot BillingStatus and only replay newer transactions after the latest checkpoint.
Auditability
- In production you would likely store the command history, not just the final balances, so that later investigations can explain why a balance has its current value.
This is the Command pattern wearing a billing costume: the layered ask (additive → overwrite → undo/redo) is testing whether you isolate mutation behind a single ingest seam so each new requirement is a localized change, not a rewrite. The decisive trap is in Part 3 — candidates who stored only deltas in Parts 1-2 get stuck the moment overwrite appears, because you cannot invert an overwrite from a delta alone. Capturing before/after snapshots of just the touched columns makes undo/redo fall out trivially and is the line interviewers are listening for. Also nail the determinism details: sort by (timestamp, transaction_id) and clear the redo stack on any new regular transaction. Drill this pattern.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Both files are read. The problem statements weren't included in the source dumps (they show only the Solution tab content), so I'll reconstruct the standard problem statements faithfully and preserve all code verbatim. Here is the HTML fragment.Problem. Given a string s, you may insert characters only at the front of s to make it a palindrome. Return the shortest such palindrome. (LC 214 — flagged as recurring in Reddit MLE loops; interviewers confirm they reuse it precisely because "KMP unless you know the trick" separates candidates who memorise patterns from those who understand them.)
reverse(suffix) + s, where suffix = s[k:] and s[:k] is a palindrome. To minimise the number of prepended characters, we want k as large as possible — i.e., find the longest palindromic prefix of s. The suffix of s that is not covered by that prefix is reversed and prepended.
Approach 1 — Brute Force (O(n²) time, O(n) space)
Try every possible prefix of s starting from the full string and working down. The first (longest) prefix that is itself a palindrome gives us the answer. Checking whether a string of length k is a palindrome costs O(k), and we check up to n prefixes, so the worst case is O(n²) time.
class SolutionBrute:
def shortestPalindrome(self, s: str) -> str:
# Edge case: empty or single character is already a palindrome
if len(s) <= 1:
return s
def is_palindrome(t: str) -> bool:
return t == t[::-1]
# Try the longest possible palindromic prefix first (greedy from the top).
# s[:n], s[:n-1], ..., s[:1] — stop at the first palindrome.
n = len(s)
for k in range(n, 0, -1):
if is_palindrome(s[:k]):
# Characters not in the palindromic prefix must be prepended in reverse
suffix = s[k:] # the "remainder" that is NOT part of the prefix
return suffix[::-1] + s # prepend its reverse
# Fallback: prepend the reverse of the entire string minus the first char
# (single char is always a palindrome, so k=1 always qualifies above)
return s[::-1] + s # unreachable in practice, but keeps the function total
# --- tests ---
def _test_brute():
sol = SolutionBrute()
assert sol.shortestPalindrome("aacecaaa") == "aaacecaaa"
assert sol.shortestPalindrome("abcd") == "dcbabcd"
assert sol.shortestPalindrome("") == ""
assert sol.shortestPalindrome("a") == "a"
assert sol.shortestPalindrome("aa") == "aa"
assert sol.shortestPalindrome("aba") == "aba"
assert sol.shortestPalindrome("abba") == "abba"
assert sol.shortestPalindrome("race") == "ecarace"
print("brute force: all tests passed")
_test_brute()
"aaaa…ab" (n−1 identical chars followed by one different char). The only palindromic prefix is the single first character, so we iterate all the way down to k=1, checking O(n) substrings each of length approaching O(n) — truly O(n²). LC's test suite includes such adversarial inputs.
Approach 2 — KMP Failure Function on s + '#' + reverse(s) (O(n) time, O(n) space)
The key observation: a prefix s[:k] is a palindrome if and only if it equals its own reverse, i.e., it matches the suffix of reverse(s) of the same length. KMP's failure function (also called the "partial match" or "prefix function") finds, for every position in a string, the length of the longest proper prefix of that string that is also a suffix. If we concatenate t = s + '#' + reverse(s), the failure function's last value gives the length of the longest prefix of s that also appears as a suffix of reverse(s) — which is exactly the longest palindromic prefix of s. The '#' separator (any character not in the input alphabet) prevents a spurious match that would cross the boundary between s and reverse(s).
class Solution:
def shortestPalindrome(self, s: str) -> str:
if not s:
return s
rev = s[::-1]
# Build the combined string with a separator that cannot appear in the
# input alphabet. The '#' sentinel is sufficient for ASCII lowercase.
combined = s + '#' + rev # length = 2n + 1
# ---------------------------------------------------------------
# Compute the KMP "failure function" (a.k.a. prefix function / lps).
# lps[i] = length of the longest proper prefix of combined[:i+1]
# that is also a suffix of combined[:i+1].
# ---------------------------------------------------------------
n = len(combined)
lps = [0] * n
# We process indices 1 .. n-1 (lps[0] is always 0 by definition).
# 'length' tracks the length of the previous longest prefix-suffix.
length = 0
i = 1
while i < n:
if combined[i] == combined[length]:
# Characters match: extend the current prefix-suffix.
length += 1
lps[i] = length
i += 1
else:
if length != 0:
# Fall back using the already-computed table.
# We do NOT increment i here — we just try a shorter prefix.
length = lps[length - 1]
else:
# No prefix-suffix possible; move on.
lps[i] = 0
i += 1
# lps[-1] is the length of the longest palindromic prefix of s.
pal_prefix_len = lps[-1]
# The characters of s beyond the palindromic prefix must be prepended
# in reverse to complete the palindrome.
suffix = s[pal_prefix_len:] # the "uncovered" tail of s
return suffix[::-1] + s # prepend reversed tail
# ---------------------------------------------------------------
# Worked trace for s = "aacecaaa"
# ---------------------------------------------------------------
# rev = "aaacecaa"
# combined = "aacecaaa#aaacecaa" (length 17)
#
# KMP prefix function step-by-step (only key transitions shown):
# i char length lps[i]
# 0 a — 0 (by definition)
# 1 a 0 → 1 1
# 2 c 0 0
# 3 e 0 0
# 4 c 0 0
# 5 a 0 → 1 1
# 6 a 1 → 2 2
# 7 a 2 → 3 3
# 8 # 3 → … → 0 0 (# never matches 'a', so fall all the way to 0)
# 9 a 0 → 1 1
# 10 a 1 → 2 2
# 11 a 2 → 3 3
# 12 c 3 → (fallback to lps[2]=0) → 0 0
# — wait, 'c' != combined[3]='e', and lps[2]=0, so 0
# Actually combined[3]='e', combined[12]='c' → no match, length=lps[2]=0
# Then combined[0]='a' vs 'c' → no match, lps[12]=0
# 13 e 0 → 0 (combined[0]='a' != 'e') → 0
# 14 c 0 → 0 (combined[0]='a' != 'c') → 0
# 15 a 0 → 1 1
# 16 a 1 → 2 2 ← THIS is lps[-1]
# But wait — combined[1]='a', combined[16]='a' ... let's re-examine.
# Longest palindromic prefix of "aacecaaa" is "aacecaa" (length 7)?
# No — "aacecaaa" reversed is "aaacecaa". Prefix "aacecaa" (k=7)?
# "aacecaa" reversed = "aacecaa" → yes, it's a palindrome! length=7
# BUT we said lps[-1]=7? Let's recount: lps[-1] gives the match length.
# "aacecaaa"[:7] = "aacecaa" is indeed a palindrome.
# suffix = s[7:] = "a" → reversed = "a" → answer = "a" + "aacecaaa" = "aaacecaaa" ✓
#
# ---------------------------------------------------------------
# Worked trace for s = "abcd"
# ---------------------------------------------------------------
# rev = "dcba"
# combined = "abcd#dcba" (length 9)
# lps[-1] = 1 (only "a" is a palindromic prefix)
# suffix = "bcd" → reversed = "dcb"
# answer = "dcb" + "abcd" = "dcbabcd" ✓
# --- tests ---
def _test_kmp():
sol = Solution()
assert sol.shortestPalindrome("aacecaaa") == "aaacecaaa", sol.shortestPalindrome("aacecaaa")
assert sol.shortestPalindrome("abcd") == "dcbabcd"
assert sol.shortestPalindrome("") == ""
assert sol.shortestPalindrome("a") == "a"
assert sol.shortestPalindrome("aa") == "aa"
assert sol.shortestPalindrome("aba") == "aba"
assert sol.shortestPalindrome("abba") == "abba"
assert sol.shortestPalindrome("race") == "ecarace"
# adversarial: all same chars + one different
assert sol.shortestPalindrome("aaab") == "baaab"
# entire string already a palindrome
assert sol.shortestPalindrome("racecar") == "racecar"
print("KMP solution: all tests passed")
_test_kmp()
combined = s + rev. Consider s = "aa": combined = "aaaa". The failure function's last value would be 3, suggesting a palindromic prefix of length 3 — but s only has length 2. The separator caps the "legitimate" match to at most len(s). If the separator itself occurred in the input (e.g., you chose 'a'), it could form spurious matches. Using a character outside the input alphabet guarantees lps never "jumps over" the separator.
lps[i] = length of the longest string that is simultaneously a prefix of combined (which starts with s) and a suffix of combined[:i+1]. At i = len(combined) - 1 (the last position), the suffix of combined lies entirely in rev = reverse(s) (because the separator breaks any crossing). A prefix of s of length k equals a suffix of rev of length k if and only if s[:k] == rev[len(rev)-k:], i.e., s[:k] == s[:k][::-1] — a palindrome. So the last lps value is the length of the longest palindromic prefix.
Approach 3 — Rolling Hash (O(n) expected time, O(n) space)
A rolling (Rabin-Karp style) hash can check, in O(1) amortised time, whether a prefix equals its reverse. We scan from the full length down to 1, maintaining both the forward hash of s[:k] and the reverse hash, stopping at the first palindromic prefix. Expected O(n) time; a single hash has a small collision probability, so double-hashing is used in production. This avoids the KMP construction entirely and is useful if you blank on the KMP trick under pressure.
class SolutionRollingHash:
def shortestPalindrome(self, s: str) -> str:
if not s:
return s
# Two independent (base, mod) pairs to reduce collision probability.
BASE1, MOD1 = 131, (1 << 61) - 1 # Mersenne prime — fast mod
BASE2, MOD2 = 137, (1 << 31) - 1
n = len(s)
# Precompute forward hash: hash(s[:k]) for all k in [1..n].
# fwd[k] = hash of s[0..k-1]
fwd1 = [0] * (n + 1)
fwd2 = [0] * (n + 1)
for i, ch in enumerate(s):
fwd1[i + 1] = (fwd1[i] * BASE1 + ord(ch)) % MOD1
fwd2[i + 1] = (fwd2[i] * BASE2 + ord(ch)) % MOD2
# Precompute reverse hash: hash(s[k-1..0]) for all k.
# rev[k] = hash of s[:k] read right-to-left (i.e., hash of s[:k][::-1])
pow1 = [1] * (n + 1)
pow2 = [1] * (n + 1)
for i in range(1, n + 1):
pow1[i] = pow1[i - 1] * BASE1 % MOD1
pow2[i] = pow2[i - 1] * BASE2 % MOD2
# rev_hash[k] = polynomial hash of the reversed prefix s[:k]
# Build it incrementally: append s[k-1] as the new *leading* character.
# rev_hash[k] = ord(s[k-1]) * BASE^(k-1) + rev_hash[k-1]
rev1 = [0] * (n + 1)
rev2 = [0] * (n + 1)
for i in range(1, n + 1):
rev1[i] = (rev1[i - 1] + ord(s[i - 1]) * pow1[i - 1]) % MOD1
rev2[i] = (rev2[i - 1] + ord(s[i - 1]) * pow2[i - 1]) % MOD2
# But the forward hash of s[:k] uses a *different* polynomial convention:
# fwd[k] = s[0]*B^(k-1) + s[1]*B^(k-2) + ... + s[k-1]*B^0
# rev[k] should match fwd[k] when the prefix is a palindrome.
# For them to be comparable we need the same polynomial convention.
# Recompute rev_hash using the same left-to-right convention on the reversed string.
rev_s = s[::-1]
rfwd1 = [0] * (n + 1)
rfwd2 = [0] * (n + 1)
for i, ch in enumerate(rev_s):
rfwd1[i + 1] = (rfwd1[i] * BASE1 + ord(ch)) % MOD1
rfwd2[i + 1] = (rfwd2[i] * BASE2 + ord(ch)) % MOD2
# Helper: extract hash of rev_s[n-k .. n-1] (= reverse of s[:k])
# using suffix extraction from rfwd.
# rfwd covers the entire reversed string; we want the LAST k characters of rev_s,
# which correspond to the first k characters of s reversed.
# rfwd[n] covers all of rev_s. The last k chars of rev_s are rev_s[n-k..n-1].
# hash(rev_s[n-k..n-1]) = rfwd[n] - rfwd[n-k] * BASE^k (standard Rabin-Karp)
def rev_hash_of_prefix(k):
h1 = (rfwd1[n] - rfwd1[n - k] * pow1[k]) % MOD1
h2 = (rfwd2[n] - rfwd2[n - k] * pow2[k]) % MOD2
return h1, h2
# Scan from the longest possible palindromic prefix down.
# We want the largest k such that hash(s[:k]) == hash(reverse(s[:k])).
best_k = 1 # single character is always a palindrome
for k in range(n, 0, -1):
# hash of s[:k]
h_fwd = (fwd1[k], fwd2[k])
# hash of reverse(s[:k])
h_rev = rev_hash_of_prefix(k)
if h_fwd == h_rev:
best_k = k
break
suffix = s[best_k:]
return suffix[::-1] + s
# --- tests ---
def _test_hash():
sol = SolutionRollingHash()
assert sol.shortestPalindrome("aacecaaa") == "aaacecaaa"
assert sol.shortestPalindrome("abcd") == "dcbabcd"
assert sol.shortestPalindrome("") == ""
assert sol.shortestPalindrome("a") == "a"
assert sol.shortestPalindrome("aba") == "aba"
assert sol.shortestPalindrome("race") == "ecarace"
assert sol.shortestPalindrome("aaab") == "baaab"
assert sol.shortestPalindrome("racecar") == "racecar"
print("rolling hash solution: all tests passed")
_test_hash()
Complexity Summary
| Approach | Time | Space | Notes |
|---|---|---|---|
| Brute force | O(n²) | O(n) | TLEs on adversarial input |
| KMP on s+'#'+rev | O(n) | O(n) | Deterministic; the "intended" solution |
| Rolling hash | O(n) expected | O(n) | Tiny collision probability; good alternative |
t:lps[i] = the length of the longest string that is both a proper prefix of t (does not equal t itself) and a suffix of t[:i+1].The key amortisation argument:
length can increase by at most 1 per outer-loop iteration (i increments). It can decrease, but only to values reachable through the lps chain, and the total number of decreases across the entire loop is bounded by the total number of increases — so the loop body runs at most O(2n) = O(n) times total. This is why building the failure function is O(n), not O(n²), even though the inner while loop looks dangerous.
s. Build combined = rev + '#' + s (reverse the roles) and take lps[-1]. Alternatively, just run the front-insertion solution on reverse(s) and reverse the result.dp[i][j] = minimum insertions to make s[i..j] a palindrome. Alternatively, it equals n - LCS(s, reverse(s)), where LCS is the Longest Common Subsequence. This is strictly harder than the front-only variant.combined = rev + '#' + s, compute the Z-array (Z[i] = length of the longest substring starting from position i that matches a prefix of combined), then find the largest k such that Z[n+1+k] + k == n, meaning the last k characters of s are matched by the first k characters of rev… which means s[n-k:] is the reverse of rev[:k] = s[n-k:], confirming a palindromic suffix of rev that aligns to the end of s. Rolling hash is the other clean alternative (see Approach 3).str handles them at the code-point level, which is usually correct. If you need grapheme-cluster-level palindrome checking, you'd use the grapheme library to split into grapheme clusters first, then apply the same algorithm on the cluster list.s. For lowercase-only input, any non-lowercase ASCII char works ('#', '|', '$'). For arbitrary Unicode, use a code point that cannot appear in the input — e.g., the null byte '\x00' if the input is user text (which rarely contains null bytes). The key invariant: the separator must make lps unable to "cross" the boundary, so the maximum lps value is capped at len(s).reverse(suffix) is always correct.p = s[:k] be a palindrome and q = s[k:] be the remaining suffix. The candidate answer is reverse(q) + s = reverse(q) + p + q. We need to show this is a palindrome:Its reverse is
reverse(q) + reverse(p) + q = reverse(q) + p + q (since p is a palindrome, reverse(p) = p).That equals the original string. QED. Minimality follows from the fact that any shorter answer would require a longer palindromic prefix, and we chose the longest one.
2n + 1; lps has the same length. Access lps[-1] (index 2n), not lps[n]. A common bug is computing lps only up to index n and missing the answer.2. Empty string.
s = "" must return "". With the guard if not s: return s this is safe. Without it, combined = "#", lps = [0], lps[-1] = 0, suffix = "", answer = "" — still correct, but interviewers want to see explicit handling.3. Already-palindrome input. If
s is already a palindrome, lps[-1] = len(s), suffix = "", and the answer is just s. Verify your code handles this without crashing (no empty-slice issues).4. Separator in the input. If the problem says input contains any printable ASCII character,
'#' could appear. Use '\x00' or another safe sentinel. In the LC 214 problem statement input is lowercase English letters only, so '#' is safe.5. Confusing "palindromic prefix" with "longest palindromic substring." The longest palindromic substring (Manacher's, O(n)) is a different and harder-to-apply result. The constraint here is that the palindrome must start at index 0.
6. KMP fall-back loop. The
while length != 0 and combined[i] != combined[length] loop must fall all the way to 0 before giving up; a common bug is writing length -= 1 instead of length = lps[length - 1], turning the algorithm O(n²).What interviewers reward: stating the palindromic-prefix insight before writing code; deriving (not memorising) why
s + '#' + rev works; explaining the amortised O(n) argument for the failure function; mentioning the rolling-hash alternative; clean handling of edge cases.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem. Given the head of a singly linked list, reorder it so all odd-indexed nodes appear first (in original order), followed by all even-indexed nodes (in original order). Indexing is 1-based: node 1 is odd, node 2 is even, etc. You must do this in place with O(1) extra space and O(n) time. Return the head of the reordered list.
Reddit context. This is LC 328 and a frequent screen question at companies that care about pointer manipulation (Meta, Google, Stripe). It tests whether you can maintain multiple "running tails" simultaneously without losing references — the same mental model used in list partitioning, merging k sorted lists, and interleave problems. A candidate who reaches for a vector of values is immediately downgraded.
Approach 1 — Collect Values, Rebuild (O(n) time, O(n) space) — Brute Force
Walk the list once, push values of odd-indexed nodes into one array and even-indexed nodes into another, then walk the list a second time and overwrite node values. Simple to reason about, but uses O(n) extra space and won't satisfy the problem constraint. Worth stating aloud in an interview to show you understand the trade-off before pivoting to the in-place approach.
from typing import Optional
class ListNode:
def __init__(self, val: int = 0, next: 'Optional[ListNode]' = None):
self.val = val
self.next = next
class SolutionBrute:
def oddEvenList(self, head: Optional[ListNode]) -> Optional[ListNode]:
# O(n) time, O(n) space — NOT acceptable per the problem constraint.
# Stated here to anchor the discussion.
if not head:
return head
odds, evens = [], []
node, idx = head, 1
while node:
if idx % 2 == 1:
odds.append(node.val)
else:
evens.append(node.val)
node = node.next
idx += 1
# Overwrite values in the original list
node = head
for v in odds + evens:
node.val = v
node = node.next
return head
# ---- helpers for testing ----
def make_list(vals):
dummy = ListNode(0)
cur = dummy
for v in vals:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
def list_vals(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
# smoke test
assert list_vals(SolutionBrute().oddEvenList(make_list([1,2,3,4,5]))) == [1,3,5,2,4]
assert list_vals(SolutionBrute().oddEvenList(make_list([2,1,3,5,6,4,7]))) == [2,3,6,7,1,5,4]
assert list_vals(SolutionBrute().oddEvenList(make_list([]))) == []
assert list_vals(SolutionBrute().oddEvenList(make_list([1]))) == [1]
assert list_vals(SolutionBrute().oddEvenList(make_list([1,2]))) == [1,2]
Approach 2 — Two-Pointer In-Place Weave (O(n) time, O(1) space) — Canonical / Optimal
Maintain two "tail" pointers: odd always points to the last node appended to the odd chain, and even always points to the last node appended to the even chain. We also save even_head (= original node 2) so we can splice the two chains together at the end. In each iteration we do two rewires: odd.next = even.next (odd tail skips the even node it was pointing through) and even.next = odd.next (even tail skips the new odd node). We then advance both tails by one in their respective chains.
No new nodes are allocated; we only bend existing next pointers. The two chains build up side-by-side in a single linear pass, then a single extra assignment stitches them together at the end.
from typing import Optional
class ListNode:
def __init__(self, val: int = 0, next: 'Optional[ListNode]' = None):
self.val = val
self.next = next
class Solution:
def oddEvenList(self, head: Optional[ListNode]) -> Optional[ListNode]:
# ----------------------------------------------------------------
# Base cases: 0 or 1 node — already trivially ordered.
# Also guards against head.next being None below.
# ----------------------------------------------------------------
if head is None or head.next is None:
return head
# odd = running tail of the odd-indexed sublist (starts at node 1)
# even = running tail of the even-indexed sublist (starts at node 2)
# even_head is locked in now and never changes — we need it to
# splice the two chains after the loop.
odd = head # node 1 (1-based, odd)
even = head.next # node 2 (1-based, even)
even_head = even # save forever
# Loop invariant entering each iteration:
# odd points to the last odd-indexed node processed so far
# even points to the last even-indexed node processed so far
# even.next is the next odd-indexed node (if it exists)
#
# We stop when even is None (list had an even number of nodes and
# even fell off the end) OR even.next is None (even was the last
# node, so there is no following odd node to pull over).
while even is not None and even.next is not None:
# Step 1: pull the next odd node (even.next) into the odd chain.
odd.next = even.next # odd tail now points to the upcoming odd node
odd = odd.next # advance odd tail to that node
# Step 2: pull the next even node (odd.next after advancing) into
# the even chain. odd.next is now the node that comes right after
# the node we just made the odd tail — which is an even-indexed node.
even.next = odd.next # even tail now points to the upcoming even node
even = even.next # advance even tail to that node
# Stitch: last odd node points to the head of the even sublist.
odd.next = even_head
return head
# ---- helpers ----
def make_list(vals):
dummy = ListNode(0)
cur = dummy
for v in vals:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
def list_vals(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
# ---- exhaustive tests ----
sol = Solution()
# LC examples
assert list_vals(sol.oddEvenList(make_list([1,2,3,4,5]))) == [1,3,5,2,4]
assert list_vals(sol.oddEvenList(make_list([2,1,3,5,6,4,7]))) == [2,3,6,7,1,5,4]
# Edge: empty
assert list_vals(sol.oddEvenList(make_list([]))) == []
# Edge: single node
assert list_vals(sol.oddEvenList(make_list([42]))) == [42]
# Edge: exactly two nodes (even-length, minimal)
assert list_vals(sol.oddEvenList(make_list([1,2]))) == [1,2]
# Edge: three nodes (odd-length, the loop runs exactly once)
assert list_vals(sol.oddEvenList(make_list([1,2,3]))) == [1,3,2]
# Edge: four nodes (even-length)
assert list_vals(sol.oddEvenList(make_list([1,2,3,4]))) == [1,3,2,4]
# Larger
assert list_vals(sol.oddEvenList(make_list([1,2,3,4,5,6]))) == [1,3,5,2,4,6]
assert list_vals(sol.oddEvenList(make_list([1,2,3,4,5,6,7]))) == [1,3,5,7,2,4,6]
print("All tests passed.")
Initial state: odd=1, even=2, even_head=2. List: 1→2→3→4→5→None.
Iteration 1 (even=2 not None, even.next=3 not None):
odd.next = 3 → list: 1→3→4→5→None, 2→3→4→5→None
odd = 3
even.next = 4 → 2→4→5→None
even = 4
State: odd=3, even=4. odd-chain: 1→3. even-chain: 2→4.
Iteration 2 (even=4 not None, even.next=5 not None):
odd.next = 5 → odd-chain: 1→3→5
odd = 5
even.next = None (odd.next after advancing = 5.next = None)
even = None
State: odd=5, even=None. Loop exits.
Stitch: odd.next = even_head → 5→2→4→None.
Final: 1→3→5→2→4→None. ✓
The loop guard is while even is not None and even.next is not None.
- even is not None — if even fell off the end (even-length list), there is no next even node to advance to. Attempting
even.nextwould raise AttributeError. - even.next is not None — if even exists but
even.nextis None, there is no next odd node to pull across. Attemptingodd.next = even.nextwould setodd.next = Noneand thenodd = odd.nextwould make odd None, breaking the stitch.
Checking only odd.next is not None is also wrong: on an even-length list odd.next points to an even-indexed node just before the end, so the condition can let the loop run one step too many and clobber even's pointer.
Time: O(n). Each node is visited exactly once. The two tails advance in lockstep, each moving one step per iteration. The total iterations are ⌊n/2⌋ − 1 (roughly), which is O(n).
Space: O(1). We use only four pointer variables: odd, even, even_head, and implicitly the loop variable. No arrays, no recursion stack, no hash maps.
- Losing even_head. Forgetting to save
even_head = head.nextbefore the loop. After the loop,evenhas advanced (and may be None), so you cannot recover the original even head. - Wrong loop termination. Using
while odd.next and even.nextor justwhile even. The former drops the last node on odd-length lists; the latter causes a null dereference on even-length lists. - Advancing odd before rewiring even. The order inside the loop matters. You must assign
even.next = odd.nextafter advancingodd, because at that pointodd.nextis the next even node (the one right after the odd node you just annexed). - Off-by-one on base cases. Not guarding
head.next is Nonecauses a crash when you unconditionally doeven = head.nexton a 1-element list and then enter the loop guard check. - Mutating values instead of pointers. Swapping
.valfields is legal only when nodes carry simple integers; real interview problems expect pointer rewiring.
Approach 3 — Explicit Index Tracking (O(n) time, O(1) space) — Alternative clarity
Some candidates find it clearer to carry an explicit index counter and two dummy heads (sentinels). This avoids index-parity reasoning at the pointer level, making the code more obviously correct, though it adds two dummy nodes as constant allocation. Interviewers generally accept this as O(1) space since it is bounded by a constant regardless of n.
from typing import Optional
class ListNode:
def __init__(self, val: int = 0, next: 'Optional[ListNode]' = None):
self.val = val
self.next = next
class SolutionDummy:
def oddEvenList(self, head: Optional[ListNode]) -> Optional[ListNode]:
odd_dummy = ListNode(0) # sentinel for odd chain
even_dummy = ListNode(0) # sentinel for even chain
odd_tail = odd_dummy
even_tail = even_dummy
idx = 1
node = head
while node:
nxt = node.next # save before we clobber node.next
node.next = None # cleanly detach
if idx % 2 == 1:
odd_tail.next = node
odd_tail = odd_tail.next
else:
even_tail.next = node
even_tail = even_tail.next
node = nxt
idx += 1
# Splice even chain onto odd chain
odd_tail.next = even_dummy.next
return odd_dummy.next
# ---- helpers ----
def make_list(vals):
dummy = ListNode(0)
cur = dummy
for v in vals:
cur.next = ListNode(v)
cur = cur.next
return dummy.next
def list_vals(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
sol = SolutionDummy()
assert list_vals(sol.oddEvenList(make_list([1,2,3,4,5]))) == [1,3,5,2,4]
assert list_vals(sol.oddEvenList(make_list([2,1,3,5,6,4,7]))) == [2,3,6,7,1,5,4]
assert list_vals(sol.oddEvenList(make_list([]))) == []
assert list_vals(sol.oddEvenList(make_list([1]))) == [1]
assert list_vals(sol.oddEvenList(make_list([1,2]))) == [1,2]
print("SolutionDummy: all tests passed.")
The dummy-head approach allocates two extra constant-size nodes and does an explicit modulo operation each iteration. It is also O(1) space and O(n) time. Approach 2 is what LeetCode's editorial teaches and is slightly faster in practice (no mod, no dummy allocation). Both are interview-acceptable; Approach 2 is what interviewers expect at senior level.
Approach 4 — Recursive (O(n) time, O(n) stack space) — Educational only
A recursive formulation is elegant to think about but uses O(n) stack space (one frame per node) and is explicitly ruled out by the O(1) space constraint. Include this only if the interviewer asks about a recursive interpretation to demonstrate breadth, then immediately flag the stack concern.
from typing import Optional
class ListNode:
def __init__(self, val: int = 0, next: 'Optional[ListNode]' = None):
self.val = val
self.next = next
class SolutionRecursive:
"""
NOT O(1) space. O(n) call stack. Shown for conceptual completeness only.
"""
def oddEvenList(self, head: Optional[ListNode]) -> Optional[ListNode]:
# Collect all odd-indexed nodes, then all even-indexed nodes via recursion.
odds = self._pick(head, skip=2) # start at node 1, step 2
evens = self._pick(head.next if head else None, skip=2)
# Stitch
tail = odds
while tail and tail.next:
tail = tail.next
if tail:
tail.next = evens
return odds
def _pick(self, node: Optional[ListNode], skip: int) -> Optional[ListNode]:
"""Return a new chain containing every skip-th node starting from node."""
if node is None:
return None
# The node after our current one in the same parity group:
node.next = self._pick(node.next.next if node.next else None, skip)
return node
Follow-up questions interviewers push
if head is None or head.next is None: return head handles both. If head.next is None we would set even = None and then the loop body would immediately dereference even.next, crashing. Always dry-run n=0 and n=1 before declaring done.prev pointer when rewiring. The complexity stays O(n) / O(1). The same approach handles it: gather odd-indexed nodes, then even-indexed nodes, rewire prev/next fields accordingly. No fundamentally different algorithm is needed.even either points to the last even node or is None — you cannot recover the start of the even chain from either of those states without additional storage. The save is O(1) and unavoidable.- Termination condition (most common failure) — Interviewers specifically probe n=4 (even-length) to catch
while even.next-only guards that dereference a null even pointer. - even_head save — Not saving it before the loop is an automatic deduct. Interviewers will ask "what is even pointing to after the loop?" to expose this.
- Order of rewires inside the loop — You must do
odd.next = even.next; odd = odd.nextbeforeeven.next = odd.next; even = even.next. Reversing the two pairs loses the reference to the incoming odd node. - Pointer discipline vs. value swapping — At senior level, solving this by copying .val is a red flag. The interviewer expects pointer manipulation.
- O(1) space awareness — Immediately calling out that arrays are disqualified shows you read constraints. Candidates who need to be corrected lose points.
- Dry-run — Always trace through [1,2,3,4] and [1,2,3,4,5] on the whiteboard or in comments. Interviewers at Meta and Google watch for this to calibrate your debugging habits.
- Relating to Reorder List / Partition List — Naming the pattern ("two running tails, stitch at end") and connecting it to sibling problems signals senior-level pattern recognition, not just problem-specific recall.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Overview
This round is a fundamentals-heavy Machine Learning Engineer discussion. The interviewer typically starts with a simple supervised learning setup, then uses a plot of two overlapping class distributions to probe how well you understand modeling assumptions, decision boundaries, feature choice, and error trade-offs.
The question often feels conversational rather than rigidly scripted. Expect the interviewer to give hints or feedback and use your answer to branch into deeper follow-ups.
Core Prompt
You are given:
- a feature x
- a target label y
The interviewer asks: how would you model the relationship between x and y?
After that, they show a figure with two class-conditional distributions over the same feature space. The two curves overlap in the middle region. Use that figure to explain:
- whether the classes are linearly separable
- whether a single threshold is reasonable
- what the overlap means for Bayes error and unavoidable misclassification
- how you would choose a threshold in practice
- which metrics you would optimize if false positives and false negatives have different costs
What The Interviewer Is Looking For
Modeling Basics
- Clarify the prediction task first:
- regression if y is continuous
- binary or multiclass classification if y is categorical
- Start with a simple baseline before jumping to a more complex model
- Explain how the amount and shape of the data affect the model choice
Interpreting The Overlap Figure
The key point is not just "pick a threshold." The more complete answer is:
- if two class distributions overlap, the problem is not perfectly separable on that feature alone
- a threshold may still be a reasonable decision rule if the feature is one-dimensional and the conditional distributions are ordered
- the overlap region implies irreducible error if only this feature is used
- moving the threshold trades off precision and recall, or false positive and false negative rates
- the best threshold depends on the product goal, class prior, and error cost
Strong candidates usually go one step further and say that if performance is inadequate, they would look for additional features that better separate the classes rather than over-focusing on the threshold itself.
Common Follow-Ups
Feature Selection
The interviewer may repeatedly ask how you would choose or create better features. Be ready to discuss:
- which raw features are likely predictive
- how to identify leakage
- how to handle missing values
- scaling or normalization when needed
- encoding for categorical features
- whether interactions or nonlinear transforms could help
- how to evaluate feature importance or feature usefulness
Model Choice
You may be asked:
- when logistic regression is sufficient
- when tree-based models are a better fit
- how to think about linear vs nonlinear boundaries
- how to balance interpretability against predictive performance
Evaluation
Expect discussion around:
- train/validation/test split
- ROC-AUC vs PR-AUC for imbalanced data
- precision/recall/F1 trade-offs
- calibration and probability interpretation
Cold Start
The round may end with cold start questions, such as:
- how to make predictions for a new user with little or no history
- how to handle a new item with limited interaction data
- what fallback features or priors you would use
- when heuristics, popularity baselines, or contextual features are appropriate before enough personalized data arrives
A Strong Interview-Safe Answer Structure
- Clarify what y represents and whether this is regression or classification.
- Start with a simple baseline model and explain why.
- Interpret the overlapping distributions as evidence that one feature alone cannot perfectly separate the classes.
- Explain threshold selection in terms of business cost and metric trade-offs, not just geometry.
- Propose additional features that could reduce overlap.
- Discuss evaluation and how you would validate that the new features or model actually help.
- Close with practical cold-start strategies.
Interview Experience
Candidates reported that the interviewer asked broad ML fundamentals questions throughout, gave feedback during the discussion, and seemed to care a lot about how candidates reasoned about feature selection.
One notable part of the round involved a plot where the class distributions overlapped. A shallow answer like "the data is linearly separable and we can pick a threshold" was not enough on its own. The stronger interpretation is that the overlap indicates ambiguity on the current feature, so the discussion should include irreducible error, threshold trade-offs, and what additional features might help.
The whole question is a test of whether you reason from the Bayes-optimal classifier instead of memorized recipes. The overlap region is the irreducible (Bayes) error for this feature: no threshold can do better, so the senior move is to stop tuning the threshold and reframe the problem as "I need a feature that separates these distributions." Tie threshold choice to the cost matrix and class prior, not to 0.5, and always name the metric (PR-AUC over ROC-AUC when positives are rare). Then drill the conceptual map at ml-rapid-review.html.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Overview
This Reddit Machine Learning Engineer interview is a practical tabular modeling exercise done in a Jupyter notebook. You are given a clean JSON dataset where each row represents a post impression for a user, and your goal is to predict whether the user will click the post.
The task is intentionally standard. The interviewer is mainly looking for whether you can move through a basic supervised learning workflow cleanly under time pressure:
- load JSON data
- convert it into a pandas DataFrame
- inspect the schema
- preprocess the features
- train a baseline and a few reasonable models
- evaluate the results
- explain the trade-offs behind your choices
Candidates reported that the dataset was already quite clean:
- no missing values
- no major class imbalance
- straightforward numeric and categorical features
That means the round tends to focus less on messy data cleaning and more on modeling judgment.
Core Prompt
You are given a dataset with these columns:
- hours_spent_reading_a (float)
- hours_spent_reading_b (float)
- hours_spent_reading_c (float)
- current_post_category (A, B, or C)
- click (binary label)
Interpretation:
- the first three columns describe how much time the user has historically spent reading posts in each category
- current_post_category is the category of the post currently being shown
- click indicates whether the user clicked that post
Build a model to predict click.
Assume the raw data is provided as JSON and should first be loaded and converted into a DataFrame before modeling.
Expected Workflow
Step 1: Load And Inspect The Data
The interviewer likely expects something close to:
import json
import pandas as pd
with open("data.json", "r") as f:
data = json.load(f)
df = pd.DataFrame(data)
print(df.head())
print(df.dtypes)
print(df["click"].value_counts())
At this stage, a strong candidate should quickly confirm:
- target column is binary classification
- numeric columns are already usable
- the category feature needs encoding
- the dataset is clean enough to move straight into modeling
Step 2: Prepare Features
A reasonable preprocessing plan is:
- separate features and target
- one-hot encode current_post_category
- use train/test or train/validation/test split
- optionally standardize features for linear models
For example:
from sklearn.model_selection import train_test_split
X = df.drop(columns=["click"])
y = df["click"]
X = pd.get_dummies(X, columns=["current_post_category"], drop_first=False)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
Reasonable Model Choices
Candidates reported comparing several common models:
- DummyClassifier for a sanity-check baseline
- logistic regression as a fast, interpretable baseline
- random forest for nonlinear interactions
- XGBoost for stronger boosted-tree performance
That is a good interview-safe progression:
- establish a trivial baseline
- train a simple linear model
- compare against tree-based nonlinear models
- discuss why one model may outperform another on this small tabular problem
What The Interviewer Is Looking For
Modeling Judgment
Be ready to explain:
- why logistic regression is a strong first baseline
- why tree-based models might capture nonlinear relationships or feature interactions
- why you did not jump immediately to a more complex model
- when interpretability matters more than raw predictive performance
- how model complexity affects speed, tuning cost, and overfitting risk
Metrics
Candidates reported direct follow-ups on metrics, including why to use one metric and not another.
Since the data appears clean and roughly balanced, acceptable primary metrics could include:
- accuracy
- F1
- ROC-AUC
A strong answer explains that the final choice should depend on product goals:
- use accuracy if classes and error costs are balanced
- use precision or recall if one mistake type matters more
- use F1 when you want a balance between precision and recall
- use ROC-AUC when comparing ranking quality across thresholds
Strong candidates also note that accuracy alone can be insufficient if business costs are asymmetric.
What To Do With More Time
The interviewer may ask what else you would do if the interview were not time-boxed. Good answers include:
- cross-validation for more robust comparison
- hyperparameter tuning
- threshold tuning
- feature engineering, such as interaction terms between historical reading profile and current category
- calibration analysis if predicted probabilities matter
- error analysis by category
A Plausible Interview-Safe Solution Structure
- Load the JSON and convert it to a DataFrame.
- Confirm this is a binary classification problem.
- One-hot encode the current post category.
- Split the data and train a DummyClassifier.
- Train logistic regression as the main baseline.
- Compare with random forest and XGBoost.
- Report metrics and explain the trade-offs.
- If time remains, mention cross-validation and hyperparameter tuning as next steps.
Common Follow-Ups
Expect questions like:
- Why did you choose logistic regression first?
- Why might random forest or XGBoost perform better here?
- Why not use SVM, k-NN, or a neural network?
- What trade-offs do you get with each model?
- Which metric would you optimize and why?
- If you had another hour, what would you do next?
Interview Experience
Candidates reported that the interview experience was friendly and practical. The interviewer allowed syntax lookups, gave small hints when the candidate made a typo, and focused on standard scikit-learn knowledge rather than deployment or monitoring.
The round was described as time-constrained but fair. Even though candidates sometimes ran out of time for cross-validation or hyperparameter tuning, the interviewer did not seem to penalize that as long as the modeling choices and reasoning were sound.
The real signal here is the baseline-first ladder: a DummyClassifier first proves you know what "better than chance" means before you reach for XGBoost. The classic trap is silent leakage and verbalizing your judgment too late, so narrate the cost trade-off as you fit each model and watch out for the interaction this feature set is built to expose, namely hours_spent_reading_X matched against current_post_category, which is exactly where logistic regression underperforms a tree. Note the deliberate signal: a user's history in category C only matters when the current post is C, so an explicit interaction term or one-hot is what unlocks accuracy. Reinforce the workflow at ml-rapid-review.html.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
These two are the ML-system-design questions in Reddit's bank. Note what candidates report interviewers actually dwell on: for Video Recommendation it's the infra/logging/observability (event collection, pipelines) more than the model; for Feature Store it's the production side (training-serving consistency, CI/CD, rollback, caching, reliability), which candidates under-cover. Pair with the full ML system design page (Design Reddit Feed + Kafka/cache/GraphQL primers).
The freshest reports (including a recent fail) sharpen what the ML-coding / case-study round actually looks like — prep these specifics, they're new:
- It's a live, train-a-model-on-the-spot round. One candidate confirmed with HR: "they give you a pile of data and you train a model on the spot; your model's performance decides whether you pass." Expect a small dataset (a few thousand rows, ~4 columns, one categorical) and a binary prediction task — basic data processing + library (sklearn) fluency, then follow-ups.
- The data is deliberately messy. A recent fail report flagged missing data and class imbalance (~20% positive label) in the case-study set. They're testing whether you notice and handle both — not just
fit/predict. Have a crisp plan ready (below). - Logistics: candidates ask whether it's Colab vs local — assume a notebook (Colab or shared) where you load a provided file. Narrate your EDA → cleaning → model → evaluation out loud; the round rewards clear process over a fancy model.
- Recurring framing ("Reddit has 3 groups A/B/C, predict click", "hours spent reading post A/B/C → predict") confirms it's the same Post Click Prediction family — tabular click prediction, not deep learning.
- EDA first (2 min):
df.info(),df.describe(),df.isna().sum(), anddf[target].value_counts(normalize=True)— out loud note "20% positive, so accuracy is misleading; I'll watch PR-AUC / F1 / recall, not accuracy." - Missing data: don't drop blindly. Numeric → median impute (
SimpleImputer(strategy='median')); categorical → an explicit"missing"category; flag missingness itself as a feature if it might be informative. Do it inside aPipelineso it's fit on the train fold only (no leakage). - Categorical column: one-hot if low-cardinality, target/frequency-encode if high — inside the pipeline / per-fold.
- Class imbalance (20% positive): easiest win is
class_weight='balanced'(logistic regression / tree). Mention alternatives: resampling (SMOTE / random under-sample) and threshold tuning on the PR curve. Don't over-engineer —LogisticRegression(class_weight='balanced')or a gradient-boosted tree is the right v0. - Evaluate honestly: stratified train/val split, report ROC-AUC + PR-AUC, show the confusion matrix, and pick the decision threshold from the business cost — not the default 0.5.
- Talk calibration if asked: if the probability is used downstream (ranking/bidding), check predicted-vs-actual rate and mention Platt/isotonic — ties straight to the fundamentals calibration chapter.
Sources: 1Point3Acres "Reddit MLE 店面+vo 挂经" (tid 1178582), "Reddit MLE phone screening" (2026-04-30), "Reddit MLE L4-L5 面经" — candidate-reported, cross-checked against darkinterview (19 questions, updated 2026-05-18) and interviewdb.io (updated 2026-05). No new question types beyond the existing bank; the new signal is the case-study round's messy-data/imbalance specifics.
Problem. Design a video recommendation system for Reddit — a platform where short-form and long-form video content competes with text posts, images, and links inside a community-first (subreddit) social graph. The system must surface the right video to the right user at the right moment across the home feed, the r/Videos discovery feed, and the dedicated video player queue. Unlike YouTube (pure watch-time maximization) or TikTok (pure engagement velocity), Reddit must balance watch-time, community health, and integrity signals simultaneously — and do so for a population that ranges from brand-new lurkers to decade-long power users with highly-specific community memberships. The interviewer at Reddit grades this question heavily on the data plane: how events flow, how logs are corrected for bias, and how the system continuously closes the loop. Model architecture is secondary.
1. Requirements & Metrics
Functional requirements (scope clarification you should do out loud): Are we ranking only videos, or also GIFs and embeds? Answer: treat anything with a duration signal as a video. Do we serve the home feed, the dedicated video feed, or both? Answer: design for both, they share the same stack. Is integrity (NSFW, misinformation, vote manipulation) in scope? Answer: yes — Reddit's community rules make this non-negotiable.
Business metrics (North Star):
- Weekly Active Video Viewers (WAVV) — the primary growth lever.
- Session watch-time per DAU — proxy for depth of engagement.
- D7 video retention — users who watch a video today return to watch more within 7 days.
ML proxy metrics (what the model is actually trained to predict):
- pCTR — probability of click/play given impression.
- pWatch — expected watch fraction (0–1), capped at 1.0 to handle loops.
- pComplete — probability of watching >80% of duration (strong satisfaction signal).
- pDislike — probability of explicit downvote or "hide" action (integrity guard).
Engagement-vs-integrity tradeoff: A short, provocative clip maximizes pCTR but may increase pDislike and harm community trust. The ranking score must penalize integrity violations explicitly, not just hope the model learns the tradeoff implicitly.
Latency budget (state this early): Home feed P99 < 200ms end-to-end. Video player queue (pre-fetch) < 500ms. This forces a two-stage funnel — exhaustive ranking of all videos in the corpus (hundreds of millions) is impossible within budget.
Answer: Watch-time going up while D7 retention goes down is the canonical canary — the model is exploiting short-term engagement at the cost of long-term health. Also track: average number of communities a user engages with per week (diversity; a drop signals filter-bubble formation), the ratio of recommended-content watch-time to organically-discovered content (if rec dominates, you've created a dependency), and complaint/report rate on recommended videos normalized by impressions.
2. The Two-Stage Funnel: Candidate Generation → Ranking → Re-Rank
Why a funnel? With O(100M) videos in the corpus and O(100ms) to respond, you cannot score every video with the full ranking model. The funnel uses cheap-but-high-recall retrieval to get from 100M → ~1,000 candidates, then expensive-but-precise ranking to get from 1,000 → ~50, then deterministic re-ranking rules to produce the final slate of ~20.
Stage 1: Candidate Generation (100M → ~1,000)
Two-tower retrieval model: The query tower encodes the user; the item tower encodes the video. Both towers produce 128-dimensional L2-normalized embeddings. At query time, approximate nearest-neighbor (ANN) search (Faiss HNSW index) retrieves the top-K videos by inner product. Training uses in-batch negatives plus hard negatives mined from "impressions not clicked."
The user tower input features: user ID embedding (learned), community membership bag-of-words (subreddits weighted by posting/commenting frequency), watch history sequence (last 50 videos, mean-pooled), device type, hour-of-day, day-of-week. The video tower input features: video ID embedding, subreddit ID, audio/visual embedding from a frozen video encoder (e.g., a distilled CLIP applied to keyframes), title token embeddings (mean-pooled), duration bucket, age-of-post in hours.
Multiple candidate sources — don't rely on a single retrieval path:
- ANN two-tower (~500 candidates): personalized, covers long-tail content a user has signaled interest in.
- Community trending (~200 candidates): top-K videos by upvote velocity in subscribed subreddits in the last 6h. No model needed — a simple sorted set in Redis suffices.
- Social graph (~100 candidates): videos upvoted/commented-on by Redditors the user frequently co-engages with (implicit social signal, since Reddit has no "follow" for users in general).
- Explore bucket (~200 candidates): random sample from communities the user does NOT subscribe to but is statistically similar to users who do, seeded by community embedding similarity. This combats filter-bubble formation.
Candidates from all sources are deduplicated by video ID before passing to Stage 2.
Answer: The trending bucket (Redis sorted set, updated every 60s by a Flink job consuming Kafka engagement events) catches it immediately — no model needed. The ANN index lags by up to the incremental insertion cycle (~5 minutes). The practical answer is: trending retrieval is your early-viral safety net; the personalized two-tower kicks in once the video accumulates enough interaction data to learn from.
Stage 2: Ranking (~1,000 → ~50)
The ranker is a multi-task deep neural network. It takes as input: the user embedding (from the two-tower user tower, or recomputed from the feature store), the video embedding, and a rich set of cross-features that the two-tower deliberately omits (cross-features are expensive to compute for 100M items but cheap for 1,000).
Cross-features added at ranking time: user-video community overlap (does this video's subreddit match user's top communities?), recency of post relative to user's last session, video duration vs. user's typical watch duration distribution (a 30-minute video for a user whose median watch is 90 seconds is a bad recommendation), title sentiment alignment with user's engagement history.
Multi-task loss: The ranker predicts multiple targets jointly. A shared bottom MLP produces a shared representation; task-specific heads branch off:
L_total = w_ctr * BCE(p̂_ctr, y_ctr)
+ w_watch * MSE(p̂_watch, y_watch)
+ w_complete * BCE(p̂_complete, y_complete)
+ w_dislike * BCE(p̂_dislike, y_dislike)
Weights are tuned offline via a grid search on a held-out validation set optimizing for D7 retention (the North Star), not for any single task. Typical values: w_ctr ≈ 0.2, w_watch ≈ 0.4, w_complete ≈ 0.3, w_dislike ≈ 0.1 — emphasizing watch quality over raw click rate.
Final ranking score:
score = p̂_watch * p̂_complete * duration_minutes - λ * p̂_dislike
The λ * p̂_dislike penalty is a hard integrity safeguard — if the dislike probability is high (e.g., a borderline-policy video), the score is depressed regardless of watch-time. λ is a policy hyperparameter set by Trust & Safety, not tuned by the ML team.
Answer: Watch-time is gameable and can be misleading. A rage-inducing or outrage-bait video holds attention but generates downvotes, reports, and reduces long-term return. pComplete is a better satisfaction proxy than raw watch time — a user who watches 95% of a 2-minute video is more satisfied than one who watches 40% of a 10-minute video. Multi-task training also improves calibration: the dislike head acts as a regularizer on the watch-time head, preventing the model from recommending high-watch, high-dislike content.
Stage 3: Re-Ranking (~50 → ~20, deterministic rules)
Re-ranking applies deterministic business logic on top of ranked scores. It is intentionally NOT a learned model — it encodes policy, which should be auditable and adjustable without retraining.
- Diversity: No more than 2 consecutive videos from the same subreddit. Implemented as a greedy "spread" pass over the ranked list.
- Deduplication: Remove near-duplicate videos (perceptual hash similarity > 0.95 against the user's watch history in the past 30 days).
- Integrity hard filter: Remove any video with an active policy violation flag (from a separate content classifier). This is a hard removal, not a score penalty — even a score of zero is not safe enough for a policy-violating video.
- Freshness injection: Force at least 2 of the top-10 slots to be posts <4h old to prevent the feed from stagnating on popular-but-old content.
- NSFW gating: If user has not opted into NSFW content in their account settings, filter at this stage (not at retrieval, to keep the retrieval index simpler).
3. The Feedback Loop in Detail (Deepest Section)
This is what the Reddit interviewer actually wants. The model is only as good as the quality of its training labels, and those labels come entirely from the event log. Every bias in the log becomes a bias in the model.
Event Collection Architecture
Every client (web, iOS, Android) emits a stream of events to a Kafka topic. The canonical events are:
- impression: {user_id, video_id, position_in_feed, timestamp, request_id, model_version, context: "home_feed" | "video_feed" | "search"}
- play_start: {user_id, video_id, timestamp, autoplay: bool}
- heartbeat: emitted every 5 seconds of continuous watch — {user_id, video_id, elapsed_seconds, timestamp}. Used to reconstruct watch fraction without relying on a single "play_end" event (which is unreliable on mobile when app is backgrounded).
- play_end: {user_id, video_id, watched_seconds, reason: "completed" | "scroll_away" | "close_app" | "next_video"}
- upvote / downvote: {user_id, video_id, timestamp}
- share / save: {user_id, video_id, timestamp}
- hide / report: {user_id, video_id, reason, timestamp}
Critical detail: log the request_id and model_version on every impression. This is how you join training labels back to the model that generated them. Without this, you cannot do counterfactual evaluation, you cannot detect when a model update shifts the distribution, and you cannot do holdback experiments cleanly.
Watch-fraction computation from heartbeats: Because mobile apps are unreliable at sending play_end, reconstruct watch fraction from heartbeats: watch_fraction = count(heartbeats) * 5s / video_duration_s. Cap at 1.0 for looping videos. Tag reconstruction as "estimated" when play_end is missing, so downstream training can apply a lower confidence weight.
Position Bias and IPS Correction
This is the most important bias to understand. A video shown at position 1 in the feed gets clicked far more often than the same video at position 10 — not because it's better, but because users scroll less. If you train naively on raw click labels, the model learns to predict "was this shown at the top?" more than "is this a good video?" The result: items that were historically ranked high get higher pCTR predictions, which gets them ranked high again. This is a popularity feedback loop that amplifies early accidents into permanent dominance.
Correction via Inverse Propensity Scoring (IPS): For each training example (user, video, position), weight the loss by the inverse of the propensity — the probability that this video would have been shown at this position under a random policy:
Weighted loss = (1 / propensity(position)) * BCE(p̂_ctr, y_ctr)
Estimate propensity(position) empirically from a randomization bucket: for ~1% of traffic, randomize the ranking order before serving. The empirical click rate at each position in that bucket gives you the position-click curve. Fit a simple exponential decay: propensity(k) = α * exp(-β * k). Typical values: position 1 → propensity 0.30, position 5 → 0.18, position 10 → 0.09.
IPS correction has variance problems at extreme positions (very high weights for low positions). Clip weights at a maximum of 10x to prevent a few low-position impressions from dominating the loss.
Label Leakage
Label leakage occurs when features available at training time contain information that would not be available at serving time. In a recommendation system, the subtlest form is future engagement leakage: if your training pipeline computes "video total_upvotes" as of today, and the model trains on an impression that happened 3 months ago, the feature includes 3 months of future upvotes that the model could not have seen at serving time. The model learns to recommend videos that became popular, not videos that were about to become popular.
Prevention: point-in-time correct (PIT-correct) features. Every feature used in training must be computed as of the timestamp of the impression event, not as of when the training pipeline runs. Implementation:
- Store the full time-series of every feature in the feature store (or warehouse), not just the current value.
- The training pipeline joins impression events to features using an AS-OF join:
SELECT * FROM impressions i JOIN video_features f ON i.video_id = f.video_id AND f.feature_ts <= i.event_ts ORDER BY f.feature_ts DESC LIMIT 1. - For user features (watch history), reconstruct the user's history as it existed at the time of the impression — exclude any videos watched after the impression timestamp.
PIT-correct joins are expensive. Practical tradeoff: apply strict PIT-correctness for slow-moving features (community membership, user age) and accept a small amount of leakage for fast-moving features (video upvote count in the last hour) by using the value from the nearest available snapshot.
Training-Serving Skew
Training-serving skew is the condition where the feature values seen by the model during training differ from the feature values seen during inference, not due to distribution shift (a legitimate change over time) but due to engineering inconsistency (computing features differently in two code paths). It is one of the most common and hardest-to-detect bugs in production ML systems.
Sources of skew in a recommendation system:
- The training pipeline computes watch_fraction from heartbeats; the serving feature store computes it from play_end events only. These two paths give different numbers for the same video.
- Text features (video title embeddings) are tokenized with a different vocabulary version in the training pipeline vs. the online model server.
- Timestamp-based features (video age in hours) are computed relative to the training pipeline's run time, not the impression timestamp.
- Feature normalization (z-score of video duration) uses statistics computed on the training set; at serving time, you apply those same statistics to new data — fine — but if you forget to apply them at all, the model sees out-of-distribution inputs.
Detection: Log the feature vector used at serving time alongside the impression event (or a hash of it). In the training pipeline, after computing features for a training example, compare to the logged serving-time vector. Compute the mean absolute deviation between the two for each feature. Alert if any feature's MAD exceeds a threshold (e.g., >5% of the feature's standard deviation). Run this check on a 0.1% sample of impressions — logging the full feature vector for 100% of impressions is too expensive.
Answer: Step 1 — check if training-serving skew increased with the new model. Pull the feature deviation report for the new model version vs. the previous one. Step 2 — check if the label distribution shifted: did the new training data include more IPS-corrected examples that changed the target distribution? Step 3 — check the candidate generation stage: did a two-tower update change which candidates are even reaching the ranker? The ranker can't fix a bad candidate set. Step 4 — check re-ranking rules: did a new integrity filter start removing too many good candidates? Step 5 — check for a Goodhart's Law problem: did the model start optimizing a proxy metric (pComplete) that diverged from the actual metric (watch-time)?
Closing the Training Loop
Training data pipeline (daily batch, with near-real-time shadow):
- Kafka impression and engagement events land in the data warehouse (e.g., Iceberg tables on S3) via a Flink consumer. Raw events are available with ~5-minute latency.
- A daily Spark job applies the label join: for each impression, join to engagement events within a 48h window (allow 48h for "lazy" signals — a user might come back and upvote the next day). Apply IPS weights. Reconstruct PIT-correct features. Output a labeled training dataset in columnar format (Parquet).
- A model training job (PyTorch + distributed training on 8–16 GPUs) trains on the last 30 days of labeled data, with down-sampling of older data (exponential time-decay weighting — a 30-day-old example gets weight 0.3x a today example). Training takes ~4–6 hours.
- The new model is evaluated offline on a held-out validation set (last 2 days, not included in training) against AUC-ROC for pCTR, RMSE for pWatch. It must beat the current production model on both by >0.2% (a statistically significant threshold determined by bootstrap resampling).
- Passing validation triggers a canary deployment (1% of traffic). Shadow evaluation runs for 24h. If no degradation in online metrics, ramp to 10% → 50% → 100% over 3 days.
4. Serving Architecture & Feature Store
Request path (target P99 < 200ms for home feed):
- Client request hits the Feed Gateway (load balancer). ~2ms.
- Gateway calls the Recommendation Service, passing user_id, context, device, session_id.
- Recommendation Service fetches user features from the online feature store (Redis/DynamoDB) in parallel. ~5ms.
- Recommendation Service fans out to three candidate sources in parallel: ANN retrieval service (HNSW lookup), trending API (Redis sorted set), social graph service. Waits for all three with a 30ms timeout; any source that doesn't respond is dropped. ~20–30ms total.
- Recommendation Service calls the Ranking Service with merged candidates + user features. ~50ms for DNN scoring of ~1,000 items (vectorized batch inference).
- Re-ranking rules applied in-memory. ~5ms.
- Response assembled and returned. ~2ms serialization.
Total: ~85ms median, ~150ms P95, ~200ms P99. Leaves headroom for network jitter.
Online feature store design: Two tiers. Tier 1: ultra-low-latency (Redis, <1ms) for the ~50 features used at every request: user watch history (last 50 video IDs, stored as a list), subscribed subreddits (list of subreddit IDs), user's embedding vector (128 floats), and per-video statistics (upvote count, view count, age). Tier 2: moderate-latency (DynamoDB, ~5ms) for less frequently changing features: user community embedding, user device history, long-term watch preferences.
Candidate caching: For non-personalized retrieval (trending), cache the top-200 trending videos per subreddit in Redis with a 60-second TTL. This avoids re-running the sorted set query on every request for popular subreddits. For personalized ANN results, caching is not worth it — user embeddings change fast enough that a 60-second stale cache degrades quality measurably.
Pre-fetching for the video player queue: When a user starts watching a video, the client immediately requests the next 5 videos in the queue in a background call. This relaxes the latency budget to 500ms for the queue request, allowing a deeper ranking pass (more candidates, more features). The pre-fetch result is stored client-side and served instantly when the user swipes to the next video.
Answer: Two strategies in combination. First, the user tower of the two-tower model takes the watch history sequence as input, not just a learned embedding. So "freshness" comes from updating the watch history list in the feature store (which happens within seconds via Flink), even if the embedding itself isn't recomputed. The model re-encodes the fresh watch history at query time. Second, for the learned user ID embedding specifically, run an incremental embedding update job every 15 minutes on a GPU worker using the last 15 minutes of engagement events — not a full retrain, just a partial gradient update to the embedding table. This is cheaper than it sounds because only the embedding lookup table is updated, not the full model.
5. Cold Start
New User Cold Start
A new Reddit account has no watch history, no upvotes, no community memberships. The ANN two-tower returns nothing meaningful for this user.
Strategy — onboarding signals: At account creation, prompt the user to select 5+ interest topics (not just subreddits — higher-level tags like "gaming," "cooking," "news"). Map these tags to communities. Use the community embedding to construct an initial user embedding as a weighted average of community embeddings. This gives the two-tower something to work with on the first session.
Strategy — session bootstrapping: Treat the first session as an exploration phase. Override the ranking score with a multi-armed bandit policy (Thompson Sampling) that explores content from the selected topics with high variance. Update the bandit's posterior after each engagement event within the session (online update, no model retraining). After 5–10 interactions in the session, the user's implicit interest profile is strong enough to switch to the normal ranking model.
Strategy — demographic proxy (with caution): If the user provides location or device locale at signup, use the aggregate engagement patterns of users from that region/locale as a prior. This is useful but must be audited for fairness — do not proxy demographic attributes that correlate with protected characteristics.
New Video Cold Start
A newly posted video has zero engagement history. The two-tower item embedding is initialized from content features only (title, thumbnail, subreddit, duration). The ranking model's pCTR and pWatch predictions are based entirely on content features, which are noisier than engagement-calibrated features.
Strategy — exploration injection: Every video, for its first 2 hours, is eligible for inclusion in the "explore bucket" of candidate generation. It gets shown to ~500 sampled users whose community memberships match the video's subreddit. These exposures generate initial engagement signal.
Strategy — warm-up prior: Initialize pCTR and pWatch for a new video to the median values for videos of the same subreddit and duration bucket from the last 30 days. This prevents extreme over-ranking (if the model is uncertain, it defaults to median performance, not zero). Concretely: replace missing engagement features with subreddit-duration-bucket means, flagged as "cold_start=true" in the feature vector so the model can learn to apply a different weight.
Strategy — content-based fallback: Run a separate content-only ranker (trained solely on content features, no engagement features) for cold-start items. Blend the content-only score with the engagement-based score using a mixing weight that transitions from 1.0 (content-only) to 0.0 (engagement-based) as the video accumulates impressions: score = α(n) * content_score + (1 - α(n)) * engagement_score, where α(n) = exp(-n/50) and n is the impression count.
6. Online Evaluation: A/B Testing, Holdbacks, Guardrails
A/B testing framework: User-level assignment (not session-level) — a user is assigned to control or treatment for the duration of the experiment to avoid carry-over effects. Assignment is deterministic (hash of user_id + experiment_id) so the same user always sees the same variant. Experiments run for a minimum of 2 weeks to capture weekly periodicity in engagement (Reddit has strong Monday-vs-weekend usage differences).
Primary and guardrail metrics: Every experiment defines one primary metric (e.g., session watch-time) and several guardrail metrics (e.g., D7 retention, complaint rate, diversity score). An experiment "wins" only if: (1) the primary metric improves by a statistically significant margin (p < 0.05 after multiple-testing correction), AND (2) no guardrail metric degrades by more than a pre-set threshold (e.g., D7 retention does not drop by more than 0.5%).
Holdback experiments: To measure the true long-term value of the recommendation system, maintain a 1% holdback group that never sees recommended videos — they see only chronological community feeds. Compare WAVV and D7 retention between the holdback and the recommendation-treated population. This is your ground truth for "does the rec system actually help?" Run holdbacks for 90-day windows to capture long-term effects.
Online metrics instrumentation: Do not rely solely on offline metrics (AUC, RMSE) to decide if a model is good. Compute online proxy metrics directly from the event stream during the canary period: rolling 1h and 24h watch-time per impression, upvote rate, downvote rate, scroll-away rate (impression with no play event within 3 seconds), and session end rate (user leaves the app within 60 seconds of seeing a video). Alert if any of these degrade by >2% relative in the canary period.
Answer: Run an A/A test first (same treatment for both groups) on every new experiment infrastructure change to verify the randomization is unbiased. Check sample ratio mismatch (SRM): if you assigned users 50/50 but 47/53 actually appear in logs, there's a logging bug that invalidates the test. Check for novelty effects: a new recommendation UI gets inflated engagement in week 1 as users explore it; the signal stabilizes by week 2. Use Welch's t-test (not Student's, because variances differ between groups) for the primary metric. For ratio metrics like watch-time-per-session, use the delta method to compute the correct standard error.
7. Infrastructure: Kafka → Flink → Warehouse + Online Store
Kafka event bus: All client events (impressions, heartbeats, plays, engagements) publish to partitioned Kafka topics. Partition key is user_id — this ensures all events for a user are on the same partition, making stateful stream processing (e.g., computing session-level features) correct. Topic retention is 7 days (enough to replay if a downstream consumer falls behind). Peak throughput on a Reddit-scale platform: ~500K events/second, requiring ~50 Kafka partitions at 10K events/partition/second.
Flink stream processing layer:
- Session stitching job: Groups events by (user_id, session_id) and computes session-level features — total watch time in session, number of unique subreddits, number of videos played. Outputs to a session features table (Iceberg).
- Real-time feature update job: Consumes engagement events and updates the online feature store. When a play_end event arrives, compute the new watch_fraction and write it to Redis for that video_id. When an upvote arrives, increment the video's upvote counter in Redis. Flink's exactly-once processing guarantee is critical here — you cannot double-count upvotes due to consumer retries.
- Trending computation job: Computes upvote velocity per video per subreddit over a sliding 6-hour window. Maintains a top-200 sorted set in Redis for each subreddit. Uses a Flink keyedProcessFunction with a MapState keyed by (subreddit_id, video_id).
- Training data generation job (near-real-time shadow): Joins impression events to engagement events with a 48h event-time window (Flink handles out-of-order events with watermarks at 2h late arrival tolerance). Outputs labeled training examples to an Iceberg table on S3, which the batch training job reads. This is the "shadow pipeline" — it runs continuously but training consumes it daily.
Data warehouse (Iceberg on S3 + query engine like Trino): Stores the full historical event log, labeled training datasets, feature snapshots (for PIT-correct joins), model evaluation results, and experiment assignment tables. Iceberg's time-travel capability is essential for PIT-correct training: you can query SELECT * FROM video_features FOR SYSTEM_TIME AS OF '2026-03-15 14:23:00' to get the exact feature values at any past timestamp.
Feature store dual-write pattern: Flink jobs write to both the online store (Redis, for serving) and the offline store (Iceberg, for training). This dual-write architecture is the primary mechanism for preventing training-serving skew: both the serving path and the training path read features from the same source-of-truth Flink job. Any divergence in feature computation exists in one place, not in two separate codebaths that drift apart over time.
Answer: (1) Client emits impression event {user_id, video_id, position, request_id, model_version} to Kafka within 200ms of the video entering the viewport. (2) A play_start event fires when autoplay begins; heartbeat events fire every 5s. (3) Flink's training data job picks up the impression event and opens a 48h window waiting for engagement events for that (user_id, video_id) pair. (4) Within the 48h window, upvote, play_end, and heartbeat events arrive and are joined to the impression. (5) At window close, a labeled training example is written to the Iceberg training table with PIT-correct features (fetched via AS-OF join) and IPS weight (based on the position field). (6) Nightly, the Spark training pipeline reads the last 30 days of the training table, samples with time-decay weighting, and trains a new model. (7) The new model passes offline eval and is deployed to 1% of traffic as a canary. (8) After 24h of canary monitoring, it ramps to 100%. Total lag from user action to model improvement: ~36–48 hours.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
Problem Statement
Design a feature store for Reddit's ML platform. Explain how offline and online feature storage stay consistent, how features are computed and materialized, how training and inference use the same definitions, and how the platform supports safe rollout across many teams.
Comprehensive Solution Resources
For detailed feature store design walkthroughs and implementation strategies, these resources are a good starting point:
- HOW TO DESIGN A FEATURE STORE FOR YOUR MLOPS PIPELINE | ML SYSTEM DESIGN
- ML System Design: Feature Store
The notes below focus on what interviewers often emphasize in real Reddit loops, so you can spend prep time on the parts candidates most often under-cover.
What Interviewers Often Care About
This question is easy to answer too heavily from the modeling side and too lightly from the production side. Candidates often spend most of their time on feature engineering, model architecture, and high-level data flow. Stronger answers also cover:
- how feature definitions are versioned and tested before rollout
- what CI/CD looks like for feature pipelines, transformations, and schema changes
- how deployments are staged and rolled back safely
- how online caching affects latency, freshness, and consistency
- how to preserve training-serving consistency across offline and online systems
- how to handle backfills, stale data, failed jobs, and degraded upstream dependencies
- what reliability and observability metrics should guard the platform
Real Interview Experience
Candidates reported that the interviewer explicitly wanted a feature store design, but pushed beyond the usual architecture diagram. The discussion often focused on testing, deployment, CI/CD, caching, and reliability optimization, because those parts are easy to overlook when candidates focus only on modeling or feature engineering.
The core pattern a feature store solves is training-serving consistency: one feature definition, materialized to an offline store (point-in-time-correct joins for training) and an online store (low-latency KV lookups for inference) from the same transformation code. The trap this loop punishes is treating it as a modeling problem — Reddit interviewers steer hard into the production plane, so lead with feature versioning, CI/CD for pipelines and schema changes, staged rollback, backfills, and what happens when an upstream job fails or serves stale data. Name your SLOs (freshness, lookup latency, null-rate) and the observability that guards them. Drill the feature-store and MLOps platform pattern, and revisit ML fundamentals for the point-in-time-correctness reasoning.
Source: darkinterview.com (real Reddit interview report, captured May 2026)
A second crowdsourced bank (interviewdb.io) lists 21 Reddit questions; most overlap the set above, but these six are genuinely additional and recent (reported 1 week – 6 months ago). Added with worked solutions. Candidate-reported; verify framing.
I1 · Build Architect Coding Phone/Onsite
Design a build system (like make/npm): given packages and dependencies, return a valid build order; if a cycle makes it impossible, return []. This is a confirmed Reddit topological-sort question — it updates the earlier caveat: topo sort is asked at Reddit, just framed as a build system rather than "Course Schedule."
"(A, B) means A depends on B" ⇒ B must come before A ⇒ edge B → A in the build graph. Kahn's algorithm; if fewer than n nodes are emitted, a cycle exists.
from collections import defaultdict, deque
def find_build_order(tasks, dependencies):
graph = defaultdict(list)
indeg = {t: 0 for t in tasks}
for a, b in dependencies: # a depends on b -> b before a
graph[b].append(a)
indeg[a] += 1
q = deque([t for t in tasks if indeg[t] == 0])
order = []
while q:
node = q.popleft()
order.append(node)
for nxt in graph[node]:
indeg[nxt] -= 1
if indeg[nxt] == 0:
q.append(nxt)
return order if len(order) == len(tasks) else [] # [] signals a cycle
O(V+E). Follow-ups: report which cycle; parallel build (process each Kahn "level" concurrently); deterministic output (heap instead of deque). Drill in graphs ch9.
Source: interviewdb.io (Reddit, reported ~1 month ago)
I2 · Find Perfect Location Coding Phone/Onsite
A city is a grid; customers sit at intersections; place a kiosk to minimize total travel. On a street grid, travel is Manhattan distance, and $\sum_i(|x-x_i|+|y-y_i|)$ separates into independent x and y problems — each minimized at the median of that coordinate (this is LC 296 "Best Meeting Point").
def best_kiosk(points):
xs = sorted(p[0] for p in points)
ys = sorted(p[1] for p in points)
return (xs[len(xs)//2], ys[len(ys)//2]) # median per axis
O(n log n) (or O(n) via quickselect). The line to say: the median minimizes the sum of absolute deviations; the mean would minimize squared (Euclidean²) distance. With an even count, any point between the two medians is equally optimal, so "any optimal location" is accepted. (InterviewDB's example outputs are loose; the principled grid answer is the per-axis median.) Drill in arrays ch2.
Source: interviewdb.io (Reddit, reported ~6 months ago)
I3 · Similar Communities Coding Phone/Onsite
Given a bipartite map of communities↔followers, find communities related to a target. Part 1: share at least one follower. Part 2: related up to a given degree of indirection (degree 2 = related to the things related to you), via layered BFS over the community graph.
from collections import deque
def related_up_to_degree(community_map, follower_map, target, degree):
seen = {target}
frontier = {target}
result = []
for _ in range(degree):
nxt = set()
for c in frontier:
for f in community_map[c]: # followers of c
for c2 in follower_map[f]: # other communities they follow
if c2 not in seen:
seen.add(c2); nxt.add(c2); result.append(c2)
frontier = nxt
return result
# related_up_to_degree(cm, fm, "C4", 1) -> ["C1"]
# related_up_to_degree(cm, fm, "C4", 2) -> ["C1", "C2"]
Each degree is one BFS layer; "up-to" accumulates all layers ≤ degree. Cost O(degree · E), E = community–follower incidences. This is the coding cousin of subreddit recommendation (collaborative filtering as graph reachability). Drill in graphs/BFS ch9.
Source: interviewdb.io (Reddit, reported ~6 months ago)
I4 · Nested Replies Indentation Coding / Frontend Phone
The API returns a flat list of replies, each with a parentId; render them with correct nesting. Build a children map keyed by parent, then DFS carrying depth (same shape as Report Chain, front-end framed).
from collections import defaultdict
def render(replies): # replies: [{id, parentId, text}]
children = defaultdict(list)
for r in replies:
children[r["parentId"]].append(r)
out = []
def dfs(parent_id, depth):
for r in children[parent_id]: # optionally sort by score/time here
out.append(" " * depth + r["text"])
dfs(r["id"], depth + 1)
dfs(None, 0) # roots have parentId == None
return out
In a real UI, render recursively with margin-left = depth · step; discuss deep-thread collapse ("continue this thread"), stable ordering, and orphan/cycle guards. Cross-link #2 Report Chain.
Source: interviewdb.io (Reddit, reported ~4 months ago)
I5 · Post Comment Likelihood ML system design Onsite · MLE
Design a system to predict the likelihood a user comments on a post. The prompt explicitly says treat the model as a black box and focus on systems: feature engineering + storage, real-time vs batch pipelines, online inference, scalability/latency. So it's a feature-store + serving design, not a modeling exercise.
- Framing: binary $P(\text{comment}\mid \text{user},\text{post},\text{context})$; feeds ranking/notification budgeting. Label = commented within a window.
- Features + storage: batch features (user history, subreddit affinity, post age/topic) → offline store + an online feature store (sub-10ms) keyed by user/post; streaming features (last-N-min engagement) via Kafka→Flink; point-in-time joins for training-serving consistency.
- Real-time vs batch: precompute slow features hourly/daily; compute fast/contextual features online; the inference service joins both at request time.
- Online inference: low-latency server, dynamic batching, embedding caching, p99 budget, graceful fallback on feature-store miss.
- Scale: shard the store, cache hot users/posts, monitor drift + training-serving skew, log predictions for retraining.
The engagement-prediction sibling of ad-click modeling — lean on Feature Store (ch18) + serving (ch6).
Source: interviewdb.io (Reddit, reported ~2 months ago)
I6 · Startup Service System design Onsite
Found a startup serving game developers via API (no frontend); core feature is "top-10 players by score" per game. Constraints: very low launch traffic, keep it simple/cheap, don't over-engineer — start on one machine (API + DB), scale only when problems appear.
- v0 (launch): single box, API + relational DB;
scores(game_id, player_id, score)with an index on(game_id, score DESC); top-10 = a simple indexed query. Cheap, correct, ships fast. - Scale when it hurts: cache top-10 per game; move the leaderboard to a Redis sorted set (
ZADD/ZREVRANGE, O(log n)); add read replicas; shard bygame_id; sorted-set bucketing for one viral game. - The signal: right-size for the stage — articulate a confident simple v0, then a crisp scaling path, instead of jumping to a distributed design. (Same core as #15 Gaming Leaderboard.)
Source: interviewdb.io (Reddit, reported ~1 week ago)
These four are in Reddit's real bank but are tagged System Design / Software Engineer, not ML. Kept brief since you're on the ML track — skim for the patterns (real-time fanout, sorted-set leaderboards, notification pipelines, tree ranking). If one comes up, the framework and infra primers on the ML system design page transfer directly. All four follow the same darkinterview 5-phase template: Requirements → Data model → API → High-level design → Scaling/trade-offs.
#14 · Design Reddit Live Chat High Frequency
Real-time chat for millions of concurrent users (~5M online). The crux is fanout of messages to room subscribers with acceptable ordering and latency.
- Protocol: persistent WebSocket connections (not poll); a connection/gateway tier holds sockets, a room/pub-sub tier fans messages out.
- Fanout: publish each message to a room topic (Kafka/Redis pub-sub); gateways subscribed to that room push to their connected clients. A room directory maps room → which gateway nodes have subscribers.
- Ordering: per-room sequence numbers (or a single partition per room) so clients can detect gaps and reorder; total global ordering is unnecessary.
- Scaling traps: hot-room fanout (one room with millions of viewers → tree/hierarchical fanout), backpressure for slow consumers (drop or buffer with caps), multi-region delivery, moderation/abuse on the write path.
- Why not cache + pull: polling at chat latency/scale is wasteful; push over persistent connections is the standard answer.
#15 · Design a Gaming Leaderboard Service
Ranked leaderboards (global, per-game, per-time-window) plus each user's personal best, at high write volume that decays over time.
- Core structure: Redis sorted set (ZADD / ZREVRANGE / ZREVRANK) for O(log n) score updates and top-K + rank queries.
- Separate concerns: personal-best store (KV) vs leaderboard (sorted set) scale independently; only update the leaderboard when a personal best improves.
- Write decay: write traffic is highest at launch and drops over time — size for the peak, then rely on caching the (rarely-changing) top of the board.
- Hot game / hot partition: shard by game_id; for a single viral game, shard the sorted set by score-bucket and merge, or replicate read replicas.
- Time windows: daily/weekly boards via separate sorted sets keyed by window; expire old windows with TTL.
#16 · Design a Notification System for Reddit New
Deliver push/in-app notifications (replies, mentions, upvote milestones, recommendations) without spamming users. (This one is ML-adjacent — Reddit's real pipeline ranks notifications.)
- Lifecycle / pipeline: generate candidate events → budget per-user send volume → rank by predicted engagement → rerank with business rules → deliver. (Mirrors Reddit's real 4-stage budgeting/retrieval/ranking/rerank system — see ML sys design ch9.)
- Event contract: producers emit typed events to Kafka; a consumer evaluates user preferences and dedups.
- Aggregation for hot threads: collapse "50 people replied" into one notification (windowed aggregation) instead of 50 sends.
- Delivery semantics: at-least-once + idempotency key for dedup; handle provider (APNs/FCM) failures with retry/backoff and token hygiene (prune dead tokens).
- Preference timing: evaluate user opt-outs/quiet-hours at send time, not generation time.
#17 · Design Reddit's Post Comment Ranking System New
Rank a post's (potentially huge, nested) comment tree under multiple sort modes. (Also ML-adjacent — the ranking signal is the ML part.)
- It's a tree problem: comments form a parent/child tree; store with parent_id + materialized path for subtree fetch and pagination ("continue this thread").
- Ranking signal: Reddit's "best" sort uses the Wilson score lower confidence bound on the up/down ratio (a 5-up/0-down comment can beat 100-up/40-down). "Top" = raw score; "new" = time. See ML sys design ch8.
- Read-optimized projection vs compute-on-read: precompute an ordered read model per (post, sort-mode) so reads don't re-sort the whole tree; update incrementally on vote events.
- Freshness vs stability: re-ranking on every vote is unstable for users mid-read; batch score updates and refresh the projection periodically.
- Abuse: vote brigading detection, rate-limited vote ingestion, shadow-removal of malicious comments; hot-post handling (cache the top of the tree).