Quy trình giải bài toán
Sau bài này bạn sẽ làm được
- Kể được sáu bước của quy trình giải bài toán
- Xác định trường hợp biên trước khi viết dòng code đầu tiên
- So sánh nhiều hướng giải trước khi chọn một hướng
- Áp dụng trọn quy trình vào một bài toán cụ thể
Đây là chương quan trọng nhất Phần 2. Không có nó, bạn chỉ biết gõ mã chứ không biết nghĩ. Sự khác nhau giữa người học ba tháng và người học ba năm nằm ở chỗ này chứ không nằm ở việc thuộc bao nhiêu hàm thư viện.
#Vì sao cần quy trình
Cách làm phổ biến của người mới là đọc đề xong mở editor gõ ngay. Cách đó chạy được với bài dễ, nhưng gặp bài khó thì tắc, và tắc theo kiểu không biết mình đang tắc ở đâu.
| Gõ ngay | Có quy trình |
|---|---|
| Phát hiện hiểu sai đề khi đã viết xong 100 dòng | Phát hiện ngay ở bước 1, mất 2 phút |
| Nhớ ra trường hợp biên khi đang gỡ lỗi | Liệt kê trường hợp biên trước khi viết dòng nào |
| Chọn cách giải đầu tiên nghĩ ra | So sánh vài cách rồi mới chọn |
| Viết xong mới biết thuật toán quá chậm | Ước lượng độ phức tạp trước khi cài |
| Sửa lỗi bằng cách thử và sai | Biết chỗ nào sai vì đã có mã giả |
#Sáu bước
Hiểu bài toán
Trả lời bốn câu hỏi trước khi làm gì khác. Nếu đề không nói rõ thì phải hỏi lại, đừng tự giả định.
- Vào là gì: kiểu dữ liệu, miền giá trị, kích thước tối đa, nguồn dữ liệu
- Ra là gì: định dạng, cách làm tròn, đơn vị
- Ràng buộc: giới hạn thời gian, giới hạn bộ nhớ, độ chính xác
- Trường hợp biên: n bằng 0, n bằng 1, số âm, dữ liệu trùng nhau, tràn số
Lấy ví dụ cụ thể
Làm bằng tay vài ví dụ nhỏ trước khi nghĩ tới mã. Lấy một ví dụ bình thường, một ví dụ biên, và một ví dụ xấu nhất. Nếu bạn không giải được bằng tay thì chắc chắn không viết được chương trình.
Tìm quy luật hoặc chia nhỏ
Hai câu hỏi mở khóa phần lớn bài toán:
- Bài này giống bài nào mình đã biết cách giải?
- Có chia được thành các bài toán con nhỏ hơn cùng dạng không?
Viết mã giả
Diễn đạt thuật toán bằng tiếng Việt có cấu trúc, chưa quan tâm cú pháp C. Bước này tách việc nghĩ ra khỏi việc gõ, nên bạn không phải làm hai việc khó cùng lúc. Cách viết mã giả ở Bài 7.3.
Cài đặt
Dịch mã giả sang C, từng phần một, biên dịch thường xuyên. Nếu bước 4 làm tốt thì bước này gần như máy móc và ít lỗi.
Kiểm thử và tối ưu
Chạy lại đúng những ví dụ đã làm tay ở bước 2, cộng thêm các trường hợp biên ở bước 1. Chỉ tối ưu sau khi đã chạy đúng, và chỉ tối ưu chỗ đã đo được là chậm.
#Áp dụng vào một bài toán
Bước 1: hiểu bài toán
Vào : mảng n số nguyên, n >= 1
Ra : một số nguyên, là giá trị lớn thứ hai
Ràng buộc: n có thể tới 10^6, nên thuật toán phải nhanh hơn O(n^2)
Trường hợp biên:
n = 1 -> không có số thứ hai, phải báo lỗi
[5, 5, 5] -> mọi phần tử bằng nhau, có số thứ hai không?
[3, 3, 1] -> giá trị lớn nhất lặp lại, số thứ hai là 3 hay 1?
mảng toàn số âm -> không được khởi tạo biến đếm bằng 0Bước 2: lấy ví dụ cụ thể
| Mảng | Đáp án | Ghi chú |
|---|---|---|
| [3, 1, 4, 1, 5, 9, 2, 6] | 6 | trường hợp bình thường |
| [1, 2] | 1 | n nhỏ nhất còn có đáp án |
| [5] | không có | n bằng 1 |
| [5, 5, 5] | không có | chỉ có một giá trị khác nhau |
| [3, 3, 1] | 1 | giá trị lớn nhất lặp lại |
| [-5, -2, -9] | -5 | toàn số âm |
Bước 3: tìm hướng giải
| Cách | Độ phức tạp | Nhận xét |
|---|---|---|
| Sắp xếp rồi lấy phần tử khác lớn nhất | O(n log n) | Dễ nghĩ, nhưng làm nhiều việc thừa và sửa đổi mảng gốc |
| Duyệt hai lần: tìm lớn nhất, rồi tìm lớn nhất trong phần còn lại | O(n) | Đúng, nhưng đọc mảng hai lượt |
| Duyệt một lần, giữ hai biến lớn nhất và lớn nhì | O(n) | Tốt nhất: một lượt, không sửa mảng, bộ nhớ phụ O(1) |
Bước 4: viết mã giả
HÀM lon_thu_hai(A, n)
NẾU n < 2 THÌ TRẢ VỀ "không có"
max1 ← âm vô cùng
max2 ← âm vô cùng
VỚI MỖI x TRONG A
NẾU x > max1 THÌ
max2 ← max1
max1 ← x
NGƯỢC LẠI NẾU x > max2 VÀ x ≠ max1 THÌ
max2 ← x
HẾT NẾU
HẾT VỚI
NẾU max2 = âm vô cùng THÌ TRẢ VỀ "không có"
TRẢ VỀ max2
HẾT HÀMBước 5: cài đặt
#include <stdio.h>
#include <limits.h>
/* Trả về 0 nếu tìm được, khác 0 nếu mảng không có giá trị lớn thứ hai. */
int lon_thu_hai(const int *a, size_t n, int *out)
{
if (n < 2) {
return -1;
}
long long max1 = LLONG_MIN;
long long max2 = LLONG_MIN;
for (size_t i = 0; i < n; ++i) {
if (a[i] > max1) {
max2 = max1;
max1 = a[i];
} else if (a[i] > max2 && a[i] != max1) {
max2 = a[i];
}
}
if (max2 == LLONG_MIN) {
return -1;
}
*out = (int)max2;
return 0;
}Bước 6: kiểm thử
static void kiem_thu(const int *a, size_t n, const char *mo_ta)
{
int kq;
if (lon_thu_hai(a, n, &kq) == 0) {
printf("%-24s -> %d\n", mo_ta, kq);
} else {
printf("%-24s -> không có\n", mo_ta);
}
}
int main(void)
{
kiem_thu((int[]){3,1,4,1,5,9,2,6}, 8, "bình thường");
kiem_thu((int[]){1,2}, 2, "n = 2");
kiem_thu((int[]){5}, 1, "n = 1");
kiem_thu((int[]){5,5,5}, 3, "mọi phần tử bằng nhau");
kiem_thu((int[]){3,3,1}, 3, "lớn nhất lặp lại");
kiem_thu((int[]){-5,-2,-9}, 3, "toàn số âm");
return 0;
}Chú ý là bộ kiểm thử này chính là bảng ví dụ đã lập ở bước 2. Đó là phần thưởng của việc làm bước 2 nghiêm túc: bạn có sẵn bộ ca kiểm thử mà không phải nghĩ thêm.
#Ba sai lầm phổ biến
Sai lầm 1: gõ trước, hiểu sau
Đọc đề xong gõ luôn. Được nửa chừng thì nhận ra hiểu sai một chi tiết, phải xóa gần hết. Lặp lại vài lần thì mất niềm tin vào bản thân chứ không phải vào cách làm.
Viết ra bốn dòng: vào là gì, ra là gì, ràng buộc gì, biên nào. Bốn dòng đó thường mất chưa tới hai phút và loại bỏ toàn bộ nhóm lỗi hiểu sai đề.
Sai lầm 2: tối ưu quá sớm
Sai lầm 3: bỏ qua trường hợp biên
Phần lớn lỗi trong chương trình thật nằm ở biên chứ không nằm ở luồng chính. Danh sách biên cần kiểm tra gần như giống nhau ở mọi bài toán:
- Dữ liệu rỗng, tức n bằng 0
- Chỉ có một phần tử
- Mọi phần tử bằng nhau
- Số âm, số 0, giá trị lớn nhất và nhỏ nhất của kiểu
- Dữ liệu đã sắp xếp sẵn, và dữ liệu sắp xếp ngược
- Kích thước tối đa mà đề cho phép
Tự làm thử
- Áp dụng trọn sáu bước cho bài toán: đếm số phần tử xuất hiện đúng một lần trong mảng.
- Áp dụng sáu bước cho bài toán: kiểm tra một chuỗi ngoặc có hợp lệ không, với ba loại ngoặc tròn, vuông và nhọn.
- Cài lại hàm
lon_thu_haitheo cách sắp xếp rồi lấy, so sánh thời gian với bản duyệt một lượt khi n bằng một triệu. - Với bài tìm số lớn thứ hai, viết thêm ba trường hợp biên chưa có trong bảng và kiểm chứng chương trình vẫn đúng.
- Tìm một bài tập cũ của bạn, viết lại bảng phân tích bốn dòng cho nó, và xem có trường hợp biên nào bạn đã bỏ só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
- Sáu bước: hiểu đề, lấy ví dụ, tìm hướng, viết mã giả, cài đặt, kiểm thử.
- Bước 1 và bước 2 hay bị bỏ nhất nhưng lại tiết kiệm nhiều thời gian nhất.
- Khi đề mơ hồ thì phải hỏi lại chứ không tự giả định.
- Làm tay vài ví dụ trước khi nghĩ tới mã. Bảng ví dụ đó chính là bộ kiểm thử sau này.
- So sánh vài hướng giải rồi mới chọn, thay vì lấy ngay cách đầu tiên nghĩ ra.
- Chạy đúng trước rồi mới tối ưu, và chỉ tối ưu chỗ đã đo được là chậm.