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ì
Đị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.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ất | Nghĩa là | Vi phạm thì sao |
|---|---|---|
| Đầu vào | Có 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 ra | Có ít nhất một kết quả | Chạy xong không cho ra gì thì vô nghĩa |
| Xác định | Mỗi bước rõ ràng, không mơ hồ, ai làm cũng ra kết quả như nhau | Hai người cài đặt ra hai chương trình khác nhau |
| Hữu hạn | Chắc chắn dừng sau hữu hạn bước | Chươ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ẵn | Không cài đặt được thành chương trình |
#Khác gì chương trình
| Thuật toán | Chươ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 | Có |
| Phụ thuộc máy | Không | Có, qua kiểu dữ liệu và thư viện |
| Bắt buộc dừng | Có | Không, hệ điều hành chạy mãi vẫn hợp lệ |
| Có thể chạy trực tiếp | Không | Có |
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.
long long tong(int n)
{
long long s = 0;
for (int i = 1; i <= n; ++i) {
s += i;
}
return s;
}long long tong(int n)
{
return (long long)n * (n + 1) / 2;
}| n | Cách 1: số phép cộng | Cách 2: số phép tính |
|---|---|---|
| 10 | 10 | 3 |
| 1 000 | 1 000 | 3 |
| 1 000 000 | 1 000 000 | 3 |
| 1 000 000 000 | 10⁹, khoảng 1 giây | 3, 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 a mũ n 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
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;
}| n | Cách chậm | Cách nhanh | Nhanh hơn |
|---|---|---|---|
| 10 | 10 | 6 | khoảng 1,7 lần |
| 1 000 | 1 000 | 16 | khoảng 62 lần |
| 1 000 000 | 10⁶ | 27 | khoảng 37 nghìn lần |
| 1 000 000 000 | 10⁹ | 43 | khoảng 23 triệu lần |
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ử
- 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.
- 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.
- 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.
- 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.
- 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. - 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ừ
nphép nhân xuống còn từlog₂ ntới2 log₂ n, và là nền tảng của mã hóa hiện đại.