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

Đả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

Mỗi lần lặp đảo đúng một mũi tên. Sau ba lần, hien thành NULL và truoc chính là đầu mới.
list.c
/* Đả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ũ */
}
  1. 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.

  2. Đảo hướng mũi tên

    Ở lần lặp đầu, truoc là NULL, nên nút đầu cũ nhận next bằng NULL, tức nó trở thành nút cuối mới. Đó chính là điều ta muốn.

  3. Tiến truoc rồi tiến hien

    Hai dòng này phải theo đúng thứ tự, vì truoc = hien cần giá trị hien cũ.

#Đảo ngược bằng đệ quy

list.c (tiếp)
/* Đả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

Không dùng, chỉ để hiểu
/* Đả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ỏ

Hai con trỏ nhanh chậm
Cho hai con trỏ cùng xuất phát từ đầu danh sách. Con chậm đi một nút mỗi bước, con nhanh đi hai nút. Sau k bước, con chậm ở nút k còn con nhanh ở nút 2k. Quan hệ đơn giản đó giải được nhiều bài chỉ trong một lượt duyệt.
Bài toánCách dùng hai con trỏKết quả
Tìm nút giữaChạy tới khi nhanh hết danh sáchChậm đang ở giữa
Phát hiện chu trìnhChạy tới khi hai con gặp nhau hoặc nhanh hếtGặp nhau tức có chu trình
Tìm nút thứ k từ cuốiCho nhanh đi trước k bước rồi cùng đi một nhịpChậm dừng ở nút thứ k từ cuối
Tìm điểm bắt đầu chu trìnhSau khi gặp, đưa một con về đầu, cùng đi một nhịpGặp lại tại điểm bắt đầu

#Tìm nút giữa trong một lượt

list.c (tiếp)
/* 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áchncham dừng ởChỉ số
[]0NULL-
[1]110
[1, 2]221
[1, 2, 3]321
[1, 2, 3, 4]432
[1, 2, 3, 4, 5]532

#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ớ.

list.c (tiếp)
/* 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 */
}
thu-chu-trinh.c
#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;
}
terminal
gcc -std=c17 -Wall -Wextra -g thu-chu-trinh.c -o t && ./t
danh sach thang : 0
co chu trinh    : 1
# Quên cắt vòng thì không giải phóng được, valgrind báo rò rỉ
valgrind --leak-check=full ./t-quen-cat
==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ờ.

list.c (tiếp)
/* 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 */
}
terminal
./tim-dau-vong
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ử

  1. Cài list_dao_nguoc bản lặp, và xác nhận head cùng tail đều đúng sau khi gọi trên danh sách rỗng, một nút, và nhiều nút.
  2. Cài bản đệ quy đuôi, dịch với -O0 và -O2, rồi tìm giá trị n nhỏ nhất làm bản -O0 sập.
  3. Cài list_giua và kiểm với n từ 0 tới 8, so kết quả với bảng trong bài.
  4. Cài list_nut_thu_k_tu_cuoi bằng hai con trỏ, chỉ duyệt một lượt.
  5. Cài list_dau_chu_trinh và 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ến truoc, tiến hien. Kết thúc thì truoc là đầ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 != NULL phả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.