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 →

Các Hiệp Sĩ (GREKNIGHT)

Các Hiệp Sĩ (GREKNIGHT)
Nguồn: VinhDinhCoder Độ khó: Trung bình Tham lam Sắp xếp Cửa sổ trượt Mảng đánh dấu

Đề bài

GREKNIGHT – Các hiệp sĩ

Khác với Hiệp sĩ thành Tron, Hiệp sĩ Ba Lan tước đi sự quý phái và hạnh phúc khi giao đấu với nhau. Mỗi hiệp sĩ có một chỉ số sức mạnh và chiến thắng một hiệp sĩ khác khi và chỉ khi sức mạnh của anh ta lớn hơn sức mạnh của đối phương.

Tuy nhiên theo quy định của quốc vương thì mỗi hiệp sĩ được giao đấu với không quá $k$ hiệp sĩ khác. Ngoài ra, mỗi hiệp sĩ có một số lượng tiền. Sau khi chiến thắng đối thủ, một hiệp sĩ có thể giành được tiền thưởng là số tiền của đối thủ.

Bây giờ mỗi hiệp sĩ suy ngẫm: anh ta có thể có bao nhiêu tiền nếu giao đấu với các hiệp sĩ khác. Bạn nên trả lời câu hỏi này cho mỗi hiệp sĩ.

Dữ liệu nhập:

  • Dòng đầu tiên chứa hai số nguyên $n$ và $k$ $(1 \le n \le 10^5,\ 0 \le k \le \min(n - 1, 10))$ – số hiệp sĩ và số $k$.
  • Dòng thứ hai chứa $n$ số nguyên $p_1, p_2, \ldots, p_n$ $(1 \le p_i \le 10^9)$ – sức mạnh của các hiệp sĩ. Sức mạnh của $n$ hiệp sĩ khác nhau từng đôi một.
  • Dòng thứ ba chứa $n$ số nguyên $c_1, c_2, \ldots, c_n$ $(0 \le c_i \le 10^9)$ – số lượng tiền mỗi hiệp sĩ có.

Kết quả:

  • In $n$ số nguyên – số tiền tối đa mà mỗi hiệp sĩ có thể có nếu anh ta giao đấu với tối đa $k$ hiệp sĩ khác.

Giới hạn thời gian: 1.0 giây. Giới hạn bộ nhớ: 128 megabyte.

Ví dụ 1 – input
4 2
4 5 9 7
1 2 11 33
output
1 3 46 36
Ví dụ 2 – input
5 1
1 2 3 4 5
1 2 3 4 5
output
1 3 5 7 9
Ví dụ 3 – input
1 0
2
3
output
3

Lời mở đầu

Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇. (Bài này cũng chính là bài Knights of a Polygonal Table – Codeforces 994B, bạn nào quen Codeforces có thể tìm thêm.)

Ngoài ra một thử thách mình đặt thêm với em học viên: kể cả đã AC rồi thì hãy tối ưu code sao cho thời gian chạy trên web $\le 125$ ms (lúc mình viết bài, nhanh nhất trên bảng chấm là 125 ms, rồi có bạn còn AC với 109 ms nữa hehe 😆).

Nhắc lại: với giới hạn 1 giây, toàn bài chỉ nên lặp khoảng $(3 \to 5) \cdot 10^7$ lần là an toàn, tệ nhất $10^8$ (hên xui). Ngưỡng này là cho C/C++, Python chậm hơn nên ngưỡng còn thấp hơn.

Đọc kỹ đề: mỗi hiệp sĩ được đấu với tối đa $k$ hiệp sĩ khác (có thể ít hơn, nhưng $k + 1$ trở lên là không được). Hiệp sĩ mạnh hơn thì thắng và lấy hết tiền của đối thủ – giống như cá cược vậy đó. Câu hỏi được hỏi độc lập cho từng hiệp sĩ.

