Chương 2·Bài 28

Interactive Problems

Hãy nghĩ tới trò đoán số: bạn nghĩ một số từ 1 đến 100, ta hỏi “lớn hơn 50 không?”, rồi cứ thế chia đôi. Bài tương tác cũng vậy: dữ liệu không nằm sẵn trong input mà nằm ở một chương trình của ban tổ chức, gọi là trình tương tác (interactor).

Lời giải gửi câu hỏi theo một giao thức cố định, đọc câu trả lời, và cuối cùng in kết luận. Cái khó thường không nằm ở thuật toán, mà ở hai chỗ: mỗi câu hỏi phải lấy được đủ thông tin để không vượt ngân sách câu hỏi, và chương trình phải tuân thủ giao thức đến từng dòng.

Hỏi và đáp

Lời giải và trình tương tác nối với nhau bằng hai đường ống: những gì lời giải in ra là input của trình tương tác và ngược lại. Một phiên thường gồm:

  • Đọc dữ liệu công khai, chẳng hạn nn.
  • Lặp: in một dòng câu hỏi đúng định dạng, đẩy bộ đệm, đọc câu trả lời.
  • In dòng kết luận, đẩy bộ đệm, kết thúc.

Đẩy bộ đệm (flush) là bắt buộc: cout gom dữ liệu trong bộ nhớ và chỉ thật sự gửi khi đầy; câu hỏi còn kẹt trong bộ đệm thì trình tương tác không thấy, còn lời giải đứng chờ câu trả lời, hai bên chờ nhau tới hết giờ (thường báo Idleness Limit Exceeded hoặc Time Limit Exceeded). endl vừa xuống dòng vừa đẩy bộ đệm, nên dùng endl cho mọi dòng gửi đi. ios::sync_with_stdio(false) và cin.tie(0) vẫn dùng được, miễn là đẩy bộ đệm đúng lúc.

Đẩy bộ đệm mỗi dòng có giá của nó. Với bài thường, không tương tác, đừng dùng endl:

In 300 nghìn dòng ra ống, trung vị 5 lần chạy, i7-10850H, g++ -O2.'\n', đẩy bộ đệm một lần56 msendl mỗi dòng1373 ms · gấp 24 lần
Ghi 300 nghìn dòng ra tệp, đo trên i7-10850H, g++ -O2.

endl chậm hơn khoảng 24 lần. Chỉ bài tương tác mới cần đẩy bộ đệm sau mỗi câu hỏi; mọi chỗ khác dùng '\n'.

Khi câu hỏi sai hoặc vượt ngân sách, trình tương tác thường trả −1-1 rồi dừng. Lời giải phải thoát ngay, nếu cứ đọc tiếp có thể nhận một lỗi khó hiểu thay vì Wrong Answer. Gói việc hỏi vào một hàm ask để mọi câu hỏi cùng đi qua một chỗ.

Giao thức tương tác đi theo hai chiều: lời giải hỏi, interactor trả lời, rồi lời giải suy luận.Lời giải? i jInteractora[i]+a[j]Lời giảicập nhật alặp lại tới khi đủ thông tin; flush trước khi chờ phản hồi
Lời giải gửi câu hỏi và đẩy bộ đệm, trình tương tác trả lời, lời giải cập nhật điều đã biết.

Tự kiểm thử. Đoạn hội thoại mẫu trong đề chỉ là một phiên; không thể dán nó vào input như bài thường. Viết một trình tương tác nhỏ (Python là đủ): sinh trạng thái bí mật ngẫu nhiên, đọc từng dòng của lời giải, kiểm định dạng và ngân sách, trả lời, rồi so kết luận. Chạy vài trăm phiên ngẫu nhiên và vài phiên biên (nn nhỏ nhất, phần tử cần tìm ở hai đầu).

Hỏi nhanh

Một lời giải dùng cout << "? " << i << ' ' << j << '\n'; rồi đọc ngay câu trả lời, không có endl hay flush. Chuyện gì dễ xảy ra nhất?

Ví dụDãy Sau Những Tổng Đôi

