Nhập môn

Tìm kiếm tuyến tính (Linear Search): từ cơ bản đến kỹ thuật lính canh và code C++, Python

Tìm kiếm tuyến tính (Linear Search, hay tìm kiếm tuần tự) là thuật toán kiểm tra lần lượt từng phần tử từ đầu đến cuối mảng để tìm giá trị `x` hoặc phần tử thỏa mãn một điều kiện cho trước. Không giống tìm kiếm nhị phân đòi hỏi mảng phải được sắp xếp sẵn, tìm kiếm tuyến tính chạy trực tiếp trên mọi mảng lộn xộn với độ phức tạp `O(n)` và là lựa chọn tối ưu nhất khi bạn chỉ cần tra cứu một vài lần.

Ý chính
  • Hoạt động trên mọi mảng bất kỳ (không cần sắp xếp trước) và trên cả danh sách liên kết với độ phức tạp thời gian O(n), bộ nhớ phụ O(1).
  • Khi mảng chưa sắp xếp và bạn chỉ cần tìm 1 lần (hoặc dưới log₂ n lần), tìm kiếm tuyến tính O(n) nhanh hơn hẳn việc sắp xếp O(n log n) rồi mới tìm kiếm nhị phân.
  • Kỹ thuật lính canh (Sentinel Linear Search) đặt thêm x vào cuối mảng để loại bỏ hoàn toàn phép kiểm tra biên ở mỗi bước lặp.
  • Mọi bài toán tìm vị trí đầu tiên, vị trí cuối cùng, hay tìm đồng thời giá trị lớn nhất và lớn thứ hai (max1, max2) đều giải quyết gọn trong 1 vòng duyệt tuyến tính duy nhất.

Tìm kiếm tuyến tính (Linear Search) hoạt động như thế nào?

Khi một dãy dữ liệu chưa có thứ tự — chẳng hạn danh sách mã hóa đơn phát sinh trong ngày a = [14, 7, 25, 7, 9, 30] — ta không thể loại bỏ nửa trái hay nửa phải mảng. Cách duy nhất đảm bảo không bỏ sót đáp án là duyệt tuần tự (sequential scan):

  1. Bắt đầu từ phần tử đầu tiên ở chỉ số i = 0.
  2. So sánh a[i] với x: nếu a[i] == x thì trả về ngay chỉ số i; nếu khác thì tăng i lên 1.
  3. Nếu đi hết mảng (i == n) mà chưa gặp x, kết luận x không tồn tại và trả về -1.

Độ phức tạp của thuật toán:

  • Tốt nhất — O(1): a[0] == x, chỉ tốn 1 phép so sánh.
  • Xấu nhất và trung bình — O(n): x nằm ở cuối mảng, không có trong mảng (tốn n lần so sánh), hoặc xuất hiện ngẫu nhiên (trung bình (n + 1) / 2 lần so sánh).

Bài toán thực tế: Khi nào Tìm kiếm tuyến tính đánh bại Tìm kiếm nhị phân?

Một hiểu lầm rất phổ biến của người mới học là: “Vì Tìm kiếm nhị phân (Binary Search) chỉ mất O(log n) còn Tìm kiếm tuyến tính mất O(n), nên gặp bài tìm kiếm nào cũng nên sắp xếp mảng rồi tìm nhị phân cho nhanh!”

Hãy làm một phép tính chi phí thực tế khi mảng ban đầu chưa được sắp xếp và bạn cần thực hiện Q lần tra cứu:

1. Duyệt tuần tự tìm x = 7 trên mảng chưa sắp xếp [14, 7, 25, 7, 9, 30] a[0] a[1] (Đầu) a[2] a[3] (Cuối) a[4] a[5] a[6] (Lính canh) 14 7 25 7 9 30 7 2. Khi mảng chưa sắp xếp: Chọn Tìm tuyến tính hay Sắp xếp + Nhị phân? Tìm kiếm tuyến tính (Linear Search) Chi phí 1 lần tìm: O(n) Chi phí Q truy vấn: O(Q · n) Tốt nhất khi tìm ít lần (Q = 1 .. vài lần) Sắp xếp + Tìm kiếm nhị phân Sắp xếp trước: O(n log n) Chi phí Q truy vấn: O((n + Q) log n) Tốt nhất khi truy vấn nhiều lần (Q >> log n)
Tìm x = 7 trong [14, 7, 25, 7, 9, 30] a[0] Đầu a[2] Cuối a[4] a[5] 14 7 25 7 9 30 Tìm tuyến tính: O(Q · n) Không cần sắp xếp mảng Nhanh hơn khi chỉ tìm 1 hoặc vài lần Sắp xếp + Nhị phân: O((n + Q) log n) Tốn O(n log n) sắp xếp ban đầu Nhanh hơn khi truy vấn rất nhiều lần
Hình 1. Minh họa tìm x = 7 trên mảng chưa sắp xếp [14, 7, 25, 7, 9, 30] và điểm hòa vốn giữa Tìm kiếm tuyến tính với Sắp xếp kèm Tìm kiếm nhị phân.
  • Phương án 1 — Dùng thẳng Tìm kiếm tuyến tính: Không tốn thời gian chuẩn bị. Với Q lần tìm, tổng chi phí là O(Q · n). Đặc biệt nếu chỉ tìm 1 lần (Q = 1), chi phí chỉ là O(n).
  • Phương án 2 — Sắp xếp trước rồi mới Tìm kiếm nhị phân: Bước sắp xếp ban đầu bằng Quick Sort hoặc Merge Sort đã ngốn ngay O(n log n) phép tính, sau đó mỗi truy vấn tốn thêm O(log n). Tổng chi phí là O((n + Q) log n).
