+3

Chia căn (phần 2) - Mo's algorithm

Đây là bài viết số 22 thuộc series Chia căn, thuộc danh sách bài viết về Cấu trúc dữ liệu nâng cao và Các kĩ thuật tối ưu hóa. Trước khi đọc bài viết này, các bạn cần nắm vững cơ bản về kĩ thuật Chia căn. Các bạn có thể xem lại bài viết phần 11 về Chia căn tại đây.

I. Giới thiệu chung

Ở bài viết phần 1,1, chúng ta đã cùng thảo luận về kĩ thuật Chia căn cơ bản - là kĩ thuật rất hiệu quả để giải quyết các bài toán truy vấn và cập nhật đoạn với input không quá lớn (khoảng n105n \le 10^5). Chia căn còn có những ứng dụng to lớn hơn nữa, cộng thêm việc cài đặt đơn giản, khiến cho nó trở thành một kĩ thuật vô cùng hữu ích trong phòng thi.

Tiếp nối các ứng dụng của Chia căn, trong bài viết này chúng ta sẽ cùng bàn về kĩ thuật tăng tốc độ trả lời các truy vấn bằng cách sắp xếp chúng theo một thứ tự nhất định, còn gọi là thuật toán Mo's (Mo's Algorithm).

Để làm rõ ý tưởng thuật toán, ta sẽ cùng xem xét bài toán minh họa sau:

Cho dãy số AA gồm nn phần tử a1,a2,,ana_1, a_2, \dots, a_n. Với một đoạn con [l,r][l, r] của dãy, ta định nghĩa Mode(l,r)Mode(l, r) là giá trị xuất hiện nhiều nhất trong đoạn con đó.

Yêu cầu: Với mỗi truy vấn (l,r)(l, r) hãy xác định Mode(l,r)?Mode(l, r)?

Input:

  • Dòng đầu tiên chứa hai số nguyên dương nnqq - độ dài dãy số và số lượng truy vấn.
  • Dòng thứ hai chứa nn số nguyên dương a1,a2,,ana_1, a_2, \dots, a_n phân tách nhau bởi dấu cách.
  • Trên qq dòng tiếp theo, mỗi dòng chứa hai số nguyên dương l,rl, r thể hiện một truy vấn.

Ràng buộc:

  • 1n,q1051 \le n, q \le 10^5.
  • 1ai105;i:1in1 \le a_i \le 10^5; \forall i: 1 \le i \le n.

Output:

  • Với mỗi truy vấn, in ra kết quả trên một dòng. Nếu có nhiều giá trị thỏa mãn thì chọn giá trị nhỏ nhất.

Sample Input:

6 3
1 1 5 3 3 6
1 3
1 5
2 5

Sample Output:

1
1
3

II. Ý tưởng

Đối với bài toán trên, có thể các bạn sẽ nghĩ ngay tới Segment Tree. Tuy nhiên, nếu nhìn nhận thật kĩ thì ta thấy rằng: Khi có thông tin của hai nút con kiểm soát đoạn [l,mid][l, mid][mid+1,r];[mid + 1, r]; ta hoàn toàn không tạo ra được thông tin hữu ích nào cho đoạn [l,r][l, r].

Cùng xem thuật toán Mo's sẽ hoạt động hiệu quả như thế nào cho bài toán này.

1. Thuật toán ngây thơ

Cách dễ nhất là vận dụng kĩ thuật Đếm phân phối để giải quyết các truy vấn. Gọi cnt[x]cnt[x] là số lần xuất hiện của phần tử x;x; với mỗi truy vấn, ta sẽ tính mảng cnt[x]cnt[x] với tất cả các giá trị xx thuộc đoạn [l,r][l, r]:

int maxv = *max_element(a.begin() + 1, a.end());

int query(int l, int r, int maxv)
{
    int res = -1;
    vector < int > cnt(maxv + 1);
    for (int i = l; i <= r; ++i)
    {
        ++cnt[a[i]];
        
        if (res == -1 || cnt[a[i]] > cnt[res])
            res = a[i];
    }
    
    return res;
}