Đề. Dãy ẩn nn số nguyên dương, 3≤n≤50003 \le n \le 5000. Câu hỏi ? i j nhận ai+aja_i + a_j; được hỏi không quá nn câu, rồi in ! và cả dãy.

Lời giải. Ba câu a1+a2a_1 + a_2, a2+a3a_2 + a_3, a1+a3a_1 + a_3 cho ba phương trình ba ẩn: a1=(a1+a2)+(a1+a3)−(a2+a3)2a_1 = \frac{(a_1 + a_2) + (a_1 + a_3) - (a_2 + a_3)}{2}, rồi suy ra a2,a3a_2, a_3. Mỗi aia_i với i≥4i \ge 4 tốn đúng một câu ai−1+aia_{i-1} + a_i. Tổng 3+(n−3)=n3 + (n - 3) = n câu, đúng ngân sách. Hỏi các cặp (1,i)(1, i) cũng được; điều cần là mỗi câu thêm đúng một ẩn mới.

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

ll n, a[5005];

ll ask(ll i, ll j) {
    cout << "? " << i << ' ' << j << endl;
    ll x = -1;
    cin >> x;
    if (x == -1) exit(0);
    return x;
}

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

    cin >> n;
    ll s12 = ask(1, 2), s23 = ask(2, 3), s13 = ask(1, 3);
    a[1] = (s12 + s13 - s23) / 2;
    a[2] = s12 - a[1];
    a[3] = s13 - a[1];
    for (ll i = 4; i <= n; i++) a[i] = ask(i - 1, i) - a[i - 1];
    cout << '!';
    for (ll i = 1; i <= n; i++) cout << ' ' << a[i];
    cout << endl;
}

Đếm khả năng

Trước khi tìm cách hỏi, đếm xem cần phân biệt bao nhiêu khả năng. Câu hỏi có cc kiểu trả lời thì qq câu phân biệt được tối đa cqc^q khả năng. Cần phân biệt KK khả năng thì phải hỏi ít nhất ⌈log⁡cK⌉\lceil \log_c K \rceil câu.

Câu hỏi có/không (c=2c = 2) cho vị trí trong n=2⋅105n = 2 \cdot 10^5 ô cần ít nhất 18 câu, nên ngân sách 20 câu gợi ý tìm kiếm nhị phân. Ngân sách nn câu gợi ý mỗi câu chốt một phần tử.

Không phải mọi bài cần chốt hẳn trạng thái. Bài chỉ hỏi một tính chất (nguyên tố hay hợp số, chẵn hay lẻ) thì chỉ cần câu hỏi tách được hai nhóm khả năng, và nhiều khi một danh sách câu hỏi cố định, hỏi mù không cần nhìn câu trả lời trước, là đủ. Khi đó việc chính là chứng minh danh sách ấy tách được mọi trường hợp.

Ví dụPhân Loại Số Bí Mật

Đề. Số bí mật xx trong [2,100][2, 100]. Câu hỏi là một số q∈[2,100]q \in [2, 100], nhận yes nếu qq chia hết xx. Hỏi không quá 20 câu rồi in prime hoặc composite.

Lời giải. Hỏi 15 số nguyên tố không quá 47 và bốn bình phương 4,9,25,494, 9, 25, 49, tổng 19 câu; đếm số câu yes. Nếu xx là hợp số, viết x=pqx = pq với p≤qp \le q là hai thừa số, pp nguyên tố. Khi q≠pq \ne p có một ước nguyên tố khác pp không quá 5050, tức không quá 47, nên ít nhất hai số nguyên tố trong danh sách chia hết xx. Khi xx chỉ có một ước nguyên tố pp thì p2∣xp^2 \mid x và p2≤100p^2 \le 100, nên p≤7p \le 7 và cả pp lẫn p2p^2 đều trả yes. Nếu xx nguyên tố thì chỉ có thể chính xx trả yes. Vậy hợp số khi và chỉ khi có ít nhất hai câu yes; đủ hai thì dừng sớm.

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

ll q[] = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 4, 9, 25, 49};

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

    ll cnt = 0;
    for (ll x : q) {
        cout << x << endl;
        string s;
        cin >> s;
        if (s == "yes") cnt++;
        if (cnt >= 2) break;
    }
    cout << (cnt >= 2 ? "composite" : "prime") << endl;
}

