Skip to content
Commitwise
Menu
Coding Interview

Arrays and strings

Know what each array and string operation costs, change arrays in place safely, answer range questions with prefix sums, and build and compare strings efficiently, with problems in Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Arrays and strings are where most interview problems start, and where most “simple” solutions are secretly slow. Removing from the front of an array, gluing strings together in a loop, or summing the same range over and over all look like one line of code and cost a whole pass each time. Knowing what each operation really costs is what turns those O(n²) solutions into O(n) ones.

New to some of these? Open what you need, or skip ahead.

Basics:What are arrays and indexes?

An array is a row of values stored one after another: [5, 8, 2, 9]. Each position has an index, starting at 0: here 5 is at index 0 and 9 at index 3. The last index is always count - 1 (Swift) or size - 1 / length - 1 (Kotlin, Java, TypeScript).

Reading nums[2] is instant, whatever the array’s size. Reading an index that doesn’t exist (like nums[4] here) crashes with an “index out of range” error (in TypeScript it quietly gives undefined, a bug of its own), which is why loops usually go from 0 up to, but not including, the length.

Basics:How do strings and characters work?

A string is text: a sequence of characters. "racecar" has 7 characters, and you can compare them, change their case (lowercased(), lowercase(), toLowerCase()) and check whether one is a letter or a digit.

One difference to know: Kotlin, Java and TypeScript let you read a character by position, s[3] or s.charAt(3) (in TypeScript, positions count UTF-16 units, so an emoji takes two). Swift doesn’t allow integer positions on String (because some characters, like emoji, take more space than others), so code often converts the string to an array first: Array(s).

Basics:What does O(n) mean?

Big-O describes how the amount of work grows with the size of the input n:

  • O(1): the same work whatever the size (reading array[5]).
  • O(log n): grows very slowly (about 20 steps for a million items).
  • O(n): grows in step with the input (one pass over the array).
  • O(n²): comparing every item with every other item, which gets slow fast.

You’ll see it next to every solution, for time and for extra space (memory). The Big-O tutorial explains it properly.

Basics:What are lists, sets and maps?

Collections hold many values:

  • A list (Swift Array, Kotlin List, Java List, a TypeScript array) keeps items in order, and allows duplicates: ["Ana", "Ben", "Ana"].
  • A set holds each value at most once and answers “is this in it?” very quickly.
  • A map (Swift Dictionary, Kotlin, Java and TypeScript Map) stores values under keys: "Ana" → 30, looked up by the key.

Lists use indexes starting at 0: in ["a", "b", "c"], "a" is at index 0 and "c" at index 2.

Part Not started

What array operations cost

Picture a row of cinema seats, numbered from 0, with no gaps allowed. Finding seat 12 is instant: you walk straight to it. A new person sitting at the end of the row is easy too. But if someone wants to sit at seat 0, everyone already seated has to move one seat to the right. And if the person in seat 0 leaves, everyone moves one seat left to close the gap.

Arrays work exactly like that row. That’s why some operations are O(1) and others are O(n), even though each is a single line of code.

Every language here uses a contiguous, growable array (Swift Array, Kotlin/Java ArrayList, JS arrays in their fast mode). Indexing is O(1) and cache-friendly; appending is amortized O(1) because the buffer doubles (or grows by 1.5×) when full; anything that shifts elements is O(n). The interview traps are calls that hide that shift (removeFirst, insert(at: 0), remove(0), shift, splice), copies hidden in slicing, and value semantics in Swift (an array copied before mutation).

OperationCostWhy
Read or write nums[i]O(1)Jump straight to the position
Add at the endO(1) on averageOccasionally the array grows into a bigger block
Insert or remove at the front or middleO(n)Every later element shifts by one
contains(x) / indexOf(x)O(n)Checks elements one by one
Copy, slice into a new array, sortO(n), O(k), O(n log n)Touches every element (sorting more than once)
var nums = [3, 5, 8]
nums.append(13)          // O(1): nothing moves
nums.insert(1, at: 0)    // O(n): 3, 5, 8, 13 all shift right
nums.remove(at: 0)       // O(n): they all shift back
val nums = mutableListOf(3, 5, 8)
nums.add(13)             // O(1): nothing moves
nums.add(0, 1)           // O(n): 3, 5, 8, 13 all shift right
nums.removeAt(0)         // O(n): they all shift back
List<Integer> nums = new ArrayList<>(List.of(3, 5, 8));
nums.add(13);            // O(1): nothing moves
nums.add(0, 1);          // O(n): 3, 5, 8, 13 all shift right
nums.remove(0);          // O(n): they all shift back
const nums = [3, 5, 8];
nums.push(13);           // O(1): nothing moves
nums.unshift(1);         // O(n): 3, 5, 8, 13 all shift right
nums.shift();            // O(n): they all shift back

