Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Bộ Số Tam Giác (BOSOTG) (HSG12 Đà Nẵng 2018-2019 – Bài 1)

Bộ Số Tam Giác (BOSOTG) (HSG12 Đà Nẵng 2018-2019 – Bài 1)
Nguồn: HSG lớp 12 TP Đà Nẵng 2018-2019 – Bài 1 Độ khó: Trung bình Sắp xếp Tìm kiếm nhị phân Hai con trỏ

Đề bài

BOSOTG – Bộ số tam giác (Bài 1 – Đề thi HSG lớp 12 TP Đà Nẵng năm học 2018-2019)

Cho dãy số $A$ gồm $n$ phần tử nguyên dương $A_1, A_2, \ldots, A_n$. Mỗi phần tử có giá trị không vượt quá $10^9$ và $1 \lt n \le 5000$. Một bộ ba số được gọi là bộ số tam giác, nếu ba số này tạo thành ba cạnh của một tam giác nào đó.

Yêu cầu: Hãy đếm xem trong dãy $A$ có bao nhiêu bộ số tam giác $(A_i, A_j, A_k)$ với $i, j, k$ đôi một khác nhau.

Dữ liệu vào:

  • Dòng đầu là số $n$;
  • Dòng tiếp theo là các phần tử của dãy $A$, mỗi phần tử cách nhau một dấu cách.

Dữ liệu ra: Ghi ra số lượng bộ số tam giác.

Ràng buộc:

  • Có 30% số test ứng với 30% số điểm của bài có $n \le 100$.
  • Có 30% số test ứng với 30% số điểm của bài có $100 \lt n \le 1000$.
  • Có 40% số test ứng với 40% số điểm của bài có $1000 \lt n \le 5000$.

Giới hạn thời gian: 1 giây. Giới hạn bộ nhớ: 512 MB. Dữ liệu vào/ra chuẩn (standard input / standard output).

Ví dụ – input
5
4 3 1 5 7
Ví dụ – output
3
Ba bộ số tam giác gồm: $(4, 3, 5)$; $(4, 5, 7)$; $(3, 5, 7)$.

Lời mở đầu

Đây là bài 1 trong đề thi HSG lớp 12 TP Đà Nẵng năm học 2018-2019. Bài này rất hay để thấy được sức mạnh của giải thuật: mình sẽ đi từ cách vét cạn cần tới hơn 20 tỷ lần lặp, rồi rút xuống còn khoảng 162 triệu, cuối cùng chỉ còn khoảng 12,5 triệu lần lặp. Hy vọng mang đến được giá trị cho các bạn 😇.

Mình giải thích đề bài một chút cho bạn nào chưa hiểu. Cái này là kiến thức toán học cơ bản, nhưng mình nghĩ ít ra trong đề cũng nên có giải thích. Như đề nói, bộ số tam giác nghĩa là 3 số đó tạo thành 3 cạnh của một tam giác. Mình nói thêm: quy tắc để 3 cạnh tạo thành tam giác hợp lệ là tổng của 2 cạnh bất kỳ phải lớn hơn cạnh còn lại.

Điều kiện tam giác
Với 3 cạnh $a, b, c$, để tạo thành tam giác thì phải thỏa đồng thời: $a + b \gt c$  và  $a + c \gt b$  và  $b + c \gt a$. Thỏa đủ cả 3 thì mới là tam giác hợp lệ, chỉ cần sai 1 trong 3 là xem như không hợp lệ.

Ví dụ 3 cạnh $1, 2, 3$ là không hợp lệ vì $1 + 2$ không lớn hơn $3$: hai cạnh $1$ và $2$ nối lại chỉ vừa đúng bằng cạnh $3$ nên "nằm bẹp" xuống luôn, không dựng lên thành tam giác được:

4 3 5
$3, 4, 5$: $3 + 4 \gt 5$, $3 + 5 \gt 4$, $4 + 5 \gt 3$ ⇒ tam giác ✔
1 2 3
$1, 2, 3$: $1 + 2 = 3$ không lớn hơn $3$ ⇒ không phải tam giác ✘

Với ví dụ của đề, dãy $4, 3, 1, 5, 7$ có $5$ phần tử nên có tất cả $\binom{5}{3} = 10$ bộ ba vị trí khác nhau. Mình liệt kê hết ra để các bạn thấy vì sao đáp án là $3$:

