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

Dấu trong đoạn (MULTI)

Dấu trong đoạn (MULTI)
Nguồn: NTUCoder (MULTI) Độ khó: Trung bình Mảng cộng dồn Truy vấn đoạn

Đề bài

MULTI – Dấu trong đoạn
Giới hạn thời gian: 1.0 giây · Giới hạn bộ nhớ: 128 megabyte

Misaki là một cô gái xinh đẹp, hiền lành, học giỏi. Lớp cô vừa được dạy về dấu của tích 2 số, cô rất hứng thú với bài học và tự hỏi nếu cho một dãy số nguyên thì dấu của tích các số trong đoạn $[L..R]$ bất kì là gì. Do dãy số có thể rất lớn nên Misaki đã nhờ bạn – một cao thủ NTUcoder giải giúp bài toán này.

Dữ liệu nhập:

  • Dòng thứ nhất là 2 số nguyên $N, M$ với $N$ là số lượng phần tử và $M$ là số truy vấn ($1 \le N, M \le 10^5$).
  • Dòng thứ hai chứa $N$ số nguyên, số nguyên $a_i$ là phần tử thứ $i$ trong dãy ($|a_i| \le 10^9$).
  • $M$ dòng tiếp theo chứa 2 số $L, R$ là đoạn cần trả lời ($1 \le L \le R \le N$).

Dữ liệu xuất:

Gồm $M$ dòng, mỗi dòng in ra câu trả lời cho mỗi truy vấn, cụ thể: in ra + nếu kết quả là dương, - nếu kết quả là âm, 0 nếu kết quả là 0. (Chú ý: các output phải lược bỏ dấu " ").

Ví dụ – input
4 3
0 1 2 -3
1 2
2 3
3 4
Ví dụ – output
0
+
-

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ạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:

Dành cho tất cả mọi ngườiNộp bài trên NTUCoder

Link này công khai, bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên NTUCoder

Bài viết đi từ cách vét cạn dễ nghĩ nhất, chỉ ra vì sao nó bị Wrong Answer rồi Time Limit Exceeded, rồi từng bước cải tiến đến cách dùng mảng cộng dồn chạy chỉ khoảng $2 \cdot 10^5$ lần lặp.

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: nhân hết các số trong đoạn

Thì một cách vét cạn rất dễ để nhìn ra cho bài này đó là với mỗi đoạn $L$ $R$ ta sẽ đi xét lần lượt từng giá trị $a[i]$ tương ứng với các vị trí trong đoạn $L$ $R$ đó. Rồi làm theo đúng quy luật của đề bài tính tích tất cả các số trong đoạn $L$ $R$ đó, sau cùng kiểm tra kết quả tích là số dương, số âm hay số 0 và in kết quả ra màn hình, cứ như thế lặp đủ $M$ truy vấn.

Source code nếu làm theo tư tưởng này:

Code cách 1 – nhân trực tiếp (C++ WA test 5, Python TLE test 10)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n, m;
    cin >> n >> m;
    int a[n + 1];
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
    }
    while(m--){
        int l, r;
        cin >> l >> r;
        long long s = 1;
        for(int i = l; i <= r; ++i){
            s *= a[i];
        }
        if(s > 0){
            cout << "+";
        }
        else if(s == 0){
            cout << "0";
        }
        else{
            cout << "-";
        }
        cout << endl;
    }
    return 0;
}
n, m = map(int, input().split())
a = [0] + list(map(int, input().split()))  # Chỉ số bắt đầu từ 1

for _ in range(m):
    l, r = map(int, input().split())
    s = 1
    for i in range(l, r + 1):
        s *= a[i]
    if s > 0:
        print("+")
    elif s == 0:
        print("0")
    else:
        print("-")
C++▶ Chạy tay từng dòng codeCách 1: nhân hết các số trong đoạn rồi xét dấu (s là long long, tràn số mô phỏng như C++).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 int n, m;
6 cin >> n >> m;
7 int a[n + 1];
8 for(int i = 1; i <= n; ++i){
9 cin >> a[i];
10 }
11 while(m--){
12 int l, r;
13 cin >> l >> r;
14 long long s = 1;
15 for(int i = l; i <= r; ++i){
16 s *= a[i];
17 }
18 if(s > 0){
19 cout << "+";
20 }
21 else if(s == 0){
22 cout << "0";
23 }
24 else{
25 cout << "-";
26 }
27 cout << endl;
28 }
29 return 0;
30}
Code C++ bị Wrong Answer ở test 5 – do tràn số

