Deque — 양쪽에서 넣고 빼는 만능 자료구조
Stack도 있고 Queue도 있는데, 왜 Deque라는 자료구조가 따로 필요할까요?
Deque란
Deque(Double-Ended Queue, 덱)는 양쪽 끝에서 삽입과 삭제가 모두 가능한 자료구조입니다.
- 앞(front)에서 삽입/삭제 가능
- 뒤(rear)에서 삽입/삭제 가능
- Stack으로도, Queue로도 사용할 수 있습니다
← 삽입/삭제 삽입/삭제 →
front [ A | B | C | D ] rear
→ 삽입/삭제 삽입/삭제 ←
왜 필요한가
Stack과 Queue를 하나로 통합 할 수 있습니다.
| 사용 방식 | 연산 | Deque 메서드 |
|---|---|---|
| Stack (LIFO) | push | addFirst() 또는 addLast() |
| Stack (LIFO) | pop | removeFirst() 또는 removeLast() |
| Queue (FIFO) | enqueue | addLast() |
| Queue (FIFO) | dequeue | removeFirst() |
Java에서 Stack 클래스는 Vector를 상속받아 불필요한 동기화 오버헤드가 있습니다. 공식 문서에서도 ArrayDeque 사용을 권장 합니다.
Java의 Deque 인터페이스
import java.util.Deque;
import java.util.ArrayDeque;
Deque<String> deque = new ArrayDeque<>();
// 앞에서 삽입/삭제
deque.addFirst("A"); // [A]
deque.addFirst("B"); // [B, A]
deque.removeFirst(); // B 반환, [A]
// 뒤에서 삽입/삭제
deque.addLast("C"); // [A, C]
deque.removeLast(); // C 반환, [A]
// Stack처럼 사용
deque.push("X"); // addFirst와 같음
deque.pop(); // removeFirst와 같음
// Queue처럼 사용
deque.offer("Y"); // addLast와 같음
deque.poll(); // removeFirst와 같음
// 조회 (제거하지 않음)
deque.peekFirst(); // 앞 요소 확인
deque.peekLast(); // 뒤 요소 확인
ArrayDeque vs LinkedList
Java에서 Deque 구현체는 크게 두 가지입니다.
ArrayDeque — 원형 배열 기반
// ArrayDeque 내부 (간략화)
public class ArrayDeque<E> {
Object[] elements; // 원형 배열
int head; // 앞쪽 인덱스
int tail; // 뒤쪽 인덱스
}
원형 배열(Circular Array) 방식으로 동작합니다.
인덱스: [0] [1] [2] [3] [4] [5] [6] [7]
데이터: D E . . . A B C
↑ tail head ↑
head는 첫 번째 요소의 위치tail은 다음에 삽입할 뒤쪽 위치- 배열의 끝에 도달하면 처음으로 돌아갑니다
LinkedList — 이중 연결 리스트 기반
Deque<String> deque = new LinkedList<>();
// 각 요소가 노드 객체로 할당됨
// 노드마다 prev, next 포인터 존재
성능 비교
| 특성 | ArrayDeque | LinkedList |
|---|---|---|
| 메모리 | 연속 배열 (캐시 친화적) | 노드별 개별 할당 |
| 오버헤드 | 배열 여분 공간 | 포인터 2개/노드 |
| 양 끝 연산 | O(1) amortized | O(1) |
| null 허용 | 불가 | 가능 |
| 인덱스 접근 | 지원하지 않음 | O(n) |
| GC 영향 | 적음 | 노드 객체 다수 생성 |
결론: 대부분의 경우 ArrayDeque가 더 빠릅니다. LinkedList는 노드 할당과 캐시 미스로 인해 실제 성능이 떨어집니다.
슬라이딩 윈도우 최댓값 — Deque의 대표적 활용
크기 k인 윈도우를 배열 위에서 움직이며 각 위치에서의 최댓값을 구하는 문제입니다.
문제
배열: [1, 3, -1, -3, 5, 3, 6, 7], k = 3
결과: [3, 3, 5, 5, 6, 7]
브루트 포스: O(n × k)
// 매 윈도우마다 k개를 비교
for (int i = 0; i <= n - k; i++) {
int max = Integer.MIN_VALUE;
for (int j = i; j < i + k; j++) {
max = Math.max(max, arr[j]);
}
result.add(max);
}
Deque 풀이: O(n)
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] result = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>(); // 인덱스를 저장
for (int i = 0; i < n; i++) {
// 윈도우 범위를 벗어난 요소 제거
while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 현재 값보다 작은 요소는 더 이상 최댓값이 될 수 없으므로 제거
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast();
}
deque.addLast(i);
// 윈도우가 완성된 시점부터 결과 기록
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
핵심 아이디어는 다음과 같습니다.
- Deque에 ** 인덱스 **를 저장합니다.
- Deque의 앞(front)에는 항상 현재 윈도우의 최댓값 인덱스가 위치합니다.
- 새 요소가 들어오면, 그보다 작은 요소들은 뒤에서 제거합니다 (더 이상 최댓값이 될 수 없으므로).
- 각 요소는 최대 한 번 삽입, 한 번 삭제되므로 ** 전체 O(n)**입니다.
실무에서의 Deque 활용
작업 스틸링 (Work Stealing)
Java의 ForkJoinPool은 각 스레드가 자체 Deque를 가집니다.
// ForkJoinPool 내부 동작 (개념적)
// 각 워커 스레드가 자신의 Deque에서 작업을 꺼냄
// 자신의 Deque가 비면 다른 스레드의 Deque 뒤쪽에서 작업을 "훔쳐옴"
ForkJoinPool pool = new ForkJoinPool();
pool.submit(() -> {
// 이 작업은 특정 스레드의 Deque에 들어감
// 해당 스레드가 바쁘면 다른 스레드가 이 작업을 가져갈 수 있음
});
- 자기 작업: 앞에서 꺼냄 (LIFO — 캐시 효율)
- 다른 스레드 작업 훔치기: 뒤에서 꺼냄 (FIFO — 큰 작업 우선)
BFS 변형 — 0-1 BFS
가중치가 0 또는 1인 그래프에서 최단 경로를 찾을 때 Deque가 유용합니다.
// 0-1 BFS: Dijkstra 대신 Deque로 O(V + E)에 해결
Deque<Integer> deque = new ArrayDeque<>();
deque.addFirst(start);
while (!deque.isEmpty()) {
int u = deque.pollFirst();
for (Edge e : graph[u]) {
if (dist[u] + e.weight < dist[e.to]) {
dist[e.to] = dist[u] + e.weight;
if (e.weight == 0) {
deque.addFirst(e.to); // 가중치 0이면 앞에 추가
} else {
deque.addLast(e.to); // 가중치 1이면 뒤에 추가
}
}
}
}
스프링에서의 활용
// 요청 처리 순서 관리
@Component
public class RequestBuffer {
// 최근 요청을 앞에, 오래된 요청을 뒤에서 제거
private final Deque<Request> buffer = new ArrayDeque<>();
public synchronized void addRequest(Request request) {
buffer.addFirst(request);
if (buffer.size() > MAX_SIZE) {
buffer.removeLast(); // 오래된 요청 제거
}
}
// 최근 N개 요청 조회
public List<Request> getRecent(int n) {
return buffer.stream().limit(n).collect(Collectors.toList());
}
}
Deque 사용 시 주의사항
- ArrayDeque는 null을 허용하지 않습니다 — 내부적으로 null을 빈 슬롯 마커로 사용
- ** 스레드 안전하지 않습니다** — 동시성이 필요하면
ConcurrentLinkedDeque사용 - Stack 대용으로 쓸 때 —
push()/pop()은 First 쪽에서 동작합니다
// 스레드 안전한 Deque가 필요할 때
Deque<String> safeDeque = new ConcurrentLinkedDeque<>();
정리
- Deque는 ** 양쪽에서 삽입/삭제가 가능한** 자료구조로, Stack과 Queue를 모두 대체할 수 있습니다.
- Java에서는 ArrayDeque 를 기본 선택으로 사용하는 것이 좋습니다. 캐시 효율과 메모리 측면에서 LinkedList보다 유리합니다.
- 슬라이딩 윈도우 문제에서 Deque를 활용하면 O(n)에 최댓값/최솟값 을 구할 수 있습니다.
- 실무에서는 ForkJoinPool의 작업 스틸링, 요청 버퍼, 0-1 BFS 등에서 활용됩니다.
댓글 로딩 중...