Giải thích ví dụ 1 ($n = 4$, $k = 2$):

  • Hiệp sĩ 1 (sức mạnh 4, tiền 1): không ai yếu hơn ⇒ không đấu được với ai ⇒ 1.
  • Hiệp sĩ 2 (sức mạnh 5, tiền 2): chỉ hiệp sĩ 1 yếu hơn, $1 \le k$ nên đấu luôn ⇒ $2 + 1 =$ 3.
  • Hiệp sĩ 3 (sức mạnh 9, tiền 11): có 3 người yếu hơn với số tiền $1, 2, 33$, nhưng chỉ được đấu 2 người ⇒ chọn 2 người giàu nhất ⇒ $11 + 33 + 2 =$ 46.
  • Hiệp sĩ 4 (sức mạnh 7, tiền 33): 2 người yếu hơn có tiền $1, 2$ ⇒ $33 + 1 + 2 =$ 36.
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 1Tham lam trực tiếp cho từng hiệp sĩ – O(N² log N)

Bài này dùng phương pháp tham lam. Số tiền của mọi người đều không âm, vậy cộng thêm càng nhiều thì càng giàu ⇒ mỗi hiệp sĩ nên cố đấu với càng nhiều người càng tốt. Nhưng khoan, chỉ "ăn" được tiền khi đấu với người yếu hơn thôi, chứ đấu với người mạnh hơn là mất sạch tiền luôn đấy haha.

Tư tưởng tham lam
Với hiệp sĩ đang xét, đếm những hiệp sĩ yếu hơn mình. Nếu số lượng đó $\le k$ thì lấy hết tiền của tất cả; nếu $\gt k$ thì chỉ lấy tổng tiền của $k$ người giàu nhất trong số đó. Như thế luôn đảm bảo số tiền là lớn nhất có thể.

Làm thẳng theo tư tưởng này: với mỗi hiệp sĩ $i$, duyệt hết $N$ hiệp sĩ để gom tiền của người yếu hơn, sắp xếp giảm dần rồi cộng $k$ số đầu. Mỗi hiệp sĩ tốn $O(N \log N)$, có $N$ hiệp sĩ ⇒ $O(N^2 \log N)$ – phức tạp quá với $N = 10^5$. Mình để code ở đây để các bạn hiểu ý tưởng và dùng làm code kiểm tra (vét cạn) thôi nhé.

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(){
    int n, k;
    cin >> n >> k;
    vector<long long> p(n), c(n);
    for(int i = 0; i < n; ++i){
        cin >> p[i];
    }
    for(int i = 0; i < n; ++i){
        cin >> c[i];
    }
    for(int i = 0; i < n; ++i){
        vector<long long> t;
        for(int j = 0; j < n; ++j){
            if(p[j] < p[i]){
                t.push_back(c[j]);
            }
        }
        sort(t.begin(), t.end(), greater<long long>());
        long long res = c[i];
        for(int j = 0; j < k && j < (int)t.size(); ++j){
            res += t[j];
        }
        cout << res << " ";
    }
    return 0;
}
import sys

data = sys.stdin.read().split()
n, k = int(data[0]), int(data[1])
p = list(map(int, data[2:2 + n]))
c = list(map(int, data[2 + n:2 + 2 * n]))
out = []
for i in range(n):
    t = [c[j] for j in range(n) if p[j] < p[i]]
    t.sort(reverse=True)
    out.append(c[i] + sum(t[:k]))
print(*out)
Không gian$O(N)$
Thời gian$O(N^2 \log N)$
Chắc chắn TLE
Với $N = 10^5$ thì riêng việc mỗi hiệp sĩ duyệt lại $N$ người đã là $10^{10}$ lần lặp, vượt xa ngưỡng 1 giây.

Cách 2Sắp xếp + cửa sổ K số tiền lớn nhất – O(N log N)

Bước 1: Sắp xếp theo sức mạnh

Ta thấy chắc chắn phải cần đến sắp xếp để bài toán đơn giản hơn: sắp các hiệp sĩ tăng dần theo sức mạnh. Khi đó hiệp sĩ ở vị trí $i$ chỉ có thể thắng đúng $i$ hiệp sĩ đứng trước nó. Ví dụ 5 hiệp sĩ sau khi sắp có index $0..4$, hiệp sĩ ở index 3 thắng được các hiệp sĩ ở index $0, 1, 2$.

