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
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ác | Danh sách thẳng (head + tail) | Danh sách vòng (chỉ tail) |
|---|---|---|
| Lấy nút đầu | l->head | l->tail->next |
| Lấy nút cuối | l->tail | l->tail |
| Thêm đầu | O(1) | O(1) |
| Thêm cuối | O(1) | O(1) |
| Xóa đầu | O(1) | O(1) |
| Xóa cuối | O(n) | O(n) |
| Ghép hai danh sách | O(1) | O(1) |
| Bộ nhớ cho struct | 24 byte | 16 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ụng | Vì sao dùng vòng |
|---|---|
| Bộ lập lịch quay vòng | Chạ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ếp | Ghi 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ại | Bà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 canh | Chí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 xongTự làm thử
- Cài
CListđầy đủ chỉ vớitailvàsize, kèm hàm in dùngdo while. - Viết hàm hủy sai như trong bài, chạy dưới
-fsanitize=addressvà xác nhận nó báo dùng sau khi giải phóng. - Cài
clist_ghepvà kiểm tra kết quả bằng cách duyệt đủsizebước rồi xác nhận quay về đúng nút xuất phát. - 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.
- 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ó
nextbằ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->nextchí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ớ.