포스트

배열과 연결 리스트 — 같은 O(n)인데 왜 한쪽이 수십 배 느린가

배열·동적 배열·연결 리스트를 메모리 관점에서 비교합니다. 인덱스 접근 O(1)과 삽입·삭제 비용, 캐시 지역성이 같은 O(n)을 수십 배 가르는 이유를 애니메이션으로.

배열과 연결 리스트 — 같은 O(n)인데 왜 한쪽이 수십 배 느린가

가장 기본인데, 가장 자주 잘못 고른다

배열과 연결 리스트는 모든 자료구조의 바닥입니다. 교과서는 “삽입/삭제가 잦으면 연결 리스트”라고 가르치지만, 실무에서 그대로 따랐다가 더 느려지는 일이 흔합니다. 이유는 복잡도 분석에서 말한 Big-O가 숨기는 상수 — 바로 메모리 지역성(cache locality) 때문입니다.

이 글은 두 구조를 연산별 복잡도뿐 아니라 CPU 캐시 관점까지 내려가 비교합니다. 같은 점근 등급에서 왜 한쪽이 수십 배 빠른지를 이해하면, 자료구조 선택이 달라집니다.

배열 — 연속된 메모리 한 덩어리

배열은 같은 크기의 원소가 메모리에 빈틈없이 연속으로 놓인 구조입니다. 그래서 i번째 원소 주소를 base + i × elem_size로 계산만 하면 바로 닿습니다 — 인덱스 접근이 O(1).

이 연속성이 결정적인 이유는 CPU가 메모리를 캐시 라인(보통 64바이트) 단위로 한 번에 끌어오기 때문입니다. 배열 한 칸을 읽으면 그 옆 원소들이 공짜로 캐시에 따라옵니다. 순회할 때 CPU의 prefetcher가 다음 줄까지 미리 예측해 가져옵니다.

반면 연결 리스트는 노드가 메모리 여기저기 흩어져 있어, 다음 노드로 가려면 포인터가 가리키는 예측 불가능한 주소로 점프합니다 — 매번 캐시 미스. 아래에서 두 순회를 나란히 보세요.

배열 순회 — 연속 메모리, 한 캐시 라인에 여럿 예측 가능 · prefetch 연결 리스트 순회 — 흩어진 노드, 포인터 추격 매번 캐시 미스
연산배열연결 리스트
인덱스 접근 a[i]O(1)O(n) (앞에서부터 추적)
값으로 탐색O(n)O(n)
맨 끝 삽입분할상환 O(1)O(1) (tail 보유 시)
중간 삽입/삭제O(n) (뒤를 밀기)O(1) (노드 보유 시)
메모리 오버헤드없음(값만)노드마다 포인터(들)
캐시 지역성매우 좋음나쁨

동적 배열 — 크기를 모를 때

순수 배열은 크기가 고정입니다. ArrayList(Java)·vector(C++)·list(Python)는 내부에 배열을 두고, 꽉 차면 2배 큰 배열로 옮겨 담는 동적 배열입니다. 맨 끝 추가가 평균 O(1) 인 이유(2배 증가의 분할상환)는 복잡도 편에서 다뤘습니다.

1
2
3
4
5
6
// ArrayList 내부의 핵심 — 꽉 차면 1.5배로 늘려 복사
private Object[] grow(int minCapacity) {
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1); // ×1.5
    return elementData = Arrays.copyOf(elementData, newCapacity);
}

핵심은 곱셈 증가라는 점입니다. 매번 +1씩 늘리면 복사 총합이 $1+2+\dots+n = O(n^2)$로 망하지만, 1.5~2배로 늘리면 총 복사가 $O(n)$에 머물러 추가당 분할상환 O(1)이 됩니다.

중간 삽입은 왜 비싼가 — 밀어내기

배열 중간에 값을 끼우려면, 그 자리를 비우기 위해 뒤의 원소를 전부 한 칸씩 밀어야 합니다. 맨 앞에 넣으면 n개를 다 밀어 O(n). 아래가 그 “밀어내기”입니다.

index 1 위치에 삽입 → 뒤 원소들이 한 칸씩 밀림 (O(n)) A B C D X

연결 리스트는 반대입니다. 삽입할 노드를 이미 손에 쥐고 있다면, 앞뒤 포인터 두어 개만 고쳐 끼우면 끝 — O(1). 다만 “그 위치를 찾는” 비용이 O(n)이라, 실제로는 탐색 비용이 삽입 이득을 까먹는 경우가 많습니다.

flowchart LR
    A["노드 A"] -->|next| B["노드 B"]
    A -.삽입 후.-> X["노드 X"]
    X -.-> B

그래서 무엇을 고르나

flowchart TD
    Q{"중간 삽입/삭제가<br/>핵심 연산인가?"}
    Q -->|"아니오 (순회·인덱스 위주)"| ARR["배열/동적배열<br/>캐시 지역성이 이긴다"]
    Q -->|"예, 그리고 위치 노드를 이미 쥠"| LL["연결 리스트<br/>예: LRU 캐시, 인접 리스트"]
    Q -->|"예, 하지만 위치 탐색도 필요"| OTHER["트리/해시 등<br/>다른 구조 고려"]

