CODING & DESIGN · REAL REPORTED QUESTIONS · WORKED END-TO-END

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).

19 real questions Sources darkinterview + 1Point3Acres Window Jan 2025 – May 2026 Updated May 2026
How to use this page — and an honesty note

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.

What 1Point3Acres added beyond the question list

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.

The questions at a glance (19 core + 6 from InterviewDB)
#QuestionCategoryNote
1Tennis Score GameCoding / OODHigh frequency · phone screen
2Report Chain (org tree)Coding / treeHigh frequency
3Moderator List HierarchyCoding / designSWE + MLE
4Logger Rate LimiterCoding / designSWE + MLE
5Merge Chat Message WindowsCoding / merge
6Word SearchCoding / backtracking
7Word Search IICoding / trie
8Dictionary Word Transformation (Word Ladder)Coding / graph BFSMLE
9Billing Status Replay (OOD)Coding / OOD
10Shortest PalindromeCoding / strings (KMP)New · ML Infra
11Odd Even Linked ListCoding / linked listNew · ML Infra
12ML Fundamentals (overlap distributions)ML fundamentalsMLE
13Post Click Prediction (notebook)ML codingMLE
18Video RecommendationML system designMLE · your round
19Feature StoreML system designMLE · your round
14–17Live Chat · Leaderboard · Notifications · Comment RankingSystem designSWE-track · brief
I1–I6Build Architect · Find Perfect Location · Similar Communities · Nested Replies · Post Comment Likelihood · Startup ServiceCoding + ML/SDInterviewDB cross-check (added)
1Point3Acres forum deep-dive — senior / staff MLE

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.

★ Staff vs Senior MLE loop — the most important finding

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:

RoundStaff MLESenior & 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 PMstaff-only: alignment, conflict, improving product via tech
Domain deep-dive (bar-raiser) w/ an L8staff-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 modelingsometimes

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.

The senior signals that decide the offer
  • 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:

  1. Add documents to the index (add(doc_id, text)).
  2. Single-word search — return doc ids containing the word.
  3. Multi-word search — return docs containing all the words (set intersection of postings).
  4. 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 GameSet that plays best-of-5 (first to 3 games wins); a single play() loop creating Game objects. 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.
ML Fundamentals — the full probe sequence (reconstructed from 2 onsite reports)
  1. Intro + walk an ML project you delivered (how it shipped).
  2. Given X (real-valued) and Y∈{A,B}: what model, and how do you decide which model? (talk it through, ask questions.)
  3. 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."
  4. Now X has 1000 features, not 1 — "what's the same, what changes, what's new?"
  5. Train acc 0.91 / test 0.85 — what problem? (overfitting; remedies.)
  6. 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

TrackReported loop
Staff MLEHM → 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 MLERecruiter → 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 / platformCoding (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-stackHR → 1-hr phone → VO: 3 algorithm (rate limiter / mod list / report chain) + HM. Sometimes no system design.
Full interview experiences — verbatim from 1Point3Acres (translated)

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):

  1. HM screening — walk the resume, walk a project, BQ, leadership, and reverse-BQ (your questions to them).
  2. 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.
  3. 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.
  4. ML system design — "Design a watch-next recommender system for Reddit's own mobile-app video browser."
  5. 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.
  6. 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.

★ Deep-dive on the BAR-RAISER (domain-expert) round — what to expect

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)

  1. 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.
  2. 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.
  3. 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.
  4. 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) and get_mod_list(). L2 adds a community column. L3 adds demote(user) which moves a user down one position in the ordering (so user1→user2→user3, after demote(user1) becomes user2→user1→user3) and this changes can_remove_mod results. 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 single play() 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).
Coding — 11 real questions (#1–11)

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.

01
Coding · SWE · High Frequency

Tennis Score Game

CodingSWEHigh Frequency

Problem Overview

Design a two-player game with three core methods:

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:

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

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

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

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}"
Coach's note

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)

02
Coding · SWE · High Frequency

Report Chain

