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

Hàng đợi cơ bản

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

  • Cài enqueue và dequeue trong O(1)
  • Xử lý đúng lúc hàng đợi trở thành rỗng
  • Giải thích vì sao thêm ở đuôi và lấy ở đầu
  • Hủy hàng đợi không rò rỉ

Hàng đợi đảo ngược quy tắc của ngăn xếp: vào trước ra trước. Nó cần hai đầu, nên cần hai con trỏ, và cả hai đều có một trường hợp biên riêng khi hàng đợi trở nên rỗng.

#FIFO và hai đầu

FIFO
First In, First Out. Phần tử vào trước là phần tử ra trước. Như xếp hàng mua vé: ai đến trước được phục vụ trước.
Thêm ở đuôi, lấy ở đầu. Thiếu con trỏ rear thì mỗi lần enqueue phải duyệt cả danh sách.
Ngăn xếpHàng đợi
Quy tắcLIFOFIFO
Số đầu phải giữ12
Thêm ởĐỉnhĐuôi
Lấy ởĐỉnhĐầu
Dùng choCấu trúc lồng nhauXử lý theo thứ tự đến
Duyệt câyTheo chiều sâuTheo tầng
Duyệt đồ thịDFSBFS

#Cài đặt bằng danh sách liên kết

queue.h
#ifndef QUEUE_H
#define QUEUE_H

#include <stddef.h>

typedef struct QNode {
    int           data;
    struct QNode *next;
} QNode;

/* Bất biến:
     1. front == NULL  khi và chỉ khi  size == 0
     2. rear == NULL   khi và chỉ khi  front == NULL
     3. rear != NULL   thì  rear->next == NULL
     4. size == số nút đi được từ front theo next               */
typedef struct {
    QNode *front;      /* nút sẽ được lấy ra tiếp theo */
    QNode *rear;       /* nút vừa được thêm vào */
    size_t size;
} Queue;

void   queue_khoi_tao(Queue *q);
void   queue_huy(Queue *q);
int    queue_rong(const Queue *q);
size_t queue_so_phan_tu(const Queue *q);
int    queue_enqueue(Queue *q, int v);
int    queue_dequeue(Queue *q, int *out);
int    queue_xem_dau(const Queue *q, int *out);

#endif
queue.c
#include <stdlib.h>

#include "queue.h"

void queue_khoi_tao(Queue *q)
{
    q->front = NULL;
    q->rear  = NULL;
    q->size  = 0;
}

int queue_rong(const Queue *q)
{
    return q->front == NULL;
}

size_t queue_so_phan_tu(const Queue *q)
{
    return q->size;
}

/* Thêm vào ĐUÔI. O(1). Trả về 0 nếu ổn, -1 nếu hết bộ nhớ. */
int queue_enqueue(Queue *q, int v)
{
    QNode *n = malloc(sizeof *n);

    if (n == NULL) return -1;

    n->data = v;
    n->next = NULL;

    if (q->rear != NULL) q->rear->next = n;      /* nối vào sau nút cuối */
    else                 q->front      = n;      /* hàng đợi đang rỗng */

    q->rear = n;
    ++q->size;

    return 0;
}

/* Lấy ở ĐẦU. O(1). Trả về 0 nếu ổn, -1 nếu rỗng. */
int queue_dequeue(Queue *q, int *out)
{
    if (q->front == NULL) return -1;

    QNode *n = q->front;

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

    q->front = n->next;

    if (q->front == NULL) q->rear = NULL;        /* vừa lấy nút cuối cùng */

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

    return 0;
}

int queue_xem_dau(const Queue *q, int *out)
{
    if (q->front == NULL) return -1;

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

    return 0;
}

void queue_huy(Queue *q)
{
    while (queue_dequeue(q, NULL) == 0) { }
}

#Hai trường hợp biên

Trường hợpTrong hàm nàoPhải làm gìQuên thì sao
Thêm vào hàng đợi đang rỗngenqueueGán cả front lẫn rearfront vẫn NULL, hàng đợi mãi rỗng dù size tăng
Lấy nút cuối cùng radequeueĐặt rear về NULLrear thành con trỏ treo, lần enqueue sau ghi vào vùng đã giải phóng
Quên đặt rear về NULL
int queue_dequeue(Queue *q, int *out)
{
    if (q->front == NULL) return -1;

    QNode *n = q->front;

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

    q->front = n->next;
    /* quên: nếu front vừa thành NULL thì rear vẫn trỏ vào n */

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

    return 0;
}
Đầy đủ
    q->front = n->next;

    if (q->front == NULL) q->rear = NULL;

    free(n);
terminal
# Thêm một, lấy một, rồi thêm một nữa
gcc -std=c17 -g -fsanitize=address queue-quen.c -o t-asan && ./t-asan
ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000018
WRITE of size 8 at 0x602000000018 thread T0
    #0 in queue_enqueue queue-quen.c:24

0x602000000018 is located 8 bytes inside of 16-byte region
freed by thread T0 here:
    #0 in free
    #1 in queue_dequeue queue-quen.c:41

Chương trình thử

main.c
#include <stdio.h>

#include "queue.h"

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

    queue_khoi_tao(&q);

    printf("dequeue tren rong: %d\n", queue_dequeue(&q, &v));

    /* Trường hợp biên: thêm một, lấy một, thêm một */
    queue_enqueue(&q, 1);
    queue_dequeue(&q, &v);
    printf("them 1 roi lay ra: %d, size = %zu\n", v, queue_so_phan_tu(&q));

    queue_enqueue(&q, 2);      /* đây là chỗ rear treo nếu quên đặt NULL */
    queue_dequeue(&q, &v);
    printf("them 2 roi lay ra: %d\n", v);

    for (int i = 1; i <= 5; ++i)
        if (queue_enqueue(&q, i * 10) != 0) { queue_huy(&q); return 1; }

    queue_xem_dau(&q, &v);
    printf("dau hang         : %d\n", v);
    printf("so phan tu       : %zu\n", queue_so_phan_tu(&q));

    printf("lay ra           : ");

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

    printf("\n");

    queue_huy(&q);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g queue.c main.c -o t && ./t
