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 →

Phi Tiêu (PHITIEU)

Phi Tiêu (PHITIEU)
Nguồn: LQDOJ Độ khó: Trung bình Quy hoạch động QHĐ tuyến tính Truy vết

Đề bài

PHITIEU – Phi tiêu

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

Sau những giờ thi HSG căng thẳng, BTC tổ chức cho các thí sinh gặp nhau và tham gia các trò chơi giải trí. Trong nội dung phóng phi tiêu, trước khi vào vị trí người chơi được BTC cho quan sát $N$ ô được đánh số từ $1$ đến $N$, trên mỗi ô ghi một số nguyên dương tương ứng với điểm có được nếu người chơi phóng tiêu trúng. Tại mỗi thời điểm người chơi chỉ có thể phóng $1$ cây tiêu và theo nguyên tắc người chơi không được phóng vào $3$ ô liên tiếp nhau.

Yêu cầu: Hãy viết chương trình giúp người chơi chọn các ô để phóng sao cho tổng điểm thu được là lớn nhất. Giả sử tỷ lệ phóng trúng là $100\%$.

Dữ liệu vào:

  • Dòng đầu tiên chứa số nguyên dương $N$ $(1 \le N \le 10^6)$.
  • Các dòng tiếp theo chứa $N$ số nguyên dương $a_i$ là số điểm ghi trên các ô $(0 \lt a_i \le 10^9)$.

Kết quả: Một dòng ghi số điểm lớn nhất tìm được.

Input
4
9 3 5 4
Output
18

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 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, kèm theo đó là toàn bộ phần hướng dẫn của mình để bạn nào chưa giải được thì tham khảo nhen. Hi vọng mang đến được giá trị cho các bạn 😇.

Khác một chút so với đề thi gốc
Đề trên LQDOJ chỉ yêu cầu in ra tổng điểm lớn nhất. Còn đề thi gốc mà em học viên hỏi mình thì yêu cầu thêm việc chỉ ra cụ thể các ô được chọn. Mình trình bày cả hai: phần Cách 1 là code nộp LQDOJ, phần Mở rộng là truy vết để in ra các ô được chọn.

Mình giải thích ví dụ mẫu để bạn nào chưa hiểu đề sẽ hiểu rõ. Có $4$ ô với điểm $9, 3, 5, 4$. Không được phóng vào $3$ ô liên tiếp, nên không thể lấy cả $4$ ô, cũng không thể lấy $3$ ô liền nhau như ô $2, 3, 4$. Cách tốt nhất là phóng vào ô $1$, ô $3$ và ô $4$ (bỏ ô $2$ chỉ có $3$ điểm): $9 + 5 + 4 = 18$.

ô 19ô 23ô 35ô 44
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 1Quy hoạch động 4 bước – O(N)

Với yêu cầu không được chọn 3 ô liền nhau và chọn sao cho tổng điểm lớn nhất, bài này áp dụng phương pháp Quy Hoạch Động (Dynamic Programming) để giải quyết nhé. Bạn nào đã biết cơ bản qua về quy hoạch động rồi thì 4 bước giải như sau:

1Bài toán con: $f[i]$ là tổng điểm lớn nhất khi xét qua $i$ ô đầu tiên.
2Bài toán cơ sở: $f[0]$, $f[1]$, $f[2]$, $f[3]$.
3Đáp án: $f[N]$.
4Truy hồi: $f[i] = \max(f[i-1],\ f[i-2] + a[i],\ f[i-3] + a[i] + a[i-1])$.

Bước 1: Xác định bài toán con

Gọi $f[i]$ là tổng điểm lớn nhất có thể đạt được khi xét qua $i$ vị trí đầu tiên (ô $1$ đến ô $i$), vẫn đảm bảo không có 3 ô liên tiếp nào cùng bị phóng.

