1763. Longest Nice Substring
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:
-
Initialize an empty string
ans
to keep track of the longest nice substring found. -
Iterate through the string
s
using a nested loop. The outer loop starts from each character indexed byi
and the inner loop checks every subsequent character up to the end of the string, indexed byj
. -
For each character, two bitmasks
lower
andupper
are maintained. If the character is lowercase (checked usings[j].islower()
), we set the corresponding bit inlower
. This is done by shifting 1 to the left byord(s[j]) - ord('a')
places (since 'a' is the ASCII starting point for lowercase letters, the difference gives us the correct bit position). -
Similarly, if the character is uppercase, the corresponding bit in
upper
is set by shifting 1 to the left byord(s[j]) - ord('A')
places (as 'A' is the ASCII starting point for uppercase letters). -
We compare
lower
andupper
to check if the current substring is nice, which would be true iflower
equalsupper
. This comparison realizes if for every lowercase letter there's a matching uppercase letter and vice versa. -
If a nice substring is found and its length is greater than the length of the current
ans
, theans
string is updated to this substring. We achieve substring extraction using Python's slice notations[i : j + 1]
. -
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. -
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 EvaluatorExample 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.
-
We start with an empty string
ans
to hold the longest nice substring we find. -
We begin by iterating over the string
s
with indexi
from 0 to the length ofs
. The first character is 'a', and we start the inner loop fromi
to check subsequent characters. -
For each character
s[j]
we encounter as we move through the inner loop:- If
s[j].islower()
is true, for examples[j] = 'a'
, we modifylower
bitmask. We dolower |= (1 << (ord('a') - ord('a')))
, resulting inlower = 1
as 'a' is the first lowercase letter. - If
s[j].isupper()
is true instead, for examples[j] = 'A'
, we modify theupper
bitmask in a similar fashion, soupper = 1
.
- If
-
Continuing this process of updating
lower
andupper
for each character in the inner loop, we reach the end of the string. By now,lower
andupper
should both be111111
in binary, which corresponds to63
in decimal, standing for the presence of 'a', 'b', and 'c' in lowercase and uppercase. -
We check if
lower
equalsupper
after each inner loop iteration and updateans
only if the current substring (s[i : j + 1]
) is longer thanans
. In this case, after the first full iteration (i = 0 to j = the end ofs
), theans
will become "aAbBcC", which is the entire string, sincelower == upper
is true. -
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. -
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.
What's the relationship between a tree and a graph?
Recommended Readings
https algomonster s3 us east 2 amazonaws com 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
https algomonster s3 us east 2 amazonaws com 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
LeetCode 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 we
Want a Structured Path to Master System Design Too? Don’t Miss This!