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 →

Xóa Số (XOSO – OLPCĐ 2012)

Xóa Số (XOSO – OLPCĐ 2012)
Nguồn: OLPCĐ 2012 (NTUCoder) Độ khó: Dễ Toán học Chẵn lẻ Đếm Tổ hợp

Đề bài

XOSO – Xóa số (OLPCĐ 2012) · Giới hạn thời gian: 1.0 giây · Giới hạn bộ nhớ: 128 megabyte

Cho dãy $n$ số nguyên không âm $a_1, a_2, \ldots, a_n$. Người ta tiến hành chọn ra 2 chỉ số $i, j$ sao cho $i \lt j$ và xóa khỏi dãy 2 số $a_i, a_j$ để tổng giá trị các số còn lại trong dãy là số chẵn.

Yêu cầu: Cho dãy số $a_1, a_2, \ldots, a_n$. Hãy đếm số lượng cách chọn 2 chỉ số $i, j$ thỏa mãn. Hai cách chọn khác nhau nếu tồn tại một chỉ số khác nhau.

Dữ liệu nhập: gồm 2 dòng

  • Dòng 1: chứa số nguyên $n$ $(2 \le n \le 10^6)$.
  • Dòng 2: chứa $n$ số nguyên không âm $a_1, a_2, \ldots, a_n$ $(0 \le a_i \le 10^9)$.

Dữ liệu xuất:

  • Là một số nguyên cho biết số cách chọn 2 chỉ số $i, j$ thỏa mãn.
Ví dụ 1 – input
5
1 2 3 4 5
output
6
Ví dụ 2 – input
5
2 4 6 8 10
output
10

Lời mở đầu

Bài này đề ngắn gọn, đọc qua là hiểu liền, nhưng lại là một bài rất hay để luyện cái tư duy đi từ vét cạn rồi tối ưu dần lên: từ $O(N^3)$ rút xuống $O(N^2)$ rồi xuống hẳn $O(N)$ chỉ nhờ một nhận xét chẵn lẻ cực kỳ đơn giản. Anh em cùng mình đi từng bước nha 😇.

Mình giải thích ví dụ mẫu để bạn nào chưa hiểu đề sẽ hiểu rõ. Ở ví dụ 1, dãy là 1 2 3 4 5, tổng cả dãy là $15$. Ta thử xóa từng cặp vị trí $(i, j)$ và xem tổng còn lại:

Cặp $(i, j)$Xóa $a_i, a_j$Tổng còn lạiChẵn?
(1, 2)1, 215 − 3 = 12✔
(1, 3)1, 315 − 4 = 11✘
(1, 4)1, 415 − 5 = 10✔
(1, 5)1, 515 − 6 = 9✘
(2, 3)2, 315 − 5 = 10✔
(2, 4)2, 415 − 6 = 9✘
(2, 5)2, 515 − 7 = 8✔
(3, 4)3, 415 − 7 = 8✔
(3, 5)3, 515 − 8 = 7✘
(4, 5)4, 515 − 9 = 6✔

Có đúng $6$ cặp cho tổng còn lại là số chẵn nên đáp án là $6$. Ở ví dụ 2, dãy 2 4 6 8 10 toàn số chẵn nên xóa cặp nào thì tổng còn lại cũng chẵn, có tất cả $\dfrac{5 \cdot 4}{2} = 10$ cặp nên đáp án là $10$.

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 mọi cặp, cộng lại phần còn lại – O(N³)

Tư tưởng vét cạn rất dễ để nhìn ra: ta đi liệt kê ra tất cả các cặp vị trí $i, j$ thỏa $i \lt j$, rồi tính tổng các phần tử còn lại (bỏ qua 2 vị trí $i, j$). Nếu tổng đó là số chẵn thì cặp $i, j$ được chọn, tăng biến đếm lên. Cứ như thế xét hết tất cả các cặp rồi thì biến đếm chính là kết quả cần tìm – y hệt cái bảng mình làm tay ở trên.