Bộ baKiểm tra (2 cạnh nhỏ cộng lại so với cạnh lớn nhất)Kết quả
(4, 3, 1)1 + 3 = 4, không > 4✘
(4, 3, 5)3 + 4 = 7 > 5✔
(4, 3, 7)3 + 4 = 7, không > 7✘
(4, 1, 5)1 + 4 = 5, không > 5✘
(4, 1, 7)1 + 4 = 5, không > 7✘
(4, 5, 7)4 + 5 = 9 > 7✔
(3, 1, 5)1 + 3 = 4, không > 5✘
(3, 1, 7)1 + 3 = 4, không > 7✘
(3, 5, 7)3 + 5 = 8 > 7✔
(1, 5, 7)1 + 5 = 6, không > 7✘

Lưu ý là đề đếm theo vị trí đôi một khác nhau, nên nếu dãy có các giá trị trùng nhau (ví dụ $5, 5, 5, 5$) thì mỗi bộ ba vị trí vẫn được đếm riêng (dãy $5, 5, 5, 5$ có đáp án là $4$).

Link nộp bài

Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.

Nộp bài trên CLUE OJ

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Cách 1Vét cạn tất cả các bộ 3 – O(N³)

Mình luôn khuyên các bạn là với mọi bài tập, nếu các bạn chưa nghĩ ra cách làm tối ưu, hoặc nghĩ ra rồi nhưng cảm giác chưa chắc chắn về độ chính xác, thì trước tiên hãy giải quyết bài đó theo cách vét cạn đi. Vì cách làm vét cạn sẽ luôn đảm bảo cho ra kết quả đúng (tất nhiên là bạn phải cài đặt đúng, nhưng thường cách vét cạn rất dễ cài), nó chỉ bị cái chạy lâu thôi. Nhờ vậy:

  • Ta lợi dụng nó để kiểm tra tính chính xác cho cách làm tối ưu mà ta sẽ cố gắng làm ra sau đó (sinh test ngẫu nhiên nhỏ, chạy cả 2 code rồi so kết quả).
  • Khi làm vét cạn rồi thì các bạn sẽ nhìn ra những cái chưa tốt của nó, từ đó làm tiền đề để tối ưu lại thành thuật toán tốt hơn.
  • Nếu sau cùng vẫn không nghĩ ra cách tối ưu thì cách vét cạn sẽ giúp các bạn vét được bao nhiêu điểm thì vét (nếu thi chấm theo thể thức IOI, đúng test nào có điểm test đó; còn chấm theo thể thức ACM phải đúng hết mới AC thì thua rồi hihi).

Vậy cách vét cạn bài này là sao? Đó là ta xét tất cả các bộ 3 phần tử tại các vị trí phân biệt, với mỗi bộ ta kiểm tra xem nó có thỏa là tam giác không. Nếu thỏa thì tăng biến đếm lên, sau cùng biến đếm chính là kết quả.

Việc xét tất cả các bộ 3 rất đơn giản. Ví dụ có 5 phần tử ở các vị trí từ 1 tới 5 thì các bộ 3 vị trí phân biệt là: $\{1, 2, 3\}$, $\{1, 2, 4\}$, $\{1, 2, 5\}$, $\{1, 3, 4\}$, $\{1, 3, 5\}$, $\{1, 4, 5\}$, $\{2, 3, 4\}$, $\{2, 3, 5\}$, $\{2, 4, 5\}$, $\{3, 4, 5\}$. Để không bị đếm trùng, ta luôn chọn $i \lt j \lt k$, nên chỉ cần 3 vòng lặp lồng nhau: $j$ chạy từ $i + 1$, $k$ chạy từ $j + 1$ là giải quyết gọn.

Source code để các bạn tham khảo nếu làm theo hướng này:

#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    int a[n];
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    long long res = 0;
    for(int i = 0; i < n; ++i){
        for(int j = i + 1; j < n; ++j){
            for(int k = j + 1; k < n; ++k){
                if(a[i] + a[j] > a[k]
                   && a[i] + a[k] > a[j]
                   && a[j] + a[k] > a[i]){
                    res++;
                }
            }
        }
    }
    cout << res;
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = [int(x) for x in data[1:1 + n]]
    res = 0
    for i in range(n):
        for j in range(i + 1, n):
            for k in range(j + 1, n):
                if (a[i] + a[j] > a[k]
                        and a[i] + a[k] > a[j]
                        and a[j] + a[k] > a[i]):
                    res += 1
    print(res)

main()

Các bạn bấm Tiếp để xem 3 con trỏ $i, j, k$ đi qua từng bộ 3, bộ nào thỏa điều kiện tam giác sẽ được tô xanh:

C++▶ Chạy tay từng dòng codeCách 1: 3 vòng lặp i < j < k, kiểm tra đủ 3 điều kiện tam giác, thỏa thì res++.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int n;
8 cin >> n;
9 int a[n];
10 for(int i = 0; i < n; ++i){
11 cin >> a[i];
12 }
13 long long res = 0;
14 for(int i = 0; i < n; ++i){
15 for(int j = i + 1; j < n; ++j){
16 for(int k = j + 1; k < n; ++k){
17 if(a[i] + a[j] > a[k]
18 && a[i] + a[k] > a[j]
19 && a[j] + a[k] > a[i]){
20 res++;
21 }
22 }
23 }
24 }
25 cout << res;
26 return 0;
27}

Giải thích 1 số chỗ trong cách làm ở trên:

  1. 2 dòng đầu hàm main (ios::sync_with_stdio(0); cin.tie(0);) giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn code C++ (để hiểu vì sao thì cứ gõ mấy từ này lên Google là ra rất nhiều bài viết giải thích nhé). Bài này $n$ tối đa $5000$, tức là số phần tử cần nhập có thể lên tới $5000$, nên kinh nghiệm là cứ để 2 dòng này vào cho chạy nhanh hơn. Tất nhiên nếu code theo cú pháp C (scanf/printf) thì chẳng cần mấy dòng đó, nhưng thường khi code lập trình thi đấu ta dùng C++ vì cú pháp ngắn gọn và nhiều hỗ trợ, nên bạn nào code C++ thì nhớ thêm 2 dòng này vào đầu hàm main nhé.
  2. Biến đáp án res nên dùng kiểu long long (số nguyên 8 byte) để tránh bị tràn số. Với $n = 5000$ và mọi bộ 3 đều là tam giác thì đáp án là $\binom{5000}{3} = 20\,820\,835\,000$ – vượt xa giới hạn khoảng $2{,}1$ tỷ của kiểu int.
  3. Đúng ra vòng lặp for i chỉ cần chạy tới $i \lt n - 2$ và for j chỉ cần tới $j \lt n - 1$, vì bộ 3 cuối cùng là 3 phần tử cuối danh sách (giá trị $i$ cuối cùng là $n - 3$, $j$ cuối cùng là $n - 2$). Tuy nhiên quan điểm của mình là cứ để $i \lt n$, $j \lt n$ luôn, vì nó chỉ xét thêm 1, 2 lần giá trị $i, j$ rồi $k$ sẽ không thỏa ngay; như vậy tốt hơn là với tất cả các giá trị $i, j$ cứ phải làm thêm phép trừ khi so sánh điều kiện vòng lặp.

Đánh giá độ phức tạp của cách làm này:

  • Không gian: $O(N)$ cho mảng $a$.
  • Thời gian: bước đọc dữ liệu lặp $N$ lần. Bước xử lý xét tất cả các bộ 3: tính chính xác thì tổng số lần lặp của 3 vòng lặp lồng nhau (cũng là tổng số bộ 3 cần xét) là $\dfrac{N(N-1)(N-2)}{6}$. Về mặt tổng quát, mỗi vòng lặp $i, j, k$ trong trường hợp xấu nhất đều chạy cỡ $N$ lần, 3 vòng lồng nhau nên độ phức tạp là $O(N^3)$. Cộng với $O(N)$ của bước đọc dữ liệu thì theo quy tắc cộng Big O ta lấy thành phần lớn nhất, kết luận $O(N^3)$.
Không gian$O(N)$
Thời gian$O(N^3)$
Chắc chắn TLE với n = 5000
Với $N = 5000$, số lần lặp là $5000 + \dfrac{5000 \cdot 4999 \cdot 4998}{6} = 20\,820\,840\,000$ (20 tỷ 820 triệu 840 ngàn) lần. Trong khi ngưỡng cho 1 giây chỉ khoảng $(3 \div 5) \cdot 10^7$, cao lắm là $10^8$ phép tính – gấp hơn 200 lần rồi, chắc chắn bị TLE. Nhưng lý do vẫn nên cài đặt nó thì mình đã nói ở trên rồi nhé.

Cách 2Sắp xếp tăng dần + tìm kiếm nhị phân – O(N²·logN)

Ta cần tìm ra cách làm với độ phức tạp thời gian tốt hơn. Ở cách trên ta phải xét qua toàn bộ các bộ 3 là do dữ liệu của ta đang lộn xộn, chưa có quy tắc. Vậy nếu chuẩn hóa cho dữ liệu có trật tự thì có thể tốt hơn. Thử với ví dụ của đề: $4, 3, 1, 5, 7$. Nếu ta sắp xếp tăng dần thành $1, 3, 4, 5, 7$ thì liệu có xét nhanh hơn không? Các bạn cùng xem nhé.

