1763. Longest Nice Substring

EasyBit ManipulationHash TableStringDivide and ConquerSliding Window
Leetcode Link

Problem Description

The challenge in this problem is to find the longest substring of the given string, s, where a "nice" substring is defined as one which contains every letter in both uppercase and lowercase forms. For example, a string "aA" is nice because it contains both 'a' and 'A'. If there are several such nice substrings, we are to return the one that occurs first. If no nice substrings exist, we should return an empty string.

Intuition

To solve this problem, we iterate through the string character by character, starting from each character in turn. We use two bitmask integers, lower and upper, to represent the presence of lowercase and uppercase letters, respectively. For each character in 'a' to 'z', a bit is set to 1 in lower if the lowercase letter is present, and correspondingly in upper if the uppercase letter is present.

For instance, if the lowercase letter 'b' is encountered, the second bit (assuming 0-indexing) of lower is set to 1. Similarly, if the uppercase letter 'B' is spotted, the second bit of upper is turned on.

We move through the string with a nested loop, checking consecutive characters starting from each index pointed by the outer loop and ending at the length of the string. While doing this, we keep updating our lower and upper bitmasks for each character we see. A substring is nice if the lower and upper bitmasks are equal at some point, meaning every lowercase character observed so far has a corresponding uppercase version present, and vice versa.

When we find such a substring, we check if it's longer than any previous "nice" substring found (initially none, as denoted by an empty string ans). If it's longer, we update our answer with the current substring.

This brute force approach checks all possible substrings starting at each point in the string, and while it's easy to understand and implement, its time complexity is not optimal for very long strings. However, it is perfectly viable for strings of moderate length and guarantees that the longest "nice" substring will be found.

Learn more about Divide and Conquer and Sliding Window patterns.

Solution Approach

To implement the solution, we use a simple brute force approach which may not be the most efficient in terms of time complexity but is straightforward to understand and guarantees to find the correct answer. Here's a step-by-step breakdown:

  1. Initialize an empty string ans to keep track of the longest nice substring found.

  2. Iterate through the string s using a nested loop. The outer loop starts from each character indexed by i and the inner loop checks every subsequent character up to the end of the string, indexed by j.

  3. For each character, two bitmasks lower and upper are maintained. If the character is lowercase (checked using s[j].islower()), we set the corresponding bit in lower. This is done by shifting 1 to the left by ord(s[j]) - ord('a') places (since 'a' is the ASCII starting point for lowercase letters, the difference gives us the correct bit position).

  4. Similarly, if the character is uppercase, the corresponding bit in upper is set by shifting 1 to the left by ord(s[j]) - ord('A') places (as 'A' is the ASCII starting point for uppercase letters).

  5. We compare lower and upper to check if the current substring is nice, which would be true if lower equals upper. This comparison realizes if for every lowercase letter there's a matching uppercase letter and vice versa.

  6. If a nice substring is found and its length is greater than the length of the current ans, the ans string is updated to this substring. We achieve substring extraction using Python's slice notation s[i : j + 1].

  7. We repeat this process, expanding our current substring check from all possible starting points (i) to include all following characters (j). Since the answer should be of the earliest occurrence of the longest nice substring, by iterating from left to right we naturally prioritize earlier substrings over later ones of the same length.

  8. Finally, after all iterations, ans holds the longest nice substring, and it is returned.

Throughout this implementation, we rely on basic bitwise operations, simple string manipulation, and nested loops. The algorithm's complexity is O(n^2) where n is the number of characters in the string. Each nested loop iteration checks one substring, and there are O(n^2) substrings in total.

Although not efficient for very large strings, for smaller strings, this simple and direct method effectively locates the desired nice substring without additional data structures or complex algorithms.

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's walk through an example to illustrate how the described solution approach is applied to the problem.