Số lần tìm kiếm Q trên mảng n = 1.000.000 chưa sắp xếp Phương án 1: Tìm tuyến tính O(Q · n) Phương án 2: Sắp xếp + Nhị phân O((n + Q) log n) Phương án nào thắng?
Chỉ tìm 1 lần (Q = 1) Tối đa 10⁶ bước (trung bình 5 × 10⁵ bước) Sắp xếp mất ≈ 2 × 10⁷ bước + tìm 20 bước Tìm tuyến tính nhanh gấp ~20 lần!
Tìm 5 lần (Q = 5) Tối đa 5 × 10⁶ bước ≈ 2 × 10⁷ bước Tìm tuyến tính vẫn nhanh hơn!
Tìm 100.000 lần (Q = 10⁵) Tới 10¹¹ bước (Quá thời gian TLE) ≈ 2.2 × 10⁷ bước (Chạy dưới 0.1 giây) Sắp xếp + Nhị phân thắng áp đảo!

Như vậy, ngưỡng chuyển giao nằm ở khoảng Q ≈ log₂ n: nếu mảng chưa có thứ tự và bạn chỉ cần tìm kiếm một hoặc vài lần (hoặc mảng liên tục bị chèn/xóa phần tử), Tìm kiếm tuyến tính là lựa chọn tối ưu nhất vì cận dưới lý thuyết cho 1 lần tìm trên mảng chưa sắp xếp chính là Ω(n).

Ba biến thể quan trọng: Vị trí đầu, vị trí cuối và Kỹ thuật lính canh (Sentinel)

Khi mảng có nhiều phần tử trùng nhau (ví dụ a = [14, 7, 25, 7, 9, 30] có hai số 7 ở chỉ số 1 và 3), cách bạn điều khiển hướng duyệt sẽ quyết định vị trí nào được trả về:

1. Tìm vị trí xuất hiện đầu tiên và cuối cùng

  • Vị trí xuất hiện đầu tiên (tim_vi_tri_dau): Cho i chạy từ 0 tăng dần lên n - 1, gặp a[i] == x đầu tiên thì return i ngay.
  • Vị trí xuất hiện cuối cùng (tim_vi_tri_cuoi): Thay vì duyệt xuôi từ đầu đến hết mảng và lưu biến tạm, cách nhanh hơn là duyệt ngược từ cuối mảng (i = n - 1) về 0 và return i ngay lần đầu tiên gặp a[i] == x.

2. Kỹ thuật lính canh (Sentinel Linear Search) của Donald Knuth

Trong vòng lặp tìm kiếm tuyến tính thông thường từ 0 đến n - 1, ở mỗi bước lặp CPU phải thực hiện 2 phép so sánh nhánh:

  1. Kiểm tra điều kiện biên xem chỉ số i đã chạm tới n hay chưa.
  2. Kiểm tra giá trị a[i] == x xem đã tìm thấy x chưa.

Nhà khoa học máy tính Donald Knuth (The Art of Computer Programming, Tập 3) chỉ ra cách cắt bỏ một nửa số phép so sánh này bằng kỹ thuật lính canh (Sentinel):

  • Đặt thêm chính giá trị x vào vị trí cuối cùng a[n] làm “lính canh”.
  • Vì chắc chắn vòng lặp sẽ gặp x (chậm nhất tại a[n]), ta bỏ hẳn phép kiểm tra biên và chỉ viết while (a[i] != x) ++i;.
  • Khi vòng lặp dừng ở chỉ số i: nếu i nhỏ hơn n thì trả về i; nếu i == n (đụng lính canh) thì trả về -1.

Dưới đây là code cài đặt cả 3 hàm bằng C++ và Python:

#include <iostream>
#include <vector>
using namespace std;

