DSA Field Notes
Course map / 01 Arrays
NeetCode · DSA for Beginners · Lessons 1-4

Array Field Notes

RAM, static arrays, dynamic arrays, and stacks. Every diagram shows which cells moved and why the cost is what it is. Read the notes, then close them and rebuild the code from memory.

0Why arrays are the first data structure

Analogy

A street of identical houses, numbered 0, 4, 8, 12 ... An array is three houses in a row on that street. If you know where the first house is and how wide each house is, you can walk straight to house number 2 without knocking on any door. That is the whole trick: position becomes arithmetic.

A data structure is a way to store data efficiently inside RAM (Random Access Memory, usually just called "memory"). An array is the simplest one: an ordered collection of contiguous elements. Contiguous means "side by side in memory, with no gaps". Everything else on this page - reading, inserting, deleting, growing, stacking - follows from that one property.

RAM one long row of numbered slots; the number is the address 1 3 5 $0 $4 $8 $12 $16 $20 $24 $28 $32 $36 $40 myArray = [1, 3, 5] starts at $12 myArray[2] lives at $12 + 2 × 4 = $20 → one multiply, one add, no searching
The array is a slice of RAM. Its elements sit at neighboring addresses, so the address of any index is start + i × size. That formula is why reading myArray[i] costs the same whether the array has 3 items or 3 million.

The four lessons build on each other: lesson 1 shows what RAM looks like, lesson 2 does the four operations on a fixed-size array, lesson 3 makes the array grow, and lesson 4 restricts it to one end and calls it a stack.

1RAM: where the array lives

Bits, bytes, gigabytes

Computers only understand bits: 0s and 1s. A byte is 8 bits. A laptop may have 8 GB of RAM, which is 8 × 1024 × 1024 × 1024 bytes (about 8.6 billion slots). Each integer has a binary representation: the same number written with only 0s and 1s. Using one byte each:

format(1, "08b")   # '00000001'
format(3, "08b")   # '00000011'
format(5, "08b")   # '00000101'
bin(5)             # '0b101'  (Python drops the leading zeros)

Addresses: every value has a location

When we store [1, 3, 5] in RAM, each element is stored contiguously (continuously, in order). Each value gets an address: a distinct location, written here as $0, $4, $8. Integers commonly occupy 4 bytes (32 bits), so integer addresses are 4 bytes apart. Characters occupy 1 byte each, so a char array has addresses 1 byte apart.

INT ARRAY [1, 3, 5] one int = 4 bytes = 32 bits, so addresses are 4 apart 00000000 00000000 00000000 00000001 00000000 00000000 00000000 00000011 00000000 00000000 00000000 00000101 $0 value 1 (index 0) $4 value 3 (index 1) $8 value 5 (index 2) CHAR ARRAY ['a', 'b', 'c'] one char = 1 byte, so addresses are 1 apart a b c $0 $1 $2 $3 $4 $5 $6 $7 free RAM same rule both times: address(i) = start + i × (bytes per element) - only the step changes
The size of the type sets the step between addresses. Ints step by 4, chars step by 1. The data type does not matter as long as the address is incremented relative to the size of the type. Each 4-byte int holds its value in binary across 32 bits; the first three bytes of a small number are all zero.

The address formula in Python, tested on both arrays:

def address_of(start, i, size):
    # address of element i when each element takes `size` bytes
    return start + i * size

[address_of(0, i, 4) for i in range(3)]   # [0, 4, 8]   int array
[address_of(0, i, 1) for i in range(3)]   # [0, 1, 2]   char array
address_of(100, 2, 4)                       # 108: works from any start
Keep in mind

This is usually abstracted away from you as a developer: Python never shows you an address. It is still worth knowing, because it is what makes O(1) indexing possible, it is relevant to many algorithms and data structures, and it is extremely important in system design (caches, memory layout, why arrays beat linked lists in practice).

Lesson 1 is mostly theory. The next three lessons cover the operations, their efficiency, and where you use them.

2Static arrays: fixed size, four operations

Analogy

An egg carton with 12 cups. You choose the carton size when you buy it. You can put eggs in, take eggs out, and slide eggs around, but you cannot make the carton hold 13 eggs. When it is full, it is full.

