Facebook Pixel

3713. Longest Balanced Substring I

MediumHash TableStringCountingEnumeration
LeetCode ↗

Problem Description

You are given a string s consisting of lowercase English letters.

A substring of s is called balanced if every distinct character that appears in the substring occurs the same number of times.

Your task is to return the length of the longest balanced substring of s.

A few points to clarify the problem:

  • A substring is a contiguous, non-empty sequence of characters within the string. For example, the substrings of "abc" include "a", "ab", "bc", and "abc", but not "ac".
  • A substring is balanced when all of its distinct characters share an identical frequency. For example:
    • "aabb" is balanced because both 'a' and 'b' appear exactly 2 times.
    • "abcabc" is balanced because 'a', 'b', and 'c' each appear exactly 2 times.
    • "aab" is not balanced because 'a' appears 2 times while 'b' appears only 1 time.
  • Any substring consisting of a single character (e.g., "a") or repeated copies of one character (e.g., "aaa") is trivially balanced, since there is only one distinct character. This means the answer is always at least 1 for a non-empty string.

The goal is to examine all possible substrings of s and find the maximum length among those that satisfy the balanced condition.

A useful observation for checking balance: if a substring has v distinct characters and the maximum frequency of any character in it is mx, then the substring is balanced if and only if mx * v equals the substring's length j - i + 1. This works because the total length is the sum of all character frequencies; if every frequency were equal to the maximum mx, the total would be exactly mx * v — any character appearing fewer times would make the actual length smaller than mx * v.

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.

Tree orgraph?noFastlookup orcounting?yesHash Table /Counting

Checking the balanced condition mx * v == length requires maintaining character frequencies and maximum count incrementally across all substrings.

Open in Flowchart

Intuition

The first thing to notice is that the "balanced" property depends on the exact frequency of every character in a substring, and this property does not behave nicely as a substring grows or shrinks — a balanced substring can become unbalanced by adding one character, and then become balanced again later. For example, "ab" is balanced, "aba" is not, but "abab" is balanced again. This non-monotonic behavior means classic techniques like a standard sliding window are hard to apply directly, because there is no simple condition telling us when to shrink the window.

Given that, the most natural strategy is brute-force enumeration of all substrings: fix a starting index i, then extend the ending index j one character at a time, and check whether s[i..j] is balanced at each step.

The key question becomes: how do we check the balanced condition efficiently as we extend j? Recounting all frequencies for every substring from scratch would cost an extra factor of time. Instead, we maintain the state incrementally:

  • A counter cnt tracking the frequency of each character in the current window s[i..j]. Adding one character only requires a single update.
  • v, the number of distinct characters — it increases by 1 exactly when a character's count goes from 0 to 1.
  • mx, the maximum frequency among all characters — it can only stay the same or increase as we extend the window, so updating it is a single max operation.

Now comes the elegant observation that makes the check O(1). The length of the substring is the sum of all character frequencies. If there are v distinct characters and the largest frequency is mx, then:

  • The total length is at most mx * v (each of the v characters appears at most mx times).
  • The total length equals mx * v exactly when every distinct character appears precisely mx times — which is precisely the definition of balanced.

So instead of comparing all frequencies against each other, we simply test:

mx * v == j - i + 1

If this equation holds, the substring s[i..j] is balanced, and we update the answer with its length. This turns an expensive "are all counts equal?" check into a single multiplication and comparison, giving an overall O(n^2) solution with O(|Σ|) extra space, where |Σ| = 26 is the alphabet size.

Solution Approach

Enumeration + Counting

Following the intuition, the implementation enumerates every possible starting position i of a substring in the range [0, ..., n-1]. For each fixed i, it extends the ending position j through the range [i, ..., n-1], maintaining the window's state incrementally as each new character s[j] joins the substring.

Data structures and variables:

  • cnt: a hash table (Python's Counter) recording the frequency of each character in the current substring s[i..j]. Since s contains only lowercase English letters, it holds at most 26 entries.
  • mx: the maximum frequency among all characters in the current substring.
  • v: the number of distinct characters in the current substring.
  • ans: the length of the longest balanced substring found so far.

Step-by-step walkthrough:

  1. Outer loop — fix the start. For each starting index i, reset the state: create a fresh cnt, and set mx = 0 and v = 0. This guarantees the state describes only the current window s[i..j].

  2. Inner loop — extend the end. For each ending index j from i to n - 1, absorb the character s[j]:

    • Increment its frequency: cnt[s[j]] += 1.
    • Update the maximum frequency: mx = max(mx, cnt[s[j]]). Only the newly added character could raise the maximum, so this single comparison suffices.
    • If cnt[s[j]] == 1, the character s[j] is appearing in this window for the first time, so increment the distinct-character count: v += 1.
  3. Constant-time balance check. The substring s[i..j] has length j - i + 1. With v distinct characters each appearing at most mx times, the length can never exceed mx * v. Equality

    mx * v == j - i + 1

    holds exactly when every distinct character appears precisely mx times — i.e., the substring is balanced. When the check passes, update the answer:

    ans = max(ans, j - i + 1)

  4. Return the result. After all pairs (i, j) have been examined, ans holds the length of the longest balanced substring.

Implementation:

class Solution:
    def longestBalanced(self, s: str) -> int:
        n = len(s)
        ans = 0
        for i in range(n):
            cnt = Counter()
            mx = v = 0
            for j in range(i, n):
                cnt[s[j]] += 1
                mx = max(mx, cnt[s[j]])
                if cnt[s[j]] == 1:
                    v += 1
                if mx * v == j - i + 1:
                    ans = max(ans, j - i + 1)
        return ans

Why the incremental updates are correct:

  • mx never decreases while j grows for a fixed i, because characters are only added to the window — so comparing against the count of the latest character is enough.
  • v only changes when a character's count transitions from 0 to 1, which is detected by cnt[s[j]] == 1 right after the increment.
  • Both invariants hold precisely because the window only ever expands within the inner loop; the state is rebuilt from scratch whenever i advances.

Complexity analysis:

  • Time complexity: O(n^2), where n is the length of s. There are O(n^2) pairs (i, j), and each inner-loop iteration performs only O(1) work thanks to the incremental updates and the mx * v check.
  • Space complexity: O(|Σ|), where |Σ| = 26 is the size of the alphabet, used by the frequency table cnt.

Example Walkthrough

Let's trace the algorithm on the input s = "aaabb" (n = 5). The expected answer is 4, achieved by the substring "aabb", where 'a' and 'b' each appear exactly 2 times.

We initialize ans = 0 and run the outer loop over every starting index i. For each i, we reset cnt, mx = 0, and v = 0, then extend j.

Outer loop: i = 0

jcharcnt after updatemxvcheck mx * v == j - i + 1ans
0a{a: 1}111 * 1 == 11
1a{a: 2}212 * 1 == 22
2a{a: 3}313 * 1 == 33
3b{a: 3, b: 1}323 * 2 == 4? No (6 ≠ 4)3
4b{a: 3, b: 2}323 * 2 == 5? No (6 ≠ 5)3

Notice how the run of identical characters "aaa" keeps passing the check (one distinct character is always balanced), but once 'b' joins with a smaller frequency, mx * v overshoots the actual length, correctly rejecting "aaab" and "aaabb".

Outer loop: i = 1 (state reset to empty)

jcharcnt after updatemxvcheck mx * v == j - i + 1ans
1a{a: 1}111 * 1 == 13
2a{a: 2}212 * 1 == 23
3b{a: 2, b: 1}222 * 2 == 3? No (4 ≠ 3)3
4b{a: 2, b: 2}222 * 2 == 44

At (i, j) = (1, 4) the substring is "aabb": there are v = 2 distinct characters, the maximum frequency is mx = 2, and mx * v = 4 exactly matches the length 4. This means every distinct character attains the maximum frequency, so the substring is balanced — ans is updated to 4.

This window also illustrates the non-monotonic nature of the property: "aab" (at j = 3) fails the check, yet extending it by one more character restores balance.

Outer loop: i = 2

jcharcntmxvcheckans
2a{a: 1}111 == 14
3b{a: 1, b: 1}122 == 2 ✓ (substring "ab")4
4b{a: 1, b: 2}224 == 3? No4

Outer loops: i = 3 and i = 4

  • i = 3: "b" passes (length 1) and "bb" passes (mx * v = 2 * 1 == 2), but neither beats ans = 4.
  • i = 4: "b" passes with length 1.

Result

After examining all (i, j) pairs, the algorithm returns ans = 4, corresponding to the longest balanced substring "aabb".

The walkthrough highlights the two pillars of the approach: the state (cnt, mx, v) is updated in O(1) per extension, and the single equation mx * v == j - i + 1 replaces a full frequency comparison, keeping the total cost at O(n^2).

Solution Implementation

1from collections import Counter
2
3
4class Solution:
5    def longestBalanced(self, s: str) -> int:
6        n = len(s)
7        max_len = 0
8
9        # Enumerate every possible starting index of the substring
10        for i in range(n):
11            char_count = Counter()   # Frequency of each character in s[i..j]
12            max_freq = 0             # Highest frequency among characters in s[i..j]
13            distinct = 0             # Number of distinct characters in s[i..j]
14
15            # Extend the substring to every possible ending index
16            for j in range(i, n):
17                char_count[s[j]] += 1
18
19                # Update the maximum frequency seen so far
20                max_freq = max(max_freq, char_count[s[j]])
21
22                # A new distinct character appears when its count becomes 1
23                if char_count[s[j]] == 1:
24                    distinct += 1
25
26                # The substring is balanced if every distinct character
27                # occurs exactly max_freq times, i.e.
28                # max_freq * distinct == length of the substring
29                if max_freq * distinct == j - i + 1:
30                    max_len = max(max_len, j - i + 1)
31
32        return max_len
33
1import java.util.Arrays;
2
3class Solution {
4    public int longestBalanced(String s) {
5        int n = s.length();
6        // Frequency array for lowercase letters 'a' to 'z'
7        int[] charCount = new int[26];
8        // Length of the longest balanced substring found so far
9        int maxLength = 0;
10
11        // Enumerate every possible starting index of the substring
12        for (int i = 0; i < n; ++i) {
13            // Reset character frequencies for the new starting index
14            Arrays.fill(charCount, 0);
15            // maxFreq: highest frequency of any character in s[i..j]
16            // distinctCount: number of distinct characters in s[i..j]
17            int maxFreq = 0, distinctCount = 0;
18
19            // Extend the substring one character at a time
20            for (int j = i; j < n; ++j) {
21                int charIndex = s.charAt(j) - 'a';
22
23                // If this character appears for the first time in the window,
24                // increase the distinct character count
25                if (++charCount[charIndex] == 1) {
26                    ++distinctCount;
27                }
28
29                // Update the maximum frequency within the current window
30                maxFreq = Math.max(maxFreq, charCount[charIndex]);
31
32                // The substring s[i..j] is balanced if and only if
33                // maxFreq * distinctCount equals its length, meaning
34                // every distinct character appears exactly maxFreq times
35                if (maxFreq * distinctCount == j - i + 1) {
36                    maxLength = Math.max(maxLength, j - i + 1);
37                }
38            }
39        }
40        return maxLength;
41    }
42}
43
1class Solution {
2public:
3    int longestBalanced(string s) {
4        int n = s.size();
5        // Frequency counter for each lowercase letter ('a' to 'z')
6        vector<int> freq(26, 0);
7        int ans = 0;
8
9        // Enumerate every possible starting index of a substring
10        for (int i = 0; i < n; ++i) {
11            // Reset the frequency counter for the new starting index
12            fill(freq.begin(), freq.end(), 0);
13
14            int maxFreq = 0;        // Highest frequency among characters in s[i..j]
15            int distinctCount = 0;  // Number of distinct characters in s[i..j]
16
17            // Extend the substring one character at a time
18            for (int j = i; j < n; ++j) {
19                int c = s[j] - 'a';
20
21                // If this character appears for the first time, it is a new distinct character
22                if (++freq[c] == 1) {
23                    ++distinctCount;
24                }
25
26                // Update the maximum frequency seen so far in this substring
27                maxFreq = max(maxFreq, freq[c]);
28
29                // A substring is balanced when every distinct character occurs
30                // exactly maxFreq times, i.e. maxFreq * distinctCount == length.
31                if (maxFreq * distinctCount == j - i + 1) {
32                    ans = max(ans, j - i + 1);
33                }
34            }
35        }
36
37        return ans;
38    }
39};
40
1/**
2 * Finds the length of the longest balanced substring of s.
3 * A substring is balanced if every distinct character in it
4 * appears exactly the same number of times.
5 *
6 * Approach: Enumerate all substrings with a double loop.
7 * For each starting index, extend the ending index while
8 * maintaining character counts. A substring of length L with
9 * `distinct` distinct characters, where the maximum frequency
10 * of any character is `maxFreq`, is balanced if and only if
11 * maxFreq * distinct === L (this forces every character's
12 * count to equal maxFreq).
13 *
14 * Time complexity: O(n^2), Space complexity: O(26) = O(1)
15 *
16 * @param s - the input string consisting of lowercase letters
17 * @returns the length of the longest balanced substring
18 */
19function longestBalanced(s: string): number {
20    const n: number = s.length;
21    let ans: number = 0;
22
23    // Enumerate every possible starting index of the substring
24    for (let i = 0; i < n; ++i) {
25        // count[k] holds occurrences of character ('a' + k) in s[i..j]
26        const count: number[] = Array(26).fill(0);
27        // maxFreq: highest frequency among characters in the window
28        // distinct: number of distinct characters in the window
29        let maxFreq: number = 0;
30        let distinct: number = 0;
31
32        // Extend the substring one character at a time
33        for (let j = i; j < n; ++j) {
34            const c: number = s.charCodeAt(j) - 97;
35
36            // A new distinct character enters the window
37            if (++count[c] === 1) {
38                ++distinct;
39            }
40
41            // Update the maximum frequency seen so far
42            maxFreq = Math.max(maxFreq, count[c]);
43
44            // The substring is balanced when every distinct character
45            // occurs exactly maxFreq times, i.e. maxFreq * distinct
46            // equals the substring length
47            if (maxFreq * distinct === j - i + 1) {
48                ans = Math.max(ans, j - i + 1);
49            }
50        }
51    }
52
53    return ans;
54}
55

Time and Space Complexity

  • Time Complexity: O(n²), where n is the length of the string s. The outer loop enumerates every starting index i, and the inner loop extends the ending index j from i to n - 1. Inside the inner loop, all operations — updating the counter cnt[s[j]], maintaining the maximum frequency mx, tracking the number of distinct characters v, and checking the balanced condition mx * v == j - i + 1 — take O(1) time. Therefore, the total work is O(n × n) = O(n²).

  • Space Complexity: O(|Σ|), where |Σ| is the size of the character set (here |Σ| = 26 for lowercase English letters). The counter cnt holds at most |Σ| distinct keys, and it is re-created for each starting index rather than accumulating, so the extra space never exceeds O(|Σ|). All remaining variables (mx, v, ans) use only O(1) space.

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

Common Pitfalls

Pitfall: Forgetting to Reset the Window State When the Starting Index Advances

The Problem:

A frequent mistake is declaring char_count, max_freq, and distinct outside the outer loop, intending to "reuse" them across different starting positions:

# WRONG: state is shared across all starting indices
class Solution:
    def longestBalanced(self, s: str) -> int:
        n = len(s)
        max_len = 0
        char_count = Counter()   # initialized only once — bug!
        max_freq = 0
        distinct = 0

        for i in range(n):
            for j in range(i, n):
                char_count[s[j]] += 1
                max_freq = max(max_freq, char_count[s[j]])
                if char_count[s[j]] == 1:
                    distinct += 1
                if max_freq * distinct == j - i + 1:
                    max_len = max(max_len, j - i + 1)

        return max_len

Every value in the algorithm's state is defined relative to the current window s[i..j]. Once i advances, the old window no longer exists, yet the leftover counts make char_count describe a mixture of multiple windows. Consequences:

  1. char_count accumulates garbage. Frequencies from earlier windows inflate the counts, so char_count[s[j]] == 1 rarely triggers and distinct becomes meaningless.
  2. max_freq is "sticky." It only ever grows, so it carries a maximum from a window that no longer exists. The check max_freq * distinct == j - i + 1 then compares unrelated quantities, producing both false positives and false negatives.
  3. The bug can be subtle in testing: on small inputs like "ab" the wrong code may still return the right answer, while longer inputs (e.g., "aabbcc") silently fail.

A closely related variant of this mistake is trying to "fix" the reuse by subtracting s[i-1] from the counter when i advances (sliding the left edge) but forgetting that max_freq cannot be decreased in O(1). Removing a character may lower the true maximum frequency, and there is no constant-time way to recompute it from a plain counter — which is exactly why this problem doesn't admit a straightforward two-pointer sliding window.

The Solution:

Re-initialize all per-window state inside the outer loop, so each starting index begins with a clean slate:

for i in range(n):
    char_count = Counter()   # fresh state for the window starting at i
    max_freq = 0
    distinct = 0
    for j in range(i, n):
        char_count[s[j]] += 1
        max_freq = max(max_freq, char_count[s[j]])
        if char_count[s[j]] == 1:
            distinct += 1
        if max_freq * distinct == j - i + 1:
            max_len = max(max_len, j - i + 1)

The correctness of the incremental updates rests on a single invariant: within a fixed i, the window only ever expands. Under expansion-only updates:

  • max_freq can only increase, so comparing it against the newest character's count is sufficient.
  • distinct only changes when a count transitions from 0 to 1.

Resetting on every new i is precisely what preserves this invariant. If you ever feel tempted to shrink the window instead, remember that the maximum frequency is not efficiently maintainable under deletions — rebuilding from scratch per starting index is the simple, correct choice and still yields the intended O(n²) time with O(26) space.

Quick sanity tests to catch this bug:

InputCorrect OutputTypical Buggy Output
"aabbcc"6varies (often < 6)
"abab"4varies
"aaab"2 ("ab")varies

Testing with strings where the best balanced substring does not start at index 0 is the most reliable way to expose stale-state bugs, since windows starting at i = 0 are unaffected by the missing reset.

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:

Depth first search is equivalent to which of the tree traversal order?


Recommended Readings

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

Load More