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

Trò Chơi Nhặt Tiền

Trò Chơi Nhặt Tiền
Nguồn: Codeforces Group Độ khó: Khó Quy hoạch động Trò chơi đối kháng

Đề bài

Hai người chơi một trò chơi với những đồng xu, luật chơi như sau:

  • Ban đầu họ có $N$ đồng xu đánh số $1, 2, \ldots, N$, đồng xu thứ $i$ có giá trị $c_i$.
  • Hai người luân phiên thực hiện lượt đi, họ nhặt các đồng xu theo thứ tự $1, 2, \ldots, N$.
  • Người đi lượt đầu tiên nhặt lấy 1 hoặc 2 đồng xu đầu tiên.
  • Người đi sau phải nhặt các đồng xu kế tiếp, số lượng ít nhất là 1, nhiều nhất gấp đôi số đồng xu người kia vừa nhặt.
  • Trò chơi kết thúc khi tất cả các đồng xu đều đã bị nhặt hết.
  • Mục tiêu của mỗi người chơi là tổng giá trị các đồng xu người đó nhặt được là lớn nhất.

Giả sử hai người đều chơi tối ưu, xác định tổng giá trị lớn nhất người đi trước có thể đạt được.

Dữ liệu:

  • Dòng 1: số nguyên $N$ $(5 \le N \le 2000)$.
  • Dòng $2 \ldots N + 1$: $N$ số nguyên $c_1, c_2, \ldots, c_N$ $(1 \le c_i \le 10^5)$.

Kết quả:

  • Dòng 1: số nguyên kết quả.

Subtask:

  • 40% số test đầu tiên có $N \le 200$.
  • 60% số test còn lại không có giới hạn gì thêm.
Ví dụ – input
5
1
3
1
7
2
Ví dụ – output
9

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 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

Đây là một bài trò chơi đối kháng kinh điển: cả hai người đều chơi tối ưu, nên ta không thể "tham lam" kiểu lượt nào cũng nhặt nhiều nhất có thể được, mà phải tính trước xem mỗi lựa chọn sẽ dẫn tới kết cục thế nào. Công cụ cho việc này chính là quy hoạch động.

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

Ví dụGiải thích ví dụ 1 3 1 7 2

Dãy đồng xu là $1, 3, 1, 7, 2$. Người chơi 1 đi trước, được nhặt 1 hoặc 2 đồng xu đầu tiên:

Trường hợp 1 – người chơi 1 lượm 2 đồng xu đầu tiên $(1, 3)$: người chơi 1 hiện tại sẽ có tổng giá trị các đồng xu là 4. Người chơi thứ 2 sau đó được quyền lựa chọn trong phạm vi 4 đồng xu ⇒ người thứ 2 sẽ lượm hết để tối ưu tổng giá trị ⇒ người thứ hai có tổng giá trị các đồng xu là $1 + 7 + 2 = 10$. Người chơi 1 chỉ được 4.

Trường hợp 2 – người chơi 1 lượm 1 đồng xu đầu tiên $(1)$: người chơi 1 hiện tại có tổng giá trị là 1. Người chơi 2 có quyền lựa chọn trong phạm vi 2 đồng xu:

  • Nếu người chơi 2 lựa chọn 2 đồng xu $(3, 1)$, thì tổng giá trị của người chơi thứ 2 sẽ là 4 và người chơi 1 lại có thể lựa chọn trong phạm vi 4 đồng xu ⇒ người chơi 1 sẽ lấy hết các đồng xu còn lại ⇒ người chơi 1 có tổng giá trị là $1 + 7 + 2 = 10$.
  • Nếu người chơi 2 lựa chọn 1 đồng xu $(3)$, tổng giá trị người thứ 2 hiện tại là 3 và người chơi 1 sau đó có quyền chọn trong phạm vi 2 đồng xu. Dễ thấy người chơi 1 nên lấy hết cả 2 đồng xu tiếp theo $(1, 7)$. Tổng giá trị hiện tại của người chơi 1 sẽ là $1 + 1 + 7 = 9$, và người chơi thứ 2 sẽ lượm đồng xu cuối cùng.

Từ hai nhánh trên, vì đang là lượt chơi của người chơi thứ 2 nên họ sẽ cố gắng tối ưu tổng giá trị đồng xu của họ nhất có thể, tương đương với việc làm tối thiểu số giá trị của người thứ 1 nhất có thể. Do đó người chơi thứ 2 sẽ chọn cách lấy 1 đồng xu, và kết quả sau cùng trường hợp này là người chơi 1 có tối đa 9.