Changing an array while you walk through it

Removing an element shifts everything after it one position to the left. That’s easy to forget inside a loop:

Spot the bugRemove all negative numbers

Tap the line with the bug.

func removeNegatives(_ nums: inout [Int]) {
    var i = 0
    while i < nums.count {
        if nums[i] < 0 { nums.remove(at: i) }
        i += 1
    }
}
fun removeNegatives(nums: MutableList<Int>) {
    var i = 0
    while (i < nums.size) {
        if (nums[i] < 0) nums.removeAt(i)
        i++
    }
}
void removeNegatives(List<Integer> nums) {
    int i = 0;
    while (i < nums.size()) {
        if (nums.get(i) < 0) nums.remove(i);
        i++;
    }
}
function removeNegatives(nums: number[]): void {
  let i = 0;
  while (i < nums.length) {
    if (nums[i] < 0) nums.splice(i, 1);
    i++;
  }
}

Predict the output

What does this print, and how much work does it do for a long array?

var result: [Int] = []
for x in [1, 2, 3] {
    result.insert(x, at: 0)
}
print(result)
val result = mutableListOf<Int>()
for (x in listOf(1, 2, 3)) {
    result.add(0, x)
}
println(result)
List<Integer> result = new ArrayList<>();
for (int x : List.of(1, 2, 3)) {
    result.add(0, x);
}
System.out.println(result);
const result: number[] = [];
for (const x of [1, 2, 3]) {
  result.unshift(x);
}
console.log(result);
Exercisemedium

Rotate Array

Rotate an array to the right by k steps, in place (no second array), so each element moves k positions to the right and the ones that fall off the end wrap around to the front.

nums = [1, 2, 3, 4, 5, 6, 7], k = 3  → [5, 6, 7, 1, 2, 3, 4]
nums = [-1, -100, 3, 99],     k = 2  → [3, 99, -1, -100]
›

Rotating by k is the same as rotating by k % n: rotating a 7-element array by 7 changes nothing. Do that first, so k is smaller than n.

›

Reverse the whole array: [7, 6, 5, 4, 3, 2, 1]. The last k elements are now at the front, but backwards. What happens if you reverse the first k elements, and then the rest, separately?

SolutionTry it yourself firstTime O(n)Space O(1)

Three reversals: the whole array, then the first k elements, then the remaining n − k. Each element is moved at most twice.

func rotate(_ nums: inout [Int], _ k: Int) {
    let n = nums.count
    guard n > 0 else { return }
    let k = k % n
    nums.reverse()                 // [7, 6, 5, 4, 3, 2, 1]
    nums[0..<k].reverse()          // [5, 6, 7, 4, 3, 2, 1]
    nums[k..<n].reverse()          // [5, 6, 7, 1, 2, 3, 4]
}
fun rotate(nums: IntArray, k: Int) {
    val n = nums.size
    if (n == 0) return
    val shift = k % n
    nums.reverse()                 // [7, 6, 5, 4, 3, 2, 1]
    nums.reverse(0, shift)         // [5, 6, 7, 4, 3, 2, 1]
    nums.reverse(shift, n)         // [5, 6, 7, 1, 2, 3, 4]
}
void rotate(int[] nums, int k) {
    int n = nums.length;
    if (n == 0) return;
    k %= n;
    reverse(nums, 0, n - 1);       // [7, 6, 5, 4, 3, 2, 1]
    reverse(nums, 0, k - 1);       // [5, 6, 7, 4, 3, 2, 1]
    reverse(nums, k, n - 1);       // [5, 6, 7, 1, 2, 3, 4]
}

