← DSA Fundamentals
Foundations

Arrays

Watch: "2. Data Structures and Dynamic Arrays" — MIT OpenCourseWare

An array is a block of memory slots of the same size, laid out back to back, addressed by a starting position plus an offset. That contiguity is the entire reason arrays behave the way they do — every property that seems arbitrary (O(1) access, O(n) insertion, resizing surprises) falls directly out of "the elements are physically next to each other in memory." Interviewers care about arrays first because almost every other structure is compared against them: a hash table trades array-style access for a hash function, a linked list trades array-style contiguity for cheap insertion, and a heap is literally implemented as an array with implicit tree structure. Understanding exactly why an array does what it does — a database's row storage, a video buffer, Python's list, Java's ArrayList — is the baseline every other structure gets measured against.

Deep Dive: Contiguous Memory and Address Arithmetic

Array of 6 elements laid out back to back from address 1000502182139435100010081016102410321040addri1000 + 5 × 8 = 1040
Every slot is 8 bytes, back to back. The address of arr[i] is base + i × size, one multiply and one add, so access is O(1) for any i.
arr[5]: computed, not searched
Code, line by line
def address_of(base, index, elem_size):
    return base + index * elem_size
  1. base is where arr[0] lives (1000 here). The array only needs to remember this one number.
  2. index * elem_size is how many bytes to skip. For arr[5] with 8-byte elements that is 40 bytes.
  3. Adding the two gives the exact address with no loop, which is why indexing never depends on the array's length.

Every element in an array has the same fixed size (say 8 bytes for a 64-bit integer), and the array is one unbroken run of memory. That means the address of any element is computable directly: address = base_address + index * element_size. No searching, no following pointers — just one multiplication and one addition, both constant-time operations regardless of how big the array is or which index you ask for. That's the entire mechanism behind "array access is O(1)": it's not that the computer is fast, it's that finding the address doesn't require looking at any other element first.

Compare this to a structure where elements are scattered across memory and linked by pointers (a linked list): there's no formula that gets you from "I want index 5" to an address, because the fifth node could be anywhere. You have to start at the head and follow four next pointers to get there, which is O(n) in the worst case. Contiguity is the one property that makes the address formula possible, and it's also the one property a linked list deliberately gives up.

Deep Dive: 2D Arrays and Row-Major Layout

3 by 4 matrix stored as one flat 12-slot array, row after row0,00,10,20,3r01,01,11,21,3r12,02,12,22,3r2matrix[row][col]one flat block in memory012345678910112 × 4 + 1 = 9
Row 0 is stored first, then row 1, then row 2. matrix[2][1] lands at flat index row × cols + col = 9.
row 0row 1row 2
Code, line by line
flat_index = row * num_cols + col
address    = base + flat_index * elem_size
  1. row * num_cols skips over every full row that comes before this one (2 rows × 4 = 8 slots for row 2).
  2. + col moves along inside the row (1 more slot), giving flat index 9.
  3. From there it is the same 1D formula. This is why looping row by row is fast: you walk memory in order.