CodingSWEHigh Frequency

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:

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:

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:

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 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 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:

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.

Coach's note

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)

Practice these on LeetCode — Tree/graph construction, cloning & chain queries
03
Coding · SWE+MLE

Moderator List Hierarchy

CodingSWE, MLE

Problem Overview

Implement a Reddit-style moderator list from a newline-delimited log string.

The interview usually comes in three parts:

The core rule is:

In the first part, each log line has four comma-separated fields:

target, action, actor, timestamp

Assume:

Each part asks for the same three operations:

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

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

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

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

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

Coach's note

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.
Practice these on LeetCode — Hierarchical design (trie / nested map) + ordered ops
04
Coding · SWE+MLE · High Frequency

Logger Rate Limiter

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}]")
Why "timestamp + 10" and not "timestamp + 9"?
The constraint is "not printed again until at least t + 10". If last print was at t = 1, the next valid time is t = 11 (a gap of 10). Setting 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')
Subtle bug: stale deque entries
When a message is printed twice (at t=1 and later at t=15), the deque holds TWO entries: (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)}")
RLock vs Lock vs threading.local
Use 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
Which algorithm for which scenario?
API rate limiting (Twitter, GitHub): sliding window counter — accurate enough, O(1) per key.
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}]")
Why Lua? Why not MULTI/EXEC?
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.
Follow-up: What if timestamps can arrive out of order?
The hashmap approach silently breaks: a late message with timestamp t' < t could falsely suppress a legitimate future print. Fix: switch to a sorted-set (sliding window log) keyed on actual timestamp. Eviction uses 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.
Follow-up: How would you support different cooldown periods per message type (e.g., ERROR = 60s, DEBUG = 5s)?
Add a 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.
Follow-up: The service restarts. How do you preserve rate-limit state?
In-memory state is lost on restart. Options in increasing durability: (1) Redis with AOF persistence — state survives restarts; (2) Redis Cluster for HA; (3) Accept that brief windows after restart may allow duplicate prints — acceptable for logging but not for billing. For billing-grade correctness, write accepted events to a durable store (Postgres, Spanner) and reconstruct state from recent events on startup.
Follow-up: What is the space complexity of the sorted-set sliding window?
O(W * K) where W = maximum number of accepted events per key in any single window and K = number of distinct keys. For dedup (max 1 per window), this collapses to O(K) — same as the hashmap. The sorted-set approach only pays extra space when max_count > 1 (general rate limiting), where you need to store all W timestamps per key to know exactly how many fall within the sliding window.
Follow-up: Explain the "boundary burst" problem with fixed windows.
If the limit is 100 requests per 10-second window and windows are [0,10), [10,20), a client can send 100 requests at t=9.99 and 100 more at t=10.01 — that's 200 requests in a 0.02-second span. Sliding windows solve this: at any time, only 100 requests are allowed in any 10-second lookback. The hybrid sliding-window-counter approximates this at O(1) cost by weighting the previous bucket: count = prev_count * (1 - elapsed/window) + curr_count.
Gotchas & what Reddit/Anthropic grade
Off-by-one on the boundary. At t=11 after last print at t=1: 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)

Practice these on LeetCode — Design-with-time: timestamp bookkeeping behind an API
05
Coding · SWE · High Frequency

Merge Chat Message Windows

CodingSWEHigh Frequency

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:

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:

Assume:

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.

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

This is the key insight the interviewer usually wants instead of:

Complexity

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:

That means:

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

The important rule is still: deduplicate by message_id, not by version number.

Then decide whether get_chat_messages(...) should return:

Complexity

Coach's note

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)

Practice these on LeetCode — Interval / stream merging — the sorted-merge family
06
Coding · SWE · High Frequency

Word Search

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.

Why '#' works as sentinel
All valid characters in the problem are upper-case English letters A–Z. The character '#' (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).