Sắp xếp phải kéo theo số tiền
Sắp theo sức mạnh nhưng khi hoán vị thì số tiền phải đi theo. Vì vậy ta dùng mảng struct, mỗi phần tử gồm sức mạnh, số tiền (và ID – xem bước 3). Nếu tách thành 2 mảng riêng mà chỉ sắp mảng sức mạnh thì tiền người này sẽ lộn qua người kia, sai bét.

Bước 2: Cửa sổ chứa K số tiền lớn nhất (Sliding Window)

Duyệt từ index 0 (yếu nhất) trở đi. Ta cần biết tổng $k$ số tiền lớn nhất trong các hiệp sĩ đứng trước. Một cách tối ưu là dùng kỹ thuật cửa sổ trượt:

  • Mảng $c$ chứa tối đa $k$ số tiền lớn nhất của các hiệp sĩ đứng trước $i$, biến sum là tổng các số trong $c$ (ban đầu 0).
  • Xét hiệp sĩ $i$: lưu số tiền ban đầu vào biến tạm temp, rồi đáp án của hiệp sĩ này là temp + sum. Phải dùng temp vì sau đó ta đưa số tiền gốc vào $c$, chứ đưa số tiền đã cộng sum vào là sai.
  • Nếu $c$ còn chưa đủ $k$ phần tử: thêm temp vào $c$, cộng vào sum. Khi vừa đủ $k$ phần tử thì sắp xếp $c$ giảm dần (chỉ một lần duy nhất).
  • Nếu $c$ đã đủ $k$: nếu temp $\le$ phần tử nhỏ nhất $c[k-1]$ thì bỏ qua; ngược lại loại $c[k-1]$ ra và chèn temp vào, cập nhật sum += temp - c[k-1].
Vì sao sắp giảm dần và dùng sắp xếp chèn?
Sắp giảm dần thì phần tử nhỏ nhất nằm cuối, loại nó chỉ tốn $O(1)$ (nếu nằm đầu thì xóa tốn $O(K)$). Và vì $c$ đã có thứ tự sẵn, chèn temp vào đúng chỗ bằng sắp xếp chèn (Insertion Sort) chỉ tốn $O(K)$, thay vì gọi lại sort tốn $O(K \log K)$ cho mỗi lần. Còn sum chỉ cập nhật phần chênh lệch, không phải cộng lại cả $K$ phần tử.

Cụ thể bước chèn: chắc chắn temp $\gt c[k-1]$ rồi nên xét $j$ từ $k-2$ ngược lên. Gặp $c[j] \lt$ temp thì đẩy $c[j]$ lùi xuống $c[j+1]$; gặp $c[j] \ge$ temp thì đặt temp vào $c[j+1]$ rồi break. Nếu temp lớn hơn tất cả thì vòng lặp chạy hết, $j = -1$, ta đặt temp vào $c[0]$.

Bước 3: In đúng thứ tự ban đầu bằng mảng đánh dấu

Sắp xếp xong thì thứ tự hiệp sĩ đã bị xáo trộn, mà đề yêu cầu in theo thứ tự input. Có bạn sẽ lưu mảng sức mạnh ban đầu rồi tìm kiếm nhị phân vị trí từng hiệp sĩ – được, nhưng tốn $O(N \log N)$.

Tối ưu hơn là thêm trường ID = index trong input vào struct (ID đi theo khi sắp xếp), và mảng res với res[ID] là đáp án của hiệp sĩ có ID đó. Tính xong hiệp sĩ $a[i]$ thì gán res[a[i].ID] = a[i].Money. Cuối cùng in res[0..n-1] là đúng thứ tự, chỉ $O(N)$.

Minh hoạ cửa sổ K số tiền lớn nhất

Em nhập dữ liệu rồi bấm Tiếp để xem các hiệp sĩ (đã sắp theo sức mạnh) lần lượt được xét và cửa sổ $c$ thay đổi ra sao:

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;

struct Data{
    long long Money;
    int Power, ID;
};