Thuật toán trên có độ phức tạp lên tới O(n×q),O(n \times q), do hàm query(l, r, maxv) đã mất độ phức tạp lên tới O(n)O(n). Ta có thể cải tiến một chút vận dụng cấu trúc dữ liệu set như sau:

  • Khi chuyển từ truy vấn [l1,r1][l_1, r_1] sang truy vấn [l2,r2][l_2, r_2] thì ta thay đổi lại mảng cntcnt một cách phù hợp:

    • Nếu l2>l1l_2 > l_1 thì ta giảm số lần xuất hiện của các giá trị al1,al1+1,...,al21a_{l_1}, a_{l_1 + 1},..., a_{l_2 - 1}.
    • Nếu l2<l1l_2 < l_1 thì ta tăng số lần xuất hiện của các giá trị al2,al2+1,...,al11a_{l_2}, a_{l_2 + 1},..., a_{l_1 - 1}.
    • Xử lý tương tự với r1r_1r2r_2.
  • Để tìm được phần tử nhỏ nhất có số lần xuất hiện nhiều nhất, ta vận dụng thêm cấu trúc dữ liệu set với kiểu pair < int, int >:

    • Giả sử phần tử xx ở truy vấn [l1,r1][l_1, r_1]cnt[x]=v1cnt[x] = v_1 và ở truy vấn [l2,r2][l_2, r_2]cnt[x]=v2cnt[x] = v_2.
    • Ta sẽ xóa cặp giá trị (x,v1)(x, v_1) khỏi set và thêm cặp giá trị (x,v2)(x, v_2) vào set (viết lại hàm so sánh sắp xếp tăng các phần tử theo trường first, nếu first bằng nhau thì sắp tăng theo second).
    • Phần tử xuất hiện nhiều nhất trong truy vấn [l2,r2][l_2, r_2] sẽ là phần tử ở đầu set. Do sử dụng thêm set nên bước này tốn thêm O(logn)O(\log n).
  • Sau khi cải tiến, thuật toán sẽ có độ phức tạp là O(logn×i=1q(lili1+riri1))O\Big(\log n \times \sum_{i = 1}^q \big(|l_i - l_{i - 1}| + |r_i - r_{i - 1}|\big)\Big). Rõ ràng thuật toán này không thể đáp ứng ràng buộc của bài toán.

2. Thuật toán Mo's

Sắp xếp lại các truy vấn

Ý tưởng của thuật toán Mo's trong các bài toán trả lời truy vấn đoạn là sắp xếp lại các truy vấn [li,ri][l_i, r_i] sao cho tổng i=1qlili1+riri1O(n×n+q×n)\sum_{i = 1}^q|l_i - l_{i - 1}| + |r_i - r_{i - 1}| \le O\big(n \times \sqrt{n} + q \times \sqrt{n}\big). Hàm so sánh dưới đây sẽ thực hiện điều đó:

int block_size = ceil(sqrt(n));

bool cmp(Query a, Query b)
{
    if (a.l / block_size != b.l / block_size)
        return a.l / block_size < b.l / block_size;
    
    return a.r < b.r;
}

Cách sắp xếp này bản chất là vận dụng kĩ thuật Chia căn:

  • Chia dãy số AA thành các khối có độ dài block_size=n\text{block\_size} = \left\lceil\sqrt{n}\right\rceil.
  • Nếu đầu trái của hai truy vấn aabb nằm ở hai khối khác nhau thì ta sắp xếp tăng dần theo đầu trái.
  • Ngược lại (đầu trái của hai truy vấn thuộc cùng một khối) thì ta sắp xếp tăng dần theo đầu phải.
  • Thực tế, để tìm ra chỉ số khối của một vị trí p,p, ta dùng công thức p+block_size1block_size\left\lfloor\frac{p + \text{block\_size} - 1}{\text{block\_size}}\right\rfloor (các khối đánh số từ 11). Tuy nhiên ở đây ta chỉ cần so sánh hai đầu mút của hai truy vấn có thuộc cùng khối không nên bỏ hằng số block_size1\text{block\_size} - 1 đi vẫn cho ra kết quả đúng.

Tuy nhiên, do các truy vấn trong thuật toán Mo's bị sắp xếp lại, nên ta chỉ có thể áp dụng thuật toán này khi bài toán có thể xử lý offline, nghĩa là các truy vấn có thể được thực hiện lần lượt rồi mới in ra kết quả ở cuối.