Kết quả tích các số có thể rất lớn nên ta đã chọn kiểu số nguyên 8 byte (long long) để lưu trữ cho đỡ bị tràn số. Tuy nhiên thì tránh cỡ nào cũng khó lòng tránh được bởi theo giới hạn đề bài thì $|a_i| \le 10^9$, nghĩa là $-10^9 \le a[i] \le 10^9$. Vậy $a[i]$ tối đa có thể là $10^9$. Hãy thử suy nghĩ: chỉ cần 2 thằng $a[i]$ cùng có giá trị $10^9$ đứng chung trong đoạn $L$ $R$ thì tụi nó nhân nhau đã thành $10^{18}$ là gần chạm giới hạn của kiểu long long (khoảng $9.2 \cdot 10^{18}$) rồi; có 3 thằng như vậy thì thành $10^{27}$ là tràn rồi. Chưa kể $1 \le L \le R \le N$ mà $N$ tối đa là $10^5$ – nếu có đến $10^5$ thằng $a[i]$ cùng bằng $10^9$ trong đoạn $L$ $R$ thì ôi thôi cỡ nào chứa cho nổi 😂.

Ví dụ cụ thể: dãy 1000000000 1000000000 1000000000, truy vấn 1 3. Tích thật là $10^{27} > 0$, đáp án phải là +, nhưng code C++ ở trên in ra - vì s đã bị tràn thành số âm. Nên cách làm này coi như phá sản nha các bạn.

Còn code Python thì bản chất Python xử lý được số lớn nên không bị Wrong Answer ở test 5, nhưng nhân những con số khổng lồ như vậy rất chậm, lại còn phải xét hết đoạn $L$ $R$ cho từng truy vấn nên bị Time Limit Exceeded ở test 10.

Cách 2Không cần nhân: đếm số 0 và số âm

Thì để giải quyết được vấn đề bị tràn số như ở trên thì nếu tinh ý một chút các bạn sẽ nhận ra không cần phải vất vả nhân liên tục các giá trị trong đoạn $L$ $R$ lại làm gì rồi sau cùng mới xét dấu của kết quả tích:

  • Nếu trong đoạn $L$ $R$ chỉ cần tồn tại ít nhất một số 0 thì chắc chắn tích các số trong đoạn đó sẽ là 0, dù cho các thằng còn lại có là giá trị nào đi nữa.
  • Còn nếu không tồn tại số 0 nào thì tích các số đó sẽ là âm hoặc dương. Theo nguyên tắc 2 số âm nhân nhau sẽ thành số dương, cứ 2 số âm nhân nhau thì dấu âm bị triệt tiêu. Trường hợp số lượng số âm là 0 (cũng được xem là số chẵn) thì tất cả đều dương.
Tóm lại
Có số 0 → in 0. Không có số 0: số lượng số âm là số chẵn → tích dương (+); số lượng số âm là số lẻ → tích âm (-).

Source code nếu làm theo tư tưởng này:

Code cách 2 – đếm số 0 và số âm (TLE)
#include <bits/stdc++.h>
using namespace std;

int main(){
    int n, m;
    cin >> n >> m;
    int a[n + 1];
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
    }
    while(m--){
        int l, r;
        cin >> l >> r;
        int cntZero = 0, cntNegativeNumber = 0;
        for(int i = l; i <= r; ++i){
            if(a[i] == 0){
                cntZero = 1;
                break;
            }
            else if(a[i] < 0){
                cntNegativeNumber++;
            }
        }
        if(cntZero == 1){
            cout << "0";
        }
        else if(cntNegativeNumber % 2 != 0){
            cout << "-";
        }
        else{
            cout << "+";
        }
        cout << endl;
    }
    return 0;
}
n, m = map(int, input().split())
a = [0] + list(map(int, input().split()))  # a[1..n]

for _ in range(m):
    l, r = map(int, input().split())
    cntZero = 0
    cntNegativeNumber = 0
    for i in range(l, r + 1):
        if a[i] == 0:
            cntZero = 1
            break
        elif a[i] < 0:
            cntNegativeNumber += 1
    if cntZero == 1:
        print("0")
    elif cntNegativeNumber % 2 != 0:
        print("-")
    else:
        print("+")