Bước 2: Xác định bài toán cơ sở

  • $f[0] = 0$ vì không có vị trí nào thì tổng điểm bằng $0$.
  • $f[1] = a[1]$ vì chỉ có 1 vị trí thì tổng điểm nhiều nhất là chọn luôn vị trí 1.
  • $f[2] = a[1] + a[2]$ vì chỉ có 2 vị trí thì chọn cả 2 (chưa thể có 3 ô liên tiếp).
  • $f[3] = \max(a[1] + a[2],\ a[1] + a[3],\ a[2] + a[3])$ vì có 3 vị trí liên tiếp thì không thể chọn cả 3, phải bỏ đi 1 trong 3 và chọn 2 vị trí còn lại – so sánh chọn ra cái lớn nhất.

Bước 3: Xác định đáp án bài toán

Đáp án là $f[N]$ vì ta cần tổng điểm lớn nhất sau khi xét qua cả $N$ vị trí. (Điểm các ô đều dương nên chọn càng nhiều càng có lợi, $f$ là dãy không giảm.)

Bước 4: Xác định công thức truy hồi

Xét ô thứ $i$, có đúng 3 khả năng:

  • Không phóng vào ô $i$: $f[i] = f[i - 1]$.
  • Phóng ô $i$, không phóng ô $i - 1$: thì ô $i - 2$ hoàn toàn có thể chọn hoặc không, nên $f[i] = f[i - 2] + a[i]$.
  • Phóng cả ô $i$ và ô $i - 1$: thì không được phóng ô $i - 2$ (vì không được chọn 3 ô liên tiếp), nên $f[i] = f[i - 3] + a[i] + a[i - 1]$.
Công thức truy hồi
$f[i] = \max\big(f[i - 1],\ f[i - 2] + a[i],\ f[i - 3] + a[i] + a[i - 1]\big)$  với $i \ge 4$.

Với ví dụ của đề: $f[0] = 0$, $f[1] = 9$, $f[2] = 9 + 3 = 12$, $f[3] = \max(12, 14, 8) = 14$, $f[4] = \max(f[3],\ f[2] + 4,\ f[1] + 4 + 5) = \max(14, 16, 18) = 18$ ✅.

Minh hoạ tính bảng f và lần ngược

Các bạn nhập dãy điểm rồi bấm Tiếp để xem từng $f[i]$ được chọn từ khả năng nào; đi hết bảng thì khung sẽ lần ngược lại (y như phần Mở rộng) để tô ra các ô được phóng:

Ví dụ nhanh:

Đây là code mình code nhanh ra cho em học viên tham khảo:

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

long long a[(int)1e6 + 1], f[(int)1e6 + 1];

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n;
    cin >> n;
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
    }
    f[0] = 0;
    f[1] = a[1];

    if(n > 1){
        f[2] = a[1] + a[2];
    }

    if(n > 2){
        f[3] = max(a[1] + a[2], max(a[1] + a[3], a[2] + a[3]));
    }

    for(int i = 4; i <= n; ++i){
        f[i] = max(f[i - 1], max(f[i - 2] + a[i], f[i - 3] + a[i] + a[i - 1]));
    }
    cout << f[n];

    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n = int(data[0])
a = [0] * (n + 1)
for i in range(1, n + 1):
    a[i] = int(data[i])
f = [0] * (n + 1)
f[0] = 0
f[1] = a[1]

if n > 1:
    f[2] = a[1] + a[2]

if n > 2:
    f[3] = max(a[1] + a[2], a[1] + a[3], a[2] + a[3])

for i in range(4, n + 1):
    f[i] = max(f[i - 1], f[i - 2] + a[i], f[i - 3] + a[i] + a[i - 1])