Áp dụng vào việc chuyển truy vấn

Ta xây dựng mảng s[v]s\big[v\big] là một mảng gồm nn set, set thứ vv lưu các giá trị xx có tần suất cùng là vv (tức là cnt[x]=v;v:0vncnt[x] = v; \forall v: 0 \le v \le n). Khi chuyển truy vấn từ [l1,r1][l_1, r_1] sang [l2,r2][l_2, r_2], sẽ có những cnt[x]cnt[x] bị thay đổi (giảm đi hoặc tăng lên), đồng nghĩa với việc các s[v]s[v] cũng sẽ bị thay đổi theo - do sẽ có những phần tử mới được thêm vào truy vấn [l2,r2][l_2, r_2] nhưng cũng có những phần tử bị mất đi.

Gọi mode_cnt\text{mode\_cnt} là chỉ số lớn nhất của mảng ss thỏa mãn s[mode_cnt]s[\text{mode\_cnt}] \ne \emptyset - đây cũng chính là số lần xuất hiện của phần tử xuất hiện nhiều nhất. Ta sẽ thiết kế hai thao tác thêm và xóa một phần tử xx khỏi truy vấn như sau:

  • Thêm một số xx:
    • Xóa xx khỏi s[cnt[x]]s\big[cnt[x]\big].
    • Tăng cnt[x]cnt[x] thêm 11.
    • Thêm xx vào s[cnt[x]]s\big[cnt[x]\big].
    • Nếu như cnt[x]>mode_cntcnt[x] > \text{mode\_cnt} thì cập nhật lại mode_cnt=cnt[x]\text{mode\_cnt} = cnt[x] và giá trị mode sẽ là phần tử đầu tiên của s[mode_cnt]s[\text{mode\_cnt}].
  • Xóa một số xx:
    • Xóa xx khỏi s[cnt[x]]s\big[cnt[x]\big].
    • Giảm cnt[x]cnt[x] đi 11.
    • Thêm xx vào s[cnt[x]]s\big[cnt[x]\big].
    • Nếu như s[mode_cnt]s[\text{mode\_cnt}] rỗng tức là phần tử vừa bị xóa đi chính là mode của truy vấn trước khi xóa, nên ta giảm mode_cnt\text{mode\_cnt} đi 11 và cập nhật lại mode của truy vấn này là phần tử đầu tiên của s[mode_cnt]s[\text{mode\_cnt}].

Cuối cùng, ta tiến hành xử lý các truy vấn như sau:

  • Các truy vấn cần được lưu lại vị trí do đã sắp xếp theo Mo's algorithm. Gọi truy vấn thứ ii sau khi sắp xếp là một bản ghi query[i],query[i], và vị trí chính xác của nó là query[i].posquery[i].pos.
  • Gọi res[i]res[i] là đáp án của truy vấn có vị trí ban đầu là ii. Ban đầu ta khởi tạo res[query[1].pos]res\big[query[1].pos\big] bằng cách gọi thao tác thêm tất cả các phần tử trong đoạn [query[1].l,query[1].r]\big[query[1].l, query[1].r\big]res[query[1].pos]res\big[query[1].pos\big] sẽ gán bằng mode của truy vấn này.
  • Xét các truy vấn từ query[2]query[2] tới query[q],query[q], ta sẽ cập nhật kết quả cho query[i]query[i] từ query[i1]query[i - 1] như sau:
    • Đặt l1,r1l_1, r_1 là truy vấn trước. Ban đầu l1=query[1].l,r1=query[1].rl_1 = query[1].l, r_1 = query[1].r.
    • Nếu l1>query[i].ll_1 > query[i].l thì thêm các phần tử trong đoạn [query[i].l,l11]\big[query[i].l, l_1 - 1\big] đồng thời giảm l1l_1 xuống tới khi l1=query[i].ll_1 = query[i].l.
    • Nếu r1<query[i].rr_1 < query[i].r thì thêm các phần tử trong đoạn [r1+1,query[i].r]\big[r_1 + 1, query[i].r\big] đồng thời tăng r1r_1 lên tới khi r1=query[i].rr_1 = query[i].r.
    • Nếu l1<query[i].ll_1 < query[i].l thì xóa các phần tử trong đoạn [l1,query[i].l)\big[l_1, query[i].l\big) đồng thời tăng l1l_1 lên tới khi l1=query[i].ll_1 = query[i].l.
    • Nếu r1>query[i].rr_1 > query[i].r thì xóa các phần tử trong đoạn [query[i].r+1,r1]\big[query[i].r + 1, r_1] đồng thời giảm r1r_1 đi tới khi r1=query[i].rr_1 = query[i].r.
    • Gán res[query[i].pos]=res\big[query[i].pos\big] = mode của truy vấn hiện tại. Lưu ý thứ tự thực hiện bắt buộc phải là thêm trước - xóa sau thì thuật toán mới chính xác.