C++▶ Chạy tay từng dòng codeCách 2: gặp số 0 thì dừng, còn lại đếm số âm và xét chẵn lẻ.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 int n, m;
6 cin >> n >> m;
7 int a[n + 1];
8 for(int i = 1; i <= n; ++i){
9 cin >> a[i];
10 }
11 while(m--){
12 int l, r;
13 cin >> l >> r;
14 int cntZero = 0, cntNegativeNumber = 0;
15 for(int i = l; i <= r; ++i){
16 if(a[i] == 0){
17 cntZero = 1;
18 break;
19 }
20 else if(a[i] < 0){
21 cntNegativeNumber++;
22 }
23 }
24 if(cntZero == 1){
25 cout << "0";
26 }
27 else if(cntNegativeNumber % 2 != 0){
28 cout << "-";
29 }
30 else{
31 cout << "+";
32 }
33 cout << endl;
34 }
35 return 0;
36}

Giải thích một số chỗ trong code ở trên:

  1. cntZero là biến đếm số lượng các số 0, cntNegativeNumber là biến đếm số lượng các số âm.
  2. Trong quá trình xét từ $L$ đến $R$, chỉ cần gặp số 0 đầu tiên thì ta kết thúc luôn (break), không cần xét tiếp mấy thằng đằng sau nữa vì giờ đây tích chắc chắn là 0. Lúc này biến cntZero đóng vai trò như cờ hiệu: ban đầu là 0, khi gặp số 0 đầu tiên thì cập nhật thành 1 và kết thúc. Ra bên ngoài ta kiểm tra cờ hiệu cntZero nếu là 1 thì in ra 0. Còn nếu không thì xét tiếp cntNegativeNumber, nếu là số lẻ thì in ra -. Không phải 2 trường hợp trên thì cuối cùng là trường hợp dương, in ra +.
  3. Vì đoạn $L$ $R$ đang tính từ 1 nên ta cho mảng $a$ có $n + 1$ phần tử, coi như bỏ qua không dùng index 0, chỉ dùng từ index 1 trở đi cho khớp với vị trí tính từ 1 của $L$ $R$.

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

Không gian$O(N)$
Thời gian$O(N + M \cdot (R - L))$

Trường hợp xấu nhất là khi $N$ có đủ $10^5$ phần tử, có đủ $M = 10^5$ truy vấn, các truy vấn đều có $L = 1$, $R = 10^5$ và không có số 0 nào, như thế thì luôn phải xét đến tận cùng từ $L$ đến $R$. Thử tính số lần lặp cả thảy: $10^5 + 10^5 \cdot 10^5$ $= 10\,000\,100\,000$ (10 tỷ 100 ngàn) lần lặp. Trong khi ngưỡng giới hạn tiêu chuẩn 1 giây là $(3 \text{ đến } 5) \cdot 10^7$ tức là 30 triệu đến 50 triệu, hay cao lắm là $10^8$ – 10 tỷ đã gấp cả 100 – 200 lần rồi nên cách làm này chắc chắn sẽ bị TLE. Ta phải nghĩ ra cách làm khác tốt hơn.

Ý tưởngMảng cộng dồn (prefix sum)

Thì một kinh nghiệm là với các dạng bài mà có các truy vấn $L$ đến $R$ trong mảng thì phần lớn rơi vào bài toán prefix sum (mảng cộng dồn). Để hiểu rõ hơn nó là gì thì cứ trực tiếp qua bài này mình sẽ hướng dẫn cho các bạn nhìn ra.

Như cách làm ở trên thì mấu chốt là với mỗi truy vấn $L$ $R$ ta phải biết trong đoạn đó có số 0 không và có bao nhiêu số âm. Việc xét qua đủ $M$ truy vấn là chắc chắn không bỏ qua được (dữ liệu input cũng phải đọc đủ $M$ truy vấn mà). $M$ tối đa $10^5$ nên bước xử lý mỗi truy vấn chỉ được lặp tối đa khoảng $10^3$ lần trở lại thì mới không bị TLE. Vậy mỗi truy vấn mà xét hết từ $L$ đến $R$ thì chắc chắn là không ổn.

Cách xử lý tốt nhất như sau: đồng thời với dãy $a$ ta tạo 2 mảng cntZero và cntNegative có $N+1$ phần tử:

  • cntZero[i] lưu tổng số lượng các số 0 tính từ vị trí 1 đến vị trí $i$;
  • cntNegative[i] lưu tổng số lượng các số âm tính từ vị trí 1 đến vị trí $i$.

