Skip to content

Arrays and Dynamic Arrays

Welcome to Phase 3. Every data structure you’ll meet from here on is either built on top of an array, or exists specifically to fix one of its limitations. Get comfortable with arrays first and half of interview prep gets easier immediately.

What you’ll learn

  • Fixed-size array vs dynamic array, and why Python’s listlist is the latter.
  • Why list.appendlist.append is amortized O(1)O(1) thanks to geometric over-allocation.
  • The real complexity of index, append, insert, and pop at different positions.
  • The two-pointer technique for in-place array work.
  • Prefix sums for O(1) range-sum queries after one O(n) pre-pass.
  • LeetCode-style problems to drill the pattern.

Fixed array vs dynamic array

A fixed-size array (like a C array) reserves one contiguous block of memory for exactly nn elements, decided up front. Growing it means allocating a whole new block and copying everything over — there’s no “just add one more”.

A dynamic array hides that pain behind a friendly appendappend. Python’s built-in listlist is a dynamic array: contiguous storage under the hood, but it over-allocates — when it runs out of room it grabs a bigger block (roughly 1.125x the old capacity once the list is large) and copies the existing pointers across, once. Because that expensive copy happens less and less often as the list grows, the average cost per appendappend stays O(1)O(1) — this is called amortized analysis.

array_growth.py
import sys
 
arr = []
prev_capacity_hint = sys.getsizeof(arr)
growths = 0
 
for i in range(25):
    arr.append(i)
    size = sys.getsizeof(arr)
    if size != prev_capacity_hint:
        growths += 1
        print(f"n={i + 1:<3} underlying buffer resized (event #{growths})")
        prev_capacity_hint = size
 
print(f"\n25 appends, only {growths} real reallocations — the rest were free.")
array_growth.py
import sys
 
arr = []
prev_capacity_hint = sys.getsizeof(arr)
growths = 0
 
for i in range(25):
    arr.append(i)
    size = sys.getsizeof(arr)
    if size != prev_capacity_hint:
        growths += 1
        print(f"n={i + 1:<3} underlying buffer resized (event #{growths})")
        prev_capacity_hint = size
 
print(f"\n25 appends, only {growths} real reallocations — the rest were free.")

Notice the buffer only resizes a handful of times across 25 appends, not once per element. Each resize is O(n)O(n), but it happens on a shrinking fraction of the appends, so the total cost for nn appends is O(n)O(n), which averages out to O(1)O(1) per append.

Watch a dynamic array double

When the backing block fills up, CPython allocates a bigger one and copies every existing element across before the new item is written in:

sketch Dynamic array growth on append p5.js
The array starts with room for 4 items. Once full, a new, bigger block is allocated and every old element is copied across (shown in amber) before the new element (green) is added.

Complexity of common list operations

OperationExampleComplexity
Index / updatearr[i]arr[i], arr[i] = xarr[i] = xO(1)O(1)
Append (end)arr.append(x)arr.append(x)O(1)O(1) amortized
Pop (end)arr.pop()arr.pop()O(1)O(1) amortized
Insert at front/middlearr.insert(0, x)arr.insert(0, x)O(n)O(n)
Pop from front/middlearr.pop(0)arr.pop(0)O(n)O(n)
Search / membershipx in arrx in arrO(n)O(n)
Lengthlen(arr)len(arr)O(1)O(1)

Two pointers

The two-pointer technique uses two indices moving through the array — usually from both ends inward, or both from the same side at different speeds — to solve problems in one pass instead of a nested loop.

two_pointers.py
def reverse_in_place(arr):
    left, right = 0, len(arr) - 1
    while left < right:
        arr[left], arr[right] = arr[right], arr[left]
        left += 1
        right -= 1
    return arr
 
 
def is_palindrome_array(arr):
    left, right = 0, len(arr) - 1
    while left < right:
        if arr[left] != arr[right]:
            return False
        left += 1
        right -= 1
    return True
 
 