Việc xét hết tất cả các cặp $i \lt j$ chính là kiểu 2 vòng lặp lồng nhau mà ta hay gặp khi cài đặt các thuật toán sắp xếp cơ bản. Nội việc đó đã tốn $O(N^2)$, mà $N$ tối đa $10^6$ thì $(10^6)^2 = 10^{12}$ – vượt quá xa ngưỡng khoảng $(3 \text{ đến } 5) \cdot 10^7$, cao lắm là $10^8$ phép tính trong 1 giây. Nhìn thấy rõ ngay từ đầu cách này chắc chắn bị TLE.

Vậy sao vẫn nên cài vét cạn?
Theo quan điểm của mình: khi chưa tìm ra cách tốt nhất, hoặc đã tìm ra nhưng chưa chắc nó đúng, thì cứ cài đặt cách vét cạn trước vì nó dễ nhìn ra và luôn cho đáp án chính xác (nếu cài đúng nhé hihi). Nó sẽ là công cụ để kiểm tra cách tối ưu (sinh test ngẫu nhiên rồi so kết quả). Và chính lúc cài vét cạn, các bạn sẽ thấy được những chỗ "dở" của nó, từ đó làm cơ sở nghĩ ra cách tốt hơ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;

const int MAX = 1e6 + 5;
int a[MAX];

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> 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){
            long long sum = 0;
            for(int k = 0; k < n; ++k){
                if(k != i && k != j){
                    sum += a[k];
                }
            }
            if(sum % 2 == 0){
                res++;
            }
        }
    }
    cout << res;
    return 0;
}
import sys

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

main()
Lưu ý kiểu dữ liệu long long
  • Biến sum phải là long long (8 byte): $a_i$ tối đa $10^9$ và $N$ tối đa $10^6$, nên tổng có thể lên tới $10^9 \cdot 10^6 = 10^{15}$ (1 triệu tỷ). Kiểu 4 byte chỉ lưu được tới hàng tỷ là hết mức.
  • Biến res cũng phải là long long: nếu mọi cặp đều thỏa thì có $\dfrac{N(N-1)}{2}$ cặp, với $N = 10^6$ là cỡ $5 \cdot 10^{11}$ (500 tỷ). Kiểu 4 byte không dấu cũng chỉ lưu được tầm hơn 4 tỷ thôi.

Em bấm Tiếp trong khung bên dưới để xem 3 vòng lặp chạy thế nào: ô đỏ là 2 phần tử đang bị "xóa", ô vàng là phần tử $k$ đang được cộng vào tổng.

C++▶ Chạy tay từng dòng codeCách 1: xét mọi cặp (i, j), vòng k cộng các phần tử còn lại – O(N³).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4const int MAX = 1e6 + 5;
5int a[MAX];
6
7int main(){
8 ios::sync_with_stdio(0);
9 cin.tie(0);
10 int n;
11 cin >> n;
12 for(int i = 0; i < n; ++i){
13 cin >> a[i];
14 }
15 long long res = 0;
16 for(int i = 0; i < n; ++i){
17 for(int j = i + 1; j < n; ++j){
18 long long sum = 0;
19 for(int k = 0; k < n; ++k){
20 if(k != i && k != j){
21 sum += a[k];
22 }
23 }
24 if(sum % 2 == 0){
25 res++;
26 }
27 }
28 }
29 cout << res;
30 return 0;
31}

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

  • Không gian: $O(N)$ với $N$ là số lượng phần tử của mảng.
  • Thời gian: $O(N)$ của bước đọc mảng + $O(N^3)$ của bước xử lý (2 vòng xét cặp, bên trong lại thêm 1 vòng duyệt cả mảng để tính tổng: $N^2 \cdot N = N^3$). Theo quy tắc cộng của Big-O lấy thành phần lớn nhất nên tổng quát là $O(N^3)$.
