LeetCode 2590 - Design a Todo List
This problem asks us to design a small task management system, similar to a lightweight todo application. We need to implement a TodoList class that supports adding tasks, marking tasks as completed, retrieving all pending tasks for a user, and filtering pending tasks by tag.
Difficulty: 🟡 Medium
Topics: Array, Hash Table, String, Design, Sorting
Solution
Problem Understanding
This problem asks us to design a small task management system, similar to a lightweight todo application. We need to implement a TodoList class that supports adding tasks, marking tasks as completed, retrieving all pending tasks for a user, and filtering pending tasks by tag.
Each task belongs to a specific user and contains four important pieces of information:
- A unique sequential
taskId - A textual description
- A
dueDate - A list of tags
The system must also track whether a task has been completed.
The key operations involve retrieving tasks in sorted order. Specifically, when we fetch tasks, they must be ordered by dueDate, and only uncompleted tasks should appear. Additionally, filtering by tag should only return uncompleted tasks containing that specific tag.
Let us restate the operations in simpler terms:
The addTask method creates a new task for a user and assigns it an automatically increasing ID. The first task gets ID 1, the second gets ID 2, and so on.
The getAllTasks method returns all incomplete task descriptions for a given user, sorted by due date.
The getTasksForTag method behaves similarly, except it only includes incomplete tasks containing the specified tag.
The completeTask method marks a task as completed, but only if:
- The task actually exists.
- The task belongs to the specified user.
- The task has not already been completed.
If any of these conditions fail, the operation does nothing.
The input in LeetCode consists of a sequence of method calls on the TodoList object. The expected output corresponds to the return value of each method call. Methods returning void produce null.
The constraints are very important for choosing a solution strategy.
- There are at most
100calls per method. userId,taskId, anddueDateare all at most100.- Due dates are guaranteed to be unique.
- Tags and descriptions are small.
These constraints are extremely small. Since there are at most a few hundred total tasks, even an approach involving sorting during retrieval is perfectly acceptable. We do not need advanced balanced trees, heaps, or indexing systems.
Several edge cases stand out immediately.
A user may not exist yet, so retrieval methods should return an empty list. A task completion request may reference the wrong user or a nonexistent task, which should safely do nothing. Tasks may have no tags, meaning tag filtering should naturally exclude them unless relevant. Since due dates are unique, sorting never faces tie-breaking ambiguity.
Approaches
Brute Force Approach
The most straightforward solution is to store every task in a single global list. Each task would contain all relevant information, including user ownership, completion status, tags, and due date.
For retrieval operations like getAllTasks, we would scan the entire task list and filter tasks that:
- Belong to the requested user
- Are not completed
Then we would sort the filtered tasks by due date and return their descriptions.
Similarly, for getTasksForTag, we would scan every task again and additionally check whether the requested tag exists in the task's tag list.
This works because every query explicitly checks the required conditions and sorting guarantees correct ordering.
However, this approach is inefficient because every query scans all tasks globally, even tasks belonging to unrelated users.
Optimal Approach
A better design is to organize tasks by user.
We maintain:
- A hash map from
userId → list of taskIds - A hash map from
taskId → task data - A sequential task ID counter
Each task stores:
- Description
- Due date
- Tags
- Completion state
- Owner (
userId)
When retrieving tasks, we only inspect the tasks owned by that user instead of scanning all tasks globally.
Since constraints are small, we can simply sort the user's active tasks during retrieval. Due dates are unique, so ordering is straightforward.
The major insight is that retrieval happens per user, so grouping tasks by user reduces unnecessary work and makes the design cleaner.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n log n) per query | O(n) | Scans all tasks globally |
| Optimal | O(k log k) per query | O(n) | Only scans tasks for one user |
Here:
n= total number of tasksk= number of tasks for a specific user
Since k ≤ n, the optimal solution is strictly better in practice.
Algorithm Walkthrough
Step 1: Create Task Storage
We maintain a dictionary mapping taskId to task information.
Each task stores:
userIddescriptiondueDatetagscompleted
This allows O(1) lookup by task ID.
Step 2: Group Tasks by User
We maintain another dictionary:
user_tasks[userId] → [taskIds]
This prevents us from scanning unrelated users' tasks.
Whenever a task is added, we append its ID to the appropriate user's list.
Step 3: Maintain Sequential Task IDs
We keep a counter starting at 1.
Every time addTask is called:
- Assign the current counter value.
- Increment the counter.
- Store the task.
This guarantees sequential IDs.
Step 4: Add a Task
When addTask is called:
- Generate a new task ID.
- Create a task object.
- Store it in the task map.
- Add the task ID to the user's list.
- Return the task ID.
This operation is constant time.
Step 5: Retrieve All Tasks
When getAllTasks(userId) is called:
- Retrieve that user's task IDs.
- Iterate through them.
- Ignore completed tasks.
- Collect
(dueDate, description)pairs. - Sort by due date.
- Return descriptions only.
Sorting ensures correct ordering.
Step 6: Retrieve Tasks for a Tag
When getTasksForTag(userId, tag) is called:
- Retrieve the user's task IDs.
- Skip completed tasks.
- Check whether the tag exists.
- Collect matching tasks.
- Sort by due date.
- Return descriptions.
Step 7: Complete a Task
When completeTask(userId, taskId) is called:
- Check whether the task exists.
- Verify ownership.
- Verify it is unfinished.
- Mark it as completed.
If any condition fails, do nothing.
Why it works
The correctness comes from maintaining a consistent mapping between users and tasks while preserving task metadata.
Every task is stored exactly once, and every user maintains references only to their own tasks. Retrieval methods explicitly filter out completed tasks and sort remaining tasks by due date, guaranteeing the required ordering. Since task completion only modifies a boolean flag, completed tasks naturally disappear from future queries.
Python Solution
from typing import List
from collections import defaultdict
class TodoList:
def __init__(self):
self.next_task_id = 1
self.tasks = {}
self.user_tasks = defaultdict(list)
def addTask(
self,
userId: int,
taskDescription: str,
dueDate: int,
tags: List[str]
) -> int:
task_id = self.next_task_id
self.next_task_id += 1
self.tasks[task_id] = {
"userId": userId,
"description": taskDescription,
"dueDate": dueDate,
"tags": set(tags),
"completed": False
}
self.user_tasks[userId].append(task_id)
return task_id
def getAllTasks(self, userId: int) -> List[str]:
pending_tasks = []
for task_id in self.user_tasks.get(userId, []):
task = self.tasks[task_id]
if not task["completed"]:
pending_tasks.append(
(task["dueDate"], task["description"])
)
pending_tasks.sort()
return [description for _, description in pending_tasks]
def getTasksForTag(
self,
userId: int,
tag: str
) -> List[str]:
filtered_tasks = []
for task_id in self.user_tasks.get(userId, []):
task = self.tasks[task_id]
if (
not task["completed"]
and tag in task["tags"]
):
filtered_tasks.append(
(task["dueDate"], task["description"])
)
filtered_tasks.sort()
return [description for _, description in filtered_tasks]
def completeTask(
self,
userId: int,
taskId: int
) -> None:
if taskId not in self.tasks:
return
task = self.tasks[taskId]
if task["userId"] != userId:
return
if task["completed"]:
return
task["completed"] = True
The implementation closely follows the algorithm.
The constructor initializes the sequential ID counter and two dictionaries. The tasks dictionary provides fast task lookup by ID, while user_tasks groups task IDs by owner.
The addTask method creates a task entry and inserts it into both structures. Tags are converted to a set so membership checking becomes O(1).
The retrieval methods iterate only through the requested user's tasks, filtering out completed items and optionally filtering by tag. Since the output must be sorted by due date, we collect (dueDate, description) tuples and sort them.
The completion method safely validates task existence and ownership before updating state.
Go Solution
package main
import "sort"
type Task struct {
userId int
description string
dueDate int
tags map[string]bool
completed bool
}
type TodoList struct {
nextTaskID int
tasks map[int]*Task
userTasks map[int][]int
}
func Constructor() TodoList {
return TodoList{
nextTaskID: 1,
tasks: make(map[int]*Task),
userTasks: make(map[int][]int),
}
}
func (this *TodoList) AddTask(
userId int,
taskDescription string,
dueDate int,
tags []string,
) int {
taskID := this.nextTaskID
this.nextTaskID++
tagSet := make(map[string]bool)
for _, tag := range tags {
tagSet[tag] = true
}
this.tasks[taskID] = &Task{
userId: userId,
description: taskDescription,
dueDate: dueDate,
tags: tagSet,
completed: false,
}
this.userTasks[userId] =
append(this.userTasks[userId], taskID)
return taskID
}
func (this *TodoList) GetAllTasks(
userId int,
) []string {
type Pair struct {
dueDate int
description string
}
var pending []Pair
for _, taskID := range this.userTasks[userId] {
task := this.tasks[taskID]
if !task.completed {
pending = append(
pending,
Pair{
dueDate: task.dueDate,
description: task.description,
},
)
}
}
sort.Slice(
pending,
func(i, j int) bool {
return pending[i].dueDate <
pending[j].dueDate
},
)
result := make([]string, 0, len(pending))
for _, task := range pending {
result = append(
result,
task.description,
)
}
return result
}
func (this *TodoList) GetTasksForTag(
userId int,
tag string,
) []string {
type Pair struct {
dueDate int
description string
}
var filtered []Pair
for _, taskID := range this.userTasks[userId] {
task := this.tasks[taskID]
if !task.completed &&
task.tags[tag] {
filtered = append(
filtered,
Pair{
dueDate: task.dueDate,
description: task.description,
},
)
}
}
sort.Slice(
filtered,
func(i, j int) bool {
return filtered[i].dueDate <
filtered[j].dueDate
},
)
result := make([]string, 0, len(filtered))
for _, task := range filtered {
result = append(
result,
task.description,
)
}
return result
}
func (this *TodoList) CompleteTask(
userId int,
taskId int,
) {
task, exists := this.tasks[taskId]
if !exists {
return
}
if task.userId != userId {
return
}
if task.completed {
return
}
task.completed = true
}
The Go solution mirrors the Python implementation closely.
Since Go does not have built-in sets, tags are stored using map[string]bool. A missing key naturally evaluates to false, making tag checks simple.
Maps return zero values for missing keys, so this.userTasks[userId] safely returns nil, which behaves like an empty slice during iteration.
Integer overflow is not a concern because the constraints are extremely small.
Worked Examples
Example 1
Initial state:
| Variable | Value |
|---|---|
| nextTaskID | 1 |
| tasks | {} |
| userTasks | {} |
Step 1
addTask(1, "Task1", 50, [])
State:
| Structure | Value |
|---|---|
| tasks | {1: (user=1, due=50, completed=False)} |
| userTasks | {1: [1]} |
Return:
1
Step 2
addTask(1, "Task2", 100, ["P1"])
State:
| Structure | Value |
|---|---|
| tasks | {1, 2} |
| userTasks | {1: [1, 2]} |
Return:
2
Step 3
getAllTasks(1)
Uncompleted tasks:
| Task | Due Date |
|---|---|
| Task1 | 50 |
| Task2 | 100 |
Sorted result:
["Task1", "Task2"]
Step 4
getAllTasks(5)
User 5 has no tasks.
Result:
[]
Step 5
addTask(1, "Task3", 30, ["P1"])
State:
| Structure | Value |
|---|---|
| userTasks | {1: [1, 2, 3]} |
Step 6
getTasksForTag(1, "P1")
Matching tasks:
| Task | Due Date |
|---|---|
| Task3 | 30 |
| Task2 | 100 |
Result:
["Task3", "Task2"]
Step 7
completeTask(5, 1)
Task belongs to user 1, nothing changes.
Step 8
completeTask(1, 2)
Task 2 becomes completed.
Step 9
getTasksForTag(1, "P1")
Only Task3 remains.
Result:
["Task3"]
Step 10
getAllTasks(1)
Remaining active tasks:
| Task | Due Date |
|---|---|
| Task3 | 30 |
| Task1 | 50 |
Result:
["Task3", "Task1"]
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(k log k) | Retrieval scans user tasks and sorts them |
| Space | O(n) | Stores all tasks and mappings |
Here, n is the total number of tasks and k is the number of tasks owned by a specific user.
Adding tasks and completing tasks are O(1) operations because they only involve hash map lookups and updates. Retrieval operations dominate runtime because sorting is required to return tasks in due-date order.
Test Cases
todo = TodoList()
# Example from problem statement
assert todo.addTask(1, "Task1", 50, []) == 1
assert todo.addTask(1, "Task2", 100, ["P1"]) == 2
assert todo.getAllTasks(1) == ["Task1", "Task2"] # basic retrieval
assert todo.getAllTasks(5) == [] # nonexistent user
assert todo.addTask(1, "Task3", 30, ["P1"]) == 3
assert todo.getTasksForTag(1, "P1") == [
"Task3",
"Task2"
] # tag filtering and sorting
todo.completeTask(5, 1)
assert todo.getAllTasks(1) == [
"Task3",
"Task1",
"Task2"
] # wrong user completion ignored
todo.completeTask(1, 2)
assert todo.getTasksForTag(1, "P1") == [
"Task3"
] # completed task removed
assert todo.getAllTasks(1) == [
"Task3",
"Task1"
] # sorted by due date
# Empty tag list
todo2 = TodoList()
todo2.addTask(1, "NoTags", 10, [])
assert todo2.getTasksForTag(1, "x") == []
# Multiple users
todo3 = TodoList()
todo3.addTask(1, "User1Task", 20, [])
todo3.addTask(2, "User2Task", 10, [])
assert todo3.getAllTasks(1) == ["User1Task"]
assert todo3.getAllTasks(2) == ["User2Task"]
# Complete nonexistent task
todo4 = TodoList()
todo4.addTask(1, "Task", 5, [])
todo4.completeTask(1, 999)
assert todo4.getAllTasks(1) == [
"Task"
] # no change
# Complete already completed task
todo5 = TodoList()
todo5.addTask(1, "Task", 5, [])
todo5.completeTask(1, 1)
todo5.completeTask(1, 1)
assert todo5.getAllTasks(1) == []
# Ordering check
todo6 = TodoList()
todo6.addTask(1, "Late", 100, [])
todo6.addTask(1, "Early", 10, [])
todo6.addTask(1, "Middle", 50, [])
assert todo6.getAllTasks(1) == [
"Early",
"Middle",
"Late"
] # due date sorting
| Test | Why |
|---|---|
| Problem example | Validates expected behavior |
| Nonexistent user | Ensures empty list handling |
| Empty tags | Confirms tag filtering correctness |
| Multiple users | Verifies user isolation |
| Nonexistent task completion | Ensures safe no-op behavior |
| Double completion | Confirms idempotency |
| Ordering test | Verifies due-date sorting |
Edge Cases
User Does Not Exist
A retrieval request may reference a user who has never added a task. A naive implementation could accidentally raise a lookup error.
Our implementation avoids this by using .get(userId, []) in Python and relying on Go's zero-value nil slice behavior. In both cases, iteration safely occurs over an empty collection, producing an empty result.
Completing a Task Owned by Another User
A task may exist, but belong to a different user. This is subtle because simply checking task existence is not enough.
Our implementation explicitly verifies ownership before modifying state. If task["userId"] != userId, the operation immediately returns, preventing accidental modification of another user's tasks.
Tasks Without Tags
Some tasks may have an empty tag list. A naive implementation could mistakenly include them during tag filtering or produce errors.
We handle this naturally by storing tags as a set or hash map. Membership checks fail cleanly, meaning untagged tasks are excluded from tag-specific queries.
Completing an Already Completed Task
The same task might be marked complete multiple times. A fragile implementation could behave unpredictably or duplicate side effects.
Our solution checks the completion flag first. If the task is already completed, the method immediately returns, making the operation safe and idempotent.