print(reverse_in_place([1, 2, 3, 4, 5]))
print(is_palindrome_array([1, 2, 3, 2, 1]))
print(is_palindrome_array([1, 2, 3]))
two_pointers.py
def reverse_in_place(arr):
    left, right = 0, len(arr) - 1
    while left < right:
        arr[left], arr[right] = arr[right], arr[left]
        left += 1
        right -= 1
    return arr
 
 
def is_palindrome_array(arr):
    left, right = 0, len(arr) - 1
    while left < right:
        if arr[left] != arr[right]:
            return False
        left += 1
        right -= 1
    return True
 
 
print(reverse_in_place([1, 2, 3, 4, 5]))
print(is_palindrome_array([1, 2, 3, 2, 1]))
print(is_palindrome_array([1, 2, 3]))

Both functions do a single O(n)O(n) pass with O(1)O(1) extra space — no temporary copy of the array needed.

Prefix sums

If you need the sum of many sub-ranges of the same array, don’t re-sum each range from scratch (that’s O(n)O(n) per query). Precompute a prefix sum array once, then answer every range-sum query in O(1)O(1).

prefix_sums.py
def build_prefix(arr):
    prefix = [0] * (len(arr) + 1)
    for i, x in enumerate(arr):
        prefix[i + 1] = prefix[i] + x
    return prefix
 
 
def range_sum(prefix, left, right):
    # inclusive sum of arr[left..right], O(1) after the O(n) pre-pass
    return prefix[right + 1] - prefix[left]
 
 
nums = [2, 4, 6, 8, 10, 12]
prefix = build_prefix(nums)
 
print("sum(1..3):", range_sum(prefix, 1, 3))   # 4 + 6 + 8
print("sum(0..5):", range_sum(prefix, 0, 5))   # whole array
prefix_sums.py
def build_prefix(arr):
    prefix = [0] * (len(arr) + 1)
    for i, x in enumerate(arr):
        prefix[i + 1] = prefix[i] + x
    return prefix
 
 
def range_sum(prefix, left, right):
    # inclusive sum of arr[left..right], O(1) after the O(n) pre-pass
    return prefix[right + 1] - prefix[left]
 
 
nums = [2, 4, 6, 8, 10, 12]
prefix = build_prefix(nums)
 
print("sum(1..3):", range_sum(prefix, 1, 3))   # 4 + 6 + 8
print("sum(0..5):", range_sum(prefix, 0, 5))   # whole array
prefix[i]=k=0i1arr[k]sum(l,r)=prefix[r+1]prefix[l].\text{prefix}[i] = \sum_{k=0}^{i-1} \text{arr}[k] \quad\Rightarrow\quad \text{sum}(l, r) = \text{prefix}[r+1] - \text{prefix}[l].

One O(n)O(n) pass builds the prefix array; every query after that is O(1)O(1).

LeetCode problem set

#ProblemDifficultyThe twist
1Two SumEasyThe canonical array-plus-hash-map warm-up: one pass, check the complement before inserting
121Best Time to Buy and Sell StockEasyA single scan tracking the running minimum — no need to store anything else
283Move ZeroesEasyIn-place partition with a write pointer; preserves order and never allocates

Practice — real LeetCode problems

Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.

LC 121 — Best Time to Buy and Sell Stock · Easy

Problem. Given prices[i]prices[i] for day ii, choose one day to buy and a later day to sell to maximise profit. Return the maximum profit, or 00 if none is possible.

Constraints. 1 <= len(prices) <= 10^51 <= len(prices) <= 10^5, 0 <= prices[i] <= 10^40 <= prices[i] <= 10^4.

Examples. [7,1,5,3,6,4][7,1,5,3,6,4] gives 55 (buy at 1, sell at 6) · [7,6,4,3,1][7,6,4,3,1] gives 00 (never profitable)

Editorial

For each day, the best possible sale is that price minus the cheapest price seen before it. Tracking a running minimum makes that O(1)O(1) per day.

Time O(n)O(n). Space O(1)O(1).

[7,6,4,3,1][7,6,4,3,1] returning 00 matters: prices only fall, so no transaction is made at all. Initialising best = 0best = 0 rather than to a negative sentinel is what encodes “you may decline to trade”.

Note this is also Kadane’s algorithm applied to the array of day-to-day differences — the maximum subarray sum of [-1, 4, -2, 3, -2][-1, 4, -2, 3, -2] is 55. Seeing the connection is worth mentioning.

Follow-ups: “Unlimited transactions (LC 122)?” — sum every positive difference. “At most two transactions (LC 123)?” — DP over four states. “With a cooldown (LC 309) or a fee (LC 714)?” — state-machine DP.

LC 283 — Move Zeroes · Easy

Problem. Move all 00s to the end of the array in place, keeping the relative order of the non-zero elements. You must not copy the array.

Constraints. 1 <= len(nums) <= 10^41 <= len(nums) <= 10^4, -2^31 <= nums[i] <= 2^31 - 1-2^31 <= nums[i] <= 2^31 - 1.

Examples. [0,1,0,3,12][0,1,0,3,12] gives [1,3,12,0,0][1,3,12,0,0] · [0][0] gives [0][0]

Editorial

writewrite marks where the next non-zero belongs. Every non-zero found is swapped into that slot, which simultaneously pushes whatever was there — necessarily a zero, or the element itself — to the read position.

Time O(n)O(n). Space O(1)O(1).

Swapping matters. Assigning nums[write] = nums[read]nums[write] = nums[read] would also compact the non-zeros, but you would then need a second loop to zero out the tail. The swap does both jobs in one pass.

This is the same read/write pointer idea behind LC 26 and LC 27 — a same-direction two-pointer scan rather than the converging kind.

Follow-ups: “Minimise writes?” — assign only when read != writeread != write, skipping no-op swaps. “Move zeroes to the front?” — scan from the right. “Remove all instances of a value (LC 27)?” — identical structure.

LC 26 — Remove Duplicates from Sorted Array · Easy

Problem. Given a sorted array, remove duplicates in place so each unique element appears once, keeping relative order. Return kk, the number of unique elements; the first kk slots must hold them.

Constraints. 1 <= len(nums) <= 3 * 10^41 <= len(nums) <= 3 * 10^4, sorted non-decreasing.

Examples. [1,1,2][1,1,2] gives k = 2k = 2 with [1,2][1,2] · [0,0,1,1,1,2,2,3,3,4][0,0,1,1,1,2,2,3,3,4] gives k = 5k = 5 with [0,1,2,3,4][0,1,2,3,4]

Editorial

Sortedness guarantees duplicates are adjacent, so one pass suffices. The subtlety is what to compare against.

Time O(n)O(n). Space O(1)O(1).

Comparing with nums[write - 1]nums[write - 1] — the last element actually kept — is correct. Comparing with nums[read - 1]nums[read - 1] happens to work here too, but the “last kept” formulation is the one that generalises: LC 80 allows each value twice, and there the test becomes nums[read] != nums[write - 2]nums[read] != nums[write - 2]. Learning the general shape now saves rederiving it.

[0,0,1,1,1,2,2,3,3,4][0,0,1,1,1,2,2,3,3,4] includes a run of three, which is where a careless comparison shows up.

Follow-ups: “Allow up to two of each (LC 80)?” — compare against nums[write - 2]nums[write - 2]. “Unsorted input?” — you need a setset, so O(n)O(n) space, and order preservation becomes a separate concern. “Return the array rather than the count?” — slice to nums[:write]nums[:write].

Recap

  • Python’s listlist is a dynamic array: contiguous storage that over-allocates so appendappend is amortized O(1)O(1).
  • End operations are cheap (O(1)O(1)); front/middle operations are expensive (O(n)O(n)) because everything after them has to shift.
  • Two pointers solve many array problems in one O(n)O(n) pass with O(1)O(1) extra space.
  • Prefix sums trade one O(n)O(n) pre-pass for O(1)O(1) range-sum queries afterward.

Next: Strings — why Python strings are immutable, and the patterns that follow from that.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did