Người viết:
Reviewer:
Thông thường khi viết một thuật toán, ta thường quan tâm nó chạy nhanh hay chậm, tốn nhiều bộ nhớ hay không.
Để ước lượng thời gian chạy của một thuật toán dựa trên kích thước đầu vào, chúng ta sử dụng khái niệm: Độ phức tạp thời gian của thuật toán.
Độ phức tạp thời gian là một hàm đo số thao tác cơ bản mà một thuật toán thực thi, với tham biến là kích thước đầu vào:
Trên thực tế, đa phần các thuật toán sẽ được đánh giá theo trường hợp xấu nhất (worst case) vì sự đơn giản và thực tế của nó.
Một số khác vẫn được đánh theo trường hợp trung bình (average case). Ví dụ như khi:
Trong từng trường hợp khác nhau của dữ liệu vào, việc tính toán chính xác hàm (với tổng quát) thường rất khó. Và ở đây, chúng ta sử dụng độ phức tạp BigO hay O-lớn thay thế.
Xét 2 hàm số dương và
Ta ký hiệu:
Theo định nghĩa giải tích, ký hiệu trên tương đương với:
Gọi là "hàm không tăng (tiệm cận) nhanh hơn ".
Nói một cách dễ hiểu: thì tồn tại hằng số để khi đủ to với mọi nào đó thì .
Ví dụ:
S1, S2, ..., Sm
và ĐPT tương ứng của chúng là với là các hàm của dữ liệu đầu vào.S1; S2; ...; Sm;
có ĐPT if E then S1 else S2
có ĐPT while E do S1
do S1 while E
for i := E1 to E2 do S1
Ký hiệu là logarit của theo cơ số (với được định nghĩa bằng tùy sách). Tuy nhiên với phép chuyển cơ số và là một hằng số nhỏ nên ta sẽ không cần quá quan tâm là cơ số bao nhiêu nữa).
ĐPT | Tên gọi | n |
---|---|---|
Hằng số (Constant) | Tùy yêu cầu bài toán | |
Tuyến tính (Linear) | ||
Linearithmic | ||
Bậc 2 (Quadratic) | ||
Bậc 3 (Cubic) | ||
Bậc 4 (Quartic) | ||
Exponential | ||
Giai thừa (factorial) |
Tuy nhiên, việc có ĐPT đáp ứng bộ dữ liệu như trong bảng trên, không có nghĩa thuật toán sẽ chạy nhanh (trong khoảng ). Bạn đọc có thể xem chi tiết trong phần Mở rộng. Hằng số ĐPT
Dựa vào các quy tắc, ta rút ra được một số mẹo
khi tính ĐPT các vòng lặp:
1. Tính số lần lặp tối đa của một vòng lặp
2. Nếu các vòng lặp nối tiếp nhau thì cộng các cận đó với nhau
3. Nếu các vòng lặp lồng nhau thì nhân các cận với nhau
Ví dụ 1:
int sum = 0;
for (int i = 0; i < n; i++) sum += i;
for (int j = 0; j < n; j++) sum += j;
Hai vòng có tổng cộng phép cộng, nên ĐPT sẽ là
Ví dụ 2:
int sum = 0;
for (int i = 0; i < n; i++){
int j = 0;
while (j < n){
sum += j;
j++;
}
}
Hai vòng lặp lồng nhau, mỗi vòng lặp có ĐPT nên ĐPT sẽ là
Ví dụ 3:
int sum = 0;
for (int i = 0; i < n; i++){
for (int j = 0; j < i; j++){
sum += j;
}
}
Vòng i
lặp n
lần, vòng j
lặp tổng cộng lần, nên ĐPT chung vẫn sẽ là dù số phép tính đã được giảm đi khá nhiều.
Cho một mảng a[]
đã được sắp xếp. Xác định xem liệu có tồn tại phần tử trong mảng mà cách nhau đơn vị hay không.
Xét lời giải sau:
int j = 0;
for (int i = 0; i < n; i++) {
while ((j < n-1) && (a[i] - a[j] > d))
j++;
if (a[i] - a[j] == d){
cout << "Ton tai!";
return 0;
}
}
cout << "Khong ton tai";
Thoạt nhìn, nó khá giống với vòng lặp lồng ở Ví dụ i.2. và cho ra ĐPT nhưng thực chất, ĐPT nhỏ hơn vậy.
Phân tích: Ta xét trường hợp xấu nhất là khi "Khong ton tai"
:
j
nhận giá trị từ 1
đến n
, mỗi giá trị n
lần, và ĐPT chung sẽ là .i
chạy từ 1
đến n
, mỗi giá trị xét lần. Còn biến j
chạy từ 1
đến n
, mỗi giá trị xét tối đa lần. Nên ĐPT trong trường hợp xấu nhất là hay .Nhận xét:
Knuth's Optimization
hay Knapsack on tree
, thì ta phải dùng cách này để tính ĐPT một cách chính xác hơn.Cho một dãy được sắp xếp tăng dần, kiểm tra xem dãy có tồn tại giá trị target
không.
Xét lời giải bằng tìm kiếm nhị phân như sau:
int binary_search(int a[], int sizeA, int target) {
int lo = 1, hi = sizeA;
while (lo <= hi) {
int mid = lo + (hi - lo)/2;
if (a[mid] == target)
return mid;
else if (a[mid] < target)
lo = mid+1;
else
hi = mid-1;
}
// không tìm thấy giá trị target trong mảng A
return -1;
}
Ở mỗi bước, kích thước của mảng cần tìm kiếm bị giảm đi một nửa. Sau bước, thì số phần tử của mảng là và dừng tìm kiếm.
Từ đó ĐPT của thuật toán là với là số phần tử ban đầu của không gian tìm kiếm.
Đây là một đoạn code sinh tất cả các hoán vị từ đến với
#include <bits/stdc++.h>
using namespace std;
const int N = 10;
int n, a[N + 5];
bool used[N + 5];
void print(){
for (int i = 1; i <= n; i++)
cout << a[i];
cout << '\n';
}
void backtrack(int i){
if (i == n + 1){
print();
return;
}
for (int j = 1; j <= n; j++) if (used[j] == false) {
a[i] = j;
used[j] = true;
backtrack(i + 1);
used[j] = 0;
}
}
int main()
{
cin >> n;
backtrack(1);
}
Phân tích:
Ta gọi hàm backtrack(1)
nên i
sẽ bắt đầu từ 1
.
Tại vòng lặp j
đầu tiên, ta xét tất cả các giá trị có thể gán cho a[1]
(số hạng thứ 1
) và đánh dấu đã sử dụng giá trị đó.
Và ta sẽ gán lần lượt a[2], ..., a[n]
.
Đến i = n + 1
, chúng ta sẽ in ra kết quả và xét đến cấu hình tiếp. Việc in kết quả sẽ tốn 1 vòng
Vì thế ta có tổng cộng phép toán. Hay ĐPT bài toán là .
Đôi khi ĐPT của một thuật toán đệ quy không quá lớn như .
Bạn đọc có thể thấy rõ với thuật toán sắp xếp Merge Sort (Sắp xếp trộn) sau đây:
MergeSort(mảng S) {
1. if (số phần tử của S <= 1)
return S;
2. chia đôi S thành hai mảng con S1 và S2 với số phần tử gần bằng nhau;
3. MergeSort(S1);
4. MergeSort(S2);
5. trộn S1 và S2 đã sắp xếp để thu được S mới đã sắp xếp;
6. return S mới;
}
Minh họa về cách thuật toán Merge Sort hoạt động:
Phân tích:
Gọi là ĐPT của hàm MergeSort(S)
với
Dễ thấy:
Trong đó:
là số nguyên lớn nhất (phần nguyên dưới).
là số nguyên nhỏ nhất (phần nguyên trên).
Từ đó, ta có :
ĐPT thuật toán này là trong cả worst case và average case.
Để có được kết luận trên, ta đi chứng minh phát biểu sau:
Tồn tại hằng số nào đó mà với ta có
Bằng quy nạp, ta có:
Chọn , ta được đpcm.
Bạn đọc có thể tham khảo dạng tổng quát của bài toán: Master theorem (Định lý thợ)
Tính độ phức tạp thời gian của đoạn code sau:
int cnt = 0;
for (int i = 1; i <= n; i++){
for (int j = i; j <= n; j += i){
cnt++;
}
}
Giải:
Rõ ràng, với mỗi biến , vòng lặp sẽ chạy lần.
Vì thế độ phức tạp sẽ là .
Và đến đây rút gọn thế nào nhỉ?
Nhận xét:
Rõ ràng,
Tính độ phức tạp thời gian của giải thuật sàng nguyên tố Erathosenes:
for (int i = 2; i * i <= n; i++) is_prime[i] = true;
for (int i = 2; i <= n; i++) if (is_prime[i]){
for (int j = i * 2; j <= n; j += i){
is_prime[j] = false;
}
}
Giải:
Tương tự bài trên, nhưng chỉ khi biến
Vì thế độ phức tạp thời gian là
Đến đây việc tính toán độ phức tạp sẽ phải dùng đến kiến thức Lý thuyết số giải tích. Bạn đọc có thể tham khảo thêm Định lý Merten 2.
Tuy
Với hầu hết các thuật toán thường gặp trong thực tế, giá trị hằng số của
Nói cách khác: nếu hằng số quá lớn thì thường là các hằng số đó có liên quan tới các đại lượng có sẵn trong đề bài. Khi đó, ta cần gán một tên gọi cho hằng số đó và thêm nó vào đánh giá ĐPT, thay vì bỏ qua.
Cũng như vừa đề cập, hằng số của thuật toán trong ĐPT cũng có ảnh hưởng đến thời gian thực thi.
Hai thuật toán có ĐPT ngang nhau không có nghĩa là chúng chạy nhanh như nhau.
std::sort
, std::priority_queue
, std::set
/std::map
đều có ĐPT std::sort
std::priority_queue
std::set
/std::map
Thuật toán có ĐPT bậc cao hơn không có nghĩa là chúng chạy chậm hơn trong mọi bộ dữ liệu.
std::sort
của C++. Thuật toán chính được sử dụng là Intro Sort, bắt đầu với Quick Sort. Để tối ưu, khi kích thước mảng nhỏ, hàm sẽ sử dụng Insertion Sort. Trong trường hợp độ sâu đệ quy vượt quá ngưỡng nhất định (khi chọn phần tử chốt của Quick-sort không hiệu quả), hàm sẽ chuyển sang Heap Sort để duy trì độ phức tạp Vì thế, trong từng trường hợp, ta nên chú ý chọn thuật toán cho phù hợp nhất để tối ưu thời gian chạy chương trình.
Và đặc biệt khi sử dụng các hàm trong thư viện sẵn có hay các code sẵn có thì nên hiểu cơ bản cách hoạt động và tốc độ của nó.
Với lập trình viên, độ phức tạp thời gian là một công cụ hữu ích để ước chừng thời gian thực thi của một thuật toán, hay so sánh giữa các thuật toán với nhau.
Trong các kỳ thi lập trình, kích cỡ của tập dữ liệu thường được cho trước trong đề bài. Dựa vào điều đó, thí sinh có thể ước chừng độ phức tạp rồi tìm ra thuật toán phù hợp. Hoặc là khi đã nghĩ ra một vài thuật toán thì liệu thuật toán nào đáng để cài đặt? Nên chọn những thuật toán nào để cài đặt?
ĐPT BigO - phân tích dựa trên tiệm cận là một công cụ mạnh mẽ. Nhưng Big O bỏ qua các hằng số, và đôi khi các hằng số lại quan trọng. Vì thế phải sử dụng nó một cách khôn khéo nhất để đạt hiệu quả cao trong lập trình.