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

Tìm kiếm

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

  • Viết vòng lặp duyệt chuẩn bằng con trỏ
  • Chọn giữa trả về con trỏ và trả về chỉ số
  • Cài tìm nút trước để dùng cho phép xóa
  • Giải thích ảnh hưởng của bộ nhớ đệm tới tốc độ tìm

Tìm trong danh sách liên kết chỉ có một cách: đi từ đầu và so sánh từng nút. Không có tìm nhị phân, không có nhảy tới giữa. Bài này bàn về hình dạng hàm tìm nên có, và về chi phí thật của nó.

#Ba dạng hàm tìm

list.c
/* Dạng 1: trả về con trỏ tới nút, hoặc NULL nếu không thấy. */
Node *list_tim(const List *l, int gia_tri)
{
    for (Node *p = l->head; p != NULL; p = p->next)
        if (p->data == gia_tri)
            return p;

    return NULL;
}

/* Dạng 2: trả về chỉ số, hoặc -1 nếu không thấy. */
long list_vi_tri(const List *l, int gia_tri)
{
    long i = 0;

    for (const Node *p = l->head; p != NULL; p = p->next, ++i)
        if (p->data == gia_tri)
            return i;

    return -1;
}

/* Dạng 3: chỉ hỏi có hay không. */
int list_co(const List *l, int gia_tri)
{
    return list_tim(l, gia_tri) != NULL;
}

#Trả về con trỏ hay chỉ số

Trả con trỏTrả chỉ số
Dùng tiếp thế nàoTruy cập trực tiếp p->data trong O(1)Phải duyệt lại từ đầu, O(n)
Giá trị khi không thấyNULL, rõ ràng-1, phải nhớ quy ước
Còn dùng được sau khi sửa danh sáchKhông, nút có thể đã bị xóaCó, nhưng có thể đã trỏ sai chỗ
Lộ chi tiết bên trongCó, người gọi thấy được NodeKhông
Hợp vớiThư viện nội bộGiao diện công khai

#Tìm nút đứng trước

Trong danh sách đơn, biết một nút là chưa đủ để xóa nó. Bạn cần nút đứng trước. Vì vậy một hàm tìm trả về cả hai thường tiện hơn.

list.c (tiếp)
/* Tìm nút đầu tiên có data bằng gia_tri.
   Nếu ra_truoc khác NULL thì ghi vào đó con trỏ tới nút đứng trước,
   hoặc NULL nếu nút tìm được chính là nút đầu.
   Trả về nút tìm được, hoặc NULL. */
Node *list_tim_kem_truoc(const List *l, int gia_tri, Node **ra_truoc)
{
    Node *truoc = NULL;

    for (Node *p = l->head; p != NULL; truoc = p, p = p->next)
        if (p->data == gia_tri) {
            if (ra_truoc != NULL) *ra_truoc = truoc;

            return p;
        }

    if (ra_truoc != NULL) *ra_truoc = NULL;

    return NULL;
}

#Tìm theo điều kiện bất kỳ

So sánh bằng chỉ là một trường hợp. Nhận một con trỏ hàm làm tiêu chí thì một hàm tìm dùng được cho mọi bài toán. Mẫu này lặp lại xuyên suốt Phần 10 và Phần 11.

list.c (tiếp)
/* Trả về nút đầu tiên mà khop trả về khác 0.
   ctx được truyền nguyên vẹn cho khop, để mang dữ liệu phụ. */
Node *list_tim_neu(const List *l,
                   int (*khop)(int data, void *ctx),
                   void *ctx)
{
    if (l == NULL || khop == NULL) return NULL;

    for (Node *p = l->head; p != NULL; p = p->next)
        if (khop(p->data, ctx))
            return p;

    return NULL;
}
main.c
#include <stdio.h>

#include "list.h"

static int la_chan(int data, void *ctx)
{
    (void)ctx;

    return data % 2 == 0;
}

static int lon_hon(int data, void *ctx)
{
    return data > *(const int *)ctx;      /* ctx mang ngưỡng */
}

