컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
ds · 01

배열 vs 연결 리스트

자료구조배열연결 리스트

배열(Array)과 연결 리스트(Linked List)는 데이터를 순서 있게 저장하는 가장 기본적인 자료구조입니다. 배열은 메모리에 연속으로 저장해 인덱스로 즉시 접근하고, 연결 리스트는 노드가 다음 노드의 주소를 가리키며 분산 저장합니다.

핵심 개념

배열의 메모리 구조

배열은 같은 타입의 데이터를 메모리에 연속으로 저장합니다. 시작 주소와 인덱스만 알면 임의 원소에 O(1)으로 접근할 수 있습니다. 크기는 선언 시 고정되며, 중간 삽입·삭제 시 원소를 한 칸씩 이동해야 해 O(n)이 걸립니다.

핵심 개념

연결 리스트의 메모리 구조

연결 리스트의 각 노드는 데이터(data)와 다음 노드를 가리키는 포인터(next)로 구성됩니다. 노드들이 메모리에 분산 저장되어 위치를 알고 있는 노드의 삽입·삭제가 O(1)이지만, 특정 위치까지 순차 탐색이 필요해 임의 접근은 O(n)입니다.

핵심 개념

시간 복잡도 비교

임의 접근: 배열 O(1) vs 연결 리스트 O(n). 맨 앞 삽입: 배열 O(n) vs 연결 리스트 O(1). 맨 뒤 삽입: 배열 O(1) vs 연결 리스트 O(n) 또는 tail 포인터 유지 시 O(1). 탐색(값으로): 둘 다 O(n).

핵심 개념

캐시 지역성(Cache Locality)

배열은 연속 메모리이므로 CPU 캐시 히트율이 높습니다. 연결 리스트는 노드가 힙 메모리 곳곳에 흩어져 있어 캐시 미스가 자주 발생합니다. 그래서 이론적 복잡도가 같아도 실제 성능은 캐시 효율 때문에 크게 달라질 수 있습니다.

핵심 개념

단순/이중/원형 연결 리스트

단순 연결 리스트(Singly)는 next 포인터만 가집니다. 이중 연결 리스트(Doubly)는 prev·next 포인터를 모두 가져 양방향 순회와 O(1) 삭제가 가능합니다. 원형(Circular)은 마지막 노드가 첫 노드를 가리켜 순환 구조를 이룹니다.

핵심 개념

동적 배열(Dynamic Array)

Java의 ArrayList, Python의 list가 동적 배열입니다. 내부적으로 고정 배열을 사용하되, 크기 초과 시 더 큰 배열을 새로 할당해 복사합니다. 성장 배율은 구현마다 다른데, OpenJDK의 ArrayList는 약 1.5배(oldCapacity + oldCapacity/2)로 늘립니다. 분할 상환 분석(Amortized)으로 append는 O(1)이며, 배열의 인덱스 접근 장점과 동적 크기 조정을 모두 갖습니다.

언제 배열을 쓰나? — 크기가 고정되어 있거나, 인덱스로 빈번하게 임의 접근이 필요한 경우. 수치 연산, 행렬, 버퍼, 정렬된 데이터 탐색 등에 적합합니다. 메모리 효율도 높아 포인터 오버헤드가 없습니다.
언제 연결 리스트를 쓰나? — 삽입·삭제가 빈번하고 크기가 자주 바뀌는 경우. 스택, 큐, LRU 캐시 등의 내부 구현에 활용됩니다. Java의 LinkedList, Deque, 그래프의 인접 리스트가 대표 예시입니다.
Java에서의 선택 — ArrayList는 조회 위주, LinkedList는 삽입/삭제 위주라고 알려졌지만, 실제로는 캐시 지역성 덕분에 ArrayList가 대체로 더 빠릅니다. LinkedList를 고려할 만한 경우는 Deque(양끝 삽입/삭제)로 사용할 때이지만, 그 용도에서도 ArrayDeque가 더 빠를 가능성이 높다고 Java 문서가 설명합니다.
메모리 사용량 — 배열은 데이터만 저장해 공간 효율이 높습니다. 연결 리스트는 노드마다 포인터(64비트 환경에서 보통 8바이트이고, Java는 압축 포인터로 4바이트일 수 있습니다)를 추가로 저장하고, 객체 헤더 오버헤드도 발생합니다. 소규모 데이터에서는 이 차이가 크게 체감됩니다.

