LeetCode 677 - Map Sum Pairs
The problem asks us to design a custom data structure that behaves like a map from strings to integers, while also supporting efficient prefix-based sum queries. There are two operations: 1. insert(key, val) This operation stores a string key with an integer value.
Difficulty: 🟡 Medium
Topics: Hash Table, String, Design, Trie
Solution
Problem Understanding
The problem asks us to design a custom data structure that behaves like a map from strings to integers, while also supporting efficient prefix-based sum queries.
There are two operations:
insert(key, val)
This operation stores a string key with an integer value. If the key already exists, its old value must be replaced with the new value.
2. sum(prefix)
This operation returns the total sum of all values whose keys begin with the given prefix.
For example, if we insert:
"apple" -> 3"app" -> 2
then calling sum("ap") should return 5, because both "apple" and "app" start with "ap".
The important detail is that keys can be overwritten. If "apple" was previously assigned 3 and later updated to 5, future prefix sums must reflect the new value rather than adding both values together.
The constraints are relatively small:
- Key and prefix lengths are at most
50 - At most
50total operations
Even though the input size is small enough for simpler solutions to pass, the problem is fundamentally testing understanding of prefix-based data structures, especially tries.
A few important observations and edge cases immediately stand out:
- Keys may be inserted multiple times, so we must correctly handle updates.
- Prefixes may match zero keys, in which case the answer should be
0. - A prefix can itself be a full key while also matching longer keys.
- Keys consist only of lowercase English letters, which makes trie-based implementations straightforward.
- Prefix queries can overlap heavily, so recomputing sums repeatedly can become inefficient in larger-scale versions of the problem.
Approaches
Brute Force Approach
The simplest solution is to store every key-value pair in a hash map.
For insert, we simply update the dictionary entry:
map[key] = val
For sum(prefix), we iterate through every key in the dictionary and check whether the key starts with the given prefix. If it does, we add its value to the result.
This works because every stored key is explicitly examined during the prefix query, guaranteeing correctness.
However, the downside is that every sum operation requires scanning all keys. If the number of keys becomes large, repeated prefix queries become expensive.
Optimal Approach, Trie with Prefix Sums
The key insight is that prefix queries naturally align with a trie, also called a prefix tree.
A trie stores strings character by character. Every node represents a prefix. For example:
root
└── a
└── p
└── p
└── l
└── e
If we store, at each trie node, the total sum of all keys passing through that node, then answering a prefix query becomes extremely efficient.
For example:
- The node representing
"ap"stores the total value of all words beginning with"ap". - The node representing
"app"stores the total value of all words beginning with"app".
Then:
insertupdates sums along the traversal pathsum(prefix)simply walks to the prefix node and returns its stored total
The tricky part is handling updates correctly. Since keys can be overwritten, we must compute the difference:
delta = new_value - old_value
Then we add this delta to every trie node along the key's path.
Without this adjustment, repeated inserts of the same key would incorrectly accumulate values.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | Insert: O(1), Sum: O(N × L) | O(N × L) | Scan all keys for every prefix query |
| Optimal Trie | Insert: O(L), Sum: O(L) | O(N × L) | Stores prefix sums directly in trie nodes |
Where:
N= number of keysL= maximum string length
Algorithm Walkthrough
Optimal Trie Algorithm
- Create a trie node structure.
Each node stores:
- A dictionary of child nodes
- A running prefix sum
The prefix sum represents the total value of all keys passing through that node. 2. Maintain a separate hash map for exact key values.
This is necessary because keys can be updated. When inserting a key again, we need to know its previous value.
3. During insert(key, val), compute the value difference.
If the key already exists:
delta = val - old_value
Otherwise:
delta = val
- Update the stored key value in the hash map.
This ensures future updates use the latest value. 5. Traverse the trie character by character.
For each character:
- Create the child node if it does not exist
- Move to the child node
- Add
deltato that node's prefix sum
This propagates the value change to every relevant prefix.
6. During sum(prefix), traverse the trie using the prefix characters.
If at any point a character path does not exist, return 0.
7. After reaching the final prefix node, return its stored prefix sum.
Since every insertion updated this node correctly, the stored sum already represents the answer.
Why it works
The trie maintains the invariant that every node's stored sum equals the total value of all keys sharing that prefix.
When inserting a key, we propagate only the change in value, not the entire value. This guarantees that updates remain accurate even when keys are overwritten multiple times.
Therefore, when we reach the node corresponding to a prefix, its stored sum is exactly the total value of all matching keys.
Python Solution
class TrieNode:
def __init__(self):
self.children = {}
self.prefix_sum = 0
class MapSum:
def __init__(self):
self.root = TrieNode()
self.values = {}
def insert(self, key: str, val: int) -> None:
old_value = self.values.get(key, 0)
delta = val - old_value
self.values[key] = val
current = self.root
for char in key:
if char not in current.children:
current.children[char] = TrieNode()
current = current.children[char]
current.prefix_sum += delta
def sum(self, prefix: str) -> int:
current = self.root
for char in prefix:
if char not in current.children:
return 0
current = current.children[char]
return current.prefix_sum
# Your MapSum object will be instantiated and called as such:
# obj = MapSum()
# obj.insert(key, val)
# param_2 = obj.sum(prefix)
The implementation begins with a TrieNode class. Each node contains:
children, mapping characters to child nodesprefix_sum, storing the sum of all keys sharing that prefix
Inside MapSum, we maintain:
root, the trie rootvalues, a dictionary storing the latest value for every key
The insert method first retrieves the old value of the key. This is critical because the problem allows overwriting existing keys.
We compute:
delta = new_value - old_value
This delta represents the amount by which all relevant prefix sums should change.
As we traverse the trie, we create missing nodes and add delta to each node's prefix_sum.
The sum method simply walks down the trie using the prefix characters. If the path exists, the final node already contains the desired total.
Go Solution
package main
type TrieNode struct {
children map[byte]*TrieNode
prefixSum int
}
type MapSum struct {
root *TrieNode
values map[string]int
}
func Constructor() MapSum {
return MapSum{
root: &TrieNode{
children: make(map[byte]*TrieNode),
},
values: make(map[string]int),
}
}
func (this *MapSum) Insert(key string, val int) {
oldValue := this.values[key]
delta := val - oldValue
this.values[key] = val
current := this.root
for i := 0; i < len(key); i++ {
char := key[i]
if _, exists := current.children[char]; !exists {
current.children[char] = &TrieNode{
children: make(map[byte]*TrieNode),
}
}
current = current.children[char]
current.prefixSum += delta
}
}
func (this *MapSum) Sum(prefix string) int {
current := this.root
for i := 0; i < len(prefix); i++ {
char := prefix[i]
if _, exists := current.children[char]; !exists {
return 0
}
current = current.children[char]
}
return current.prefixSum
}
/**
* Your MapSum object will be instantiated and called as such:
* obj := Constructor()
* obj.Insert(key, val)
* param_2 := obj.Sum(prefix)
*/
The Go implementation follows the same logic as the Python version.
A few Go-specific details are worth noting:
- Trie children are stored as
map[byte]*TrieNodebecause the input only contains lowercase English letters. - Maps must be initialized using
make. - Accessing a missing key in a Go map returns the zero value, which conveniently gives
0for nonexistent previous key values. - Pointers are used for trie nodes to avoid unnecessary copying.
Worked Examples
Example 1
Operations:
insert("apple", 3)
sum("ap")
insert("app", 2)
sum("ap")
Step 1: insert("apple", 3)
delta = 3 - 0 = 3
Trie updates:
| Prefix | Stored Sum |
|---|---|
| a | 3 |
| ap | 3 |
| app | 3 |
| appl | 3 |
| apple | 3 |
Stored values map:
| Key | Value |
|---|---|
| apple | 3 |
Step 2: sum("ap")
Traverse:
root -> a -> p
Node "ap" stores:
3
Return:
3
Step 3: insert("app", 2)
delta = 2 - 0 = 2
Update trie:
| Prefix | Previous Sum | New Sum |
|---|---|---|
| a | 3 | 5 |
| ap | 3 | 5 |
| app | 3 | 5 |
Stored values map:
| Key | Value |
|---|---|
| apple | 3 |
| app | 2 |
Step 4: sum("ap")
Traverse:
root -> a -> p
Node "ap" now stores:
5
Return:
5
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(L) | Both insert and sum traverse at most the string length |
| Space | O(N × L) | Trie may store every character of every key |
Where:
N= number of inserted keysL= maximum key length
The trie guarantees efficient prefix operations because each character is processed exactly once during traversal. The auxiliary hash map also provides constant-time access for overwrite handling.
The total trie size depends on how many unique prefixes exist across all inserted keys.
Test Cases
# Basic example from problem statement
obj = MapSum()
obj.insert("apple", 3)
assert obj.sum("ap") == 3 # single matching key
obj.insert("app", 2)
assert obj.sum("ap") == 5 # multiple matching keys
# Overwriting existing key
obj = MapSum()
obj.insert("apple", 3)
obj.insert("apple", 5)
assert obj.sum("apple") == 5 # old value replaced correctly
# Prefix with no matches
obj = MapSum()
obj.insert("apple", 3)
assert obj.sum("xyz") == 0 # nonexistent prefix
# Prefix equal to full key
obj = MapSum()
obj.insert("app", 2)
obj.insert("apple", 3)
assert obj.sum("app") == 5 # includes exact key and longer keys
# Multiple independent branches
obj = MapSum()
obj.insert("cat", 4)
obj.insert("car", 6)
obj.insert("dog", 5)
assert obj.sum("ca") == 10 # cat + car
assert obj.sum("do") == 5 # dog only
assert obj.sum("z") == 0 # no matches
# Repeated updates
obj = MapSum()
obj.insert("a", 1)
obj.insert("a", 2)
obj.insert("a", 10)
assert obj.sum("a") == 10 # latest value only
# Single character keys
obj = MapSum()
obj.insert("a", 1)
obj.insert("b", 2)
assert obj.sum("a") == 1
assert obj.sum("b") == 2
# Deep prefix chain
obj = MapSum()
obj.insert("a", 1)
obj.insert("ab", 2)
obj.insert("abc", 3)
assert obj.sum("a") == 6
assert obj.sum("ab") == 5
assert obj.sum("abc") == 3
Test Summary
| Test | Why |
|---|---|
| Basic example | Verifies normal insertion and prefix summation |
| Overwriting existing key | Ensures updates replace old values correctly |
| Prefix with no matches | Confirms missing prefixes return 0 |
| Prefix equal to full key | Validates exact-key prefix handling |
| Multiple independent branches | Tests trie branching correctness |
| Repeated updates | Ensures delta logic works repeatedly |
| Single character keys | Tests smallest valid key size |
| Deep prefix chain | Validates nested prefix accumulation |
Edge Cases
One important edge case is reinserting the same key with a different value. A naive implementation might simply add the new value again, causing prefix sums to become inflated over time. For example, inserting "apple" with 3 and later with 5 should result in a total contribution of 5, not 8. The implementation handles this by storing previous values and updating trie sums using only the difference between old and new values.
Another important case occurs when querying a prefix that does not exist in the trie. During traversal, the algorithm may encounter a missing child node before finishing the prefix. In that situation, the implementation immediately returns 0, because no stored keys can share a nonexistent prefix path.
A third subtle edge case happens when one key is itself a prefix of another key. For example:
"app"
"apple"
Both should contribute to sum("app"). The trie naturally handles this because every node stores cumulative sums for all descendant keys, not just keys ending at that node.
A final edge case involves very short keys and prefixes, especially length 1. Since the traversal logic works character by character regardless of string length, single-character inputs are handled identically to longer strings without any special-case code.