int main(void)
{
    List l;

    list_khoi_tao(&l);

    int nguon[] = { 7, 3, 12, 5, 20, 9 };

    for (size_t i = 0; i < sizeof nguon / sizeof nguon[0]; ++i)
        if (list_them_cuoi(&l, nguon[i]) != 0) { list_huy(&l); return 1; }

    list_in(&l);

    Node *a = list_tim_neu(&l, la_chan, NULL);

    printf("so chan dau tien : %d\n", a ? a->data : -1);

    int nguong = 10;
    Node *b = list_tim_neu(&l, lon_hon, &nguong);

    printf("so dau tien > 10 : %d\n", b ? b->data : -1);

    printf("vi tri cua 5     : %ld\n", list_vi_tri(&l, 5));
    printf("vi tri cua 99    : %ld\n", list_vi_tri(&l, 99));

    list_huy(&l);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g list.c main.c -o t && ./t
[7, 3, 12, 5, 20, 9]  (size = 6)
so chan dau tien : 12
so dau tien > 10 : 12
vi tri cua 5     : 3
vi tri cua 99    : -1

#Vì sao tìm trong danh sách chậm

Cả tìm trong mảng chưa sắp lẫn tìm trong danh sách đều là O(n). Nhưng hằng số ẩn khác nhau rất xa, và Bài 21.1 đã nói lý do: bộ nhớ đệm.

do-tim.c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

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

#define N 2000000
#define LAN 20

int main(void)
{
    int  *a   = malloc(N * sizeof *a);
    Node *dau = NULL, *cuoi = NULL;

    if (a == NULL) return 1;

    for (size_t i = 0; i < N; ++i) {
        a[i] = (int)i;

        Node *n = malloc(sizeof *n);

        if (n == NULL) return 1;

        n->data = (int)i;
        n->next = NULL;

        if (cuoi) cuoi->next = n; else dau = n;

        cuoi = n;
    }

    /* Tìm phần tử cuối, tức trường hợp xấu nhất, lặp LAN lần */
    clock_t t0 = clock();
    long long tim_thay = 0;

    for (int k = 0; k < LAN; ++k)
        for (size_t i = 0; i < N; ++i)
            if (a[i] == N - 1) { ++tim_thay; break; }

    double giay_a = (double)(clock() - t0) / CLOCKS_PER_SEC;

    t0 = clock();

    for (int k = 0; k < LAN; ++k)
        for (Node *p = dau; p; p = p->next)
            if (p->data == N - 1) { ++tim_thay; break; }

    double giay_l = (double)(clock() - t0) / CLOCKS_PER_SEC;

    printf("mang      : %.3f s\n", giay_a);
    printf("danh sach : %.3f s\n", giay_l);
    printf("cham hon  : %.1f lan  (tim thay %lld lan)\n",
           giay_l / giay_a, tim_thay);

    free(a);

    for (Node *p = dau; p; ) { Node *t = p->next; free(p); p = t; }

    return 0;
}
terminal
gcc -std=c17 -O2 do-tim.c -o do-tim && ./do-tim
mang      : 0.028 s
danh sach : 0.242 s
cham hon  : 8.6 lan  (tim thay 40 lan)

Ba cấu trúc, ba đặc điểm

Mảng đã sắpDanh sách liên kếtCây cân bằng
TìmO(log n)O(n)O(log n)
ChènO(n)O(1) nếu có con trỏO(log n)
XóaO(n)O(1) nếu có con trỏO(log n)
Duyệt theo thứ tựO(n), rất nhanhO(n), chậm hơnO(n)
Bộ nhớ phụ08 byte mỗi nút16 byte mỗi nút

Tự làm thử

  1. Cài đủ ba dạng hàm tìm, kèm list_tim_kem_truoc, và viết chương trình thử cho từng cái.
  2. Cài list_tim_lien_ket trả về Node **, rồi dùng nó viết lại list_xoa_gia_tri trong ba dòng.
  3. Cài list_dem_neu đếm số phần tử thỏa một vị từ, dùng cùng mẫu con trỏ hàm và ctx.
  4. Chạy do-tim.c trên máy bạn. Đổi N thành 1000 và đo lại, giải thích vì sao tỷ lệ thay đổi.
  5. Cài list_tim_cuoi tìm phần tử khớp cuối cùng chứ không phải đầu tiên, vẫn chỉ duyệt một lượt.

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

  • Tìm trong danh sách luôn là O(n), không có cách nào nhanh hơn kể cả khi dữ liệu đã sắp xếp.
  • Trả về con trỏ tiện cho thư viện nội bộ, trả về chỉ số an toàn hơn cho giao diện công khai. Cả hai đều hết hạn khi danh sách thay đổi.
  • Trong danh sách đơn, muốn xóa thì phải biết nút trước, nên hàm tìm kèm nút trước hoặc trả về Node ** tiện hơn nhiều.
  • Mẫu con trỏ hàm kèm ctx làm một hàm tìm dùng được cho mọi tiêu chí, và tránh được biến toàn cục.
  • Tìm trong danh sách chậm hơn tìm trong mảng khoảng tám tới mười lần do bộ nhớ đệm, dù cùng độ phức tạp.