567. Permutation in String

Check whether any permutation of s1 appears as a contiguous substring of s2. The key is a fixed-size sliding window of width s1.length and a single integer matches counter that tracks how many of the 26 letter-counts are currently in sync — no recount needed on each slide.

MediumFixed-size sliding windowFrequency countingTwo PointersTypeScript

PROBLEM What we're solving

Given strings s1 and s2, return true if any permutation of s1 is a contiguous substring of s2. s1="ab", s2="eidbaooo" true because "ba" (a permutation of "ab") appears at index 3. s1="ab", s2="eidboaoo" false.

KEY IDEA A permutation is just identical letter-counts in a window

Insight → a window of s2 is a permutation of s1 iff their letter-frequency arrays are identical. Instead of recomputing the full array on every slide, maintain a single matches counter (0–26). Each slide updates at most two characters (the one entering and the one leaving), so matches changes by at most ±2. When matches === 26, all counts agree — done.

RECIPE Seed, then slide one step at a time

  • 0 · Quick reject. s1.length > s2.length → impossible, return false immediately.
  • 1 · Build need[]. Count every character of s1 into a 26-slot array.
  • 2 · Seed first window. Fill have[] for s2[0..k-1]. For each slot, if have[c] === need[c] increment matches; if it just crossed from equal to unequal, decrement.
  • 3 · Check. If matches === 26, return true.
  • 4 · Slide. For each new right index r: add s2[r] and remove s2[r-k], updating matches for each. Check again.
  • 5 · Return false if no window matched.
Classic confusion → when you add a character and have[c] goes from equal to over, you must decrement matches(the slot just became unequal). Many people only increment on the "just became equal" path and forget the symmetric decrement on the "just left equal" path — causing phantom matches.

COST Complexity & alternatives

Sort each window
O(n · k log k)
Resort every slide — expensive for large k.
Count + match counter
O(n)
O(n) time; O(1) space (fixed 26-char alphabet).

Space is O(1) because the alphabet is fixed at 26 — two number[26] arrays regardless of input size. If you needed arbitrary Unicode, use a Map (O(k) space).

Pattern transfer → the same fixed-window + match-counter trick appears in Find All Anagrams in a String (LC 438 — collect every matching start index instead of stopping at the first), Minimum Window Substring (LC 76 — variable size window), and Valid Anagram (LC 242 — no sliding, just a single window over the whole string).

RUN IT Fixed-size window — slide and match 26 counts

step 0 / 8
STARTWindow size k = 2. Build need[] from s1, initialize have[] and matches = 0.
1function checkInclusion(s1: string, s2: string): boolean {
2 const k = s1.length;
3 if (k > s2.length) return false;
4
5 // Frequency arrays for s1 (need) and the current window of s2 (have)
6 const need = new Array<number>(26).fill(0);
7 const have = new Array<number>(26).fill(0);
8 const a = 'a'.charCodeAt(0);
9
10 for (const c of s1) need[c.charCodeAt(0) - a]++;
11
12 // matches = number of letters whose count in 'have' equals 'need'
13 let matches = 0;
14
15 // Seed the first window
16 for (let i = 0; i < k; i++) {
17 const idx = s2.charCodeAt(i) - a;
18 have[idx]++;
19 if (have[idx] === need[idx]) matches++;
20 else if (have[idx] - 1 === need[idx]) matches--;
21 }
22 if (matches === 26) return true;
23
24 // Slide one step at a time
25 for (let r = k; r < s2.length; r++) {
26 // Bring the right character into the window
27 const rIdx = s2.charCodeAt(r) - a;
28 have[rIdx]++;
29 if (have[rIdx] === need[rIdx]) matches++;
30 else if (have[rIdx] - 1 === need[rIdx]) matches--;
31
32 // Evict the leftmost character
33 const lIdx = s2.charCodeAt(r - k) - a;
34 have[lIdx]--;
35 if (have[lIdx] === need[lIdx]) matches++;
36 else if (have[lIdx] + 1 === need[lIdx]) matches--;
37
38 if (matches === 26) return true;
39 }
40 return false;
41}
e0i1d2b3a4o5o6o7
State
2
k
{ a:1, b:1 }
need
{}
have
0
left
1
right
0 / 26
matches
...
result
right (incoming)inside windowneed[] (s1 counts)all counts matchno match found
slowfast