Consider the string s = "aAbBcC". We are looking for the longest "nice" substring, that is, a substring where each letter exists in both uppercase and lowercase form.

  1. We start with an empty string ans to hold the longest nice substring we find.

  2. We begin by iterating over the string s with index i from 0 to the length of s. The first character is 'a', and we start the inner loop from i to check subsequent characters.

  3. For each character s[j] we encounter as we move through the inner loop:

    • If s[j].islower() is true, for example s[j] = 'a', we modify lower bitmask. We do lower |= (1 << (ord('a') - ord('a'))), resulting in lower = 1 as 'a' is the first lowercase letter.
    • If s[j].isupper() is true instead, for example s[j] = 'A', we modify the upper bitmask in a similar fashion, so upper = 1.
  4. Continuing this process of updating lower and upper for each character in the inner loop, we reach the end of the string. By now, lower and upper should both be 111111 in binary, which corresponds to 63 in decimal, standing for the presence of 'a', 'b', and 'c' in lowercase and uppercase.

  5. We check if lower equals upper after each inner loop iteration and update ans only if the current substring (s[i : j + 1]) is longer than ans. In this case, after the first full iteration (i = 0 to j = the end of s), the ans will become "aAbBcC", which is the entire string, since lower == upper is true.

  6. To continue our process, we would next start with i = 1, but given that we already found a nice substring that includes the entire string, no longer substring is possible.

  7. Completing the loops without finding a longer nice substring, we would still have ans = "aAbBcC", which is indeed the longest nice substring that meets the criteria.

At the end of the algorithm, the ans string, which equals "aAbBcC", is returned as the correct answer.

This example demonstrates the scenario where the entire string meets the conditions to be a nice string. Thus, the first and longest nice substring is found on the very first iteration of the outer loop.

Solution Implementation

1class Solution:
2    def longestNiceSubstring(self, s: str) -> str:
3        n = len(s)  # length of the input string
4        longest_nice_substring = ''  # Initialize the longest nice substring
5
6        # Iterate over the string with two pointers
7        for i in range(n):
8            lower_case_flags = 0  # Bit flags for lowercase letters
9            upper_case_flags = 0  # Bit flags for uppercase letters
10
11            # Explore the substring starting from index i
12            for j in range(i, n):
13                if s[j].islower():
14                    # Set the bit corresponding to the lowercase letter
15                    lower_case_flags |= 1 << (ord(s[j]) - ord('a'))
16                else:
17                    # Set the bit corresponding to the uppercase letter
18                    upper_case_flags |= 1 << (ord(s[j]) - ord('A'))
19
20                # Check if the current substring is nice (lowercase and uppercase bits match)
21                if lower_case_flags == upper_case_flags and len(longest_nice_substring) < j - i + 1:
22                    # Update the longest nice substring if the current one is longer
23                    longest_nice_substring = s[i : j + 1]
24
25        # Return the longest nice substring found
26        return longest_nice_substring
27
1class Solution {
2
3    public String longestNiceSubstring(String inputString) {
4        // Length of the input string
5        int stringLength = inputString.length();
6      
7        // 'start' will keep the index at which the longest nice substring begins
8        int start = -1;
9        // 'maxLength' is the length of the longest nice substring found so far
10        int maxLength = 0;
11      
12        // Iterate over each character in the string as the starting point
13        for (int i = 0; i < stringLength; ++i) {
14            // 'lowerCaseBitmask' and 'upperCaseBitmask' are bitmasks to keep track of
15            // lowercase and uppercase characters encountered
16            int lowerCaseBitmask = 0, upperCaseBitmask = 0;
17          
18            // Try extending the substring from the starting point 'i' to 'j'
19            for (int j = i; j < stringLength; ++j) {
20                // Get the current character
21                char currentChar = inputString.charAt(j);
22
23                // If it's lowercase, set the corresponding bit in the bitmask
24                if (Character.isLowerCase(currentChar)) {
25                    lowerCaseBitmask |= 1 << (currentChar - 'a');
26                }
27                // If it's uppercase, set the corresponding bit in the bitmask
28                else {
29                    upperCaseBitmask |= 1 << (currentChar - 'A');
30                }
31
32                // Check if the substring from 'i' to 'j' is a nice string
33                // A nice string has the same set of lowercase and uppercase characters
34                if (lowerCaseBitmask == upperCaseBitmask && maxLength < j - i + 1) {
35                    // Update the maxLength and the starting index 'start'
36                    maxLength = j - i + 1;
37                    start = i;
38                }
39            }
40        }
41
42        // If 'start' was updated (meaning a nice substring was found), return it
43        // Otherwise, if no nice substring exists, return an empty string
44        return (start == -1) ? "" : inputString.substring(start, start + maxLength);
45    }
46}
47
1class Solution {
2public:
3    string longestNiceSubstring(string s) {
4        // Initialize the size of the string.
5        int strSize = s.size();
6        // These will keep track of the start index of the longest nice substring
7        // and its length.
8        int startIndex = -1, maxLength = 0;
9
10        // Iterate through each character as starting point of the nice substring.
11        for (int i = 0; i < strSize; ++i) {
12            // Bitmask to represent lowercase and uppercase letters encountered.
13            int lowerBitmask = 0, upperBitmask = 0;
14
15            // Explore substrings starting at index i.
16            for (int j = i; j < strSize; ++j) {
17                // Get the current character.
18                char c = s[j];
19
20                // Set the bit for this character in the appropriate bitmask.
21                if (islower(c))
22                    lowerBitmask |= 1 << (c - 'a');
23                else
24                    upperBitmask |= 1 << (c - 'A');
25
26                // Check if the current substring is nice: it has both cases for each letter.
27                // Also check if it's the longest so far.
28                if (lowerBitmask == upperBitmask && maxLength < j - i + 1) {
29                    maxLength = j - i + 1; // Update max length to the new longest nice substring.
30                    startIndex = i; // Update start index to the starting index of the new longest nice substring.
31                }
32            }
33        }
34      
35        // If no nice substring is found, return an empty string.
36        // Otherwise, return the longest nice substring found.
37        return startIndex == -1 ? "" : s.substr(startIndex, maxLength);
38    }
39};
40
1/**
2 * Finds the longest substring where each character appears in both lower and upper case.
3 * @param {string} s - The input string to search for the nice substring.
4 * @return {string} - The longest nice substring found in the input string.
5 */
6function longestNiceSubstring(s: string): string {
7    const lengthOfString = s.length;
8    let longestSubstring = '';
9
10    // Iterate through the string to find all possible substrings
11    for (let start = 0; start < lengthOfString; start++) {
12        let lowerCaseMask = 0;  // Bitmap for tracking lowercase letters
13        let upperCaseMask = 0;  // Bitmap for tracking uppercase letters
14
15        // Explore the substrings starting from 'start' index
16        for (let end = start; end < lengthOfString; end++) {
17            const charCode = s.charCodeAt(end);
18          
19            // If the character is lowercase, update the lowerCaseMask
20            if (charCode > 96) {
21                lowerCaseMask |= 1 << (charCode - 97);
22            } 
23            // If the character is uppercase, update the upperCaseMask
24            else {
25                upperCaseMask |= 1 << (charCode - 65);
26            }
27          
28            // Check if the current substring is "nice" and if it is longer than the current longest
29            if (lowerCaseMask === upperCaseMask && end - start + 1 > longestSubstring.length) {
30                longestSubstring = s.substring(start, end + 1);
31            }
32        }
33    }
34
35    // Return the longest nice substring found
36    return longestSubstring;
37}
38