Người 1 (max) kết quả = 9 lấy 1, 3 lấy 1 Người 2, tối đa 4 lấy hết 1, 7, 2 Người 1 = 4 Người 2 (min) tối đa 2 đồng lấy 3 lấy 3, 1 Người 1, tối đa 2 lấy 1, 7 Người 1, tối đa 4 lấy hết 7, 2 Người 2 lấy 2 Người 1 = 1+1+7 = 9 Người 1 = 10 Người 2 chọn nhánh làm Người 1 ít hơn (9 < 10); Người 1 chọn nhánh nhiều hơn (9 > 4).

Trong hai cách lựa chọn lượt đi đầu của người chơi 1 ở trên, ta thấy cách thứ 2 là đem lại tổng giá trị lớn nhất nếu cả hai cùng chơi tối ưu (9 so với 4), do đó kết quả là 9.

Ý tưởngTrạng thái f[i][j][k] và hiệu số

Ta gọi f[i][j][k] với:

  • $i$ có nghĩa là còn $n - i + 1$ đồng xu (tính cả đồng xu vị trí $i$) chưa được nhặt, tức là các đồng $i, i + 1, \ldots, n$.
  • $j$ có nghĩa là số lượng đồng xu người chơi hiện tại được phép bốc (tối đa).
  • $k$ có nghĩa là người chơi hiện tại là người đi trước ban đầu hay người đi sau ($k = 0$ là người chơi đi trước – người thứ nhất, $k = 1$ là người chơi đi sau – người thứ hai).

Giá trị f[i][j][k] là hiệu giữa tổng giá trị các đồng xu mà người thứ nhất nhặt được với tổng giá trị các đồng xu mà người thứ hai nhặt được (tính trên phần còn lại $i..n$), khi cả hai chơi tối ưu. Xét các trường hợp sau:

  • Nếu $k = 0$: người chơi thứ nhất sẽ cố gắng tối ưu hiệu này, đồng nghĩa với việc người chơi thứ nhất sẽ xem xét chọn lựa nước đi sao cho hiệu của những lượt đi sau thu được phải là lớn nhất có thể. Do đó ta có công thức truy hồi: $$f[i][j][0] = \max_{1 \le u \le j}\Big(f[i + u][\min(2u, n)][1] + sum[i][i + u - 1]\Big)$$
  • Nếu $k = 1$: tương tự nhưng thay vì lấy tối đa là lấy tối thiểu (người thứ hai muốn hiệu càng nhỏ càng tốt), và lượt sau dành cho người thứ nhất: $$f[i][j][1] = \min_{1 \le u \le j}\Big(f[i + u][\min(2u, n)][0] - sum[i][i + u - 1]\Big)$$

Với:

  • $sum[i][i + u - 1]$: tổng đoạn con từ $i$ đến $i + u - 1$. Có thể tính sẵn mảng sum này với mảng cộng dồn cơ bản.
  • $f[i + u][\min(2u, n)][1]$ có nghĩa là nếu ta chọn nhặt $u$ đồng xu tiếp theo thì sẽ đến lượt người chơi thứ hai đi với giới hạn lựa chọn là $\min(2u, n)$ (người chơi thứ hai không thể bốc quá số lượng đồng xu ban đầu).
  • Nếu $i + u = n + 1$ tức là nhặt $u$ đồng là hết sạch xu, trò chơi kết thúc: hiệu chính là $+sum[i][n]$ (người thứ nhất nhặt) hoặc $-sum[i][n]$ (người thứ hai nhặt).
f[i][j][0]còn xu i..n, được nhặt ≤ j
nhặt u đồng (1 ≤ u ≤ j)
được + sum[i][i+u−1]
f[i+u][min(2u, n)][1]lượt người thứ hai, được nhặt ≤ 2u
Từ hiệu số ra đáp án
Ban đầu người thứ nhất đi, còn đủ $n$ đồng xu, được nhặt tối đa 2 đồng ⇒ hiệu cuối cùng là $D = f[1][2][0] = A - B$. Mà $A + B = sum[1][n]$ (tổng tất cả các đồng xu) ⇒ $A = \dfrac{f[1][2][0] + sum[1][n]}{2}$. Đây chính là dòng return trong code.

Vì sao lại lưu hiệu mà không lưu thẳng tổng của từng người? Vì với hiệu thì cả 2 người cùng "kéo co" trên 1 con số: người thứ nhất kéo lên (max), người thứ hai kéo xuống (min). Một con số duy nhất là đủ mô tả kết cục của mỗi trạng thái.

Thứ tự tính: f[i][..][..] cần f[i + u][..][..] với $u \ge 1$ nên ta cho $i$ chạy từ $n$ về $1$.

Em nhập dãy đồng xu rồi bấm Tiếp để xem hai người chơi tối ưu: ở mỗi lượt, người chơi tra bảng f để biết mỗi lựa chọn $u$ dẫn tới hiệu $A - B$ cuối cùng bằng bao nhiêu, rồi chọn cái tốt nhất cho mình:

Ví dụ nhanh:

Subtask 1Duyệt mọi lựa chọn u – O(N³)

Đến đây, ta có hai cách xử lý. Cách trâu: với mỗi trạng thái f[i][j][k] ta duyệt qua tất cả những lựa chọn $u$ (trong code là biến k của vòng lặp trong cùng) có thể xảy ra. Có $O(N^2)$ trạng thái, mỗi trạng thái duyệt $O(N)$ lựa chọn ⇒ $O(N^3)$, chỉ đủ cho subtask 1 ($N \le 200$).

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

int n, a[2005];

int f[2005][2005][2], sum[2005][2005];
int solve()
{
    for (int i = 1; i <= n; i ++)
    {
        sum[i][i] = a[i];
        for (int j = i + 1; j <= n; j ++)
        {
            sum[i][j] = sum[i][j - 1] + a[j];
        }
    }
    for (int i = n; i >= 1; i --)
    {
        for (int j = 1; j <= n; j ++)
        {
            // f[i][j][k] neu gioi han la j
            // Player 1
            f[i][j][0] = -1e18;
            for (int k = 1; k <= j; k ++)
            {
                if (i + k == (n + 1))
                {
                    f[i][j][0] = max(f[i][j][0], sum[i][n]);
                }
                else f[i][j][0] = max(f[i + k][min(n, k * 2)][1] + sum[i][i + k - 1], f[i][j][0]);
            }
            // Player 2
            f[i][j][1] = 1e18;
            for (int k = 1; k <= j; k ++)
            {
                if (i + k == (n + 1)) f[i][j][1] = min(f[i][j][1], -sum[i][n]);
                else f[i][j][1] = min(f[i + k][min(n, k * 2)][0] - sum[i][i + k - 1], f[i][j][1]);
            }
        }
    }
    return (f[1][2][0] + sum[1][n]) / 2;
}

signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i ++)
    {
        cin >> a[i];
    }
    cout << solve() << endl;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = [0] + [int(x) for x in data[1:n + 1]]
    # pre[i] = a[1] + ... + a[i]  ->  sum[i][j] = pre[j] - pre[i - 1]
    pre = [0] * (n + 1)
    for i in range(1, n + 1):
        pre[i] = pre[i - 1] + a[i]
    INF = 10 ** 18
    # f0[i][j]: lượt người thứ nhất, f1[i][j]: lượt người thứ hai
    f0 = [[0] * (n + 1) for _ in range(n + 2)]
    f1 = [[0] * (n + 1) for _ in range(n + 2)]
    for i in range(n, 0, -1):
        for j in range(1, n + 1):
            best0, best1 = -INF, INF
            for k in range(1, j + 1):
                s = pre[min(i + k - 1, n)] - pre[i - 1]
                if i + k >= n + 1:
                    s = pre[n] - pre[i - 1]
                    best0 = max(best0, s)
                    best1 = min(best1, -s)
                    break  # k lớn hơn nữa cũng là lấy hết
                nk = min(n, k * 2)
                best0 = max(best0, f1[i + k][nk] + s)
                best1 = min(best1, f0[i + k][nk] - s)
            f0[i][j] = best0
            f1[i][j] = best1
    print((f0[1][2] + pre[n]) // 2)

main()
C++▶ Chạy tay từng dòng codeSubtask 1: với mỗi f[i][j][k] duyệt mọi số đồng nhặt k = 1..j, O(N³).
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, a[2005];
6
7int f[2005][2005][2], sum[2005][2005];
8int solve()
9{
10 for (int i = 1; i <= n; i ++)
11 {
12 sum[i][i] = a[i];
13 for (int j = i + 1; j <= n; j ++)
14 {
15 sum[i][j] = sum[i][j - 1] + a[j];
16 }
17 }
18 for (int i = n; i >= 1; i --)
19 {
20 for (int j = 1; j <= n; j ++)
21 {
22 // f[i][j][k] neu gioi han la j
23 // Player 1
24 f[i][j][0] = -1e18;
25 for (int k = 1; k <= j; k ++)
26 {
27 if (i + k == (n + 1))
28 {
29 f[i][j][0] = max(f[i][j][0], sum[i][n]);
30 }
31 else f[i][j][0] = max(f[i + k][min(n, k * 2)][1] + sum[i][i + k - 1], f[i][j][0]);
32 }
33 // Player 2
34 f[i][j][1] = 1e18;
35 for (int k = 1; k <= j; k ++)
36 {
37 if (i + k == (n + 1)) f[i][j][1] = min(f[i][j][1], -sum[i][n]);
38 else f[i][j][1] = min(f[i + k][min(n, k * 2)][0] - sum[i][i + k - 1], f[i][j][1]);
39 }
40 }
41 }
42 return (f[1][2][0] + sum[1][n]) / 2;
43}
44
45signed main()
46{
47 ios::sync_with_stdio(0);
48 cin.tie(0);
49 cin >> n;
50 for (int i = 1; i <= n; i ++)
51 {
52 cin >> a[i];
53 }
54 cout << solve() << endl;
55}

Lưu ý nhỏ: khi $i + k > n + 1$ (nhặt nhiều hơn số xu còn lại) thì code vẫn nhảy vào nhánh else, nhưng các phần tử mảng toàn cục ngoài phạm vi đều bằng 0 nên giá trị thu được là 0, không bao giờ tốt hơn lựa chọn "nhặt hết" (vì $c_i \ge 1$), nên kết quả vẫn đúng.

Không gian$O(N^2)$
Thời gian$O(N^3)$

Subtask 2Tận dụng f[i][j − 1] – O(N²)

Thay vì cứ mỗi lần ta duyệt qua những lựa chọn $u$, ta có thể thấy nếu đang cần tính f[i][j][0] thì ta đã tính được f[i][j - 1][0]. Sự khác biệt giữa f[i][j][0] và f[i][j - 1][0] chỉ khác ở chỗ là f[i][j][0] có thêm đúng một lựa chọn là $u = j$. Khi đấy, ta có thể tối ưu công thức truy hồi như sau:

$$f[i][j][0] = \max\Big(f[i][j - 1][0],\ f[i + j][\min(2j, n)][1] + sum[i][i + j - 1]\Big)$$ $$f[i][j][1] = \min\Big(f[i][j - 1][1],\ f[i + j][\min(2j, n)][0] - sum[i][i + j - 1]\Big)$$
f[i][j − 1] đã xét
u = 1u = 2…u = j − 1u = j
f[i][j] = các lựa chọn của f[i][j − 1] (xám, đã có max/min sẵn) + đúng 1 lựa chọn mới u = j (cam)

Còn nếu $i + j > n$ thì được phép nhặt hết toàn bộ các đồng xu còn lại, và vì các đồng xu đều có giá trị dương nên nhặt hết luôn là tốt nhất: f[i][j][0] = sum[i][n], f[i][j][1] = -sum[i][n]. Mỗi trạng thái giờ chỉ mất $O(1)$ ⇒ tổng $O(N^2)$.

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

int n, a[2005];
int f[2005][2005][2], sum[2005][2005], mx[2005], mn[2005];

int solve()
{
    for (int i = 1; i <= n; i ++)
    {
        sum[i][i] = a[i];
        for (int j = i + 1; j <= n; j ++)
        {
            sum[i][j] = sum[i][j - 1] + a[j];
        }
    }
    for (int i = n; i >= 1; i --)
    {
        for (int j = 1; j <= n; j ++)
        {
            f[i][j][0] = - 1e18;
            f[i][j][1] = 1e18;
            if (j != 1)
            {
                f[i][j][0] = f[i][j - 1][0];
                f[i][j][1] = f[i][j - 1][1];
            }
            if ((i + j) > n)
            {
                f[i][j][0] = sum[i][n];
                f[i][j][1] = -sum[i][n];
            }
            else
            {
                f[i][j][0] = max(f[i][j][0], f[i + j][min(n, j * 2)][1] + sum[i][i + j - 1]);
                f[i][j][1] = min(f[i][j][1], f[i + j][min(n, j * 2)][0] - sum[i][i + j - 1]);
            }
        }
    }
    return (f[1][2][0] + sum[1][n]) / 2;
}

signed main()
{
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n;
    for (int i = 1; i <= n; i ++)
    {
        cin >> a[i];
    }
    cout << solve() << endl;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = [0] + [int(x) for x in data[1:n + 1]]
    pre = [0] * (n + 1)
    for i in range(1, n + 1):
        pre[i] = pre[i - 1] + a[i]
    INF = 10 ** 18
    f0 = [[0] * (n + 1) for _ in range(n + 2)]
    f1 = [[0] * (n + 1) for _ in range(n + 2)]
    for i in range(n, 0, -1):
        r0, r1 = f0[i], f1[i]
        cur0, cur1 = -INF, INF          # f[i][j - 1][0], f[i][j - 1][1]
        rest = pre[n] - pre[i - 1]      # sum[i][n]
        for j in range(1, n + 1):
            if i + j > n:
                cur0, cur1 = rest, -rest  # lấy hết các đồng xu còn lại
            else:
                s = pre[i + j - 1] - pre[i - 1]
                nj = min(n, j * 2)
                v0 = f1[i + j][nj] + s
                v1 = f0[i + j][nj] - s
                if v0 > cur0: cur0 = v0
                if v1 < cur1: cur1 = v1
            r0[j] = cur0
            r1[j] = cur1
    print((f0[1][2] + pre[n]) // 2)

main()
C++▶ Chạy tay từng dòng codeSubtask 2: f[i][j] kế thừa f[i][j − 1] và chỉ xét thêm lựa chọn u = j, O(N²).
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define int long long
3using namespace std;
4
5int n, a[2005];
6int f[2005][2005][2], sum[2005][2005], mx[2005], mn[2005];
7
8int solve()
9{
10 for (int i = 1; i <= n; i ++)
11 {
12 sum[i][i] = a[i];
13 for (int j = i + 1; j <= n; j ++)
14 {
15 sum[i][j] = sum[i][j - 1] + a[j];
16 }
17 }
18 for (int i = n; i >= 1; i --)
19 {
20 for (int j = 1; j <= n; j ++)
21 {
22 f[i][j][0] = - 1e18;
23 f[i][j][1] = 1e18;
24 if (j != 1)
25 {
26 f[i][j][0] = f[i][j - 1][0];
27 f[i][j][1] = f[i][j - 1][1];
28 }
29 if ((i + j) > n)
30 {
31 f[i][j][0] = sum[i][n];
32 f[i][j][1] = -sum[i][n];
33 }
34 else
35 {
36 f[i][j][0] = max(f[i][j][0], f[i + j][min(n, j * 2)][1] + sum[i][i + j - 1]);
37 f[i][j][1] = min(f[i][j][1], f[i + j][min(n, j * 2)][0] - sum[i][i + j - 1]);
38 }
39 }
40 }
41 return (f[1][2][0] + sum[1][n]) / 2;
42}
43
44signed main()
45{
46 ios::sync_with_stdio(0);
47 cin.tie(0);
48 cin >> n;
49 for (int i = 1; i <= n; i ++)
50 {
51 cin >> a[i];
52 }
53 cout << solve() << endl;
54}
Không gian$O(N^2)$
Thời gian$O(N^2)$

Với $N = 2000$ thì có $2000 \times 2000 \times 2 = 8 \cdot 10^6$ trạng thái, mỗi trạng thái $O(1)$, chạy rất nhanh. Bộ nhớ: mảng f và sum kiểu long long khoảng $96$ MB; bản Python (mình viết thêm) dùng mảng cộng dồn 1 chiều thay cho sum[i][j] nên nhẹ hơn.

Ghi chú của mình: code C++ của 2 subtask là của mình, bản Python của cả 2 cách là mình viết thêm. Tất cả đã được kiểm tra với 250 bộ test ngẫu nhiên ($5 \le N \le 12$, giá trị nhỏ lẫn lớn) so với cách vét cạn đệ quy minimax trên cây trò chơi: khớp hết. Thời gian: Subtask 1 với $N = 200$ – C++ 0.02 giây, Python khoảng 1.2 giây; Subtask 2 với $N = 2000$ – C++ khoảng 0.18 giây, Python khoảng 2.6 giây (Python chậm hơn nhiều, nên nộp C++ cho chắc).
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 😇