Khi cần biết trong đoạn $L$ đến $R$ có bao nhiêu số 0 hay bao nhiêu số âm thì các bạn cứ kẻ thử trục số sẽ thấy: tại vị trí $R$ đang lưu số lượng từ 1 đến $R$, tại vị trí $L - 1$ đang lưu số lượng từ 1 đến $L - 1$. Lấy số lượng tại $R$ trừ cho số lượng tại $L - 1$ thì phần còn lại chính là từ $L$ đến $R$:

i12345678a[i]3-25-1-460-7cntNegative[i]01123334cntNegative[R=6] = 3 (số âm từ 1 đến 6)cntNegative[L-1=2] = 1[L..R] = [3..6]: 3 - 1 = 2 số âm
$$\text{số lượng trong } [L..R] = cnt[R] - cnt[L-1]$$

Vì sao là $L - 1$ mà không phải $L$? Nếu lấy $cnt[R] - cnt[L]$ thì phần còn lại là từ $L+1$ đến $R$, mất luôn phần tử tại vị trí $L$, lại phải xét riêng nó. Trừ cho $cnt[L-1]$ thì khỏi mất công xét riêng vị trí $L$, cũng đồng nghĩa không cần tạo mảng $a$ lưu các phần tử nữa, chỉ cần 1 biến $x$ đọc dữ liệu từ input vào thôi.

Lưu ý cnt[0] = 0
Nếu truy vấn có $L = 1$ thì $cnt[L-1]$ tức là $cnt[0]$. Do ta chỉ đang tính index từ 1 nên phải nhớ gán $cnt[0] = 0$, để khi $L = 1$ thì $cnt[R] - cnt[0] = cnt[R]$. Nếu không gán thì $cnt[0]$ có thể mang giá trị rác làm sai lệch kết quả.

Em nhập dãy số và các truy vấn rồi bấm Tiếp để xem từng bước: dựng 2 mảng cộng dồn trước, sau đó mỗi truy vấn chỉ cần 2 phép trừ:

Ví dụ nhanh:

Cách 3Code mảng cộng dồn – O(N + M)

Source code nếu làm theo tư tưởng này:

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

int main(){
    int n, m;
    cin >> n >> m;
    int cntZero[n + 1], cntNegative[n + 1];
    cntZero[0] = cntNegative[0] = 0;
    int zero = 0, negative = 0;
    for(int i = 1; i <= n; ++i){
        int x;
        cin >> x;
        if(x == 0){
            zero++;
        }
        else if(x < 0){
            negative++;
        }
        cntZero[i] = zero;
        cntNegative[i] = negative;
    }
    while(m--){
        int l, r;
        cin >> l >> r;
        if(cntZero[r] - cntZero[l - 1] > 0){
            cout << 0 << endl;
            continue;
        }
        if((cntNegative[r] - cntNegative[l - 1]) % 2 != 0){
            cout << "-" << endl;
            continue;
        }
        cout << "+" << endl;
    }
    return 0;
}
n, m = map(int, input().split())
arr = list(map(int, input().split()))

cntZero = [0] * (n + 1)
cntNegative = [0] * (n + 1)

zero = 0
negative = 0

for i in range(1, n + 1):
    x = arr[i - 1]
    if x == 0:
        zero += 1
    elif x < 0:
        negative += 1
    cntZero[i] = zero
    cntNegative[i] = negative

for _ in range(m):
    l, r = map(int, input().split())
    if cntZero[r] - cntZero[l - 1] > 0:
        print("0")
    elif (cntNegative[r] - cntNegative[l - 1]) % 2 != 0:
        print("-")
    else:
        print("+")
C++▶ Chạy tay từng dòng codeCách 3: dựng 2 mảng cộng dồn, mỗi truy vấn chỉ cần 2 phép trừ – O(N + M).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 int n, m;
6 cin >> n >> m;
7 int cntZero[n + 1], cntNegative[n + 1];
8 cntZero[0] = cntNegative[0] = 0;
9 int zero = 0, negative = 0;
10 for(int i = 1; i <= n; ++i){
11 int x;
12 cin >> x;
13 if(x == 0){
14 zero++;
15 }
16 else if(x < 0){
17 negative++;
18 }
19 cntZero[i] = zero;
20 cntNegative[i] = negative;
21 }
22 while(m--){
23 int l, r;
24 cin >> l >> r;
25 if(cntZero[r] - cntZero[l - 1] > 0){
26 cout << 0 << endl;
27 continue;
28 }
29 if((cntNegative[r] - cntNegative[l - 1]) % 2 != 0){
30 cout << "-" << endl;
31 continue;
32 }
33 cout << "+" << endl;
34 }
35 return 0;
36}

