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

Thuật toán

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

  • Kể năm tính chất bắt buộc của một thuật toán
  • Phân biệt thuật toán với chương trình
  • So sánh hai thuật toán cho cùng bài toán bằng số phép tính
  • Cài đặt được thuật toán lũy thừa nhanh

Thuật toán là phần khó nhất và cũng là phần đáng giá nhất của lập trình. Một thuật toán tốt trên máy tính cũ vẫn thắng một thuật toán tệ trên siêu máy tính. Bài này định nghĩa cho chặt và cho thấy chênh lệch đó lớn tới mức nào bằng số cụ thể.

#Thuật toán là gì

Thuật toán
Một dãy hữu hạn các bước rõ ràng, thực hiện được, biến dữ liệu vào thành dữ liệu ra mong muốn, và chắc chắn dừng sau hữu hạn bước.

Định nghĩa này có trước máy tính rất lâu. Thuật toán Euclid tìm ước chung lớn nhất ra đời khoảng năm 300 trước Công nguyên và vẫn là cách tốt nhất tới hôm nay. Chính từ đó cho thấy: thuật toán độc lập với ngôn ngữ lập trình và độc lập với cả máy tính.

Thuật toán Euclid, viết bằng lời:

    Cho hai số nguyên dương a và b.
    Chừng nào b còn khác 0:
        Đặt r là phần dư của a chia b.
        Đặt a là b, đặt b là r.
    Khi b bằng 0 thì a chính là ước chung lớn nhất.
euclid.c
int ucln(int a, int b)
{
    while (b != 0) {
        int r = a % b;

        a = b;
        b = r;
    }

    return a;
}

#Năm tính chất bắt buộc

Tính chấtNghĩa làVi phạm thì sao
Đầu vàoCó 0 hoặc nhiều dữ liệu vào được xác định rõKhông biết thuật toán làm việc trên cái gì
Đầu raCó ít nhất một kết quảChạy xong không cho ra gì thì vô nghĩa
Xác địnhMỗi bước rõ ràng, không mơ hồ, ai làm cũng ra kết quả như nhauHai người cài đặt ra hai chương trình khác nhau
Hữu hạnChắc chắn dừng sau hữu hạn bướcChương trình treo, không phải thuật toán
Hiệu quảMỗi bước thực hiện được bằng phương tiện có sẵnKhông cài đặt được thành chương trình

#Khác gì chương trình

Thuật toánChương trình
Dạng tồn tạiÝ tưởng, mã giả, lưu đồMã nguồn của một ngôn ngữ
Phụ thuộc ngôn ngữKhông
Phụ thuộc máyKhôngCó, qua kiểu dữ liệu và thư viện
Bắt buộc dừngKhông, hệ điều hành chạy mãi vẫn hợp lệ
Có thể chạy trực tiếpKhông

Một thuật toán có thể cài thành nhiều chương trình khác nhau bằng nhiều ngôn ngữ khác nhau. Ngược lại, một chương trình thường là tổ hợp của nhiều thuật toán. Khi bạn học cấu trúc dữ liệu ở Phần 10 và giải thuật ở Phần 11, thứ bạn học là thuật toán, và nó dùng được cho mọi ngôn ngữ bạn học sau này.

#Cùng bài toán, chênh vạn lần

Đây là phần cho thấy vì sao thuật toán đáng học. Xét bài toán rất đơn giản: tính tổng các số từ 1 tới n.

Cách 1: vòng lặp, O(n)
long long tong(int n)
{
    long long s = 0;

    for (int i = 1; i <= n; ++i) {
        s += i;
    }

    return s;
}
Cách 2: công thức, O(1)
long long tong(int n)
{
    return (long long)n * (n + 1) / 2;
}
nCách 1: số phép cộngCách 2: số phép tính
10103
1 0001 0003
1 000 0001 000 0003
1 000 000 00010⁹, khoảng 1 giây3, tức thì

Không phải bài nào cũng có công thức đóng như trên. Nhưng gần như bài nào cũng có nhiều hướng giải với độ phức tạp khác nhau, và bước 3 của quy trình ở Bài 7.1 tồn tại chính là để bạn cân nhắc chúng.