void reverse(int[] nums, int left, int right) {
    while (left < right) {
        int tmp = nums[left];
        nums[left++] = nums[right];
        nums[right--] = tmp;
    }
}
function rotate(nums: number[], k: number): void {
  const n = nums.length;
  if (n === 0) return;
  k %= n;
  reverse(nums, 0, n - 1);         // [7, 6, 5, 4, 3, 2, 1]
  reverse(nums, 0, k - 1);         // [5, 6, 7, 4, 3, 2, 1]
  reverse(nums, k, n - 1);         // [5, 6, 7, 1, 2, 3, 4]
}

function reverse(nums: number[], left: number, right: number): void {
  while (left < right) {
    [nums[left], nums[right]] = [nums[right], nums[left]];
    left++;
    right--;
  }
}

A version with k calls to “remove the last element and insert it at the front” also works, but each insert at index 0 is O(n), so it’s O(n · k). The reversal trick is the kind of idea worth remembering: it shows up again in “reverse the words in a sentence”.

Hidden shifts and copies

Predict the output

A worker processes a queue of 100,000 jobs by repeatedly taking the first element and removing it from an array-backed list. Roughly how many element moves does that cost in total?

Value semantics and slices

Swift

Swift arrays are value types with copy-on-write: passing one around is O(1), but the first mutation of a shared buffer copies it, O(n). A helper that takes [Int] and mutates a local var copy inside a loop can turn O(n) into O(n²). Use inout, or make sure the buffer is uniquely referenced. nums[a..<b] is an ArraySlice sharing storage (O(1)); Array(slice) copies.

Kotlin

subList(a, b) is a view (O(1)) that breaks if the original list is structurally modified; slice(a until b) and copyOfRange copy (O(k)). toList() and sorted() always allocate.

Java

subList is a view, O(1), and throws ConcurrentModificationException after a structural change to the parent. Arrays.copyOfRange and new ArrayList<>(list) copy. List.of and toList() from streams are unmodifiable.

TypeScript

slice and spread ([...a]) copy in O(k). splice mutates and shifts in O(n). Arrays with holes or mixed element types can fall out of the engine’s fast mode; keep them dense and of one type in hot code.

  • Contiguous growable arrays: O(1) index, amortized O(1) append, O(n) for anything that shifts. contains/indexOf are O(n).
  • Removing while iterating forward skips the next element; prefer removeAll/removeIf/filter, or a read/write pointer pass.
  • Front removal in a loop is O(n²): use a deque or a read index.
SwiftKotlinJavaTypeScript
O(n) front removalremoveFirst()removeAt(0)remove(0)shift()
O(1) queueDeque (swift-collections), slice + popFirstArrayDequeArrayDequeread index
View, not copyArraySlicesubListsubListnone (slice copies)

Swift

  • Copy-on-write: mutating a shared array copies it once, O(n). Use inout in helpers.

Check this part

  1. Which operation on an array of a million numbers is slowest?

  2. A loop removes the element at index i and then always does i += 1. What goes wrong?

  3. You process a 100k-element queue with Array.removeFirst(). Total cost?You process a 100k-element queue with ArrayList.removeAt(0). Total cost?You process a 100k-element queue with ArrayList.remove(0). Total cost?You process a 100k-element queue with shift(). Total cost?

  4. Why is appending to a dynamic array O(1) only on average?

Reading by index and adding at the end are cheap; inserting or removing anywhere else shifts every later element, O(n). Removing while looping forward skips elements, and front removal in a loop is O(n²).

Part Not started

Prefix sums: answer range questions in O(1)

A car’s odometer shows the total distance driven since the car was new. To find out how far you drove on Tuesday, you don’t need a log of every trip: you subtract Tuesday morning’s reading from Tuesday evening’s.

A prefix sumPrefix sumAn array of running totals, where position i holds the sum of the first i numbers, so any range sum is one subtraction.Open in glossary → array is an odometer for an array: position i holds the total of all the numbers before it. The sum of any range is then one subtraction, instead of a loop over the range.

Precompute prefix[i] = nums[0] + … + nums[i − 1] with prefix[0] = 0 (the extra leading zero removes every special case). Then sum(l…r) = prefix[r + 1] − prefix[l] in O(1) after O(n) preprocessing. The same idea generalizes to anything invertible (XOR, counts, 2-D matrices), and combined with a hash map of previously seen prefixes it solves subarray-sum problems that sliding windows can’t, because negative numbers break a window’s monotonicity.

