Lessons
1 · Foundations (Think Like a Programmer)
2 · Data Structures and Recursion
- 05Arrays and Array-Based Problem Solving
- 06Strings: Characters, Immutability, and Efficient Manipulation
- 07Linked Lists: Nodes, Pointers, and Pointer Reasoning
- 08Stacks and Queues
- 09Hashing: HashMap, HashSet, and Frequency Patterns
- 10Recursion and Backtracking
- 11Trees and Binary Search Trees
- 12Heaps and Priority Queues
3 · Core Algorithms and Reusable Patterns
4 · Advanced Interview Topics
Lesson 26 · From Practice to Job-Ready
Build a DSA Practice Project
Build a small, complete Java project that uses many data structures together: a practice tracker with spaced-repetition reviews, undo, title autocomplete and a prerequisite-based study order. Requirements, design, milestones, test cases and extension ideas, with no paid services.
For
Learners who want a realistic project that shows how data structures fit together, and who need something honest to discuss in interviews.
Level
Before you start
Most of the data-structure articles: hash maps, heaps, stacks, tries and topological sorting. You also need classes, enums and a little of java.time from Java basics.
On this page
Key takeaways
- A good practice project is small, finished and honest. Choose one you will actually use, so you keep improving it.
- Start from requirements and a simple domain model, then add features in milestones, each one runnable and tested.
- Each data structure earns its place by solving a real need: a map for lookup by topic, a heap for due dates, a stack for undo, a trie for autocomplete, a graph for study order.
- Write tests as you go, even without a framework. A small check helper is enough to catch regressions.
- Be ready to explain every choice, the trade-offs, and what you would improve next. That conversation is the real value for interviews.
By now you have seen arrays, maps, heaps, tries, graphs and more, mostly one at a time. Real programs use several of them together, and the interesting questions are about fit: which structure serves which need, and what it costs.
This article walks you through a small project that applies a handful of the structures from this course to something you can use yourself: a DSA practice tracker. It remembers which problems you have solved, tells you which ones are due for review (using spaced repetition), lets you undo mistakes, suggests problem titles as you type, and proposes a sensible order for studying topics.
It needs nothing beyond the Java standard library, so there is nothing to pay for or sign up for. And it is the kind of project you can talk about honestly in an interview, because you can explain every decision in it.
What you will learn
#- How to turn an idea into requirements and a simple design.
- Which data structure to pick for each need, and why.
- How to build a project in small milestones, each with a clear check.
- How to test without a framework.
- How to extend the project, and how to talk about it in an interview.
Requirements
#Write requirements before code. Here are ours, split into must-have and nice-to-have.
Must have
- Add a problem with a title, a topic (for example
arrays) and a difficulty (easy, medium or hard). - List problems by topic.
- Mark a problem as solved. The first solve schedules a review for the next day.
- Each later successful review schedules the next one further away, using the gaps 1, 3, 7, 14 and 30 days (a simple spaced-repetition idea: review just as you are about to forget).
- Show which problems are due for review on a given date, oldest first.
- Undo the last action, repeatedly.
- Suggest titles that start with what the user has typed.
- Suggest an order to study topics, respecting prerequisites (arrays before strings, recursion before trees, and so on).
- Save to and load from a plain text file.
Nice to have
- A command-line interface that reads commands.
- A report of the topics with the most unsolved problems.
Non-requirements (so we do not over-build): no database, no web server, no accounts, no network.
Design
#Domain model
#Difficulty: anenumwithEASY,MEDIUM,HARD.Problem:id,title,topic,difficulty,solved,attempts,reviewStage(how far through the schedule), andnextReview(aLocalDate, ornullbefore the first solve).
Which data structure for which need?
#Need | Structure | Why |
|---|---|---|
Find a problem by its id |
|
|
Problems due on a date, oldest first |
| The heap hands back the earliest date first |
Undo the last action |
| Last in, first out: each action pushes a way to reverse itself |
Title suggestions | A trie over titles (children in a |
|
Topic study order | A directed graph and Kahn's algorithm | Topological sorting respects prerequisites, and detects cycles |
Topics with most unsolved problems | A | The top- |
Architecture
#Keep three layers, so each can be understood and tested on its own.
Console -> Tracker -> Problem (model) parses business logic plain data commands and data structuresConsoleturns a line of text into a call onTracker, and a result back into text. It holds no business rules.Trackerowns the data structures and all the rules (scheduling, undo, due lists).Problemis just data.
For this article the runnable program is one file (Main.java) with nested classes, so you can paste it and run it. In your own repository, split it into one class per file:
dsa-tracker/ src/ Main.java (runs a demo or the interactive loop) tracker/Difficulty.java tracker/Problem.java tracker/TitleIndex.java (the trie) tracker/Curriculum.java (topic order) tracker/Tracker.java tracker/Console.java tests/ TrackerChecks.java README.mdCompile all the .java files into an out folder with javac -d out and run with java -cp out Main (adjust the paths for your operating system).
The complete program
#Read this in the order of the milestones below. The code follows the design above, and the last part of main runs a scripted demo and a set of self-checks. The date is set by a command, not read from the clock, so the output is the same every time.
// File: Main.javaimport java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;import java.time.LocalDate;import java.util.ArrayDeque;import java.util.ArrayList;import java.util.Collections;import java.util.Comparator;import java.util.Deque;import java.util.HashMap;import java.util.LinkedHashMap;import java.util.List;import java.util.Locale;import java.util.Map;import java.util.NoSuchElementException;import java.util.PriorityQueue;import java.util.TreeMap;public class Main { enum Difficulty { EASY, MEDIUM, HARD } static class Problem { final int id; final String title; final String topic; final Difficulty difficulty; boolean solved; int attempts; int reviewStage; // index into the review gaps: 0 = first review LocalDate nextReview; // null until the problem is first solved Problem(int id, String title, String topic, Difficulty difficulty) { this.id = id; this.title = title; this.topic = topic; this.difficulty = difficulty; } @Override public String toString() { return "#" + id + " " + title + " [" + topic + ", " + difficulty.name().toLowerCase(Locale.ROOT) + (solved ? ", solved" : "") + "]"; } } // Milestone 6: prefix suggestions over titles. Case-insensitive, any characters, alphabetical output. static class TitleIndex { private static class Node { final TreeMap<Character, Node> children = new TreeMap<>(); String title; // set when a title ends here } private final Node root = new Node(); void add(String title) { Node node = root; for (char c : title.toLowerCase(Locale.ROOT).toCharArray()) { node = node.children.computeIfAbsent(c, k -> new Node()); } node.title = title; } void remove(String title) { Node node = find(title.toLowerCase(Locale.ROOT)); if (node != null) { node.title = null; // simple removal: the empty path is left in place } } List<String> suggest(String prefix, int limit) { List<String> result = new ArrayList<>(); Node start = find(prefix.toLowerCase(Locale.ROOT)); if (start != null) { collect(start, result, limit); } return result; } private Node find(String text) { Node node = root; for (char c : text.toCharArray()) { node = node.children.get(c); if (node == null) { return null; } } return node; } private void collect(Node node, List<String> result, int limit) { if (result.size() >= limit) { return; } if (node.title != null) { result.add(node.title); } for (Node child : node.children.values()) { collect(child, result, limit); } } } // Milestone 5: a study order that respects prerequisites (Kahn's algorithm, alphabetical tie-breaks). static class Curriculum { static List<String> studyOrder(Map<String, List<String>> prerequisites) { Map<String, Integer> indegree = new TreeMap<>(); Map<String, List<String>> unlocks = new HashMap<>(); for (Map.Entry<String, List<String>> entry : prerequisites.entrySet()) { indegree.putIfAbsent(entry.getKey(), 0); for (String before : entry.getValue()) { indegree.putIfAbsent(before, 0); unlocks.computeIfAbsent(before, k -> new ArrayList<>()).add(entry.getKey()); indegree.merge(entry.getKey(), 1, Integer::sum); } } PriorityQueue<String> ready = new PriorityQueue<>(); for (Map.Entry<String, Integer> entry : indegree.entrySet()) { if (entry.getValue() == 0) { ready.offer(entry.getKey()); } } List<String> order = new ArrayList<>(); while (!ready.isEmpty()) { String topic = ready.poll(); order.add(topic); for (String next : unlocks.getOrDefault(topic, Collections.emptyList())) { if (indegree.merge(next, -1, Integer::sum) == 0) { ready.offer(next); } } } if (order.size() != indegree.size()) { throw new IllegalStateException("the prerequisites contain a cycle"); } return order; } } static class Tracker { static final int[] REVIEW_GAPS = {1, 3, 7, 14, 30}; // days until the next review private final Map<Integer, Problem> problems = new LinkedHashMap<>(); private final TitleIndex titles = new TitleIndex(); private final Deque<Runnable> undoStack = new ArrayDeque<>(); private int nextId = 1; // Milestone 1: add and look up problems. Problem add(String title, String topic, Difficulty difficulty) { if (title.isBlank() || topic.isBlank() || title.contains(",") || topic.contains(",")) { throw new IllegalArgumentException("title and topic must be non-empty and contain no commas"); } Problem problem = new Problem(nextId++, title, topic, difficulty); register(problem); undoStack.push(() -> { problems.remove(problem.id); titles.remove(problem.title); nextId--; }); return problem; } private void register(Problem problem) { problems.put(problem.id, problem); titles.add(problem.title); } Problem get(int id) { Problem problem = problems.get(id); if (problem == null) { throw new NoSuchElementException("no problem with id " + id); } return problem; } List<Problem> byTopic(String topic) { List<Problem> result = new ArrayList<>(); for (Problem p : problems.values()) { if (p.topic.equals(topic)) { result.add(p); } } return result; } // Milestone 2: solving and the review schedule. void markSolved(int id, LocalDate today) { Problem p = get(id); boolean oldSolved = p.solved; int oldAttempts = p.attempts; int oldStage = p.reviewStage; LocalDate oldNext = p.nextReview; p.attempts++; if (!p.solved) { p.solved = true; p.reviewStage = 0; // first solve: review soon } else { p.reviewStage = Math.min(p.reviewStage + 1, REVIEW_GAPS.length - 1); } p.nextReview = today.plusDays(REVIEW_GAPS[p.reviewStage]); undoStack.push(() -> { // Milestone 4: remember how to reverse this p.solved = oldSolved; p.attempts = oldAttempts; p.reviewStage = oldStage; p.nextReview = oldNext; }); } // Milestone 3: the review list, earliest due date first, using a heap. List<Problem> dueOn(LocalDate date) { PriorityQueue<Problem> heap = new PriorityQueue<>( Comparator.comparing((Problem p) -> p.nextReview).thenComparingInt(p -> p.id)); for (Problem p : problems.values()) { if (p.solved && !p.nextReview.isAfter(date)) { heap.offer(p); } } List<Problem> due = new ArrayList<>(); while (!heap.isEmpty()) { due.add(heap.poll()); } return due; } // Milestone 4: undo. boolean undo() { if (undoStack.isEmpty()) { return false; } undoStack.pop().run(); return true; } List<String> suggest(String prefix, int limit) { return titles.suggest(prefix, limit); } // The k topics with the most unsolved problems: a size-k heap again. List<String> topicsWithMostUnsolved(int k) { Map<String, Integer> unsolved = new HashMap<>(); for (Problem p : problems.values()) { if (!p.solved) { unsolved.merge(p.topic, 1, Integer::sum); } } PriorityQueue<Map.Entry<String, Integer>> heap = new PriorityQueue<>((a, b) -> a.getValue().equals(b.getValue()) ? b.getKey().compareTo(a.getKey()) : Integer.compare(a.getValue(), b.getValue())); for (Map.Entry<String, Integer> entry : unsolved.entrySet()) { heap.offer(entry); if (heap.size() > k) { heap.poll(); // drop the weakest candidate } } List<String> result = new ArrayList<>(); while (!heap.isEmpty()) { result.add(heap.poll().getKey()); } Collections.reverse(result); return result; } // Milestone 7: plain-text persistence (one problem per line). void save(Path file) throws IOException { List<String> lines = new ArrayList<>(); for (Problem p : problems.values()) { lines.add(String.join(",", String.valueOf(p.id), p.title, p.topic, p.difficulty.name(), String.valueOf(p.solved), String.valueOf(p.attempts), String.valueOf(p.reviewStage), p.nextReview == null ? "" : p.nextReview.toString())); } Files.write(file, lines); } static Tracker load(Path file) throws IOException { Tracker tracker = new Tracker(); for (String line : Files.readAllLines(file)) { String[] f = line.split(",", -1); Problem p = new Problem(Integer.parseInt(f[0]), f[1], f[2], Difficulty.valueOf(f[3])); p.solved = Boolean.parseBoolean(f[4]); p.attempts = Integer.parseInt(f[5]); p.reviewStage = Integer.parseInt(f[6]); p.nextReview = f[7].isEmpty() ? null : LocalDate.parse(f[7]); tracker.register(p); tracker.nextId = Math.max(tracker.nextId, p.id + 1); } return tracker; } String summary() { StringBuilder sb = new StringBuilder(); for (Problem p : problems.values()) { sb.append(p).append(" next=").append(p.nextReview).append(" stage=").append(p.reviewStage).append(" attempts=").append(p.attempts).append('\n'); } return sb.toString(); } } // Milestone 8: a tiny command interpreter. It holds no business rules. static class Console { private final Tracker tracker; private LocalDate today = LocalDate.of(2026, 1, 1); Console(Tracker tracker) { this.tracker = tracker; } String run(String line) { String[] parts = line.trim().split("\\s+", 4); try { switch (parts[0].toLowerCase(Locale.ROOT)) { case "today": today = LocalDate.parse(parts[1]); return "today is " + today; case "add": // add TOPIC DIFFICULTY title words... return "added " + tracker.add(parts[3], parts[1], Difficulty.valueOf(parts[2].toUpperCase(Locale.ROOT))); case "solve": tracker.markSolved(Integer.parseInt(parts[1]), today); return "solved #" + parts[1] + ", next review " + tracker.get(Integer.parseInt(parts[1])).nextReview; case "undo": return tracker.undo() ? "undone" : "nothing to undo"; case "due": return "due: " + tracker.dueOn(today); case "topic": return tracker.byTopic(parts[1]).toString(); case "search": return "suggestions: " + tracker.suggest(line.trim().substring(7), 5); default: return "unknown command: " + parts[0]; } } catch (RuntimeException e) { return "error: " + e.getMessage(); } } } static int checks = 0; static int passed = 0; static void check(String name, boolean condition) { checks++; if (condition) { passed++; } else { System.out.println("FAILED: " + name); } } public static void main(String[] args) throws IOException { // A scripted session. The date comes from a command, so the output never changes. Console console = new Console(new Tracker()); String[] script = { "today 2026-10-05", "add arrays easy Two Sum", "add arrays medium Product Except Self", "add hashing easy Contains Duplicate", "add trees medium Validate BST", "add graphs hard Word Ladder", "solve 1", "solve 3", "search t", "today 2026-10-06", "due", "solve 1", "undo", "solve 1", "today 2026-10-09", "due", "undo", "topic arrays", "solve 99", "add arrays easy Bad,Title", "fly away" }; for (String line : script) { System.out.println("> " + line + "\n " + console.run(line)); } // Study order from prerequisites. Map<String, List<String>> prerequisites = new HashMap<>(); prerequisites.put("arrays", List.of()); prerequisites.put("strings", List.of("arrays")); prerequisites.put("hashing", List.of("arrays")); prerequisites.put("recursion", List.of("arrays")); prerequisites.put("trees", List.of("recursion")); prerequisites.put("graphs", List.of("trees")); prerequisites.put("dp", List.of("recursion", "hashing")); List<String> order = Curriculum.studyOrder(prerequisites); System.out.println("study order: " + order); // Self-checks (the "tests"): small, fast, and run on every change. Tracker t = new Tracker(); Problem a = t.add("Two Sum", "arrays", Difficulty.EASY); Problem b = t.add("Three Sum", "arrays", Difficulty.MEDIUM); t.add("Course Schedule", "graphs", Difficulty.MEDIUM); LocalDate day = LocalDate.of(2026, 3, 1); t.markSolved(a.id, day); check("first solve schedules +1 day", a.nextReview.equals(day.plusDays(1))); t.markSolved(a.id, day.plusDays(1)); check("second solve schedules +3 days", a.nextReview.equals(day.plusDays(4))); for (int i = 0; i < 6; i++) { t.markSolved(a.id, day); } check("the schedule stops growing at 30 days", a.nextReview.equals(day.plusDays(30)) && a.reviewStage == 4); check("attempts are counted", a.attempts == 8); t.markSolved(b.id, day); check("due list is ordered by date then id", t.dueOn(day.plusDays(40)).get(0).id == b.id); check("nothing is due before the date", t.dueOn(day).isEmpty()); check("unsolved problems are never due", t.dueOn(day.plusDays(100)).size() == 2); int attemptsBefore = a.attempts; t.markSolved(a.id, day); check("undo restores a solve", t.undo() && a.attempts == attemptsBefore); int sizeBefore = t.byTopic("graphs").size(); t.add("Number of Islands", "graphs", Difficulty.MEDIUM); check("undo removes an added problem", t.undo() && t.byTopic("graphs").size() == sizeBefore && t.suggest("number", 5).isEmpty()); check("undo on an empty history is safe", new Tracker().undo() == false); boolean rejected = false; try { t.add("Bad, Title", "arrays", Difficulty.EASY); } catch (IllegalArgumentException e) { rejected = true; } check("commas in titles are rejected", rejected); check("autocomplete is case-insensitive and sorted", t.suggest("T", 5).equals(List.of("Three Sum", "Two Sum"))); check("autocomplete respects the limit", t.suggest("t", 1).size() == 1); check("autocomplete finds nothing for an unknown prefix", t.suggest("zzz", 5).isEmpty()); boolean orderValid = true; for (Map.Entry<String, List<String>> entry : prerequisites.entrySet()) { for (String before : entry.getValue()) { orderValid &= order.indexOf(before) < order.indexOf(entry.getKey()); } } check("study order respects every prerequisite", orderValid && order.size() == prerequisites.size()); boolean cycleDetected = false; try { Curriculum.studyOrder(Map.of("a", List.of("b"), "b", List.of("a"))); } catch (IllegalStateException e) { cycleDetected = true; } check("a prerequisite cycle is reported", cycleDetected); Tracker many = new Tracker(); many.add("p1", "arrays", Difficulty.EASY); many.add("p2", "arrays", Difficulty.EASY); many.add("p3", "graphs", Difficulty.HARD); many.add("p4", "dp", Difficulty.HARD); many.add("p5", "dp", Difficulty.HARD); many.markSolved(4, day); check("top unsolved topics, ties alphabetical", many.topicsWithMostUnsolved(2).equals(List.of("arrays", "dp"))); Path file = Files.createTempFile("tracker", ".csv"); try { t.save(file); Tracker loaded = Tracker.load(file); check("save and load round-trip", loaded.summary().equals(t.summary())); Problem added = loaded.add("After Load", "arrays", Difficulty.EASY); check("ids continue after loading", added.id == 4); } finally { Files.deleteIfExists(file); } System.out.println(passed + " of " + checks + " checks passed"); }}> today 2026-10-05 today is 2026-10-05> add arrays easy Two Sum added #1 Two Sum [arrays, easy]> add arrays medium Product Except Self added #2 Product Except Self [arrays, medium]> add hashing easy Contains Duplicate added #3 Contains Duplicate [hashing, easy]> add trees medium Validate BST added #4 Validate BST [trees, medium]> add graphs hard Word Ladder added #5 Word Ladder [graphs, hard]> solve 1 solved #1, next review 2026-10-06> solve 3 solved #3, next review 2026-10-06> search t suggestions: [Two Sum]> today 2026-10-06 today is 2026-10-06> due due: [#1 Two Sum [arrays, easy, solved], #3 Contains Duplicate [hashing, easy, solved]]> solve 1 solved #1, next review 2026-10-09> undo undone> solve 1 solved #1, next review 2026-10-09> today 2026-10-09 today is 2026-10-09> due due: [#3 Contains Duplicate [hashing, easy, solved], #1 Two Sum [arrays, easy, solved]]> undo undone> topic arrays [#1 Two Sum [arrays, easy, solved], #2 Product Except Self [arrays, medium]]> solve 99 error: no problem with id 99> add arrays easy Bad,Title error: title and topic must be non-empty and contain no commas> fly away unknown command: flystudy order: [arrays, hashing, recursion, dp, strings, trees, graphs]19 of 19 checks passedMilestones
#Build the project in small steps. After each one, you should be able to run something and see it work. Resist the urge to write everything at once.
Milestone | What you build | How you know it works |
|---|---|---|
1. Model and add |
| You can add problems and list them by topic. Adding a title with a comma is rejected |
2. Solve and schedule |
| The first solve gives "tomorrow", the next gives "in 3 days", and the stage stops growing at 30 |
3. Due list |
| Problems come out oldest first. Unsolved problems never appear |
4. Undo | An undo stack of | Undoing a solve restores the old date. Undoing an add removes the problem. Undo on empty history is safe |
5. Study order |
| The order respects every prerequisite, and a cycle is reported |
6. Autocomplete |
|
|
7. Persistence |
| A loaded tracker has the same summary as the one that was saved, and new ids carry on |
8. Console |
| A script of commands produces the expected text, and bad commands give errors instead of crashes |
How the pieces use data structures
#- Undo with closures. Each action pushes a small piece of code (a
Runnable) that reverses it, andundopops and runs it. The code that records "the old values" must capture them before changing anything, which is whymarkSolvedcopiesoldSolved,oldAttempts,oldStageandoldNextfirst. - The due list. A heap is not strictly necessary here, because we could sort the due problems. We use a
PriorityQueueto keep the lesson in context: the ordering is expressed once, in the comparator (nextReview, thenid, so ties are deterministic). - Study order. Kahn's algorithm with a
PriorityQueue<String>for the ready topics makes the order deterministic (alphabetical among the topics that are free). Without that, any valid order is acceptable, but tests are easier when the result is predictable. - Autocomplete. The trie's nodes use a
TreeMap, so suggestions come out alphabetically for free. We store the original title at the end node, and walk the lower-cased text, so matching ignores case but the suggestion keeps its capital letters.
Reading the demo output
#In the scripted session above, notice the details:
- After
solve 1on 5 October, the next review is 6 October (a 1-day gap).dueon the 6th lists problems 1 and 3 (both solved on the 5th). - Solving problem 1 again on the 6th would schedule it 3 days later (9 October). We
undothat, then solve it again, which gives the same result, and laterundoit once more. This shows that undo restores the old state exactly. solve 99and the badaddandfly awaycommands produce error messages, not crashes. The interpreter catches exceptions and turns them into text, which is the job of that layer.
Test cases
#You do not need a testing framework to start. A check(name, condition) helper that counts passes and prints failures is enough, and it is how this program tests itself. Here are the cases it covers, which are also a good model for choosing tests in your own projects:
Area | Test |
|---|---|
Scheduling | First solve gives |
Due list | Ordered by date then id. Empty before any date is reached. Unsolved problems excluded |
Undo | Undoing a solve restores attempts and dates. Undoing an add removes the problem and its autocomplete entry. Undo on empty history returns false |
Validation | A title with a comma is rejected |
Autocomplete | Case-insensitive, alphabetical, respects the limit, empty for unknown prefixes |
Study order | Every prerequisite comes before its dependent. A cycle throws |
Top topics | Counts only unsolved problems, ties broken alphabetically |
Persistence | Save then load gives an identical summary. New ids continue after loading |
Once this works, a good next step is to move the checks into JUnit, the standard Java testing library. That would add a dependency, so it is optional, but it is what many real projects use.
Extension ideas
#Choose one at a time, and finish it before starting another.
- Group related topics with disjoint set union: "arrays" and "strings" can merge into a "basics" group, and the tracker reports progress per group.
- Recently viewed problems with an LRU cache built on
LinkedHashMap(its access-order mode makes this short). - Daily statistics with prefix sums: how many problems you solved in the last 7 or 30 days, in
O(1)per query after a pass over the data. - A harder scheduler: lengthen the gap after an easy review, and reset to the first stage after a failed one, using
Difficultyas input. - Export a Markdown report of solved problems by topic.
- Better persistence: handle commas by escaping them, or switch to JSON using a library (this adds a dependency).
- A simple web or desktop front end on top of
Tracker. The three-layer design means the core does not change. - Real concurrency or networking are out of scope here, but they make a good second project.
Talking about the project in an interview
#A project helps in an interview only if you can discuss it honestly and in depth. Prepare short answers to these:
- What does it do, and why did you build it? One or two sentences, in terms of the problem it solves for you.
- Which data structures did you use, and why those? Use the table above. Mention the trade-offs: for example, the trie costs more memory than a
TreeSetbut gives sorted prefix results directly. - What was the hardest bug? Real examples are best (such as capturing old values before changing state for undo).
- How did you test it? The checks, and what they caught.
- What would you do next, and what are the limits? For example: titles cannot contain commas, undo history is not saved, the schedule is simplistic. Admitting limits honestly is a strength.
- What is the complexity of its main operations?
markSolvedisO(1),dueOnisO(n log n)fornproblems, autocomplete isO(L + size of the subtrie).
Do not describe features you did not build, and do not claim technologies you only skimmed. If the interviewer asks about something you did not use, say so, and explain how you would approach it. The next article, Your 12-Week DSA Study and Revision Plan, schedules this project into your preparation, and Indian Fresher Placement and Coding Interview Preparation covers how to present projects in placement interviews.
Complexity summary
#Operation | Time | Notes |
|---|---|---|
| O(L) and O(1) |
|
| O(n) | A scan. For many topics, keep a |
| O(1) | Plus a constant for the undo record |
| O(n log n) | Heap of the due problems |
| O(1) | Running the stored reversal |
| O(L + k) | Walk the prefix, then collect |
| O((V + E) log V) |
|
| O(n + m log k) |
|
| O(n) | One pass over the problems |
Edge cases and common mistakes
#- Changing state before recording how to undo it. Capture the old values first.
- Data that breaks the file format. Commas in titles break the simple comma-separated format. Validate, escape, or use a different format.
- Using the system clock inside the logic. It makes tests unrepeatable. Pass the date in, as
todayis passed here. - Mixing layers. Business rules in the console code, or printing in the tracker, make testing painful.
- Sharing mutable objects.
byTopicreturns problems that callers could modify. Decide whether to return copies or read-only views. - Stale index entries. When a problem is removed (by undo), remove its title from the trie too.
- Id handling after loading. The next id must be larger than any loaded id, or new problems will collide.
- Date arithmetic across month ends and leap years. Let
LocalDatehandle it, and never add days by hand. - Forgetting empty states. An empty tracker, no due problems, and an empty undo history should all behave sensibly.
- Over-building. Finish the milestones before adding extensions.
Exercises
#Exercise 1
Trace the scheduler
#A problem is first solved on 10 March. It is reviewed successfully on its due date three more times. List the review dates and the reviewStage after each solve.
Exercise 2: Test design
#Write a list of at least six additional test cases for dueOn, markSolved and undo, that the program above does not cover yet.
Exercise 3: Add a feature
#Add markFailed(id, today): a failed review resets the problem to the first stage and schedules it for the next day, and it must be undoable. Show the code and a test.
Exercise 4: Debug
#A developer writes the undo for markSolved like this. What is wrong, and how would you see the bug in a test?
undoStack.push(() -> { p.solved = false; p.attempts--;});Exercise 5: Explain your design
#An interviewer asks: "Why did you use a trie for autocomplete instead of a TreeSet?" Write a balanced answer of a few sentences, including when you would choose the other option.
Show solutionHide solution
The gaps are 1, 3, 7, 14, 30 days, and the stage starts at 0 on the first solve.
Event | Date |
| Next review |
|---|---|---|---|
First solve | 10 March | 0 | +1 day: 11 March |
Review 1 (on the due date) | 11 March | 1 | +3 days: 14 March |
Review 2 | 14 March | 2 | +7 days: 21 March |
Review 3 | 21 March | 3 | +14 days: 4 April |
The next one would be stage 4 (+30 days), and every later review stays at stage 4. Dates cross the month boundary correctly because we use LocalDate.plusDays.
// File: Main.javaimport java.time.LocalDate;public class Main { public static void main(String[] args) { int[] gaps = {1, 3, 7, 14, 30}; LocalDate date = LocalDate.of(2026, 3, 10); int stage = 0; LocalDate next = date.plusDays(gaps[stage]); System.out.println("solve " + date + " -> stage " + stage + ", next " + next); for (int review = 1; review <= 3; review++) { date = next; stage = Math.min(stage + 1, gaps.length - 1); next = date.plusDays(gaps[stage]); System.out.println("review " + date + " -> stage " + stage + ", next " + next); } }}solve 2026-03-10 -> stage 0, next 2026-03-11review 2026-03-11 -> stage 1, next 2026-03-14review 2026-03-14 -> stage 2, next 2026-03-21review 2026-03-21 -> stage 3, next 2026-04-04Exercise 2
Show solutionHide solution
Additional tests:
dueOnon the exact due date includes the problem (the comparison is "not after"), and on the day before excludes it.- Two problems with the same due date come out in id order.
- A review done late. If a problem due on the 5th is reviewed on the 9th, the next date is 9th plus the gap, not 5th plus the gap.
markSolvedon an unknown id throwsNoSuchElementExceptionand leaves the undo stack unchanged (a failed action must not push an undo entry).- Undo twice in a row after two solves restores both, in the right order.
- Undo after
addthenmarkSolvedundoes the solve first, then the add. - Undo an
addand then add again: the new problem gets the same id the removed one had, and its title appears in autocomplete exactly once. dueOnwith no solved problems returns an empty list.- A solved problem whose review is far in the future is never listed for earlier dates.
Number 4 is worth a closer look: in markSolved, get(id) throws before anything is pushed on the undo stack, so the stack stays consistent. If the code pushed the undo entry first and then failed, a later undo would run a reversal for an action that never happened.
Exercise 3
Show solutionHide solution
A failed review should: record the old values (for undo), increment attempts, reset reviewStage to 0, and set nextReview to today + 1. It only makes sense for a problem that has been solved before, so throw otherwise. We use the same pattern as markSolved: capture old state first, then change it, then push the reversal.
// File: Main.javaimport java.time.LocalDate;import java.util.ArrayDeque;import java.util.Deque;public class Main { static class Problem { boolean solved; int attempts; int reviewStage; LocalDate nextReview; } static final Deque<Runnable> undoStack = new ArrayDeque<>(); static void markFailed(Problem p, LocalDate today) { if (!p.solved) { throw new IllegalStateException("only a solved problem can fail a review"); } int oldAttempts = p.attempts; // capture first int oldStage = p.reviewStage; LocalDate oldNext = p.nextReview; p.attempts++; p.reviewStage = 0; p.nextReview = today.plusDays(1); undoStack.push(() -> { p.attempts = oldAttempts; p.reviewStage = oldStage; p.nextReview = oldNext; }); } public static void main(String[] args) { Problem p = new Problem(); p.solved = true; p.attempts = 3; p.reviewStage = 3; p.nextReview = LocalDate.of(2026, 3, 21); markFailed(p, LocalDate.of(2026, 3, 21)); System.out.println(p.attempts + " " + p.reviewStage + " " + p.nextReview); undoStack.pop().run(); System.out.println(p.attempts + " " + p.reviewStage + " " + p.nextReview); try { markFailed(new Problem(), LocalDate.of(2026, 3, 21)); } catch (IllegalStateException e) { System.out.println("error: " + e.getMessage()); } }}4 0 2026-03-223 3 2026-03-21error: only a solved problem can fail a reviewThe test above is the pattern to follow: check the new state, undo, check the old state, and check that the invalid case throws.
Exercise 4
Show solutionHide solution
The reversal is wrong because it guesses the old state instead of restoring it. It always sets solved = false and subtracts one attempt. That is only correct for a first solve. If the problem was already solved (this was a review), undoing it should leave solved = true, restore the old reviewStage, and restore the old nextReview, which the code does not touch at all.
A test that shows it: solve a problem on 1 March, solve it again on 2 March (a review), then undo. The correct state after the undo is solved = true, attempts = 1, nextReview equal to 2 March. The buggy undo gives solved = false, attempts = 1, and leaves nextReview at the later date, so the problem would even disappear from the due list.
The fix is the one in the program: capture all four fields before changing them, and restore those exact values.
Exercise 5
Show solutionHide solution
A balanced answer:
"I used a trie because autocomplete is mostly a prefix operation. Walking down the prefix costs time proportional to its length, independent of how many titles are stored, and the children in a sorted map give alphabetical suggestions for free. The cost is memory: every character of every title is a node, so a trie uses more space than a sorted set of strings.
A TreeSet<String> would also work. With subSet(prefix, prefix + Character.MAX_VALUE) it returns the titles with that prefix in O(log n + k) comparisons, with much less memory and less code. For a personal tracker with a few hundred titles, that is probably the more sensible choice. I would pick the trie when there are many prefix queries, when I want to attach data to prefixes (like counts), or when I need to prune a search character by character. I built the trie here partly because it is a good exercise, and I would say so if asked."
This kind of answer shows you know the alternative, the trade-off, and the actual reason for your choice, including when the reason is "to practise".
Where to go next
#- Next: Your 12-Week DSA Study and Revision Plan puts all the topics, this project and revision into a realistic schedule.
- Related: Indian Fresher Placement and Coding Interview Preparation explains how to present projects honestly. For ideas on presenting your work online, see How to Build a Developer Portfolio Website That Gets You Interviews.
- Practice habit: after every milestone, commit your work with a clear message. A repository with a small, steady history shows how you work, and gives you real stories for an interview.
Connected lessons
References
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
How to Learn Data Structures and Algorithms Without Burning Out
Guide · Career
The OA Landscape: Platforms, Formats, Proctoring and Rules
Guide · Career
A LinkedIn Profile That Gets Recruiter Messages

