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

Danh sách vòng

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

  • Duyệt danh sách vòng bằng do while thay vì kiểm tra NULL
  • Thêm và xóa mà vẫn giữ được vòng
  • Cài bài toán Josephus
  • Nêu ba ứng dụng thật của danh sách vòng

Cho nút cuối trỏ về nút đầu thì danh sách khép thành vòng. Không nút nào còn next bằng NULL, nên mọi vòng lặp quen thuộc đều chạy vô hạn nếu bạn không đổi điều kiện dừng.

#Nút cuối trỏ về đầu

Chỉ một dòng gán biến danh sách thẳng thành vòng, nhưng mọi hàm duyệt đều phải viết lại.
clist.h
#ifndef CLIST_H
#define CLIST_H

#include <stddef.h>

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

/* Chỉ cần giữ tail. Nút đầu luôn là tail->next.
   Bất biến:
     1. tail == NULL  khi và chỉ khi  size == 0
     2. tail != NULL  thì đi theo next từ tail đúng size bước sẽ về lại tail
     3. size == số nút trong vòng                                        */
typedef struct {
    CNode *tail;
    size_t size;
} CList;

void clist_khoi_tao(CList *l);
void clist_huy(CList *l);
int  clist_them_dau(CList *l, int data);
int  clist_them_cuoi(CList *l, int data);
int  clist_xoa_dau(CList *l, int *out);
void clist_in(const CList *l);

#endif

#Duyệt: điều kiện dừng khác hẳn

Điều kiện của danh sách thẳng
for (CNode *p = dau; p != NULL; p = p->next)
    printf("%d ", p->data);      /* chạy vô hạn, không nút nào có next NULL */
Vòng do while so với nút xuất phát
if (l->tail != NULL) {
    CNode *p = l->tail->next;      /* bắt đầu từ nút đầu */

    do {
        printf("%d ", p->data);
        p = p->next;
    } while (p != l->tail->next);  /* dừng khi quay lại nút xuất phát */
}
clist.c
#include <stdio.h>
#include <stdlib.h>

#include "clist.h"

void clist_khoi_tao(CList *l)
{
    l->tail = NULL;
    l->size = 0;
}

void clist_in(const CList *l)
{
    printf("(");

    if (l->tail != NULL) {
        const CNode *dau = l->tail->next;
        const CNode *p   = dau;

        do {
            printf("%d", p->data);
            p = p->next;

            if (p != dau) printf(" -> ");
        } while (p != dau);

        printf(" -> ...");
    }

    printf(")  (size = %zu)\n", l->size);
}

#Thêm và xóa

clist.c (tiếp)
static CNode *cnode_tao(int data)
{
    CNode *n = malloc(sizeof *n);

    if (n == NULL) return NULL;

    n->data = data;
    n->next = n;          /* nút đơn độc là một vòng một phần tử */

    return n;
}

/* Thêm vào đầu, tức ngay sau tail. O(1). */
int clist_them_dau(CList *l, int data)
{
    CNode *n = cnode_tao(data);

    if (n == NULL) return -1;

    if (l->tail == NULL) {
        l->tail = n;              /* n->next đã trỏ về chính nó */
    } else {
        n->next       = l->tail->next;
        l->tail->next = n;
    }

    ++l->size;

    return 0;
}

/* Thêm vào cuối: y hệt thêm đầu, chỉ khác là tail lùi sang nút mới. O(1). */
int clist_them_cuoi(CList *l, int data)
{
    if (clist_them_dau(l, data) != 0) return -1;

    l->tail = l->tail->next;      /* nút vừa thêm giờ là cuối */

    return 0;
}
clist.c (tiếp)
/* Xóa nút đầu, tức tail->next. O(1). */
int clist_xoa_dau(CList *l, int *out)
{
    if (l->tail == NULL) return -1;

    CNode *dau = l->tail->next;

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

    if (dau == l->tail) {              /* chỉ còn một nút */
        l->tail = NULL;
    } else {
        l->tail->next = dau->next;
    }

    free(dau);
    --l->size;

    return 0;
}