// 1. Tìm vị trí xuất hiện đầu tiên của x (trả về -1 nếu không có)
int tim_vi_tri_dau(const vector<int>& a, int x) {
    for (int i = 0; i < (int)a.size(); ++i) {
        if (a[i] == x) {
            return i;
        }
    }
    return -1;
}

// 2. Tìm vị trí xuất hiện cuối cùng của x (duyệt ngược từ cuối về đầu)
int tim_vi_tri_cuoi(const vector<int>& a, int x) {
    for (int i = (int)a.size() - 1; i >= 0; --i) {
        if (a[i] == x) {
            return i;
        }
    }
    return -1;
}

// 3. Kỹ thuật lính canh (Sentinel Linear Search): bỏ kiểm tra i < n trong vòng lặp
int tim_linh_canh(vector<int> a, int x) {
    int n = (int)a.size();
    a.push_back(x); // Đặt lính canh x ở vị trí a[n]
    int i = 0;
    while (a[i] != x) {
        ++i; // Chắc chắn dừng vì có lính canh ở cuối
    }
    return (i < n) ? i : -1;
}

int main() {
    vector<int> a = {14, 7, 25, 7, 9, 30};

    // Test 1: Phần tử 7 xuất hiện nhiều lần (đầu tiên ở 1, cuối cùng ở 3)
    cout << "Dau(7): " << tim_vi_tri_dau(a, 7)
         << " | Cuoi(7): " << tim_vi_tri_cuoi(a, 7)
         << " | LinhCanh(7): " << tim_linh_canh(a, 7) << "\n";

    // Test 2: Phần tử 30 nằm ở cuối mảng (chỉ số 5)
    cout << "Dau(30): " << tim_vi_tri_dau(a, 30)
         << " | Cuoi(30): " << tim_vi_tri_cuoi(a, 30)
         << " | LinhCanh(30): " << tim_linh_canh(a, 30) << "\n";

    // Test 3: Phần tử 99 không tồn tại trong mảng (trả về -1)
    cout << "Dau(99): " << tim_vi_tri_dau(a, 99)
         << " | Cuoi(99): " << tim_vi_tri_cuoi(a, 99)
         << " | LinhCanh(99): " << tim_linh_canh(a, 99) << "\n";

    return 0;
}

(Lưu ý: Trong C++ thực chiến, thư viện chuẩn cung cấp sẵn hàm std::find(a.begin(), a.end(), x) trả về iterator tới vị trí đầu tiên bằng x; còn trong Python bạn có thể dùng a.index(x) sau khi kiểm tra if x in a:).

Ứng dụng kinh điển: Tìm số lớn nhất và lớn thứ hai chỉ trong 1 vòng duyệt

Tìm kiếm tuyến tính không chỉ dừng lại ở việc so khớp a[i] == x. Nó là khuôn mẫu cho mọi bài toán duyệt mảng một lượt (single-pass scan) để tìm phần tử cực trị thỏa mãn điều kiện, chẳng hạn như bước tìm min_idx bên trong Sắp xếp chọn (Selection Sort).

Một bài toán phỏng vấn và thi lập trình rất hay gặp là: “Cho mảng a gồm n số nguyên. Hãy tìm giá trị lớn nhất (max1) và giá trị lớn thứ hai phân biệt (max2) mà không được sắp xếp mảng và chỉ dùng đúng 1 vòng lặp.”

Để giải trong 1 lượt duyệt duy nhất, ta duy trì đồng thời hai biến max1 (nhà vô địch hiện tại) và max2 (á quân hiện tại). Khi xét đến phần tử val:

  1. Nếu val lớn hơn max1: Ta tìm thấy nhà vô địch mới! Trước khi cập nhật max1 = val, ta đẩy nhà vô địch cũ xuống làm á quân: max2 = max1;, rồi mới gán max1 = val;.
  2. Ngược lại, nếu val nhỏ hơn max1 nhưng lớn hơn max2: Phần tử val không vượt qua được max1 (và cũng không bằng max1), nhưng lại lớn hơn á quân hiện tại, nên ta cập nhật max2 = val;.
#include <climits>
#include <iostream>
#include <utility>
#include <vector>
using namespace std;

// Tìm giá trị lớn nhất (max1) và lớn thứ hai phân biệt (max2) chỉ trong 1 vòng duyệt
// Nếu không tồn tại max2 phân biệt, max2 giữ giá trị LLONG_MIN
pair<long long, long long> tim_hai_so_lon_nhat(const vector<long long>& a) {
    long long max1 = LLONG_MIN;
    long long max2 = LLONG_MIN;
    for (long long val : a) {
        if (val > max1) {
            max2 = max1; // Cựu vô địch lùi xuống làm á quân
            max1 = val;  // Tân vô địch mới
        } else if (val < max1 && val > max2) {
            max2 = val;  // Nhỏ hơn max1 nhưng vượt qua max2 hiện tại
        }
    }
    return {max1, max2};
}