현대 하드웨어에서는 순회·검색이 섞인 대부분의 워크로드에서 배열이 이깁니다. 연결 리스트는 인덱스 추적이 O(n)인 데다 캐시 미스가 누적돼, “삽입 O(1)”의 이론적 이점이 실측에서 사라지기 일쑤입니다. 연결 리스트가 제값을 하는 곳은 위치 노드를 이미 들고 있는 경우 — LRU 캐시의 노드 이동, 그래프의 인접 리스트, 커널의 연결 구조 등입니다.

프로덕션에서 마주치는 함정

함정증상해법
큰 List 맨 앞에 잦은 add(0, x)매번 전체 밀기 → O(n²)끝에 추가 후 뒤집기, 또는 ArrayDeque
LinkedList에 get(i) 반복인덱스 접근이 O(n) → 루프 전체 O(n²)ArrayList로 교체, 또는 이터레이터 순회
초기 용량 미지정재할당·복사 반복new ArrayList<>(expectedSize)
원시값을 List<Integer>로박싱·포인터로 캐시 파괴원시 배열 int[] 사용
삭제 후에도 큰 배열 점유메모리 누수성 점유trimToSize()/재생성

면접/리뷰 단골 질문

  • Q. 배열과 연결 리스트, 삽입이 잦으면 무조건 연결 리스트? → 아니다. 위치를 “찾는” 비용이 O(n)이고 캐시 미스가 커서, 실측은 보통 배열이 빠르다. 노드를 이미 쥔 경우에만 연결 리스트가 유리.
  • Q. 동적 배열 추가가 O(1)인 핵심? → 곱셈(2배) 증가의 분할상환. +1 증가면 O(n).
  • Q. 같은 O(n) 순회인데 속도 차이가 나는 이유? → 캐시 지역성. 배열은 연속이라 캐시 라인·prefetch 이득, 연결 리스트는 포인터 점프마다 캐시 미스.
  • Q. ArrayList에서 중간 삭제가 O(n)인 이유? → 빈자리를 메우려 뒤 원소를 앞으로 당겨야 해서.
  • Q. 메모리 오버헤드 차이? → 배열은 값만, 연결 리스트는 노드마다 next(단방향)·prev(양방향) 포인터 추가.

정리

  • 배열은 연속 메모리 → 인덱스 O(1) + 캐시 지역성. 연결 리스트는 포인터 추격 → 인덱스 O(n) + 캐시 미스.
  • 동적 배열은 내부 배열을 곱셈 증가시켜 끝 추가를 분할상환 O(1)로 만든다.
  • 중간 삽입/삭제는 배열이 O(n)(밀기), 연결 리스트는 노드를 쥐면 O(1)이나 탐색이 O(n).
  • 현대 하드웨어에선 의심스러우면 배열 — 연결 리스트는 “위치 노드 보유”가 보장될 때만.

다음 글은 이 배열·연결 리스트 위에 규칙을 얹은 스택·큐·힙입니다. LIFO·FIFO·우선순위라는 접근 규칙이 어떻게 강력한 도구가 되는지 봅니다.

</content> </invoke>

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 알고리즘 A-Z (20편)

  1. 복잡도 분석 — Big-O, 점근 표기, 그리고 분할상환이라는 거짓말 같은 진실
  2. 배열과 연결 리스트 — 같은 O(n)인데 왜 한쪽이 수십 배 느린가 지금 보는 글
  3. 스택·큐·힙 — 접근 규칙 하나가 만드는 강력함
  4. 해시 테이블 — 평균 O(1)의 비밀과 그 평균이 깨지는 순간
  5. 비교 정렬 — 퀵·머지·힙, 그리고 Ω(n log n)이라는 넘을 수 없는 벽
  6. 선형 시간 정렬 — 비교를 버리면 Ω(n log n) 벽이 사라진다
  7. 이진 탐색 — 반씩 버리는 마법, 그리고 "답을 이분하라"
  8. 재귀와 분할정복 — 콜스택에 쌓이는 신뢰, 마스터 정리로 푸는 비용
  9. 이진 탐색 트리와 균형 — 한쪽으로 쏠리는 트리를 회전으로 바로 세우기
  10. 그래프 순회 — BFS의 물결, DFS의 깊이, 그리고 위상정렬
  11. 최단 경로 — 다익스트라의 확신, A*의 직감, 벨만-포드의 인내
  12. 최소 신장 트리와 Union-Find — 가장 싸게 잇기, 그리고 거의 O(1)의 마법
  13. 동적계획법 — 같은 계산을 두 번 하지 않는다는 단 하나의 원칙
  14. 그리디 — 눈앞의 최선만 집었는데 전체가 최적일 때
  15. 문자열 매칭 — 어긋났을 때 얼마나 똑똑하게 다시 시작하는가
  16. 확률적 자료구조 — 정확함을 메모리와 맞바꾸는 기술 (Bloom·HyperLogLog·Count-Min)
  17. 일관된 해싱 — 노드가 들고날 때 데이터를 통째로 옮기지 않는 법
  18. 합의 알고리즘 — 죽는 서버들 사이에서 하나의 진실을 정하는 법 (Raft·Paxos)
  19. 레이트 리미팅과 스트리밍 알고리즘 — 폭주를 막고, 끝없는 흐름에서 표본을 뽑다
  20. 의미로 검색하다 — 벡터 검색(ANN·HNSW)과 PageRank, 그리고 시리즈의 끝