왜 둘을 비교하나

배열과 연결 리스트는 '순서 있는 데이터를 저장한다'는 같은 목적을 서로 반대되는 방식으로 풉니다. 배열은 메모리를 연속으로 잡아 위치 계산으로 바로 접근하고, 연결 리스트는 흩어진 노드를 포인터로 이어 붙입니다. 어느 쪽이 낫다기보다 어떤 연산이 많은지에 따라 갈립니다.

숫자로 확인하기

맨 앞 삽입 비용 — 원소가 1,000,000개인 배열의 맨 앞에 넣으려면 기존 원소 1,000,000개를 한 칸씩 밀어야 합니다. 연결 리스트는 새 노드를 만들고 head 포인터만 바꾸면 됩니다.
동적 배열의 복사 총량 — 용량이 꽉 찰 때마다 1.5배로 늘리는 구현이면, 원소 n개를 하나씩 추가하는 동안 일어나는 복사한 원소 수의 합이 등비급수로 대략 3n 이하입니다(1 + 2/3 + 4/9 + … = 3). 원소를 넣는 n번까지 더해도 O(n)이라 append 하나당 평균 O(1)입니다.
ArrayList의 용량 변화 예 — Java 문서는 빈 ArrayList의 초기 용량이 10이라고 설명하고, OpenJDK 구현에서는 10 → 15 → 22 → 33 → 49처럼 약 1.5배씩 늘어납니다. 증가 방식 자체는 명세에 정해져 있지 않습니다.
포인터 비용 — 64비트 환경에서 포인터 하나는 8바이트입니다(Java는 기본 설정에서 객체 참조를 4바이트로 줄이기도 합니다). 원소가 4바이트 정수라면 단순 연결 리스트의 노드는 포인터 때문에 최소 3배 정도 큽니다(객체 헤더 등은 제외한 단순 계산).

자주 하는 오해

연결 리스트는 삽입·삭제가 항상 O(1)이다 — 삽입할 위치의 노드를 이미 알고 있을 때의 이야기입니다. 값이나 인덱스로 그 위치를 찾으려면 O(n) 순회가 먼저 필요해서, 위치를 찾는 비용까지 합치면 배열과 큰 차이가 없을 수 있습니다.
이론 복잡도가 같으면 속도도 같다 — 값으로 탐색은 둘 다 O(n)이지만, 배열은 연속된 메모리를 읽어 CPU 캐시를 잘 활용하고 연결 리스트는 노드를 따라 메모리 곳곳을 오가서 캐시 미스가 늘기 쉽습니다. 상수 차이로 실제 시간은 크게 다를 수 있습니다.
ArrayList는 늘 1.5배로 자란다 — 문서가 보장하는 것은 요소 추가가 분할 상환 상수 시간이라는 점뿐입니다. 1.5배는 OpenJDK의 구현이고, 다른 언어와 구현은 배율이 다릅니다.
배열은 크기를 못 바꾼다 — 고정 배열은 못 바꾸지만 동적 배열(ArrayList, Python list)은 내부에서 더 큰 배열로 옮겨 가며 크기를 바꿉니다. 그 비용이 분할 상환으로 숨겨져 있을 뿐입니다.

면접에서 이렇게 물어요

면접 질문

배열과 연결 리스트의 차이를 설명해 주세요.

배열은 연속 메모리라 인덱스로 O(1) 접근이 되지만 중간 삽입·삭제는 원소를 밀어야 해서 O(n)입니다. 연결 리스트는 포인터로 이어져 노드 위치만 알면 삽입·삭제가 O(1)이지만 임의 접근은 O(n)입니다.

면접 질문

동적 배열의 append가 왜 분할 상환 O(1)인가요?