nums:     [ 3,  1,  4,  1,  5]
prefix:  [0, 3,  4,  8,  9, 14]       prefix[i] = sum of the first i numbers

sum of nums[1…3] = 1 + 4 + 1 = 6  =  prefix[4] − prefix[1]  =  9 − 3
struct RangeSums {
    private let prefix: [Int]          // prefix[i] = sum of the first i numbers

    init(_ nums: [Int]) {
        var prefix = [0]
        for x in nums { prefix.append(prefix.last! + x) }
        self.prefix = prefix
    }

    func sum(_ left: Int, _ right: Int) -> Int {    // both ends included
        prefix[right + 1] - prefix[left]
    }
}
class RangeSums(nums: IntArray) {
    private val prefix = IntArray(nums.size + 1)   // prefix[i] = sum of the first i numbers

    init {
        for (i in nums.indices) prefix[i + 1] = prefix[i] + nums[i]
    }

    fun sum(left: Int, right: Int): Int =          // both ends included
        prefix[right + 1] - prefix[left]
}
class RangeSums {
    private final int[] prefix;                    // prefix[i] = sum of the first i numbers

    RangeSums(int[] nums) {
        prefix = new int[nums.length + 1];
        for (int i = 0; i < nums.length; i++) prefix[i + 1] = prefix[i] + nums[i];
    }

    int sum(int left, int right) {                 // both ends included
        return prefix[right + 1] - prefix[left];
    }
}
class RangeSums {
  private readonly prefix: number[];             // prefix[i] = sum of the first i numbers

  constructor(nums: number[]) {
    this.prefix = [0];
    for (const x of nums) this.prefix.push(this.prefix[this.prefix.length - 1] + x);
  }

  sum(left: number, right: number): number {     // both ends included
    return this.prefix[right + 1] - this.prefix[left];
  }
}

Building the prefix array is one pass, O(n). After that, every sum(left, right) is O(1), however long the range. If an app asks for a thousand different range totals (sales between two dates, steps between two hours), that’s a thousand subtractions instead of a thousand loops.

The extra 0 at the start is what makes sum(0, right) work without a special case: prefix[right + 1] − prefix[0].

Practice: trace it

  1. Input: nums = [2, -1, 3, 5]. Start with prefix = [0].
  2. Add each number to the last total: 0 + 2 = 2, 2 + (−1) = 1, 1 + 3 = 4, 4 + 5 = 9. So prefix = [0, 2, 1, 4, 9].
  3. Sum of indexes 1 to 2 (−1 and 3): prefix[3] − prefix[1] = 4 − 2 = 2. Check by hand: −1 + 3 = 2.
  4. Sum of the whole array (0 to 3): prefix[4] − prefix[0] = 9 − 0 = 9.
Exercisemedium

Product of Array Except Self

Return an array where answer[i] is the product of every number in nums except nums[i]. Don’t use division, and do it in O(n).

[1, 2, 3, 4]       → [24, 12, 8, 6]
[-1, 1, 0, -3, 3]  → [0, 0, 9, 0, 0]
›

The product of everything except nums[i] is (the product of everything to its left) × (the product of everything to its right). That’s a prefix idea, with multiplication instead of addition.

›

Walk left to right, storing the product of everything before i in answer[i]. Then walk right to left with a running product of everything after i, and multiply it in. No extra arrays needed.