dequeue tren rong: -1
them 1 roi lay ra: 1, size = 0
them 2 roi lay ra: 2
dau hang         : 10
so phan tu       : 5
lay ra           : 10 20 30 40 50 
# Valgrind chạy trên bản không bật sanitizer
valgrind --leak-check=full ./t
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts

Thứ tự lấy ra đúng bằng thứ tự đưa vào. Đó là toàn bộ nội dung của FIFO, và nó khác hẳn Bài 22.1 nơi thứ tự bị đảo.

#Vì sao mảng ngây thơ không dùng được

Thử cài hàng đợi bằng mảng theo cách trực tiếp nhất: giữ hai chỉ số, thêm ở rear và lấy ở front. Vấn đề lộ ra rất nhanh.

queue-mang-te.c
#define QMAX 100

typedef struct {
    int    data[QMAX];
    size_t front;      /* chỉ số phần tử đầu */
    size_t rear;       /* chỉ số ô trống kế tiếp */
} QueueTe;

int qt_enqueue(QueueTe *q, int v)
{
    if (q->rear == QMAX) return -1;      /* "đầy" */

    q->data[q->rear++] = v;

    return 0;
}

int qt_dequeue(QueueTe *q, int *out)
{
    if (q->front == q->rear) return -1;  /* rỗng */

    *out = q->data[q->front++];

    return 0;
}
terminal
./queue-mang-te
them 100 phan tu, lay ra 100 phan tu
front = 100, rear = 100
so phan tu thuc te = 0
nhung them tiep: -1  (bao DAY du mang trong khong!)
Cách chữaChi phíĐánh giá
Dồn mọi phần tử về đầu mảng khi rear chạm biênO(n) mỗi lần dồnChạy được, nhưng có đỉnh nhọn về thời gian
Dịch cả mảng mỗi lần dequeueO(n) mỗi lần lấyTệ, biến O(1) thành O(n)
Cho chỉ số quay vòng bằng phép chia dưO(1) mọi lúcĐúng cách, xem Bài 23.2

#Ứng dụng

Ứng dụngCái gì xếp hàngVì sao FIFO là đúng
Duyệt cây theo tầngNút chờ được thămNút tầng trên phải được thăm trước tầng dưới
BFS trên đồ thịĐỉnh chờ được thămBảo đảm tìm được đường đi ngắn nhất theo số cạnh
Bộ lập lịch tiến trìnhTiến trình sẵn sàng chạyCông bằng, ai chờ lâu được chạy trước
Hàng đợi in ấnTài liệu chờ inNgười gửi trước được in trước
Bộ đệm giữa hai luồngDữ liệu bên sản xuất tạo raBên tiêu thụ xử lý đúng thứ tự
Hàng đợi sự kiện giao diệnSự kiện chuột và bàn phímSự kiện phải xử lý theo đúng thứ tự xảy ra

Duyệt cây theo tầng, xem trước Bài 24.2

theo-tang.c
#include <stdio.h>

#include "queue-ptr.h"      /* hàng đợi chứa TNode *, cùng mẫu ở trên */

typedef struct TNode {
    int           data;
    struct TNode *left, *right;
} TNode;

/* In cây theo từng tầng từ trên xuống, trái sang phải. */
void theo_tang(TNode *goc)
{
    if (goc == NULL) return;

    QueueP q;

    queue_khoi_tao(&q);

    if (queue_enqueue(&q, goc) != 0) return;

    while (!queue_rong(&q)) {
        TNode *n;

        queue_dequeue(&q, &n);

        printf("%d ", n->data);

        if (n->left  != NULL) queue_enqueue(&q, n->left);
        if (n->right != NULL) queue_enqueue(&q, n->right);
    }

    printf("\n");
    queue_huy(&q);
}
terminal
./theo-tang
cay:
        50
      /    \
    30      70
   /  \    /  \
  20  40  60   80

theo tang: 50 30 70 20 40 60 80

Tự làm thử

  1. Cài Queue đầy đủ và chạy dãy thao tác thêm một, lấy một, thêm một dưới -fsanitize=address để kiểm bẫy rear treo.
  2. Bỏ dòng đặt rear về NULL, rồi tìm dãy thao tác ngắn nhất làm chương trình sai.
  3. Cài bản có nút canh và đếm số nhánh if của hai bản.
  4. Cài hàng đợi mảng ngây thơ, thêm và lấy đủ QMAX lần, rồi xác nhận nó báo đầy dù mảng trống.
  5. Cài duyệt cây theo tầng có xuống dòng giữa các tầng, dùng mẹo chốt số nút của tầng trước vòng lặp trong.

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 là FIFO, cần hai con trỏ: front để lấy và rear để thêm.
  • Thêm phải ở đuôi và lấy phải ở đầu, vì với danh sách đơn thì xóa ở cuối là O(n).
  • Hai trường hợp biên: thêm vào hàng đợi rỗng phải gán cả front, và lấy nút cuối cùng phải đặt rear về NULL.
  • Hàng đợi mảng với hai chỉ số chỉ đi tới sẽ lãng phí toàn bộ phần đầu mảng. Lời giải là cho chỉ số quay vòng.
  • Đổi hàng đợi thành ngăn xếp trong một thuật toán duyệt là đổi từ duyệt theo tầng sang duyệt theo chiều sâu.