Facebook Pixel

1763. Longest Nice Substring

EasyBit ManipulationHash TableStringDivide and ConquerSliding Window
Leetcode Link

Problem Description

A string is considered nice if every letter that appears in the string has both its uppercase and lowercase versions present in the string.

For example:

  • "abABB" is nice because:
    • Letter 'a' appears as both 'a' and 'A'
    • Letter 'b' appears as both 'b' and 'B'
  • "abA" is NOT nice because:
    • Letter 'b' appears only in lowercase (no 'B')
    • Even though 'a' has both cases, the presence of 'b' without 'B' makes it not nice

Given a string s, you need to find the longest substring that is nice. A substring is a contiguous sequence of characters within the string.

Important constraints:

  • If multiple substrings have the same maximum length, return the one that appears earliest in the string
  • If no nice substring exists, return an empty string

The solution uses a brute force approach by checking all possible substrings. For each starting position i, it extends to position j and maintains a set of characters seen so far. At each extension, it checks if all characters in the current substring have both their uppercase and lowercase versions present. If this condition is met and the current substring is longer than the previously found answer, it updates the answer.

The key insight in the checking logic is that for a substring to be nice, every character c in the substring must have both c.lower() and c.upper() present in the character set of that substring.

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

Intuition

The first observation is that we need to check substrings, not subsequences. This means we're looking at continuous portions of the string. Since we want the longest nice substring, we need to examine all possible substrings and keep track of the longest one that satisfies our "nice" condition.

The brute force approach becomes natural here - we can generate all possible substrings by using two nested loops: one for the starting position and one for the ending position. This gives us all substrings of the form s[i:j+1].

For checking if a substring is "nice", we need to verify that every letter present has both its cases. A clever way to do this is to maintain a set of all characters in the current substring. Then, for each character in this set, we check if both its lowercase and uppercase versions exist in the same set.

Why use a set? Because:

  1. It automatically handles duplicates (we only care about which characters are present, not how many times)
  2. Membership checking (in operation) is O(1), making our validation efficient

The checking condition all(c.lower() in ss and c.upper() in ss for c in ss) elegantly handles all cases:

  • If c is lowercase, it checks if both c (itself) and c.upper() are in the set
  • If c is uppercase, it checks if both c.lower() and c (itself) are in the set
  • This works correctly regardless of whether we encounter the uppercase or lowercase version first

Since we iterate from left to right (starting position i from 0 to n-1), we naturally get the earliest occurrence when multiple substrings have the same maximum length. We only update our answer when we find a strictly longer nice substring, ensuring we keep the first one of any given length.

Learn more about Divide and Conquer and Sliding Window patterns.

Solution Approach

The implementation uses a nested loop approach to examine all possible substrings:

  1. Outer Loop (Starting Position): Iterate through each index i from 0 to n-1 as the starting position of potential substrings.

  2. Initialize Character Set: For each starting position, create an empty set ss to store the characters encountered in the current substring.

  3. Inner Loop (Ending Position): For each starting position i, iterate through indices j from i to n-1 as ending positions:

    • Add the character at position j to the set: ss.add(s[j])
    • This incrementally builds up the character set for substring s[i:j+1]
  4. Check Nice Condition: After adding each new character, verify if the current substring is nice:

    • Use all(c.lower() in ss and c.upper() in ss for c in ss) to check if every character in the set has both its uppercase and lowercase versions present
    • This condition iterates through each unique character c in the set and ensures both cases exist
  5. Update Answer: If the substring is nice AND its length (j - i + 1) is greater than the current answer's length:

    • Update ans to the current substring: ans = s[i : j + 1]
    • The condition len(ans) < j - i + 1 ensures we only update for strictly longer substrings
    • Since we iterate from left to right, this naturally preserves the earliest occurrence
  6. Return Result: After checking all possible substrings, return ans (which remains empty string if no nice substring was found).

Time Complexity: O(n² × m) where n is the length of the string and m is the size of the character set (at most 52 for all uppercase and lowercase letters). The nested loops give us O(n²) substrings, and for each substring, we verify the nice condition which takes O(m) time.

Space Complexity: O(m) for the character set, where m is at most 52 characters.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through the solution with the string s = "YazaAay".

Goal: Find the longest nice substring where every letter has both uppercase and lowercase versions.

Step-by-step process:

Starting with i = 0 (starting at 'Y'):

  • j = 0: substring = "Y", set = {'Y'}
    • Check: Does 'Y' have both cases? Need 'Y' ✓ and 'y' ✗ → Not nice
  • j = 1: substring = "Ya", set = {'Y', 'a'}
    • Check: 'Y' needs 'y' ✗, 'a' needs 'A' ✗ → Not nice
  • j = 2: substring = "Yaz", set = {'Y', 'a', 'z'}
    • Check: 'Y' needs 'y' ✗, 'a' needs 'A' ✗, 'z' needs 'Z' ✗ → Not nice
  • j = 3: substring = "Yaza", set = {'Y', 'a', 'z'}
    • Check: Same as above → Not nice
  • j = 4: substring = "YazaA", set = {'Y', 'a', 'z', 'A'}
    • Check: 'Y' needs 'y' ✗, 'a' has 'A' ✓, 'z' needs 'Z' ✗ → Not nice
  • j = 5: substring = "YazaAa", set = {'Y', 'a', 'z', 'A'}
    • Check: Same as above → Not nice
  • j = 6: substring = "YazaAay", set = {'Y', 'a', 'z', 'A', 'y'}
    • Check: 'Y' has 'y' ✓, 'a' has 'A' ✓, 'z' needs 'Z' ✗ → Not nice

Starting with i = 1 (starting at 'a'):

  • j = 1: substring = "a", set = {'a'}
    • Check: 'a' needs 'A' ✗ → Not nice
  • j = 2: substring = "az", set = {'a', 'z'}
    • Check: 'a' needs 'A' ✗, 'z' needs 'Z' ✗ → Not nice
  • j = 3: substring = "aza", set = {'a', 'z'}
    • Check: Same as above → Not nice
  • j = 4: substring = "azaA", set = {'a', 'z', 'A'}
    • Check: 'a' has 'A' ✓, 'z' needs 'Z' ✗ → Not nice
  • j = 5: substring = "azaAa", set = {'a', 'z', 'A'}
    • Check: Same as above → Not nice
  • j = 6: substring = "azaAay", set = {'a', 'z', 'A', 'y'}
    • Check: 'a' has 'A' ✓, 'z' needs 'Z' ✗, 'y' needs 'Y' ✗ → Not nice

Starting with i = 2 (starting at 'z'):

  • j = 2: substring = "z", set = {'z'}
    • Check: 'z' needs 'Z' ✗ → Not nice
  • Continue similarly...

Starting with i = 3 (starting at 'a'):

  • j = 3: substring = "a", set = {'a'}
    • Check: 'a' needs 'A' ✗ → Not nice
  • j = 4: substring = "aA", set = {'a', 'A'}
    • Check: 'a' has 'A' ✓, 'A' has 'a' ✓ → Nice!
    • Length = 2, update ans = "aA"
  • j = 5: substring = "aAa", set = {'a', 'A'}
    • Check: 'a' has 'A' ✓, 'A' has 'a' ✓ → Nice!
    • Length = 3 > 2, update ans = "aAa"
  • j = 6: substring = "aAay", set = {'a', 'A', 'y'}
    • Check: 'a' has 'A' ✓, 'A' has 'a' ✓, 'y' needs 'Y' ✗ → Not nice

Starting with i = 4 (starting at 'A'):

  • j = 4: substring = "A", set = {'A'}
    • Check: 'A' needs 'a' ✗ → Not nice
  • j = 5: substring = "Aa", set = {'A', 'a'}
    • Check: 'A' has 'a' ✓, 'a' has 'A' ✓ → Nice!
    • Length = 2 < 3 (current answer length), don't update
  • j = 6: substring = "Aay", set = {'A', 'a', 'y'}
    • Check: 'y' needs 'Y' ✗ → Not nice

Starting with i = 5 and i = 6 find no nice substrings or none longer than 3.

Final answer: "aAa" - the longest nice substring with length 3.

Solution Implementation

