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

Xóa phần tử

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

  • Cài xóa đầu trong O(1) và xóa cuối trong O(n)
  • Dùng con trỏ tới con trỏ để bỏ hẳn trường hợp đặc biệt ở đầu danh sách
  • Giữ tail đúng sau mọi phép xóa
  • Tránh dùng con trỏ sau khi đã giải phóng

Xóa khó hơn thêm, vì bạn phải tìm được nút đứng trước nút cần xóa, và vì nút đầu danh sách không có ai đứng trước nó. Mẹo con trỏ tới con trỏ trong bài này xóa hẳn trường hợp đặc biệt đó.

#Xóa đầu, O(1)

list.c
/* Xóa nút đầu. Nếu out khác NULL thì chép giá trị ra đó.
   Trả về 0 nếu xóa được, -1 nếu danh sách rỗng. */
int list_xoa_dau(List *l, int *out)
{
    if (l->head == NULL) return -1;

    Node *n = l->head;

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

    l->head = n->next;               /* đầu mới là nút thứ hai */

    if (l->head == NULL)             /* vừa xóa nút cuối cùng */
        l->tail = NULL;

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

    return 0;
}

#Xóa cuối, O(n) dù có tail

Đây là chỗ danh sách đơn lộ ra giới hạn của nó. Bạn cầm sẵn tail, nhưng để xóa nó thì phải cho nút áp chót trỏ vào NULL, mà từ tail không có cách nào đi ngược lại. Phải duyệt từ đầu.

list.c (tiếp)
/* Xóa nút cuối. O(n) vì phải tìm nút áp chót. */
int list_xoa_cuoi(List *l, int *out)
{
    if (l->head == NULL) return -1;

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

    if (l->head == l->tail) {        /* chỉ có một nút */
        free(l->head);
        l->head = NULL;
        l->tail = NULL;
        l->size = 0;

        return 0;
    }

    Node *ap_chot = l->head;

    while (ap_chot->next != l->tail)
        ap_chot = ap_chot->next;

    free(l->tail);
    ap_chot->next = NULL;
    l->tail       = ap_chot;
    --l->size;

    return 0;
}
Thao tácDanh sách đơnDanh sách đôi (Bài 21.7)
Thêm đầuO(1)O(1)
Thêm cuốiO(1) nhờ tailO(1)
Xóa đầuO(1)O(1)
Xóa cuốiO(n)O(1)
Xóa nút đã có con trỏO(n)O(1)

Hai ô O(n) trong cột giữa chính là lý do danh sách đôi tồn tại. Nếu chương trình của bạn xóa ở cuối nhiều, hoặc xóa nút đã cầm sẵn con trỏ nhiều, thì tám byte thêm cho mỗi nút là cái giá rất đáng.

#Xóa theo giá trị, cách thông thường

list.c (tiếp)
/* Xóa nút ĐẦU TIÊN có data bằng gia_tri.
   Bản thông thường: phải theo dõi cả nút hiện tại lẫn nút trước nó. */
int list_xoa_gia_tri_thuong(List *l, int gia_tri)
{
    Node *truoc = NULL;
    Node *hien  = l->head;

    while (hien != NULL) {
        if (hien->data == gia_tri) {
            if (truoc == NULL) l->head       = hien->next;   /* xóa nút đầu */
            else               truoc->next   = hien->next;   /* xóa nút giữa */

            if (l->tail == hien) l->tail = truoc;            /* xóa nút cuối */

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

            return 0;
        }

        truoc = hien;
        hien  = hien->next;
    }

    return -1;      /* không tìm thấy */
}

#Mẹo con trỏ tới con trỏ

Con trỏ tới con trỏ next
Thay vì giữ địa chỉ của nút đứng trước, ta giữ địa chỉ của ô nhớ đang chứa con trỏ tới nút hiện tại. Ô đó là l->head với nút đầu tiên, và là truoc->next với mọi nút còn lại. Nhờ vậy hai trường hợp gộp thành một.
list.c (tiếp)
/* Cùng chức năng, không còn trường hợp đặc biệt cho nút đầu. */
int list_xoa_gia_tri(List *l, int gia_tri)
{
    Node **pp = &l->head;        /* địa chỉ của ô đang trỏ tới nút hiện tại */

    while (*pp != NULL) {
        Node *n = *pp;

        if (n->data == gia_tri) {
            *pp = n->next;                       /* ghi thẳng vào ô đó */

            if (l->tail == n)                    /* vừa xóa nút cuối */
                l->tail = (*pp == NULL)
                        ? ((pp == &l->head) ? NULL : /* danh sách thành rỗng */
                           (Node *)((char *)pp - offsetof(Node, next)))
                        : l->tail;

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

            return 0;
        }

        pp = &n->next;           /* tiến sang ô next của nút hiện tại */
    }

    return -1;
}
Bản gọn nhất, List chỉ có head
/* Không có tail thì mẹo này thật sự đẹp: bảy dòng, không trường hợp riêng. */
int xoa_gia_tri(Node **head, int gia_tri)
{
    for (Node **pp = head; *pp != NULL; pp = &(*pp)->next) {
        Node *n = *pp;

        if (n->data == gia_tri) {
            *pp = n->next;
            free(n);

            return 0;
        }
    }

    return -1;
}
  1. pp bắt đầu ở &head

    Ô nhớ đầu tiên đang giữ địa chỉ một nút chính là biến head. Nó không nằm trong nút nào cả, nhưng vai trò thì giống hệt trường next.

  2. Mỗi bước, pp nhảy sang &(*pp)->next

    Tức là địa chỉ trường next của nút vừa xét. Đó lại là ô đang giữ địa chỉ nút kế tiếp.

  3. Xóa là ghi vào *pp

    Không cần biết ô đó nằm ở đâu. Nếu nó là head thì head được sửa, nếu nó là truoc->next thì truoc->next được sửa. Một dòng lo cả hai.

Bản thườngBản dùng Node **pp
Số dòng thân hàm1810
Số nhánh if trong vòng lặp31
Xử lý riêng nút đầuCóKhông
Dễ đọc với người mớiCóCần làm quen
Hợp khi List có tailCóKhông, phải bù thêm

#Xóa mọi phần tử khớp

Đây là chỗ mẹo trên tỏa sáng: xóa nhiều nút trong một lượt mà không rối. Điểm cần chú ý là không tiến pp khi vừa xóa, vì ô đó giờ đang giữ nút kế tiếp và phải xét lại.

Luôn tiến pp
for (Node **pp = &head; *pp; pp = &(*pp)->next) {
    if ((*pp)->data == v) {
        Node *n = *pp;
        *pp = n->next;
        free(n);
        /* vòng for vẫn tiến pp, nên bỏ sót nút vừa nhảy vào vị trí này */
    }
}
Chỉ tiến khi không xóa
size_t xoa_tat_ca(Node **head, int v)
{
    size_t dem = 0;
    Node **pp  = head;

    while (*pp != NULL) {
        Node *n = *pp;

        if (n->data == v) {
            *pp = n->next;      /* KHÔNG tiến pp, xét lại chính ô này */
            free(n);
            ++dem;
        } else {
            pp = &n->next;      /* chỉ tiến khi không xóa */
        }
    }

    return dem;
}
terminal
./xoa-tat-ca
truoc: [1, 7, 7, 2, 7, 3, 7, 7]
da xoa 5 phan tu
sau : [1, 2, 3]
# Bản sai bỏ sót các nút 7 đứng liền nhau
./xoa-tat-ca-sai
truoc: [1, 7, 7, 2, 7, 3, 7, 7]
da xoa 3 phan tu
sau : [1, 7, 2, 3, 7]

#Sau khi free thì con trỏ chết

Free trước, đọc sau
Node *n = *pp;

free(n);
*pp = n->next;      /* đọc vùng đã giải phóng */
Đọc trước, free sau
Node *n = *pp;

*pp = n->next;      /* đọc xong rồi mới free */
free(n);

Bản sai thường vẫn chạy đúng, vì bộ cấp phát chưa kịp ghi đè vùng đó. Bài 14.9 đã chỉ ra glibc ghi con trỏ liên kết vào 16 byte đầu của khối vừa giải phóng, mà next nằm ở byte thứ 8, nên nó sẽ bị phá. Chỉ là không phải lần nào cũng phá ngay.

Chương trình thử đầy đủ

main.c
#include <stdio.h>

#include "list.h"

int main(void)
{
    List l;
    int  v;

    list_khoi_tao(&l);

    for (int i = 1; i <= 5; ++i)
        if (list_them_cuoi(&l, i * 10) != 0) { list_huy(&l); return 1; }

    list_in(&l);

    list_xoa_dau(&l, &v);   printf("xoa dau  -> %d  ", v);  list_in(&l);
    list_xoa_cuoi(&l, &v);  printf("xoa cuoi -> %d  ", v);  list_in(&l);

    printf("xoa 30   -> %d  ", list_xoa_gia_tri(&l, 30));   list_in(&l);
    printf("xoa 99   -> %d  ", list_xoa_gia_tri(&l, 99));   list_in(&l);

    while (list_xoa_dau(&l, NULL) == 0) { }

    list_in(&l);
    printf("xoa tren danh sach rong: %d\n", list_xoa_dau(&l, NULL));

    list_huy(&l);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g list.c main.c -o t && ./t
[10, 20, 30, 40, 50]  (size = 5)
xoa dau  -> 10  [20, 30, 40, 50]  (size = 4)
xoa cuoi -> 50  [20, 30, 40]  (size = 3)
xoa 30   -> 0  [20, 40]  (size = 2)
xoa 99   -> -1  [20, 40]  (size = 2)
[]  (size = 0)
xoa tren danh sach rong: -1
# 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

Tự làm thử

  1. Cài đủ bốn hàm xóa, gọi list_kiem_tra sau mỗi lần và chạy dưới valgrind cho tới khi không còn rò rỉ.
  2. Viết lại list_xoa_gia_tri theo cả hai cách, rồi so số dòng và số nhánh của hai bản.
  3. Bỏ dòng cập nhật tail trong list_xoa_dau, xóa hết phần tử rồi thêm cuối một phần tử. Chạy dưới -fsanitize=address và đọc báo cáo.
  4. Cài xoa_tat_ca theo mẫu Node **pp, thử với danh sách toàn phần tử giống nhau và với danh sách rỗng.
  5. Cài list_loc(List *l, int (*giu)(int)) giữ lại những phần tử mà hàm giu trả về khác 0, dùng mẫu Node **pp.

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

  • Xóa đầu là O(1), xóa cuối trong danh sách đơn là O(n) vì phải tìm nút áp chót.
  • Mọi hàm xóa phải đặt tail về NULL khi danh sách trở thành rỗng, nếu không tail thành con trỏ treo.
  • Mẹo Node **pp giữ địa chỉ của liên kết chứ không phải của nút, nhờ vậy nút đầu không còn là trường hợp riêng.
  • Khi xóa nhiều nút trong một lượt, chỉ tiến pp ở nhánh không xóa.
  • Luôn đọc n->next xong rồi mới free(n). Đảo lại thường vẫn chạy, nhưng đó là hành vi không xác định.