Chặt nhị phân

Một câu hỏi có/không đơn điệu theo vị trí (sai, sai, …, đúng, đúng) là khuôn của tìm kiếm nhị phân (bài 05): mỗi câu loại một nửa số khả năng, ⌈log⁡2n⌉\lceil \log_2 n \rceil câu là đủ. Việc cần làm là biến câu trả lời thành điều kiện đơn điệu. Câu hỏi tổng đoạn cho số bit 1 trên tiền tố, nên số bit 0 trên tiền tố [1,m][1, m] là mm trừ tổng, tăng theo mm. Câu hỏi “phần tử lớn thứ hai trong đoạn” cho biết phần tử lớn nhất nằm cùng phía với vị trí đó hay không. Mỗi câu chỉ cần thêm một bit thông tin đúng chỗ.

Ví dụSố Không Thứ K

Đề. Mảng nhị phân ẩn dài n≤2⋅105n \le 2 \cdot 10^5; đọc nn, t=1t = 1, rồi kk. Câu hỏi ? l r nhận tổng các bit trên [l,r][l, r]; hỏi không quá 20 câu rồi in ! x, vị trí của số 0 thứ kk.

Lời giải. Số 0 trong [1,m][1, m] là m−sum(1,m)m - \mathrm{sum}(1, m), không giảm theo mm. Vị trí cần tìm là mm nhỏ nhất có ít nhất kk số 0 trong [1,m][1, m]: tìm kiếm nhị phân trên mm, mỗi bước hỏi ? 1 m. Có ⌈log⁡2(2⋅105)⌉=18\lceil \log_2 (2 \cdot 10^5) \rceil = 18 câu.

Mảng bị giấu; bên dưới là cuộc hỏi đáp giữa lời giải và trình tương tác.
C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll n, t, k;

ll ask(ll l, ll r) {
    cout << "? " << l << ' ' << r << endl;
    ll x = -1;
    cin >> x;
    if (x == -1) exit(0);
    return x;
}

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

    cin >> n >> t >> k;
    ll l = 1, r = n, res = n;
    while (l <= r) {
        ll mid = (l + r) >> 1;
        if (mid - ask(1, mid) >= k) r = (res = mid) - 1;
        else l = mid + 1;
    }
    cout << "! " << res << endl;
}

Ví dụVị Trí Lớn Nhất

Đề. Mảng ẩn n≤105n \le 10^5 số khác nhau. Câu hỏi ? l r với l<rl < r nhận chỉ số của phần tử lớn thứ hai trong [l,r][l, r]. Tìm chỉ số phần tử lớn nhất với không quá 40 câu.

Lời giải. Hỏi cả mảng được ss, vị trí lớn thứ hai. Một đoạn chứa ss trả lời đúng ss khi và chỉ khi nó cũng chứa phần tử lớn nhất pp. Hỏi ? 1 s (khi s>1s > 1) để biết pp nằm bên trái hay bên phải ss. Bên trái thì ? m s trả ss khi và chỉ khi m≤pm \le p: tìm mm lớn nhất như vậy. Bên phải thì ? s m trả ss khi và chỉ khi m≥pm \ge p: tìm mm nhỏ nhất. Tổng 2+17=192 + 17 = 19 câu.

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

ll n;

ll ask(ll l, ll r) {
    cout << "? " << l << ' ' << r << endl;
    ll x = -1;
    cin >> x;
    if (x == -1) exit(0);
    return x;
}

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

    cin >> n;
    ll s = ask(1, n), res;
    if (s > 1 && ask(1, s) == s) {
        ll l = 1, r = s - 1;
        res = 1;
        while (l <= r) {
            ll mid = (l + r) >> 1;
            if (ask(mid, s) == s) l = (res = mid) + 1;
            else r = mid - 1;
        }
    } else {
        ll l = s + 1, r = n;
        res = n;
        while (l <= r) {
            ll mid = (l + r) >> 1;
            if (ask(s, mid) == s) r = (res = mid) - 1;
            else l = mid + 1;
        }
    }
    cout << "! " << res << endl;
}