Khi dãy đã tăng dần, ta dùng 2 vòng lặp for i, j lồng nhau để xét tất cả các cặp và tính tổng từng cặp. Nhưng tiếp theo không cần vòng lặp for k như cách trước nữa, mà ta tìm từ vị trí $j + 1$ trở đi đến cuối xem có bao nhiêu phần tử có giá trị nhỏ hơn tổng $a[i] + a[j]$. Có bao nhiêu phần tử như vậy thì bấy nhiêu phần tử đó sẽ kết hợp được với cặp $a[i], a[j]$ để tạo thành bộ số tam giác.

Thử với dãy đã sắp xếp $1, 3, 4, 5, 7$:

  • Cặp $\{1, 3\}$ tổng là $4$: sau phần tử $3$ chẳng có thằng nào nhỏ hơn $4$ ⇒ không có bộ nào.
  • Cặp $\{1, 4\}$ tổng là $5$: sau phần tử $4$ chẳng có thằng nào nhỏ hơn $5$ ⇒ không có bộ nào.
  • Cặp $\{1, 5\}$ tổng là $6$: sau phần tử $5$ chẳng có thằng nào nhỏ hơn $6$ ⇒ không có bộ nào.
  • Cặp $\{1, 7\}$ tổng là $8$: sau phần tử $7$ không còn phần tử nào để xét.
  • Cặp $\{3, 4\}$ tổng là $7$: sau phần tử $4$ có duy nhất $5$ nhỏ hơn $7$ ⇒ tìm được bộ $\{3, 4, 5\}$.
  • Cặp $\{3, 5\}$ tổng là $8$: sau phần tử $5$ có duy nhất $7$ nhỏ hơn $8$ ⇒ thêm bộ $\{3, 5, 7\}$, có 2 bộ.
  • Cặp $\{3, 7\}$ tổng là $10$: sau phần tử $7$ không còn phần tử nào.
  • Cặp $\{4, 5\}$ tổng là $9$: sau phần tử $5$ có duy nhất $7$ nhỏ hơn $9$ ⇒ thêm bộ $\{4, 5, 7\}$, có 3 bộ.
  • Các cặp $\{4, 7\}$, $\{5, 7\}$: phía sau không còn phần tử nào.

Vậy sau cùng ta tìm được $3$ bộ – đúng đáp án.

Vì sao chỉ cần kiểm tra 1 điều kiện?
Có thể các bạn thắc mắc: điều kiện tam giác phải xét cả 3 trường hợp, sao ở đây chỉ xét mỗi "phần tử phía sau nhỏ hơn tổng 2 phần tử trước"? Giả sử dãy đã tăng dần $a \le b \le c$. Khi tìm $c$ thỏa $a + b \gt c$ thì:
  • $a + c \gt b$ là đương nhiên: $c$ vốn đã $\ge b$, còn được cộng thêm $a \gt 0$ nữa thì càng lớn hơn $b$.
  • $b + c \gt a$ cũng đương nhiên: $c$ vốn đã $\ge a$, cộng thêm $b$ nữa thì càng lớn hơn $a$.
Nên chỉ cần kiểm tra duy nhất: tổng 2 cạnh nhỏ lớn hơn cạnh lớn nhất.

Và đến đây đừng nói với mình là các bạn còn nghĩ đến việc đi tìm tuần tự từ vị trí $j + 1$ trở đi, đếm xem có bao nhiêu thằng nhỏ hơn $a[i] + a[j]$, gặp thằng nào $\ge$ thì dừng (vì xét tiếp toàn những thằng lớn hơn nữa thôi) nhé. Nghĩ vậy thì cũng tối ưu thêm được chút xíu so với cách 1 vì vòng for k có thể kết thúc sớm, nhưng không tối ưu thêm bao nhiêu cả – xét ra vẫn là tìm kiếm tuần tự.

Tối ưu hơn là ta tìm kiếm nhị phân, vì danh sách đã có thứ tự rồi các bạn nha. Cụ thể sau khi tính tổng $a[i] + a[j]$, ta tìm từ vị trí $j + 1$ trở đi xem vị trí đầu tiên có giá trị $\ge a[i] + a[j]$, gọi là $k$. Tại đó là sự kết thúc, nên các phần tử nằm giữa $j$ và $k$ (từ $j + 1$ đến $k - 1$) chính là các phần tử nhỏ hơn tổng $a[i] + a[j]$, và số lượng tính ngay được bằng cách lấy 2 vị trí trừ nhau:

Công thức đếm
$k = $ vị trí đầu tiên trong đoạn $[j + 1, n)$ có $a[k] \ge a[i] + a[j]$  ⇒  số bộ thêm được $= k - (j + 1) = k - j - 1$.
Ví dụ $j = 2$, $k = 5$ thì các vị trí $3, 4$ thỏa, có $5 - 2 - 1 = 2$ phần tử. Nếu không tìm được thằng nào (ngay $a[j + 1]$ đã $\ge$ tổng) thì $k = j + 1$, trừ ra $0$. Còn nếu mọi phần tử phía sau đều nhỏ hơn tổng thì $k = n$.

Đến đây sẽ rất gọn nếu bạn nào biết hàm lower_bound trong thư viện giải thuật của C++: hàm trả về vị trí đầu tiên có phần tử $\ge x$ (với $x$ là giá trị cần tìm), nếu không có thì trả về vị trí cuối đoạn tìm. Bạn nào chưa biết cách dùng hàm này thì nên Google tìm hiểu thêm nhé. Bên Python thì tương ứng là hàm bisect_left trong thư viện bisect.

Minh hoạ: với mỗi cặp (i, j), tìm k bằng lower_bound

Các bạn nhập dãy rồi bấm Tiếp. Hai ô vàng là cặp $a[i], a[j]$; các ô xanh là những phần tử phía sau nhỏ hơn tổng (mỗi ô xanh là 1 bộ số tam giác); ô đỏ là vị trí $k$ mà lower_bound trả về:

Ví dụ nhanh:

Source code để các bạn tham khảo nếu làm theo hướng này:

#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    int a[n];
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    sort(a, a + n);
    long long res = 0;
    for(int i = 0; i < n; ++i){
        for(int j = i + 1; j < n; ++j){
            int k = lower_bound(a + j + 1, a + n, a[i] + a[j]) - a;
            res += k - j - 1;
        }
    }
    cout << res;
    return 0;
}
import sys
from bisect import bisect_left

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = sorted(int(x) for x in data[1:1 + n])
    res = 0
    for i in range(n):
        for j in range(i + 1, n):
            # vị trí đầu tiên trong đoạn [j + 1, n) có giá trị >= a[i] + a[j]
            k = bisect_left(a, a[i] + a[j], j + 1, n)
            res += k - j - 1
    print(res)

main()
C++▶ Chạy tay từng dòng codeCách 2: sắp xếp tăng dần; với mỗi cặp (i, j), lower_bound tìm k đầu tiên có a[k] ≥ a[i] + a[j], cộng k - j - 1.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int n;
8 cin >> n;
9 int a[n];
10 for(int i = 0; i < n; ++i){
11 cin >> a[i];
12 }
13 sort(a, a + n);
14 long long res = 0;
15 for(int i = 0; i < n; ++i){
16 for(int j = i + 1; j < n; ++j){
17 int k = lower_bound(a + j + 1, a + n, a[i] + a[j]) - a;
18 res += k - j - 1;
19 }
20 }
21 cout << res;
22 return 0;
23}

Giải thích 1 số chỗ trong cách làm ở trên:

  1. sort(a, a + n); là sắp xếp tăng dần mảng $a$ từ vị trí đầu tiên (tham số đầu a chính là địa chỉ ô $a[0]$, vì tên mảng luôn "trỏ" vào ô đầu tiên) và sắp $n$ phần tử (tham số thứ 2 là a + n). Đây là hàm trong thư viện giải thuật algorithm. Do mình include <bits/stdc++.h> là đã bao hết các thư viện khác rồi, còn bạn nào không dùng thư viện này thì nhớ khai báo #include <algorithm> mới dùng được hàm sort nha.
  2. lower_bound(a + j + 1, a + n, a[i] + a[j]) nghĩa là bắt đầu tìm từ vị trí $j + 1$ đến hết mảng (phần tử thứ $n$), giá trị cần tìm là $a[i] + a[j]$. Hàm trả về địa chỉ của phần tử tìm thấy; muốn lấy ra chỉ số (index) thì trừ cho địa chỉ đầu mảng a – ra khoảng cách từ đầu mảng tới đó, chính là index. Hàm này cũng nằm trong thư viện algorithm.
  3. Tổng $a[i] + a[j]$ lớn nhất là $2 \cdot 10^9$, vẫn nằm vừa trong kiểu int (tối đa $2\,147\,483\,647$) nên không bị tràn – nhưng chỉ dư có chút xíu thôi, đề mà cho $a_i$ tới $1{,}1 \cdot 10^9$ là phải chuyển sang long long rồi nha.