Giải thích một số chỗ trong code ở trên:

  1. cntZero, cntNegative là mảng thống kê tổng số lượng tính từ 1 đến từng vị trí index tương ứng. Biến zero và negative là 2 biến đếm để cập nhật vào 2 mảng kia.
  2. Ta tận dụng ngay chính vòng lặp đọc dữ liệu: mỗi giá trị đọc vào thì xét nó là số 0 hay số âm, số lượng đếm được cộng dồn lên liên tục và cập nhật vào vị trí index $i$ tương ứng của từng mảng.
  3. if(cntZero[r] - cntZero[l - 1] > 0): số lượng số 0 trong đoạn $L$ đến $R$ có $> 0$ không? Nếu có thì ngay lập tức kết luận 0 và continue để qua truy vấn tiếp theo, không cần xét trường hợp + hay - nữa.
  4. if((cntNegative[r] - cntNegative[l - 1]) % 2 != 0): số lượng số âm trong đoạn có là số lẻ không? Nếu là số lẻ thì kết luận - và continue.
  5. Nếu 2 cái if đều không thỏa mà chương trình còn chạy được xuống tận cùng thì ta in ra + vì chỉ còn mỗi trường hợp này thôi.

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

Không gian$O(N)$
Thời gian$O(N + M)$

Độ phức tạp không gian thực ra là $O(2 \cdot N)$ nhưng theo quy tắc nhân BigO thì bỏ hằng số, vẫn kết luận là $O(N)$. Thời gian là $O(N)$ cho bước đọc dữ liệu kết hợp tạo 2 mảng cộng dồn, cộng $O(M)$ cho bước trả lời $M$ truy vấn; theo quy tắc cộng BigO lấy thành phần lớn nhất nên là $O(\max(N, M))$. Trường hợp xấu nhất mảng đủ $10^5$ phần tử và đủ $10^5$ truy vấn thì cả thảy chỉ lặp $2 \cdot 10^5$ (200 ngàn) lần thôi – nhỏ hơn ngưỡng 1 giây ít nhất cả 100 lần nên yên tâm không sợ bị TLE nhé.

Đến đây các bạn đã thấy cái hay của giải thuật chưa? Từ việc phải lặp xử lý hết cả 10 tỷ 100 ngàn lần, giờ chịu tốn thêm gấp 2 lần bộ nhớ nhưng bù lại số lần lặp rút xuống chỉ còn 200 ngàn lần. Thật tuyệt vời đúng không nào 😊

Kết quả kiểm tra: cả 3 code C++ và 3 code Python đã được chạy với 250 bộ test ngẫu nhiên nhỏ (có số 0, số âm, $|a_i|$ nhỏ để cách 1 không bị tràn), so với cách tính tích bằng số lớn – khớp hết. Với test lớn nhất ($N = M = 10^5$, mọi truy vấn là $[1, 10^5]$), code cách 3 bằng C++ chạy khoảng 0,17 giây (phần lớn là thời gian đọc/in vì dùng cin/endl), Python khoảng 0,45 giây. Cách 2 chỉ với 300 truy vấn $[1, 10^5]$ đã mất 0,04 giây, suy ra $10^5$ truy vấn cần cỡ 12 giây → TLE đúng như phân tích. Ví dụ tràn số ở cách 1 (3 số $10^9$) in sai - đúng như đã nêu.
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 😇

Khóa Số 3

Khóa Số 3
Nguồn: LMH '1819 Độ khó: Khó Tìm kiếm nhị phân Prefix sum

Đề bài

Đề thi học sinh giỏi tin học của trường mầm non SuperKids được đặt trong một két sắt và khoá lại bằng một khoá số. Khoá số này có cấu tạo rất đặc biệt: khoá gồm $n$ đĩa tròn đánh số từ 1 tới $n$. Mỗi đĩa chia thành $m$ hình quạt bằng nhau đánh số từ 1 tới $m$, trong đó có đúng một hình quạt bị đục rỗng gọi là khe của đĩa đó.

Ban đầu, các đĩa tròn này được xếp chồng lên nhau sao cho các hình quạt được đánh số giống nhau nằm chồng khít lên nhau. Muốn mở được khoá này cần xoay các đĩa sao cho khe của các đĩa phải chồng khít lên nhau.

Giáo sư X được giao nhiệm vụ phát đề và ông cần mở khoá để lấy đề phát cho các thí sinh. Biết rằng trong một giây, giáo sư X chỉ có thể quay một trong các đĩa theo một trong hai chiều: theo chiều kim đồng hồ hoặc ngược chiều kim đồng hồ dịch đi một hình quạt.