A "2D array" is a convenient fiction — underneath, memory is one-dimensional, so a 2D array is stored as a single flat run with a formula that maps (row, col) to a flat position. Row-major order (used by C, C++, Python's NumPy by default, and most languages) lays a matrix out row by row: all of row 0's elements, then all of row 1's, and so on. That's why matrix[1][0] above lands at flat index 4 — it's the start of row 1, which is right after row 0's 4 elements.

This has a real performance consequence, not just a bookkeeping one: iterating a row-major matrix row-by-row visits consecutive memory addresses, which is cache-friendly (the CPU pulls in a chunk of nearby memory on every cache miss, and row-by-row access keeps reusing that chunk). Iterating column-by-column jumps num_cols * elem_size bytes on every step, which can miss the cache almost every time on a large matrix. Same algorithm, same Big-O, genuinely different real-world speed — this is the concrete case where "two O(n) algorithms can have very different real-world speed" actually shows up.

Deep Dive: Why Insertion and Deletion Are O(n) — the Shift

Inserting 99 at index 1 shifts four elements rightbefore52819after599281999 written into the gap
To open a gap at index 1, the four elements after it each move one slot right, starting from the end. That is 4 shifts for 5 elements, O(n) in general.
must shiftinserted
Code, line by line
def insert_at(arr, pos, value):
    arr.append(None)
    shifts = 0
    for i in range(len(arr) - 1, pos, -1):
        arr[i] = arr[i - 1]
        shifts += 1
    arr[pos] = value
    return shifts
  1. arr.append(None) grows the array by one empty slot at the end so there is room to shift into.
  2. The loop runs backwards from the last index down to pos + 1. Going backwards means each value is copied into a slot that has already been emptied, so nothing is overwritten.
  3. arr[i] = arr[i - 1] moves one element one slot right. This line runs n − pos times, which is where the O(n) comes from.
  4. arr[pos] = value fills the gap that is now open at pos.
  5. Deleting is the mirror image: loop forwards from pos and copy arr[i + 1] into arr[i].

Contiguity, the same property that makes access O(1), is exactly why insertion in the middle is expensive: there is no gap to drop a new element into. To insert 99 at index 1, every element from index 1 onward has to slide one slot to the right first (9→1, then 8, 2, 5 keep shifting up) to open a hole — that's 4 element moves for a 5-element array, i.e. up to n-1 moves in the worst case (inserting at index 0). Deletion is the mirror image: removing an element leaves a hole, and everything after it has to slide left by one to close the gap, again up to n-1 moves.

The worst case for both is inserting or deleting at index 0 (shift everything — O(n)); the best case is inserting or deleting at the very end, where there's nothing after it to shift, so it's O(1) — you're just writing to (or vacating) the last slot directly. This is why "insert at the end" and "insert somewhere in the middle" have completely different complexity even though they're "the same operation" conceptually: the cost is entirely about how many elements sit after the target position, not about the operation itself.

Deep Dive: Dynamic Arrays and Amortized O(1) Append

Capacity doubles 1, 2, 4, 8, 16, 32 as elements are appendedcap 1cap 2copied 1cap 4copied 2cap 8copied 4cap 16copied 8cap 32copied 16
Each row is the moment a resize happens: the full old block is copied into a block twice the size, then the new element (blue) goes in. 17 appends cost 1+2+4+8+16 = 31 copies, under 2 per append.
copied from old blockthe append that triggered it
Code, line by line
def append(self, value):
    if self.size == self.capacity:
        self.capacity *= 2
        new_data = [None] * self.capacity
        for i in range(self.size):
            new_data[i] = self.data[i]
        self.data = new_data
    self.data[self.size] = value
    self.size += 1
  1. size == capacity means every slot is used. Only then do we pay for a resize.
  2. capacity *= 2: grow by doubling, not by +1. Doubling is what makes resizes rare enough to average out.
  3. Allocate a new block and copy all size elements over. This one append is O(n).
  4. self.data[self.size] = value and size += 1 is the normal O(1) path taken by almost every append.
  5. Because copies total 1 + 2 + 4 + … < 2n, the average cost per append is O(1): amortized, not worst case.

A static array has a fixed size decided at creation — appending past that size is simply impossible without a new array. A dynamic array (Python's list, Java's ArrayList, C++'s vector) fakes "growable" by allocating a fixed-size backing array under the hood and, when it fills up, allocating a new array (typically double the size), copying every existing element over, and freeing the old one. That resize is a real O(n) operation — the copy touches every element — so any single append can be expensive.

The reason it's still called "O(1) amortized" (not O(1) worst case) is that expensive resizes get rarer exactly as fast as they get bigger. Doubling means you do a size-1 copy, then a size-2 copy, then size-4, size-8... — a geometric series that sums to roughly 2n for n total appends, so the *average* cost per append stays bounded by a constant even though occasional individual appends cost O(n). The demo confirms this concretely: 17 appends triggered 5 resizes (doubling 1→2→4→8→16→32) totaling 31 element-copies, averaging 1.82 copies per append — bounded, not growing with n. If the array grew by a fixed amount instead of doubling (say +1 each time), you'd get a resize on every single append, making it O(n) per append and O(n²) for n appends total — doubling specifically is what makes the amortized analysis work.

Deep Dive: Cache-Friendliness — Arrays vs Pointer-Chasing

Array elements are neighbours in memory; linked nodes are scatteredarray528193linked52819
Reading an array moves steadily through memory, so the CPU loads the next elements in the same cache line. Linked nodes sit wherever they were allocated, so each hop can be a cache miss.
sequential readpointer chase

Big-O treats "visit every element" as O(n) whether it's an array or a linked list, but real hardware doesn't treat them the same. A CPU doesn't fetch one byte at a time from RAM — it pulls in a cache line (a chunk of nearby bytes, often 64 bytes) and keeps it close to the core. Scanning an array sequentially means each cache-line fetch serves many subsequent accesses for free, because the next several elements are already sitting in that fetched line. A linked list's nodes are typically scattered wherever the allocator put them, so each next pointer jump is likely to land outside the current cache line, forcing a fresh (slow) memory fetch almost every step.

This doesn't change either structure's Big-O — both are O(n) to traverse — but it's the honest answer to "why do arrays feel faster in practice for sequential access": fewer cache misses, not a different complexity class. This is worth being precise about in an interview: don't claim a specific multiplier ("arrays are 10x faster") without having measured it on the actual hardware and data size in question — the real effect is real but its magnitude depends on element size, list length, and the memory allocator, not a fixed constant.

Key Pointers

How It Works

  1. To access arr[i]: compute base_address + i * element_size and read directly — no traversal.
  2. To insert at position k: make room (grow the backing store if needed), shift every element from the end down to position k one slot to the right, then write the new value at k.
  3. To delete at position k: shift every element after k one slot to the left, overwriting the deleted slot, then shrink the logical size by one.
  4. To append to a dynamic array: if there's spare capacity, write directly at the next free slot (O(1)); if full, allocate a new backing array (commonly double the capacity), copy every existing element across, free the old array, then write the new element.
  5. To access matrix[r][c] in a row-major 2D array: compute the flat index r * num_cols + c, then apply the normal 1D address formula.
  6. To reverse an array in place: swap the first and last elements, then the second and second-to-last, moving two pointers toward the middle until they meet or cross.
  7. To find the second-largest element in one pass: track the two largest values seen so far, updating both as you scan — no need to sort or make a second pass.

Try It Yourself

Four small demos: address arithmetic, the insert/delete shift cost, a hand-rolled dynamic array tracking real resize and copy counts, and one-pass second-largest plus in-place reversal.

What the code does
def address_of(base, index, elem_size):
    return base + index * elem_size

base, elem_size = 1000, 8
for i in [0, 1, 2, 5]:
    print(f"address of arr[{i}] = {base} + {i}*{elem_size} = {address_of(base, i, elem_size)}")

# Output:
# address of arr[0] = 1000 + 0*8 = 1000
# address of arr[1] = 1000 + 1*8 = 1008
# address of arr[2] = 1000 + 2*8 = 1016
# address of arr[5] = 1000 + 5*8 = 1040

def insert_at(arr, pos, value):
    arr.append(None)
    shifts = 0
    for i in range(len(arr) - 1, pos, -1):
        arr[i] = arr[i - 1]
        shifts += 1
    arr[pos] = value
    return shifts

arr = [5, 2, 8, 1, 9]
shifts = insert_at(arr, 1, 99)
print("after insert 99 at index 1:", arr, "shifts performed:", shifts)
# after insert 99 at index 1: [5, 99, 2, 8, 1, 9] shifts performed: 4

class DynamicArray:
    def __init__(self):
        self.capacity = 1
        self.size = 0
        self.data = [None] * self.capacity
        self.resizes = 0
        self.total_copies = 0

    def append(self, value):
        if self.size == self.capacity:
            self.capacity *= 2
            new_data = [None] * self.capacity
            for i in range(self.size):
                new_data[i] = self.data[i]
                self.total_copies += 1
            self.data = new_data
            self.resizes += 1
        self.data[self.size] = value
        self.size += 1

d = DynamicArray()
for i in range(17):
    d.append(i)
print(f"after 17 appends: size={d.size}, capacity={d.capacity}, "
      f"resizes={d.resizes}, total element-copies={d.total_copies}")
print(f"average copies per append = {d.total_copies/17:.2f}")

# Output:
# after 17 appends: size=17, capacity=32, resizes=5, total element-copies=31
# average copies per append = 1.82

def second_largest(arr):
    first = second = float("-inf")
    for x in arr:
        if x > first:
            second = first
            first = x
        elif first > x > second:
            second = x
    return second

arr2 = [5, 2, 8, 1, 9, 3]
print(f"arr={arr2} -> second largest = {second_largest(arr2)}")
# arr=[5, 2, 8, 1, 9, 3] -> second largest = 8
  1. address_of: the same base + index × size formula from the first diagram, printed for a few indexes.
  2. insert_at: grows the list by one, shifts everything after pos right (backwards loop), then writes the value. It returns the shift count so you can see the O(n) cost: 4 shifts for 5 elements.
  3. DynamicArray.append: doubles capacity when full and counts every copied element. After 17 appends: 5 resizes, 31 copies, about 1.82 copies per append.
  4. second_largest: one pass keeping two variables. A value bigger than first pushes the old first down to second; a value strictly between the two just replaces second. O(n) time, O(1) extra space, no sorting.

Common Mistakes & Interview Traps

Interview Q&A

Q: Why is array access O(1) but insertion O(n)?

A: Access only needs one computation — base + index * element_size — because contiguous memory makes every element's address directly derivable. Insertion at an arbitrary position has to physically slide every element after that position over by one slot to open a gap, which touches up to n-1 elements in the worst case. Same underlying property (contiguity), opposite consequence for these two operations.

Q: What happens when a dynamic array (like Python's list or Java's ArrayList) runs out of space?

A: It allocates a new, larger backing array — typically double the old capacity — copies every existing element into it, and then proceeds with the append. The old backing array is discarded. This single resizing append is a real O(n) operation because the copy touches every element currently in the array.

Q: Why is that append still called O(1) amortized?

A: Because resizes get exponentially rarer as the array grows — doubling means the total copying work across n appends sums to roughly 2n (a geometric series: 1+2+4+...+n/2 ≈ n), so the *average* cost per append is a small constant even though a handful of individual appends cost O(n). The 17-append demo shows this directly: 5 resizes, 31 total copies, 1.82 copies per append on average — bounded, not growing with n.

Q: How would you find the second-largest element in an array in one pass?

A: Track two running values, the largest seen so far and the second-largest seen so far, updating both in a single scan: if the current element beats the largest, the old largest becomes the new second-largest before updating; if it beats only the second-largest (and isn't equal to the largest), update just that. This is O(n) time and O(1) auxiliary space, versus O(n log n) for sorting first.

Q: Why are arrays generally faster in practice than linked lists for sequential access, even though both can be O(n) to traverse?

A: Big-O counts operations, not cache misses. An array's elements sit in consecutive memory, so scanning it reuses CPU cache lines fetched for earlier elements; a linked list's nodes are usually scattered across memory, so each next jump is likely to miss the cache and trigger a fresh, slower memory fetch. The asymptotic complexity is identical; the constant factor hidden inside that O(n) is not.

Q: What's the time complexity of reversing an array in place?

A: O(n) time and O(1) auxiliary space, using two pointers starting at each end, swapping and moving toward the middle until they meet or cross — that's n/2 swaps for n elements, which is still O(n) after dropping the constant factor. A recursive version would do the same number of swaps but cost O(n) auxiliary space for the call stack instead.

Follow-up: If doubling capacity gives amortized O(1) append, why not just grow by a large fixed amount like +1000 each time instead?

A: A fixed increment still causes a resize every ~1000 appends regardless of current size, so for large n the *number* of resizes stays proportional to n (n/1000 resizes), and each later resize copies a huge, ever-growing array — the total copying work becomes O(n²) for n appends, not O(n). Doubling ties the resize frequency to the current size, which is what keeps the geometric series — and the amortized bound — bounded by O(n) total.

Follow-up: Does shrinking a dynamic array (e.g. after many deletions) need the same doubling trick in reverse?

A: Yes — naively shrinking the backing array back to fit every time you delete causes the same problem in reverse (repeated allocation/copy near a size boundary), and naively never shrinking wastes memory forever. Real implementations typically only shrink when usage drops to roughly a quarter of capacity, halving capacity at that point, which avoids the "thrashing" case of repeatedly growing and shrinking across the same boundary (append, delete, append, delete right at the threshold).

Follow-up: You said row-major access is more cache-friendly — does that mean you should always iterate matrices row by row?

A: For row-major-stored matrices (the default in most languages), yes — row-by-row iteration matches memory order and stays cache-friendly, while column-by-column jumps across rows on every step. Some languages (Fortran, and MATLAB by default) use column-major order instead, in which case the cache-friendly direction flips — the rule isn't "rows are always faster," it's "iterate in the same order the memory is actually laid out."

Trade-offs

Arrays are the wrong choice when a workload does frequent insertions or deletions away from the end — every such operation pays an O(n) shift, and a structure built for cheap mid-sequence mutation (a linked list, or a balanced tree for sorted mutable data) avoids that cost entirely, at the price of losing O(1) indexed access and cache-friendly scanning. Arrays are also a poor fit when the final size is wildly unpredictable and memory is tight, since a dynamic array can hold up to roughly double the elements it needs in unused capacity right after a resize. Use an array (or dynamic array) when access pattern is dominated by index lookups or end-appends and mutation in the middle is rare; reach for a linked structure, deque, or tree when mid-sequence insertion/deletion dominates, and reach for a hash table entirely when lookup needs to be by key rather than by position.

PracticeSolve related problems in the 150 →