#Ví dụ: lũy thừa nhanh

Bài toán: tính an với n là số nguyên không âm. Đây là ví dụ kinh điển cho thấy chia để trị mạnh tới mức nào.

Cách ngây thơ

long long luy_thua_cham(long long a, int n)
{
    long long r = 1;

    for (int i = 0; i < n; ++i) {
        r *= a;
    }

    return r;
}
/* n phép nhân */

Ý tưởng chia đôi

a^n  =  (a^(n/2))^2            nếu n chẵn
a^n  =  a * (a^((n-1)/2))^2    nếu n lẻ

Ví dụ tính 3^10:
    3^10 = (3^5)^2
    3^5  = 3 * (3^2)^2
    3^2  = (3^1)^2
    3^1  = 3 * (3^0)^2

Chỉ cần 4 tầng chia đôi, tổng cộng 6 phép nhân thay vì 10.

Cài đặt bằng vòng lặp

luy-thua.c
long long luy_thua_nhanh(long long a, int n)
{
    long long r = 1;

    while (n > 0) {
        if (n % 2 == 1) {    /* n lẻ thì nhân thêm một lần a hiện tại */
            r *= a;
        }

        a *= a;              /* bình phương cơ số */
        n /= 2;              /* giảm nửa số mũ */
    }

    return r;
}
nCách chậmCách nhanhNhanh hơn
10106khoảng 1,7 lần
1 0001 00016khoảng 62 lần
1 000 00010⁶27khoảng 37 nghìn lần
1 000 000 00010⁹43khoảng 23 triệu lần
Cả hai cột đều đếm số phép nhân, để so sánh cho công bằng.
terminal
# Đo thử với n lớn, dùng phép nhân theo modulo để không tràn
./luy-thua 1000000000
cách chậm: 3.812 giây
cách nhanh: 0.000 giây

Cùng ý tưởng cho phép nhân

/* Nhân hai số chỉ bằng phép cộng và phép dịch, cùng ý tưởng chia đôi */
long long nhan_nhanh(long long a, long long b)
{
    long long r = 0;

    while (b > 0) {
        if (b % 2 == 1) {
            r += a;
        }

        a += a;
        b /= 2;
    }

    return r;
}

Nhận ra một ý tưởng dùng lại được ở nhiều bài toán khác nhau chính là điều làm nên người giải thuật giỏi. Phần 11 sẽ hệ thống hóa các nhóm ý tưởng như vậy: chia để trị, tham lam, quy hoạch động và quay lui.

Tự làm thử

  1. Kiểm chứng năm tính chất cho thuật toán Euclid: chỉ rõ đại lượng nào giảm dần và vì sao nó chắc chắn dừng.
  2. Cài cả hai cách tính tổng 1 tới n, đo thời gian với n bằng một tỷ, ghi lại chênh lệch.
  3. Cài lũy thừa nhanh, đếm chính xác số phép nhân với các giá trị n bằng 10, 100, 1 000 và 10 000.
  4. Viết lũy thừa nhanh theo cách đệ quy, so sánh độ dễ đọc với bản dùng vòng lặp.
  5. Cài nhan_nhanh ở trên, giải thích vì sao nó chỉ dùng phép cộng mà vẫn cho ra tích.
  6. Tìm một thuật toán O(n) cho bài toán tìm số xuất hiện nhiều nhất trong mảng, biết rằng các giá trị nằm trong khoảng 0 tới 1000.

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

  • Thuật toán là dãy hữu hạn các bước rõ ràng, chắc chắn dừng, biến dữ liệu vào thành dữ liệu ra.
  • Năm tính chất bắt buộc: có đầu vào, có đầu ra, xác định, hữu hạn, và hiệu quả.
  • Tính hữu hạn hay bị vi phạm nhất. Luôn chỉ ra đại lượng nào giảm dần.
  • Thuật toán độc lập với ngôn ngữ và với máy, nên học một lần dùng được mãi.
  • Đổi thuật toán mạnh hơn đổi phần cứng rất nhiều lần.
  • Lũy thừa nhanh giảm từ n phép nhân xuống còn từ log₂ n tới 2 log₂ n, và là nền tảng của mã hóa hiện đại.