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ào | Truy 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ấy | NULL, rõ ràng | -1, phải nhớ quy ước |
| Còn dùng được sau khi sửa danh sách | Không, nút có thể đã bị xóa | Có, nhưng có thể đã trỏ sai chỗ |
| Lộ chi tiết bên trong | Có, người gọi thấy được Node | Không |
| Hợp với | Thư 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ắp | Danh sách liên kết | Cây cân bằng | |
|---|---|---|---|
| Tìm | O(log n) | O(n) | O(log n) |
| Chèn | O(n) | O(1) nếu có con trỏ | O(log n) |
| Xóa | O(n) | O(1) nếu có con trỏ | O(log n) |
| Duyệt theo thứ tự | O(n), rất nhanh | O(n), chậm hơn | O(n) |
| Bộ nhớ phụ | 0 | 8 byte mỗi nút | 16 byte mỗi nút |
Tự làm thử
- 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. - Cài
list_tim_lien_kettrả vềNode **, rồi dùng nó viết lạilist_xoa_gia_tritrong ba dòng. - 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. - Chạy
do-tim.ctrên máy bạn. ĐổiNthành 1000 và đo lại, giải thích vì sao tỷ lệ thay đổi. - Cài
list_tim_cuoitì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
ctxlà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.