Không gian$O(N)$
Thời gian$O(N^3)$
Chắc chắn TLE
Chỉ riêng 2 vòng xét cặp $O(N^2)$ đã TLE rồi, giờ còn nhân thêm $N$ của vòng tính tổng nữa thì còn tệ hơn nhiều.

Cách 2Tính tổng cả mảng trước, bỏ vòng lặp thứ 3 – O(N²)

Ta phải tiếp tục nhìn ra những cái không tốt để tối ưu lại. Có thật sự cần vòng lặp thứ 3 duyệt cả mảng chỉ để tính tổng các phần tử còn lại không? Bản chất ta chỉ cần tổng của các phần tử trừ 2 vị trí $i, j$. Vậy ngay từ đầu ta tính tổng $S$ của toàn bộ mảng, rồi khi xét cặp $i, j$ chỉ việc lấy:

Tổng còn lại sau khi xóa
$\text{còn lại} = S - a_i - a_j$

Như thế là không cần vòng lặp thứ 3 lồng bên trong nữa, độ phức tạp rút xuống $O(N^2)$. Còn việc tính $S$ thì cũng không cần thêm một vòng lặp riêng: ta tận dụng luôn vòng lặp đọc dữ liệu, đọc số nào cộng số đó vào sum. Đọc xong input là có luôn tổng cả mảng.

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;

const int MAX = 1e6 + 5;
int a[MAX];

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

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = list(map(int, data[1:1 + n]))
    s = sum(a)
    res = 0
    for i in range(n):
        for j in range(i + 1, n):
            if (s - a[i] - a[j]) % 2 == 0:
                res += 1
    print(res)

main()
C++▶ Chạy tay từng dòng codeCách 2: tính sum lúc đọc, mỗi cặp chỉ cần kiểm (sum - a[i] - a[j]) % 2 – O(N²).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4const int MAX = 1e6 + 5;
5int a[MAX];
6
7int main(){
8 ios::sync_with_stdio(0);
9 cin.tie(0);
10 int n;
11 cin >> n;
12 long long sum = 0;
13 for(int i = 0; i < n; ++i){
14 cin >> a[i];
15 sum += a[i];
16 }
17 long long res = 0;
18 for(int i = 0; i < n; ++i){
19 for(int j = i + 1; j < n; ++j){
20 if((sum - a[i] - a[j]) % 2 == 0){
21 res++;
22 }
23 }
24 }
25 cout << res;
26 return 0;
27}

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

  • Không gian: $O(N)$ với $N$ là số lượng phần tử của mảng.
  • Thời gian: $O(N)$ của bước đọc mảng + $O(N^2)$ của bước xử lý, tổng quát là $O(N^2)$.
Không gian$O(N)$
Thời gian$O(N^2)$
Vẫn TLE
Như đã phân tích lúc đầu, $O(N^2)$ với $N = 10^6$ là $10^{12}$ phép tính nên vẫn sẽ bị TLE. Ta phải nhìn ra cách làm tốt hơn nữa.

Cách 3Nhận xét chẵn lẻ + đếm tổ hợp – O(N)

Để ý một chút quy luật toán học: ta đâu cần biết tổng còn lại bằng bao nhiêu, ta chỉ cần biết nó chẵn hay lẻ. Mà tính chẵn lẻ của phép trừ thì quá quen thuộc:

Chẵn − chẵnchẵn
Lẻ − lẻchẵn
Lẻ − chẵn / chẵn − lẻlẻ

Trường hợp 1: tổng $S$ của cả mảng là số lẻ. Để $S - (a_i + a_j)$ ra số chẵn thì $a_i + a_j$ phải là số lẻ (chứ nếu $a_i + a_j$ chẵn thì lẻ − chẵn = lẻ rồi). Mà để tổng 2 số là lẻ thì phải là 1 thằng lẻ, 1 thằng chẵn – 2 thằng cùng lẻ cộng lại ra chẵn, 2 thằng cùng chẵn cộng lại cũng ra chẵn.

