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

Hàng đợi vòng

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

  • Cài hàng đợi vòng bằng cặp front và size
  • Phân biệt rỗng và đầy mà không cần hy sinh một ô
  • Thay chia dư bằng phép và bit khi kích thước là lũy thừa của hai
  • Nêu ứng dụng trong hệ nhúng

Bài 23.1 kết thúc với một hàng đợi mảng báo đầy dù mảng trống. Lời giải chỉ là một phép chia dư, nhưng đi kèm một câu hỏi tinh tế: làm sao phân biệt hàng đợi rỗng với hàng đợi đầy khi hai chỉ số trùng nhau.

#Chỉ số quay vòng

Mảng vòng
Một mảng thường, nhưng chỉ số được lấy dư cho kích thước mảng. Ô sau ô cuối cùng là ô đầu tiên, nên mảng hành xử như một vòng tròn và không ô nào bị bỏ phí.
front bằng 5 và size bằng 4, nên các ô đang dùng là 5, 6, 7, 0. Vị trí thêm tiếp theo là ô 1.
/* Không quay vòng: chỉ số chỉ tăng, chạm biên là hết */
q->data[q->rear++] = v;

/* Quay vòng: chỉ số quay lại 0 sau khi vượt quá cuối mảng */
q->data[(q->front + q->size) % QMAX] = v;
Thao tácfrontsizeÔ được ghi hoặc đọc
Khởi tạo00-
enqueue 1001(0 + 0) % 8 = 0
enqueue 2002(0 + 1) % 8 = 1
dequeue -> 10110
dequeue -> 20201
... sau nhiều lần, front = 660-
enqueue 3061(6 + 0) % 8 = 6
enqueue 4062(6 + 1) % 8 = 7
enqueue 5063(6 + 2) % 8 = 0 quay vòng

#Phân biệt rỗng với đầy

Nếu giữ hai chỉ số front và rear thì cả hàng đợi rỗng lẫn hàng đợi đầy đều cho front == rear. Có ba cách chữa.

CáchRỗng khiĐầy khiĐánh giá
Hy sinh một ô, không bao giờ dùng hết mảngfront == rear(rear + 1) % N == frontĐơn giản, mất một ô
Giữ thêm trường sizesize == 0size == NRõ ràng nhất, tốn thêm một trường
Giữ thêm một cờ đầyfront == rear và cờ tắtfront == rear và cờ bậtPhải nhớ bật tắt cờ ở mọi chỗ, dễ sai
Hai chỉ số, không có gì để phân biệt
typedef struct {
    int    data[QMAX];
    size_t front, rear;
} CQTe;

/* front == rear ở CẢ HAI trạng thái, không phân biệt được */
int cq_rong(const CQTe *q) { return q->front == q->rear; }
int cq_day (const CQTe *q) { return q->front == q->rear; }   /* vô nghĩa */
front và size
typedef struct {
    int    data[QMAX];
    size_t front;      /* chỉ số phần tử đầu */
    size_t size;       /* số phần tử đang có */
} CQ;

int cq_rong(const CQ *q) { return q->size == 0; }
int cq_day (const CQ *q) { return q->size == QMAX; }

#Cài đặt đầy đủ

cqueue.h
#ifndef CQUEUE_H
#define CQUEUE_H

#include <stddef.h>

#define QMAX 8

/* Bất biến:
     1. 0 <= front < QMAX
     2. 0 <= size <= QMAX
     3. Phần tử thứ i tính từ đầu nằm ở data[(front + i) % QMAX]  */
typedef struct {
    int    data[QMAX];
    size_t front;
    size_t size;
} CQueue;

void   cq_khoi_tao(CQueue *q);
int    cq_rong(const CQueue *q);
int    cq_day(const CQueue *q);
size_t cq_so_phan_tu(const CQueue *q);
int    cq_enqueue(CQueue *q, int v);
int    cq_dequeue(CQueue *q, int *out);
int    cq_xem_dau(const CQueue *q, int *out);
void   cq_in(const CQueue *q);

#endif
cqueue.c
#include <stdio.h>

#include "cqueue.h"

void cq_khoi_tao(CQueue *q)
{
    q->front = 0;
    q->size  = 0;
}

int cq_rong(const CQueue *q) { return q->size == 0; }
int cq_day (const CQueue *q) { return q->size == QMAX; }

size_t cq_so_phan_tu(const CQueue *q) { return q->size; }

