DAY 3 / 30 · WEEK 1
Arrays & Strings
Goal of the day
- Use the core string operations —
slice,indexOf,includes,split,charAt— as fluently as you use array operations. - Understand why strings are immutable in JS, and how that changes the way you build new strings.
- Spot and fix the #1 array mistake: mutating an array while you're looping over it.
Concept
An array is like a whiteboard — you can erase and rewrite any square directly, in place.
A string is like a printed page — you can never edit a single letter on it. Every "change"
to a string actually prints a brand new page. That's why str[0] = 'b' does
nothing in ordinary JS — no error, no effect, it's silently ignored,
because strings are immutable. (In "use strict" code, ES modules, or inside a
class body, that same line throws a TypeError instead — same
root cause, stricter enforcement. Either way, it never actually changes the string.) To
change a string, you always build a new one: 'b' + str.slice(1).
The second big idea today is a trap, not a feature: modifying an array while you
loop over it. When you splice() an item out mid-loop, every element
after it shifts down one index — but your loop counter keeps counting up regardless. The
result: you silently skip checking the very next element. This bites experienced developers,
not just beginners, because the code runs without any error — it just gives a quietly wrong
answer.
Diagrams
1. String immutability
2. The mutate-while-iterating trap
Interactive visualizations
1. String immutability, step by step
2. The mutate-while-iterating bug, live
The code
// --- core string operations ---
const str = "hello world";
str.length; // 11 — same idea as arrays
str[0]; // "h" — index access, O(1)
str.charAt(0); // "h" — same thing, older API
str.slice(0, 5); // "hello" — start inclusive, end EXCLUSIVE (same as arrays!)
str.indexOf("world"); // 6 — position of first match, -1 if not found
str.includes("wor"); // true — just "does this exist", no position
str.split(" "); // ["hello", "world"] — string -> array
["a","b"].join("-"); // "a-b" — array -> string (the reverse)
// --- strings are immutable: this never changes s ---
let s = "cat";
s[0] = "b";
console.log(s); // still "cat" (sloppy mode: silently ignored;
// "use strict" / modules / classes: throws TypeError instead)
// --- to "change" a string, build a new one ---
const newS = "b" + s.slice(1); // "bat" — a brand new string
// --- THE TRAP: mutating an array while looping over it ---
function removeEvensBuggy(arr) {
arr.forEach((val, i) => {
if (val % 2 === 0) arr.splice(i, 1); // shifts everything after i left!
});
return arr;
}
removeEvensBuggy([2, 4, 6, 8, 10]); // [4, 8] — WRONG, expected []
// --- THE FIX: use a non-mutating method instead ---
function removeEvensFixed(arr) {
return arr.filter((val) => val % 2 !== 0); // builds a NEW array, no index shifting
}
removeEvensFixed([2, 4, 6, 8, 10]); // [] — correct
Line by line:
str.slice(0, 5)— just like array slicing: the end index is exclusive. This is the #1 off-by-one trap with strings.str.split(" ")/arr.join("-")— your two converters between strings and arrays. Almost every string interview problem starts withstr.split('')to turn it into an array you can index and mutate freely.s[0] = "b"— assignment to a string index never changes the string. In ordinary (sloppy-mode) code it's silently ignored — no error, which is exactly what makes this bug sneaky. In"use strict"code, ES modules, or aclassbody, it throws aTypeErrorinstead.removeEvensBuggy—splice(i, 1)removes the element at indexiand shifts everything after it one slot left. ButforEach's internal counter still moves toi + 1next — which is now a different element than you'd expect, so the one that shifted into slotinever gets checked.removeEvensFixed—filterbuilds a brand new array by testing every original element exactly once. No indices ever shift out from under you.
Dry run: removeEvensBuggy([2, 4, 6, 8, 10])
| i | arr before | val (arr[i]) | action | arr after |
|---|---|---|---|---|
| 0 | [2,4,6,8,10] | 2 | even → splice(0,1) | [4,6,8,10] |
| 1 | [4,6,8,10] | 6 | even → splice(1,1) | [4,8,10] |
| 2 | [4,8,10] | 10 | even → splice(2,1) | [4,8] |
| 3 | [4,8] | undefined (out of bounds) | skipped — no element there | [4,8] |
| 4 | [4,8] | undefined (out of bounds) | skipped — no element there | [4,8] |
4 and 8 were never re-checked after the array shifted under the loop — they survive by accident, not because they're odd.
Complexity
| Operation | Time | Space | Why |
|---|---|---|---|
str[i] / str.charAt(i) | O(1) | O(1) | Same direct-index trick as arrays. |
str.slice(a, b) | O(b - a) | O(b - a) | Has to copy every character in the range into a new string. |
str.indexOf / includes | O(n) | O(1) | Worst case, scans the whole string looking for a match. |
str1 + str2 | O(n) | O(n) | n = combined length — building the new string touches every character. |
str += x inside an n-iteration loop | O(n²) worst case | O(n) | Naively, each concatenation copies the whole string built so far. Some engines optimize repeated concatenation internally — but O(n²) is still the safe assumption to state in an interview. |
arr.splice(i, 1) | O(n) | O(1) | Same reason as shift() — everything after index i shifts left. |
The str += x row is why, when
you're building a huge string piece by piece in a loop, it's often faster to push pieces
into an array and .join('') them once at the end.
Interview corner
- What happens when you run
str[0] = 'x'? It never changes the string either way — but whether it errors depends on mode. In ordinary (sloppy-mode) script code it's a silent no-op: JS treats it like trying to write to a read-only property and just ignores it. In"use strict"code, ES modules, or aclassbody — increasingly the default in real codebases — that same line throws aTypeError. Know both, and say "it depends on strict mode" rather than asserting either one universally. - How would you reverse a string in JS?
str.split('').reverse().join('')is the idiomatic one-liner — split into an array (which IS mutable), reverse it in place, join back into a string.
↳ Common follow-up: "Can you do it without the built-ins?" — two pointers from both ends swapping into a new array/char list, O(n) time, O(n) space (you can't avoid the space since you can't mutate the original string). We formalize the two-pointer pattern itself on Day 4. - What's wrong with removing items from an array inside a
forEachor a forwardforloop? Explained in today's Concept and Diagram — indices shift under you and you silently skip elements.
⚠ Trap: "just loop backwards instead" is a real fix (shifting elements you've already passed doesn't affect indices you haven't reached yet), butfilter()is almost always clearer and safer — mention both, but reach forfilterfirst. - What's the time complexity of
str1 + str2? O(n), where n is the combined length — every character has to be copied into the new string.
How to talk through it out loud: when you catch yourself writing
arr.splice() or arr.push() inside a loop that's also iterating
over that same array, say so out loud: "I need to be careful here — I'm mutating the array
I'm looping over, so I'll either loop backwards or switch to filter/map
to avoid shifting indices." Naming the risk before it becomes a bug is exactly what
interviewers are listening for.
Practice problems
1. Reverse String (Easy — LeetCode: Reverse String)
Reverse an array of characters s in place.
function reverseString(s) {
let left = 0, right = s.length - 1;
while (left < right) {
[s[left], s[right]] = [s[right], s[left]]; // swap via destructuring
left++;
right--;
}
}2. Valid Palindrome (Easy — LeetCode: Valid Palindrome)
Given a string, return true if it reads the same forwards and backwards, ignoring non-alphanumeric characters and case.
function isPalindrome(s) {
const clean = s.toLowerCase().replace(/[^a-z0-9]/g, '');
return clean === clean.split('').reverse().join('');
}3. String Compression (Medium — LeetCode: String Compression)
Compress an array of characters in place: consecutive repeated characters become the character followed by the count (a group of 1 is written with no count). Return the new length.
read)
scans through looking for where a run of repeats ends, the other (write)
writes the compressed result back into the same array.function compress(chars) {
let write = 0, read = 0;
while (read < chars.length) {
const char = chars[read];
let count = 0;
while (read < chars.length && chars[read] === char) { read++; count++; }
chars[write++] = char;
if (count > 1) {
for (const digit of String(count)) chars[write++] = digit;
}
}
return write;
}Quiz
1. In ordinary (non-strict) code, what does this print? let s = "cat"; s[0] = "b"; console.log(s);
2. What does "hello".slice(1, 3) return?
3. What's the time complexity of str1 + str2?
4. What goes wrong with arr.forEach((v, i) => { if (cond) arr.splice(i, 1); })?
5. What does "abc".split('') return?