Đánh giá độ phức tạp của cách làm này:

  • Không gian: $O(N)$.
  • Thời gian: bước đọc dữ liệu là $O(N)$. Bước sắp xếp dùng hàm sort có sẵn là $O(N \log N)$. Rồi 2 vòng lặp lồng nhau xét hết các cặp, tính chính xác là $\dfrac{N(N-1)}{2}$ lần, nhưng tổng quát thì vòng $j$ trường hợp lớn nhất cũng chạy cỡ $N$ lần nên xem là $O(N^2)$. Bên trong là lower_bound tìm kiếm nhị phân $O(\log N)$ – phạm vi tìm thật ra nhỏ dần, nhưng ta cứ tính gọn là $N$. Vậy bước này là $O(N^2 \log N)$. Tổng: $O(N) + O(N \log N) + O(N^2 \log N)$, lấy thành phần lớn nhất ⇒ $O(N^2 \log N)$.
Không gian$O(N)$
Thời gian$O(N^2 \log N)$
Sát nút giới hạn thời gian
Với $N = 5000$, $\log_2 N$ làm tròn lên là $13$: $5000 + 5000 \cdot 13 + 12\,497\,500 \cdot 13 = 162\,537\,500$ (162 triệu 537 ngàn 500) lần lặp – vượt ngưỡng $(3 \div 5) \cdot 10^7$, cao lắm là $10^8$, gần gấp 2 lần. Thật ra con số thực tế ít hơn vì phạm vi tìm kiếm nhỏ dần, và $\log(5000)$ mình tính $13$ là dư rồi. Nhưng khi mình nộp code trên thì vẫn bị TLE ở test 10 (test cuối cùng, cũng là test lớn nhất với $N = 5000$): chạy hết 1093 ms, vượt 1000 ms. Nghĩa là $O(N^2 \log N)$ chưa phải là thuật toán tối ưu nhất.
Mẹo cứu cánh khi chỉ lố thời gian một xíu
Mình chỉ cho các bạn một mẹo, hữu ích trong tình huống code chỉ bị chạy quá thời gian một xíu thôi chứ không phải quá mức như trường hợp này. Thêm vào đầu code ở trên dòng:
#pragma GCC optimize("Ofast")
Nộp lại thì lần này nó Accepted, test 10 chạy 968 ms (vẫn ≤ 1000 ms) – trước đó là 1093 ms. Các bạn có thể Google tìm hiểu xem từ khóa đó làm sao mà giúp code chạy nhanh hơn được chút xíu nha ^^ (cứ search từ khóa đó kèm chữ "GeeksforGeeks" là ra bài giải thích chi tiết; đọc tiếng Anh không được thì chuột phải chọn dịch sang tiếng Việt). Ngoài Ofast còn O1, O2, O3… các bạn tìm hiểu hết đi nhé hehe.

Cách 3Sắp xếp giảm dần + hai con trỏ – O(N²)

Từ đầu tới giờ ta đã đi từ $O(N^3)$ lên $O(N^2 \log N)$ nhưng vẫn chưa thực sự hiệu quả: nếu không nhờ #pragma GCC optimize thì đã bị TLE. Code mà phải nhờ vào gcc optimize mới AC được thì đó không phải code tối ưu, và cũng hên xui may rủi lắm các bạn ơi. Nên ta không nên chấp nhận như vậy mà phải cố tìm ra cách tốt hơn nữa.

Mình nói luôn: bài này giải được với độ phức tạp $O(N^2)$, và đây là độ phức tạp tối ưu nhất cho bài này – ít nhất theo quan điểm cá nhân mình thì không có cách nào tốt hơn $O(N^2)$ được. Để làm được thì ta dùng kỹ thuật Two Pointers (hai con trỏ). Yên tâm là không phải kiểu con trỏ, bộ nhớ trong C/C++ đâu nha, đó chỉ là tên gọi thôi: đại ý là 2 biến lưu vị trí (index) cùng di chuyển phối hợp với nhau.

Nhận xét khi sắp giảm dần
Với bộ 3 $\{a, b, c\}$ sắp giảm dần $a \ge b \ge c$, nó chắc chắn là tam giác khi $b + c \gt a$. Hai điều kiện còn lại là đương nhiên: $a \ge c$ rồi cộng thêm $b$ càng $\gt c$; $a \ge b$ rồi cộng thêm $c$ càng $\gt b$. Nên chỉ cần 2 thằng nhỏ hơn cộng lại lớn hơn thằng lớn nhất là được.