bool cmp(const Data &a, const Data &b){
    return a.Power < b.Power;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    int n, k;
    cin >> n >> k;
    Data a[n];
    for(int i = 0; i < n; ++i){
        cin >> a[i].Power;
        a[i].ID = i;
    }
    for(int i = 0; i < n; ++i){
        cin >> a[i].Money;
    }
    sort(a, a + n, cmp);
    int c[k + 1], idx = 0;
    long long sum = 0, res[n];
    for(int i = 0; i < n; ++i){
        int temp = a[i].Money;
        a[i].Money += sum;
        res[a[i].ID] = a[i].Money;
        if(idx < k){
            c[idx++] = temp;
            sum += temp;
            if(idx == k){
                sort(c, c + k, greater<int>());
            }
        }
        else if(k > 0){
            if(temp > c[k - 1]){
                sum += temp - c[k - 1];
                int j;
                for(j = k - 2; j >= 0; --j){
                    if(temp > c[j]){
                        c[j + 1] = c[j];
                    }
                    else{
                        c[j + 1] = temp;
                        break;
                    }
                }
                if(j == -1){
                    c[0] = temp;
                }
            }
        }
    }
    for(int i = 0; i < n; ++i){
        cout << res[i] << " ";
    }
    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n, k = int(data[0]), int(data[1])
power = data[2:2 + n]
money = data[2 + n:2 + 2 * n]
a = sorted(range(n), key=lambda i: int(power[i]))  # ID các hiệp sĩ, tăng dần theo sức mạnh
c = []          # tối đa k số tiền lớn nhất, giữ giảm dần
total = 0       # tổng các số trong c
res = [0] * n
for i in a:
    temp = int(money[i])
    res[i] = temp + total
    if len(c) < k:
        c.append(temp)
        total += temp
        if len(c) == k:
            c.sort(reverse=True)
    elif k > 0 and temp > c[k - 1]:
        total += temp - c[k - 1]
        j = k - 2
        while j >= 0 and temp > c[j]:
            c[j + 1] = c[j]
            j -= 1
        c[j + 1] = temp
print(*res)
Một chỉnh sửa nhỏ so với code gốc của mình
Code gốc mình viết int c[k] và nhánh else đọc thẳng c[k - 1]. Khi $k = 0$ (đề cho phép) thì đó là mảng 0 phần tử và c[-1] truy cập ra ngoài mảng. Code gốc vẫn AC trên web (khi biên dịch với -O2 thì may mắn ra đúng), nhưng chạy với trình kiểm tra lỗi bộ nhớ thì báo lỗi ngay. Nên ở đây mình sửa nhẹ: khai báo c[k + 1] và đổi thành else if(k > 0) cho chắc ăn nha các bạn.

Giải thích một số chỗ trong code:

  1. 3 dòng đầu hàm main: giúp nhập xuất C++ nhanh gần bằng C. Bài này phải nhập $2 \cdot 10^5$ số và xuất $10^5$ số; bỏ 3 dòng này nộp lại sẽ thấy chậm hơn gần 4 lần đó.
  2. bool cmp(const Data &a, const Data &b) chỉ định sắp tăng dần theo Power, truyền làm tham số thứ 3 của sort. Tham số nên để tham chiếu & để không phải copy cả struct (tốn thời gian), còn const để lỡ vô tình sửa nó trong hàm thì trình biên dịch báo lỗi – kỹ năng lập trình thôi ^^
  3. sort(c, c + k, greater<int>()) sắp mảng $c$ giảm dần; bỏ tham số thứ 3 thì là tăng dần. Mảng số nguyên thì tự biết so sánh nên không cần hàm cmp.
  4. Hàm sort của thư viện luôn đảm bảo $O(N \log N)$. Đừng tự code Selection/Interchange/Bubble Sort $O(N^2)$ với $N = 10^5$ là TLE ngay. Nhưng khi dữ liệu đã có thứ tự như mảng $c$ thì dùng Insertion Sort mới tối ưu – hiểu nguyên lý các thuật toán sắp xếp mới thấy đáng giá chỗ này hehe.
  5. Vòng for j chính là bước chèn temp vào $c$ để $c$ vẫn giảm dần, như mình đã phân tích ở trên.
C++▶ Chạy tay từng dòng codeCách 2: sắp theo sức mạnh, giữ cửa sổ c gồm k số tiền lớn nhất (giảm dần), res[ID] = tiền + sum.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4struct Data{
5 long long Money;
6 int Power, ID;
7};
8
9bool cmp(const Data &a, const Data &b){
10 return a.Power < b.Power;
11}
12
13int main() {
14 ios::sync_with_stdio(false);
15 cin.tie(0);
16 cout.tie(0);
17 int n, k;
18 cin >> n >> k;
19 Data a[n];
20 for(int i = 0; i < n; ++i){
21 cin >> a[i].Power;
22 a[i].ID = i;
23 }
24 for(int i = 0; i < n; ++i){
25 cin >> a[i].Money;
26 }
27 sort(a, a + n, cmp);
28 int c[k + 1], idx = 0;
29 long long sum = 0, res[n];
30 for(int i = 0; i < n; ++i){
31 int temp = a[i].Money;
32 a[i].Money += sum;
33 res[a[i].ID] = a[i].Money;
34 if(idx < k){
35 c[idx++] = temp;
36 sum += temp;
37 if(idx == k){
38 sort(c, c + k, greater<int>());
39 }
40 }
41 else if(k > 0){
42 if(temp > c[k - 1]){
43 sum += temp - c[k - 1];
44 int j;
45 for(j = k - 2; j >= 0; --j){
46 if(temp > c[j]){
47 c[j + 1] = c[j];
48 }
49 else{
50 c[j + 1] = temp;
51 break;
52 }
53 }
54 if(j == -1){
55 c[0] = temp;
56 }
57 }
58 }
59 }
60 for(int i = 0; i < n; ++i){
61 cout << res[i] << " ";
62 }
63 return 0;
64}

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

  • Không gian: mảng struct $N$ phần tử là $O(S \cdot N)$ với $S$ là kích thước một struct, mảng res $O(N)$, mảng $c$ $O(K)$ ⇒ tổng quát $O(\max(S \cdot N, K))$, tức là $O(N)$.
  • Thời gian: đọc dữ liệu $O(2N)$; sắp xếp mảng $a$ $O(N \log N)$; vòng xử lý chính $O(N)$ lần lặp, trong đó sắp $c$ một lần $O(K \log K)$ và mỗi lần chèn $O(K)$ ⇒ tệ nhất $O(N \cdot K)$; in kết quả $O(N)$. Tổng: $O(2N) + O(N \log N) + O(K \log K) + O(N \cdot K) + O(N)$. Với $K \le 10$ thì thành phần lớn nhất là $O(N \log N)$ đến từ việc sắp xếp các hiệp sĩ.

Nhẩm với $N = 10^5$, $K = 10$: $2 \cdot 10^5 + 17 \cdot 10^5 + 40 + 10 \cdot 10^5 + 10^5 \approx 3 \cdot 10^6$, ít hơn ngưỡng 1 giây từ 10 đến 20 lần nên yên tâm không TLE.

Không gian$O(N)$
Thời gian$O(N \log N)$
Muốn chạy nhanh hơn nữa?
Nhiều bạn code cùng tư tưởng nhưng dùng vector, map thay cho mảng thường nên chậm hơn (map luôn tốn chi phí cân bằng cây, vector là mảng động). Về thuật toán thì các chỗ dễ làm chậm là: gọi sort lại mảng $c$ mỗi lần, cộng lại cả $c$ thay vì cập nhật sum, hay tìm kiếm nhị phân thay cho mảng đánh dấu ID. Code trên của mình chạy 140 ms trên web; muốn xuống 125 ms hay 109 ms thì có một "giải pháp tà đạo" là thêm pragma như #pragma GCC optimize("Ofast") lên đầu chương trình 😆. Nhưng thật ra chênh nhau vài chục ms gần như là như nhau, còn phụ thuộc server lúc nộp nữa, chỉ khi cách biệt lớn kiểu 650 ms với 140 ms mới đáng nói.
Ghi chú của mình: code C++ của cả 2 cách đã được kiểm tra với 500 bộ test ngẫu nhiên nhỏ ($n \le 9$, đủ các giá trị $k$ kể cả $k = 0$), bản Python của cả 2 cách với 200 bộ đầu, so với cách 1 vét cạn – khớp hết, và đúng cả 3 ví dụ của đề. Với $n = 10^5$, $k = 10$ trên máy mình: C++ cách 2 chạy khoảng 0.04 giây, Python cách 2 khoảng 0.45 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