void in_ket_qua(const vector<long long>& a) {
    auto [max1, max2] = tim_hai_so_lon_nhat(a);
    cout << "max1: " << max1 << " | max2: ";
    if (max2 == LLONG_MIN) cout << "NONE\n";
    else cout << max2 << "\n";
}

int main() {
    // Test 1: Mảng có giá trị lớn nhất lặp lại nhiều lần
    in_ket_qua({12, 35, 1, 10, 35, 34}); // max1: 35 | max2: 34

    // Test 2: Mảng số âm
    in_ket_qua({-10, -3, -25, -7});      // max1: -3 | max2: -7

    // Test 3: Mọi phần tử bằng nhau -> không có giá trị lớn thứ hai phân biệt
    in_ket_qua({8, 8, 8});               // max1: 8 | max2: NONE

    return 0;
}

Cách 1 vòng duyệt chỉ mất O(n) thời gian và O(1) bộ nhớ, đồng thời xử lý đúng cả khi giá trị lớn nhất lặp lại nhiều lần ([12, 35, 1, 10, 35, 34] ra max1 = 35, max2 = 34) lẫn khi mọi phần tử bằng nhau ([8, 8, 8] báo NONE).

Lỗi hay gặp khi viết Tìm kiếm tuyến tính

Cẩn thận
  • Viết else return -1; ngay bên trong vòng lặp for. Nếu đặt return -1 trong nhánh else của câu lệnh if (a[i] == x), hàm sẽ chỉ kiểm tra duy nhất phần tử đầu tiên a[0] rồi thoát ngay lập tức! Lệnh return -1 bắt buộc phải nằm ở bên ngoài, phía sau vòng lặp for, khi mọi phần tử từ 0 đến n - 1 đều đã được kiểm tra mà không khớp.
  • Khởi tạo max_val = 0 khi tìm giá trị lớn nhất trong mảng. Nếu đề bài cho mảng chứa toàn số âm (ví dụ [-10, -3, -25, -7]), việc khởi tạo max_val = 0 sẽ khiến chương trình in ra kết quả sai là 0 (một con số không hề có trong mảng!). Hãy luôn khởi tạo bằng phần tử đầu tiên a[0], hoặc bằng hằng số âm vô cùng (LLONG_MIN / INT_MIN trong C++, None hoặc -math.inf trong Python).
  • Gọi thẳng a.index(x) trong Python mà không kiểm tra trước. Phương thức list.index(x) của Python sẽ ném ngoại lệ ValueError: x is not in list và làm dừng chương trình nếu x không tồn tại. Hãy kiểm tra if x in a: trước khi gọi.

Luyện ngay trên Big-O Coding

Thực hành ngay các bài toán duyệt mảng tuyến tính, tìm cực trị và đếm điều kiện trên hệ thống Big-O Coding:

Câu hỏi thường gặp

Nếu mảng đã được sắp xếp tăng dần nhưng vẫn dùng Tìm kiếm tuyến tính thì có dừng sớm được không?

Có. Khi mảng đã tăng dần, trong lúc duyệt từ trái sang phải để tìm x, ngay khi gặp phần tử a[i] lớn hơn x, bạn có thể break và kết luận ngay x không tồn tại trong mảng mà không cần đi hết phần còn lại. Tuy nhiên, trên mảng đã sắp xếp bạn nên dùng Tìm kiếm nhị phân để đạt O(log n).

Tìm kiếm tuyến tính có dùng được trên Danh sách liên kết (Linked List) không?

Có, và đây là ưu thế lớn của Tìm kiếm tuyến tính. Trên danh sách liên kết đơn, ta không thể nhảy thẳng tới phần tử ở giữa a[mid] trong O(1) nên Tìm kiếm nhị phân không hoạt động hiệu quả, trong khi Tìm kiếm tuyến tính vẫn chạy tự nhiên qua từng nút với độ phức tạp O(n).

Muốn liệt kê tất cả các vị trí xuất hiện của x trong mảng thì làm thế nào?

Thay vì return i ngay lần đầu gặp a[i] == x, bạn tạo một mảng kết quả vi_tri rỗng, duyệt i từ 0 đến n - 1, mỗi khi thấy a[i] == x thì thêm i vào vi_tri (push_back trong C++ hoặc append trong Python).

Muốn học bài bản cùng giảng viên?

Khóa Green cho người mới, sinh viên năm 1–2: xây nền tảng lập trình và thuật toán vững chắc, biết dùng AI hỗ trợ. 16 buổi online, học thử buổi 1 miễn phí.

Xem Khóa Green

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483