/* Thêm vào đuôi. O(1). Trả về 0 nếu ổn, -1 nếu đầy. */
int cq_enqueue(CQueue *q, int v)
{
    if (q->size == QMAX) return -1;

    q->data[(q->front + q->size) % QMAX] = v;
    ++q->size;

    return 0;
}

/* Lấy ở đầu. O(1). Trả về 0 nếu ổn, -1 nếu rỗng. */
int cq_dequeue(CQueue *q, int *out)
{
    if (q->size == 0) return -1;

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

    q->front = (q->front + 1) % QMAX;
    --q->size;

    return 0;
}

int cq_xem_dau(const CQueue *q, int *out)
{
    if (q->size == 0) return -1;

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

    return 0;
}

/* In cả trạng thái bên trong để thấy chỉ số quay vòng. */
void cq_in(const CQueue *q)
{
    printf("front=%zu size=%zu  [", q->front, q->size);

    for (size_t i = 0; i < q->size; ++i) {
        printf("%d", q->data[(q->front + i) % QMAX]);

        if (i + 1 < q->size) printf(", ");
    }

    printf("]  o nho: ");

    for (size_t k = 0; k < QMAX; ++k) {
        int dang_dung = 0;

        for (size_t i = 0; i < q->size; ++i)
            if ((q->front + i) % QMAX == k) dang_dung = 1;

        if (dang_dung) printf("%d ", q->data[k]);
        else           printf(". ");
    }

    printf("\n");
}
main.c
#include <stdio.h>

#include "cqueue.h"

