Arrays & Dynamic Arrays

📖 16 min read

Why Array Indexing Is O(1)

An array stores its elements side by side in one contiguous block of memory, and every element has the same size. So the address of element i is a single calculation: the start of the block plus i times the element size. Reading numbers[500_000] costs the same arithmetic as reading numbers[0], and nothing is searched or followed along the way.

Contiguity also makes arrays fast in practice, beyond their Big O. A CPU reads memory in fixed-size chunks called cache lines, commonly 64 bytes, and keeps recently read lines in a small, fast cache. When code walks an array in order, one memory read brings in the next several elements too, so a sequential scan rarely waits on main memory. Structures that scatter their elements across memory, like linked lists, lose this advantage even when their Big O is the same.


Fixed-Size Arrays in C#

A C# array such as int[] has a length fixed when it is created. It lives on the managed heap, its elements start at their default value (0 for int, null for reference types), and every index is bounds-checked. An out-of-range index throws IndexOutOfRangeException instead of reading whatever memory lies past the end.

int[] numbers = new int[5];        // [0, 0, 0, 0, 0]
int[] primes = { 2, 3, 5, 7, 11 };  // Length fixed at 5

numbers[0] = 100;
Console.WriteLine(numbers.Length);  // 5
Console.WriteLine(primes[4]);       // 11

“Growing” a fixed-size array means allocating a larger one and copying the elements across. Array.Resize does exactly that, and whenever the size actually changes it costs O(n) in the new size.


Operation Costs

Operation Fixed-size array Dynamic array (List<T>) Why
Read or write by index O(1) O(1) Address arithmetic
Search an unsorted array O(n) O(n) Every element may need checking
Append at the end Not possible O(1) amortized, O(n) worst case A full array must be copied first
Insert at the front or middle Not possible O(n) Every later element shifts right
Remove from the end Not possible O(1) Nothing shifts
Remove from the front or middle Not possible O(n) Every later element shifts left

Amortized means averaged over a long run of operations. Most appends are cheap, an occasional one is expensive, and the average stays constant. The section on appending below shows why.

The O(n) cost of inserting or removing in the middle comes from shifting, not from finding the spot. Removing index 0 from a million-element List<T> moves 999,999 elements.


Dynamic Arrays

A dynamic array keeps a fixed-size array internally, called its backing array, that is usually larger than the number of elements stored. The count is how many slots are in use, and the capacity is the backing array’s length. Appending writes into the next free slot. When no slot is free, the dynamic array allocates a larger backing array, copies every element into it, and discards the old one.

public class DynamicArray<T>
{
    private T[] _items = new T[4];
    private int _count;

    public int Count => _count;
    public int Capacity => _items.Length;

    public T this[int index]
    {
        get
        {
            if ((uint)index >= (uint)_count) throw new ArgumentOutOfRangeException(nameof(index));
            return _items[index];
        }
        set
        {
            if ((uint)index >= (uint)_count) throw new ArgumentOutOfRangeException(nameof(index));
            _items[index] = value;
        }
    }

    public void Add(T value)
    {
        if (_count == _items.Length)
            Grow();

        _items[_count++] = value;
    }

    public void Insert(int index, T value)
    {
        if ((uint)index > (uint)_count) throw new ArgumentOutOfRangeException(nameof(index));

        if (_count == _items.Length)
            Grow();

        Array.Copy(_items, index, _items, index + 1, _count - index);  // Shift right
        _items[index] = value;
        _count++;
    }

    public void RemoveAt(int index)
    {
        if ((uint)index >= (uint)_count) throw new ArgumentOutOfRangeException(nameof(index));

        _count--;
        Array.Copy(_items, index + 1, _items, index, _count - index);  // Shift left
        _items[_count] = default!;  // Release the reference so the GC can collect it
    }

    private void Grow()
    {
        var larger = new T[_items.Length * 2];
        Array.Copy(_items, larger, _count);
        _items = larger;
    }
}

The (uint) casts fold “index is negative” and “index is too large” into one comparison, because a negative int becomes a very large uint. List<T> uses the same trick.

How List<T> Grows