Yêu cầu: hãy tính xem giáo sư X phải mất tối thiểu bao nhiêu giây mới mở được khoá của chiếc két sắt.

Dữ liệu:

  • Dòng 1 chứa hai số nguyên dương $n, m$ $(n \le 10^5$; $2 \le m \le 10^9)$.
  • Dòng 2 chứa $n$ số nguyên dương, số thứ $i$ là số hiệu khe của chiếc đĩa thứ $i$.

Kết quả: ghi ra thời gian tối thiểu tính bằng giây cần để mở được khoá của chiếc két sắt.

Subtask: 50% số test đầu tiên có $n \le 10^4$; 50% số test còn lại không có giới hạn gì thêm.

Ví dụ – input
3 8
8 3 1
Ví dụ – output
3
Ba đĩa của ví dụ với khe ở các vị trí 8, 3, 1

Ba đĩa trong ví dụ, ô trắng là khe: 8, 3, 1. Xoay đĩa khe 8 thêm 1 nấc và đĩa khe 3 lùi 2 nấc để cùng về vị trí 1: tổng 3 giây.

Lời mở đầu

Bài này mình được một em học viên hỏi và mình đã hướng dẫn chi tiết cách thức giải quyết cho em ấy. Tin rằng sẽ có giá trị với nhiều bạn nên mình chia sẻ đề bài để các bạn thử sức làm, kèm toàn bộ tin nhắn của mình với em ấy để bạn nào không giải quyết được thì có thể tham khảo nhen. Hi vọng mang đến được giá trị cho các bạn 😇. Chúc các bạn học tốt và gặt hái nhiều thành công.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

Nộp bài trên CLUE OJ
Dành cho tất cả mọi ngườiNộp bài trên Codeforces

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên Codeforces

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

50%Cách vét cạn O(N²)

HV
“bài 1 ngoài vét O(n^2) thì còn cách nào ko ạ thầy”

Có phải $O(N^2)$ của em là: em chọn 1 số $a_i$ làm vị trí đích, sau đó xét các $a_j$ còn lại tính số bước xoay đến $a_i$. Từ $x$ mà xoay đến $y$ thì số bước xoay là $|x - y|$ hoặc $m - |x - y|$ (xoay chiều ngược lại), lấy cái nhỏ hơn, cộng hết lại rồi lấy min của tất cả các $a_i$?

HV
“Chuẩn thầy”
chi phí xoay x → y = min(|x − y|, m − |x − y|)

Với $n$ tới $10^5$ thì $O(N^2) = 10^{10}$, chắc chắn TLE ở 50% test sau. Bài này thì anh mới xem qua và có ý tưởng như thế này, anh cứ trình bày ra hết rồi em xem xét thật kỹ rồi thử làm nhé.

Ý tưởngSắp xếp + chia đôi vòng tròn

Đầu tiên sort dãy $a$ lại. Với số $a_i$ (vị trí đích), nếu thằng $a_j$ xoay đến $a_i$ mà số nấc xoay lớn hơn $m/2$ thì em biết số nấc phải là $m - |a_i - a_j|$. Hiển nhiên: ổ khoá có 10 nấc mà em xoay theo chiều thuận thấy hết 7 nấc rồi thì nên xoay chiều ngược thôi 😄. Như vậy xoay theo chiều thuận tối đa chỉ $m/2$ nấc.

Chia các đĩa bên trái a_i thành 2 nhóm
Tìm thằng $l$ xa $a_i$ nhất về bên trái mà vẫn thoả $a_i - a_l \le m/2$. Không cần trị tuyệt đối vì dãy đã sắp xếp, $a_l \le a_i$ chắc chắn. Việc tìm $l$ dùng tìm kiếm nhị phân.
• Các thằng từ $l$ đến $i$: xoay thẳng, chi phí $a_i - a_j$.
• Các thằng nhỏ hơn $l$ (gọi là $a_x$, $x$ từ 1 đến $l-1$): xoay vòng ngược, chi phí $m - (a_i - a_x) = m - a_i + a_x$.

Nhìn lại công thức: từ $l$ đến $i$ có tổng cộng $i - l + 1$ lần $a_i$ mang dấu dương (vì cộng $a_i - a_j$). Còn đám $a_x$ với $x$ từ 1 đến $l-1$ thì cộng $m - a_i + a_x$: có $l - 1$ lần $m$ xuất hiện và $l - 1$ lần $a_i$ mang dấu âm.