Từ đây ta thấy, ví dụ có 10 số đã sắp giảm dần: $10, 9, 8, 7, 6, 5, 4, 3, 2, 1$ (vị trí mình tạm tính từ 1 cho dễ hình dung). Khi xét $a[1] = 10$ làm cạnh lớn nhất, chỉ cần đếm trong 9 số còn lại có bao nhiêu cặp có tổng lớn hơn $10$ – mỗi cặp như vậy ghép với $10$ thành 1 bộ số tam giác.

Đếm các cặp này ta không cần xét hết mọi cặp với chi phí $O(N^2)$. Đặt left = i + 1 = 2, right = n = 10:

  • $a[2] + a[10] = 9 + 1 = 10$ không lớn hơn $10$. Danh sách đang giảm dần, muốn tổng lớn hơn thì phải giảm right (tiến lên phía trước là các phần tử lớn hơn): right = 9.
  • $a[2] + a[9] = 9 + 2 = 11 \gt 10$: tìm được cặp đầu tiên. Nếu giữ nguyên left = 2 thì mọi vị trí right từ $9$ lùi về $3$ đều cho tổng $\gt 10$, vì right càng giảm thì phần tử càng lớn, tổng càng tăng. Vậy tính ngay được right − left = 9 − 2 = 7 cặp ghép với $10$ thành tam giác.
  • Tăng left lên $3$. $a[3] + a[9] = 8 + 2 = 10$ không thỏa ⇒ giảm right = 8. $a[3] + a[8] = 8 + 3 = 11 \gt 10$ ⇒ thêm 8 − 3 = 5 cặp, rồi lại tăng left…
  • Cứ thế lặp lại khi nào left < right. Khi left >= right thì dừng: đã đếm xong số cặp ghép được với $a[i]$. Sau đó i++ sang phần tử tiếp theo, khởi tạo lại left = i + 1, right = n và làm lại quy trình trên.
Quy tắc di chuyển 2 con trỏ (với mỗi i)
l = i + 1, r = n - 1; trong khi l < r:
  • Nếu $a[l] + a[r] \le a[i]$: tổng chưa đủ lớn ⇒ r-- (lấy phần tử lớn hơn).
  • Ngược lại: $a[l]$ ghép được với mọi $a[l+1], \ldots, a[r]$ ⇒ res += r - l, rồi l++.
Mỗi bước, $l$ tăng hoặc $r$ giảm, nên với mỗi $i$ vòng while chạy không quá $n$ lần.

Minh hoạ hai con trỏ

Các bạn nhập dãy rồi bấm Tiếp. Ô vàng là $a[i]$ (cạnh lớn nhất), hai con trỏ $l, r$ được đánh dấu bên dưới. Khi $a[l] + a[r] \gt a[i]$, ô tím là $a[l]$ và các ô xanh là những bạn ghép được với $a[l]$:

Ví dụ nhanh:

Source code để các bạn tham khảo nếu làm theo hướng này:

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    int a[n];
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    sort(a, a + n, greater<int>());
    long long res = 0;
    for(int i = 0; i < n; ++i){
        int l = i + 1;
        int r = n - 1;
        while(l < r){
            if(a[l] + a[r] <= a[i]){
                r--;
            }
            else{
                res += r - l;
                l++;
            }
        }
    }
    cout << res;
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = sorted((int(x) for x in data[1:1 + n]), reverse=True)
    res = 0
    for i in range(n):
        l = i + 1
        r = n - 1
        ai = a[i]
        while l < r:
            if a[l] + a[r] <= ai:
                r -= 1
            else:
                res += r - l
                l += 1
    print(res)

main()
C++▶ Chạy tay từng dòng codeCách 3: sắp giảm dần; với mỗi i, hai con trỏ l = i + 1, r = n - 1: tổng ≤ a[i] thì r--, ngược lại res += r - l và l++.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main() {
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int n;
8 cin >> n;
9 int a[n];
10 for(int i = 0; i < n; ++i){
11 cin >> a[i];
12 }
13 sort(a, a + n, greater<int>());
14 long long res = 0;
15 for(int i = 0; i < n; ++i){
16 int l = i + 1;
17 int r = n - 1;
18 while(l < r){
19 if(a[l] + a[r] <= a[i]){
20 r--;
21 }
22 else{
23 res += r - l;
24 l++;
25 }
26 }
27 }
28 cout << res;
29 return 0;
30}