print(f[n])
C++▶ Chạy tay từng dòng codeCách 1: f[1..3] cơ sở, f[i] = max(f[i-1], f[i-2] + a[i], f[i-3] + a[i] + a[i-1]).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4long long a[(int)1e6 + 1], f[(int)1e6 + 1];
5
6int main(){
7 ios::sync_with_stdio(0);
8 cin.tie(0);
9
10 int n;
11 cin >> n;
12 for(int i = 1; i <= n; ++i){
13 cin >> a[i];
14 }
15 f[0] = 0;
16 f[1] = a[1];
17
18 if(n > 1){
19 f[2] = a[1] + a[2];
20 }
21
22 if(n > 2){
23 f[3] = max(a[1] + a[2], max(a[1] + a[3], a[2] + a[3]));
24 }
25
26 for(int i = 4; i <= n; ++i){
27 f[i] = max(f[i - 1], max(f[i - 2] + a[i], f[i - 3] + a[i] + a[i - 1]));
28 }
29 cout << f[n];
30
31 return 0;
32}
Mảng lớn thì để toàn cục
Vì $N$ tối đa $10^6$ nên mình đem 2 mảng a, f ra làm biến toàn cục để tránh bị tràn stack. Tốt nhất các mảng lớn các bạn hãy cho nó là toàn cục nha. (Trong bản gốc gửi em học viên, mình lỡ khai báo thêm một mảng cục bộ long long f[n + 1]; trong main – nó "che" mất mảng f toàn cục và chiếm tới 8 MB trên stack, máy nào stack nhỏ (ví dụ Windows mặc định 1 MB) là chạy lỗi ngay. Ở trên mình đã bỏ dòng đó đi, dùng luôn mảng toàn cục.)
Nhớ dùng long long
Tổng điểm có thể tới $10^6 \cdot 10^9 = 10^{15}$, nên $a$ và $f$ đều là long long.

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

  • Không gian: $O(N)$ cho 2 mảng $a$ và $f$.
  • Thời gian: $O(N)$ – mỗi $f[i]$ chỉ so sánh 3 khả năng, nên yên tâm AC.
Không gian$O(N)$
Thời gian$O(N)$

Mở rộngTruy vết – in ra các ô được chọn

Giờ các bạn xem tiếp nha: đề thi gốc không chỉ yêu cầu xuất ra tổng điểm lớn nhất mà còn yêu cầu chỉ rõ những vị trí nào được chọn để đạt tổng điểm đó. Thông thường các bài chỉ cần in ra kết quả lớn nhất thôi, nhưng có một số bài bắt phải truy vết ngược lại để in ra cách chọn. Chỗ này đơn giản là ta lần ngược lại mảng $f$ thôi.

Mình in thử mảng $f$ từ $f[0]$ đến $f[n]$ với ví dụ của đề (thêm đoạn for(int i = 0; i <= n; ++i) cout << f[i] << " "; rồi chạy lại với test 4 / 9 3 5 4), mảng $f$ sẽ là: 0 9 12 14 18.

Nhìn lại công thức $f[i] = \max(f[i - 1],\ f[i - 2] + a[i],\ f[i - 3] + a[i] + a[i - 1])$. Nếu lần ngược xét $f[i]$ với $i$ từ $n$ về $1$:

  • Nếu $f[i] = f[i - 1]$: nghĩa là không chọn vị trí $i$ ⇒ xét tiếp $i - 1$.
  • Nếu $f[i] = f[i - 2] + a[i]$: nghĩa là chọn vị trí $i$ và không chọn vị trí $i - 1$ ⇒ xét tiếp $i - 2$.
  • Nếu $f[i] = f[i - 3] + a[i] + a[i - 1]$: nghĩa là chọn vị trí $i - 1$ và $i$, không chọn $i - 2$ ⇒ xét tiếp $i - 3$.

Ví dụ xét $f[4] = 18$ rơi vào tình huống nào? Thấy $f[4] = f[1] + a[4] + a[3] = 9 + 4 + 5 = 18$, nên đưa vị trí $3, 4$ vào đáp án, rồi nhảy về $f[1]$. Khi $i \le 3$ thì dựa vào điều kiện cơ sở: $f[1] = a[1]$ nghĩa là chọn vị trí $1$. Vậy tổng cộng 3 vị trí được chọn là $1, 3, 4$.

