Theme:

Stack도 있고 Queue도 있는데, 왜 Deque라는 자료구조가 따로 필요할까요?

Deque란

Deque(Double-Ended Queue, 덱)는 양쪽 끝에서 삽입과 삭제가 모두 가능한 자료구조입니다.

  • 앞(front)에서 삽입/삭제 가능
  • 뒤(rear)에서 삽입/삭제 가능
  • Stack으로도, Queue로도 사용할 수 있습니다
PLAINTEXT
         ← 삽입/삭제                삽입/삭제 →
  front [  A  |  B  |  C  |  D  ] rear
         → 삽입/삭제                삽입/삭제 ←

왜 필요한가

Stack과 Queue를 하나로 통합 할 수 있습니다.

사용 방식연산Deque 메서드
Stack (LIFO)pushaddFirst() 또는 addLast()
Stack (LIFO)popremoveFirst() 또는 removeLast()
Queue (FIFO)enqueueaddLast()
Queue (FIFO)dequeueremoveFirst()

Java에서 Stack 클래스는 Vector를 상속받아 불필요한 동기화 오버헤드가 있습니다. 공식 문서에서도 ArrayDeque 사용을 권장 합니다.

Java의 Deque 인터페이스

JAVA
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 — 원형 배열 기반

JAVA
// ArrayDeque 내부 (간략화)
public class ArrayDeque<E> {
    Object[] elements;  // 원형 배열
    int head;           // 앞쪽 인덱스
    int tail;           // 뒤쪽 인덱스
}

원형 배열(Circular Array) 방식으로 동작합니다.

PLAINTEXT
인덱스: [0] [1] [2] [3] [4] [5] [6] [7]
데이터:  D   E   .   .   .   A   B   C
              ↑ tail     head ↑
  • head는 첫 번째 요소의 위치
  • tail은 다음에 삽입할 뒤쪽 위치
  • 배열의 끝에 도달하면 처음으로 돌아갑니다

LinkedList — 이중 연결 리스트 기반

JAVA
Deque<String> deque = new LinkedList<>();
// 각 요소가 노드 객체로 할당됨
// 노드마다 prev, next 포인터 존재

성능 비교

특성ArrayDequeLinkedList
메모리연속 배열 (캐시 친화적)노드별 개별 할당
오버헤드배열 여분 공간포인터 2개/노드
양 끝 연산O(1) amortizedO(1)
null 허용불가가능
인덱스 접근지원하지 않음O(n)
GC 영향적음노드 객체 다수 생성

결론: 대부분의 경우 ArrayDeque가 더 빠릅니다. LinkedList는 노드 할당과 캐시 미스로 인해 실제 성능이 떨어집니다.

슬라이딩 윈도우 최댓값 — Deque의 대표적 활용

크기 k인 윈도우를 배열 위에서 움직이며 각 위치에서의 최댓값을 구하는 문제입니다.

문제

PLAINTEXT
배열: [1, 3, -1, -3, 5, 3, 6, 7], k = 3
결과: [3, 3, 5, 5, 6, 7]

브루트 포스: O(n × k)

JAVA
// 매 윈도우마다 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)

JAVA
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를 가집니다.

JAVA
// ForkJoinPool 내부 동작 (개념적)
// 각 워커 스레드가 자신의 Deque에서 작업을 꺼냄
// 자신의 Deque가 비면 다른 스레드의 Deque 뒤쪽에서 작업을 "훔쳐옴"

ForkJoinPool pool = new ForkJoinPool();
pool.submit(() -> {
    // 이 작업은 특정 스레드의 Deque에 들어감
    // 해당 스레드가 바쁘면 다른 스레드가 이 작업을 가져갈 수 있음
});
  • 자기 작업: 앞에서 꺼냄 (LIFO — 캐시 효율)
  • 다른 스레드 작업 훔치기: 뒤에서 꺼냄 (FIFO — 큰 작업 우선)

BFS 변형 — 0-1 BFS

가중치가 0 또는 1인 그래프에서 최단 경로를 찾을 때 Deque가 유용합니다.

JAVA
// 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이면 뒤에 추가
            }
        }
    }
}

스프링에서의 활용

JAVA
// 요청 처리 순서 관리
@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 사용 시 주의사항

  1. ArrayDeque는 null을 허용하지 않습니다 — 내부적으로 null을 빈 슬롯 마커로 사용
  2. ** 스레드 안전하지 않습니다** — 동시성이 필요하면 ConcurrentLinkedDeque 사용
  3. Stack 대용으로 쓸 때push()/pop()은 First 쪽에서 동작합니다
JAVA
// 스레드 안전한 Deque가 필요할 때
Deque<String> safeDeque = new ConcurrentLinkedDeque<>();

정리

  • Deque는 ** 양쪽에서 삽입/삭제가 가능한** 자료구조로, Stack과 Queue를 모두 대체할 수 있습니다.
  • Java에서는 ArrayDeque 를 기본 선택으로 사용하는 것이 좋습니다. 캐시 효율과 메모리 측면에서 LinkedList보다 유리합니다.
  • 슬라이딩 윈도우 문제에서 Deque를 활용하면 O(n)에 최댓값/최솟값 을 구할 수 있습니다.
  • 실무에서는 ForkJoinPool의 작업 스틸링, 요청 버퍼, 0-1 BFS 등에서 활용됩니다.
댓글 로딩 중...