Giải thích 1 số chỗ trong cách làm ở trên:

  1. Ở ví dụ phía trên mình tính vị trí từ 1 cho các bạn dễ hình dung, nhưng trong lập trình index tính từ 0, nên right ban đầu là vị trí cuối cùng $n - 1$. Còn lại mọi thứ không có gì thay đổi, đúng như những gì mình đã phân tích.
  2. Hàm sort mình giải thích ở cách 2 rồi, ở đây có thêm tham số thứ 3 là greater<int>() để quy định sắp xếp giảm dần; không có nó thì mặc định là tăng dần nha các bạn. Bên Python thì dùng sorted(..., reverse=True).

Đánh giá độ phức tạp của cách làm này:

  • Không gian: $O(N)$.
  • Thời gian: đọc dữ liệu $O(N)$, sắp xếp giảm dần $O(N \log N)$. Bước 2 vòng lặp lồng nhau tính ra chỉ lặp khoảng $\dfrac{N(N-1)}{2}$ lần: vòng while với $i$ đầu tiên chạy gần $N$ lần, các lần sau là $N - 1, N - 2, \ldots, 2, 1$ (giống kiểu 2 vòng lặp lồng nhau của thuật toán sắp xếp cơ bản mà các bạn học hồi nhập môn đó). Tính theo trường hợp lớn nhất thì bước này là $O(N^2)$. Tổng: $O(N) + O(N \log N) + O(N^2)$ ⇒ $O(N^2)$.
Không gian$O(N)$
Thời gian$O(N^2)$

Với $N = 5000$, số lần lặp khoảng $5000 + 5000 \cdot 13 + \dfrac{5000 \cdot 4999}{2} = 12\,567\,500$ (12 triệu 567 ngàn 500) lần – ít hơn ngưỡng 1 giây từ 3 đến 10 lần, không sợ TLE. Mình nộp code trên chạy hết 109 ms; thêm gcc optimize như mẹo ở cách 2 thì còn 78 ms.

Về hàm sort
Ở cách 2 và cách 3 mình dùng hàm sort có sẵn của C++, độ phức tạp luôn được đảm bảo $O(N \log N)$ vì nó là sự kết hợp nhiều thuật toán sắp xếp tối ưu bên trong (bạn nào chưa biết có thể Google tìm hiểu thêm). Nếu không thì phải tự cài Quick Sort, Merge Sort, Heap Sort… – vừa mất công, lỡ cài sai, hoặc xui gặp trường hợp xấu nhất của Quick Sort thì cũng thành $O(N^2)$. Còn nếu cài các thuật toán cơ bản như Interchange Sort, Selection Sort, Bubble Sort ($O(N^2)$) thì về lý thuyết độ phức tạp cách 2 và cách 3 vẫn không đổi (vì bước xử lý chính đã $\ge N^2$ rồi), nhưng thực tế dùng hàm sort sẽ chạy nhanh hơn, điều này là không thể bàn cãi.

Đến đây các bạn đã thấy cái hay của giải thuật rồi chứ? Từ cách làm chạy hết $20\,820\,840\,000$ lần lặp rút xuống còn $162\,537\,500$ lần, cuối cùng chỉ còn $12\,567\,500$ lần. Cách 2 nhanh hơn cách 1 khoảng 128 lần, cách 3 nhanh hơn cách 2 khoảng 13 lần. Quá tuyệt vời đúng không các bạn ^^

Bạn nào code Python để ý
Bản Python của cách 3 mình đo với $n = 5000$ mất khoảng 1,3 – 1,8 giây (cách 2 khoảng 4 giây), tức là vẫn có thể bị TLE với giới hạn 1 giây. Bài này các bạn nên nộp C++ nha (hoặc chọn PyPy nếu hệ thống chấm có hỗ trợ).
Ghi chú của mình: code C++ và Python của cả 3 cách đều cho đúng output ví dụ của đề, và đã được so với một chương trình vét cạn độc lập trên 500 bộ test ngẫu nhiên ($n \le 40$, có cả test nhiều số trùng nhau và test giá trị tới $10^9$) – khớp hết. Với $n = 5000$ (giá trị ngẫu nhiên, hoặc toàn bộ bằng $10^9$ để đáp án lớn nhất $20\,820\,835\,000$): trên máy mình bản C++ cách 3 chạy khoảng 0,02 – 0,07 giây, cách 2 khoảng 0,13 – 0,3 giây, còn cách 1 mất gần 60 giây (với $n = 1000$ thì cách 1 chạy khoảng 0,4 giây).
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét