← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202622 phút đọc18 min read

Mảng (Array)Arrays

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Memory layout
  4. Static array và dynamic array
  5. Độ phức tạp
  6. Dựng dynamic array từ đầu
  7. Vì sao nhân đôi cho ra O(1) amortized
  8. Khái niệm chính
  9. Cache locality — lý do array thắng trong thực tế
  10. Mảng hai chiều
  11. Slicing
  12. Array thực sự giỏi ở đâu
  13. Array thua ở đâu
  14. Các kỹ thuật array thường gặp
  15. Best Practices
  16. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Memory layout
  4. Static vs dynamic arrays
  5. Complexity
  6. Building a dynamic array from scratch
  7. Why doubling gives amortized O(1)
  8. Key Concepts
  9. Cache locality — the reason arrays win in practice
  10. Two-dimensional arrays
  11. Slicing
  12. What arrays are genuinely good at
  13. Where arrays lose
  14. Common array techniques
  15. Best Practices
  16. References

Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.

Tổng quan

Array là cấu trúc dữ liệu đơn giản nhất, và cũng là quan trọng nhất. Nó là một khối bộ nhớ liên tục (contiguous) chứa một số phần tử cố định có kích thước bằng nhau. Chỉ riêng đặc tính đó — liên tục, kích thước bằng nhau — mua được thứ mà không cấu trúc nào khác có được miễn phí: bạn có thể tính ra địa chỉ của phần tử i thay vì phải đi tìm nó.

address(A[i]) = base_address + i * element_size

Một phép nhân, một phép cộng, một lần đọc bộ nhớ. Thời gian hằng số, bất kể array có 10 phần tử hay 10 tỷ phần tử. Mọi cấu trúc “random access” khác trong bộ kiến thức này — hash table, binary heap, Fenwick tree, adjacency matrix, page cache của database — suy cho cùng đều là một array với một lược đồ đánh địa chỉ chồng lên trên. Hiểu array cho tử tế thì một nửa phần còn lại chỉ là ghi sổ.

Cái giá của tính liên tục đó là sự cứng nhắc. Vì các phần tử nằm sát vai nhau, không có khoảng trống ở giữa, bạn không thể chèn vào giữa mà không dịch chuyển tất cả những gì phía sau, và không thể mở rộng quá cuối khối mà không đi tìm một khối lớn hơn rồi copy sang. Array đánh đổi tính linh hoạt lấy tốc độ, và toàn bộ không gian thiết kế “dùng cấu trúc nào ở đây” phần lớn là câu hỏi liệu đánh đổi đó có đáng với access pattern của bạn hay không.

Note này bao quát cả hai nửa của câu chuyện: static array (dung lượng cố định, thứ mà C gọi là int a[100] và là thứ thực sự tồn tại ở tầng phần cứng), và dynamic array (list của Python, vector của C++, ArrayList của Java, slice của Go) — vốn chỉ là một static array được giấu sau một chính sách tự động tăng trưởng. Chúng ta sẽ dựng dynamic array từ đầu, vì list của Python vốn đã là một dynamic array rồi và dùng nó để dạy chính nó thì chẳng giải thích được gì.

Kiến thức nền tảng

Memory layout

Một static array gồm năm số nguyên 4 byte, bắt đầu tại địa chỉ 0x1000:

       index:     0        1        2        3        4
                +--------+--------+--------+--------+--------+
   memory:      |   17   |    3   |   99   |   42   |    8   |
                +--------+--------+--------+--------+--------+
     address:    0x1000   0x1004   0x1008   0x100C   0x1010
                    ^
                    base

   A[3]  ->  0x1000 + 3 * 4  =  0x100C   ->  42       O(1), một phép tính địa chỉ

Hai hệ quả xuất hiện ngay lập tức:

Đây chính xác là điều ngược lại với linked list, nơi các phần tử có thể nằm bất cứ đâu trong bộ nhớ nhưng bạn phải đi hết chuỗi mới tới được phần tử thứ n.

Static array và dynamic array

Static arrayDynamic array
Dung lượngCố định lúc tạoTự động tăng
Ví dụC int a[100], Java int[], NumPy ndarray, Go [5]intPython list, C++ std::vector, Java ArrayList, Go slice, Rust Vec
Lưu trữChính khối bộ nhớ đóMột pointer tới khối + size + capacity
AppendKhông thể vượt quá cuối khốiO(1) amortized
Overhead bộ nhớKhông cóLãng phí tới ~50% capacity (với tăng trưởng 2×)