Cài đặt

#include <bits/stdc++.h>
#define int long long

using namespace std;

const int maxn = 200010;

struct QueryType
{
    int l, r, pos;
} query[maxn];

int block_size, mode_val, mode_cnt;
int cnt[maxn], a[maxn];
set < int > s[maxn];

// Sắp xếp các truy vấn theo Mo's Algorithm.
bool cmp(QueryType a, QueryType b) 
{
    if (a.l / block_size != b.l / block_size)
        return a.l < b.l;
    else
        return a.r < b.r;
}

// Thêm 1 phần tử.
void add_value(int pos) 
{
    int val = a[pos];

    s[cnt[val]].erase(val);
    ++cnt[val];
    s[cnt[val]].insert(val);

    // nếu thêm một phần tử vào và cnt của nó > mode_cnt trước đó, thì phần tử vừa thêm vào này chính là Mode mới của
    // đoạn đang xét. Cập nhật mode mới và mode_cnt.
    if (cnt[val] > mode_cnt)
        mode_cnt = cnt[val];

    // Cập nhật giá trị mode của đoạn mới, chọn phần tử nhỏ nhất.
    mode_val = *s[mode_cnt].begin(); 
}

// Xóa 1 phần tử.
void remove_value(int pos) 
{
    int val = a[pos];

    s[cnt[val]].erase(val);
    if (cnt[val] > 0) // Nếu phần tử val đã xuất hiện rồi thì mới xóa nó đi được.
    {
        --cnt[val];
        s[cnt[val]].insert(val);
    }

    // Mode trước đó có số lần xuất hiện là mode_cnt, nếu sau bước này mà s[mode_cnt] rỗng tức là mode mới có số lần
    // xuất hiện là (mode_cnt - 1).
    if (s[mode_cnt].empty())
        --mode_cnt;

    // Cập nhật mode của đoạn mới, chọn giá trị nhỏ nhất.
    mode_val = *s[mode_cnt].begin(); 
}

void solution(int n, int t)
{
    // Sort lại các truy vấn. Tính trước truy vấn 1 và cập nhật dần lên các truy vấn sau.
    block_size = ceil(sqrt(n * 1.0));
    sort(query + 1, query + t + 1, cmp);
    
    // Khởi tạo truy vấn đầu tiên. Ban đầu tất cả các phần tử đều có tần suất là 0.
    s[0] = unordered_set < int >(a + 1, a + n + 1);
    for (int i = query[1].l; i <= query[1].r; ++i)
        add_value(i);
    
    vector < int > res(n + 1);
    res[query[1].pos] = mode_val;
    // Gọi l1, r1 là (l, r) của truy vấn trước; (l2, r2) là của truy vấn hiện tại.
    int l1 = query[1].l, r1 = query[1].r;
    for (int k = 2; k <= t; ++k)
    {
        while (l1 > query[k].l) // l1 > l2: Tăng cnt[a[l2] -> a[l1 - 1]].
        {
            --l1;
            add_value(l1);
        }
        
        while (r1 < query[k].r) // r1 < r2: Tăng cnt[a[r1 + 1] -> a[r2]].
        {
            ++r1;
            add_value(r1);
        }
        
        while (l1 < query[k].l) // l1 < l2: Giảm cnt[a[l1] -> a[l2 - 1]].
        {
            remove_value(l1);
            ++l1;
        }

        while (r1 > query[k].r) // r1 > r2: Giảm cnt[a[r2 + 1] -> a[r1]].
        {
            remove_value(r1);
            --r1;
        }

        res[query[k].pos] = mode_val;
    }

    for (int i = 1; i <= t; ++i)
        cout << res[i] << '\n';
}