Time and Space Complexity

The given Python code snippet is designed to find the longest substring of a given string s such that the substring is "nice". A substring is considered "nice" if it contains both the uppercase and lowercase forms of the same letter.

Time Complexity

The time complexity of the code is determined by the nested for-loops. The outer loop runs n times where n is the length of string s. The inner loop runs at most n times for each iteration of the outer loop as it starts from the current position of i to the end of the string s. This gives us a quadratic number of iterations in the worst case.

The operations inside the inner loop are constant time operations, such as checking if a character is lower or uppercase and setting bits in an integer. Hence, they don't affect the time complexity's order.

Therefore, the overall time complexity of the code is O(n^2).

Space Complexity

The space complexity includes the variables to store the bitmasks for lowercase (lower) and uppercase (upper) letters, a variable for the answer (ans), and two loop variables (i and j). The bitmasks lower and upper use fixed space since there are exactly 26 lowercase and 26 uppercase English letters, and they can be represented within a fixed-size integer type.

Since the algorithm's space usage does not scale with the size of the input string s, besides the output string ans, the space complexity is O(1) for the working variables. However, if we consider the space used by the output ans, it could be O(n) in the case when the whole string s is a "nice" substring itself. Therefore, the overall space complexity, including the space for the output, is O(n).

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


Discover Your Strengths and Weaknesses: Take Our 2-Minute Quiz to Tailor Your Study Plan:
Question 1 out of 10

What are the two properties the problem needs to have for dynamic programming to be applicable? (Select 2)


Recommended Readings

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


Load More