TYPESCRIPT The solution, annotated

checkInclusion.ts
function checkInclusion(s1: string, s2: string): boolean {
  const k = s1.length;
  if (k > s2.length) return false;

  // Frequency arrays for s1 (need) and the current window of s2 (have)
  const need = new Array<number>(26).fill(0);
  const have = new Array<number>(26).fill(0);
  const a = 'a'.charCodeAt(0);

  for (const c of s1) need[c.charCodeAt(0) - a]++;

  // matches = number of letters whose count in 'have' equals 'need'
  let matches = 0;

  // Seed the first window
  for (let i = 0; i < k; i++) {
    const idx = s2.charCodeAt(i) - a;
    have[idx]++;
    if (have[idx] === need[idx]) matches++;
    else if (have[idx] - 1 === need[idx]) matches--;
  }
  if (matches === 26) return true;

  // Slide one step at a time
  for (let r = k; r < s2.length; r++) {
    // Bring the right character into the window
    const rIdx = s2.charCodeAt(r) - a;
    have[rIdx]++;
    if (have[rIdx] === need[rIdx]) matches++;
    else if (have[rIdx] - 1 === need[rIdx]) matches--;

    // Evict the leftmost character
    const lIdx = s2.charCodeAt(r - k) - a;
    have[lIdx]--;
    if (have[lIdx] === need[lIdx]) matches++;
    else if (have[lIdx] + 1 === need[lIdx]) matches--;

    if (matches === 26) return true;
  }
  return false;
}

Reading it block by block

Lines 2–3 — cheap reject. If s1 is longer than s2 no window can hold it — bail immediately.
Lines 6–11 — frequency arrays. need[] tallies s1's letter counts; have[] will track the sliding window. Both are fixed 26-slot arrays, so space stays O(1).
Lines 14–20 — seed the first window. Walk s2[0..k-1], incrementing have[c] each time. After each increment, check both directions: if the slot just became equal to need, matches++; if it just left equal (was equal, now over), matches--. Start by checking right after the first window.
Lines 23–36 — slide. Each iteration adds s2[r] (right edge) and removes s2[r-k] (left edge). Both updates apply the same ±1 matches logic. The window is a permutation the instant matches === 26.
Line 37 — default. If no window matched, return false.
Complexity → O(n) time — each character is added once and removed once. O(1) space — two fixed-length arrays of 26 integers regardless of input size.

INTERVIEWFollow-ups they'll ask

  • "Return all starting indices instead of just true/false?" That is LC 438 (Find All Anagrams). Keep the same window logic but push l to a result array every time matches === 26.
  • "What if s1 can have uppercase or non-ASCII characters?" Replace the fixed number[26] array with a Map<string, number>and track the number of "satisfied" keys rather than the count 26.
  • "What is the brute-force and why is this better?" Sort every k-length window of s2 and compare to the sorted s1 — O(n · k log k). The count approach avoids re-sorting by maintaining state incrementally.
  • "Minimum Window Substring?" That is the variable-size sibling (LC 76): shrink the window from the left whenever all characters are covered, tracking the minimum length seen.
  • "Can you do it in one pass?" Yes — seed the first window in the same loop that starts sliding, but it complicates the match bookkeeping. The two-phase approach above is cleaner and equally asymptotically optimal.

OPTIMAL Fixed-size sliding window

