Ray Book
자료구조와 알고리즘

배열과 링크드 리스트

연속 메모리 vs 포인터 연결, 두 자료구조의 트레이드오프와 Big-O 표기법을 시각화합니다

csdata-structurearraylinked-listbig-o

왜 자료구조인가

프로그래밍의 본질은 데이터를 다루는 것입니다. 같은 데이터라도 어떻게 저장하느냐 에 따라 읽기, 쓰기, 검색 속도가 완전히 달라집니다.

100만 개의 데이터에서 특정 값을 찾는다고 생각해 보세요. 정렬된 배열에서 이진 탐색을 하면 약 20번의 비교로 찾을 수 있지만, 정렬되지 않은 링크드 리스트에서는 최악의 경우 100만 번을 순회해야 합니다. 자료구조 선택이 곧 성능입니다.

이 시리즈의 첫 번째 글에서는 가장 기본적인 두 자료구조, 배열링크드 리스트를 비교하면서 Big-O 표기법을 함께 익힙니다.

배열 (Array)

배열은 연속된 메모리 공간 에 같은 타입의 데이터를 순서대로 저장하는 자료구조입니다.

메모리 구조

배열이 메모리 주소 0x100에서 시작하고, 각 요소가 4바이트라면:

주소        값
0x100      arr[0]
0x104      arr[1]
0x108      arr[2]
0x10C      arr[3]

인덱스로 접근할 때는 시작 주소 + (인덱스 x 요소 크기)로 바로 계산합니다. 이것이 배열의 인덱스 접근이 O(1)인 이유입니다.

의사코드

Array-Access(A, i):
    return A[i]                    // O(1) - 주소 계산으로 바로 접근

Array-Insert(A, i, value):
    for j = length(A) down to i+1  // O(n) - 뒤 요소를 한 칸씩 이동
        A[j] = A[j-1]
    A[i] = value
    length(A) = length(A) + 1

Array-Delete(A, i):
    for j = i to length(A)-2       // O(n) - 앞으로 한 칸씩 이동
        A[j] = A[j+1]
    length(A) = length(A) - 1

JavaScript 구현

// 배열 기본 연산
const arr = [10, 20, 30, 40, 50];

// 인덱스 접근 - O(1)
console.log(arr[2]); // 30

// 중간 삽입 - O(n): splice는 뒤 요소를 모두 이동
arr.splice(2, 0, 25); // [10, 20, 25, 30, 40, 50]

// 중간 삭제 - O(n): 뒤 요소를 앞으로 당김
arr.splice(2, 1);     // [10, 20, 30, 40, 50]

// 끝에 추가/삭제 - 평균 O(1)
arr.push(60);  // [10, 20, 30, 40, 50, 60]
arr.pop();     // [10, 20, 30, 40, 50]

pushpop은 요소 이동이 필요 없으므로 평균 O(1)입니다. 하지만 shift (맨 앞 삭제)와 unshift (맨 앞 삽입)는 모든 요소를 이동해야 하므로 O(n)입니다.

링크드 리스트 (Linked List)

링크드 리스트는 각 노드가 값(data)과 다음 노드를 가리키는 포인터 (next)로 구성됩니다. 메모리 어디에나 흩어져 있어도 포인터로 연결됩니다.

메모리 구조

head -> [A | next] -> [B | next] -> [C | next] -> null

각 노드는 메모리의 임의 위치에 저장됨:
  노드 A: 0x3A0 (next: 0x7F8)
  노드 B: 0x7F8 (next: 0x120)
  노드 C: 0x120 (next: null)

배열과 달리 연속된 공간이 필요 없으므로, 메모리 단편화 상황에서도 유리합니다.

의사코드

Node:
    data
    next

LinkedList-Access(head, i):
    current = head
    for j = 0 to i-1               // O(n) - 포인터를 따라 순회
        current = current.next
    return current.data

LinkedList-Insert(prev, value):     // prev 노드 뒤에 삽입
    newNode = Node(value)
    newNode.next = prev.next        // O(1) - 포인터 2개만 변경
    prev.next = newNode

LinkedList-Delete(prev):            // prev 다음 노드를 삭제
    target = prev.next
    prev.next = target.next         // O(1) - 포인터 1개만 변경

JavaScript 구현

class Node {
  constructor(data) {
    this.data = data;
    this.next = null;
  }
}

class LinkedList {
  constructor() {
    this.head = null;
    this.size = 0;
  }

  // 인덱스로 접근 - O(n)
  get(index) {
    let current = this.head;
    for (let i = 0; i < index; i++) {
      current = current.next;
    }
    return current.data;
  }

  // 특정 노드 뒤에 삽입 - O(1)
  insertAfter(node, data) {
    const newNode = new Node(data);
    newNode.next = node.next;
    node.next = newNode;
    this.size++;
  }

  // 특정 노드 뒤의 노드 삭제 - O(1)
  deleteAfter(node) {
    if (node.next === null) return;
    node.next = node.next.next;
    this.size--;
  }