Khi nhiều khả năng cùng bằng f[i]
Có thể nhiều khả năng cùng cho giá trị bằng $f[i]$ (ví dụ dãy toàn số bằng nhau). Khi đó chọn khả năng nào cũng ra một cách chọn tối ưu hợp lệ, code dưới đây ưu tiên theo thứ tự kiểm tra: $f[i - 2] + a[i]$ trước, rồi $f[i - 3] + a[i] + a[i - 1]$, cuối cùng mới là "không chọn".

Đây là code đã chỉnh lại để lần ngược ra cụ thể các vị trí chọn, độ phức tạp vẫn là $O(N)$. Kết quả in ra: dòng đầu là số ô được chọn và tổng điểm lớn nhất, sau đó mỗi dòng là một vị trí được chọn (in từ cuối về đầu):

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

long long a[(int)1e6 + 1], f[(int)1e6 + 1];
int b[(int)1e6 + 1];

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n;
    cin >> n;
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
    }
    f[0] = 0;
    f[1] = a[1];

    if(n > 1){
        f[2] = a[1] + a[2];
    }

    if(n > 2){
        f[3] = max(a[1] + a[2], max(a[1] + a[3], a[2] + a[3]));
    }

    for(int i = 4; i <= n; ++i){
        f[i] = max(f[i - 1], max(f[i - 2] + a[i], f[i - 3] + a[i] + a[i - 1]));
    }

    int nb = 0;
    int idx;
    for(idx = n; idx >= 4; --idx){
        if(f[idx] == f[idx - 2] + a[idx]){
            b[nb++] = idx;
            idx--;
        }
        else if(f[idx] == f[idx - 3] + a[idx] + a[idx - 1]){
            b[nb++] = idx;
            b[nb++] = idx - 1;
            idx -= 2;
        }
    }
    if(idx == 3){
        if(f[3] == a[1] + a[2]){
            b[nb++] = 2;
            b[nb++] = 1;
        }
        else if(f[3] == a[1] + a[3]){
            b[nb++] = 3;
            b[nb++] = 1;
        }
        else if(f[3] == a[2] + a[3]){
            b[nb++] = 3;
            b[nb++] = 2;
        }
    }
    else if(idx == 2){
        b[nb++] = 2;
        b[nb++] = 1;
    }
    else if(idx == 1){
        b[nb++] = 1;
    }

    cout << nb << " " << f[n];
    for(int i = 0; i < nb; ++i){
        cout << '\n' << b[i];
    }

    return 0;
}
import sys

data = sys.stdin.buffer.read().split()
n = int(data[0])
a = [0] * (n + 1)
for i in range(1, n + 1):
    a[i] = int(data[i])
f = [0] * (n + 1)
f[0] = 0
f[1] = a[1]

if n > 1:
    f[2] = a[1] + a[2]

if n > 2:
    f[3] = max(a[1] + a[2], a[1] + a[3], a[2] + a[3])

for i in range(4, n + 1):
    f[i] = max(f[i - 1], f[i - 2] + a[i], f[i - 3] + a[i] + a[i - 1])

b = []
idx = n
while idx >= 4:
    if f[idx] == f[idx - 2] + a[idx]:
        b.append(idx)
        idx -= 1
    elif f[idx] == f[idx - 3] + a[idx] + a[idx - 1]:
        b.append(idx)
        b.append(idx - 1)
        idx -= 2
    idx -= 1
if idx == 3:
    if f[3] == a[1] + a[2]:
        b += [2, 1]
    elif f[3] == a[1] + a[3]:
        b += [3, 1]
    elif f[3] == a[2] + a[3]:
        b += [3, 2]
elif idx == 2:
    b += [2, 1]
elif idx == 1:
    b.append(1)

