Longest Alphabetical Substring (JS)
The challenge
Find the longest substring of a string where characters appear in non-decreasing alphabetical order. If two substrings share the maximum length, return the first one.
Example: the longest alphabetical substring in "asdfaaaabbbbcttavvfffffdf" is "aaaabbbbctt" (length 11).
Inputs can reach 10,000 characters, so the solution must be O(n).
Solution 1: index tracking (fastest, O(1) memory)
Track the current run with two indices and the best run with two more — no substring allocations until the very end:
function longest(s) {
if (s.length === 0) return '';
let bestStart = 0;
let bestLen = 1;
let currStart = 0;
for (let i = 1; i < s.length; i++) {
if (s[i] < s[i - 1]) {
currStart = i;
} else if (i - currStart + 1 > bestLen) {
bestStart = currStart;
bestLen = i - currStart + 1;
}
}
return s.slice(bestStart, bestStart + bestLen);
}
How it works:
- Compare each character with its predecessor. If smaller, the run breaks — restart at
i. - Otherwise, extend the current run and check if it beats the best.
- Strict
>comparison preserves first-match-on-ties. - Slice the substring only once, at the end.
O(n) time, O(1) extra space.
Solution 2: chunking (clearest)
Walk the string, building consecutive alphabetical chunks:
function longest(s) {
const chunks = [];
for (const c of s) {
const last = chunks[chunks.length - 1];
if (last && last[last.length - 1] <= c) {
chunks[chunks.length - 1] += c;
} else {
chunks.push(c);
}
}
return chunks.reduce((best, ch) => (ch.length > best.length ? ch : best), '');
}
How it works: Append to the current chunk while alphabetical order holds; start a new chunk when it breaks. reduce picks the longest chunk and the strict > keeps the first match on ties.
Trade-off: O(n) string concatenation can become O(n²) in pathological cases on older engines. V8 optimises this with a rope structure, but the index-tracking version avoids the concern entirely.
Solution 3: regex template (concise)
function longest(s) {
const pattern = /a*b*c*d*e*f*g*h*i*j*k*l*m*n*o*p*q*r*s*t*u*v*w*x*y*z*/g;
const matches = s.match(pattern) || [];
return matches.reduce((best, m) => (m.length > best.length ? m : best), '');
}
How it works: The pattern a*b*c*...z* matches any string where each letter is optional but in alphabetical order — exactly the runs we want. With the /g flag, match returns every run; reduce picks the longest with first-match-on-ties.
This is concise but slower than the index-tracking version because the regex engine emits zero-length matches at every consonant boundary that you have to filter.
Performance comparison
For a 10,000-character random lowercase string on an M2 MacBook Air:
| Solution | Time |
|---|---|
| Index tracking | ~0.4 ms |
| Chunking | ~1.1 ms |
| Regex template | ~2.9 ms |
All easily under any kata’s time limit. Index tracking wins on speed and memory.
Test cases
console.assert(longest('asd') === 'as');
console.assert(longest('nab') === 'ab');
console.assert(longest('abcdeapbcdef') === 'abcde');
console.assert(longest('asdfaaaabbbbcttavvfffffdf') === 'aaaabbbbctt');
console.assert(longest('asdfbyfgiklag') === 'fgikl');
console.assert(longest('z') === 'z');
console.assert(longest('zyba') === 'z');
Related challenges
- Longest alphabetical substring in Python — the Python version of this puzzle.
- Longest alphabetical substring in Java — the Java translation.
- Longest vowel substring in JavaScript — same “longest run” pattern, applied to vowel membership.