용량이 찰 때만 O(n) 복사가 일어나지만 용량을 일정 배율로 늘리면 그 복사가 드물게 일어나서, n번 추가하는 전체 비용을 n으로 나누면 상수가 됩니다.

면접 질문

ArrayList와 LinkedList 중 무엇을 쓰나요?

대부분의 경우 ArrayList가 낫습니다. LinkedList는 양쪽 끝에서 자주 넣고 빼는 Deque 용도에서 쓸 수 있지만, 같은 용도라면 ArrayDeque가 더 빠르다고 Java 문서가 설명합니다.

면접 질문

이중 연결 리스트의 장점은 무엇인가요?

prev 포인터가 있어 양방향 순회가 되고, 삭제할 노드를 알면 앞 노드를 따로 찾지 않고 O(1)에 지울 수 있습니다. 대신 노드마다 포인터가 하나 더 듭니다.

실제 시스템에서는

Java — ArrayList 문서는 크기 조정 가능한 배열 구현이고 add가 분할 상환 상수 시간이라고 설명합니다. LinkedList는 이중 연결 리스트이고 인덱스로 접근하면 처음이나 끝 중 가까운 쪽에서 순회합니다.
Python — CPython의 list는 객체 참조를 담은 가변 길이 배열입니다. 양쪽 끝에서 자주 넣고 빼려면 collections.deque를 쓰라고 문서가 안내합니다.
LRU 캐시 — 해시 테이블과 이중 연결 리스트를 함께 써서 O(1)로 만드는 설계가 널리 알려져 있습니다. 해시 테이블 토픽에서 이어서 봅니다.

함께 보면 좋은 토픽

스택 / 큐 — 배열과 연결 리스트 위에 만드는 대표 자료구조를 봅니다.
해시 테이블 — 배열 위에서 O(1) 접근을 만드는 방법을 봅니다.
캐시 메모리 — 배열이 캐시에 유리한 이유를 구조에서 봅니다.
시간·공간 복잡도 — 분할 상환과 점근 표기를 봅니다.
이진 탐색 — 임의 접근이 되는 배열에서만 성립하는 탐색을 봅니다.

참고 자료

Java ArrayList — ArrayList — Java SE 21 API
Java LinkedList — LinkedList — Java SE 21 API
Java ArrayDeque — ArrayDeque — Java SE 21 API
교재 — Cormen, Leiserson, Rivest, Stein, "Introduction to Algorithms" 4판, "Elementary Data Structures" 장(연결 리스트), "Amortized Analysis" 장(동적 테이블)

Java — 배열 직접 구현 vs ArrayList

/* ── 고정 배열 — 인덱스 직접 접근 ─────────────────────── */
int[] arr = new int[5];          // 크기 고정, 연속 메모리 할당
arr[0] = 10; arr[1] = 30;        // O(1) 임의 접근

/* ── 중간 삽입: O(n) — 원소 이동 필요 ──────────────────── */
public static void insertAt(int[] arr, int idx, int val, int size) {
    // 뒤에서부터 한 칸씩 오른쪽으로 이동
    for (int i = size - 1; i >= idx; i--) {
        arr[i + 1] = arr[i];
    }
    arr[idx] = val;
}

/* ── 동적 배열 ArrayList — 내부는 배열, 크기 자동 조정 ──── */
List<Integer> list = new ArrayList<>();
list.add(10);                      // Amortized O(1) — 공간 초과 시 2배 재할당
list.add(1, 20);                  // O(n) — 이후 원소 이동
list.get(0);                      // O(1) — 인덱스 직접 접근
list.remove(0);                   // O(n) — 이후 원소 앞으로 이동

// ArrayList 내부 재할당 원리
// DEFAULT_CAPACITY = 10, 초과 시 newCapacity = oldCapacity * 1.5
// Arrays.copyOf()로 새 배열에 복사 후 참조 교체

/* ── 배열 이진 탐색 — 정렬된 배열에서 O(log n) ─────────── */
int[] sorted = {10, 20, 30, 40, 50};
int idx = Arrays.binarySearch(sorted, 30); // idx = 2

