1763. Longest Nice Substring
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'
- Letter 'a' appears as both
"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
- Letter 'b' appears only in lowercase (no
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.
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:
- It automatically handles duplicates (we only care about which characters are present, not how many times)
- 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 bothc
(itself) andc.upper()
are in the set - If
c
is uppercase, it checks if bothc.lower()
andc
(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:
-
Outer Loop (Starting Position): Iterate through each index
i
from0
ton-1
as the starting position of potential substrings. -
Initialize Character Set: For each starting position, create an empty set
ss
to store the characters encountered in the current substring. -
Inner Loop (Ending Position): For each starting position
i
, iterate through indicesj
fromi
ton-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]
- Add the character at position
-
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
- Use
-
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
- Update
-
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 EvaluatorExample 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
ton-1
, iteratingn
times - The inner loop runs from
j = i
ton-1
, iterating up ton
times in the worst case - Inside the inner loop, the
all()
function checks each character in the setss
, which can contain at most 52 unique characters (26 lowercase + 26 uppercase letters), making this checkO(1)
since it's bounded by a constant - String slicing
s[i : j + 1]
takesO(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 usesO(1)
space - The variable
ans
stores the result string, which in the worst case could be the entire input string of lengthn
, contributingO(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.
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
https assets algo monster cover_photos dnd svg Divide and Conquer The main idea of divide and conquer is to split the main problem into two smaller components assuming that each one of the components had already been solved recursively and then try to solve the bigger problem using the solutions
https assets algo monster cover_photos stack svg Sliding Window Maximum Monotonic Stack We have an array and a sliding window defined by a start index and an end index The sliding window moves from left of the array to right There are always k elements in the window The window
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Want a Structured Path to Master System Design Too? Don’t Miss This!