The single most important line: restoring the cell
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?"
Order of base-case checks matters
Check 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
Follow-up: What if the same cell could be reused? How does your solution change?
Remove the in-place marking and restoration entirely (and remove the visited-set in the brute-force version). The DFS then allows cycles, so a word like "ABA" on a 1×2 board [["A","B"]] would return True. The complexity drops to O(m·n·4L) but the constant is larger because we can revisit cells. In practice this variant rarely appears but it's worth knowing.
Follow-up: Word Search II (LC 212) — find ALL words from a dictionary that exist in the board. How do you extend this?
Build a Trie from all dictionary words first. Then DFS from every cell once, walking the Trie in parallel with the grid. When you reach a Trie node marked as end-of-word, record that word. Prune branches where the current prefix is not in the Trie at all. This shares prefix work across all words: time is O(m·n·4L) total for all words combined rather than independently, where L is the longest word. Additionally, once a word is found, you can delete it from the Trie (node.word = None) to avoid duplicates without an extra seen-set.
Follow-up: The grid is 100×100 and you run this in Python — you get TLE. What do you do?
Several tactics: (1) Apply the frequency pre-check and reversed-word optimization from Approach 3 — these are free asymptotic wins. (2) Increase Python's recursion limit with 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.
Follow-up: What is the actual effective branching factor, and does the 4L bound feel tight?
It is loose. The first step from a starting cell has at most 4 neighbours. Every subsequent step has at most 3 unvisited neighbours (we cannot revisit the cell we came from — it is marked). So the tighter bound is O(m·n·3L). In practice, near edges and corners the branching factor is even lower (2 or 1). The 4L bound is conventionally stated because it is simpler to explain in an interview, and the difference between 3L and 4L does not change the overall conclusion about feasibility.
Follow-up: Can this problem be solved with BFS instead of DFS?
Technically yes, but BFS is wrong here unless you keep per-path visited state. A standard BFS queue does not carry the "which cells are currently in use" information — each queue entry would need its own copy of the visited set, exploding memory to O(m·n·L·m·n) in the worst case. DFS + backtracking is the natural fit because the call stack implicitly carries the visited state for the current path, which is exactly what backtracking needs. BFS is never the right approach for this problem.
Follow-up: What happens if word contains a '#' character?
The sentinel breaks. The cell marked '#' would match word[idx] == '#' and cause incorrect behavior. The fix is to choose a sentinel that cannot appear in the input. The problem constraints guarantee only uppercase A–Z, so '#' is safe. For a general solution you could XOR each character with a flag bit, use a separate boolean array (Approach 1), or use a global counter that is compared against a per-path counter stored at each cell.
Gotchas and what they grade

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)

Practice these on LeetCode — Grid DFS / backtracking with visited-state restore
07
Coding · SWE · High Frequency

Word Search II

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']
Why the naive approach TLEs
The LC word list allows up to 30 000 words. Even on a modest 12 × 12 board, running 30 000 independent DFS sweeps is prohibitive. The core waste: every word's search starts from scratch even when many words share a common prefix (e.g., "apple", "application", "apply"). A Trie eliminates this redundancy by exploring shared prefixes exactly once.

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.")
The three pruning tricks — know all three for Reddit
  1. 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.
  2. 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.
  3. Dead-branch pruning (_prune / refs decrement) — 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']
Complexity deep-dive

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.