1class Solution:
2    def longestNiceSubstring(self, s: str) -> str:
3        """
4        Find the longest nice substring in the given string.
5        A nice string is one where for every character that appears, 
6        both its lowercase and uppercase forms are present.
7      
8        Args:
9            s: Input string to search for nice substrings
10          
11        Returns:
12            The longest nice substring found, or empty string if none exists
13        """
14        n = len(s)
15        result = ''
16      
17        # Try all possible starting positions
18        for start in range(n):
19            char_set = set()
20          
21            # Extend substring from current starting position
22            for end in range(start, n):
23                # Add current character to the set
24                char_set.add(s[end])
25              
26                # Check if current substring is nice
27                # A substring is nice if every character has both cases present
28                is_nice = all(
29                    char.lower() in char_set and char.upper() in char_set 
30                    for char in char_set
31                )
32              
33                current_length = end - start + 1
34              
35                # Update result if we found a longer nice substring
36                if is_nice and len(result) < current_length:
37                    result = s[start:end + 1]
38      
39        return result
40
1class Solution {
2    public String longestNiceSubstring(String s) {
3        int stringLength = s.length();
4        int startIndex = -1;  // Starting index of the longest nice substring
5        int maxLength = 0;    // Length of the longest nice substring found
6      
7        // Try every possible starting position
8        for (int i = 0; i < stringLength; ++i) {
9            Set<Character> charSet = new HashSet<>();
10          
11            // Expand substring from position i to the end
12            for (int j = i; j < stringLength; ++j) {
13                // Add current character to the set
14                charSet.add(s.charAt(j));
15              
16                // Check if current substring [i, j] is nice
17                boolean isNice = true;
18                for (char ch : charSet) {
19                    // XOR with 32 toggles between uppercase and lowercase
20                    // 'A' (65) ^ 32 = 'a' (97), 'a' (97) ^ 32 = 'A' (65)
21                    char counterpart = (char) (ch ^ 32);
22                  
23                    // A nice substring must contain both uppercase and lowercase of each letter
24                    if (!charSet.contains(ch) || !charSet.contains(counterpart)) {
25                        isNice = false;
26                        break;
27                    }
28                }
29              
30                // Update the longest nice substring if current one is longer
31                if (isNice && maxLength < j - i + 1) {
32                    maxLength = j - i + 1;
33                    startIndex = i;
34                }
35            }
36        }
37      
38        // Return the longest nice substring, or empty string if none found
39        return startIndex == -1 ? "" : s.substring(startIndex, startIndex + maxLength);
40    }
41}
42
1class Solution {
2public:
3    string longestNiceSubstring(string s) {
4        int stringLength = s.size();
5        int startIndex = -1;  // Starting index of the longest nice substring
6        int maxLength = 0;     // Maximum length of nice substring found
7      
8        // Try all possible starting positions
9        for (int i = 0; i < stringLength; ++i) {
10            unordered_set<char> charSet;  // Set to store unique characters in current substring
11          
12            // Extend substring from position i to the end
13            for (int j = i; j < stringLength; ++j) {
14                charSet.insert(s[j]);
15              
16                // Check if current substring is "nice"
17                // A substring is nice if for every letter in it, both uppercase and lowercase exist
18                bool isNice = true;
19                for (const auto& ch : charSet) {
20                    // XOR with 32 toggles between uppercase and lowercase
21                    // 'A' (65) ^ 32 = 'a' (97), 'a' (97) ^ 32 = 'A' (65)
22                    char toggledCase = ch ^ 32;
23                  
24                    // Check if both the character and its case counterpart exist
25                    if (!charSet.count(ch) || !charSet.count(toggledCase)) {
26                        isNice = false;
27                        break;
28                    }
29                }
30              
31                // Update result if we found a longer nice substring
32                if (isNice && maxLength < j - i + 1) {
33                    maxLength = j - i + 1;
34                    startIndex = i;
35                }
36            }
37        }
38      
39        // Return the longest nice substring, or empty string if none found
40        return startIndex == -1 ? "" : s.substr(startIndex, maxLength);
41    }
42};
43
1/**
2 * Finds the longest nice substring in the given string.
3 * A string is nice if for every letter that appears in it, both its lowercase and uppercase appear.
4 * @param s - The input string to search for nice substrings
5 * @returns The longest nice substring found
6 */
7function longestNiceSubstring(s: string): string {
8    const stringLength: number = s.length;
9    let longestNiceSubstr: string = '';
10  
11    // Try all possible starting positions
12    for (let startIndex = 0; startIndex < stringLength; startIndex++) {
13        // Bitmask to track which lowercase letters are present (26 bits for a-z)
14        let lowercaseMask: number = 0;
15        // Bitmask to track which uppercase letters are present (26 bits for A-Z)
16        let uppercaseMask: number = 0;
17      
18        // Extend substring from current starting position
19        for (let endIndex = startIndex; endIndex < stringLength; endIndex++) {
20            const charCode: number = s.charCodeAt(endIndex);
21          
22            // Check if character is lowercase (ASCII 97-122 for a-z)
23            if (charCode > 96) {
24                // Set the bit corresponding to this lowercase letter
25                lowercaseMask |= 1 << (charCode - 97);
26            } else {
27                // Character is uppercase (ASCII 65-90 for A-Z)
28                // Set the bit corresponding to this uppercase letter
29                uppercaseMask |= 1 << (charCode - 65);
30            }
31          
32            // Check if current substring is nice (same letters in both cases)
33            // and if it's longer than the current longest nice substring
34            const currentSubstrLength: number = endIndex - startIndex + 1;
35            if (lowercaseMask === uppercaseMask && currentSubstrLength > longestNiceSubstr.length) {
36                longestNiceSubstr = s.substring(startIndex, endIndex + 1);
37            }
38        }
39    }
40  
41    return longestNiceSubstr;
42}
43

Time and Space Complexity

Time Complexity: O(n²)

The algorithm uses two nested loops:

  • The outer loop runs from i = 0 to n-1, iterating n times
  • The inner loop runs from j = i to n-1, iterating up to n times in the worst case
  • Inside the inner loop, the all() function checks each character in the set ss, which can contain at most 52 unique characters (26 lowercase + 26 uppercase letters), making this check O(1) since it's bounded by a constant
  • String slicing s[i : j + 1] takes O(j - i + 1) time, but this only happens when a valid nice substring is found

Overall: O(n) × O(n) × O(1) = O(n²)

Space Complexity: O(1)

  • The set ss stores unique characters from the current substring, which can contain at most 52 characters (all uppercase and lowercase English letters), so it uses O(1) space
  • The variable ans stores the result string, which in the worst case could be the entire input string of length n, contributing O(n) space
  • Other variables (i, j, n) use constant space

Since the output string is typically not counted towards space complexity (as it's required for the answer), the auxiliary space complexity is O(1).

If we count the output string, the space complexity would be O(n).

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

Common Pitfalls

1. Inefficient Character Set Building

A common mistake is rebuilding the character set from scratch for each substring instead of incrementally adding characters. This would change the time complexity from O(n²) to O(n³).

Incorrect approach:

for start in range(n):
    for end in range(start, n):
        # Wrong: Rebuilding set every time
        char_set = set(s[start:end+1])
        is_nice = all(...)

Correct approach:

for start in range(n):
    char_set = set()  # Initialize once per starting position
    for end in range(start, n):
        char_set.add(s[end])  # Incrementally build the set
        is_nice = all(...)

2. Incorrect Nice Condition Check

A frequent error is checking if both cases exist in the original string rather than in the current substring's character set.

Incorrect:

# Wrong: Checking in the entire string s
is_nice = all(char.lower() in s and char.upper() in s for char in char_set)

Correct:

# Right: Checking within the current substring's character set
is_nice = all(char.lower() in char_set and char.upper() in char_set for char in char_set)

3. Using <= Instead of < for Length Comparison

Using <= would update the result even for equal-length substrings, potentially returning a later occurrence instead of the earliest one.

Incorrect:

if is_nice and len(result) <= current_length:  # Wrong: Returns last occurrence
    result = s[start:end + 1]

Correct:

if is_nice and len(result) < current_length:  # Right: Returns first occurrence
    result = s[start:end + 1]

4. Mishandling Edge Cases

Forgetting to handle empty strings or single-character strings properly. The solution should naturally return an empty string for these cases since a single character cannot have both uppercase and lowercase versions present.

Solution: The current implementation handles this correctly by initializing result = '' and only updating when a valid nice substring is found.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What's the output of running the following function using the following tree as input?

1def serialize(root):
2    res = []
3    def dfs(root):
4        if not root:
5            res.append('x')
6            return
7        res.append(root.val)
8        dfs(root.left)
9        dfs(root.right)
10    dfs(root)
11    return ' '.join(res)
12
1import java.util.StringJoiner;
2
3public static String serialize(Node root) {
4    StringJoiner res = new StringJoiner(" ");
5    serializeDFS(root, res);
6    return res.toString();
7}
8
9private static void serializeDFS(Node root, StringJoiner result) {
10    if (root == null) {
11        result.add("x");
12        return;
13    }
14    result.add(Integer.toString(root.val));
15    serializeDFS(root.left, result);
16    serializeDFS(root.right, result);
17}
18
1function serialize(root) {
2    let res = [];
3    serialize_dfs(root, res);
4    return res.join(" ");
5}
6
7function serialize_dfs(root, res) {
8    if (!root) {
9        res.push("x");
10        return;
11    }
12    res.push(root.val);
13    serialize_dfs(root.left, res);
14    serialize_dfs(root.right, res);
15}
16

Recommended Readings

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

Load More