  // 맨 앞에 삽입 - O(1)
  prepend(data) {
    const newNode = new Node(data);
    newNode.next = this.head;
    this.head = newNode;
    this.size++;
  }
}

링크드 리스트의 삽입/삭제가 O(1)이 되는 핵심 조건은 삽입/삭제할 위치의 노드를 이미 알고 있을 때 입니다. 위치를 모르면 먼저 O(n) 순회가 필요합니다.

Big-O 표기법

Big-O는 입력 크기(n)에 따른 최악의 경우 성능 증가율을 표현합니다. 상수는 무시하고 증가율의 차수만 봅니다.

// O(1) - 상수 시간: 입력 크기와 무관
function getFirst(arr) {
  return arr[0];
}

// O(n) - 선형 시간: 입력에 비례
function findValue(arr, target) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === target) return i;
  }
  return -1;
}

// O(n^2) - 이차 시간: 중첩 루프
function hasDuplicate(arr) {
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[i] === arr[j]) return true;
    }
  }
  return false;
}

n = 1,000일 때:

  • O(1): 1번 연산
  • O(n): 1,000번 연산
  • O(n^2): 1,000,000번 연산

O(1)과 O(n)의 차이는 작을 수 있지만, O(n)과 O(n^2)의 차이는 데이터가 커질수록 극적으로 벌어집니다.

시각화

아래 시각화에서 배열과 링크드 리스트의 접근, 삽입, 삭제 과정을 단계별로 비교해 보세요.

배열 (Array)
A
[0]
B
[1]
C
[2]
D
[3]
E
[4]
연속 메모리: 0x00, 0x04, 0x08, ...
링크드 리스트 (Linked List)
head->
Anext
->
Bnext
->
Cnext
->
Dnext
->
Enext
null
비연속 메모리: 0x3A, 0x7F, 0x12, ...
초기 상태입니다. 배열은 연속된 메모리 블록에 값을 저장합니다. 링크드 리스트는 각 노드가 값과 다음 노드를 가리키는 포인터를 가집니다. 메모리 배치 방식이 성능 차이를 만듭니다.

비교 표

연산배열링크드 리스트비고
인덱스 접근O(1)O(n)배열은 주소 계산, 리스트는 순회
탐색O(n)O(n)둘 다 최악의 경우 전체 순회
맨 앞 삽입O(n)O(1)배열은 전체 이동 필요
맨 뒤 삽입O(1)*O(n)*배열은 공간 부족 시 재할당
중간 삽입O(n)O(1)**** 위치를 알고 있을 때
삭제O(n)O(1)**** 위치를 알고 있을 때
메모리 효율높음낮음리스트는 포인터 추가 저장
캐시 친화성높음낮음배열은 연속 메모리로 캐시 히트

실무 연결: JavaScript의 Array는 진짜 배열이 아니다

C/Java의 배열은 위에서 설명한 고정 크기의 연속 메모리 구조입니다. 하지만 JavaScript의 Array는 다릅니다.

V8 엔진은 배열을 두 가지 모드 로 관리합니다.

// 1. Packed (밀집) 모드 - C 배열처럼 연속 메모리 사용
const packed = [1, 2, 3, 4, 5];

// 2. Holey (구멍 있는) 모드 - 해시맵처럼 동작
const holey = [1, , , 4, 5];   // 빈 슬롯이 있음
packed[100] = 99;               // 갑자기 큰 인덱스 사용 -> Holey로 전환

V8의 내부 분류 (V8 블로그 "Elements kinds in V8" 기준):

  • SMI (Small Integer) : 정수만 들어 있을 때 가장 빠름
  • DOUBLE : 부동소수점 포함 시
  • ELEMENTS : 다양한 타입 혼재 시
  • HOLEY 변형 : 빈 슬롯이 있으면 각각의 Holey 버전으로 전환
// 성능 팁: V8이 Packed SMI를 유지하도록
const fast = [];
for (let i = 0; i < 1000; i++) {
  fast.push(i);     // SMI, Packed 유지 -> 최적화됨
}

// 피해야 할 패턴
const slow = new Array(1000); // Holey! 빈 슬롯 1000개
slow[999] = 1;                // 여전히 Holey

핵심은 JavaScript Array동적 크기 조절, 타입 혼합, 희소 배열 을 지원하기 위해 전통적인 배열보다 복잡한 내부 구조를 사용한다는 것입니다. 밀집 배열을 유지하면 C 배열에 가까운 성능을 얻을 수 있습니다.

다음 단계

배열과 링크드 리스트는 모든 자료구조의 기초입니다. 다음 글에서는 이 두 자료구조를 활용해 만드는 스택 (Stack)과 큐 (Queue) 를 살펴봅니다. "마지막에 넣은 것을 먼저 꺼내는" LIFO와 "먼저 넣은 것을 먼저 꺼내는" FIFO가 실제로 어떻게 구현되는지 시각화합니다.