In-place marking vs. visited matrix — which to use?
In-place marking (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.
Follow-up: The board is 300 × 300 and the word list is 30 000 words averaging 10 chars. What's the actual bottleneck?
Building the Trie is O(30 000 × 10) = 300 000 character insertions — fast. The DFS outer loop is 90 000 starting cells. With a real English word list, the Trie prunes aggressively because most cell sequences don't match any word prefix; empirically fewer than 0.1 % of cells lead to a Trie hit past depth 2. The dead-branch pruning further shrinks the Trie as words are collected. Real performance is dominated by cache misses on the board array and Trie pointer chasing, not raw DFS expansion.
Follow-up: Can you do better than O(m·n·4L)?
Not asymptotically for arbitrary boards — finding a single word is NP-hard on general 2D grids. But you can tighten the constant: (1) precompute a letter-to-cell map so you only start DFS from cells whose letter appears in the Trie root; (2) Aho-Corasick automaton instead of a Trie gives O(m·n·L) on a DFA that handles all words simultaneously with failure links, though the code complexity is much higher; (3) bidirectional BFS from both the word end and the board edge can prune further for long words.
Follow-up: What if the same word appears multiple times in the words list?
Deduplicate the input with 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.
Follow-up: What if words can wrap around the board (toroidal grid)?
Only the adjacency logic changes. Replace the bounds check with modular arithmetic: 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.
Follow-up: Reddit follow-up — "design the API if this runs as a service queried thousands of times per second with a static word list but changing boards."
Build the Trie once at startup from the static word list and cache it. Each request deserialized a board, runs the DFS, and returns results. Since the Trie is read-only during serving (and the dead-branch pruning mutates it), you must either (a) skip the pruning optimization for thread safety, (b) deep-copy the Trie per request (expensive), or (c) use a copy-on-write Trie with atomic reference counts. For high-throughput, option (a) + a process pool worker per CPU core is the pragmatic answer: Trie is immutable/shared, each worker owns its board copy.
Follow-up: Why use a dict for Trie children instead of an array of 26?
A fixed array of 26 gives O(1) access with better cache locality and avoids hash overhead. The tradeoff is memory: every node allocates 26 slots whether used or not. For a sparse word list on an alphabet of only 26 lowercase letters, an array of 26 is the faster, simpler choice. A dict is better when the alphabet is large (Unicode, mixed-case + digits) or when memory is constrained and the Trie is very sparse. For this specific LC problem, either works — state the tradeoff and the interviewer will appreciate the analysis.
Gotchas & what Reddit grades
  • 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 = None immediately 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 refs counter 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.setrecursionlimit or 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)

Practice these on LeetCode — Trie + grid backtracking (the pruning behind I→II)
08
Coding · MLE · High Frequency

Dictionary Word Transformation Path / Word Ladder

CodingMLEHigh Frequency

Problem Overview

You are given:

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:

Assume:

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:

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:

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:

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:

Good follow-up discussion:

Coach's note

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)

Practice these on LeetCode — Shortest transformation = BFS on a word graph
09
Coding · SWE · High Frequency

Billing Status Replay (OOD)

CodingSWEHigh Frequency

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:

Assume:

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:

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:

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:

To remove ambiguity, assume standard editor-style undo/redo semantics:

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

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.

where k is the number of columns touched by that transaction, and k <= c.

Overall replay remains:

Common Follow-Ups

Interviewers may push on a few practical extensions:

Idempotency

Streaming ingestion

Checkpointing

Auditability

Coach's note

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.
Practice these on LeetCode — Stateful OOD: command history, undo/redo, time lookups
10
Coding · ML Infra · New

Shortest Palindrome

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.)

The core insight before any code
Every valid answer has the form 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()
Why brute force TLEs on LC
The worst case is a string like "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()
Why '#' is mandatory — and why it must be out-of-alphabet
Without the separator, 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.
Why the last failure-function value is the answer
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()
Rolling hash vs KMP — trade-offs
Both are O(n) time and O(n) space. KMP is deterministic and has zero collision risk; rolling hash has an astronomically small but non-zero probability of a false positive (double-hashing makes this negligible in practice). In an interview, the KMP construction is the "intended" O(n) solution for LC 214. Mention rolling hash as an alternative if asked "is there another O(n) approach?" — it demonstrates breadth. Manacher's algorithm (which finds all palindromic substrings in O(n)) can also solve this but is harder to explain under pressure.

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
Deep dive: the KMP failure function itself
The failure function (also called lps — Longest Proper Prefix which is also Suffix) is the engine of the Knuth-Morris-Pratt string-matching algorithm. For a string 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.
Follow-up: what if you could add characters at the END instead of the front?
Symmetric problem. You want the longest palindromic suffix of 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.
Follow-up: what if you could add characters anywhere (front, back, or middle)?
This is the classic "minimum insertions to make a palindrome" problem (LC 1312). It is solved with DP in O(n²) time: 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.
Follow-up: can you solve this without KMP? (they want rolling hash or Z-function)
Yes — the Z-function approach: build 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).
Follow-up: how would you handle Unicode (multi-byte chars)?
Python strings are Unicode by default; indexing and slicing operate on code points, not bytes, so the solution works as-is for most Unicode. The only subtlety is that some characters (e.g., flag emojis) are represented as surrogate pairs or have combining diacritics; Python's 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.
Follow-up: what separator guarantees correctness?
Any single character that is guaranteed not to appear in 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).
Follow-up: prove that prepending reverse(suffix) is always correct.
Let 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.
Gotchas & what Reddit interviewers grade
1. Off-by-one on the separator. The combined string has length 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)