int main(void)
{
    CQueue q;
    int    v;

    cq_khoi_tao(&q);

    for (int i = 1; i <= 6; ++i) cq_enqueue(&q, i * 10);

    cq_in(&q);

    for (int i = 0; i < 5; ++i) cq_dequeue(&q, &v);

    cq_in(&q);

    /* Thêm tiếp, chỉ số sẽ quay vòng qua đầu mảng */
    for (int i = 1; i <= 6; ++i) cq_enqueue(&q, i * 100);

    cq_in(&q);

    printf("day chua : %d\n", cq_day(&q));
    printf("them nua : %d\n", cq_enqueue(&q, 999));

    printf("lay het  : ");

    while (cq_dequeue(&q, &v) == 0) printf("%d ", v);

    printf("\n");
    cq_in(&q);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g cqueue.c main.c -o t && ./t
front=0 size=6  [10, 20, 30, 40, 50, 60]  o nho: 10 20 30 40 50 60 . . 
front=5 size=1  [60]  o nho: . . . . . 60 . . 
front=5 size=7  [60, 100, 200, 300, 400, 500, 600]  o nho: 300 400 500 . . 60 100 200 
day chua : 0
them nua : 0
lay het  : 60 100 200 300 400 500 600 999 
front=0 size=0  []  o nho: . . . . . . . . 

#Thay chia dư bằng phép và bit

Phép chia dư là một trong những lệnh chậm nhất của bộ xử lý, thường hai mươi tới bốn mươi chu kỳ. Khi kích thước mảng là lũy thừa của hai thì thay được bằng phép và bit, chỉ một chu kỳ.

Đồng nhất thức
/* Với N là lũy thừa của 2:
     x % N  ==  x & (N - 1)      khi x không âm

   Ví dụ N = 8, tức N - 1 = 7 = 0b111:
     13 % 8 = 5      13 & 7 = 0b1101 & 0b0111 = 0b101 = 5
     16 % 8 = 0      16 & 7 = 0b10000 & 0b00111 = 0        */

#define QMAX      8
#define QMASK     (QMAX - 1)

_Static_assert((QMAX & QMASK) == 0, "QMAX phai la luy thua cua 2");

int cq_enqueue(CQueue *q, int v)
{
    if (q->size == QMAX) return -1;

    q->data[(q->front + q->size) & QMASK] = v;
    ++q->size;

    return 0;
}
terminal
# Một trăm triệu lần enqueue rồi dequeue
gcc -std=c17 -O2 do-chia-du.c -o t && ./t
chia du (%)  : 0.412 s
va bit (&)   : 0.198 s
nhanh hon    : 2.1 lan

#Bộ đệm vòng trong hệ nhúng

Hàng đợi vòng là cấu trúc dữ liệu được dùng nhiều nhất trong lập trình nhúng, vì nó có ba tính chất mà hệ nhúng cần: kích thước cố định biết trước, không cấp phát động, và mọi thao tác là O(1) với thời gian không đổi.

Nơi dùngAi ghi vàoAi đọc ra
Bộ đệm nhận của cổng nối tiếpTrình xử lý ngắt khi có byte tớiVòng lặp chính khi rảnh
Bộ đệm âm thanhChương trình sinh mẫuBộ chuyển đổi số sang tương tự, theo ngắt định thời
Hàng đợi sự kiệnNhiều nguồn ngắtVòng lặp xử lý sự kiện
Nhật ký vòngMọi nơi trong chương trìnhĐọc ra khi gỡ lỗi, ghi đè bản ghi cũ nhất khi đầy
uart-buffer.c
#include <stdint.h>

#define RX_SIZE  256
#define RX_MASK  (RX_SIZE - 1)

_Static_assert((RX_SIZE & RX_MASK) == 0, "RX_SIZE phai la luy thua cua 2");

/* Một bên ghi, một bên đọc. Với đúng một người ghi và đúng một người đọc,
   cấu trúc này an toàn mà KHÔNG cần khóa, nếu hai chỉ số là nguyên tử. */
static volatile uint8_t  rx_dem[RX_SIZE];
static volatile uint16_t rx_ghi;      /* chỉ trình xử lý ngắt sửa */
static volatile uint16_t rx_doc;      /* chỉ vòng lặp chính sửa */

/* Gọi từ trình xử lý ngắt. Trả về 0 nếu chứa được, -1 nếu đầy. */
int rx_them(uint8_t b)
{
    uint16_t ke = (uint16_t)((rx_ghi + 1) & RX_MASK);

    if (ke == rx_doc) return -1;      /* đầy, hy sinh một ô để phân biệt */

    rx_dem[rx_ghi] = b;
    rx_ghi         = ke;              /* cập nhật SAU khi ghi dữ liệu */

    return 0;
}

/* Gọi từ vòng lặp chính. Trả về 0 nếu lấy được, -1 nếu rỗng. */
int rx_lay(uint8_t *ra)
{
    if (rx_doc == rx_ghi) return -1;  /* rỗng */

    *ra    = rx_dem[rx_doc];
    rx_doc = (uint16_t)((rx_doc + 1) & RX_MASK);

    return 0;
}

Nhật ký vòng: ghi đè bản ghi cũ nhất

Ghi đè thay vì báo đầy
/* Với nhật ký, đầy không nên là lỗi. Ghi đè bản ghi cũ nhất
   hợp lý hơn, vì thông tin gần đây quan trọng hơn thông tin cũ. */
void log_them(CQueue *q, int v)
{
    if (q->size == QMAX) {
        q->data[q->front] = v;                    /* ghi đè ô cũ nhất */
        q->front          = (q->front + 1) % QMAX; /* đẩy front lên */
    } else {
        q->data[(q->front + q->size) % QMAX] = v;
        ++q->size;
    }
}
terminal
./nhat-ky-vong
them 1..12 vao bo dem 8 o:
[5, 6, 7, 8, 9, 10, 11, 12]
bon ban ghi dau tien da bi ghi de

Tự làm thử

  1. Cài CQueue với front và size, kèm hàm cq_in hiện cả trạng thái bộ nhớ để thấy chỉ số quay vòng.
  2. Cài bản hy sinh một ô, rồi so số phần tử tối đa chứa được của hai bản với cùng QMAX.
  3. Đổi QMAX thành 100 trong bản dùng phép và bit, xác nhận _Static_assert chặn được lúc biên dịch.
  4. Đo thời gian mười triệu lần enqueue và dequeue với % và với &, kích thước truyền qua biến để trình biên dịch không tự tối ưu.
  5. Cài nhật ký vòng ghi đè bản ghi cũ nhất, rồi thêm hàm đọc ra theo thứ tự từ cũ tới mới.

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

  • Hàng đợi vòng lấy dư chỉ số cho kích thước mảng, nên không ô nào bị bỏ phí.
  • Giữ front và size là cách rõ ràng nhất để phân biệt rỗng với đầy, và không mất ô nào.
  • Duyệt phải theo chỉ số logic rồi ánh xạ sang vật lý bằng (front + i) % N, không được duyệt thẳng theo chỉ số mảng.
  • Với kích thước là lũy thừa của hai thì % N thay được bằng & (N - 1). Đặt _Static_assert để chặn kích thước sai.
  • Trong bối cảnh có ngắt, hai chỉ số độc lập an toàn hơn một trường size mà cả hai bên cùng sửa.