In statically typed languages (Java, C++, C#) an array must be given a size and a type when it is created. That is a static array: its size cannot change once declared, and once it is full you cannot store more. Python and JavaScript do not have static arrays; their default array is a dynamic array (section 3). We still use Python here and simulate the fixed size with two extra numbers: length and capacity.

Keep in mind

length = how many real values are inside. capacity = the maximum the array can hold (the memory that was allocated). In a static array the capacity is fixed; the length moves between 0 and capacity.

Reading: O(1)

myArray = [1, 3, 5]
myArray[i]      # access the element at index i
myArray[0]      # 1
myArray[2]      # 5

Accessing a single element is always instant, because each index maps to an address in RAM (section 1). Regardless of the array's size, the time is the same: O(1), "constant time".

myArray[2] 1 3 5 index 0 index 1 index 2 $0 $4 $8 address = start + i × 4 = $0 + 2 × 4 = $8 one multiply and one add same cost for n = 3 or n = 3,000,000 → O(1)
No walking, no searching. The computer computes the address and jumps there. The size of the array never enters the calculation.
Common confusion

O(1) does not mean "fast". It means "does not grow with the input". A step could take 1000 operations and still be O(1), as long as that number stays 1000 when the array gets bigger. Ask "does the work grow with n?", not "is it quick?".

Traversing: O(n)

Traverse means "visit every element from first to last". Two equivalent loops:

for i in range(len(myArray)):
    print(myArray[i])

# OR
i = 0
while i < len(myArray):
    print(myArray[i])
    i += 1

The last element is at index n - 1, where n is the size. Size 3 → last index 2. Both loops stop before index 3, because range(3) gives 0, 1, 2 and i < 3 fails at 3. Traversal is O(n): the number of operations grows linearly with the size. 10 elements → 10 operations, 100 elements → 100 operations.

Loop turnii < 3?prints
10True1
21True3
32True5
43False- (exit)

Deleting from the end: O(1)

In statically typed languages every index is filled with 0 (or some default value like null or -1) when the array is created. So "removing" the last element is a soft delete: overwrite it with the default and consider the length reduced by 1. The slot still exists; we simply stop counting it.

# Remove from the last position in the array if the array
# is not empty (i.e. length is non-zero).
def removeEnd(arr, length):
    if length > 0:
        # Overwrite last element with some default value.
        # We would also consider the length to be decreased by 1.
        arr[length - 1] = 0

arr = [4, 5, 6]
removeEnd(arr, 3)     # arr is now [4, 5, 0], length is treated as 2
removeEnd(arr, 0)     # empty: nothing happens

NeetCode's version does not return the new length, so a version that does (this is what "we would consider the length decreased" means in practice):

def removeEnd2(arr, length):
    if length > 0:
        arr[length - 1] = 0
        length -= 1
    return length          # removeEnd2([4,5,6], 3) → 2

Deleting at index i: shift left, O(n)

Naively writing 0 into the middle would break the contiguous nature of the array: you would have a hole, and every later read would have to skip it. Deleting from the end keeps the array contiguous; deleting from the middle does not. The fix is to close the hole by moving everything after it one step left:

  1. Given the deletion index i.
  2. Iterate from i + 1 to the end.
  3. Shift each element 1 position to the left.
  4. (Optional) replace the last element with 0 / None and decrement the length.
# Remove value at index i before shifting elements to the left.
# Assuming i is a valid index.
def removeMiddle(arr, i, length):
    # Shift starting from i + 1 to end.
    for index in range(i + 1, length):
        arr[index - 1] = arr[index]
    # No need to 'remove' arr[i], since we already shifted

arr = [4, 5, 6, 7, 8]
removeMiddle(arr, 1, 5)    # arr is now [4, 6, 7, 8, 8]; length is treated as 4
remove index 1 0 1 2 3 4 4 5 6 7 8 before (length 5) ① ② ③ arr[index - 1] = arr[index] 4 6 7 8 8 after (length 4) 0 1 2 3 4 stale copy, ignored
The 5 is never "erased"; it is overwritten by the 6. Each orange arrow is one arr[index - 1] = arr[index]. The last slot keeps a stale 8, which the length now hides. Three moves here; deleting index 0 would need four. Worst case (target index 0) shifts every element: O(n).
Stepindexdoesarr after
start-call removeMiddle(arr, 1, 5); loop is range(2, 5)[4, 5, 6, 7, 8]
①2arr[1] = arr[2][4, 6, 6, 7, 8]
②3arr[2] = arr[3][4, 6, 7, 7, 8]
③4arr[3] = arr[4][4, 6, 7, 8, 8]
end-length is now 4; index 4 is stale[4, 6, 7, 8, 8]

Inserting at the end: O(1)

The next open position is always index length. If there is room (length is below capacity), write there.

# Insert n into arr at the next open position.
# Length is the number of 'real' values in arr, and capacity
# is the size (aka memory allocated for the fixed size array).
def insertEnd(arr, n, length, capacity):
    if length < capacity:
        arr[length] = n

arr = [4, 5, 0, 0]           # length 2, capacity 4
insertEnd(arr, 6, 2, 4)      # [4, 5, 6, 0]
insertEnd(arr, 7, 3, 4)      # [4, 5, 6, 7]
insertEnd(arr, 8, 4, 4)      # full: nothing happens

Inserting at index i: shift right, O(n)

Consider [4, 5, 6] and insert at i = 1 or i = 0. You cannot simply overwrite arr[i]: that value would be lost. Instead, shift every value from index i onward one position to the right, starting from the end, then write the new value at i.

# Insert n into index i after shifting elements to the right.
# Assuming i is a valid index and arr is not full.
def insertMiddle(arr, i, n, length):
    # Shift starting from the end to i.
    for index in range(length - 1, i - 1, -1):
        arr[index + 1] = arr[index]
    # Insert at i
    arr[i] = n

arr = [4, 5, 6, 0]           # length 3, one spare slot
insertMiddle(arr, 1, 8, 3)   # [4, 8, 5, 6]
arr = [4, 5, 6, 0]
insertMiddle(arr, 0, 8, 3)   # [8, 4, 5, 6]  worst case: every element moved
Stepindexdoesarr after
start-insertMiddle([4,5,6,_], 1, 8, 3); loop is range(2, 0, -1) → 2, 1[4, 5, 6, _]
①2arr[3] = arr[2][4, 5, 6, 6]
②1arr[2] = arr[1][4, 5, 5, 6]
③-arr[1] = 8[4, 8, 5, 6]

NeetCode's example goes one step further: insert 8 at index 1 into [4, 5, 6] when there is no spare capacity. The shifting still happens before the insert, so no value is overwritten by the 8, but the last element 6 has nowhere to go. It falls off the end and is lost.

insert 8 at index 1 0 1 2 4 5 6 before capacity 3, length 3: full (dashed box) ② ① 6 → index 3: no such slot 4 8 5 6 after [4, 8, 5] - the 6 is gone 0 1 2 ③ arr[1] = 8 lost
Shift from the end, then insert. Going right to left is what keeps the 5 from being overwritten before it is copied. With one spare slot the result is [4, 8, 5, 6]; with no spare slot the 6 drops off, which is why a static array must check capacity before inserting.
Why the loop runs backwards

If you shift from the left (arr[2] = arr[1] first), the 6 at index 2 is overwritten before anyone copied it, and you end with [4, 5, 5, 5]. Shifting from the right copies each value into an empty (or already-copied) slot. Same rule as moving boxes down a hallway: move the last box first.

In real Python

Python's list does the shifting for you, but the cost is the same. Know which methods hide an O(n) shift:

lst = [4, 5, 6]
lst.insert(1, 8)    # [4, 8, 5, 6]   shift right: O(n)
lst.pop(1)          # [4, 5, 6]      shift left:  O(n)
lst.pop()           # [4, 5]         end: O(1)
del lst[0]          # [5]            shift left:  O(n)
lst.append(9)       # [5, 9]         end: O(1)*  (star explained in section 3)

Time complexity of a static array

OperationBig-ONotes
ReadingO(1)index → address, one jump
InsertionO(n)*O(1) if inserting at the end
DeletionO(n)*O(1) if deleting at the end

These operations are critical for many interview problems. The key to a lot of them is doing insert-middle and delete-middle efficiently, usually by avoiding the shift entirely (see the two-pointer pattern in section 5).

3Dynamic arrays: the array that grows

Analogy

You rent a storage unit. When it fills up, you rent a unit twice the size, carry every box over, and return the old key. Moving day is expensive, but it happens rarely, and each time it happens you get twice as long before the next one.

A dynamic array grows as elements are added. It is much more common than a static array, and it is the default array in Python and JavaScript: no size needed when you create it. Some languages assign a default starting size (Java 10, C# 4) and resize automatically at runtime. Under the hood a dynamic array is a static array plus a rule for what to do when it is full.

Insertion at the end: pushback

# Insert n in the last position of the array
def pushback(self, n):
    if self.length == self.capacity:
        self.resize()
    # insert at next empty position
    self.arr[self.length] = n
    self.length += 1

Resize: copy into a bigger static array

Resizing means allocating a new static array that is double the size of the original (size 3 → size 6), copying every value across, and pointing self.arr at the new one. After the copy, the first static array is deallocated (its memory is given back).

def resize(self):
    # Create new array of double capacity
    self.capacity = 2 * self.capacity
    newArr = [0] * self.capacity
    # Copy elements to newArr
    for i in range(self.length):
        newArr[i] = self.arr[i]
    self.arr = newArr
arr (capacity 3, length 3 → full) 1 3 5 0 1 2 pushback(7): length == capacity → resize() ③ after the copy, this array is deallocated ① copy 3 values (O(n)) 1 3 5 7 0 0 newArr (capacity 6, length 4) 0 1 2 3 4 5 ② arr[3] = 7
One expensive step buys three cheap ones. The copy touches every element (O(n)), but the new array has room for 3 more pushes before the next copy. The two empty slots hold 0 by default and are hidden by length.

The full class (NeetCode's "Design Dynamic Array")

class DynamicArray:
    def __init__(self, capacity=1):
        self.capacity = capacity
        self.length = 0
        self.arr = [0] * capacity

    def get(self, i):
        return self.arr[i]

    def set(self, i, n):
        self.arr[i] = n

    # Insert n in the last position of the array
    def pushback(self, n):
        if self.length == self.capacity:
            self.resize()
        # insert at next empty position
        self.arr[self.length] = n
        self.length += 1

    def popback(self):
        if self.length > 0:
            self.length -= 1          # soft delete: the value stays, length hides it
        return self.arr[self.length]

    def resize(self):
        # Create new array of double capacity
        self.capacity = 2 * self.capacity
        newArr = [0] * self.capacity
        # Copy elements to newArr
        for i in range(self.length):
            newArr[i] = self.arr[i]
        self.arr = newArr

    def getSize(self):
        return self.length

    def getCapacity(self):
        return self.capacity

d = DynamicArray(3)
for v in [1, 3, 5]:
    d.pushback(v)              # size 3, capacity 3
d.pushback(7)                  # full → resize to 6, then insert: size 4, capacity 6

Amortized O(1): the honest average

Adding an element runs in O(1) amortized time. Amortized means "averaged over a whole sequence of operations". A single resize is O(n), but a resize is not performed on every push, so the average cost per push works out to O(1). This is only true because we double the size when we run out of space. Grow by a fixed amount (say +1 or +10) and you would resize almost every time, and the average would become O(n).

Why double? Count the operations

Start with capacity 1 and push until the array has size 8. The capacities go 1 → 2 → 4 → 8. Count every write, both the pushes and the copies during resizes:

cap 1 0 1 write (1 push) cap 2 0 1 2 writes (1 copy + 1 push) cap 4 0 1 2 3 4 writes (2 copies + 2 pushes) cap 8 0 1 2 3 4 5 6 7 8 writes (4 copies + 4 pushes) plain = copied from the previous array orange = newly pushed 1 + 2 + 4 + 8 = 15 writes for 8 pushes. Earlier rows sum to 7 < 8: the last row wins.
The last term dominates. Each row is one capacity level: the left half is copied in from the old array, the right half is new pushes. The sum of every earlier row (7) is less than the last row (8), so the total is less than 2 × 8. In general, building an array of size n takes at most 2n operations. 2n is O(n), so n pushes cost O(n), and one push costs O(n) / n = O(1) amortized.
def build_cost(n):
    # count every write (pushes + copies) while pushing n items,
    # starting from capacity 1 and doubling
    cap, length, writes = 1, 0, 0
    for _ in range(n):
        if length == cap:
            writes += length        # copy every existing element
            cap *= 2
        writes += 1                 # the push itself
        length += 1
    return writes

build_cost(8)        # 15  = 8 pushes + (1 + 2 + 4) copies
build_cost(1024)     # 2047 = 2n - 1, still under 2n
Push #capacity beforeresize?cost of this pushcapacity after
11no11
21yes: copy 11 + 1 = 22
32yes: copy 22 + 1 = 34
44no14
54yes: copy 44 + 1 = 58
6, 7, 88no1 each8
total15, average 15 / 8 < 2 per push
Asymptotic analysis

We care how the run time grows as the input grows, not the exact count. O(2n) and O(n) are the same thing (both linear), so we drop constants and coefficients: 2n → n, n/2 → n, 3n + 5 → n. That is why "at most 2n operations" is reported as O(n).

Other operations

Insert or remove in the middle of a dynamic array works exactly like the static array: shift elements, O(n). Growing does not help you there; the shift is about keeping the array contiguous, not about space.

Time complexity of a dynamic array

OperationBig-ONotes
AccessO(1)
InsertionO(1)*O(n) if in the middle (shifting); the end is amortized O(1)
DeletionO(1)*O(n) if in the middle (shifting)

In Python, list is a dynamic array. append is the pushback, pop() is the popback, and the resize happens inside the interpreter (CPython over-allocates by roughly 12.5% instead of doubling, which keeps the same amortized O(1) result).

4Stacks: a dynamic array with one door

Analogy

A stack of plates in a cafeteria. You take a plate from the top. You put a clean plate on the top. You never pull one from the middle. The last plate put on is the first plate taken off.

A stack supports a subset of the dynamic array operations: add and remove only from one end, called the top. Stacks are dynamic (they grow) and LIFO: Last In, First Out. The last element added is the first one out. There are exactly three operations: push, pop, peek. Compare with a queue (next topic), which is FIFO.

Push: append to the end

Adding to the top is the same as appending to the end of a dynamic array. O(1). Visualize the stack as a vertical array; the top pointer always moves to the item added most recently.

def push(self, n):
    # using the pushback function from dynamic arrays to add to the stack
    self.stack.append(n)

Pop: read and remove the end

Removing from the top is reading and removing the last element of the dynamic array. O(1). Check that the stack is not empty before popping, or Python raises IndexError: pop from empty list.

def pop(self):
    return self.stack.pop()

Peek: look, do not remove

Returns the top element without removing it. O(1). Some languages call it top().

def peek(self):
    return self.stack[-1]
push 1, 2, 3, 4 1 2 3 4 0 1 2 3 top bottom = index 0 top moves up on every push peek() → 4 1 2 3 4 top returns 4 nothing moves pop() → 4 1 2 3 4 top top ① returns 4 and removes it length 4 → 3
Only the top cell is ever touched. Push writes at index length, peek reads index length - 1, pop reads index length - 1 and lowers the length. No shifting, so every operation is O(1). The stack is the same array drawn vertically.

The full class

Many languages have no built-in stack type, so a dynamic array is used directly. In Python a plain list is the standard stack. A class makes the three operations explicit:

class Stack:
    def __init__(self):
        self.stack = []

    def push(self, n):
        # using the pushback function from dynamic arrays to add to the stack
        self.stack.append(n)

    def pop(self):
        return self.stack.pop()

    def peek(self):
        return self.stack[-1]

    def isEmpty(self):
        return len(self.stack) == 0

s = Stack()
for v in [1, 2, 3, 4]:
    s.push(v)                # stack is [1, 2, 3, 4], top is 4
s.peek()                     # 4, stack unchanged
s.pop()                      # 4, stack is [1, 2, 3]
s.pop()                      # 3
s.peek()                     # 2
Callreturnsstack aftertop
push(1)-[1]1
push(2)-[1, 2]2
push(3)-[1, 2, 3]3
push(4)-[1, 2, 3, 4]4
peek()4[1, 2, 3, 4]4
pop()4[1, 2, 3]3
pop()3[1, 2]2
pop() twice more, then pop()2, 1, then IndexError[]none
Check before you pop

[].pop() and [][-1] both crash. Guard with if stack: (a non-empty list is truthy) or if not s.isEmpty():. In interview code write the guard first; it is the line reviewers look for.

def safe_pop(stack):
    if not stack:          # empty list is falsy
        return None
    return stack.pop()

A stack reverses things

Because a stack removes in the reverse order of insertion, it can be used to reverse any sequence. Push every character of a string, then pop until empty: the characters come back out backwards.

def reverse_string(s):
    stack = []
    for ch in s:
        stack.append(ch)          # push: "n", "e", "e", "t"
    out = []
    while stack:
        out.append(stack.pop())   # pop:  "t", "e", "e", "n"
    return "".join(out)

reverse_string("neet")     # 'teen'
reverse_string("abc")      # 'cba'

Time complexity of a stack

OperationBig-ONotes
PushO(1)amortized, like pushback
PopO(1)*check if the stack is empty first
Peek / TopO(1)*retrieves without removing; also needs the empty check

5Four moves that solve most array problems

The lessons teach the structure. Interviews test these moves on top of it. Each one exists to avoid an O(n) shift or an O(n²) double loop.

Move 1: Two pointers (a slow writer and a fast reader)

When to use: "remove / keep / compact elements in place, O(1) extra space". L marks where the next kept value goes; R scans every element. Nothing shifts, because you overwrite as you go.

# LeetCode 26: Remove Duplicates from Sorted Array
def removeDuplicates(nums):
    if not nums:
        return 0
    L = 0                               # index of the last unique value written
    for R in range(1, len(nums)):
        if nums[R] != nums[L]:          # found a new value
            L += 1
            nums[L] = nums[R]           # write it right after the last unique
    return L + 1                        # count of unique values; nums[:L+1] is the answer

nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
removeDuplicates(nums)      # 5, and nums[:5] == [0, 1, 2, 3, 4]
L = 2 R = 5 0 1 2 1 1 2 2 3 3 4 0 1 2 3 4 5 6 7 8 9 nums[5] = 2 ≠ nums[1] = 1 → L becomes 2, nums[2] = 2. Cells 0..L are the answer.
The write pointer never passes the read pointer. So every overwrite lands on a cell R has already read, and no value is lost. One pass, O(n) time, no extra array.

The same move from both ends reverses an array in place, which is the array version of what the stack did to the string:

def reverse_in_place(arr):
    L, R = 0, len(arr) - 1
    while L < R:
        arr[L], arr[R] = arr[R], arr[L]   # Python swap, no temp variable
        L += 1
        R -= 1
    return arr

reverse_in_place([1, 3, 5])     # [5, 3, 1]

Move 2: Sliding window

When to use: "best / longest / sum of a contiguous run". Keep a window [L, R]; when R moves right, update the answer with one add and one drop instead of re-summing the whole window.

# fixed-size window: largest sum of any k neighbors
def max_sum_window(arr, k):
    window = sum(arr[:k])
    best = window
    for R in range(k, len(arr)):
        window += arr[R] - arr[R - k]   # add the new right cell, drop the old left cell
        best = max(best, window)
    return best

max_sum_window([2, 1, 5, 1, 3, 2], 3)    # 9  (5 + 1 + 3)
old window [1, 5, 1] = 7 new window [5, 1, 3] = 9 2 1 5 1 3 2 0 1 2 3 4 5 drop arr[1] add arr[4] 7 - 1 + 3 = 9. One subtract, one add per slide: O(1) per step, O(n) total.
Do not re-sum the window. The dashed box is the old window, the orange box is the new one. They share two cells, so only two numbers change.

The window can also have a variable size. LeetCode 121 keeps L at the cheapest day seen so far and slides it forward only when a cheaper day appears:

# LeetCode 121: Best Time to Buy and Sell Stock
def maxProfit(prices):
    L, best = 0, 0                      # L = cheapest day so far
    for R in range(1, len(prices)):
        if prices[R] < prices[L]:
            L = R                       # found a cheaper buy day: slide the window start
        else:
            best = max(best, prices[R] - prices[L])
    return best

maxProfit([7, 1, 5, 3, 6, 4])    # 5  (buy at 1, sell at 6)
maxProfit([7, 6, 4, 3, 1])       # 0  (never profitable)

Move 3: Prefix sum

When to use: "sum of a range, asked many times". Precompute prefix[i] = sum of the first i elements. Then any range sum is one subtraction. O(n) to build, O(1) per query.

# LeetCode 303: Range Sum Query - Immutable
class NumArray:
    def __init__(self, nums):
        self.prefix = [0]                   # prefix[i] = nums[0] + ... + nums[i-1]
        for n in nums:
            self.prefix.append(self.prefix[-1] + n)

    def sumRange(self, left, right):
        return self.prefix[right + 1] - self.prefix[left]

na = NumArray([-2, 0, 3, -5, 2, -1])
na.prefix              # [0, -2, -2, 1, -4, -2, -3]
na.sumRange(0, 2)      # 1   = prefix[3] - prefix[0]
na.sumRange(2, 5)      # -1  = prefix[6] - prefix[2]
i0123456
nums[i]-203-52-1-
prefix[i]0-2-21-4-2-3

The leading 0 is what makes sumRange(0, r) work without a special case. Always start the prefix array with it.

Move 4: A stack for matching and reversal

When to use: "does this close what was most recently opened?" (brackets, tags, undo) or "process things in reverse order". The stack holds the open items; the top is always the most recent one.

# LeetCode 20: Valid Parentheses
def isValid(s):
    stack = []
    close_to_open = {")": "(", "]": "[", "}": "{"}
    for ch in s:
        if ch in close_to_open:                     # a closing bracket
            if stack and stack[-1] == close_to_open[ch]:
                stack.pop()                         # it matches the most recent open
            else:
                return False                        # wrong type, or nothing open
        else:                                       # an opening bracket
            stack.append(ch)
    return not stack                                # valid only if everything was closed

isValid("{[]}")     # True
isValid("([)]")     # False
isValid("(")        # False: one bracket left open
Input "([)]", charopening or closing?top of stackactionstack after
(opening-push[ ( ]
[opening(push[ ( [ ]
)closing, needs ([mismatch → return False[ ( [ ]

A second stack that remembers the minimum at every level turns "what is the smallest thing in the stack?" into an O(1) peek:

# LeetCode 155: Min Stack
class MinStack:
    def __init__(self):
        self.stack = []
        self.minStack = []          # minStack[-1] is the min of everything in stack

    def push(self, val):
        self.stack.append(val)
        val = min(val, self.minStack[-1] if self.minStack else val)
        self.minStack.append(val)

    def pop(self):
        self.stack.pop()
        self.minStack.pop()         # both stacks always have the same height

    def top(self):
        return self.stack[-1]

    def getMin(self):
        return self.minStack[-1]

ms = MinStack()
ms.push(-2); ms.push(0); ms.push(-3)
ms.getMin()      # -3
ms.pop()
ms.top()         # 0
ms.getMin()      # -2

6Big-O summary

Say these out loud in interviews. Every star has a one-line reason; give the reason without being asked.

OperationStatic arrayDynamic arrayStackPython list
Read by indexO(1)O(1)-O(1)
Search by value (unsorted)O(n)O(n)-O(n) in
Insert at endO(1)O(1)*O(1) pushO(1)* append
Delete at endO(1)O(1)O(1)** popO(1) pop()
Peek at endO(1)O(1)O(1)** peekO(1) lst[-1]
Insert at index iO(n)O(n)-O(n) insert(i, x)
Delete at index iO(n)O(n)-O(n) pop(i)
Grow past capacityimpossibleO(n) per resizesame as dynamicsame as dynamic
Extra memorynoneup to 2× (empty slots)same as dynamicsame as dynamic

* amortized: a single push can cost O(n) when it triggers a resize, but n pushes cost at most 2n writes, so the average is O(1). ** O(1) only after checking the stack is not empty; an empty pop or peek is an error, not a slow operation. NeetCode writes the static-array table the other way round (Insertion O(n)*, "O(1) if at the end") - same facts, different default.

7Mistakes to watch for

Off by one at the end

The last index is n - 1, not n. arr[len(arr)] raises IndexError. Loop with range(len(arr)) or while i < len(arr), never <=.

Deleting from the middle by writing 0

That leaves a hole and breaks contiguity; every later loop has to know about it. Shift left instead, or use the two-pointer move so you never delete at all.

Shifting right from the wrong end

for index in range(i, length) copies arr[i] over arr[i+1] before arr[i+1] was saved; you end with repeated values. Start from length - 1 and go down.

Confusing length and capacity

length is how many real values you have; capacity is how many slots exist. Insert at index length, resize when length == capacity, and never read past length - 1 even though the slots exist.

Popping an empty stack

[].pop() and [][-1] raise IndexError. Write if stack: before every pop or peek. In Valid Parentheses this is the difference between a pass and a crash on input ")".

Treating pop(0) and insert(0, x) as O(1)

Both shift every element: O(n). For a queue use collections.deque. For a stack use the end of the list: append and pop().

Thinking O(1) means fast, or that O(2n) beats O(n)

O(1) means the cost does not grow with n. O(2n) and O(n) are the same class; drop constants. An interviewer who hears "O(2n)" will ask you to simplify it.

Removing from a list while looping over it

for x in arr: if bad(x): arr.remove(x) skips the element after every removal, because the shift moves it into the index you already passed. Build a new list, or use the two-pointer write index.

Growing by a constant instead of doubling

If resize adds 1 (or 10) slots, you copy on almost every push and the amortized cost becomes O(n). Doubling is what makes the sum 1 + 2 + 4 + ... stay under 2n.

Interview one-liners

  • "Array elements are contiguous in RAM, so index i is at start + i × size: reading is O(1)."
  • "O(1) means the cost does not grow with input size; it does not mean fast."
  • "Insert or delete in the middle shifts everything after it, so O(n); at the end it is O(1)."
  • "A dynamic array doubles when full. One resize is O(n), but n pushes cost at most 2n writes, so push is O(1) amortized."
  • "A stack is a dynamic array where I only touch the end: push, pop, peek are all O(1), and I check for empty before pop."
  • "For in-place removal I use two pointers, for range sums a prefix array, for matching or reversal a stack."

8Practice, in this order

Notes do not make you good at this. Solving does. Do these with no notes open, then compare with the templates above. The first row is NeetCode's own exercise and has no LeetCode number.

#ProblemMove it trainsLevel
-Design Dynamic Array (NeetCode)pushback, popback, resize by doublingEasy
1929Concatenation of Arrayindex math, build a new arrayEasy
26Remove Duplicates from Sorted Arraytwo pointers, write indexEasy
27Remove Elementtwo pointers, write indexEasy
283Move Zeroestwo pointers, swap instead of shiftEasy
217Contains Duplicatetraversal; O(n²) vs a setEasy
121Best Time to Buy and Sell Stocksliding window, variable sizeEasy
303Range Sum Query - Immutableprefix sumEasy
20Valid Parenthesesstack for matchingEasy
682Baseball Gamestack you read back intoEasy
155Min Stacktwo stacks in stepMedium
150Evaluate Reverse Polish Notationstack of operandsMedium

Your next step today: LeetCode 26. Write it from memory with L and R, run it on [0,0,1,1,1,2,2,3,3,4], then reread the two-pointer diagram and compare.

9Self-check

Answer out loud before opening. If you cannot answer in one breath, reread that section.

Q1Why do the addresses of an int array go $0, $4, $8, but a char array goes $0, $1, $2?

Elements are contiguous, and the address step equals the size of one element. An int is 4 bytes, a char is 1 byte. The type sets the step; the rule start + i × size is the same.

Q2Why is myArray[i] O(1) no matter how big the array is?

The index is converted to an address with one multiply and one add. No element is visited on the way. The size of the array does not appear in that calculation.

Q3Does O(1) mean the operation is fast?

No. It means the number of operations does not grow with the input size. A step that always takes 1000 operations is O(1). A step that takes n operations is O(n) even when n is 3.

Q4What index holds the last element of an array of size n, and what happens if you read index n?

n - 1. Reading index n is out of range: IndexError in Python, garbage or a crash in C.

Q5Trace it: arr = [4, 5, 6, 7, 8], call removeMiddle(arr, 1, 5). What is arr after each loop turn?

Loop is range(2, 5). index 2: arr[1] = arr[2] → [4, 6, 6, 7, 8]. index 3: arr[2] = arr[3] → [4, 6, 7, 7, 8]. index 4: arr[3] = arr[4] → [4, 6, 7, 8, 8]. The length is now considered 4; the final 8 is stale.

Q6Trace it: arr = [4, 5, 6, _] (length 3, capacity 4), call insertMiddle(arr, 1, 8, 3). What happens step by step, and what if the capacity were only 3?

Loop is range(2, 0, -1) → index 2, then 1. index 2: arr[3] = arr[2] → [4, 5, 6, 6]. index 1: arr[2] = arr[1] → [4, 5, 5, 6]. Then arr[1] = 8 → [4, 8, 5, 6]. With capacity 3 there is no index 3, so the 6 has nowhere to go and is lost: [4, 8, 5].

Q7Why does insertMiddle loop from the end toward i instead of from i toward the end?

Each shift writes into the cell to the right. Going right to left, that cell is empty or already copied. Going left to right, you overwrite a value before it has been copied and end with duplicates.

Q8What is amortized time, and why is pushback O(1) amortized?

Amortized means averaged over a sequence of operations. A resize costs O(n), but with doubling it happens only at sizes 1, 2, 4, 8, ... so n pushes cost at most 2n writes in total. 2n / n = 2 per push, a constant: O(1).

Q9Start with capacity 1 and push 8 items. List the capacities and the total number of writes.

Capacities 1 → 2 → 4 → 8. Writes: 8 pushes + copies of 1 + 2 + 4 = 15, which is also 1 + 2 + 4 + 8. The last term (8) is bigger than everything before it (7), so the total is under 2 × 8.

Q10Why are O(2n) and O(n) "the same"?

Asymptotic analysis asks how the cost grows as n grows. Both grow linearly; doubling n doubles both. Constant factors and coefficients are dropped, so 2n, n/2 and 3n + 5 are all O(n).

Q11Name the three stack operations, their cost, and the one check you must do first.

push (append to the end), pop (read and remove the end), peek (read the end without removing). All O(1). Before pop or peek, check that the stack is not empty.

Q12Trace it: push 1, 2, 3, 4 onto a stack, then peek(), pop(), pop(), peek(). What is returned each time and what is the stack at the end?

After the pushes the stack is [1, 2, 3, 4], top is 4. peek → 4 (unchanged). pop → 4, stack [1, 2, 3]. pop → 3, stack [1, 2]. peek → 2. Final stack [1, 2].

Q13Why can a stack reverse a string?

A stack is LIFO: the last character pushed is the first popped. Push all characters in order, then pop until empty, and they come out in reverse order.