Array와 Linked List의 차이점을 시간/공간 복잡도 관점에서 설명해주세요.
힌트
임의 접근, 삽입/삭제 연산의 비용을 생각해보세요.
정답 및 해설
Array와 Linked List의 차이점을 시간/공간 복잡도 관점에서 설명해주세요.
Array(배열)와 Linked List(연결 리스트)는 가장 기본적인 선형 자료구조입니다. 두 자료구조는 메모리 저장 방식이 근본적으로 달라, 각 연산의 시간/공간 복잡도에서 명확한 차이를 보입니다. 어떤 자료구조를 선택하느냐에 따라 프로그램의 성능이 크게 달라질 수 있습니다.
Array (배열)
메모리 구조
배열은 연속된 메모리 공간에 요소들을 저장합니다. 배열을 생성할 때 운영체제로부터 연속된 메모리 블록을 할당받고, 모든 요소는 고정된 크기로 인접하여 저장됩니다.
메모리 주소: 1000 1004 1008 1012 1016
배열 요소: [ 10 ][ 20 ][ 30 ][ 40 ][ 50 ]
인덱스: [0] [1] [2] [3] [4]
인덱스 i의 요소 주소 = 시작 주소 + i × 요소 크기
이 단순한 공식 덕분에 임의의 인덱스에 O(1) 로 즉시 접근할 수 있습니다.
시간 복잡도
| 연산 | 시간 복잡도 | 설명 |
|---|---|---|
| 접근 (Access) | O(1) | 인덱스로 직접 계산 |
| 탐색 (Search) | O(n) | 순차 탐색 필요 |
| 삽입 (Insert) - 끝 | O(1) | 단순 추가 |
| 삽입 (Insert) - 중간/앞 | O(n) | 요소 이동 필요 |
| 삭제 (Delete) - 끝 | O(1) | 단순 제거 |
| 삭제 (Delete) - 중간/앞 | O(n) | 요소 이동 필요 |
삽입/삭제 시 요소 이동
# 인덱스 2에 99 삽입하는 경우
arr = [10, 20, 30, 40, 50]
# 삽입 전: [10, 20, 30, 40, 50]
# 인덱스 2 이후 요소들을 모두 오른쪽으로 이동
# [10, 20, _, 30, 40, 50]
# [10, 20, 99, 30, 40, 50] <- 총 n-2회 이동 발생
arr.insert(2, 99)
print(arr) # [10, 20, 99, 30, 40, 50]
캐시 친화성 (Cache Friendliness)
배열은 연속된 메모리 배치 덕분에 CPU 캐시를 효율적으로 활용합니다. 배열의 한 요소에 접근하면 인접한 요소들도 캐시 라인에 함께 로드되어, 순차 접근 시 캐시 히트율이 높습니다.
캐시 라인 (64 bytes): [arr[0], arr[1], arr[2], ..., arr[15]]
↑ 한 번에 캐시에 로드됨
이 특성 때문에 실제 성능에서 배열이 연결 리스트보다 유리한 경우가 많습니다.
Linked List (연결 리스트)
메모리 구조
연결 리스트는 비연속적인 메모리에 노드(Node)를 분산 저장합니다. 각 노드는 데이터와 다음 노드를 가리키는 포인터로 구성됩니다.
노드 구조:
+--------+------+
| data | next |
+--------+------+
메모리상 배치 (비연속):
[data:10 | ptr:→] ... [data:20 | ptr:→] ... [data:30 | ptr:null]
주소 1000 주소 2048 주소 3512
class Node:
def __init__(self, data):
self.data = data
self.next = None # 다음 노드를 가리키는 포인터
class LinkedList:
def __init__(self):
self.head = None
def append(self, data):
new_node = Node(data)
if not self.head:
self.head = new_node
return
current = self.head
while current.next:
current = current.next
current.next = new_node
시간 복잡도
| 연산 | 시간 복잡도 | 설명 |
|---|---|---|
| 접근 (Access) | O(n) | head부터 순차 탐색 |
| 탐색 (Search) | O(n) | 순차 탐색 필요 |
| 삽입 - 맨 앞 | O(1) | head 포인터만 변경 |
| 삽입 - 위치 알 때 | O(1) | 포인터 조작만 필요 |
| 삽입 - 위치 모를 때 | O(n) | 탐색 후 삽입 |
| 삭제 - 맨 앞 | O(1) | head 포인터만 변경 |
| 삭제 - 위치 알 때 | O(1) | 포인터 조작만 필요 |
삽입/삭제의 O(1) 원리
# 특정 노드 뒤에 삽입 - 포인터 조작만으로 O(1)
def insert_after(prev_node, data):
new_node = Node(data)
new_node.next = prev_node.next # 새 노드의 next = 기존 다음 노드
prev_node.next = new_node # 이전 노드의 next = 새 노드
# 요소 이동 전혀 없음!
# 특정 노드 삭제 - 포인터 조작만으로 O(1)
def delete_after(prev_node):
if prev_node.next:
prev_node.next = prev_node.next.next # 다음 노드를 건너뜀
공간 복잡도 비교
배열 (int 5개):
[10][20][30][40][50]
= 5 × 4 bytes = 20 bytes
단순 연결 리스트 (int 5개, 64bit 포인터):
[10|ptr][20|ptr][30|ptr][40|ptr][50|null]
= 5 × (4 + 8) bytes = 60 bytes ← 포인터로 인한 오버헤드
연결 리스트는 각 노드마다 포인터 공간이 추가로 필요하여 배열보다 메모리를 더 사용합니다. 이중 연결 리스트(Doubly Linked List)의 경우 노드당 포인터가 2개이므로 오버헤드가 더 커집니다.
연결 리스트의 종류
단순 연결 리스트 (Singly Linked List)
head → [1|→] → [2|→] → [3|→] → [4|null]
이중 연결 리스트 (Doubly Linked List)
head → [null|1|→] ↔ [←|2|→] ↔ [←|3|→] ↔ [←|4|null]
이전 노드로도 이동 가능. 삭제 시 이전 노드를 O(1)에 찾을 수 있어 유리합니다.
원형 연결 리스트 (Circular Linked List)
head → [1|→] → [2|→] → [3|→] → [4|→] → (다시 head로)
마지막 노드가 첫 번째 노드를 가리킵니다. 순환 처리에 유용합니다.
실제 코드 비교 (Java)
import java.util.ArrayList;
import java.util.LinkedList;
public class DataStructureComparison {
public static void main(String[] args) {
int SIZE = 100_000;
// ArrayList (내부적으로 배열 사용)
ArrayList<Integer> arrayList = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < SIZE; i++) {
arrayList.add(0, i); // 맨 앞에 삽입: O(n)
}
System.out.println("ArrayList 앞 삽입: " + (System.nanoTime() - start) + "ns");
// LinkedList
LinkedList<Integer> linkedList = new LinkedList<>();
start = System.nanoTime();
for (int i = 0; i < SIZE; i++) {
linkedList.addFirst(i); // 맨 앞에 삽입: O(1)
}
System.out.println("LinkedList 앞 삽입: " + (System.nanoTime() - start) + "ns");
// 임의 접근 비교
start = System.nanoTime();
for (int i = 0; i < SIZE; i++) {
arrayList.get(i); // O(1)
}
System.out.println("ArrayList 임의 접근: " + (System.nanoTime() - start) + "ns");
start = System.nanoTime();
for (int i = 0; i < SIZE; i++) {
linkedList.get(i); // O(n) - 매우 느림
}
System.out.println("LinkedList 임의 접근: " + (System.nanoTime() - start) + "ns");
}
}
선택 기준
Array를 선택해야 할 때
- 인덱스 기반의 빠른 임의 접근이 필요한 경우
- 데이터 크기가 고정되어 있거나 자주 변하지 않는 경우
- 캐시 효율이 중요한 경우 (순차 탐색이 많을 때)
- 메모리 사용을 최소화해야 하는 경우
# 좋은 예: 학생 성적 조회 (인덱스로 빠른 접근)
scores = [95, 87, 92, 78, 88]
print(scores[2]) # O(1) - 즉시 접근
Linked List를 선택해야 할 때
- 빈번한 삽입/삭제가 필요한 경우 (특히 리스트 앞/중간)
- 데이터 크기가 동적으로 변하는 경우
- 메모리를 미리 예약할 필요가 없는 경우
- 스택, 큐와 같은 자료구조 구현의 기반으로 사용할 때
# 좋은 예: 브라우저 방문 기록 (앞에서 삽입/삭제 빈번)
from collections import deque
history = deque()
history.appendleft("google.com") # O(1)
history.appendleft("github.com") # O(1)
history.appendleft("stackoverflow.com") # O(1)
history.popleft() # 뒤로가기: O(1)
성능 요약 비교표
| 항목 | Array | Linked List |
|---|---|---|
| 메모리 배치 | 연속 | 비연속 |
| 임의 접근 | O(1) | O(n) |
| 순차 탐색 | O(n) | O(n) |
| 앞 삽입/삭제 | O(n) | O(1) |
| 중간 삽입/삭제 | O(n) | O(1) (위치 알 때) |
| 끝 삽입/삭제 | O(1) | O(n) / O(1) (tail 포인터 있을 때) |
| 추가 메모리 | 없음 | 포인터 오버헤드 |
| 캐시 효율 | 높음 | 낮음 |
| 크기 변경 | 어려움 (재할당) | 용이 |
| 적합한 용도 | 빈번한 조회 | 빈번한 삽입/삭제 |