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

Tìm tuyến tính

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

  • Cài tìm tuyến tính trả về chỉ số hoặc con trỏ
  • Hiểu mẹo lính gác và giới hạn của nó
  • Biết khi nào không đáng sắp xếp trước
  • Đo số phép so sánh trung bình

Tìm tuyến tính là thuật toán đơn giản nhất trong cả khóa học: đi qua từng phần tử cho tới khi gặp. Nó đáng học kỹ vì nó là chuẩn để so, và vì trong nhiều tình huống thực tế nó vẫn là lựa chọn đúng.

#Vòng lặp cơ bản

tim-tuyen-tinh.c
#include <stddef.h>

/* Trả về chỉ số phần tử đầu tiên bằng x, hoặc -1 nếu không có. */
long tim_tuyen_tinh(const int *a, size_t n, int x)
{
    for (size_t i = 0; i < n; ++i)
        if (a[i] == x)
            return (long)i;

    return -1;
}

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

Ba biến thể
/* Biến thể 1: chỉ số, quy ước trả về n */
size_t tim_chi_so(const int *a, size_t n, int x);

/* Biến thể 2: con trỏ, quy ước trả về NULL */
int *tim_con_tro(int *a, size_t n, int x)
{
    for (size_t i = 0; i < n; ++i)
        if (a[i] == x) return &a[i];

    return NULL;
}

/* Biến thể 3: tổng quát cho mọi kiểu, giống lfind của POSIX */
void *tim_tong_quat(const void *khoa, const void *mang,
                    size_t n, size_t co,
                    int (*so_sanh)(const void *, const void *))
{
    const char *p = mang;

    for (size_t i = 0; i < n; ++i, p += co)
        if (so_sanh(khoa, p) == 0)
            return (void *)p;

    return NULL;
}
Chỉ sốCon trỏTổng quát
Dấu hiệu không thấynNULLNULL
Dùng được với kiểu bất kỳKhôngKhôngCó
Tốc độNhanh nhấtNhanhChậm, mỗi phần tử một lời gọi
Tính được vị trí tương đốiCó ngayPhải trừ con trỏPhải trừ rồi chia
Dùng khiCần biết vị tríCần sửa phần tửViết thư viện

#Mẹo lính gác

Lính gác
Đặt giá trị cần tìm vào ngay sau phần tử cuối, để vòng lặp chắc chắn dừng. Nhờ vậy bỏ được phép kiểm biên i < n, còn đúng một phép so sánh mỗi vòng thay vì hai.
linh-gac.c
/* Mảng phải có ÍT NHẤT n+1 ô, ô thứ n dùng làm chỗ đặt lính gác.
   Hàm sửa a tạm thời rồi khôi phục, nên a không được là const. */
size_t tim_linh_gac(int *a, size_t n, size_t suc_chua, int x)
{
    if (suc_chua <= n) return n;      /* không có chỗ cho lính gác */

    int cuu = a[n];

    a[n] = x;                          /* đặt lính gác */

    size_t i = 0;

    while (a[i] != x) ++i;             /* KHÔNG cần kiểm i < n */

    a[n] = cuu;                        /* khôi phục */

    return i;                          /* i bằng n nghĩa là không tìm thấy */
}
terminal
gcc -std=c17 -O0 do-linh-gac.c -o t0 && ./t0 100000000
khong linh gac : 0.412 s
co linh gac    : 0.284 s
nhanh hon      : 1.45 lan
# Nhưng với -O2 thì chênh lệch gần như biến mất
gcc -std=c17 -O2 do-linh-gac.c -o t2 && ./t2 100000000
khong linh gac : 0.038 s
co linh gac    : 0.037 s
nhanh hon      : 1.03 lan

#Số phép so sánh trung bình

Trường hợpSố phép so sánhGhi chú
Tốt nhất1Phần tử đầu tiên khớp
Xấu nhất khi cónPhần tử cuối cùng khớp
Trung bình khi có(n + 1) / 2Giả sử mọi vị trí đều khả năng như nhau
Không tìm thấynLuôn phải quét hết
do-trung-binh.c
#include <stdio.h>
#include <stdlib.h>

static long dem;

static long tim_dem(const int *a, long n, int x)
{
    for (long i = 0; i < n; ++i) {
        ++dem;

        if (a[i] == x) return i;
    }

    return -1;
}

int main(void)
{
    const long n = 10000;
    int       *a = malloc((size_t)n * sizeof *a);

    if (a == NULL) return 1;

    for (long i = 0; i < n; ++i) a[i] = (int)i;

    srand(2024);

    /* Tìm phần tử chắc chắn có */
    dem = 0;

    for (long k = 0; k < 100000; ++k) tim_dem(a, n, rand() % (int)n);

    printf("tim thay     : %.1f phep so sanh TB, ly thuyet %.1f\n",
           (double)dem / 100000.0, (n + 1) / 2.0);

    /* Tìm phần tử chắc chắn không có */
    dem = 0;

    for (long k = 0; k < 100000; ++k) tim_dem(a, n, -1);

    printf("khong tim thay: %.1f phep so sanh TB, ly thuyet %ld\n",
           (double)dem / 100000.0, n);

    free(a);

    return 0;
}
terminal
gcc -std=c17 -O2 do-trung-binh.c -o t && ./t
tim thay     : 5000.4 phep so sanh TB, ly thuyet 5000.5
khong tim thay: 10000.0 phep so sanh TB, ly thuyet 10000

#Khi nào tuyến tính là lựa chọn đúng

Tình huốngVì sao tuyến tính thắng
n nhỏ, dưới khoảng 50Hằng số ẩn nhỏ hơn, và mảng nhỏ nằm gọn trong bộ nhớ đệm
Chỉ tìm một hoặc vài lầnSắp xếp trước tốn O(n log n), không bù lại được
Dữ liệu thay đổi liên tụcGiữ mảng luôn sắp xếp tốn O(n) mỗi lần chèn
Tiêu chí tìm không sắp xếp đượcVí dụ tìm phần tử đầu tiên thỏa một vị từ phức tạp
Cấu trúc không truy cập ngẫu nhiên đượcDanh sách liên kết chỉ đi được tuần tự
Cần tìm MỌI phần tử khớpVẫn phải quét hết, tìm nhị phân không giúp gì

Tự làm thử

  1. Cài ba biến thể của tìm tuyến tính và đo thời gian của chúng trên một trăm triệu phép tìm.
  2. Cài bản có lính gác, đo với -O0 và -O2, rồi giải thích vì sao chênh lệch biến mất.
  3. Đếm số phép so sánh thực tế khi tìm thấy và khi không tìm thấy, so với công thức lý thuyết.
  4. Cài mẹo chuyển lên trước, rồi đo trên phân bố đều và phân bố Zipf.
  5. Tìm điểm hòa vốn giữa tìm tuyến tính và sắp xếp cộng tìm nhị phân trên máy bạn, với n bằng một triệ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

  • Tìm tuyến tính là O(n) và trung bình tốn (n+1)/2 phép so sánh khi phần tử có mặt.
  • Trả về n làm dấu hiệu không tìm thấy là quy ước tốt nhất khi chỉ số có kiểu size_t.
  • Mẹo lính gác từng đáng giá nhưng nay gần như vô nghĩa với -O2, và nó sửa mảng nên không dùng được với dữ liệu chỉ đọc.
  • Bản tổng quát nhận con trỏ hàm chậm hơn khoảng mười lăm lần vì mỗi phần tử tốn một lời gọi không nội tuyến được.
  • Sắp xếp trước chỉ đáng khi số lần tìm vượt khoảng log2(n). Dưới ngưỡng đó, tìm tuyến tính thắng.