checkInclusion.ts
function checkInclusion(s1: string, s2: string): boolean {
  const k = s1.length;
  if (k > s2.length) return false;

  // Frequency arrays for s1 (need) and the current window of s2 (have)
  const need = new Array<number>(26).fill(0);
  const have = new Array<number>(26).fill(0);
  const a = 'a'.charCodeAt(0);

  for (const c of s1) need[c.charCodeAt(0) - a]++;

  // matches = number of letters whose count in 'have' equals 'need'
  let matches = 0;

  // Seed the first window
  for (let i = 0; i < k; i++) {
    const idx = s2.charCodeAt(i) - a;
    have[idx]++;
    if (have[idx] === need[idx]) matches++;
    else if (have[idx] - 1 === need[idx]) matches--;
  }
  if (matches === 26) return true;

  // Slide one step at a time
  for (let r = k; r < s2.length; r++) {
    // Bring the right character into the window
    const rIdx = s2.charCodeAt(r) - a;
    have[rIdx]++;
    if (have[rIdx] === need[rIdx]) matches++;
    else if (have[rIdx] - 1 === need[rIdx]) matches--;

    // Evict the leftmost character
    const lIdx = s2.charCodeAt(r - k) - a;
    have[lIdx]--;
    if (have[lIdx] === need[lIdx]) matches++;
    else if (have[lIdx] + 1 === need[lIdx]) matches--;

    if (matches === 26) return true;
  }
  return false;
}
Complexity → O(n) time — each character is added once and removed once. O(1) space — two fixed-length arrays of 26 integers regardless of input size.

ALT 1 Brute force — sort every window and compare

O(n · k log k) time · O(k) space

A window is a permutation of s1 exactly when its sorted characters equal the sorted s1. Slide a fixed-width window of length k = s1.length across s2, sort each window, and compare.

approach-2.ts
function checkInclusion(s1: string, s2: string): boolean {
  const k = s1.length;
  if (k > s2.length) return false;

  const target = s1.split('').sort().join('');

  for (let i = 0; i + k <= s2.length; i++) {
    const window = s2.slice(i, i + k).split('').sort().join('');
    if (window === target) return true;
  }
  return false;
}
Note → Sorting each of the n − k + 1windows costs O(k log k), so the whole scan is O(n · k log k) — re-sorting from scratch throws away all the work done on the previous window. Maintaining a 26-slot frequency count incrementally (one char enters, one leaves per slide) drops it to O(n).

MNEMONIC The one-liner

"Fixed window of s1-size; one match-counter for 26 slots — slide in right, evict left, check 26."

TRIGGERS When you see ___ → reach for ___

"does s2 contain a permutation / anagram of s1?"fixed-size sliding window + 26-count match counter
window size is fixed, known up frontseed first window, then slide — no need for two-pointer expand/shrink
comparing letter frequencies of two stringsnumber[26] arrays + matches counter avoids full recount
"find all anagram start indices" (LC 438)same window; collect l instead of returning true

SKELETON The reusable shape

skeleton.ts
const k = s1.length;
if (k > s2.length) return false;
const need = new Array<number>(26).fill(0);
const have = new Array<number>(26).fill(0);
const a = 'a'.charCodeAt(0);
for (const c of s1) need[c.charCodeAt(0) - a]++;
let matches = 0;
// seed first window, track matches
for (let i = 0; i < k; i++) { /* add s2[i] to have; update matches */ }
if (matches === 26) return true;
for (let r = k; r < s2.length; r++) {
  // add s2[r], remove s2[r-k], update matches both times
  if (matches === 26) return true;
}
return false;

FLASHCARDS Tap to flip

When is a window a permutation of s1?
When have[c] === need[c] for all 26 letters, i.e. matches === 26.
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 9
QUESTION 01
What is the optimal time complexity for checkInclusion(s1, s2)?
QUESTION 02
For s1="ab", s2="eidbaooo", what is the window size?
QUESTION 03
After adding character c to have[], when should you decrement matches?
QUESTION 04
Trace: s1="ab", s2="eidboaoo". Does the function return true?
QUESTION 05
What is the space complexity of the 26-array approach?
QUESTION 06
Which sibling problem collects every matching start index instead of stopping at the first?
QUESTION 07
Why is the initial quick-reject check if (k > s2.length) return false necessary?
QUESTION 08
#567 · Permutation in StringSlide a fixed-size window of length |s1| across s2 and track a "matched count" of the 26 letter differences — the window is a permutation of s1 the instant all 26 differences are zero.Which algorithmic approach does this primarily use?
QUESTION 09
#567 · Permutation in StringSlide a fixed-size window of length |s1| across s2 and track a "matched count" of the 26 letter differences — the window is a permutation of s1 the instant all 26 differences are zero.Which implementation correctly solves it?