List<T> is .NET’s dynamic array, and its growth policy is visible in the dotnet/runtime source. A new List<T> starts with capacity 0 and no backing array to speak of. The first Add allocates capacity 4, and every growth that Add triggers after that doubles the capacity, up to the runtime’s maximum array length. Methods that know how much room they need grow to the larger of the next doubling (4 when empty) and that size. EnsureCapacity always knows. AddRange and InsertRange know only when the source is an ICollection<T>, which reports its count. Given a lazy sequence, they add one element at a time and double as they go.

Three members control the copying directly:

  • The new List<T>(capacity) constructor allocates once, up front, when the final size is known. Loading a million items into a list created with that capacity skips about 18 grow-and-copy steps.
  • EnsureCapacity(n) does the same for a list that already exists, before a large batch of adds.
  • TrimExcess() shrinks the backing array to fit the count, unless the list is already more than 90 percent full, in which case it does nothing. Microsoft’s documentation notes that the threshold could change. After growth by doubling, up to about half the capacity can sit unused.

List<T> never shrinks on its own, even when elements are removed.


Why Appending Is O(1) Amortized

An append that finds the backing array full costs O(n), because it copies every element. Appending is still O(1) amortized, because doubling makes those copies rare and each copy pays for many cheap appends that follow it.

Follow a dynamic array that starts at capacity 4 through 33 appends. Appends 5, 9, 17, and 33 each find the array full and copy 4, 8, 16, and 32 elements first. Every other append writes one element. The copies total 4 + 8 + 16 + 32 = 60, so all 33 appends cost 33 writes plus 60 copies, which is 93, or about 2.8 per append.

Chart

Append Cost in a Doubling Dynamic Array

Cost of each of 33 appends, with copies at each doubling.

Cost of each append to a dynamic array that doubles from capacity 4 A bar chart of 33 appends to a dynamic array that starts at capacity 4 and doubles when full. Most appends cost one element write. Appends 5, 9, 17, and 33 find the array full and also copy 4, 8, 16, and 32 elements into a new array twice the size. A line showing the average cost per append so far stays below 3 and ends at about 2.8. 10 20 30 1 copy 4 5 8 copy 8 9 16 copy 16 17 24 32 copy 32 33 average cost per append so far: 2.8 append number cost (element writes) write the new element copy into a doubled array

The pattern holds at any size. Each copy moves as many elements as all the copies before it combined, plus the initial capacity (32 = 4 + 8 + 16 + 4), so the copies over n appends always total less than 2n. That keeps the average cost per append below 3 no matter how many appends run, even though the single append that triggers a copy is O(n).

The growth factor is what makes this work. A dynamic array that grew by a fixed amount, say 10 slots at a time, would copy every 10 appends, and its copies would total about n²/20, which makes each append O(n) amortized. Any constant growth factor above 1 keeps appends O(1) amortized. List<T> doubles. Other libraries use smaller factors, such as 1.5 in Java’s ArrayList, which copy more often and leave less capacity unused.


Two Pointers

The two pointers technique walks an array with two indices instead of one, and uses what it knows about the array, usually that it is sorted, to move one index at a time. Many problems whose obvious solution checks every pair in O(n²) drop to O(n) this way.

Moving Toward Each Other

To find two numbers in a sorted array that add up to a target, the brute-force approach checks every pair. With one index at each end, each comparison rules out every remaining pair that uses one of the two elements. If the sum is too small, no pair using the left element can reach the target, because the right element is already the largest available, so the left index moves right. If the sum is too large, the right index moves left by the same argument.

public static (int, int)? PairWithSum(int[] sorted, int target)
{
    int left = 0, right = sorted.Length - 1;

    while (left < right)
    {
        int sum = sorted[left] + sorted[right];

        if (sum == target) return (left, right);
        if (sum < target) left++;
        else right--;
    }

    return null;
}

int[] values = { 1, 3, 4, 6, 8, 11 };
Console.WriteLine(PairWithSum(values, 10));  // (2, 3): 4 + 6

Each iteration moves one index one step closer to the other, so the loop runs at most n − 1 times. The approach depends on the array being sorted. Unsorted input needs either a sort first, for O(n log n) in total, or a hash set of the values seen so far, for O(n) on average.

Moving in the Same Direction

A read index and a write index can move in the same direction to rewrite an array in place. Removing duplicates from a sorted array keeps a write index at the end of the unique prefix and copies each new value there as the read index finds it:

public static int RemoveDuplicatesSorted(int[] sorted)
{
    if (sorted.Length == 0)
        return 0;

    int write = 1;

    for (int read = 1; read < sorted.Length; read++)
    {
        if (sorted[read] != sorted[read - 1])
            sorted[write++] = sorted[read];
    }

    return write;  // Length of the unique prefix
}

int[] data = { 1, 1, 2, 2, 2, 3, 4, 4, 5 };
int length = RemoveDuplicatesSorted(data);
Console.WriteLine(string.Join(", ", data.Take(length)));  // 1, 2, 3, 4, 5

This is O(n) time and O(1) extra space. Merging two sorted arrays works the same way, with one read index per input and a write index into the output.


Sliding Window

A sliding window is a special case of two pointers where the two indices mark the start and end of a contiguous range. The window moves across the array while the code keeps a running summary of what is inside it, updating that summary as elements enter and leave instead of recomputing it from scratch.

Fixed-Size Windows

The largest sum of k consecutive elements needs k additions for the first window. After that, each slide adds the element entering on the right and subtracts the one leaving on the left, which takes O(n) in total instead of O(n × k).

public static int? MaxSumOfWindow(int[] values, int k)
{
    if (k <= 0 || values.Length < k)
        return null;

    int windowSum = 0;
    for (int i = 0; i < k; i++)
        windowSum += values[i];

    int maxSum = windowSum;

    for (int i = k; i < values.Length; i++)
    {
        windowSum += values[i] - values[i - k];  // Add the entering element, drop the leaving one
        maxSum = Math.Max(maxSum, windowSum);
    }

    return maxSum;
}

Console.WriteLine(MaxSumOfWindow(new[] { 2, 1, 5, 1, 3, 2 }, 3));  // 9, from 5 + 1 + 3

Variable-Size Windows

When the window’s size depends on its contents, the right edge grows the window and the left edge shrinks it whenever a condition breaks. Finding the shortest run of positive numbers whose sum reaches a target works this way. Neither edge ever moves backward, so each element enters and leaves the window at most once, and the whole scan is O(n).

public static int ShortestWindowWithSum(int[] positives, int target)
{
    if (target <= 0)
        throw new ArgumentOutOfRangeException(nameof(target), "The target must be positive.");

    int best = int.MaxValue, windowSum = 0, left = 0;

    for (int right = 0; right < positives.Length; right++)
    {
        windowSum += positives[right];

        while (windowSum >= target)
        {
            best = Math.Min(best, right - left + 1);
            windowSum -= positives[left++];
        }
    }

    return best == int.MaxValue ? 0 : best;  // 0 means no window reaches the target
}

Console.WriteLine(ShortestWindowWithSum(new[] { 2, 3, 1, 2, 4, 3 }, 7));  // 2, from 4 + 3
Flow

A Variable-Size Sliding Window

The windows the shortest-sum scan passes through, edge by edge.

The windows a variable-size sliding window passes through while finding the shortest run with sum at least 7 The array 2, 3, 1, 2, 4, 3 at indexes 0 to 5, searched for the shortest run of elements whose sum reaches 7. Each row shows a window the inner loop checks while its sum is at least 7, just before the left edge moves right. Indexes 0 to 3 sum to 8, length 4. Indexes 1 to 4 sum to 10, length 4. Indexes 2 to 4 sum to 7, length 3. Indexes 3 to 5 sum to 9, length 3. Indexes 4 to 5 sum to 7, length 2, the shortest. Elements to the left of each window have already been dropped, and elements to the right have not been reached yet. Both edges only move right, so each element enters and leaves the window at most once. Index 5 never leaves. EACH WINDOW THAT REACHES THE TARGET OF 7 0 1 2 3 4 5 2 3 1 2 4 3 0..3 sum 8, length 4 2 3 1 2 4 3 1..4 sum 10, length 4 2 3 1 2 4 3 2..4 sum 7, length 3 2 3 1 2 4 3 3..5 sum 9, length 3 2 3 1 2 4 3 4..5 sum 7, length 2 shortest Each row is a window the inner loop checks with sum at least 7, just before the left edge moves right. Both edges only move right, so each element enters the window once and leaves it at most once. In the window Shortest window found Already dropped from the left Not reached yet

Shrinking from the left is only safe because no value is negative, and the method trusts its caller on that. If values could be negative, removing an element could raise the sum, and the window’s logic would no longer hold. A target of zero or less would also be met by the empty window, so the method rejects it. With negative values, the usual tool is prefix sums: running totals that make any range’s sum one subtraction. For a range that sums to exactly the target, look up the matching earlier total in a hash table. The shortest range with a sum of at least the target takes a more involved technique over the same prefix sums.


Multi-Dimensional Arrays

C# has two kinds of multi-dimensional array, and they are laid out differently in memory.

Kind Declaration Layout Rows
Rectangular int[,] grid = new int[3, 4]; One contiguous block, stored row by row All the same length
Jagged int[][] rows = new int[3][]; An array of references to separate one-dimensional arrays Each row can have its own length
int[,] grid = { { 1, 2, 3 }, { 4, 5, 6 } };
Console.WriteLine(grid[1, 2]);           // 6
Console.WriteLine(grid.GetLength(0));    // 2 rows
Console.WriteLine(grid.GetLength(1));    // 3 columns

int[][] triangle = new int[3][];
for (int i = 0; i < triangle.Length; i++)
    triangle[i] = new int[i + 1];        // Rows of length 1, 2, and 3
Structure

Rectangular and Jagged Arrays in Memory

One contiguous block versus an array of separate row arrays.

How a rectangular array and a jagged array are laid out in memory Top: the rectangular array int[,] with rows 1, 2, 3 and 4, 5, 6 is one contiguous block of six elements stored row by row, so element [r, c] sits at position r times 3 plus c, and reading row by row walks memory in order. Bottom: the jagged array int[][] with rows of length 1, 2, and 3 is an array of three references, each pointing to its own row array stored separately in memory. Reaching element [r][c] takes two lookups, first the row reference and then the element within that row. RECTANGULAR: int[,] grid = { { 1, 2, 3 }, { 4, 5, 6 } } One contiguous block, stored row by row 1 [0,0] 2 [0,1] 3 [0,2] 4 [1,0] 5 [1,1] 6 [1,2] Element [r, c] sits at position r × 3 + c. Reading row by row walks memory in order. JAGGED: int[][] triangle, rows of length 1, 2, and 3 An array of references, each pointing to its own row array somewhere else in memory [0] [1] [2] 0 0 0 0 0 0 Element [r][c] takes two lookups: the row reference, then the element within that row.

A rectangular array’s element address is still a single calculation, row × column count + column. A jagged array needs two lookups, one to find the row’s array and one within it. Because rectangular arrays are stored row by row, a loop that walks each row in order reads memory sequentially, while a loop that walks down columns jumps a full row’s width on every step.


Worked Example: Rotate an Array in Place

Rotating an array right by k positions moves the last k elements to the front. The direct approach copies into a new array, which is O(n) extra space. Three reversals do it in place with O(1) extra space: reverse the whole array, then reverse the first k elements, then reverse the rest.

public static void RotateRight(int[] values, int k)
{
    int n = values.Length;
    if (n == 0) return;

    k = ((k % n) + n) % n;  // Rotating by n is a no-op, and a negative k rotates left
    Reverse(values, 0, n - 1);
    Reverse(values, 0, k - 1);
    Reverse(values, k, n - 1);
}

private static void Reverse(int[] values, int start, int end)
{
    while (start < end)
    {
        (values[start], values[end]) = (values[end], values[start]);
        start++;
        end--;
    }
}

int[] items = { 1, 2, 3, 4, 5, 6, 7 };
RotateRight(items, 3);
Console.WriteLine(string.Join(", ", items));  // 5, 6, 7, 1, 2, 3, 4

Reversing the whole array puts the last k elements at the front, but backward, and the first n − k at the back, also backward. The two smaller reversals put each group back in order. With k = 3, the array goes 1234567, then 7654321, then 5674321, then 5671234. The Reverse helper is itself two pointers moving toward each other.


Arrays in Practice

List<T> is the default collection in C# for good reason. It has O(1) indexing, amortized O(1) appends, and sequential memory for cache-friendly scans. Plain arrays fit when the size is fixed and known, as with lookup tables, buffers, and interop with native code, or when the extra indirection of List<T> measurably matters.

An array is the wrong choice when the workload inserts or removes near the front or middle of a large collection, because every such operation shifts the elements after it. It is also a poor fit for “is this value present” checks on a large collection, which cost O(n) per lookup in an array and O(1) on average in a hash-based set.

Found this guide helpful? Share it with your team:

Share on LinkedIn