<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. DSA
  3. Linear data structures
All lessons in this course

Lesson 7 of 50 14 minBeginner

Dynamic arrays: how List<T> grows

Build your own List<T>, see why it doubles its capacity, and understand amortised O(1): why Add is fast on average even though resizing is slow.

Arrays have a fixed size, but most of the time you do not know in advance how many items you will have. A dynamic array solves this. It keeps a normal array with some spare room, plus a count of how many slots are used. When the array is full, it allocates a bigger one and copies everything over. List<T> in .NET is exactly this.

Count vs Capacity

  • Count: how many items you have added.
  • Capacity: how many items fit in the hidden array before it must grow.
Capacity.cs
var list = new List<int>();Console.WriteLine($"Start: Count {list.Count}, Capacity {list.Capacity}"); for (int i = 1; i <= 9; i++){    list.Add(i);    Console.Write($"{list.Capacity} ");}
Output
Start: Count 0, Capacity 0
4 4 4 4 8 8 8 8 16 

The first Add allocates room for 4. The 5th finds it full, so a new array of 8 is created and the 4 items are copied over. The 9th grows it to 16. Try it, and compare with a list that grows by a fixed 4 slots each time:

Interactive

Inside List<T>.Add

Add items. When the hidden array is full, a bigger one is allocated and every item is copied across. Compare the two growth rules.

When full
Count 0 · Capacity 0
0items copied by resizes
0resizes
–average work per Add
new List<int>() starts with capacity 0. The first Add allocates room for 4.

Build your own List<T>

SimpleList.cs
public class SimpleList<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 item)    {        if (_count == _items.Length)            Grow();                       // rare: O(n)        _items[_count++] = item;          // usual case: O(1)    }     public void RemoveAt(int index)    {        if ((uint)index >= (uint)_count) throw new ArgumentOutOfRangeException(nameof(index));        _count--;        // shift everything after index one slot left        Array.Copy(_items, index + 1, _items, index, _count - index);        _items[_count] = default!;        // let the GC free the old reference    }     private void Grow()    {        var bigger = new T[_items.Length * 2];        Array.Copy(_items, bigger, _count);        _items = bigger;                  // the old array becomes garbage    }}

Amortised O(1): why doubling works

A single Add that triggers a resize is O(n): it copies every item. So how can we say Add is O(1)? Look at the total work for n adds with doubling. Resizes happen at 4, 8, 16, 32… items, and copy 4 + 8 + 16 + … + n/2 items. That sum is less than n. So n adds cost at most n writes plus fewer than n copies: under 2n steps in total, about 2 per Add. The rare expensive Add is “paid for” by the many cheap ones. This is called amortised O(1).

Growth ruleCopies for n addsCost per Add (average)
Double the capacityfewer than nO(1) amortised
Grow by 1.5× (also common)about 2nO(1) amortised
Add a fixed 4 (or 100) slotsabout n² / 8O(n): the whole thing is O(n²)

Practical tips

  • If you know roughly how many items you will add, say so: new List<Beam>(beamCount). No resizes at all.
  • list.EnsureCapacity(n) grows once ahead of a big batch of adds.
  • Capacity never shrinks on its own. After removing most items from a huge list, TrimExcess() gives the memory back.
  • Removing many items with RemoveAt in a loop is O(n²). Use RemoveAll(predicate), which compacts the list in one O(n) pass.
`List<T>` operationCost
list[i], CountO(1)
AddO(1) amortised
Insert(i, x), RemoveAt(i)O(n − i)
Contains, IndexOf, Remove(x)O(n)
RemoveAll(pred)O(n)
Sort()O(n log n)
Quick check

A List<int> has Count 8 and Capacity 8. What happens on the next Add?

Quick check

Why does List<T> double its capacity instead of adding a fixed 10 slots?

Practice

This code removes all negative readings from a list but gets slow with a million items. Explain why, then rewrite it to be O(n) in two ways: with RemoveAll, and by hand with a read and a write index.

Show a hint

Each RemoveAt shifts all later items. For the manual version, copy every reading you keep to the write position, then remove the leftover tail once with RemoveRange.

Show the solution
Solution.cs
var readings = new List<int> { 5, -2, 7, -1, -9, 3 }; // Slow: each RemoveAt shifts the rest → O(n²)// for (int i = readings.Count - 1; i >= 0; i--)//     if (readings[i] < 0) readings.RemoveAt(i); // Fast 1: one pass inside .NETvar a = new List<int>(readings);a.RemoveAll(x => x < 0); // Fast 2: compact by handvar b = new List<int>(readings);int write = 0;for (int read = 0; read < b.Count; read++)    if (b[read] >= 0) b[write++] = b[read];b.RemoveRange(write, b.Count - write);   // drop the tail once Console.WriteLine(string.Join(", ", a));Console.WriteLine(string.Join(", ", b));
Output
5, 7, 3
5, 7, 3

Remember

  • A dynamic array is an array with spare room plus a count; List<T> is one.
  • When full it allocates a bigger array (double the size in .NET) and copies everything.
  • Doubling makes Add amortised O(1): n Adds cost fewer than 2n steps in total.
  • Pre-size lists when you know the count, and use RemoveAll instead of RemoveAt in a loop.
0 of 50 lessons completed