out = [str(len(b)) + " " + str(f[n])]
out += [str(x) for x in b]
print('\n'.join(out))
C++▶ Chạy tay từng dòng codeMở rộng: tính f như cách 1, rồi lần ngược idx từ n về để đưa các ô được chọn vào mảng b.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4long long a[(int)1e6 + 1], f[(int)1e6 + 1];
5int b[(int)1e6 + 1];
6
7int main(){
8 ios::sync_with_stdio(0);
9 cin.tie(0);
10
11 int n;
12 cin >> n;
13 for(int i = 1; i <= n; ++i){
14 cin >> a[i];
15 }
16 f[0] = 0;
17 f[1] = a[1];
18
19 if(n > 1){
20 f[2] = a[1] + a[2];
21 }
22
23 if(n > 2){
24 f[3] = max(a[1] + a[2], max(a[1] + a[3], a[2] + a[3]));
25 }
26
27 for(int i = 4; i <= n; ++i){
28 f[i] = max(f[i - 1], max(f[i - 2] + a[i], f[i - 3] + a[i] + a[i - 1]));
29 }
30
31 int nb = 0;
32 int idx;
33 for(idx = n; idx >= 4; --idx){
34 if(f[idx] == f[idx - 2] + a[idx]){
35 b[nb++] = idx;
36 idx--;
37 }
38 else if(f[idx] == f[idx - 3] + a[idx] + a[idx - 1]){
39 b[nb++] = idx;
40 b[nb++] = idx - 1;
41 idx -= 2;
42 }
43 }
44 if(idx == 3){
45 if(f[3] == a[1] + a[2]){
46 b[nb++] = 2;
47 b[nb++] = 1;
48 }
49 else if(f[3] == a[1] + a[3]){
50 b[nb++] = 3;
51 b[nb++] = 1;
52 }
53 else if(f[3] == a[2] + a[3]){
54 b[nb++] = 3;
55 b[nb++] = 2;
56 }
57 }
58 else if(idx == 2){
59 b[nb++] = 2;
60 b[nb++] = 1;
61 }
62 else if(idx == 1){
63 b[nb++] = 1;
64 }
65
66 cout << nb << " " << f[n];
67 for(int i = 0; i < nb; ++i){
68 cout << '\n' << b[i];
69 }
70
71 return 0;
72}

Giải thích 1 số chỗ trong code:

  1. Biến idx khai báo ngoài vòng for để sau khi thoát vòng lặp ta còn biết nó dừng ở đâu ($3$, $2$, $1$ hay $0$) mà xử lý phần cơ sở.
  2. Trong vòng lặp, khi chọn ô idx (bỏ ô idx - 1) mình idx-- thêm 1 lần, cộng với --idx của vòng for là nhảy về idx - 2; khi chọn 2 ô thì idx -= 2 cộng --idx là nhảy về idx - 3; còn không rơi vào 2 trường hợp đó thì chắc chắn $f[idx] = f[idx - 1]$, chỉ lùi 1 ô.
  3. Khi idx == 3 thì so $f[3]$ với 3 cặp ở bước cơ sở; idx == 2 thì chọn cả 2 ô đầu; idx == 1 thì chọn ô 1; idx == 0 thì không còn gì để chọn.
Không gian$O(N)$
Thời gian$O(N)$
Ghi chú của mình: code C++ và Python của cả 2 phần đều cho đúng output ví dụ của đề. Mình đã so với code vét cạn (thử mọi tập ô, loại tập có 3 ô liên tiếp) trên 500 bộ test ngẫu nhiên ($N \le 14$, có cả giá trị tới $10^9$): tổng điểm khớp hết, và ở phần mở rộng các vị trí in ra đều hợp lệ (không có 3 ô liền nhau, đúng số lượng, cộng lại đúng bằng tổng lớn nhất). Với $N = 10^6$: bản C++ chạy khoảng 0.15 giây (phần mở rộng khoảng 0.2 giây); bản Python mất khoảng 1.7 giây – vượt giới hạn 1 giây của đề, nên bạn nào nộp Python có thể bị TLE ở test lớn, nộp C++ cho chắc nhé.
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