배열은 메모리에 연속으로 저장되므로 arr[i] 접근은 시작 주소 + i × 타입크기로 계산해 O(1)입니다.
중간 삽입·삭제는 이후 원소를 이동해야 하므로 O(n)입니다.
ArrayList는 내부 배열이 꽉 차면 1.5배 크기의 새 배열을 할당하고 복사합니다. add()의 분할 상환 복잡도는 O(1)입니다.
정렬된 배열에서는 이진 탐색으로 O(log n) 탐색이 가능합니다.
연결 리스트는 이진 탐색 불가능(임의 접근 없음)합니다.

Java — 연결 리스트 직접 구현 vs LinkedList

/* ── 단순 연결 리스트 노드 ──────────────────────────────── */
class Node<T> {
    T data;
    Node<T> next;
    Node(T data) { this.data = data; }
}

/* ── 중간 삽입: O(1) — 포인터 교환만 필요 ──────────────── */
public void insertAfter(Node<Integer> prev, int val) {
    Node<Integer> newNode = new Node<>(val);
    newNode.next = prev.next;  // 새 노드가 다음 노드 가리킴
    prev.next    = newNode;    // 이전 노드가 새 노드 가리킴
    // 포인터 2번 변경으로 완료 — 원소 이동 없음
}

/* ── 탐색: O(n) — 순차 순회 필요 ───────────────────────── */
public Node<Integer> findNode(Node<Integer> head, int val) {
    Node<Integer> cur = head;
    while (cur != null) {
        if (cur.data == val) return cur;
        cur = cur.next;
    }
    return null;
}

/* ── Java LinkedList — 이중 연결 리스트 ─────────────────── */
LinkedList<Integer> linked = new LinkedList<>();
linked.addFirst(10);             // O(1) — head 포인터 변경
linked.addLast(50);              // O(1) — tail 포인터 변경
linked.add(1, 20);               // O(n) 탐색 + O(1) 삽입
linked.get(2);                   // O(n) — 순차 탐색
linked.removeFirst();             // O(1) — head 포인터 변경

/* ── Deque로 사용 시 — 양끝 O(1) ───────────────────────── */
Deque<Integer> deque = new LinkedList<>();
deque.offerFirst(1);             // 앞에 추가
deque.offerLast(2);              // 뒤에 추가
deque.pollFirst();               // 앞에서 제거

연결 리스트 삽입·삭제는 포인터 2개만 바꾸면 되므로 위치를 알고 있을 때 O(1)입니다.
단, 삽입 위치까지 탐색하는 과정이 O(n)이므로 전체 복잡도는 탐색+삽입입니다.
Java의 LinkedList는 이중 연결 리스트로 prev·next 포인터를 모두 갖습니다. head/tail 포인터도 유지해 맨 앞/뒤 삽입·삭제는 O(1)입니다.
임의 접근(get(i))은 O(n)이므로 인덱스 기반 조회가 많다면 ArrayList가 훨씬 유리합니다.
실무에서 LinkedList가 가장 빛나는 용도는 양끝 삽입/삭제가 빈번한 Deque 구현입니다.

Q1

배열과 연결 리스트 중 임의 인덱스 접근(random access)이 O(1)인 것은?

인덱스 계산으로 바로 주소를 구할 수 있는 구조입니다.
배열은 메모리에 연속적으로 저장되어 인덱스로 주소를 바로 계산할 수 있어 O(1) 접근이 가능합니다. 연결 리스트는 포인터를 따라가야 해서 O(n)입니다.

Q2

연결 리스트가 배열보다 유리한 경우는?

포인터만 바꾸면 되는 연산이 무엇일까요?
연결 리스트는 위치를 이미 알고 있다면 포인터만 바꿔 O(1)에 삽입/삭제할 수 있습니다. 배열은 뒤 원소들을 옮겨야 해서 O(n)입니다.

Q3

배열의 삽입/삭제가 연결 리스트보다 비효율적인 이유는?

중간에 하나를 넣으려면 뒤 칸들을 한 칸씩 밀어야 합니다.
배열 중간에 삽입/삭제하면 그 뒤의 모든 원소를 한 칸씩 밀거나 당겨야 해서 O(n) 시간이 걸립니다.