Đến đây em phải đảm bảo là hiểu những gì anh đã nói từ đầu đến giờ rồi thì mới xem tiếp nha 😄. Bây giờ chỉ còn vụ tổng các $a_j$ với $j$ từ $l$ đến $i$ và tổng các $a_x$ với $x$ từ 1 đến $l-1$: dùng mảng cộng dồn (prefix sum) là xong, mỗi tổng tính trong $O(1)$:

Σj=l..i (ai − aj) = (i − l + 1)·ai − (sumi − suml−1)
Σx=1..l−1 (m − ai + ax) = (l − 1)·(m − ai) + suml−1

Phần bên phải em làm tương tự: tìm $r$ xa nhất bên phải mà $a_r - a_i \le m/2$ (xoay thẳng $a_j - a_i$), những thằng sau $r$ thì xoay vòng $m + a_i - a_j$. Em nháp ra công thức thôi, anh để sẵn trong phần chú thích đầu code.

Vì sao chỉ cần thử đích là một trong các $a_i$? Tổng khoảng cách trên vòng tròn là hàm tuyến tính từng khúc giữa hai khe liên tiếp, nên giá trị nhỏ nhất luôn đạt tại một khe nào đó (giống bài "trung vị" quen thuộc trên đường thẳng). Mình cũng đã kiểm tra bằng máy: thử mọi vị trí đích từ 1 đến $m$ cho ra đúng cùng đáp án.

Minh hoạThử từng vị trí đích

Em nhập $m$ (tối đa 24) và khe của các đĩa (tối đa 10 đĩa), bấm Tiếp để thử lần lượt từng khe làm đích. Chấm xanh dương là đĩa xoay thẳng (nằm trong đoạn $[l, r]$), chấm cam là đĩa nên xoay vòng qua mốc $m \to 1$:

Ví dụ nhanh:

CodeSource code

Anh cũng vừa code và nộp thành công trên web, làm theo đúng tư tưởng ở trên không khác gì hết. Anh nói trước là code này anh code vội ra theo đúng tư tưởng đã chia sẻ với em chứ chưa có clean đâu nha:

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

/*
Tong (a[i] - a[j]) voi j: minLeft -> i = (i - minLeft + 1) * a[i] - sum[i] + sum[minLeft - 1]
Tong (m - a[i] + a[j]) voi j: 1 -> minLeft - 1 = (minLeft - 1) * (m - a[i]) + sum[minLeft - 1]
Tong (a[j] - a[i]) voi j: i -> maxRight = sum[maxRight] - sum[i - 1] - (maxRight - i + 1) * a[i]
Tong (m + a[i] - a[j]) voi moi j: maxRight + 1 -> n = (n - maxRight) * (m + a[i]) - sum[n] + sum[maxRight]
*/

long long a[100005];
long long sum[100005];
int n;
long long m;

int main () {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    sort(a + 1, a + n + 1);
    for (int i = 1; i <= n; i++) {
        sum[i] = sum[i - 1] + a[i];
    }
    long long ans = 10000000000000000LL;
    for (int i = 1; i <= n; i++) {
        int l = 1;
        int r = i;
        long long totalCost = 0LL;
        int minLeft = -1;
        while (l <= r) {
            int mid = (l + r) / 2;
            if (a[i] - a[mid] <= m / 2) {
                minLeft = mid;
                r = mid - 1;
            }
            else {
                l = mid + 1;
            }
        }
        totalCost += 1LL * (minLeft - 1) * (m - a[i]) - sum[i] + 1LL * (i - minLeft + 1) * a[i] + 2LL * sum[minLeft - 1];
        int maxRight = -1;
        l = i;
        r = n;
        while (l <= r) {
            int mid = (l + r) / 2;
            if (a[mid] - a[i] <= m / 2) {
                maxRight = mid;
                l = mid + 1;
            }
            else {
                r = mid - 1;
            }
        }
        totalCost += 1LL * (n - maxRight) * (m + a[i]) - sum[n] + 2LL * sum[maxRight] - sum[i - 1] - 1LL * (maxRight - i + 1) * a[i];
        ans = min(ans, totalCost);
    }
    cout << ans;
    return 0;
}
import sys
from bisect import bisect_left, bisect_right

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    a = [0] + sorted(map(int, data[2:2 + n]))      # đánh chỉ số từ 1 giống bản C++
    s = [0] * (n + 1)
    for i in range(1, n + 1):
        s[i] = s[i - 1] + a[i]
    half = m // 2
    ans = None
    for i in range(1, n + 1):
        # minLeft: vị trí nhỏ nhất bên trái mà a[i] - a[minLeft] <= m/2 (xoay thuận)
        minLeft = bisect_left(a, a[i] - half, 1, i + 1)
        # maxRight: vị trí lớn nhất bên phải mà a[maxRight] - a[i] <= m/2
        maxRight = bisect_right(a, a[i] + half, i, n + 1) - 1
        cost = (minLeft - 1) * (m - a[i]) - s[i] + (i - minLeft + 1) * a[i] + 2 * s[minLeft - 1]
        cost += (n - maxRight) * (m + a[i]) - s[n] + 2 * s[maxRight] - s[i - 1] - (maxRight - i + 1) * a[i]
        if ans is None or cost < ans:
            ans = cost
    print(ans)