void clist_huy(CList *l)
{
    while (clist_xoa_dau(l, NULL) == 0) { }

    /* Sau vòng lặp: tail đã NULL và size đã 0 */
}

#Chỉ giữ tail là đủ

Thao tácDanh sách thẳng (head + tail)Danh sách vòng (chỉ tail)
Lấy nút đầul->headl->tail->next
Lấy nút cuốil->taill->tail
Thêm đầuO(1)O(1)
Thêm cuốiO(1)O(1)
Xóa đầuO(1)O(1)
Xóa cuốiO(n)O(n)
Ghép hai danh sáchO(1)O(1)
Bộ nhớ cho struct24 byte16 byte

Ghép hai vòng trong O(1)

clist.c (tiếp)
/* Nối toàn bộ b vào cuối a. Sau khi gọi, b thành rỗng. O(1). */
void clist_ghep(CList *a, CList *b)
{
    if (b->tail == NULL) return;

    if (a->tail == NULL) {
        a->tail = b->tail;
        a->size = b->size;
    } else {
        CNode *dau_a = a->tail->next;
        CNode *dau_b = b->tail->next;

        a->tail->next = dau_b;      /* cuối a nối vào đầu b */
        b->tail->next = dau_a;      /* cuối b khép lại về đầu a */

        a->tail  = b->tail;         /* cuối chung là cuối b */
        a->size += b->size;
    }

    clist_khoi_tao(b);
}
terminal
./ghep
a = (1 -> 2 -> 3 -> ...)  (size = 3)
b = (7 -> 8 -> ...)  (size = 2)
sau khi ghep:
a = (1 -> 2 -> 3 -> 7 -> 8 -> ...)  (size = 5)
b = ()  (size = 0)

Bốn phép gán, không vòng lặp nào. Với danh sách thẳng thì cũng làm được trong O(1) nếu có tail, nhưng vòng làm việc này tự nhiên hơn vì không có đầu mút nào phải xử lý riêng.

#Bài toán Josephus

n người đứng thành vòng tròn, đánh số từ 1 tới n. Bắt đầu từ người số 1, đếm k người thì người thứ k bị loại. Tiếp tục đếm từ người kế tiếp. Hỏi người cuối cùng còn lại là ai.

josephus.c
#include <stdio.h>
#include <stdlib.h>

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

/* Trả về số của người sống sót, hoặc -1 nếu hết bộ nhớ.
   In ra thứ tự bị loại nếu in_buoc khác 0. */
int josephus(int n, int k, int in_buoc)
{
    if (n <= 0 || k <= 0) return -1;

    /* Dựng vòng n nút, giữ tail */
    CNode *tail = NULL;

    for (int i = 1; i <= n; ++i) {
        CNode *m = malloc(sizeof *m);

        if (m == NULL) {
            /* Dọn phần đã dựng rồi báo lỗi */
            if (tail != NULL) {
                CNode *p = tail->next;

                while (p != tail) { CNode *t = p->next; free(p); p = t; }

                free(tail);
            }

            return -1;
        }

        m->data = i;

        if (tail == NULL) { m->next = m; }
        else              { m->next = tail->next; tail->next = m; }

        tail = m;
    }

    /* truoc luôn là nút đứng ngay trước nút đang xét */
    CNode *truoc = tail;

    while (truoc->next != truoc) {          /* còn nhiều hơn một nút */
        for (int b = 1; b < k; ++b)
            truoc = truoc->next;            /* đi k-1 bước */

        CNode *loai = truoc->next;          /* nút thứ k bị loại */

        if (in_buoc) printf("loai %d\n", loai->data);

        truoc->next = loai->next;
        free(loai);
    }

    int song = truoc->data;

    free(truoc);

    return song;
}