SolutionTry it yourself firstTime O(n)Space O(1) besides the output
func productExceptSelf(_ nums: [Int]) -> [Int] {
    var answer = [Int](repeating: 1, count: nums.count)
    var left = 1
    for i in 0..<nums.count {            // answer[i] = product of everything left of i
        answer[i] = left
        left *= nums[i]
    }
    var right = 1
    for i in stride(from: nums.count - 1, through: 0, by: -1) {
        answer[i] *= right               // times the product of everything right of i
        right *= nums[i]
    }
    return answer
}
fun productExceptSelf(nums: IntArray): IntArray {
    val answer = IntArray(nums.size)
    var left = 1
    for (i in nums.indices) {            // answer[i] = product of everything left of i
        answer[i] = left
        left *= nums[i]
    }
    var right = 1
    for (i in nums.indices.reversed()) {
        answer[i] *= right               // times the product of everything right of i
        right *= nums[i]
    }
    return answer
}
int[] productExceptSelf(int[] nums) {
    int[] answer = new int[nums.length];
    int left = 1;
    for (int i = 0; i < nums.length; i++) {   // answer[i] = product of everything left of i
        answer[i] = left;
        left *= nums[i];
    }
    int right = 1;
    for (int i = nums.length - 1; i >= 0; i--) {
        answer[i] *= right;                   // times the product of everything right of i
        right *= nums[i];
    }
    return answer;
}
function productExceptSelf(nums: number[]): number[] {
  const answer = new Array<number>(nums.length);
  let left = 1;
  for (let i = 0; i < nums.length; i++) {   // answer[i] = product of everything left of i
    answer[i] = left;
    left *= nums[i];
  }
  let right = 1;
  for (let i = nums.length - 1; i >= 0; i--) {
    answer[i] *= right;                     // times the product of everything right of i
    right *= nums[i];
  }
  return answer;
}

With [1, 2, 3, 4]: after the first pass answer = [1, 1, 2, 6] (products to the left); the second pass multiplies indexes 3, 2, 1 and 0 by the products to their right (1, 4, 12 and 24), giving [24, 12, 8, 6]. Zeros need no special case, which is exactly what the “divide the total product by nums[i]” idea gets wrong.

Counting subarrays with a given sum

“How many contiguous subarrays sum to k?” With only positive numbers, a sliding window works. With negatives it doesn’t: growing a window can make the sum smaller. Prefix sums fix that: a subarray i…j sums to k exactly when prefix[j + 1] − prefix[i] = k, so walking left to right, the number of subarrays ending here equals the number of earlier prefixes equal to current − k. Keep those counts in a hash map: O(n) time, O(n) space.

Spot the bugSubarray Sum Equals K

Tap the line with the bug.

func subarraySum(_ nums: [Int], _ k: Int) -> Int {
    var counts: [Int: Int] = [:]
    var prefix = 0, result = 0
    for x in nums {
        prefix += x
        result += counts[prefix - k, default: 0]
        counts[prefix, default: 0] += 1
    }
    return result
}
fun subarraySum(nums: IntArray, k: Int): Int {
    val counts = HashMap<Int, Int>()
    var prefix = 0; var result = 0
    for (x in nums) {
        prefix += x
        result += counts[prefix - k] ?: 0
        counts[prefix] = (counts[prefix] ?: 0) + 1
    }
    return result
}
int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> counts = new HashMap<>();
    int prefix = 0, result = 0;
    for (int x : nums) {
        prefix += x;
        result += counts.getOrDefault(prefix - k, 0);
        counts.merge(prefix, 1, Integer::sum);
    }
    return result;
}
function subarraySum(nums: number[], k: number): number {
  const counts = new Map<number, number>();
  let prefix = 0, result = 0;
  for (const x of nums) {
    prefix += x;
    result += counts.get(prefix - k) ?? 0;
    counts.set(prefix, (counts.get(prefix) ?? 0) + 1);
  }
  return result;
}

Deep divePrefix sums in two dimensionsInterview favorite

For a matrix, let P[r][c] be the sum of the rectangle from (0, 0) to (r − 1, c − 1). Build it with inclusion-exclusion: P[r+1][c+1] = m[r][c] + P[r][c+1] + P[r+1][c] − P[r][c]. Any rectangle (r1, c1)…(r2, c2) is then P[r2+1][c2+1] − P[r1][c2+1] − P[r2+1][c1] + P[r1][c1]: four lookups. Image processing calls this a summed-area table; it’s how box blurs and some face-detection features run in constant time per window.

  • prefix of size n + 1 with prefix[0] = 0; sum(l…r) = prefix[r + 1] − prefix[l]. O(n) build, O(1) per query. Works for XOR and counts; 2-D with inclusion-exclusion.
  • Subarray count with sum k (negatives allowed): hash map of prefix counts seeded with {0: 1}, look up prefix − k before inserting prefix. O(n) time and space.
  • Product except self: left products in one pass, right products in a reverse pass, no division.
  • Sums can overflow 32-bit ints on the JVM; use Long/long prefix arrays for large inputs.

