(CS/컴퓨터공학) 배열 vs 연결리스트 완전 비교

✨ 개요

배열과 연결리스트는 컬렉션 설계의 출발점입니다.
둘의 시간/공간 특성CPU 캐시 관점을 이해하면, 실제 서비스에서 어떤 구조를 선택해야 할지 명확해집니다.


1. 한 장 요약

항목 배열 (동적 배열: ArrayList 등) 연결리스트 (Singly/Doubly)
인덱스 접근 a[i] O(1) (진짜 상수) O(n) (앞/뒤에서 선형 탐색)
맨 끝 추가 평균 O(1) (가끔 리사이즈 O(n)) O(1) (테일 포인터가 있으면)
중간 삽입/삭제 O(n) (이동/시프트 필요) O(1) (해당 노드 참조 이미 있으면) / 찾기는 O(n)
메모리 오버헤드 낮음 (연속 영역) 높음 (노드 포인터 1~2개 + 객체 헤더)
캐시 지역성 아주 좋음 (연속 배치) 나쁨 (포인터 점프, 캐시 미스↑)
랜덤 접근 최적 부적합
안정적 반복(iteration) 빠름 (순차 스캔) 느림 (포인터 따라가며 캐시 미스)
대표 구현 Java ArrayList, Kotlin MutableList(Array 기반) Java LinkedList (Doubly)

핵심: “랜덤 접근/순차 스캔/메모리 효율”이면 배열, “중간 삽입/삭제가 매우 빈번 + 노드 참조를 이미 갖고 있음”이면 연결리스트.


2 구조(Structure)

2.1 배열

index: 0 1 2 3 4 5
memory: [A] [B] [C] [D] [E] [F]
↑연속 메모리 블록 → CPU 캐시에 잘 올라감

2.2 단일 연결리스트(Singly)

[A]→[B]→[C]→[D]→[E]
next next next ...
(메모리 여기저기 흩어짐 → 포인터 따라가며 캐시 미스 가능)

2.3 Signature

signature = Sign( base64url(header) + "." + base64url(payload), key, alg )

3 동적 배열의 리사이즈와 ‘평균 O(1)’

동적 배열(ArrayList 등)은 용량이 꽉 차면 더 큰 배열로 복사합니다(보통 x1.5~x2).
개별 add는 가끔 O(n) 복사를 겪지만, 아몰타이즈드(평균) O(1) 로 간주합니다.

val list = ArrayList<Int>()
repeat(1_000_000) { list.add(it) } // 평균 O(1) 삽입

4. 연결리스트가 진짜 빛나는 순간

노드 참조를 이미 갖고 있는 상태에서 그 자리에서 삽입/삭제해야 할 때 (예: LRU 캐시의 중간 노드 이동, 스케줄러의 ready 큐에서 특정 노드 O(1) 제거)

양방향 리스트는 앞/뒤 이동 및 삭제가 편하지만, 포인터가 늘어 오버헤드↑

// 단일 연결리스트 노드 기준: 현재 노드 뒤에 삽입 O(1)
class Node<T>(var value: T, var next: Node<T>? = null)
fun <T> insertAfter(cur: Node<T>, newNode: Node<T>) {
    newNode.next = cur.next
    cur.next = newNode
}

5. 시간 복잡도 & 상수 비용 감각

연산 배열 단일 연결리스트
get(i) O(1) O(n)
push_back Amortized O(1) O(1)(tail 보유 시)
insert(i, x) O(n) O(n)(찾기) + O(1)(삽입)
remove(i) O(n) O(n)(찾기) + O(1)(삭제)
순차 반복 빠름 (캐시 우수) 느림 (포인터 점프)

6. 메모리/GC 관점


7. 자주 하는 실수/오해


8. Kotlin/Java 실용 예시

8.1 중간 삽입이 있지만 “한 번에 모아서” 처리 (배열이 유리)

val list = ArrayList<Int>(10_000)
list.addAll(0..9999)
// 중간에 1000개 삽입이 필요하다면 → 새 리스트에 병합 후 한 번에 교체 (복사 비용 한번)
val add = (5000..5999).toList()
val merged = ArrayList<Int>(list.size + add.size).apply {
    addAll(list.subList(0, 5000))
    addAll(add)
    addAll(list.subList(5000, list.size))
}

8.2 LRU 캐시 같은 “노드 참조 기반 이동/삭제” (리스트가 유리)

class DNode<K, V>(var k: K, var v: V) {
    var prev: DNode<K, V>? = null
    var next: DNode<K, V>? = null
}
class DLinkedList<K, V> {
    private val head = DNode<K, V>(k = Any() as K, v = Any() as V)
    private val tail = DNode<K, V>(k = Any() as K, v = Any() as V)
    init { head.next = tail; tail.prev = head }
    fun addFront(n: DNode<K, V>) { n.next = head.next; n.prev = head; head.next!!.prev = n; head.next = n }
    fun remove(n: DNode<K, V>) { n.prev!!.next = n.next; n.next!!.prev = n.prev }
}

해시맵(Key→Node)과 양방향 리스트를 결합하면 이동/삭제 O(1) LRU를 만들 수 있습니다.


9. 선택 가이드 (실무 기준)


결론



Related Posts