Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 23.320 phút đọc

Deque

Sau bài này bạn sẽ làm được

  • Cài bốn thao tác của deque
  • So sánh cài bằng danh sách đôi và bằng mảng vòng
  • Dùng deque giải bài cửa sổ trượt lớn nhất
  • Thấy deque thay được cả ngăn xếp lẫn hàng đợi

Deque cho phép thêm và lấy ở cả hai đầu, tất cả trong O(1). Nó là cấu trúc tổng quát hơn cả ngăn xếp lẫn hàng đợi, và nó giải được một lớp bài toán mà hai cấu trúc kia bó tay: cửa sổ trượt.

#Bốn thao tác

Deque
Double-Ended Queue, hàng đợi hai đầu. Thêm và lấy được ở cả đầu lẫn cuối, mỗi thao tác O(1).
int deque_them_dau (Deque *d, int v);
int deque_them_cuoi(Deque *d, int v);
int deque_lay_dau  (Deque *d, int *out);
int deque_lay_cuoi (Deque *d, int *out);

/* Cộng ba hàm hỏi trạng thái */
int    deque_rong(const Deque *d);
size_t deque_so_phan_tu(const Deque *d);
int    deque_xem_dau(const Deque *d, int *out);
int    deque_xem_cuoi(const Deque *d, int *out);
Ngăn xếpHàng đợiDeque
Thêm đầuKhôngKhôngO(1)
Thêm cuốiO(1)O(1)O(1)
Lấy đầuKhôngO(1)O(1)
Lấy cuốiO(1)KhôngO(1)
Truy cập phần tử thứ iKhôngKhôngO(1) nếu cài bằng mảng

#Cài bằng mảng vòng

Bài 23.2 đã có gần hết. Chỉ cần thêm phép thêm ở đầu, tức lùi front một bước theo vòng.

deque.h
#ifndef DEQUE_H
#define DEQUE_H

#include <stddef.h>

#define DMAX 8

typedef struct {
    int    data[DMAX];
    size_t front;
    size_t size;
} Deque;

void   dq_khoi_tao(Deque *d);
int    dq_rong(const Deque *d);
int    dq_day(const Deque *d);
size_t dq_so_phan_tu(const Deque *d);
int    dq_them_dau(Deque *d, int v);
int    dq_them_cuoi(Deque *d, int v);
int    dq_lay_dau(Deque *d, int *out);
int    dq_lay_cuoi(Deque *d, int *out);
int    dq_tai(const Deque *d, size_t i, int *out);

#endif
deque.c
#include "deque.h"

void dq_khoi_tao(Deque *d) { d->front = 0; d->size = 0; }

int dq_rong(const Deque *d) { return d->size == 0; }
int dq_day (const Deque *d) { return d->size == DMAX; }

size_t dq_so_phan_tu(const Deque *d) { return d->size; }

/* Thêm vào cuối: y hệt enqueue của Bài 23.2. */
int dq_them_cuoi(Deque *d, int v)
{
    if (d->size == DMAX) return -1;

    d->data[(d->front + d->size) % DMAX] = v;
    ++d->size;

    return 0;
}

/* Thêm vào đầu: LÙI front một bước theo vòng, rồi ghi vào đó. */
int dq_them_dau(Deque *d, int v)
{
    if (d->size == DMAX) return -1;

    d->front = (d->front + DMAX - 1) % DMAX;      /* lùi một, không dùng số âm */
    d->data[d->front] = v;
    ++d->size;

    return 0;
}

/* Lấy ở đầu: y hệt dequeue. */
int dq_lay_dau(Deque *d, int *out)
{
    if (d->size == 0) return -1;

    if (out != NULL) *out = d->data[d->front];

    d->front = (d->front + 1) % DMAX;
    --d->size;

    return 0;
}

/* Lấy ở cuối: chỉ cần giảm size, front không đổi. */
int dq_lay_cuoi(Deque *d, int *out)
{
    if (d->size == 0) return -1;

    if (out != NULL) *out = d->data[(d->front + d->size - 1) % DMAX];

    --d->size;

    return 0;
}

/* Truy cập phần tử thứ i tính từ đầu. O(1). */
int dq_tai(const Deque *d, size_t i, int *out)
{
    if (i >= d->size) return -1;

    *out = d->data[(d->front + i) % DMAX];

    return 0;
}

#Cài bằng danh sách đôi

Danh sách đôi ở Bài 21.7 vốn đã cho cả bốn thao tác trong O(1). Chỉ cần đặt tên lại cho hợp ngữ cảnh.

deque-lk.c
#include <stdlib.h>

typedef struct DNode {
    int           data;
    struct DNode *prev, *next;
} DNode;

typedef struct {
    DNode *head, *tail;
    size_t size;
} DequeLK;

int dql_them_dau(DequeLK *d, int v)
{
    DNode *n = malloc(sizeof *n);

    if (n == NULL) return -1;

    n->data = v;
    n->prev = NULL;
    n->next = d->head;

    if (d->head != NULL) d->head->prev = n;
    else                 d->tail       = n;

    d->head = n;
    ++d->size;

    return 0;
}

int dql_lay_cuoi(DequeLK *d, int *out)
{
    if (d->tail == NULL) return -1;

    DNode *n = d->tail;

    if (out != NULL) *out = n->data;

    d->tail = n->prev;

    if (d->tail != NULL) d->tail->next = NULL;
    else                 d->head       = NULL;

    free(n);
    --d->size;

    return 0;
}
Mảng vòngDanh sách đôi
Bốn thao tác chínhO(1)O(1)
Truy cập phần tử thứ iO(1)O(n)
Kích thướcCố định, hoặc phải cấp lạiKhông giới hạn
Bộ nhớ mỗi phần tử4 byte40 byte thực tế
Thân thiện bộ nhớ đệmRất tốtKém
Con trỏ tới phần tử có bền khôngKhôngCó
Số dòng cài đặtÍt hơnNhiều hơn

#Cửa sổ trượt lớn nhất

Cho mảng n số và cửa sổ rộng k. Tìm giá trị lớn nhất trong mỗi cửa sổ khi nó trượt từ trái sang phải. Cách ngây thơ tốn O(n nhân k). Deque cho O(n).

cua-so.c
#include <stdio.h>
#include <stdlib.h>

/* Deque chứa CHỈ SỐ, không chứa giá trị.
   Bất biến: các giá trị tại những chỉ số trong deque giảm dần.
   Nhờ vậy phần tử đầu deque luôn là chỉ số của giá trị lớn nhất
   trong cửa sổ hiện tại.                                          */
int cua_so_lon_nhat(const int *a, size_t n, size_t k, int *ra)
{
    if (a == NULL || ra == NULL || k == 0 || k > n) return -1;

    size_t *dq = malloc(n * sizeof *dq);      /* deque chỉ số, đủ chỗ xấu nhất */

    if (dq == NULL) return -1;

    size_t dau = 0, cuoi = 0;                 /* [dau, cuoi) là vùng dùng */
    size_t m = 0;

    for (size_t i = 0; i < n; ++i) {
        /* 1. Bỏ ở ĐẦU những chỉ số đã rơi ra ngoài cửa sổ */
        while (dau < cuoi && dq[dau] + k <= i) ++dau;

        /* 2. Bỏ ở CUỐI những chỉ số có giá trị nhỏ hơn hoặc bằng a[i].
              Chúng không bao giờ còn là lớn nhất nữa, vì a[i] vào sau
              và lớn hơn, nên sẽ ở lại lâu hơn.                        */
        while (dau < cuoi && a[dq[cuoi - 1]] <= a[i]) --cuoi;

        /* 3. Thêm i vào cuối */
        dq[cuoi++] = i;

        /* 4. Từ cửa sổ đầy đủ trở đi, ghi kết quả */
        if (i + 1 >= k) ra[m++] = a[dq[dau]];
    }

    free(dq);

    return 0;
}
main.c
#include <stdio.h>

int cua_so_lon_nhat(const int *a, size_t n, size_t k, int *ra);

int main(void)
{
    int    a[] = { 1, 3, -1, -3, 5, 3, 6, 7 };
    size_t n   = sizeof a / sizeof a[0];
    size_t k   = 3;
    int    ra[8];

    if (cua_so_lon_nhat(a, n, k, ra) != 0) return 1;

    printf("mang : ");

    for (size_t i = 0; i < n; ++i) printf("%d ", a[i]);

    printf("\nk = %zu\n", k);

    for (size_t i = 0; i + k <= n; ++i) {
        printf("cua so [");

        for (size_t j = i; j < i + k; ++j) printf("%d%s", a[j], j + 1 < i + k ? " " : "");

        printf("] -> %d\n", ra[i]);
    }

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g cua-so.c main.c -o t && ./t
mang : 1 3 -1 -3 5 3 6 7 
k = 3
cua so [1 3 -1] -> 3
cua so [3 -1 -3] -> 3
cua so [-1 -3 5] -> 5
cua so [-3 5 3] -> 5
cua so [5 3 6] -> 6
cua so [3 6 7] -> 7
# So với cách ngây thơ trên mảng một triệu phần tử, k = 1000
./do-cua-so 1000000 1000
ngay tho : 4.812 s
deque    : 0.011 s
nhanh hon: 437.5 lan

#Deque thay được cả ngăn xếp lẫn hàng đợi

Dùng deque làm ngăn xếp và hàng đợi
Deque d;

dq_khoi_tao(&d);

/* Dùng như NGĂN XẾP: chỉ đụng vào cuối */
dq_them_cuoi(&d, 10);
dq_them_cuoi(&d, 20);
dq_lay_cuoi(&d, &v);      /* 20, LIFO */

/* Dùng như HÀNG ĐỢI: thêm cuối, lấy đầu */
dq_them_cuoi(&d, 10);
dq_them_cuoi(&d, 20);
dq_lay_dau(&d, &v);       /* 10, FIFO */

Ứng dụng thật của deque

Ứng dụngVì sao cần cả hai đầu
Cửa sổ trượt lớn nhất và nhỏ nhấtBỏ ở đầu khi hết hạn, bỏ ở cuối khi bị lấn át
Lịch sử duyệt web có giới hạnThêm ở cuối, và bỏ mục cũ nhất ở đầu khi vượt giới hạn
Hoàn tác có giới hạn số bướcCùng lý do trên
Ăn cắp việc trong lập lịch song songLuồng chủ lấy ở một đầu, luồng khác cướp việc ở đầu kia
Thuật toán 0-1 BFSCạnh trọng số 0 đẩy vào đầu, cạnh trọng số 1 đẩy vào cuối

Tự làm thử

  1. Cài Deque bằng mảng vòng với đủ bốn thao tác cộng dq_tai.
  2. Viết bản dq_them_dau dùng (front - 1) % DMAX, đổi DMAX thành 10 và xác nhận nó sai.
  3. Cài cua_so_lon_nhat và kiểm với mảng trong bài, rồi với k bằng 1 và k bằng n.
  4. Sửa hàm đó thành cua_so_nho_nhat chỉ bằng cách đổi một dấu so sánh.
  5. Đo thời gian của cách ngây thơ và cách deque với n bằng một triệu và k bằng 10, 100, 1000. Giải thích vì sao tỷ lệ tăng theo k.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Deque cho thêm và lấy ở cả hai đầu trong O(1). Ngăn xếp và hàng đợi đều là deque bị bịt bớt một phía.
  • Lùi chỉ số theo vòng phải viết (front + N - 1) % N, không được viết (front - 1) % N với kiểu không dấu.
  • Cài bằng mảng vòng cho truy cập theo chỉ số O(1) và tiết kiệm bộ nhớ. Cài bằng danh sách đôi cho kích thước không giới hạn và con trỏ bền.
  • Bài cửa sổ trượt cần bỏ ở cả hai đầu, nên nó là bài toán mà chỉ deque giải được trong O(n).
  • Có deque rồi vẫn nên dùng kiểu chuyên biệt khi bài toán chỉ cần một đầu, vì mã nói được ý định và trình biên dịch bắt lỗi giúp.