main()
{
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, t;
    cin >> n >> t;

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

    for (int i = 1; i <= t; ++i)
    {
        cin >> query[i].l >> query[i].r;
        query[i].pos = i;
    }

    solution(n, t);

    return 0;
}

Đánh giá độ phức tạp

Độ phức tạp thời gian

  • Khi di chuyển từ l1l_1 sang l2l_2:
    • Nếu l1l_1l2l_2 thuộc cùng một khối: Với mỗi thao tác, do độ dài khối là n\left\lceil\sqrt{n}\right\rceil nên số thao tác không vượt quá n\left\lceil\sqrt{n}\right\rceil. Có tổng cộng qq truy vấn nên tổng ộộ phức tạp là O(q×n)O(q \times \sqrt{n}).
    • Nếu l1l_1l2l_2 thuộc hai khối khác nhau: Vì ta đã ưu tiên sắp xếp các khối theo ll tăng dần, nên trường hợp này xảy ra không quá n\left\lceil\sqrt{n}\right\rceil lần. Mỗi khi xảy ra trường hợp này tốn độ phức tạp tối đa là O(n)O(n) nên độ phức tạp là O(n×n)O(n \times \sqrt{n}).
  • Khi di chuyển từ r1r_1 sang r2r_2:
    • Nếu l1l_1l2l_2 thuộc cùng một khối: Vì trong cùng một khối, các giá trị rr sắp xếp tăng dần nên với mỗi khối của ll ta chỉ mất độ phức tạp tổng là O(n)O(n). Có tổng cộng n\left\lceil\sqrt{n}\right\rceil khối khác nhau nên tổng độ phức tạp là O(n×n)O(n \times \sqrt{n}).
    • Nếu l1l_1l2l_2 thuộc hai khối khác nhau: Do chỉ có tối đa n\left\lceil\sqrt{n}\right\rceil lần đổi khối và mỗi lần đổi mất tối đa O(n)O(n) để di chuyển rr nên ta mất độ phức tạp tổng là O(n×n)O(n \times \sqrt{n}).

Tổng hợp lại, ta có độ phức tạp thời gian O((n+q)×n)O\big((n + q) \times \sqrt{n}\big).

Độ phức tạp không gian

Ta dùng tới các mảng có độ dài nn nên độ phức tạp không gian tổng quát là O(n)O(n).

3. Cải tiến thời gian thực thi cho Mo's Algorithm

Trên thực tế, việc đặt kích thước của các khối bằng chính xác n\left\lceil\sqrt{n}\right\rceil không phải luôn luôn tạo ra thời gian thực thi tối ưu. Chẳng hạn, nếu như n=750\left\lceil\sqrt{n}\right\rceil = 750 thì đặt kích thước khối bằng 700700 hoặc 800800 sẽ chạy tốt hơn.

Hãy luôn luôn đặt kích thước của các khối là giá trị const, chứ không nên tính toán nó trong Runtime, bởi vì các giá trị hằng số sẽ được chương trình biên dịch tối ưu tốt hơn khi thực hiện phép chia.

Hàm sắp xếp có thể được cải tiến hơn một chút ở trường hợp chỉ số ll của hai truy vấn thuộc cùng một khối:

  • Nếu khối mang chỉ số lẻ, ta sắp xếp tăng dần đầu bên phải của truy vấn.
  • Nếu khối mang chỉ số chẵn, ta sắp xếp giảm dần đầu bên phải của truy vấn.
  • Điều này sẽ giảm thiểu chuyển động của con trỏ bên phải, vì việc sắp xếp thông thường sẽ di chuyển con trỏ bên phải từ cuối trở lại đầu ở đầu mỗi khối (do cài đặt trong thuật toán sắp xếp của STL C++). Với phiên bản cải tiến, việc khởi tạo lại này không còn cần thiết nữa.