main()
C++▶ Chạy tay từng dòng codeSort + prefix sum + 2 lần tìm kiếm nhị phân cho mỗi đích, O(N log N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4/*
5Tong (a[i] - a[j]) voi j: minLeft -> i = (i - minLeft + 1) * a[i] - sum[i] + sum[minLeft - 1]
6Tong (m - a[i] + a[j]) voi j: 1 -> minLeft - 1 = (minLeft - 1) * (m - a[i]) + sum[minLeft - 1]
7Tong (a[j] - a[i]) voi j: i -> maxRight = sum[maxRight] - sum[i - 1] - (maxRight - i + 1) * a[i]
8Tong (m + a[i] - a[j]) voi moi j: maxRight + 1 -> n = (n - maxRight) * (m + a[i]) - sum[n] + sum[maxRight]
9*/
10
11long long a[100005];
12long long sum[100005];
13int n;
14long long m;
15
16int main () {
17 ios::sync_with_stdio(0);
18 cin.tie(0);
19 cin >> n >> m;
20 for (int i = 1; i <= n; i++) {
21 cin >> a[i];
22 }
23 sort(a + 1, a + n + 1);
24 for (int i = 1; i <= n; i++) {
25 sum[i] = sum[i - 1] + a[i];
26 }
27 long long ans = 10000000000000000LL;
28 for (int i = 1; i <= n; i++) {
29 int l = 1;
30 int r = i;
31 long long totalCost = 0LL;
32 int minLeft = -1;
33 while (l <= r) {
34 int mid = (l + r) / 2;
35 if (a[i] - a[mid] <= m / 2) {
36 minLeft = mid;
37 r = mid - 1;
38 }
39 else {
40 l = mid + 1;
41 }
42 }
43 totalCost += 1LL * (minLeft - 1) * (m - a[i]) - sum[i] + 1LL * (i - minLeft + 1) * a[i] + 2LL * sum[minLeft - 1];
44 int maxRight = -1;
45 l = i;
46 r = n;
47 while (l <= r) {
48 int mid = (l + r) / 2;
49 if (a[mid] - a[i] <= m / 2) {
50 maxRight = mid;
51 l = mid + 1;
52 }
53 else {
54 r = mid - 1;
55 }
56 }
57 totalCost += 1LL * (n - maxRight) * (m + a[i]) - sum[n] + 2LL * sum[maxRight] - sum[i - 1] - 1LL * (maxRight - i + 1) * a[i];
58 ans = min(ans, totalCost);
59 }
60 cout << ans;
61 return 0;
62}

Hai đoạn tìm kiếm nhị phân lần lượt tìm minLeft (vị trí $l$) và maxRight (vị trí $r$). Riêng $j = i$ được tính ở cả hai phần nhưng chi phí bằng 0 nên không ảnh hưởng. Biến ans khởi tạo bằng $10^{16}$, đủ lớn vì chi phí tối đa khoảng $10^5 \times 5 \cdot 10^8 = 5 \cdot 10^{13}$, nhớ dùng long long.

Không gian$O(N)$
Thời gian$O(N \log N)$
Ghi chú của mình: bản Python là mình viết thêm theo đúng công thức của bản C++, dùng bisect thay cho 2 vòng tìm kiếm nhị phân tự viết. Cả 2 bản đã được kiểm tra với 600 bộ test ngẫu nhiên so với cách thử mọi vị trí đích từ 1 đến $m$; với $n = 10^5$, bản C++ chạy khoảng 0.02 giây, bản Python khoảng 0.16 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 😇