Dynamic array không phải một cấu trúc dữ liệu khác. Nó là một static array cộng thêm hai số nguyên và một chính sách:

   DynamicArray:  size = 5,  capacity = 8

                +----+----+----+----+----+----+----+----+
     _data ---> | 17 |  3 | 99 | 42 |  8 |    |    |    |
                +----+----+----+----+----+----+----+----+
                  0    1    2    3    4    ^^^^^^^^^^^^
                                            phần dư: đã cấp phát nhưng chưa dùng

   append(23):  size < capacity, chỉ cần ghi rồi tăng size  ->  O(1)
   append(...) khi size == capacity:
        cấp phát khối mới capacity*2, copy toàn bộ n phần tử, giải phóng khối cũ  ->  O(n)

Độ phức tạp

Thao tácStatic arrayDynamic array (trung bình)Dynamic array (worst case)Lý do
Truy cập A[i]O(1)O(1)O(1)Tính địa chỉ số học
Cập nhật A[i] = xO(1)O(1)O(1)Tính địa chỉ số học
Append ở cuốiO(1) amortizedO(n)Worst case là lúc resize và copy
Chèn ở đầuO(n)O(n)Dịch cả n phần tử sang phải
Chèn tại index iO(n − i)O(n)Dịch phần đuôi
Xoá ở cuối (pop)O(1) amortizedO(1)Chỉ giảm size
Xoá ở đầuO(n)O(n)Dịch cả n − 1 phần tử sang trái
Tìm kiếm (chưa sort)O(n)O(n)O(n)Phải duyệt hết mọi phần tử
Tìm kiếm (đã sort)O(log n)O(log n)O(log n)Binary search
Bộ nhớO(n)O(n)O(n)Hệ số 1×–2× với dynamic

Vì sao là “amortized” chứ không phải O(1) thuần: một lần append đơn lẻ có thể tốn O(n) khi nó kích hoạt resize. Nhưng những lần append đắt đỏ đó hiếm đến mức trung bình trên cả một chuỗi thao tác vẫn là hằng số. Sự phân biệt đó được triển khai trong độ phức tạp thuật toán và được chứng minh bên dưới.

Dựng dynamic array từ đầu

list của Python vốn đã là dynamic array, nên để thấy được cơ chế bên trong ta cần một array kích thước cố định thật sự làm nền. ctypes cho ta điều đó — một khối capacity ô thật, không thể tự lớn lên:

import ctypes