bool cmp(QueryType a, QueryType b) // sắp xếp các truy vấn theo Mo's Algorithm.
{
    if (a.l / block_size != b.l / block_size)
        return a.l < b.l;
    else
    {
        int id = (a.l + block_size - 1) / block_size;
        return (id & 1) ? a.r < b.r : a.r > b.r;
    }
}

Thuật toán Mo's có thể được cải tiến tốc độ nhiều hơn nữa nếu sử dụng TSP và Hilbert curve. Tuy nhiên đây là kĩ thuật khó nên trong bài viết này không giới thiệu, các bạn có thể tìm đọc thêm tại đây.

III. Bài tập minh họa

1. D - Query

Đề bài

Cho dãy số AA gồm nn phần tử a1,a2,,ana_1, a_2, \dots, a_nqq truy vấn. Mỗi truy vấn có dạng (l,r)(l, r) yêu cầu bạn phải trả về số lượng phần tử phân biệt trong đoạn vị trí [l,r][l, r] của dãy AA.

Yêu cầu: Hãy trả lời các truy vấn và đưa ra đáp án?

Input:

  • Dòng đầu tiên chứa số nguyên dương nn.
  • Dòng thứ hai chứa nn số nguyên dương a1,a2,,ana_1, a_2, \dots, a_n.
  • Dòng thứ ba chứa số nguyên dương qq.
  • Trên qq dòng tiếp theo, mỗi dòng chứa một cặp số nguyên dương l,rl, r thể hiện một truy vấn.

Ràng buộc:

  • 1n300001 \le n \le 30000.
  • 1ai109;i:1in1 \le a_i \le 10^9; \forall i: 1 \le i \le n.
  • 1q2×1051 \le q \le 2 \times 10^5.
  • 1lrn1 \le l \le r \le n.

Output:

  • Với mỗi truy vấn, in ra kết quả trên một dòng.

Sample Input:

5
1 1 2 1 3
3
1 5
2 4
3 5

Sample Output:

3
2
3

Ý tưởng

Đầu tiên ta sắp xếp lại các truy vấn theo Mo's Algorithm.

Để đếm số lượng phần tử phân biệt trong một truy vấn, ta sẽ duy trì mảng đếm phân phối cnt[x]cnt[x] để đếm số lượng giá trị xx trong đoạn hiện tại.

Khi chuyển đoạn từ [l1,r1][l_1, r_1] sang [l2,r2],[l_2, r_2], ta sẽ xử lý thêm - xóa phần tử theo như Mo's Algorithm, và lưu ý:

  • Nếu một phần tử xx đang có cnt[x]=0cnt[x] = 0 và sau khi thực hiện thao tác thêm phần tử, nó trở thành cnt[x]=1,cnt[x] = 1, thì số lượng phần tử phân biệt của đoạn sẽ tăng thêm 11.
  • Nếu một phần tử xx đang có cnt[x]=1cnt[x] = 1 và sau khi thực hiện thao tác xóa phần tử, nó trở thành cnt[x]=0,cnt[x] = 0, thì số lượng phần tử phân biệt của đoạn sẽ giảm đi 11.

Tuy nhiên, các giá trị ai109,a_i \le 10^9, nên muốn thực hiện đếm phân phối thì phải rời rạc hóa các giá trị aia_i về đoạn [1,n][1, n]. Các bạn có thể xem lại kĩ thuật rời rạc hóa tại đây.

Độ phức tạp: O((n+q)×n)O\big((n + q) \times \sqrt{n}\big).

Cài đặt

#pragma GCC optimize("O3","unroll-loops")
#pragma GCC target("avx2")

#include <bits/stdc++.h>

using namespace std;

const int maxn = 30001, maxv = 1e6 + 1, maxq = 200001;

struct Query
{
    int l, r, pos;
} query[maxq];

int block_size;
int cnt[maxv], a[maxn];

bool cmp(Query a, Query b)
{
    if (a.l / block_size != b.l / block_size)
        return a.l < b.l;
    else
    {
        int id = (a.l + block_size - 1) / block_size;
        return (id & 1) ? a.r < b.r : a.r > b.r;
    }
}