Check this part

  1. nums = [3, 1, 4], so prefix = [0, 3, 4, 8]. What is the sum of nums[1…2]?

  2. Why does the prefix array start with an extra 0?

  3. Counting subarrays with sum k using a map of prefix counts. What must the map start with?

  4. Why can't a sliding window count subarrays with sum k when numbers can be negative?

A prefix sum array stores the running total, so the sum of any range is one subtraction: prefix[r + 1] − prefix[l]. Combined with a hash map of earlier prefixes, it counts subarrays with a given sum in O(n), even with negative numbers.

Part Not started

Working with strings

Most string problems are array problems in disguise: you walk through characters, compare them, and count them. Two habits make them easier.

Compare position by position

func longestCommonPrefix(_ words: [String]) -> String {
    guard let first = words.first else { return "" }
    let chars = words.map { Array($0) }
    for i in 0..<chars[0].count {
        for word in chars where i >= word.count || word[i] != chars[0][i] {
            return String(chars[0][..<i])
        }
    }
    return first
}
fun longestCommonPrefix(words: Array<String>): String {
    if (words.isEmpty()) return ""
    val first = words[0]
    for (i in first.indices) {
        for (word in words) {
            if (i >= word.length || word[i] != first[i]) return first.substring(0, i)
        }
    }
    return first
}
String longestCommonPrefix(String[] words) {
    if (words.length == 0) return "";
    String first = words[0];
    for (int i = 0; i < first.length(); i++) {
        for (String word : words) {
            if (i >= word.length() || word.charAt(i) != first.charAt(i)) return first.substring(0, i);
        }
    }
    return first;
}
function longestCommonPrefix(words: string[]): string {
  if (words.length === 0) return "";
  const first = words[0];
  for (let i = 0; i < first.length; i++) {
    for (const word of words) {
      if (i >= word.length || word[i] !== first[i]) return first.slice(0, i);
    }
  }
  return first;
}

["flower", "flow", "flight"] → "fl". The outer loop goes column by column (position 0, 1, 2…); the inner loop checks that column in every word. The first word that’s too short, or has a different character, ends the prefix. In the worst case it looks at every character once: O(S), where S is the total number of characters.

Build strings with a builder

In Kotlin and Java, strings can’t be changed: result += "x" creates a new string and copies everything that was already in result. In a loop, that copying adds up to O(n²). A builder collects the pieces and creates the string once at the end:

JVM strings are immutable: += in a loop copies the accumulated string each iteration, O(n²) (the compiler only optimizes concatenation within one expression). Swift’s String is a mutable value, and += on a uniquely referenced string appends in amortized O(1). JS engines use rope-like representations, so += is usually fine, though parts.join("") is the predictable choice.

var csv = ""
for row in rows {
    csv += row.joined(separator: ",")   // Swift appends in place: amortized O(1)
    csv += "\n"
}
val csv = buildString {               // a StringBuilder under the hood
    for (row in rows) {
        append(row.joinToString(","))
        append('\n')
    }
}
StringBuilder sb = new StringBuilder();
for (List<String> row : rows) {
    sb.append(String.join(",", row)).append('\n');
}
String csv = sb.toString();
const lines: string[] = [];
for (const row of rows) lines.push(row.join(","));
const csv = lines.join("\n") + "\n";

Practice: counting characters

Two words are anagrams if they use the same letters the same number of times: listen and silent. Counting letters is the usual approach. This version passed every test the author tried, and then a user found a case it gets wrong:

Spot the bugIs it an anagram?

Tap the line with the bug.

func isAnagram(_ s: String, _ t: String) -> Bool {
    var counts: [Character: Int] = [:]
    for c in s { counts[c, default: 0] += 1 }
    for c in t {
        counts[c, default: 0] -= 1
        if counts[c]! < 0 { return false }
    }
    return true
}
fun isAnagram(s: String, t: String): Boolean {
    val counts = HashMap<Char, Int>()
    for (c in s) counts[c] = (counts[c] ?: 0) + 1
    for (c in t) {
        counts[c] = (counts[c] ?: 0) - 1
        if (counts[c]!! < 0) return false
    }
    return true
}
boolean isAnagram(String s, String t) {
    Map<Character, Integer> counts = new HashMap<>();
    for (char c : s.toCharArray()) counts.merge(c, 1, Integer::sum);
    for (char c : t.toCharArray()) {
        counts.merge(c, -1, Integer::sum);
        if (counts.get(c) < 0) return false;
    }
    return true;
}
function isAnagram(s: string, t: string): boolean {
  const counts = new Map<string, number>();
  for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
  for (const c of t) {
    counts.set(c, (counts.get(c) ?? 0) - 1);
    if (counts.get(c)! < 0) return false;
  }
  return true;
}