Vậy nếu ngay ở bước đọc dữ liệu ta thống kê được có $X$ số chẵn và $Y$ số lẻ, thì số cách chọn 1 thằng chẵn ghép với 1 thằng lẻ chính là $X \cdot Y$. Ví dụ $X = 4$ thằng chẵn, $Y = 3$ thằng lẻ: $X_1$ ghép được với cả $\{Y_1, Y_2, Y_3\}$, $X_2$ cũng ghép được với cả $\{Y_1, Y_2, Y_3\}$, rồi $X_3$, $X_4$ cũng vậy. Tổng cộng $3 + 3 + 3 + 3 = 12 = 4 \cdot 3$. Phép nhân có tính giao hoán nên hiểu ngược lại "mỗi $Y$ ghép với cả 4 thằng $X$" cũng ra như nhau.

Trường hợp 2: tổng $S$ là số chẵn. Muốn trừ đi $a_i + a_j$ mà vẫn còn chẵn thì $a_i + a_j$ cũng phải chẵn (chỉ có chẵn − chẵn = chẵn). Tổng 2 số chẵn thì có 2 tình huống: cả 2 cùng chẵn hoặc cả 2 cùng lẻ.

Câu hỏi giờ là: từ một tập có $K$ phần tử, chọn ra được bao nhiêu cặp? Thử với $K = 4$ vị trí $\{1, 2, 3, 4\}$: các cặp là $\{1,2\}, \{1,3\}, \{1,4\}, \{2,3\}, \{2,4\}, \{3,4\}$ – cả thảy 6 cặp. Quy luật: vị trí thứ $i$ ghép đôi với $K - i$ thằng đứng sau nó, nên số cặp là $(K-1) + (K-2) + \ldots + 1 + 0$ – tổng cộng dồn từ 1 đến $K - 1$, có công thức tính nhanh $\dfrac{K(K-1)}{2}$. Với $K = 4$: $\dfrac{4 \cdot 3}{2} = 6$ ✔. Công thức này rất phổ biến, bạn nào muốn tìm hiểu cách chứng minh thì Google search "chứng minh bằng quy nạp" nhé.

Công thức đáp án
Gọi $X$ = số lượng số chẵn, $Y = N - X$ = số lượng số lẻ, $S$ = tổng cả mảng:
  • $S$ lẻ: $\text{đáp án} = X \cdot Y$
  • $S$ chẵn: $\text{đáp án} = \dfrac{X(X-1)}{2} + \dfrac{Y(Y-1)}{2}$

Kiểm lại với ví dụ 1: 1 2 3 4 5 có $X = 2$ (số 2, 4), $Y = 3$ (số 1, 3, 5), $S = 15$ lẻ ⇒ đáp án $2 \cdot 3 = 6$ ✔. Ví dụ 2: $X = 5$, $Y = 0$, $S = 30$ chẵn ⇒ $\dfrac{5 \cdot 4}{2} + 0 = 10$ ✔.

Minh hoạ đếm chẵn lẻ

