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

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õ ngayCó quy trình
Phát hiện hiểu sai đề khi đã viết xong 100 dòngPhát hiện ngay ở bước 1, mất 2 phút
Nhớ ra trường hợp biên khi đang gỡ lỗiLiệ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ĩ raSo 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à saiBiết chỗ nào sai vì đã có mã giả

#Sáu bước

  1. 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ố
  2. 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.

  3. 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?
  4. 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 , 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.

  5. 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.

  6. 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ài
Cho một mảng n số nguyên. Tìm giá trị lớn thứ hai trong mảng.

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 0

Bước 2: lấy ví dụ cụ thể

MảngĐáp ánGhi chú
[3, 1, 4, 1, 5, 9, 2, 6]6trường hợp bình thường
[1, 2]1n 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]1giá trị lớn nhất lặp lại
[-5, -2, -9]-5toàn số âm

Bước 3: tìm hướng giải

CáchĐộ phức tạpNhận xét
Sắp xếp rồi lấy phần tử khác lớn nhấtO(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ạiO(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ÀM

Bước 5: cài đặt

lon-thu-hai.c
#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ử

test.c
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

Mở editor ngay

Đọ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.

Mở giấy trước

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ử

  1. Á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.
  2. Á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.
  3. Cài lại hàm lon_thu_hai theo 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.
  4. 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.
  5. 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.