Hỏi theo cặp

Khi ngân sách là cỡ 2n2n câu, hãy tìm một cặp câu hỏi mà dù trả lời thế nào cũng chốt chắc một phần tử. Ý quen thuộc là hỏi một quan hệ không đối xứng theo cả hai chiều. Với phép lấy dư trên hai số dương khác nhau u<vu < v: u mod v=uu \bmod v = u, còn v mod u<uv \bmod u < u. Vậy hỏi cả hai chiều, kết quả lớn hơn chính là số nhỏ hơn trong hai số. Duy trì một “ứng viên lớn nhất”, so với từng phần tử mới: số nhỏ hơn của cặp được chốt, số lớn hơn thành ứng viên mới. Ứng viên còn lại cuối cùng là giá trị lớn nhất, suy ra không cần hỏi.

Hàng trên là hoán vị bị giấu; hàng dưới là những gì lời giải đã chốt. Ô cam là hai vị trí đang được hỏi.

Hỏi nhanh

Hoán vị ẩn, hai vị trí i,ji, j có pi mod pj=3p_i \bmod p_j = 3 và pj mod pi=7p_j \bmod p_i = 7. Ta biết gì?

Ví dụHoán Vị Qua Phần Dư

Đề. Hoán vị ẩn pp của 1..n1..n, n≤104n \le 10^4. Câu hỏi ? i j nhận pi mod pjp_i \bmod p_j. Hỏi không quá 2n2n câu rồi in cả hoán vị.

Lời giải. Giữ chỉ số ứng viên mx, ban đầu là 1. Với mỗi j=2..nj = 2..n: hỏi x=pmx mod pjx = p_{mx} \bmod p_j và y=pj mod pmxy = p_j \bmod p_{mx}. Nếu x>yx > y thì pmx=xp_{mx} = x và jj thành ứng viên mới; ngược lại pj=yp_j = y. Cuối cùng pmx=np_{mx} = n. Tổng 2(n−1)2(n - 1) câu; n=1n = 1 thì in luôn.

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

ll n, p[10005];

ll ask(ll i, ll j) {
    cout << "? " << i << ' ' << j << endl;
    ll x = -1;
    cin >> x;
    if (x == -1) exit(0);
    return x;
}

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

    cin >> n;
    ll mx = 1;
    for (ll j = 2; j <= n; j++) {
        ll x = ask(mx, j), y = ask(j, mx);
        if (x > y) {
            p[mx] = x;
            mx = j;
        } else p[j] = y;
    }
    p[mx] = n;
    cout << '!';
    for (ll i = 1; i <= n; i++) cout << ' ' << p[i];
    cout << endl;
}

Khi đi thi

  • Đọc kỹ giao thức: định dạng câu hỏi, câu trả lời, dòng kết luận có tính vào ngân sách không, có nhiều test trong một phiên không. Mọi dòng gửi đi dùng endl; gặp −1-1 thì thoát ngay.
  • Ngân sách gợi ý thuật toán: log⁡2n\log_2 n là tìm kiếm nhị phân, nn là mỗi câu một ẩn, 2n2n là hỏi theo cặp, n+cn + c là tìm một mốc rồi suy ra phần còn lại.
  • Viết một trình tương tác ngẫu nhiên nhỏ và chạy nhiều phiên trước khi nộp; kiểm cả số câu hỏi ở trường hợp xấu nhất, không chỉ kết quả.

Tóm tắt

  • Lời giải và trình tương tác nói chuyện qua hai đường ống; mỗi dòng gửi đi phải được đẩy khỏi bộ đệm.
  • qq câu hỏi có cc kiểu trả lời tách được tối đa cqc^q khả năng, cho cận dưới số câu cần hỏi.
  • Câu trả lời biến được thành điều kiện đơn điệu thì tìm kiếm nhị phân, ⌈log⁡2n⌉\lceil \log_2 n \rceil câu.
  • Hỏi một quan hệ không đối xứng theo hai chiều để mỗi cặp câu chốt chắc một phần tử.

Bài tập

Đọc thêm