Skip to content
SparkTime, home

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.

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

  1. Add a problem with a title, a topic (for example arrays) and a difficulty (easy, medium or hard).
  2. List problems by topic.
  3. Mark a problem as solved. The first solve schedules a review for the next day.
  4. 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).
  5. Show which problems are due for review on a given date, oldest first.
  6. Undo the last action, repeatedly.
  7. Suggest titles that start with what the user has typed.
  8. Suggest an order to study topics, respecting prerequisites (arrays before strings, recursion before trees, and so on).
  9. 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: an enum with EASY, MEDIUM, HARD.
  • Problem: id, title, topic, difficulty, solved, attempts, reviewStage (how far through the schedule), and nextReview (a LocalDate, or null before the first solve).

Which data structure for which need?

#

Need

Structure

Why

Find a problem by its id

Map<Integer, Problem> (a LinkedHashMap, so it keeps insertion order)

O(1) lookup, and stable listing

Problems due on a date, oldest first

PriorityQueue<Problem> ordered by nextReview

The heap hands back the earliest date first

Undo the last action

Deque<Runnable> used as a stack

Last in, first out: each action pushes a way to reverse itself

Title suggestions

A trie over titles (children in a TreeMap)

O(prefix length) to find the subtree, alphabetical output for free

Topic study order

A directed graph and Kahn's algorithm

Topological sorting respects prerequisites, and detects cycles

Topics with most unsolved problems

A HashMap for counts, then a size-k heap

The top-k pattern

Architecture

#

Keep three layers, so each can be understood and tested on its own.

Code
Console  ->  Tracker  ->  Problem (model)  parses        business logic      plain data  commands      and data structures
  • Console turns a line of text into a call on Tracker, and a result back into text. It holds no business rules.
  • Tracker owns the data structures and all the rules (scheduling, undo, due lists).
  • Problem is 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:

Code
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.md

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

Java
// 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");    }}
Code
> 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 passed

Milestones

#

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

Difficulty, Problem, Tracker.add, get, byTopic

You can add problems and list them by topic. Adding a title with a comma is rejected

2. Solve and schedule

markSolved with the gaps 1, 3, 7, 14, 30

The first solve gives "tomorrow", the next gives "in 3 days", and the stage stops growing at 30

3. Due list

dueOn(date) using a PriorityQueue

Problems come out oldest first. Unsolved problems never appear

4. Undo

An undo stack of Runnables, pushed by add and markSolved

Undoing a solve restores the old date. Undoing an add removes the problem. Undo on empty history is safe

5. Study order

Curriculum.studyOrder (Kahn's algorithm)

The order respects every prerequisite, and a cycle is reported

6. Autocomplete

TitleIndex, a trie with TreeMap children

"T" suggests Three Sum and Two Sum, sorted, case-insensitively

7. Persistence

save and load with java.nio.file.Files

A loaded tracker has the same summary as the one that was saved, and new ids carry on

8. Console

Console.run, a command interpreter

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, and undo pops and runs it. The code that records "the old values" must capture them before changing anything, which is why markSolved copies oldSolved, oldAttempts, oldStage and oldNext first.
  • The due list. A heap is not strictly necessary here, because we could sort the due problems. We use a PriorityQueue to keep the lesson in context: the ordering is expressed once, in the comparator (nextReview, then id, 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 1 on 5 October, the next review is 6 October (a 1-day gap). due on 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 undo that, then solve it again, which gives the same result, and later undo it once more. This shows that undo restores the old state exactly.
  • solve 99 and the bad add and fly away commands 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 +1 day. Second gives +3. After many solves, the gap stays at 30 and reviewStage stops at 4

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.

  1. Group related topics with disjoint set union: "arrays" and "strings" can merge into a "basics" group, and the tracker reports progress per group.
  2. Recently viewed problems with an LRU cache built on LinkedHashMap (its access-order mode makes this short).
  3. 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.
  4. A harder scheduler: lengthen the gap after an easy review, and reset to the first stage after a failed one, using Difficulty as input.
  5. Export a Markdown report of solved problems by topic.
  6. Better persistence: handle commas by escaping them, or switch to JSON using a library (this adds a dependency).
  7. A simple web or desktop front end on top of Tracker. The three-layer design means the core does not change.
  8. 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 TreeSet but 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? markSolved is O(1), dueOn is O(n log n) for n problems, autocomplete is O(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

add, get

O(L) and O(1)

L is the title length (trie insert)

byTopic

O(n)

A scan. For many topics, keep a Map<String, List<Problem>>

markSolved

O(1)

Plus a constant for the undo record

dueOn

O(n log n)

Heap of the due problems

undo

O(1)

Running the stored reversal

suggest

O(L + k)

Walk the prefix, then collect k titles

studyOrder

O((V + E) log V)

V topics, E prerequisites, because of the heap

topicsWithMostUnsolved

O(n + m log k)

m distinct topics

save, load

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 today is passed here.
  • Mixing layers. Business rules in the console code, or printing in the tracker, make testing painful.
  • Sharing mutable objects. byTopic returns 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 LocalDate handle 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?

Java
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

reviewStage after

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.

Java
// 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);        }    }}
Code
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-04

Exercise 2

Show solutionHide solution

Additional tests:

  1. dueOn on the exact due date includes the problem (the comparison is "not after"), and on the day before excludes it.
  2. Two problems with the same due date come out in id order.
  3. 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.
  4. markSolved on an unknown id throws NoSuchElementException and leaves the undo stack unchanged (a failed action must not push an undo entry).
  5. Undo twice in a row after two solves restores both, in the right order.
  6. Undo after add then markSolved undoes the solve first, then the add.
  7. Undo an add and then add again: the new problem gets the same id the removed one had, and its title appears in autocomplete exactly once.
  8. dueOn with no solved problems returns an empty list.
  9. 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.

Java
// 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());        }    }}
Code
4 0 2026-03-223 3 2026-03-21error: only a solved problem can fail a review

The 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

#
  • 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