배열 vs 연결리스트 시간복잡도 비교
각 연산별 시간복잡도를 비교하고 왜 그런지 이해하기
시간복잡도 비교표
| 연산 | 배열 | 동적 배열 | 단일 연결리스트 | 이중 연결리스트 |
|---|---|---|---|---|
| 접근 (인덱스) | O(1) | O(1) | O(n) | O(n) |
| 맨 앞 삽입 | O(n) | O(n) | O(1) | O(1) |
| 맨 뒤 삽입 | O(1)* | O(1)** | O(n)*** | O(1) |
| 중간 삽입 | O(n) | O(n) | O(n) | O(n) |
| 맨 앞 삭제 | O(n) | O(n) | O(1) | O(1) |
| 맨 뒤 삭제 | O(1)* | O(1) | O(n) | O(1) |
| 중간 삭제 | O(n) | O(n) | O(n) | O(n)**** |
| 탐색 (값) | O(n) | O(n) | O(n) | O(n) |
* 공간 있을 때 ** Amortized O(1) *** tail 포인터 있으면 O(1) **** 노드 참조 있으면 O(1)
연산별 상세 분석
1. 접근 (Access)
배열: O(1)
arr[i]의 주소 = base + i * size
→ 한 번의 계산으로 바로 접근
연결리스트: O(n)
head에서 시작해서 i번 next를 따라가야 함
→ 최악의 경우 n번 이동
2. 맨 앞 삽입/삭제
배열: O(n)
삽입 전: [A][B][C][D][ ]
삽입 후: [X][A][B][C][D]
모든 요소를 한 칸씩 뒤로 이동해야 함
// 배열 맨 앞 삽입
void insertFirst(int[] arr, int value, int size) {
for (int i = size; i > 0; i--) {
arr[i] = arr[i-1]; // O(n) 이동
}
arr[0] = value;
}연결리스트: O(1)
삽입 전: head -> [A] -> [B] -> [C]
삽입 후: head -> [X] -> [A] -> [B] -> [C]
포인터 2개만 변경
// 연결리스트 맨 앞 삽입
void insertFirst(Node newNode) {
newNode.next = head; // O(1)
head = newNode; // O(1)
}3. 맨 뒤 삽입/삭제
동적 배열: Amortized O(1)
일반적: O(1) - 빈 공간에 추가
확장 시: O(n) - 전체 복사
분할 상환: 여러 연산의 평균 → O(1)
단일 연결리스트
삽입: O(n) (tail 없으면) / O(1) (tail 있으면)
// tail 없을 때 - O(n)
void insertLast(int data) {
Node current = head;
while (current.next != null) { // O(n) 순회
current = current.next;
}
current.next = new Node(data);
}
// tail 있을 때 - O(1)
void insertLast(int data) {
tail.next = new Node(data);
tail = tail.next;
}삭제: O(n)
// 마지막 노드의 이전 노드를 찾아야 함
void deleteLast() {
Node current = head;
while (current.next.next != null) { // O(n)
current = current.next;
}
current.next = null;
tail = current;
}이중 연결리스트: O(1)
void deleteLast() {
tail = tail.prev; // O(1)
tail.next = null; // O(1)
}4. 중간 삽입/삭제
공통: O(n)
- 위치 탐색에 O(n) 필요
- 배열: 요소 이동 추가
- 연결리스트: 포인터 변경만
단, 연결리스트에서 노드 참조를 이미 가지고 있다면
→ 삽입/삭제 자체는 O(1)
5. 값 탐색
모두 O(n)
- 정렬되지 않은 경우 처음부터 순회 필요
- 정렬된 배열: 이진 탐색으로 O(log n) 가능
실제 성능 고려사항
캐시 효율성
배열:
┌────────────────────────────────┐
│ CPU가 arr[0] 읽을 때 │
│ → arr[0]~arr[15] 함께 캐시에 │
│ → 연속 접근 시 캐시 히트 │
└────────────────────────────────┘
연결리스트:
┌────────────────────────────────┐
│ 노드들이 메모리에 흩어져 있음 │
│ → 캐시 미스 빈번 │
│ → 실제 성능은 이론보다 나쁨 │
└────────────────────────────────┘
메모리 오버헤드
// 배열: 데이터만 저장
int[] arr = new int[100]; // 400 bytes
// 연결리스트: 데이터 + 포인터
class Node {
int data; // 4 bytes
Node next; // 8 bytes (64bit)
}
// 100개 노드 = 1200 bytes (3배)
// 이중 연결리스트
class Node {
int data; // 4 bytes
Node prev; // 8 bytes
Node next; // 8 bytes
}
// 100개 노드 = 2000 bytes (5배)정리: 언제 무엇을 사용?
배열/ArrayList 선택
✓ 인덱스 접근이 잦음
✓ 데이터 크기가 예측 가능
✓ 순차 처리 (for 루프)
✓ 메모리 효율 중요
LinkedList 선택
✓ 앞쪽 삽입/삭제가 잦음
✓ 크기 예측 불가
✓ 중간 삽입/삭제가 잦고, 위치를 이미 알고 있음
✓ 양방향 순회 필요 (이중)
면접 포인트
-
ArrayList와 LinkedList의 get(i) 차이는?
- ArrayList: O(1) - 인덱스 계산
- LinkedList: O(n) - 순차 탐색 (최적화로 양쪽에서)
-
왜 LinkedList의 중간 삽입도 O(n)인가?
- 삽입 자체는 O(1)이지만
- 위치를 찾는 데 O(n)이 필요
-
배열이 이론상 느린데 실제로 빠른 이유는?
- CPU 캐시 효율성
- 메모리 지역성 (Locality)
- 브랜치 예측 가능성
-
Java에서 어떤 상황에 LinkedList를 쓰나?
- Queue/Deque로 사용할 때
- 앞뒤 삽입/삭제가 주 연산일 때
- 대부분의 경우 ArrayList가 더 나음