class DynamicArray:
    """Dynamic array dựng trên một raw array dung lượng cố định, tự nhân đôi thủ công."""

    GROWTH_FACTOR = 2

    def __init__(self):
        self._n = 0                                # số phần tử thực sự đang chứa
        self._capacity = 1                         # số ô đã cấp phát
        self._data = self._make_array(self._capacity)

    @staticmethod
    def _make_array(capacity):
        """Cấp phát một khối raw, cố định `capacity` ô. Không thể tự lớn lên."""
        return (capacity * ctypes.py_object)()

    def __len__(self):
        return self._n

    def __getitem__(self, i):
        if not 0 <= i < self._n:                   # bỏ qua index âm cho gọn
            raise IndexError("index out of range")
        return self._data[i]                       # O(1) — tính địa chỉ số học

    def __setitem__(self, i, value):
        if not 0 <= i < self._n:
            raise IndexError("index out of range")
        self._data[i] = value                      # O(1)

    def append(self, value):
        """O(1) amortized: O(1) hầu hết thời gian, O(n) khi phải resize."""
        if self._n == self._capacity:              # đã đầy — không còn ô dư
            self._resize(self._capacity * self.GROWTH_FACTOR)
        self._data[self._n] = value
        self._n += 1

    def _resize(self, new_capacity):
        """O(n): cấp phát khối lớn hơn rồi copy từng phần tử sang."""
        new_data = self._make_array(new_capacity)
        for i in range(self._n):                   # chính vòng copy này tốn O(n)
            new_data[i] = self._data[i]
        self._data = new_data                      # khối cũ trở thành rác
        self._capacity = new_capacity

    def insert(self, i, value):
        """O(n): mọi phần tử từ index i trở đi phải dịch sang phải một ô."""
        if not 0 <= i <= self._n:
            raise IndexError("index out of range")
        if self._n == self._capacity:
            self._resize(self._capacity * self.GROWTH_FACTOR)
        for j in range(self._n, i, -1):            # đi ngược để không ghi đè lên dữ liệu
            self._data[j] = self._data[j - 1]
        self._data[i] = value
        self._n += 1

    def pop(self, i=None):
        """O(1) ở cuối, O(n) ở mọi vị trí khác — phần đuôi phải lấp chỗ trống."""
        if self._n == 0:
            raise IndexError("pop from empty array")
        if i is None:
            i = self._n - 1
        if not 0 <= i < self._n:
            raise IndexError("index out of range")
        value = self._data[i]
        for j in range(i, self._n - 1):            # dịch phần đuôi sang trái
            self._data[j] = self._data[j + 1]
        self._n -= 1
        # Thu nhỏ khi còn 1/4, không phải 1/2 — xem phần "hysteresis" bên dưới
        if 0 < self._n <= self._capacity // 4:
            self._resize(max(1, self._capacity // 2))
        return value

    def __iter__(self):
        for i in range(self._n):
            yield self._data[i]

    def __repr__(self):
        return f"DynamicArray({list(self)}, capacity={self._capacity})"


arr = DynamicArray()
for x in [17, 3, 99, 42, 8]:
    arr.append(x)
print(arr)              # DynamicArray([17, 3, 99, 42, 8], capacity=8)
arr.insert(2, 55)
print(arr)              # DynamicArray([17, 3, 55, 99, 42, 8], capacity=8)
print(arr.pop(0), arr)  # 17 DynamicArray([3, 55, 99, 42, 8], capacity=8)

Vì sao nhân đôi cho ra O(1) amortized

Bắt đầu với capacity 1 và append n phần tử. Resize xảy ra tại size 1, 2, 4, 8, …, và mỗi lần resize tại size k phải copy k phần tử. Tổng công copy:

1 + 2 + 4 + 8 + ... + n/2 + n  <  2n

Một cấp số nhân công bội 2 có tổng nhỏ hơn hai lần số hạng lớn nhất. Vậy n lần append tốn O(n) tổng cộng cho việc copy cộng với O(n) cho chính các lần ghi — tức O(1) mỗi lần append theo nghĩa amortized.

Cách lập luận theo phương pháp kế toán (accounting method) trực quan hơn: tính 3 đơn vị chi phí cho mỗi lần append. Một đơn vị trả cho việc ghi phần tử; hai đơn vị còn lại được gửi tiết kiệm vào ô đó. Khi array nhân đôi từ k lên 2k, k/2 phần tử được thêm kể từ lần resize trước, mỗi phần tử mang theo 2 đơn vị tiết kiệm — vừa đúng k đơn vị, đủ trả cho việc copy cả k phần tử. Số dư tài khoản không bao giờ âm, nên chi phí amortized mỗi thao tác là hằng số 3.

Vì sao hệ số tăng trưởng lại quan trọng:

Hệ sốCông copy cho n lần appendBộ nhớ lãng phíGhi chú
+1 (tăng một lượng hằng số)1+2+…+n = O(n²)~0Thảm hoạ. Append là O(n), không phải O(1).
×1.125O(n), hằng số lớn hơn≤ 12%Chính sách của CPython list: newsize + (newsize >> 3) + 6, có làm tròn.
×1.5O(n)≤ 33%MSVC std::vector. Khối đã giải phóng có thể tái sử dụng cho lần cấp phát sau.
×2O(n)≤ 50%libstdc++ std::vector, Go slice (×2 cho tới 256 phần tử).

Bất kỳ hệ số nào lớn hơn 1 đều cho ra O(1) amortized; chỉ hằng số thay đổi. Tăng theo một lượng cố định thì không — đó là lựa chọn sai thực sự duy nhất, và là một cái bẫy phỏng vấn kinh điển.

Hysteresis khi thu nhỏ. Để ý pop ở trên thu nhỏ khi còn 1/4 và chia đôi capacity. Nếu thay vào đó bạn thu nhỏ khi còn 1/2, một workload xen kẽ append/pop ngay tại ngưỡng sẽ resize ở mỗi một thao tácO(n) mỗi lần gọi, mãi mãi. Khoảng cách giữa ngưỡng tăng (đầy 100%) và ngưỡng giảm (còn 25%) đảm bảo rằng sau bất kỳ lần resize nào, phải có Ω(n) thao tác nữa trước lần resize kế tiếp. Nhiều implementation (kể cả list của CPython) đơn giản là không bao giờ thu nhỏ.

Khái niệm chính

Cache locality — lý do array thắng trong thực tế

Mô hình RAM nói A[i]A[j] tốn như nhau. Phần cứng thật không đồng ý, đôi khi lệch nhau tới 50 lần. Bộ nhớ được nạp theo cache line — thường là 64 byte — nên đọc A[0] sẽ kéo luôn A[0..15] (với int 4 byte) vào L1 cache miễn phí. Một lần quét tuần tự nhận được 15 lần hit miễn phí cho mỗi lần miss; một lần quét theo thứ tự ngẫu nhiên gần như miss ở mọi lần truy cập một khi array vượt quá kích thước cache.

   Quét tuần tự một array int (cache line 64 byte, int 4 byte):

   miss  hit hit hit ... hit   miss  hit hit hit ... hit
   [ A[0] A[1] ... A[15] ]     [ A[16] ... A[31] ]
   \___ một cache line ____/   \___ một cache line ___/

   -> 1 lần nạp bộ nhớ cho mỗi 16 phần tử, chưa kể prefetcher thấy được bước nhảy
      và nạp sẵn line kế tiếp trước cả khi bạn yêu cầu.

   Duyệt linked list:

   node ---> ??? ---> ??? ---> ???      mỗi `next` là một địa chỉ không đoán trước được
   miss      miss     miss     miss     -> không prefetch, ~1 lần nạp bộ nhớ mỗi phần tử

Độ trễ xấp xỉ trên CPU hiện đại: L1 hit ~1 ns, L2 ~4 ns, L3 ~15 ns, RAM chính ~80–100 ns. Chính khoảng chênh đó là lý do một lần quét O(n) trên array liên tục thường xuyên thắng một lần tìm O(log n) trên cây dựa trên pointer với n nhỏ đến trung bình, và là lý do các implementation thực tế của những cấu trúc “lớn” (B-tree, hash table, heap) đều được dựng trên array. Đó cũng là lý do database lưu row trong các page kích thước cố định — xem storage internals.

import time

n = 20_000_000
data = list(range(n))
index = list(range(n))

start = time.perf_counter()
total = 0
for i in index:                      # tuần tự: locality hoàn hảo
    total += data[i]
sequential = time.perf_counter() - start

import random
random.shuffle(index)
start = time.perf_counter()
total = 0
for i in index:                      # cùng số phép toán, nhưng thứ tự địa chỉ ngẫu nhiên
    total += data[i]
shuffled = time.perf_counter() - start

print(f"sequential {sequential:.2f}s vs shuffled {shuffled:.2f}s")
# Thường chậm hơn 3-10 lần khi xáo trộn, với cùng một lượng công việc O(n).

Một lưu ý riêng cho Python: list của CPython là một array các pointer trỏ tới object, không phải array các giá trị. Các pointer thì liên tục; các số nguyên mà chúng trỏ tới thì nằm rải rác trên heap. Bạn được một tầng locality, không phải hai. ndarray của NumPy lưu giá trị thô liên tục và là công cụ đúng khi locality thực sự quan trọng.

Mảng hai chiều

Ở tầng phần cứng không tồn tại thứ gọi là mảng 2 chiều — bộ nhớ là một chiều. Mảng 2 chiều là một mảng 1 chiều cộng với một công thức index.

   Ma trận 3x4 về mặt logic:

        col:  0   1   2   3
   row 0:   [ a   b   c   d ]
   row 1:   [ e   f   g   h ]
   row 2:   [ i   j   k   l ]

   Row-major layout (C, C++, Python/NumPy mặc định, Java):
   [ a b c d | e f g h | i j k l ]
     M[r][c] = flat[r * COLS + c]

   Column-major layout (Fortran, MATLAB, R, bên trong BLAS):
   [ a e i | b f j | c g k | d h l ]
     M[r][c] = flat[c * ROWS + r]

Layout quyết định thứ tự duyệt nào là nhanh. Với lưu trữ row-major, duyệt theo row-rồi-column là đi tuần tự trong bộ nhớ; duyệt theo column-rồi-row thì nhảy COLS * element_size byte mỗi bước, chạm vào một cache line mới mỗi lần.

def flat_matrix(rows, cols, fill=0):
    """Mảng 2 chiều thật sự: một khối liên tục, index theo row-major."""
    return {"rows": rows, "cols": cols, "data": [fill] * (rows * cols)}


def get(m, r, c):
    return m["data"][r * m["cols"] + c]          # công thức index, O(1)


def set_(m, r, c, value):
    m["data"][r * m["cols"] + c] = value


m = flat_matrix(3, 4)
set_(m, 1, 2, 99)
print(get(m, 1, 2))        # 99
print(m["data"])           # [0,0,0,0, 0,0,99,0, 0,0,0,0] — khối row-major


# "Mảng 2 chiều" thông thường trong Python là list của list — KHÔNG liên tục.
# Mỗi row là một lần cấp phát heap riêng; list ngoài chỉ chứa pointer tới chúng.
grid = [[0] * 4 for _ in range(3)]

# Lỗi kinh điển: cách này tạo BA THAM CHIẾU TỚI CÙNG MỘT ROW, không phải ba row.
broken = [[0] * 4] * 3
broken[0][0] = 1
print(broken)              # [[1,0,0,0], [1,0,0,0], [1,0,0,0]]  <- cả ba đều đổi

Lỗi [[0] * 4] * 3 xuất hiện liên tục trong code thật. Toán tử * trên list copy tham chiếu, và [0] * 4 là một object list duy nhất được tham chiếu ba lần. Luôn dùng comprehension.

Slicing

Slice của Python tạo ra một list mới và copy các phần tửa[i:j] tốn O(j − i) thời gian và O(j − i) bộ nhớ. Đây là cách vô tình tạo ra thuật toán bậc hai phổ biến nhất trong Python:

# O(n^2): mỗi lần slice copy toàn bộ phần đuôi còn lại
def dedupe_slow(items):
    out = []
    while items:
        out.append(items[0])
        items = items[1:]              # copy n-1, rồi n-2, rồi n-3 ... = O(n^2)
    return out

# O(n): dùng index, không bao giờ copy
def dedupe_fast(items):
    return [x for i, x in enumerate(items) if i == 0 or x != items[i - 1]]

Không phải ngôn ngữ nào cũng làm vậy. Slice của Go và của NumPy là view: chúng dùng chung khối bộ nhớ gốc và chỉ ghi lại offset, length, stride, nên slice là O(1) — nhưng ghi qua view sẽ sửa cả dữ liệu gốc. Cả hai thiết kế đều hợp lý; điều bạn cần biết là mình đang dùng cái nào.

import numpy as np

a = np.arange(10)
view = a[2:5]         # O(1) — không copy, dùng chung bộ nhớ
view[0] = 999
print(a[2])           # 999 — dữ liệu gốc đã thay đổi

b = list(range(10))
copy = b[2:5]         # O(3) — copy thật sự
copy[0] = 999
print(b[2])           # 2 — dữ liệu gốc không bị đụng tới

Array thực sự giỏi ở đâu

Array thua ở đâu

Các kỹ thuật array thường gặp

Hai họ kỹ thuật xuất hiện liên tục và có note riêng ở hai con trỏ và cửa sổ trượt, nhưng đáng nhắc tên ở đây vì chúng chỉ hoạt động được nhờ index O(1):

# Prefix sum: tiền xử lý O(n) biến mọi truy vấn tổng đoạn thành O(1).
def build_prefix(nums):
    prefix = [0] * (len(nums) + 1)
    for i, x in enumerate(nums):
        prefix[i + 1] = prefix[i] + x            # prefix[k] = tổng của nums[:k]
    return prefix


def range_sum(prefix, lo, hi):                   # tổng của nums[lo:hi], O(1)
    return prefix[hi] - prefix[lo]


nums = [3, 1, 4, 1, 5, 9, 2, 6]
pre = build_prefix(nums)
print(range_sum(pre, 2, 6))                      # 4+1+5+9 = 19


# Đảo ngược tại chỗ với hai con trỏ: O(n) thời gian, O(1) bộ nhớ phụ.
def reverse_in_place(arr):
    lo, hi = 0, len(arr) - 1
    while lo < hi:
        arr[lo], arr[hi] = arr[hi], arr[lo]
        lo += 1
        hi -= 1
    return arr


print(reverse_in_place([1, 2, 3, 4, 5]))         # [5, 4, 3, 2, 1]


# Xoá tại chỗ giữ nguyên thứ tự: một con trỏ ghi, một con trỏ đọc. O(n), không cấp phát.
def remove_value(arr, target):
    write = 0
    for read in range(len(arr)):
        if arr[read] != target:
            arr[write] = arr[read]
            write += 1
    del arr[write:]
    return arr


print(remove_value([3, 2, 3, 1, 3, 4], 3))       # [2, 1, 4]

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

An array is the simplest data structure there is, and also the most important one. It is a block of contiguous memory holding a fixed number of equally sized elements. That single property — contiguous, equally sized — buys you the one thing no other structure gets for free: you can compute the address of element i instead of searching for it.

address(A[i]) = base_address + i * element_size

One multiply, one add, one memory read. Constant time, regardless of whether the array holds 10 elements or 10 billion. Every other “random access” structure in this knowledge base — hash tables, binary heaps, Fenwick trees, adjacency matrices, the page cache of a database — is ultimately an array with an addressing scheme layered on top of it. Learn arrays properly and half of the rest is bookkeeping.

The cost of that contiguity is rigidity. Because the elements sit shoulder to shoulder with nothing between them, you cannot insert into the middle without shifting everything after it, and you cannot grow past the end of the block without finding a bigger block and copying. Arrays trade flexibility for speed, and the whole design space of “which structure do I use here” is largely a question of whether that trade pays off for your access pattern.

This note covers both halves of the story: the static array (fixed capacity, what C calls int a[100] and what actually exists at the hardware level), and the dynamic array (Python’s list, C++‘s vector, Java’s ArrayList, Go’s slice) which hides a static array behind an automatic-growth policy. We build the dynamic array from scratch, because Python’s list is already one and using it to teach itself explains nothing.

Fundamentals

Memory layout

A static array of five 4-byte integers, starting at address 0x1000:

       index:     0        1        2        3        4
                +--------+--------+--------+--------+--------+
   memory:      |   17   |    3   |   99   |   42   |    8   |
                +--------+--------+--------+--------+--------+
     address:    0x1000   0x1004   0x1008   0x100C   0x1010
                    ^
                    base

   A[3]  ->  0x1000 + 3 * 4  =  0x100C   ->  42       O(1), one address computation

Two consequences follow immediately:

This is the exact opposite of a linked list, where elements can live anywhere in memory but you must walk the chain to reach the n-th one.

Static vs dynamic arrays

Static arrayDynamic array
CapacityFixed at creationGrows automatically
ExamplesC int a[100], Java int[], NumPy ndarray, Go [5]intPython list, C++ std::vector, Java ArrayList, Go slice, Rust Vec
StorageThe block itselfA pointer to a block + size + capacity
AppendNot possible past the endAmortized O(1)
Memory overheadNoneUp to ~50% wasted capacity (with 2× growth)

A dynamic array is not a different data structure. It is a static array plus two integers and a policy:

   DynamicArray:  size = 5,  capacity = 8

                +----+----+----+----+----+----+----+----+
     _data ---> | 17 |  3 | 99 | 42 |  8 |    |    |    |
                +----+----+----+----+----+----+----+----+
                  0    1    2    3    4    ^^^^^^^^^^^^
                                            slack: allocated but unused

   append(23):  size < capacity, so just write and increment  ->  O(1)
   append(...) when size == capacity:
        allocate a new block of capacity*2, copy all n elements, free the old  ->  O(n)

Complexity

OperationStatic arrayDynamic array (average)Dynamic array (worst)Why
Access A[i]O(1)O(1)O(1)Address arithmetic
Update A[i] = xO(1)O(1)O(1)Address arithmetic
Append at endO(1) amortizedO(n)Worst case is the resize-and-copy
Insert at frontO(n)O(n)Shift all n elements right
Insert at index iO(n − i)O(n)Shift the tail
Delete at end (pop)O(1) amortizedO(1)Just decrement size
Delete at frontO(n)O(n)Shift all n − 1 elements left
Search (unsorted)O(n)O(n)O(n)Must inspect every element
Search (sorted)O(log n)O(log n)O(log n)Binary search
SpaceO(n)O(n)O(n)Constant factor 1×–2× for dynamic

Why “amortized” and not just O(1): a single append can cost O(n) when it triggers a resize. But those expensive appends are rare enough that the average over a sequence is constant. That distinction is developed under algorithmic complexity and proved below.

Building a dynamic array from scratch

Python’s list is already a dynamic array, so to see the mechanics we need a genuinely fixed-size array underneath. ctypes gives us one — a real block of capacity slots that cannot grow:

import ctypes


class DynamicArray:
    """A dynamic array built on a fixed-capacity raw array, with manual doubling."""

    GROWTH_FACTOR = 2

    def __init__(self):
        self._n = 0                                # number of elements actually stored
        self._capacity = 1                         # slots allocated
        self._data = self._make_array(self._capacity)

    @staticmethod
    def _make_array(capacity):
        """Allocate a raw, fixed-size block of `capacity` slots. Cannot grow."""
        return (capacity * ctypes.py_object)()

    def __len__(self):
        return self._n

    def __getitem__(self, i):
        if not 0 <= i < self._n:                   # negative indices omitted for clarity
            raise IndexError("index out of range")
        return self._data[i]                       # O(1) — address arithmetic

    def __setitem__(self, i, value):
        if not 0 <= i < self._n:
            raise IndexError("index out of range")
        self._data[i] = value                      # O(1)

    def append(self, value):
        """Amortized O(1): O(1) most of the time, O(n) on the resize."""
        if self._n == self._capacity:              # full — no slack left
            self._resize(self._capacity * self.GROWTH_FACTOR)
        self._data[self._n] = value
        self._n += 1

    def _resize(self, new_capacity):
        """O(n): allocate a bigger block and copy every element into it."""
        new_data = self._make_array(new_capacity)
        for i in range(self._n):                   # the copy is what costs O(n)
            new_data[i] = self._data[i]
        self._data = new_data                      # old block becomes garbage
        self._capacity = new_capacity

    def insert(self, i, value):
        """O(n): every element from index i onward shifts one slot right."""
        if not 0 <= i <= self._n:
            raise IndexError("index out of range")
        if self._n == self._capacity:
            self._resize(self._capacity * self.GROWTH_FACTOR)
        for j in range(self._n, i, -1):            # walk backwards to avoid overwriting
            self._data[j] = self._data[j - 1]
        self._data[i] = value
        self._n += 1

    def pop(self, i=None):
        """O(1) at the end, O(n) anywhere else — the tail must close the gap."""
        if self._n == 0:
            raise IndexError("pop from empty array")
        if i is None:
            i = self._n - 1
        if not 0 <= i < self._n:
            raise IndexError("index out of range")
        value = self._data[i]
        for j in range(i, self._n - 1):            # shift the tail left
            self._data[j] = self._data[j + 1]
        self._n -= 1
        # Shrink at 1/4 full, not 1/2 — see "hysteresis" below
        if 0 < self._n <= self._capacity // 4:
            self._resize(max(1, self._capacity // 2))
        return value

    def __iter__(self):
        for i in range(self._n):
            yield self._data[i]

    def __repr__(self):
        return f"DynamicArray({list(self)}, capacity={self._capacity})"


arr = DynamicArray()
for x in [17, 3, 99, 42, 8]:
    arr.append(x)
print(arr)              # DynamicArray([17, 3, 99, 42, 8], capacity=8)
arr.insert(2, 55)
print(arr)              # DynamicArray([17, 3, 55, 99, 42, 8], capacity=8)
print(arr.pop(0), arr)  # 17 DynamicArray([3, 55, 99, 42, 8], capacity=8)

Why doubling gives amortized O(1)

Start with capacity 1 and append n elements. Resizes happen at sizes 1, 2, 4, 8, …, and each resize at size k copies k elements. Total copy work:

1 + 2 + 4 + 8 + ... + n/2 + n  <  2n

A geometric series with ratio 2 sums to less than twice its largest term. So n appends cost O(n) total copying plus O(n) for the writes themselves — O(1) per append amortized.

The accounting-method version of the same argument is more intuitive: charge 3 units of cost to every append. One pays for writing the element; the other two are saved on that slot. When the array doubles from k to 2k, the k/2 elements added since the last resize each carry 2 saved units — exactly k units, enough to pay for copying all k elements. The bank balance never goes negative, so the amortized cost per operation is the constant 3.

Why the growth factor matters:

FactorCopy work for n appendsWasted memoryComment
+1 (grow by a constant)1+2+…+n = O(n²)~0Catastrophic. Appending is O(n), not O(1).
×1.125O(n), larger constant≤ 12%CPython’s list policy: newsize + (newsize >> 3) + 6, rounded.
×1.5O(n)≤ 33%MSVC std::vector. Freed blocks can be reused by later allocations.
×2O(n)≤ 50%libstdc++ std::vector, Go slices (×2 up to 256 elements).

Any factor strictly greater than 1 gives amortized O(1); only the constants change. Growing by a constant amount does not — that is the one genuinely wrong choice, and it is a classic interview trap.

Hysteresis on shrinking. Notice the pop above shrinks at 1/4 full and halves. If you instead shrank at 1/2 full, a workload that alternates append/pop right at the boundary would resize on every single operationO(n) per call forever. The gap between the grow threshold (100% full) and the shrink threshold (25% full) guarantees that after any resize, Ω(n) operations must happen before the next one. Many implementations (including CPython’s list) simply never shrink at all.

Key Concepts

Cache locality — the reason arrays win in practice

The RAM model says A[i] and A[j] cost the same. Real hardware disagrees, sometimes by a factor of 50. Memory is fetched in cache lines — typically 64 bytes — so reading A[0] pulls A[0..15] (for 4-byte ints) into L1 cache for free. A sequential scan gets 15 free hits for every miss; a random-order scan misses on nearly every access once the array exceeds cache size.

   Sequential scan of an int array (64-byte cache line, 4-byte ints):

   miss  hit hit hit ... hit   miss  hit hit hit ... hit
   [ A[0] A[1] ... A[15] ]     [ A[16] ... A[31] ]
   \____ one cache line ___/   \____ one cache line __/

   -> 1 memory fetch per 16 elements, plus the prefetcher sees the stride
      and loads the next line before you ask for it.

   Linked list traversal:

   node ---> ??? ---> ??? ---> ???      each `next` is an unpredictable address
   miss      miss     miss     miss     -> no prefetch, ~1 memory fetch per element

Approximate latencies on a modern CPU: L1 hit ~1 ns, L2 ~4 ns, L3 ~15 ns, main memory ~80–100 ns. That spread is why an O(n) scan over a contiguous array frequently beats an O(log n) search over a pointer-based tree for small-to-medium n, and why real implementations of “big” structures (B-trees, hash tables, heaps) are all built on arrays. It is also why databases store rows in fixed-size pages — see storage internals.

import time

n = 20_000_000
data = list(range(n))
index = list(range(n))

start = time.perf_counter()
total = 0
for i in index:                      # sequential: perfect locality
    total += data[i]
sequential = time.perf_counter() - start

import random
random.shuffle(index)
start = time.perf_counter()
total = 0
for i in index:                      # same operations, random address order
    total += data[i]
shuffled = time.perf_counter() - start

print(f"sequential {sequential:.2f}s vs shuffled {shuffled:.2f}s")
# Typically 3-10x slower shuffled, for identical O(n) work.

A caveat specific to Python: a CPython list is an array of pointers to objects, not an array of values. The pointers are contiguous; the integers they point at are scattered on the heap. You get one level of locality, not two. NumPy’s ndarray stores unboxed values contiguously and is the right tool when locality actually matters.

Two-dimensional arrays

There is no such thing as a 2D array in hardware — memory is one-dimensional. A 2D array is a 1D array plus an index formula.

   Logical 3x4 matrix:

        col:  0   1   2   3
   row 0:   [ a   b   c   d ]
   row 1:   [ e   f   g   h ]
   row 2:   [ i   j   k   l ]

   Row-major layout (C, C++, Python/NumPy default, Java):
   [ a b c d | e f g h | i j k l ]
     M[r][c] = flat[r * COLS + c]

   Column-major layout (Fortran, MATLAB, R, BLAS internals):
   [ a e i | b f j | c g k | d h l ]
     M[r][c] = flat[c * ROWS + r]

The layout dictates which traversal order is fast. In row-major storage, iterating rows-then-columns walks memory sequentially; iterating columns-then-rows jumps COLS * element_size bytes each step, touching a new cache line every time.

def flat_matrix(rows, cols, fill=0):
    """A true 2D array: one contiguous block, row-major indexing."""
    return {"rows": rows, "cols": cols, "data": [fill] * (rows * cols)}


def get(m, r, c):
    return m["data"][r * m["cols"] + c]          # the index formula, O(1)


def set_(m, r, c, value):
    m["data"][r * m["cols"] + c] = value


m = flat_matrix(3, 4)
set_(m, 1, 2, 99)
print(get(m, 1, 2))        # 99
print(m["data"])           # [0,0,0,0, 0,0,99,0, 0,0,0,0] — the row-major block


# Python's usual "2D array" is a list of lists — NOT contiguous.
# Each row is a separate heap allocation; the outer list holds pointers to them.
grid = [[0] * 4 for _ in range(3)]

# The classic bug: this makes THREE REFERENCES TO THE SAME ROW, not three rows.
broken = [[0] * 4] * 3
broken[0][0] = 1
print(broken)              # [[1,0,0,0], [1,0,0,0], [1,0,0,0]]  <- all three changed

That [[0] * 4] * 3 bug appears in real code constantly. * on a list copies references, and [0] * 4 is a single list object being referenced three times. Always use the comprehension.

Slicing

Python’s slice creates a new list and copies the elementsa[i:j] is O(j − i) time and O(j − i) space. This is the single most common accidental quadratic in Python:

# O(n^2): each slice copies the remaining tail
def dedupe_slow(items):
    out = []
    while items:
        out.append(items[0])
        items = items[1:]              # copies n-1, then n-2, then n-3 ... = O(n^2)
    return out

# O(n): index, never copy
def dedupe_fast(items):
    return [x for i, x in enumerate(items) if i == 0 or x != items[i - 1]]

Not every language works this way. Go slices and NumPy slices are views: they share the underlying block and only record an offset, length, and stride, so slicing is O(1) — but a write through the view mutates the original. Both designs are defensible; you need to know which one you have.

import numpy as np

a = np.arange(10)
view = a[2:5]         # O(1) — no copy, shares memory
view[0] = 999
print(a[2])           # 999 — the original changed

b = list(range(10))
copy = b[2:5]         # O(3) — a real copy
copy[0] = 999
print(b[2])           # 2 — the original is untouched

What arrays are genuinely good at

Where arrays lose

Common array techniques

Two families of technique come up constantly and get their own note in two pointers and sliding window, but they are worth naming here because they only work because of O(1) indexing:

# Prefix sums: O(n) preprocessing turns any range-sum query into O(1).
def build_prefix(nums):
    prefix = [0] * (len(nums) + 1)
    for i, x in enumerate(nums):
        prefix[i + 1] = prefix[i] + x            # prefix[k] = sum of nums[:k]
    return prefix


def range_sum(prefix, lo, hi):                   # sum of nums[lo:hi], O(1)
    return prefix[hi] - prefix[lo]


nums = [3, 1, 4, 1, 5, 9, 2, 6]
pre = build_prefix(nums)
print(range_sum(pre, 2, 6))                      # 4+1+5+9 = 19


# In-place reversal with two pointers: O(n) time, O(1) extra space.
def reverse_in_place(arr):
    lo, hi = 0, len(arr) - 1
    while lo < hi:
        arr[lo], arr[hi] = arr[hi], arr[lo]
        lo += 1
        hi -= 1
    return arr


print(reverse_in_place([1, 2, 3, 4, 5]))         # [5, 4, 3, 2, 1]


# Stable in-place removal: one write pointer, one read pointer. O(n), no allocation.
def remove_value(arr, target):
    write = 0
    for read in range(len(arr)):
        if arr[read] != target:
            arr[write] = arr[read]
            write += 1
    del arr[write:]
    return arr


print(remove_value([3, 2, 3, 1, 3, 4], 3))       # [2, 1, 4]

Best Practices

References