Em nhập dãy số rồi bấm Tiếp để xem từng số được phân loại chẵn (xanh) / lẻ (vàng) thế nào, và cuối cùng các cặp hợp lệ hiện ra ra sao (cặp gạch ngang là cặp không hợp 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;
    long long cntEven = 0, sum = 0;
    int temp = n;
    while(temp--){
        int x;
        cin >> x;
        sum += x;
        if(x % 2 == 0){
            cntEven++;
        }
    }
    long long cntOdd = n - cntEven;
    if(sum % 2 != 0){
        cout << cntEven * cntOdd;
    }
    else{
        cout << (cntEven * (cntEven - 1)) / 2 + (cntOdd * (cntOdd - 1)) / 2;
    }
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    cnt_even = 0
    s = 0
    for k in range(1, n + 1):
        x = int(data[k])
        s += x
        if x % 2 == 0:
            cnt_even += 1
    cnt_odd = n - cnt_even
    if s % 2 != 0:
        print(cnt_even * cnt_odd)
    else:
        print(cnt_even * (cnt_even - 1) // 2 + cnt_odd * (cnt_odd - 1) // 2)

main()

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

  1. Biến sum để kiểu long long, lý do mình đã nói trong phần lưu ý ở cách 1 (tổng có thể tới $10^{15}$).
  2. cntEven là biến lưu số lượng số chẵn, cntOdd là biến lưu số lượng số lẻ của dãy ban đầu. Mình nói luôn để bạn nào không rành tiếng Anh thì không bị rối.
  3. cntEven, cntOdd cũng để kiểu long long. Dù bản thân chúng tối đa chỉ $10^6$, nhưng ở phép tính cuối cntEven * (cntEven - 1), kết quả tạm được lưu theo kiểu của các biến thành phần. Nếu để int thì $10^6 \cdot (10^6 - 1)$ cỡ $10^{12}$ sẽ bị tràn, làm sai kết quả. Cho long long từ đầu thì mọi vùng nhớ tạm khi tính toán cũng là long long, khỏi phải ép kiểu từng chỗ cho rối.
  4. Không cần đếm riêng số lẻ: mỗi số chỉ có thể chẵn hoặc lẻ, nên đếm số chẵn xong thì cntOdd = n - cntEven. Ví dụ mảng 10 phần tử có 4 số chẵn thì chắc chắn có 6 số lẻ.
  5. Không cần mảng nữa: đọc vào biến x, xét luôn rồi bỏ, về sau không cần dùng lại.
C++▶ Chạy tay từng dòng codeCách 3: đếm số chẵn cntEven, tính sum; dùng công thức theo tính chẵn lẻ của sum – O(N).
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 long long cntEven = 0, sum = 0;
10 int temp = n;
11 while(temp--){
12 int x;
13 cin >> x;
14 sum += x;
15 if(x % 2 == 0){
16 cntEven++;
17 }
18 }
19 long long cntOdd = n - cntEven;
20 if(sum % 2 != 0){
21 cout << cntEven * cntOdd;
22 }
23 else{
24 cout << (cntEven * (cntEven - 1)) / 2 + (cntOdd * (cntOdd - 1)) / 2;
25 }
26 return 0;
27}

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

  • Không gian: $O(1)$ vì giờ ta không cần mảng lưu trữ, chỉ đọc từng số vào biến x rồi xét luôn.
  • Thời gian: $O(N)$ của bước đọc dữ liệu. Trường hợp xấu nhất $N = 10^6$ thì vòng lặp chỉ chạy $10^6$ lần, ít hơn ngưỡng 1 giây cả 50 – 100 lần nên thoải mái không sợ TLE.
Không gian$O(1)$
Thời gian$O(N)$
Cái hay của giải thuật
Từ độ phức tạp thời gian $O(N^3)$ rút xuống $O(N^2)$ rồi lại rút xuống còn $O(N)$. Không gian thì từ $O(N)$ xuống $O(1)$. Tất cả chỉ nhờ việc chịu khó nhìn ra "chỗ dở" của cách trước để cải tiến dần 😎.
Ghi chú của mình: code C++ và Python của cả 3 cách đã được kiểm tra với 400 bộ test ngẫu nhiên nhỏ ($n \le 12$, có cả số 0 và số tới $10^9$), so với code vét cạn – khớp hết. Với $n = 10^6$, cách 3 bản C++ chạy khoảng 0.1 giây, bản Python khoảng 0.25 giây (đo CPU trên máy mình); trường hợp $n = 10^6$ toàn số $10^9$ cho đáp án $499999500000$, đúng là phải dùng long long. Còn cách 2 với $n = 10^5$ đã mất hơn 3 giây, cách 1 với $n = 2000$ đã mất gần 4 giây ⇒ TLE đúng như phân tích.
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