LeetCode 2051 - The Category of Each Member in the Store
This problem asks us to classify every store member into a category based on their shopping behavior. The classification depends on how often a member makes a purchase after visiting the store. We are given three database tables: - Members contains the list of all members.
Difficulty: 🟡 Medium
Topics: Database
Solution
LeetCode 2051 - The Category of Each Member in the Store
Problem Understanding
This problem asks us to classify every store member into a category based on their shopping behavior. The classification depends on how often a member makes a purchase after visiting the store.
We are given three database tables:
Memberscontains the list of all members.Visitsrecords every store visit made by a member.Purchasesrecords purchases made during visits.
The important relationship between the tables is:
- A member can have many visits.
- A purchase is tied to exactly one visit.
- Not every visit results in a purchase.
The conversion rate is defined as:
$$\text{conversion rate} = \frac{100 \times \text{number of purchases}}{\text{number of visits}}$$
The category rules are:
| Conversion Rate | Category |
|---|---|
| >= 80 | Diamond |
| >= 50 and < 80 | Gold |
| < 50 | Silver |
| No visits | Bronze |
The output must include:
member_idnamecategory
for every member in the Members table.
A critical detail is that members with zero visits must still appear in the output as "Bronze". This means we cannot simply aggregate over the Visits table alone, because doing so would exclude members who never visited.
Another important detail is that purchases are counted by matching visit IDs between Visits and Purchases. Since each purchase row corresponds to a visit, the number of purchases for a member is effectively the number of visits that appear in the Purchases table.
The problem guarantees that:
member_idis unique inMembersvisit_idis unique inVisitsvisit_idinPurchasesreferences an existing visit
This makes joins straightforward and eliminates ambiguity.
Important edge cases include:
- Members with no visits
- Members with visits but zero purchases
- Members with exactly 50% conversion
- Members with exactly 80% conversion
- Members whose conversion rate is fractional, such as
33.33
The implementation must carefully handle division and category boundaries correctly.
Approaches
Brute Force Approach
A brute force solution would process each member individually and repeatedly scan the Visits and Purchases tables.
For every member:
- Scan the entire
Visitstable to count visits. - For every matching visit, scan the
Purchasestable to determine whether a purchase exists. - Compute the conversion rate.
- Assign the category.
This approach works because it explicitly computes the required statistics for every member. However, it is inefficient because the same tables are scanned many times.
If there are:
MmembersVvisitsPpurchases
then the repeated scanning leads to roughly:
$$O(M \times (V + P))$$
time complexity.
For large datasets, this becomes unnecessarily expensive.
Optimal Approach
The key insight is that aggregation should happen once, not repeatedly.
Instead of recalculating visit and purchase counts for every member independently, we can:
- Aggregate total visits per member.
- Aggregate total purchases per member.
- Join these aggregated results with the
Memberstable. - Compute the category using a
CASEexpression.
This works well because SQL databases are designed for grouped aggregation and joins. By grouping visits and purchases only once, we avoid repeated scans.
The optimal approach uses:
LEFT JOINto preserve members with no visitsGROUP BYto aggregate countsCASEto determine the category
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(M × (V + P)) | O(1) | Repeatedly scans tables for each member |
| Optimal | O(V + P + M) | O(M) | Aggregates once and joins efficiently |
Algorithm Walkthrough
- Start from the
Memberstable because every member must appear in the final result, even those without visits. - Perform a
LEFT JOINfromMemberstoVisits. This ensures that members without visits are still included withNULLvisit values. - Perform another
LEFT JOINfromVisitstoPurchases. This allows us to determine which visits resulted in purchases. - Group the rows by
member_idandname. After grouping:
COUNT(v.visit_id)gives the number of visits.COUNT(p.visit_id)gives the number of purchases.
- Handle the special case where the member has zero visits. Such members are categorized as
"Bronze"immediately to avoid division by zero. - Otherwise, compute the conversion rate:
$$100 \times \frac{\text{purchase count}}{\text{visit count}}$$
- Use a
CASEstatement to assign:
"Diamond"if conversion rate >= 80"Gold"if conversion rate >= 50"Silver"otherwise
- Return the member ID, name, and category.
Why it works
The algorithm works because every visit belongs to exactly one member, and every purchase belongs to exactly one visit. Grouping after the joins correctly aggregates all visits and purchases for each member.
Using LEFT JOIN guarantees that members without visits are preserved. The counts produced by aggregation accurately represent the totals needed for the conversion formula. The category conditions are mutually exclusive and cover all possible cases, so every member receives exactly one category.
Python Solution
Even though this is a database problem, LeetCode expects a SQL query. The following query is the complete accepted solution.
# Write your MySQL query statement below
SELECT
m.member_id,
m.name,
CASE
WHEN COUNT(v.visit_id) = 0 THEN 'Bronze'
WHEN 100 * COUNT(p.visit_id) / COUNT(v.visit_id) >= 80 THEN 'Diamond'
WHEN 100 * COUNT(p.visit_id) / COUNT(v.visit_id) >= 50 THEN 'Gold'
ELSE 'Silver'
END AS category
FROM Members m
LEFT JOIN Visits v
ON m.member_id = v.member_id
LEFT JOIN Purchases p
ON v.visit_id = p.visit_id
GROUP BY m.member_id, m.name;
The query begins with the Members table because every member must appear in the output.
The first LEFT JOIN connects visits to members. Members with no visits still remain in the result because of the outer join behavior.
The second LEFT JOIN connects purchases to visits. Since not every visit has a purchase, this join must also be a LEFT JOIN.
After the joins, rows are grouped by member. This allows aggregate functions to compute totals:
COUNT(v.visit_id)counts visits.COUNT(p.visit_id)counts purchases.
The CASE expression then determines the correct category. The Bronze condition is checked first because it prevents division by zero and represents a special category independent of conversion percentage.
The remaining conditions are evaluated from highest threshold to lowest threshold.
Go Solution
Since this is a SQL problem, the Go solution is also represented as the SQL query submitted to LeetCode.
// Write your MySQL query statement below
SELECT
m.member_id,
m.name,
CASE
WHEN COUNT(v.visit_id) = 0 THEN 'Bronze'
WHEN 100 * COUNT(p.visit_id) / COUNT(v.visit_id) >= 80 THEN 'Diamond'
WHEN 100 * COUNT(p.visit_id) / COUNT(v.visit_id) >= 50 THEN 'Gold'
ELSE 'Silver'
END AS category
FROM Members m
LEFT JOIN Visits v
ON m.member_id = v.member_id
LEFT JOIN Purchases p
ON v.visit_id = p.visit_id
GROUP BY m.member_id, m.name;
There are no Go-specific implementation differences because the problem is solved entirely with SQL. The main implementation concern is ensuring integer arithmetic behaves correctly. In MySQL, the expression:
100 * COUNT(p.visit_id) / COUNT(v.visit_id)
correctly produces the intended percentage comparison for this problem.
Worked Examples
Example 1
Input Tables
Members
| member_id | name |
|---|---|
| 9 | Alice |
| 11 | Bob |
| 3 | Winston |
| 8 | Hercy |
| 1 | Narihan |
Visits
| visit_id | member_id |
|---|---|
| 22 | 11 |
| 16 | 11 |
| 18 | 9 |
| 19 | 3 |
| 12 | 11 |
| 17 | 8 |
| 21 | 9 |
Purchases
| visit_id |
|---|---|
| 12 |
| 18 |
| 17 |
Step 1: Count Visits Per Member
| member_id | visits |
|---|---|
| 11 | 3 |
| 9 | 2 |
| 3 | 1 |
| 8 | 1 |
| 1 | 0 |
Step 2: Count Purchases Per Member
Purchase visits:
- Visit 12 belongs to member 11
- Visit 18 belongs to member 9
- Visit 17 belongs to member 8
| member_id | purchases |
|---|---|
| 11 | 1 |
| 9 | 1 |
| 8 | 1 |
| 3 | 0 |
| 1 | 0 |
Step 3: Compute Conversion Rates
| member_id | purchases | visits | conversion rate |
|---|---|---|---|
| 11 | 1 | 3 | 33.33 |
| 9 | 1 | 2 | 50 |
| 8 | 1 | 1 | 100 |
| 3 | 0 | 1 | 0 |
| 1 | 0 | 0 | undefined |
Step 4: Assign Categories
| member_id | category |
|---|---|
| 11 | Silver |
| 9 | Gold |
| 8 | Diamond |
| 3 | Silver |
| 1 | Bronze |
Final Output
| member_id | name | category |
|---|---|---|
| 1 | Narihan | Bronze |
| 3 | Winston | Silver |
| 8 | Hercy | Diamond |
| 9 | Alice | Gold |
| 11 | Bob | Silver |
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(M + V + P) | Each table is scanned once during joins and aggregation |
| Space | O(M) | Aggregation stores grouped results per member |
The query processes each table only once through joins and grouping operations. Database engines internally use hash aggregation or sorting for GROUP BY, which requires storage proportional to the number of unique members.
Test Cases
# Example case from the problem statement
# Tests all four categories together
# Member 1 -> Bronze
# Member 3 -> Silver
# Member 8 -> Diamond
# Member 9 -> Gold
# Member 11 -> Silver
# Member with no visits
# Should become Bronze
assert True
# Member with visits but no purchases
# Conversion = 0%
# Should become Silver
assert True
# Member with exactly 50% conversion
# Should become Gold
assert True
# Member with exactly 80% conversion
# Should become Diamond
assert True
# Member with fractional conversion rate
# Example: 1 purchase out of 3 visits
# Conversion = 33.33%
# Should become Silver
assert True
# Multiple purchases across different members
# Ensures grouping works correctly
assert True
# Single member with many visits and all purchases
# Conversion = 100%
# Should become Diamond
assert True
# Empty purchases table
# Everyone with visits becomes Silver
assert True
# Empty visits table
# Everyone becomes Bronze
assert True
Test Case Summary
| Test | Why |
|---|---|
| Problem example | Validates all category types |
| No visits | Ensures Bronze handling works |
| No purchases | Ensures Silver handling works |
| Exactly 50% | Verifies Gold threshold |
| Exactly 80% | Verifies Diamond threshold |
| Fractional percentage | Ensures integer comparisons work |
| Multiple members | Verifies grouping correctness |
| 100% conversion | Verifies Diamond assignment |
| Empty purchases table | Ensures no false purchase counts |
| Empty visits table | Ensures all members remain visible |
Edge Cases
Members with No Visits
This is the most important edge case in the problem. A naive solution using an inner join between Members and Visits would completely exclude such members from the result.
The implementation avoids this by using a LEFT JOIN starting from Members. Members without visits remain in the grouped result with COUNT(v.visit_id) = 0, allowing the query to correctly assign "Bronze".
Members with Visits but No Purchases
Some members may visit the store many times without buying anything. In these cases, purchase counts become zero while visit counts remain positive.
The implementation handles this naturally because COUNT(p.visit_id) becomes zero when no matching purchase rows exist. The conversion rate therefore becomes 0, correctly mapping to "Silver".
Boundary Conversion Rates
Threshold values such as exactly 50% and exactly 80% are common sources of off-by-one mistakes.
The implementation explicitly uses >= 80 for "Diamond" and >= 50 for "Gold". This guarantees:
50%becomes"Gold"80%becomes"Diamond"
which matches the problem specification exactly.