// Rời rạc hóa mảng A về các giá trị [1...n].
void discretizing(int n)
{
    map < int, vector < int > > m;
    for (int i = 1; i <= n; ++i)
        m[a[i]].push_back(i);

    int d = 0;
    for (auto e: m)
    {
        ++d;
        for (int p: e.second)
            a[p] = d;
    }
}

// Thêm 1 phần tử.
void add_value(int pos, int& distinct_cnt)
{
    int val = a[pos];

    ++cnt[val];

    if (cnt[val] == 1)
        ++distinct_cnt;
}

// Xóa 1 phần tử.
void remove_value(int pos, int& distinct_cnt)
{
    int val = a[pos];

    if (cnt[val] > 0)
    {
        --cnt[val];

        if (cnt[val] == 0)
            --distinct_cnt;
    }
}

void solution(int n, int q)
{
    block_size = ceil(sqrt(n * 1.0));
    sort(query + 1, query + q + 1, cmp);
    discretizing(n);

    int distinct_cnt = 0;
    for (int i = query[1].l; i <= query[1].r; ++i)
        add_value(i, distinct_cnt);

    vector < int > res(q + 1);
    res[query[1].pos] = distinct_cnt;
    int l1 = query[1].l, r1 = query[1].r;
    for (int i = 2; i <= q; ++i)
    {
        while (l1 > query[i].l)
            --l1, add_value(l1, distinct_cnt);

        while (r1 < query[i].r)
            ++r1, add_value(r1, distinct_cnt);

        while (l1 < query[i].l)
            remove_value(l1, distinct_cnt), ++l1;

        while (r1 > query[i].r)
            remove_value(r1, distinct_cnt), --r1;

        res[query[i].pos] = distinct_cnt;
    }

    for (int i = 1; i <= q; ++i)
        cout << res[i] << '\n';
}

signed main()
{
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

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

    int q;
    cin >> q;

    for (int i = 1; i <= q; ++i)
        cin >> query[i].l >> query[i].r, query[i].pos = i;

    solution(n, q);

    return 0;
}

2. XOR and Favorite Number

Đề bài

Cho một mảng AA gồm nn số nguyên a1,a2,,ana_1, a_2, \dots, a_n và một số nguyên kk.

Bạn cần trả lời mm truy vấn, mỗi truy vấn là một cặp số nguyên (l,r)(l, r) yêu cầu đếm số cặp (i,j)(i, j) thỏa mãn:

  • lijrl \le i \le j \le r.
  • aiai+1ai+2ar=k;a_i \oplus a_{i + 1} \oplus a_{i + 2} \oplus \cdots \oplus a_r = k; với \oplus là phép toán XOR\text{XOR}.

Yêu cầu: Hãy đưa ra đáp án của các truy vấn?

Input:

  • Dòng đầu tiên chứa ba số nguyên n,m,kn, m, k - độ dài dãy A,A, số lượng truy vấn và số nguyên kk.
  • Dòng thứ hai chứa nn số nguyên dương a1,a2,,ana_1, a_2, \dots, a_n.
  • Trên mm dòng tiếp theo, mỗi dòng chứa một cặp số nguyên dương l,rl, r thể hiện một truy vấn.

Ràng buộc:

  • 1n,m1051 \le n, m \le 10^5.
  • 0k1060 \le k \le 10^6.
  • 0ai106;i:1in0 \le a_i \le 10^6; \forall i: 1 \le i \le n.
  • 1lrn1 \le l \le r \le n.

Output:

  • Với mỗi truy vấn, đưa ra đáp án trên một dòng.

Sample Input:

6 2 3
1 2 1 1 0 3
1 6
3 5

Sample Output:

7
0

Ý tưởng

Gọi pref[i]pref[i] là tổng XOR\text{XOR} tiền tố của các giá trị a1...ia_{1...i}. Tức là:

{pref[0]=0.pref[i]=pref[i1]ai\begin{cases}pref[0] = 0. \\ pref[i] = pref[i - 1] \oplus a_i \end{cases}

Khi đó, ta có tính chất tổng XOR\text{XOR} của một đoạn ai,ai+1,,aja_i, a_{i + 1}, \dots, a_j sẽ là:

pref[j]pref[i1]pref[j] \oplus pref[i - 1]

Như vậy, một truy vấn (l,r)(l, r) có thể đưa về bài toán đếm số cặp (i,j)(i, j) thỏa mãn:

{l1i<jr.pref[j]pref[j]=kpref[i]=kpref[j]\begin{cases} l - 1 \le i < j \le r. \\ pref[j] \oplus pref[j] = k \Leftrightarrow pref[i] = k \oplus pref[j] \end{cases}

Ta sắp xếp lại các truy vấn theo Mo's Algorithm. Thuật toán khi chuyển truy vấn sẽ như sau:

  • Khi thêm một phần tử xx (tương ứng với một pref[j]pref[j] hoặc một pref[i]pref[i] mới) vào cấu trúc dữ liệu, ta cần đếm số giá trị y=kxy = k \oplus x (tương ứng với các pref[i]pref[i] hoặc pref[j]pref[j] ở trước đó) đã có sẵn, cộng thêm lượng đó vào kết quả.
  • Khi xóa một phần tử xx (tương ứng với một pref[i]pref[i] hoặc một pref[j]pref[j] bị xóa đi), ta cần đếm số giá trị y=kxy = k \oplus x (tương ứng với các pref[i]pref[i] hoặc pref[j]pref[j] ở trước đó) đã có sẵn, trừ đi lượng đó khỏi kết quả.
  • Cả hai thao tác trên dễ dàng thực hiện bằng một mảng đếm phân phối.

Độ phức tạp: O((n+q)×n)O\big((n + q) \times \sqrt{n}\big).

Cài đặt

#pragma GCC optimize("O3","unroll-loops")
#pragma GCC target("avx2")

#include <bits/stdc++.h>
#define int long long

using namespace std;

const int maxn = 1e5 + 1, maxv = 2e6 + 1;

struct Query
{
    int l, r, pos;
} query[maxn];

int block_size;
int cnt[maxv], a[maxn], pref[maxn];

bool cmp(Query a, Query b)
{
    if (a.l / block_size != b.l / block_size)
        return a.l < b.l;
    else
        return a.r < b.r;
}

// Thêm 1 phần tử.
void add_value(int pos, int k, int& pair_cnt)
{
    int val = pref[pos];
    pair_cnt += cnt[k ^ val];
    ++cnt[val];
}

// Xóa 1 phần tử.
void remove_value(int pos, int k, int& pair_cnt)
{
    int val = pref[pos];
    --cnt[val];
    pair_cnt -= cnt[k ^ val];
}

void data_preparation(int n, int q, int k, int& pair_cnt)
{
    pref[0] = 0;
    for (int i = 1; i <= n; ++i)
        pref[i] = pref[i - 1] ^ a[i];

    block_size = ceil(sqrt(n * 1.0));
    sort(query + 1, query + q + 1, cmp);

    for (int i = query[1].l; i <= query[1].r; ++i)
        add_value(i, k, pair_cnt);
}

void solution(int n, int q, int k)
{
    int pair_cnt = 0;
    data_preparation(n, q, k, pair_cnt);

    int l1 = query[1].l, r1 = query[1].r;
    vector < int > res(q + 1);
    res[query[1].pos] = pair_cnt;
    for (int i = 2; i <= q; ++i)
    {
        while (l1 > query[i].l)
            --l1, add_value(l1, k, pair_cnt);

        while (r1 < query[i].r)
            ++r1, add_value(r1, k, pair_cnt);

        while (l1 < query[i].l)
            remove_value(l1, k, pair_cnt), ++l1;

        while (r1 > query[i].r)
            remove_value(r1, k, pair_cnt), --r1;

        res[query[i].pos] = pair_cnt;
    }

    for (int i = 1; i <= q; ++i)
        cout << res[i] << '\n';
}

signed main()
{
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, q, k;
    cin >> n >> q >> k;

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

    for (int i = 1; i <= q; ++i)
    {
        cin >> query[i].l >> query[i].r;
        --query[i].l;
        query[i].pos = i;
    }

    solution(n, q, k);

    return 0;
}

IV. Tài liệu tham khảo


©️ Tác giả: Vũ Quế Lâm từ Viblo


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí