Facebook Pixel

3860. Unique Email Groups 🔒

MediumArrayHash TableString
LeetCode ↗

Problem Description

You are given an array of strings emails, where each string is a valid email address. Your task is to find out how many unique email groups exist after applying a set of normalization rules.

Two email addresses belong to the same group if both their normalized local names and normalized domain names are identical.

Every email address is made up of two parts separated by the '@' symbol:

  • The local name is the part before the '@'.
  • The domain name is the part after the '@'.

The normalization rules are as follows:

  • For the local name:
    • Ignore all dots '.' (remove them).
    • Ignore everything after the first '+', if a '+' is present (keep only the part before it).
    • Convert the remaining characters to lowercase.
  • For the domain name:
    • Convert it to lowercase.

After normalizing each email address, two emails are considered to be in the same group when their normalized local name and normalized domain name match exactly.

Return an integer denoting the number of unique email groups after normalization.

For example, the local name Test.Email+spam normalizes to testemail (dots removed, the +spam suffix dropped, and converted to lowercase). The domain name LeetCode.com normalizes to leetcode.com. Two emails sharing the same normalized local name and domain name count as one group.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

How We Pick the Algorithm

Why Hash Table / Counting?

This problem maps to Hash Table / Counting through a short path in the full flowchart.

Fastlookup orcounting/grouping?yesLinkedlist?noHash Table /Counting

Normalize each email by applying rules then count distinct normalized strings via a hash set.

Open in Flowchart

Intuition

The key observation is that two email addresses belong to the same group only after they have been normalized. So instead of comparing the original strings directly, we first transform each email into its canonical (normalized) form, and then any two emails that map to the same canonical form belong to the same group.

Once every email is reduced to a single, consistent normalized string, the problem becomes very simple: we just need to count how many distinct normalized strings there are. This naturally leads us to use a hash set, since a set automatically discards duplicates and keeps only unique values.

So the approach forms two steps:

  • Normalize each email by applying the rules: for the local name, remove all dots '.', drop everything after the first '+', and convert to lowercase; for the domain name, just convert to lowercase. Then join them back together into one string.
  • Add each normalized string to a set. Because the set stores only unique entries, emails that end up identical after normalization collapse into a single element.

Finally, the size of the set is exactly the number of unique email groups, since each distinct element in the set represents one group.

Solution Approach

Solution 1: Hash Table

We use a hash set st to store the normalized result of each email address. For each email address, we normalize it according to the problem requirements, then add the result to the set. Since a set keeps only unique elements, its final size gives us the number of unique email groups.

The detailed steps are as follows:

  • Split the email into a local name and a domain name using the '@' symbol. In the code this is done with local, domain = email.split("@").

  • Normalize the local name:

    • First, drop everything after the first '+' by taking local.split("+")[0], which keeps only the part before the plus sign (or the whole string if no '+' exists).
    • Then remove all dots with .replace(".", "").
    • Finally convert to lowercase with .lower().
  • Normalize the domain name by simply converting it to lowercase with domain.lower().

  • Concatenate the normalized local name and domain name into a single string normalized = local + domain, and add it to the hash set st.

After processing all emails, the number of elements in the hash set st is the number of unique email groups, returned with len(st).

Complexity Analysis:

  • Time complexity: O(L), where L is the total length of all the characters in emails. Each email is scanned a constant number of times during splitting, replacing, and lowercasing.
  • Space complexity: O(L), since in the worst case the hash set stores normalized strings whose combined length is proportional to the input.
class Solution:
    def uniqueEmailGroups(self, emails: list[str]) -> int:
        st = set()
        for email in emails:
            local, domain = email.split("@")
            local = local.split("+")[0].replace(".", "").lower()
            domain = domain.lower()
            normalized = local + domain
            st.add(normalized)
        return len(st)

Example Walkthrough

Let's trace through the solution approach using a small example:

emails = [
    "Test.Email+spam@LeetCode.com",
    "test.e.mail+bob.cathy@leetcode.com",
    "TestEmail+david@lee.tcode.com"
]

We initialize an empty hash set st = {} and process each email one at a time.

Email 1: "Test.Email+spam@LeetCode.com"

  • Split on '@': local = "Test.Email+spam", domain = "LeetCode.com".
  • Normalize the local name:
    • Drop everything after the first '+': "Test.Email+spam".split("+")[0]"Test.Email".
    • Remove all dots: "Test.Email".replace(".", "")"TestEmail".
    • Convert to lowercase: "TestEmail".lower()"testemail".
  • Normalize the domain name: "LeetCode.com".lower()"leetcode.com".
  • Concatenate: "testemail" + "leetcode.com""testemailleetcode.com".
  • Add to set: st = {"testemailleetcode.com"}.

Email 2: "test.e.mail+bob.cathy@leetcode.com"

  • Split on '@': local = "test.e.mail+bob.cathy", domain = "leetcode.com".
  • Normalize the local name:
    • Drop after '+': "test.e.mail+bob.cathy".split("+")[0]"test.e.mail".
    • Remove dots: "test.e.mail".replace(".", "")"testemail".
    • Lowercase: "testemail" (already lowercase).
  • Normalize the domain name: "leetcode.com" (already lowercase).
  • Concatenate: "testemail" + "leetcode.com""testemailleetcode.com".
  • Add to set: This string already exists, so the set is unchanged: st = {"testemailleetcode.com"}.

Notice how emails 1 and 2 looked very different originally but collapsed to the same normalized form — this is exactly the behavior we want, and the set handles the duplicate automatically.

Email 3: "TestEmail+david@lee.tcode.com"

  • Split on '@': local = "TestEmail+david", domain = "lee.tcode.com".
  • Normalize the local name:
    • Drop after '+': "TestEmail+david".split("+")[0]"TestEmail".
    • Remove dots: "TestEmail" (no dots).
    • Lowercase: "testemail".
  • Normalize the domain name: "lee.tcode.com".lower()"lee.tcode.com".
  • Concatenate: "testemail" + "lee.tcode.com""testemaillee.tcode.com".
  • Add to set: This is a new string (the domain differs), so: st = {"testemailleetcode.com", "testemaillee.tcode.com"}.

Even though email 3 has the same normalized local name as the others (testemail), its domain is lee.tcode.com, not leetcode.com. Since a group requires both parts to match, this email forms its own group.

Final Result

After processing all three emails, the set contains:

{
    "testemailleetcode.com",
    "testemaillee.tcode.com"
}

The size of the set is 2, so we return len(st) = 2. There are 2 unique email groups: emails 1 and 2 form one group, and email 3 forms another.

Solution Implementation

1class Solution:
2    def uniqueEmailGroups(self, emails: list[str]) -> int:
3        # Set to store distinct normalized email addresses
4        unique_emails: set[str] = set()
5
6        for email in emails:
7            # Split into local name and domain name on the single '@'
8            local, domain = email.split("@")
9
10            # In the local part:
11            #   - everything after the first '+' is ignored (alias),
12            #   - all '.' characters are removed,
13            #   - normalize case to lowercase for consistent comparison
14            local = local.split("+")[0].replace(".", "").lower()
15
16            # Domain part only needs case normalization
17            domain = domain.lower()
18
19            # Rebuild the canonical address and record it
20            normalized_email = local + "@" + domain
21            unique_emails.add(normalized_email)
22
23        # Number of distinct addresses equals the size of the set
24        return len(unique_emails)
25
1class Solution {
2    public int uniqueEmailGroups(String[] emails) {
3        // Use a set to store the normalized email addresses,
4        // since a set automatically discards duplicates.
5        Set<String> uniqueEmails = new HashSet<>();
6
7        for (String email : emails) {
8            // Split each email into the local name and the domain name
9            // using '@' as the separator.
10            String[] parts = email.split("@");
11            String localName = parts[0];
12            String domainName = parts[1];
13
14            // If a '+' appears in the local name, everything after it
15            // (including the '+') is ignored.
16            int plusIndex = localName.indexOf('+');
17            if (plusIndex != -1) {
18                localName = localName.substring(0, plusIndex);
19            }
20
21            // Remove all dots from the local name, as they are ignored,
22            // and convert it to lower case to make the comparison case-insensitive.
23            localName = localName.replace(".", "").toLowerCase();
24
25            // Normalize the domain name to lower case as well.
26            domainName = domainName.toLowerCase();
27
28            // Combine the processed local name and domain name to form the
29            // canonical email, then add it to the set.
30            String normalizedEmail = localName + "@" + domainName;
31            uniqueEmails.add(normalizedEmail);
32        }
33
34        // The size of the set is the number of distinct email addresses.
35        return uniqueEmails.size();
36    }
37}
38
1class Solution {
2public:
3    int uniqueEmailGroups(vector<string>& emails) {
4        // Set to store the normalized form of each email; duplicates are ignored.
5        unordered_set<string> uniqueAddresses;
6
7        for (const auto& email : emails) {
8            // Split the email into local and domain parts at the '@' separator.
9            size_t atPos = email.find('@');
10            string localPart = email.substr(0, atPos);
11            string domainPart = email.substr(atPos + 1);
12
13            // In the local part, anything after a '+' is ignored.
14            size_t plusPos = localPart.find('+');
15            if (plusPos != string::npos) {
16                localPart = localPart.substr(0, plusPos);
17            }
18
19            // Build the cleaned local part: drop '.' characters and lowercase the rest.
20            string cleanedLocal;
21            for (char c : localPart) {
22                if (c != '.') {
23                    cleanedLocal += static_cast<char>(tolower(c));
24                }
25            }
26
27            // Lowercase the domain part for case-insensitive comparison.
28            for (char& c : domainPart) {
29                c = static_cast<char>(tolower(c));
30            }
31
32            // Insert the normalized "local@domain" form into the set.
33            uniqueAddresses.insert(cleanedLocal + "@" + domainPart);
34        }
35
36        // The number of distinct entries equals the number of unique email groups.
37        return static_cast<int>(uniqueAddresses.size());
38    }
39};
40
1/**
2 * Counts the number of unique email groups after normalization.
3 *
4 * Normalization rules for each email (split into local and domain by '@'):
5 *  - Local part: ignore everything after a '+', remove all '.' characters,
6 *    and convert to lowercase.
7 *  - Domain part: convert to lowercase.
8 *
9 * Two emails belong to the same group if their normalized forms are identical.
10 *
11 * @param emails - The list of email addresses to process.
12 * @returns The count of distinct normalized emails.
13 */
14function uniqueEmailGroups(emails: string[]): number {
15    // Stores each normalized email; duplicates are automatically discarded.
16    const uniqueEmails: Set<string> = new Set<string>();
17
18    for (const email of emails) {
19        // Separate the email into its local and domain components.
20        let [localPart, domainPart] = email.split('@');
21
22        // Normalize the local part:
23        //  1. Drop any characters after the first '+'.
24        //  2. Remove all '.' characters.
25        //  3. Convert to lowercase.
26        localPart = localPart
27            .split('+')[0]
28            .replace(/\./g, '')
29            .toLowerCase();
30
31        // Normalize the domain part by lowercasing it.
32        domainPart = domainPart.toLowerCase();
33
34        // Combine the normalized parts into a single canonical key.
35        const normalizedEmail: string = localPart + domainPart;
36
37        // Record the canonical form; the Set ignores repeats.
38        uniqueEmails.add(normalizedEmail);
39    }
40
41    // The size of the Set equals the number of unique email groups.
42    return uniqueEmails.size;
43}
44

Time and Space Complexity

  • Time Complexity: O(n · m), where n is the number of email addresses and m is the average length of each email address. The algorithm iterates over all n emails, and for each one performs string operations such as split("@"), split("+"), replace(".", ""), and lower(). Each of these operations processes the characters of the email, costing O(m) per email. Building the normalized string and inserting it into the set also takes O(m) on average. Therefore, the total time complexity is O(n · m).

  • Space Complexity: O(n · m). In the worst case, all email addresses are distinct after normalization, so the set st stores up to n normalized strings, each of length up to O(m). This results in O(n · m) space. Additionally, the intermediate strings created during each iteration (local, domain, normalized) take O(m) space, which is dominated by the set's storage. Hence, the overall space complexity is O(n · m).

Pattern Learn more about how to find time and space complexity quickly.

Common Pitfalls

Pitfall 1: Forgetting a separator when concatenating the local and domain names

A subtle but serious bug appears in the Solution Approach above: it builds the key as normalized = local + domain without any separator between the two parts. This can cause two genuinely different emails to collapse into the same group.

Consider these two emails:

  • "ab@c.com" → local "ab", domain "c.com" → key "abc.com"
  • "a@bc.com" → local "a", domain "bc.com" → key "abc.com"

These are clearly different addresses, yet both produce the identical key "abc.com", so they would be wrongly counted as one group.

Solution: Always insert the '@' (or any character that cannot appear in a normalized local name) between the two parts when forming the key. The final Python Code already does this correctly with normalized_email = local + "@" + domain. Keep that separator:

normalized_email = local + "@" + domain
unique_emails.add(normalized_email)

Pitfall 2: Applying the local-name rules to the domain name

It is easy to accidentally strip dots or cut off '+' from the domain as well. The dot-removal and '+'-truncation rules apply only to the local name. Removing dots from the domain would merge "leetcode.com" and "leetcodecom", and would also corrupt legitimate domains.

Solution: Normalize the domain with lowercasing only:

local  = local.split("+")[0].replace(".", "").lower()  # full rules
domain = domain.lower()                                 # lowercase only

Pitfall 3: Splitting on '@' incorrectly

Using a plain split("@") is fine for valid emails (exactly one '@'), but if you ever generalize the input, multiple '@' characters would raise a ValueError: too many values to unpack. Likewise, ordering matters: you must drop the '+' suffix before removing dots, otherwise a '+' could survive inside the part you keep.

Solution: Rely on the guarantee that each email is valid (exactly one '@'), and if you want to be defensive, split with a limit so the domain keeps any stray characters:

local, domain = email.split("@", 1)  # split only on the first '@'

Pitfall 4: Case sensitivity handled too late or only partially

If you compare or store the key before calling .lower(), addresses differing only in case ("Test@X.com" vs "test@x.com") are treated as distinct. Both local and domain require lowercasing.

Solution: Apply .lower() to both parts during normalization, as shown, so the comparison is fully case-insensitive.

Ready to land your dream job?

Unlock your dream job with a 5-minute quiz for a personalized study roadmap!

Get My Roadmap
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Get a Personalized Study Roadmap:

Which type of traversal does breadth first search do?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More