Đảo ngược và hai con trỏ
Sau bài này bạn sẽ làm được
- Đảo ngược danh sách trong O(n) thời gian và O(1) bộ nhớ
- Giải thích bốn bước của vòng lặp đảo
- Tìm nút giữa trong một lần duyệt
- Cài thuật toán rùa và thỏ để phát hiện chu trình
Đảo ngược danh sách là bài kiểm tra hiểu con trỏ kinh điển. Kỹ thuật hai con trỏ chạy với tốc độ khác nhau thì giải được ba bài toán tưởng chừng không liên quan: tìm nút giữa, phát hiện chu trình, và tìm điểm chu trình bắt đầu.
#Đảo ngược bằng vòng lặp
/* Đảo ngược tại chỗ. O(n) thời gian, O(1) bộ nhớ. */
void list_dao_nguoc(List *l)
{
Node *truoc = NULL;
Node *hien = l->head;
l->tail = l->head; /* đầu cũ sẽ thành cuối mới */
while (hien != NULL) {
Node *sau = hien->next; /* 1. LƯU nút kế trước khi mất nó */
hien->next = truoc; /* 2. ĐẢO hướng mũi tên */
truoc = hien; /* 3. TIẾN truoc */
hien = sau; /* 4. TIẾN hien */
}
l->head = truoc; /* truoc dừng ở nút cuối cũ */
}Lưu nút kế trước khi mất nó
Dòng tiếp theo sẽ ghi đè
hien->next. Không lưu lại thì phần đuôi danh sách mất hẳn và vòng lặp dừng ngay.Đảo hướng mũi tên
Ở lần lặp đầu,
truoclà NULL, nên nút đầu cũ nhậnnextbằng NULL, tức nó trở thành nút cuối mới. Đó chính là điều ta muốn.Tiến truoc rồi tiến hien
Hai dòng này phải theo đúng thứ tự, vì
truoc = hiencần giá trịhiencũ.
#Đảo ngược bằng đệ quy
/* Đảo ngược đệ quy. Trả về đầu mới của đoạn đã đảo. */
static Node *dao_de_quy(Node *hien, Node *truoc)
{
if (hien == NULL) return truoc; /* hết danh sách, truoc là đầu mới */
Node *sau = hien->next;
hien->next = truoc;
return dao_de_quy(sau, hien); /* đệ quy đuôi */
}
void list_dao_nguoc_de_quy(List *l)
{
l->tail = l->head;
l->head = dao_de_quy(l->head, NULL);
}Bản này là bản lặp viết lại: truoc và hien thành hai tham số thay vì hai biến. Đó là dạng đệ quy đuôi, tức lời gọi đệ quy là việc cuối cùng hàm làm.
Bản đệ quy không đuôi, để so sánh
/* Đảo phần đuôi trước, rồi nối nút hiện tại vào cuối.
Đây KHÔNG phải đệ quy đuôi, nên không tối ưu thành vòng lặp được. */
static Node *dao_khong_duoi(Node *hien)
{
if (hien == NULL || hien->next == NULL) return hien;
Node *dau_moi = dao_khong_duoi(hien->next);
hien->next->next = hien; /* nút kế giờ trỏ ngược về hien */
hien->next = NULL;
return dau_moi;
}Bản này khó đọc hơn, tốn O(n) ngăn xếp trong mọi trường hợp, và không nhanh hơn chút nào. Nó chỉ đáng biết vì cùng khuôn với thao tác đệ quy trên cây ở Chương 24, nơi đệ quy thật sự là cách tự nhiên.
#Kỹ thuật hai con trỏ
| Bài toán | Cách dùng hai con trỏ | Kết quả |
|---|---|---|
| Tìm nút giữa | Chạy tới khi nhanh hết danh sách | Chậm đang ở giữa |
| Phát hiện chu trình | Chạy tới khi hai con gặp nhau hoặc nhanh hết | Gặp nhau tức có chu trình |
| Tìm nút thứ k từ cuối | Cho nhanh đi trước k bước rồi cùng đi một nhịp | Chậm dừng ở nút thứ k từ cuối |
| Tìm điểm bắt đầu chu trình | Sau khi gặp, đưa một con về đầu, cùng đi một nhịp | Gặp lại tại điểm bắt đầu |
#Tìm nút giữa trong một lượt
/* Trả về nút giữa. Với n chẵn thì trả về nút thứ n/2, tức nút
đầu của nửa sau. Trả về NULL nếu danh sách rỗng. */
Node *list_giua(const List *l)
{
Node *cham = l->head;
Node *nhanh = l->head;
while (nhanh != NULL && nhanh->next != NULL) {
cham = cham->next;
nhanh = nhanh->next->next;
}
return cham;
}| Danh sách | n | cham dừng ở | Chỉ số |
|---|---|---|---|
| [] | 0 | NULL | - |
| [1] | 1 | 1 | 0 |
| [1, 2] | 2 | 2 | 1 |
| [1, 2, 3] | 3 | 2 | 1 |
| [1, 2, 3, 4] | 4 | 3 | 2 |
| [1, 2, 3, 4, 5] | 5 | 3 | 2 |
#Phát hiện chu trình bằng rùa và thỏ
Một danh sách hỏng có thể có nút trỏ ngược về nút trước đó. Vòng lặp duyệt thông thường sẽ chạy mãi. Thuật toán Floyd phát hiện được điều đó trong O(n) thời gian và O(1) bộ nhớ.
/* Trả về 1 nếu có chu trình, 0 nếu không. */
int list_co_chu_trinh(const List *l)
{
Node *cham = l->head;
Node *nhanh = l->head;
while (nhanh != NULL && nhanh->next != NULL) {
cham = cham->next;
nhanh = nhanh->next->next;
if (cham == nhanh) return 1; /* gặp nhau, chắc chắn có vòng */
}
return 0; /* nhanh ra khỏi danh sách */
}#include <stdio.h>
#include <stdlib.h>
typedef struct Node { int data; struct Node *next; } Node;
int co_chu_trinh(Node *head)
{
Node *cham = head, *nhanh = head;
while (nhanh && nhanh->next) {
cham = cham->next;
nhanh = nhanh->next->next;
if (cham == nhanh) return 1;
}
return 0;
}
int main(void)
{
Node *n[6];
for (int i = 0; i < 6; ++i) {
n[i] = malloc(sizeof *n[i]);
if (n[i] == NULL) return 1;
n[i]->data = i;
n[i]->next = NULL;
}
for (int i = 0; i < 5; ++i) n[i]->next = n[i + 1];
printf("danh sach thang : %d\n", co_chu_trinh(n[0]));
n[5]->next = n[2]; /* tạo chu trình quay về nút 2 */
printf("co chu trinh : %d\n", co_chu_trinh(n[0]));
n[5]->next = NULL; /* cắt vòng để giải phóng được */
for (int i = 0; i < 6; ++i) free(n[i]);
return 0;
}danh sach thang : 0 co chu trinh : 1
==9012== 64 bytes in 4 blocks are definitely lost in loss record 1 of 1
#Tìm điểm bắt đầu chu trình
Biết có chu trình là một chuyện, biết nó bắt đầu từ nút nào lại là chuyện khác. Phần thứ hai của thuật toán Floyd giải được, và chứng minh của nó ngắn một cách bất ngờ.
/* Trả về nút mà chu trình bắt đầu, hoặc NULL nếu không có chu trình. */
Node *list_dau_chu_trinh(const List *l)
{
Node *cham = l->head, *nhanh = l->head;
/* Giai đoạn 1: tìm điểm gặp nhau */
while (nhanh != NULL && nhanh->next != NULL) {
cham = cham->next;
nhanh = nhanh->next->next;
if (cham == nhanh) break;
}
if (nhanh == NULL || nhanh->next == NULL) return NULL; /* không có vòng */
/* Giai đoạn 2: đưa một con về đầu, cùng đi MỘT nhịp */
cham = l->head;
while (cham != nhanh) {
cham = cham->next;
nhanh = nhanh->next;
}
return cham; /* điểm bắt đầu chu trình */
}0 -> 1 -> 2 -> 3 -> 4 -> 5 -> quay ve 2 chu trinh bat dau tai nut co data = 2 do dai chu trinh = 4
Tự làm thử
- Cài
list_dao_nguocbản lặp, và xác nhậnheadcùngtailđều đúng sau khi gọi trên danh sách rỗng, một nút, và nhiều nút. - Cài bản đệ quy đuôi, dịch với
-O0và-O2, rồi tìm giá trị n nhỏ nhất làm bản-O0sập. - Cài
list_giuavà kiểm với n từ 0 tới 8, so kết quả với bảng trong bài. - Cài
list_nut_thu_k_tu_cuoibằng hai con trỏ, chỉ duyệt một lượt. - Cài
list_dau_chu_trinhvà thêm hàm đo độ dài chu trình. Thử với chu trình bắt đầu ngay tại nút đầu.
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
- Vòng lặp đảo ngược có bốn bước cố định: lưu
next, đảo hướng, tiếntruoc, tiếnhien. Kết thúc thìtruoclà đầu mới. - Bản đệ quy đuôi chỉ an toàn khi trình biên dịch tối ưu được. Chuẩn C không bảo đảm, nên với dữ liệu lớn hãy dùng bản lặp.
- Hai con trỏ nhanh chậm giải được tìm nút giữa, phát hiện chu trình và tìm nút thứ k từ cuối, tất cả trong một lượt và O(1) bộ nhớ.
- Điều kiện
nhanh != NULL && nhanh->next != NULLphải đúng thứ tự, dựa vào đánh giá ngắn mạch của&&. - Danh sách có chu trình không hủy được bằng vòng lặp thường, vì sẽ giải phóng cùng một nút hai lần.