int main(void)
{
    printf("n = 7, k = 3\n");

    int s = josephus(7, 3, 1);

    printf("song sot: %d\n\n", s);

    for (int k = 2; k <= 4; ++k)
        printf("n = 41, k = %d  -> song sot %d\n", k, josephus(41, k, 0));

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g josephus.c -o t && ./t
n = 7, k = 3
loai 3
loai 6
loai 2
loai 7
loai 5
loai 1
song sot: 4

n = 41, k = 2  -> song sot 19
n = 41, k = 3  -> song sot 31
n = 41, k = 4  -> song sot 3
# Valgrind 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

#Ứng dụng thật

Ứng dụngVì sao dùng vòng
Bộ lập lịch quay vòngChạy hết tiến trình cuối thì tự quay về đầu, không cần kiểm tra
Bộ đệm vòng cho âm thanh và cổng nối tiếpGhi và đọc chạy vòng quanh vùng nhớ cố định, không cấp phát thêm
Danh sách phát lặp lạiBài cuối tự nối về bài đầu
Thuật toán quét thang máy trong hệ điều hànhĐầu đọc quét tới cuối đĩa rồi quay về đầu
Danh sách đôi có nút canhChính là vòng, xem Bài 21.7

Bộ lập lịch quay vòng đơn giản

quay-vong.c
#include <stdio.h>
#include <stdlib.h>

typedef struct TT {
    char        ten[16];
    int         con_lai;      /* số lượng tử thời gian còn cần */
    struct TT  *next;
} TT;

/* Mỗi lượt cho một tiến trình chạy đúng mot lượng tử.
   Tiến trình xong thì bị gỡ khỏi vòng. */
void chay(TT *tail)
{
    int thoi_diem = 0;

    while (tail != NULL) {
        TT *hien = tail->next;

        --hien->con_lai;
        ++thoi_diem;

        printf("t=%2d  chay %-6s con lai %d\n",
               thoi_diem, hien->ten, hien->con_lai);

        if (hien->con_lai == 0) {
            printf("      %s xong\n", hien->ten);

            if (hien == tail) {           /* tiến trình cuối cùng */
                free(hien);
                tail = NULL;
            } else {
                tail->next = hien->next;
                free(hien);
            }
        } else {
            tail = hien;                  /* tiến sang tiến trình kế */
        }
    }
}
terminal
./quay-vong
t= 1  chay A      con lai 2
t= 2  chay B      con lai 0
      B xong
t= 3  chay C      con lai 1
t= 4  chay A      con lai 1
t= 5  chay C      con lai 0
      C xong
t= 6  chay A      con lai 0
      A xong

Tự làm thử

  1. Cài CList đầy đủ chỉ với tail và size, kèm hàm in dùng do while.
  2. Viết hàm hủy sai như trong bài, chạy dưới -fsanitize=address và xác nhận nó báo dùng sau khi giải phóng.
  3. Cài clist_ghep và kiểm tra kết quả bằng cách duyệt đủ size bước rồi xác nhận quay về đúng nút xuất phát.
  4. Cài cả hai bản Josephus, so kết quả với n từ 1 tới 50 và k từ 1 tới 5, rồi đo thời gian với n bằng một triệu.
  5. Cài bộ lập lịch quay vòng cho bốn tiến trình có lượng tử khác nhau, in bảng thời điểm mỗi tiến trình kết thúc.

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

  • Danh sách vòng chỉ khác một dòng gán, nhưng mọi hàm duyệt phải viết lại vì không nút nào có next bằng NULL.
  • Điều kiện dừng chuẩn là do while (p != nut_xuat_phat), và phải bọc trong kiểm tra danh sách rỗng.
  • Chỉ cần giữ tail, vì tail->next chính là nút đầu. Nhờ vậy thêm đầu, thêm cuối và ghép hai vòng đều là O(1).
  • Hàm hủy phải lưu mốc dừng vào biến riêng và giải phóng nó sau cùng, vì so sánh với con trỏ đã giải phóng cũng là hành vi không xác định.
  • Bài Josephus mô phỏng được bằng vòng, nhưng công thức truy hồi cho lời giải O(n) và O(1) bộ nhớ.