Exerciseeasy

Compress a string

Compress runs of repeated characters into the character followed by the run length. If the result isn’t shorter than the original, return the original.

"aaabccdddd"  → "a3b1c2d4"
"abc"         → "abc"        ("a1b1c1" would be longer)
""            → ""
›

Use two indices: i at the start of a run, and j moving forward while the character is the same. The run length is j - i; then continue from j.

›

Build the result with a builder (Kotlin buildString, Java StringBuilder, TypeScript an array of parts), so the whole thing stays O(n).

SolutionTry it yourself firstTime O(n)Space O(n)
func compress(_ s: String) -> String {
    let chars = Array(s)
    var result = ""
    var i = 0
    while i < chars.count {
        var j = i
        while j < chars.count && chars[j] == chars[i] { j += 1 }
        result.append(chars[i])
        result += String(j - i)
        i = j
    }
    return result.count < s.count ? result : s
}
fun compress(s: String): String {
    val result = buildString {
        var i = 0
        while (i < s.length) {
            var j = i
            while (j < s.length && s[j] == s[i]) j++
            append(s[i]).append(j - i)
            i = j
        }
    }
    return if (result.length < s.length) result else s
}
String compress(String s) {
    StringBuilder sb = new StringBuilder();
    int i = 0;
    while (i < s.length()) {
        int j = i;
        while (j < s.length() && s.charAt(j) == s.charAt(i)) j++;
        sb.append(s.charAt(i)).append(j - i);
        i = j;
    }
    return sb.length() < s.length() ? sb.toString() : s;
}
function compress(s: string): string {
  const parts: string[] = [];
  let i = 0;
  while (i < s.length) {
    let j = i;
    while (j < s.length && s[j] === s[i]) j++;
    parts.push(s[i], String(j - i));
    i = j;
  }
  const result = parts.join("");
  return result.length < s.length ? result : s;
}

Each character is visited once by j, and i only jumps forward, so it’s O(n) even though there are two loops. That “inner loop that never goes back” shape is common; count how far each index moves in total, not how many loops there are.

What is a character?

Predict the output

What does this print? (The string is a thumbs-up emoji with a skin-tone modifier.)

let s = "👍🏽"
print(s.count)
val s = "👍🏽"
println(s.length)
String s = "👍🏽";
System.out.println(s.length());
const s = "👍🏽";
console.log(s.length);

In interviews, ask: “Can I assume ASCII?” Usually the answer is yes, and a fixed [26] or [128] counter array is expected. In production, string length limits, truncation and reversal must work on grapheme clusters, or users see broken emoji and split accented letters.

Exercisemedium

Truncate a display name

Your app shows display names in a list and must cut them to at most 20 visible characters, adding ”…” when cut. Product reports that some names end in a broken symbol, and some Hindi and emoji-heavy names are cut much shorter than others. The current code is name.substring(0, min(20, name.length)) (or the equivalent). Fix it in your language, and explain what “20 characters” should mean.

›

The broken symbol is half of a surrogate pair, or a base character separated from its modifier. You need to cut at grapheme cluster boundaries.

SolutionTry it yourself first

“20 characters” should mean 20 user-perceived characters (extended grapheme clusters), cut only at cluster boundaries:

func truncate(_ name: String, to max: Int) -> String {
    name.count <= max ? name : String(name.prefix(max)) + "…"   // Character = grapheme cluster
}
fun truncate(name: String, max: Int): String {
    val iter = BreakIterator.getCharacterInstance().apply { setText(name) }
    var end = 0
    repeat(max) {
        val next = iter.next()
        if (next == BreakIterator.DONE) return name
        end = next
    }
    return if (iter.next() == BreakIterator.DONE) name else name.substring(0, end) + "…"
}
static String truncate(String name, int max) {
    BreakIterator it = BreakIterator.getCharacterInstance();
    it.setText(name);
    int end = 0;
    for (int i = 0; i < max; i++) {
        int next = it.next();
        if (next == BreakIterator.DONE) return name;
        end = next;
    }
    return it.next() == BreakIterator.DONE ? name : name.substring(0, end) + "…";
}
function truncate(name: string, max: number): string {
  const graphemes = Array.from(
    new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(name),
    (s) => s.segment,
  );
  return graphemes.length <= max ? name : graphemes.slice(0, max).join("") + "…";
}
  • Swift gets it right by default: Character is a grapheme cluster, so prefix(20) never splits one. Its cost is that count is O(n).
  • Java and Kotlin: BreakIterator.getCharacterInstance() follows the Unicode grapheme rules for emoji sequences from Java 20; on older JDKs, use ICU4J’s BreakIterator for correct emoji handling.
  • TypeScript: Intl.Segmenter is available in all current browsers and Node.
  • Width is a separate problem: CJK characters are wider than Latin ones, so for pixel-perfect lists, let the UI truncate (lineLimit(1), maxLines = 1 with ellipsis, CSS text-overflow: ellipsis) and use this only for stored or sent limits.
  • Column-by-column comparison (LCP) and fixed-size counter arrays ([26], [128]) for ASCII; ask whether ASCII may be assumed.
  • JVM strings are immutable: += in a loop is O(n²), use StringBuilder/buildString. Swift += appends in place; JS += is usually fine, join is predictable.
  • Anagram counting needs the length check (or a final all-zero check).
  • “Character” differs: Swift count = grapheme clusters (O(n)); Kotlin/Java/TS length = UTF-16 units. Truncate and reverse on grapheme boundaries in production (prefix, BreakIterator, Intl.Segmenter).
var counts = [Int](repeating: 0, count: 26)
for b in s.utf8 { counts[Int(b - 97)] += 1 }        // ASCII lowercase
let shortName = String(name.prefix(20))            // never splits a grapheme
val counts = IntArray(26)
for (c in s) counts[c - 'a']++
val csv = buildString { rows.forEach { appendLine(it.joinToString(",")) } }
int[] counts = new int[26];
for (int i = 0; i < s.length(); i++) counts[s.charAt(i) - 'a']++;
String csv = rows.stream().map(r -> String.join(",", r)).collect(Collectors.joining("\n"));
const counts = new Array<number>(26).fill(0);
for (let i = 0; i < s.length; i++) counts[s.charCodeAt(i) - 97]++;
const csv = rows.map((r) => r.join(",")).join("\n");

Check this part

  1. What is the longest common prefix of ["flower", "flow", "flight"]?

  2. Why do Kotlin and Java code use a StringBuilder in loops?Why use buildString instead of result += piece in a long loop?Why use StringBuilder instead of result += piece in a long loop?What is the predictable way to build one big string from many pieces?

  3. What does "👍🏽".count return in Swift?What does "👍🏽".length return in Kotlin?What does "👍🏽".length() return in Java?What does "👍🏽".length return in TypeScript?

  4. An anagram check counts s up and t down and returns false when a count goes negative. What else is needed?

Compare and count characters column by column or with a fixed-size array, and build strings with a builder instead of repeated concatenation. What counts as one character depends on the language: Swift counts what users see, the others count UTF-16 units.

Key takeaways

  • Reading by index and adding at the end are cheap; inserting or removing anywhere else shifts elements and costs O(n).
  • Don’t remove elements while looping forward by index; use removeAll, removeIf or a filter.
  • Prefix sums turn every range sum into one subtraction after a single pass.
  • Compare strings position by position, count letters with a map or a 26-element array, and build long strings with a builder.
  • Know the hidden O(n) calls in your language (front removal, contains, slicing copies) and replace them with deques, read indexes or views.
  • Prefix sums plus a hash map of prefix counts (seeded with {0: 1}) handle subarray sums with negatives in O(n).
  • JVM string concatenation in loops is O(n²); Swift appends in place.
  • Character count, truncation and reversal depend on the encoding model: grapheme clusters for users, code units for most length APIs.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.