11
Coding · ML Infra · New

Odd Even Linked List

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]
Why the brute force is disqualified
The problem explicitly requires O(1) extra space. Storing values in a Python list is O(n). Overwriting node values also feels like cheating — in real systems nodes may carry non-copyable payloads; the right primitive is pointer rewiring.

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.")
Dry-run: [1, 2, 3, 4, 5]

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. ✓

Why the termination condition must check BOTH even and even.next

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.next would raise AttributeError.
  • even.next is not None — if even exists but even.next is None, there is no next odd node to pull across. Attempting odd.next = even.next would set odd.next = None and then odd = odd.next would 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.

Complexity analysis

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.

Common bugs in interview submissions
  • Losing even_head. Forgetting to save even_head = head.next before the loop. After the loop, even has advanced (and may be None), so you cannot recover the original even head.
  • Wrong loop termination. Using while odd.next and even.next or just while 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.next after advancing odd, because at that point odd.next is 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 None causes a crash when you unconditionally do even = head.next on a 1-element list and then enter the loop guard check.
  • Mutating values instead of pointers. Swapping .val fields 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.")
Trade-off vs. Approach 2

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

Follow-up: What if the list has only one node or is empty?
Return it immediately. The guard 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.
Follow-up: Can you do this if the list is doubly linked?
Yes, and it is actually easier. You still maintain odd/even tail pointers but you also fix the 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.
Follow-up: Generalize — partition the list into k groups by (index mod k), preserving order within each group. How does the algorithm change?
Keep an array of k tail pointers and k head pointers (or k dummy sentinel nodes). Walk the list once; for each node at 1-based index i, append it to chain i % k. After the traversal, stitch chain 0 → chain 1 → … → chain k−1. Time is still O(n); space is O(k), which is O(1) if k is treated as a constant. This is exactly the "partition list into buckets" pattern used in radix sort.
Follow-up: LC 143 — Reorder List (interleave first and second halves). How does it relate?
Reorder List (1 → 2 → 3 → 4 → 5 becomes 1 → 5 → 2 → 4 → 3) requires: (1) find the midpoint with slow/fast pointers, (2) reverse the second half in place, (3) interleave the two halves. The interleave step is structurally identical to our two-pointer weave here. Odd-Even List is essentially a warm-up for Reorder List — same pointer discipline, just no reversal step.
Follow-up: LC 86 — Partition List (all nodes less than x come before nodes ≥ x). Same pattern?
Exactly. Maintain two dummy heads (less_dummy and geq_dummy), walk the list, append each node to the appropriate chain, splice at the end. The structure is identical to our Approach 3 (dummy-head variant). Odd-Even, Partition List, and Reorder List all reduce to "maintain multiple running chains, stitch at the end."
Follow-up: What if indices are 0-based instead of 1-based?
The code is unchanged in structure — "odd index" just flips to "even index" and vice versa. Since we don't hard-code which nodes go first (the problem dictates that node-1-indexed nodes go first, which are the head, head.next.next, etc.), the only change is cosmetic labeling. No pointer logic changes.
Follow-up: Can this be done in a single pass with no saved even_head?
No. You must save even_head before the loop because after the loop 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.
Follow-up: How would you unit-test this in a real codebase?
Test vectors should cover: empty list, length 1, length 2 (minimum even), length 3 (minimum odd with a non-trivial odd chain), length 4, and a larger case like length 7. Also test that the output list has exactly n nodes (no node lost or duplicated) and that no cycles were introduced (walk the result and count nodes; if you reach n+1 you have a cycle). In a typed language you'd also verify that the original head is still the returned head.
Gotchas and what they grade
  • 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.next before even.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)

Practice these on LeetCode — In-place pointer surgery on linked lists
12
ML Fundamentals · MLE · High Frequency

ML Fundamentals

ML FundamentalsMachine Learning EngineerHigh Frequency

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:

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:

What The Interviewer Is Looking For

Modeling Basics

Interpreting The Overlap Figure

The key point is not just "pick a threshold." The more complete answer is:

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:

Model Choice

You may be asked:

Evaluation

Expect discussion around:

Cold Start

The round may end with cold start questions, such as:

A Strong Interview-Safe Answer Structure

  1. Clarify what y represents and whether this is regression or classification.
  2. Start with a simple baseline model and explain why.
  3. Interpret the overlapping distributions as evidence that one feature alone cannot perfectly separate the classes.
  4. Explain threshold selection in terms of business cost and metric trade-offs, not just geometry.
  5. Propose additional features that could reduce overlap.
  6. Discuss evaluation and how you would validate that the new features or model actually help.
  7. 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.

Coach's note

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)

13
ML Coding · MLE · High Frequency

Post Click Prediction

ML CodingMachine Learning EngineerHigh Frequency

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:

Candidates reported that the dataset was already quite clean:

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:

Interpretation:

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:

Step 2: Prepare Features

A reasonable preprocessing plan is:

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:

That is a good interview-safe progression:

What The Interviewer Is Looking For

Modeling Judgment

Be ready to explain:

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:

A strong answer explains that the final choice should depend on product goals:

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:

A Plausible Interview-Safe Solution Structure

  1. Load the JSON and convert it to a DataFrame.
  2. Confirm this is a binary classification problem.
  3. One-hot encode the current post category.
  4. Split the data and train a DummyClassifier.
  5. Train logistic regression as the main baseline.
  6. Compare with random forest and XGBoost.
  7. Report metrics and explain the trade-offs.
  8. If time remains, mention cross-validation and hyperparameter tuning as next steps.

Common Follow-Ups

Expect questions like:

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.

Coach's note

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)

ML system design (#18–19) — your round

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).

★ Latest reports (1Point3Acres, checked 2026-06) — the live ML case-study round

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.
Your on-the-spot playbook for messy-data + imbalance (say + do this)
  • EDA first (2 min): df.info(), df.describe(), df.isna().sum(), and df[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 a Pipeline so 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.

18
ML System Design · Machine Learning Engineer

ML System Design: Video Recommendation

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):

ML proxy metrics (what the model is actually trained to predict):

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.

Key clarification to make in the interview: Reddit's engagement signal is bimodal — upvote/downvote. That is richer than a pure like system: a downvote on a recommended video is a strong negative training signal, not just absence of engagement. Log both polarities.

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.

Interviewer probe: "What metrics would you use to detect if the recommendation system is making the platform worse?"
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:

Candidates from all sources are deduplicated by video ID before passing to Stage 2.

ANN index management: The HNSW index over 100M video embeddings is ~20–30GB in RAM. It is rebuilt nightly from the item tower encoder. Hot-swap it without downtime by loading the new index into a shadow shard, then atomically promoting it. Stale embeddings (videos >7 days old with no new engagement) are pruned to keep the index size tractable. New videos are added via incremental index insertion within minutes of publish.
Interviewer probe: "How do you handle a viral video that was posted 30 minutes ago? The ANN index won't have it yet."
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.

Interviewer probe: "Why not just maximize watch-time as a single target?"
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.

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:

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.

Randomization bucket design: The 1% randomization bucket must be large enough to get reliable propensity estimates at all positions you care about, but small enough not to hurt user experience with random slates. It must be held out from the training set of the model whose propensities you're estimating — otherwise you're using the model to estimate the propensity of its own training data, which is circular. Use a separate model version identifier to tag and exclude this traffic.

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:

  1. Store the full time-series of every feature in the feature store (or warehouse), not just the current value.
  2. 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.
  3. 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:

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.

Interviewer probe: "You just deployed a new model and watch-time went down. How do you debug it?"
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):

  1. 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.
  2. 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).
  3. 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.
  4. 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).
  5. 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.
Why 48h label window? Engagement signals (upvotes, shares) are bursty — a video gets most upvotes in the first few hours but continues accumulating for days. If you close the label window at 1h, you miss late signals and underestimate pUpvote for long-tail content that propagates slowly through communities. But if you wait 7 days to label, your training loop is 7 days slow to react to distribution shift. 48h is the empirical sweet spot for Reddit's engagement pattern.

4. Serving Architecture & Feature Store

Request path (target P99 < 200ms for home feed):

  1. Client request hits the Feed Gateway (load balancer). ~2ms.
  2. Gateway calls the Recommendation Service, passing user_id, context, device, session_id.
  3. Recommendation Service fetches user features from the online feature store (Redis/DynamoDB) in parallel. ~5ms.
  4. 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.
  5. Recommendation Service calls the Ranking Service with merged candidates + user features. ~50ms for DNN scoring of ~1,000 items (vectorized batch inference).
  6. Re-ranking rules applied in-memory. ~5ms.
  7. 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.

Interviewer probe: "How do you keep user embeddings fresh? If someone just watched 10 videos, their embedding is stale."
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.

Interviewer probe: "How do you know if your A/B test result is trustworthy?"
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:

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.

Observability stack: Every Kafka consumer, Flink job, and serving service emits metrics to a time-series store (e.g., Prometheus + Grafana). Critical alerts: (1) Kafka consumer lag >5 minutes on the feature update job (online features are stale), (2) ANN index insertion latency >10 minutes (new videos not appearing in retrieval), (3) Flink watermark delay >30 minutes on the training data job (labels are late, training will skip recent data), (4) Redis p99 read latency >2ms (feature store becoming a serving bottleneck). Each alert has an on-call runbook — not just a threshold.
Interviewer probe: "Walk me through exactly what happens between a user scrolling past a video and that event improving the model."
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.
What the interviewer is really grading. The Reddit rec-sys interview is not a transformer architecture quiz. The interviewer wants to know if you understand that a recommendation system is primarily a data pipeline with an ML component attached — and that the data pipeline is where most production bugs and most business impact live. The questions that actually distinguish strong candidates are: Can you explain position bias and IPS correction from first principles, with the formula? Can you describe exactly how a training example is constructed — which features are PIT-correct, how the label is derived from heartbeats not just play_end, where the IPS weight comes from? Can you describe training-serving skew and how the dual-write feature store pattern prevents it? Do you know what to log on an impression event (request_id, model_version, position) and why each field matters? Can you reason about the Flink job topology — what is the partition key, what is the watermark delay, what happens when a consumer lags? The candidate who launches into "I'd use a transformer with cross-attention between user and item" before drawing a single data-flow box will lose this interview. The candidate who draws the Kafka → Flink → Redis/Iceberg pipeline first, explains the feedback loop in detail, and only then discusses multi-task loss wins it.

Source: darkinterview.com (real Reddit interview report, captured May 2026)

19
ML System Design · Machine Learning Engineer

ML System Design: Feature Store

ML System DesignMachine Learning Engineer

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:

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:

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.

Coach's note

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)

Also on InterviewDB (cross-check, May 2026) — 6 questions I'd missed

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 by game_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)

Bonus — pure system design (#14–17, SWE-track)

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).