Try   HackMD

I. Giới thiệu

Những bài toán truy vấn đoạn luôn luôn là chủ đề thú vị trong các kì thi lập trình. Một số thao tác truy vấn đoạn đơn giản có thể kể đến là tính tổng đoạn con, tìm max/min đoạn con hay tìm

GCD của đoạn con,Những thao tác này nếu như không có sự hỗ trợ của các cấu trúc dữ liệu thì sẽ tốn độ phức tạp thời gian là
O(n),
thường dẫn đến lỗi TLE trong các bài toán multi-testcase.

Chia căn (Squaroot Decomposition) chính là một phương pháp (hay cấu trúc dữ liệu) hiệu quả để hỗ trợ các thao tác trên giảm độ phức tạp xuống còn

O(n), nhanh hơn rất nhiều so với thuật toán duyệt thông thường. Trong bài viết này, tôi sẽ giới thiệu tới các bạn dạng đơn giản nhất của kĩ thuật này: Chia mảng ra làm
n
khối (blocks),
thường dùng để giải quyết các bài toán truy vấn đoạn.

Ta sẽ cùng phân tích một bài toán kinh điển để hiểu rõ hơn về ý tưởng của phương pháp này:

"Cho một mảng

A gồm
n
phần tử
a1,a2,,an
. Hãy xây dựng một cấu trúc dữ liệu để tìm ra tổng của đoạn con
[l,r]
bất kì chỉ trong
O(n)?
"

Input:

  • Dòng đầu tiên chứa số nguyên dương
    n
    .
  • Dòng thứ hai chứa
    n
    số nguyên
    a1,a2,,an
    .
  • Dòng thứ ba chứa số nguyên dương
    t
    - số lượng truy vấn.
  • Trên
    t
    dòng tiếp theo, mỗi dòng chứa hai số nguyên dương
    l,r (1lrn)
    mô tả một đoạn con cần tính tổng.

Ràng buộc:

  • 1n105
    .
  • 1ai109;i:1in
    .
  • 1t104
    .

Output:

  • Đưa ra trên
    t
    dòng, mỗi dòng là tổng của đoạn con tương ứng.

Sample Input:

13
3 1 5 2 1 5 2 1 3 2 6 7 1
3
1 4
3 8
7 13

Sample Output:

11
16
22

II. Phân tích chi tiết

1. Ý tưởng

Tiền xử lý dữ liệu

Ý tưởng cơ bản của Chia căn chính là tiền xử lý. Ta sẽ chia nhỏ mảng

A thành các khối (block) có độ dài là
n
(có thể tồn tại khối có độ dài nhỏ hơn, tức là không đủ phần tử). Với mỗi khối thứ
i,
ta gọi
b[i]
là tổng các phần tử trong khối.

Giả sử cả độ dài các khối lẫn số lượng khối là

s=n. Lúc này, mảng
A
sẽ được chia thành
s
khối như sau:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Riêng khối cuối cùng có thể sẽ không có đủ

s phần tử, nếu như
n
không phải là một bội của
s
. Tuy nhiên điều này không ảnh hưởng tới ý tưởng Chia căn, vì nó có thể được xử lý riêng rất dễ dàng.

Tổng của khối thứ

k sẽ được lưu trong phần tử
b[k]
:

b[k]=i=(k1)×s+1min(k×s,n)ai

Việc tính toán các

b[k] có thể thực hiện dễ dàng trong
O(n)
bằng đoạn code dưới đây:

int s = ceil(sqrt(n * 1.0)); // Số lượng khối và độ dài một khối.

vector < long long > data_preparation(int n, int s, vector < int >& a)
{
    vector < long long > b(s + 1);
    for (int i = 1; i <= n; ++i)
    {
        int id = (i + s - 1) / s; // Chỉ số khối chứa phần tử a[i].
        b[id] += a[i]; 
    }
        
    return b;
}

Xử lý một truy vấn

Giờ giả sử ta đã tính xong các giá trị

b[k], liệu nó có thể giúp gì cho việc tính tổng đoạn
[l,r]?

Nếu như một đoạn

[l,r] đủ độ dài, thì nó sẽ bao gồm một hoặc một số khối có đầy đủ phần tử - việc tính tổng các khối này có thể thực hiện chỉ trong một thao tác. Ngoài ra, sẽ có thể có những phần tử dư ra ở hai đầu đoạn
[l,r]
- chúng sẽ thuộc vào hai khối khác, khi đó ta sẽ tính tổng những phần tử này thủ công.

Ví dụ, với mảng

A={3,1,5,2,1,5,2,1,3,2,6,7,1} (n=13,s=4) thì nó sẽ được chia như sau:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Xét một khối thứ

k gồm đầy đủ
s
phần tử mà nằm trong đoạn
[l,r],
thì tổng của nó hẳn nhiên đã được lưu trong
b[k]
. Gọi chỉ số của khối chứa
al
và khối chứa
ar
lần lượt là
blockl
blockr,
ta có công thức:

{blockl=lsblockr=rs

Các khối độ dài đầy đủ sẽ có chỉ số nằm trong đoạn

[blockl+1,blockr1]. Còn các phần tử nằm ở khối chứa
al
và khối chứa
ar
- những khối bị thiếu - ta sẽ tính tổng riêng. Chỉ số của phần tử cuối cùng trong khối
blockl
và phần tử đầu tiên trong khối
blockr
lần lượt là
endl=blockl×s
startr=(blockr1)×s+1

Như vậy, tổng của đoạn

[l,r] lúc này sẽ tính bằng công thức:

i=lrai=i=lendlai+i=startrrai+i=blocll+1blockr1b[i]

Tuy nhiên, công thức này sẽ sai nếu như một truy vấn có

l
r
thuộc cùng một khối. Lúc này ta cần phải tính tổng của truy vấn một cách thủ công.

long long query(int l, int r, int n, int s, vector < int >& a, const vector < long long >& b)
{   
    int block_l = (l + s - 1) / s, block_r = (r + s - 1) / s;
    if (block_l == block_r)
        return accumulate(a.begin() + l, a.begin() + r + 1, 0LL);
    
    int end_l = block_l * s, start_r = (block_r - 1) * s + 1;
    long long res = 0;
    res += accumulate(a.begin() + l, a.begin() + end_l + 1, 0LL);
    res += accumulate(a.begin() + start_r, a.begin() + r + 1, 0LL) 
    res += accumulate(b.begin() + block_l + 1, b.begin() + block_r, 0LL);
    
    return res;
}

2. Cài đặt đầy đủ

Trong cài đặt dưới đây, tôi sử dụng định danh #define int long long để đưa kiểu dữ liệu khi tính toán về long long cho phù hợp với ràng buộc của đề bài. Giá trị

s cũng được tính sẵn ở hàm main() rồi truyền qua các hàm khác để đỡ phải tính nhiều lần.

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

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

using namespace std;

vector < int > data_preparation(int n, int s, const vector < int >& a)
{
    vector < int > b(s + 1);
    for (int i = 1; i <= n; ++i)
    {
        int id = (i + s - 1) / s;; // Chỉ số khối chứa phần tử a[i].
        b[id] += a[i];
    }

    return b;
}

int query(int l, int r, int n, int s, const vector < int >& a, const vector < int >& b)
{    
    int block_l = (l + s - 1) / s, block_r = (r + s - 1) / s;
    if (block_l == block_r)
        return accumulate(a.begin() + l, a.begin() + r + 1, 0LL);

    int end_l = block_l * s, start_r = (block_r - 1) * s + 1;
    int res = 0;
    res += accumulate(a.begin() + l, a.begin() + end_l + 1, 0LL);
    res += accumulate(a.begin() + start_r, a.begin() + r + 1, 0LL);
    res += accumulate(b.begin() + block_l + 1, b.begin() + block_r, 0LL);

    return res;
}

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

    vector < int > a(n + 1);
    for (int i = 1; i <= n; ++i)
        cin >> a[i];
    
    int s = ceil(sqrt(n * 1.0)); // Số lượng khối và độ dài một khối.
    vector < int > b = data_preparation(n, s, a);

    int t;
    cin >> t;

    while (t--)
    {
        int l, r;
        cin >> l >> r;

        cout << query(l, r, n, s, a, b) << '\n';
    }

    return 0;
}

4. Đánh giá

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

Giả sử số đoạn chia ra là

s, vậy mỗi đoạn có độ dài là
ns
.

Đối với mỗi truy vấn, ta phải xét các đoạn đầy đủ và các đoạn thiếu ở hai đầu:

  • Đối với các đoạn đầy đủ, trường hợp tệ nhất ta phải xét đủ cả
    s
    đoạn, mỗi đoạn ta đã tính trước
    b[k],
    do đó tổng độ phức tạp là
    O(s)
    .
  • Đối với các đoạn thiếu, xét riêng mỗi phần tử mất
    O(1)
    . Mỗi đoạn có độ dài tối đa
    ns,
    do đó tối đa ta mất
    O(ns)
    cho bước này.

Như vậy, mỗi truy vấn ta mất tổng đpt

O(s+ns). Áp dụng bất đẳng thức Cauchy với hai số không âm
s
ns,
ta thấy
s+ns
đạt giá trị nhỏ nhất bằng
n,
khi và chỉ khi
s=ns,
tức là
s=n
và độ phức tạp sẽ tiến tới
O(2n)
cho mỗi truy vấn.

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

Các mảng sử dụng chỉ tốn tối đa

O(n) cho không gian lưu trữ. Vì thế, độ phức tạp không gian tổng quát cũng là
O(n)
.

III. Mở rộng cho thao tác cập nhật

1. Cập nhật một phần tử

Điều thú vị của kĩ thuật Chia căn nằm ở việc nó có thể hỗ trợ cả thao tác cập nhật một phần tử trong mảng, hoặc cập nhật lại đoạn trên mảng. Nếu như ở kĩ thuật Mảng tổng tiền tố, ta phải thay đổi lại cả mảng tổng khi có thao tác cập nhật, thì trong kĩ thuật Chia căn ta chỉ cần cập nhật lại khối chứa phần tử bị thay đổi.

Giả sử ta cần cập nhật lại

ap=x, thì ta chỉ cần cập nhật lại khối chứa
ap
k=is,
sau đó cập nhật lại
ap=x
.

{b[k]=b[k]ap+xap=x

Thao tác này chỉ mất

O(1). Thậm chí, bài toán có thể thay đổi thành tìm giá trị lớn nhất/giá trị nhỏ nhất trong các đoạn
[l,r]
(bài toán RMQ) - ta chỉ cần xây dựng lại mảng
b[k]
theo cách hoàn toàn tương tự như tính tổng, tuy nhiên đối với thao tác cập nhật phần tử thì cần lưu ý cập nhật lại tất cả khối chứa
ai
bị thay đổi - mất độ phức tạp chỉ là
O(n)
:

void update(int x, int p, int n, int s, const vector < int >& a, vector < int >& b)
{   
    int block = (p + s - 1) / s;
    int min_id = (block - 1) * s + 1, max_id = block * s;
    
    a[p] = x;
    b[block] = a[min_id];
    for (int i = min_id + 1; i <= max_id; ++i)
        b[block] = max(b[block], a[i]); // Hoặc b[block] = min(b[block], a[i]).
}

2. Cập nhật đoạn

Chia căn cũng có thể sử dụng cho thao tác cập nhật một đoạn phần tử tăng/giảm giá trị hoặc thay đổi thành giá trị khác.

Giả định ta có hai loại truy vấn trong bài toán, một là tăng đoạn

[l,r] lên
x
đơn vị, hai là in ra giá trị của phần tử
ap
. Lúc này, ta vận dụng Chia căn như sau:

  • Sử dụng mảng
    b[k]
    để lưu trữ tổng giá trị cần phải tăng thêm cho toàn bộ phần tử thuộc khối
    k
    (ban đầu tất cả các
    b[k]=0
    ). Có nghĩa là ta sẽ lưu trữ riêng các giá trị cập nhật của các đoạn đầy đủ ra một mảng.
  • Với thao tác tăng đoạn
    [l,r],
    ta cần lặp qua tất cả các khối
    k
    có đủ độ dài
    s=n
    và gán
    b[k]=b[k]+x
    - với ý nghĩa là các phần tử thuộc khối
    k
    đã được tăng thêm
    x
    đơn vị. Còn các phần tử
    ai
    ở hai đầu đoạn mà không thuộc vào các khối đầy đủ thì ta sẽ tăng trực tiếp các
    ai
    đó lên. Thao tác này có độ phức tạp
    O(n)
    .
  • Với thao tác in ra giá trị của
    ap,
    ta chỉ cần tìm ra chỉ số khối chứa
    ap
    block=ps
    rồi đưa ra kết quả là
    ap+b[block]
    . Thao tác này chỉ có độ phức tạp
    O(1)
    .
// Hàm update trực tiếp lên mảng A.
void manual_update(int l, int r, int x, vector < int >& a)
{
    for (int i = l; i <= r; ++i)
        a[i] += x;
}

// Update cho một truy vấn [l, r].
void update(int l, int r, int x, int n, int s, vector < int >& a, vector < int >& b)
{
    int block_l = (l + s - 1) / s, block_r = (r + s - 1) / s;
    if (block_l == block_r)
    {
        manual_update(l, r, x, a);
        return;
    }

    int end_l = block_l * s, start_r = (block_r - 1) * s + 1;
    manual_update(l, end_l, x, a);
    manual_update(start_r, r, x, a);
    for (int i = block_l + 1; i < block_r; ++i)
        b[i] += x;
}

// Trả về giá trị của phần tử a[p].
int get_value(int p, int n, int s, vector < int >& a, vector < int >& b)
{
    int block = (p + s - 1) / s;

    return a[p] + b[block];
}

IV. Một số lưu ý bổ sung

Có rất nhiều cấu trúc dữ liệu khác hỗ trợ thao tác tính toán và cập nhật trên đoạn một cách hiệu quả, chẳng hạn như Segment Tree, tuy nhiên Chia căn vẫn luôn luôn được yêu thích bởi cài đặt đơn giản, dễ nhớ, và đặc biệt nó có thể áp dụng cho nhiều dạng bài khác nhau dựa trên ý tưởng chia dãy số thành

n đoạn để xử lý riêng biệt.

Những bài toán thao tác trên đoạn mà giới hạn input nhỏ như

n5×104 hoặc ràng buộc thời gian lớn như
2s
có thể là dấu hiệu cho việc áp dụng Chia căn.

Trên thực tế, ta không nhất thiết phải đặt chính xác

s=n, mà sẽ tùy thuộc vào bài toán và kích thước input để chọn ra một kích thước đoạn tối ưu. Chẳng hạn, một số trường hợp như sau có thể lưu ý:

  • Nếu bài toán chỉ đi qua các khối mà hầu như không thao tác với từng phần tử trong mỗi khối, thì sẽ tốt hơn khi đặt
    s<n
    để làm giảm số khối đi, khi đó số phần tử trong mỗi khối sẽ
    >n
    .
  • Nếu như một thao tác update có độ phức tạp tỉ lệ thuận với kích thước khối - tức là
    O(c×s)
    và một thao tác truy vấn kết quả có độ phức tạp tỉ lệ thuận với số lượng khối nhân với
    logn
    - tức là
    O(c×s×logn);
    thì ta có thể đặt
    snlogn
    để làm cho cả hai thao tác có độ phức tạp giảm xuống còn
    O(n×logn)
    .

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

1. Đếm giá trị

Đề bài

Cho dãy gồm

n số nguyên dương
a1,a2,,an
. Với mỗi dãy con
al,al+1,,ar (1lrn)
và số nguyên dương
k;
đặt
Fk
là số lần xuất hiện của
k
trong dãy con đó.

Ví dụ, cho dãy gồm

8 số nguyên
{1,1,2,2,1,3,1,1}
. Dãy con với
(l=2,r=7)
F1=3,F2=2,F3=1;
còn với
k>3
thì
Fk=0
.

Yêu cầu: Cho

T dãy con dạng
(l,r,k);
hãy xác định giá trị
Fk
của mỗi dãy con?

Input:

  • Dòng đầu tiên chứa hai số nguyên dương
    n
    T
    .
  • Dòng thứ hai chứa
    n
    số nguyên dương
    a1,a2,,an
    phân tách nhau bởi dấu cách.
  • Trên
    T
    dòng tiếp theo, mỗi dòng chứa ba số nguyên dương
    l,r,k
    thể hiện một dãy con.

Ràng buộc:

  • 1n,T2×105
    .
  • 1ai109;i:1in
    .

Output:

  • Gồm
    T
    dòng, dòng thứ
    i
    ghi một số nguyên là kết quả của câu hỏi thứ
    i
    .

Sample Input:

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

Sample Output:

2
2
2

Ý tưởng

Để đếm số lần xuất hiện của các giá trị trong mảng, phương pháp đơn giản nhất ta có thể nghĩ tới là Đếm phân phối. Giả sử bài toán luôn luôn có

l=1
r=n
thì ta dễ dàng giải quyết nó bằng cách gọi
cnt[x]
là số lần xuất hiện của giá trị
x
trong mảng.

Áp dụng ý tưởng trên, ta sẽ tạo ra

n mảng
cnt
để kiểm soát số lần xuất hiện của các phần tử trong
n
đoạn của mảng
A
. Đặt
cnt[i][x]
là số lần xuất hiện của giá trị
x
trong khối thứ
i,
ta dễ dàng tính ra toàn bộ các mảng
cnt
chỉ trong
O(n)
.

Lúc này với mỗi truy vấn

(l,r,k) ta sẽ tính tổng tất cả các
cnt[i][k]
thỏa mãn
i
là các khối đầy đủ thuộc đoạn
[l,r]
. Còn các phần tử bị dư ra ở hai đầu đoạn
[l,r]
thì ta sẽ xét từng phần tử bằng vòng lặp để đếm số lượng giá trị bằng với
k
.

Tuy nhiên, do các giá trị

ai109, nên để thực hiện được đếm phân phối, trước hết mảng
A
cần được rời rạc hóa. Khi đó với mỗi truy vấn
(l,r,k),
ta sẽ đưa về bài toán đếm số lượng giá trị
k
trong đoạn
[l,r]
- với
k
là giá trị của
k
sau khi rời rạc hóa.

Độ phức tạp:

O(n+T×2n).

Cài đặt

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

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

using namespace std;

vector < int > digitize(int n, vector < int >& a)
{
    map < int, vector < int > > id_list;
    for (int i = 1; i <= n; ++i)
        id_list[a[i]].push_back(i);

    int counter = 0;
    vector < int > digi_val(n + 1);
    for (auto e: id_list)
    {
        ++counter;

        for (int p: e.second)
            a[p] = counter;

        digi_val[e.first] = counter;
    }

    return digi_val;
}

vector < vector < int > > data_preparation(int n, int s, const vector < int >& a)
{
    vector < vector < int > > cnt(s + 1, vector < int >(n + 1));
    for (int i = 1; i <= n; ++i)
    {
        int id = (i + s - 1) / s;; // Chỉ số khối chứa phần tử a[i].
        ++cnt[id][a[i]];
    }

    return cnt;
}

int query(int l, int r, int k, int n, int s, vector < int >& a, vector < vector < int > >& cnt)
{
    int block_l = (l + s - 1) / s, block_r = (r + s - 1) / s;
    if (block_l == block_r)
        return count(a.begin() + l, a.begin() + r + 1, k);

    int end_l = block_l * s, start_r = (block_r - 1) * s + 1;
    int res = 0;
    res += count(a.begin() + l, a.begin() + end_l + 1, k);
    res += count(a.begin() + start_r, a.begin() + r + 1, k);
    for (int i = block_l + 1; i < block_r; ++i)
        res += cnt[i][k];

    return res;
}

signed main()
{
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);
    
    int n, t;
    cin >> n >> t;

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

    int s = ceil(sqrt(n * 1.0));
    vector < int > digi_val = digitize(n, a);
    vector < vector < int > > cnt = data_preparation(n, s, a);

    while (t--)
    {
        int l, r, k;
        cin >> l >> r >> k;

        if (!digi_val[k])
            cout << 0 << '\n';
        else
            cout << query(l, r, digi_val[k], n, s, a, cnt) << '\n';
    }

    return 0;
}

2. Holes

Tý đang chơi một trò chơi có tên là "Holes". Trò chơi này có quy tắc như sau:

n cái hố nằm trên đường thẳng được đánh số từ
1
tới
n,
hố thứ
i
có sức mạnh
pi
. Nếu như Tý ném một quả bóng vào hố thứ
i
thì ngay lập tức nó sẽ nhảy sang hố thứ
i+pi,
rồi cứ tiếp tục như vậy cho tới khi không còn hố nào để nhảy được nữa thì quả bóng sẽ nhảy ra khỏi hàng.

Tý được phép thực hiện

m lượt chơi, ở mỗi lượt cậu sẽ làm một trong hai thao tác:

  • Thao tác
    1
    : Đặt sức mạnh của hố
    a
    thành
    b
    .
  • Thao tác
    2
    : Ném một quả bóng vào hố
    a,
    rồi đếm số bước nhảy của nó cho tới khi nó nhảy ra khỏi hàng, đồng thời ghi lại chỉ số của hố cuối cùng mà quả bóng nhảy vào trước khi nó ra khỏi hàng.

Yêu cầu: Cho danh sách

m lượt chơi mà Tý thực hiện, hãy đưa ra đáp án của các lượt chơi mà Tý làm thao tác số
2?

Input:

  • Dòng đầu tiên chứa hai số nguyên dương
    n
    m
    .
  • Dòng thứ hai chứa
    n
    số nguyên dương
    a1,a2,,an
    phân tách nhau bởi dấu cách.
  • Trên
    m
    dòng tiếp theo, mỗi dòng chứa thông tin về một lượt chơi của Tý:
    • Đầu tiên là số nguyên
      type
      thể hiện loại thao tác của lượt chơi này. Giá trị của
      type=0
      tương ứng với thao tác loại
      1
      type=1
      tương ứng với thao tác loại
      2
      .
    • Nếu
      type=0
      thì theo sau là hai số nguyên dương
      a,b;
      ngược lại thì theo sau là một số nguyên dương
      a
      .

Ràng buộc:

  • 1n,m105
    .
  • 1pin;i:1in
    .
  • 1a,bn
    .

Output:

  • Với mỗi thao tác loại
    2,
    đưa ra hai số nguyên lần lượt là chỉ số của hố cuối cùng mà quả bóng nhảy vào, và số bước nhảy cho tới khi quả bóng rời khỏi hàng (tính cả bước nhảy ra khỏi hàng).

Sample Input:

8 5
1 1 1 1 1 2 8 2
1 1
0 1 3
1 1
0 3 4
1 2

Sample Output:

8 7
8 5
7 3

Ý tưởng

Ta sẽ chia các hố thành

s=n khối liên tiếp.

Với mỗi hố

i, ta sẽ lưu trữ thêm hai giá trị sau:

  • nxt[i]
    : Chỉ số của hố đầu tiên nằm khác khối với hố
    i,
    mà từ hố
    i
    có thể nhảy tới được. Nếu từ
    i
    không thể nhảy được tới hố nào trong dãy thì coi
    nxt[i]=i
    .
  • cnt[i]
    : Số bước nhảy để từ hố
    i
    tới được hố
    nxt[i]
    . Nếu
    nxt[i]=i
    thì
    cnt[i]=0
    .

Hai mảng này sẽ được xây dựng bằng Quy hoạch động như sau:

  • Ban đầu ta tính toán từng
    nxt[i]
    cnt[i]
    chỉ với một bước nhảy sang hố tiếp theo, coi như đây là cơ sở quy hoạch động.
  • Xét các hố
    i
    từ
    n
    về
    1,
    ta biết rằng hố tiếp theo nó sẽ nhảy tới là
    i+pi
    . Nếu như
    i+pi>n
    hoặc
    i+pi
    đã nằm ở khối khác với khối chứa
    i
    thì giữ nguyên
    nxt[i]
    cnt[i]
    . Ngược lại thì ta sẽ "nhảy cóc" sang hố phía sau hố
    i+pi
    :
    nxt[i]=nxt[i+pi],cnt[i]=1+cnt[i+pi]

Bây giờ ta xét các loại truy vấn:

  • Loại
    1
    : Đặt
    pa=b
    . Khi đó ta phải cập nhật lại các giá trị
    nxt[a],cnt[a]
    và cả các giá trị
    nxt[i],cnt[i]
    của các hố
    i
    nằm cùng một khối với hố
    a (i<a)
    . Lưu ý bước này ta phải cập nhật lại từ các hố ở phía sau lùi dần ra phía trước (giống ở bước khởi tạo). Do độ dài một khối là
    s=n
    nên thao tác này sẽ diễn ra trong
    O(n)
    .
  • Loại
    2
    : Nhảy từ hố
    a
    tới khi ra khỏi hàng. Bước này ta chỉ cần liên tục nhảy từ
    a
    sang
    nxt[a]
    cho tới khi
    nxt[a]=a,
    đồng thời cộng
    cnt[a]
    vào kết quả. Do ta nhảy cóc từ khối này sang khối khác, nên bước này cũng chỉ tốn tối đa
    O(n)
    .

Như vậy, thông qua kĩ thuật chia căn, ta có thuật toán với độ phức tạp

O(n+m×n).

Cài đặt

Bên dưới là cài đặt mẫu của bài toán, các bạn có thể nộp thử ở link sau: CF Beta 13 - Holes.

Tuy nhiên, bài toán này có ràng buộc thời gian và bộ nhớ khá chặt, nên các mảng đều được cài đặt mảng tĩnh để tăng tốc độ chương trình. Ngoài ra, việc kiểm tra hai phần tử

x
y
có cùng một khối hay không cũng được đơn giản hóa thông qua phép kiểm tra x / s == y / s - để giảm bớt độ phức tạp của các phép cộng trừ.

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

#include <bits/stdc++.h>

using namespace std;

const int maxn = 1e5 + 1;
int n, m, s;
int p[maxn], nxt[maxn], cnt[maxn];

// Kiểm tra xem hai hố x và y có nằm ở hai khối khác nhau không.
bool check_diff_block(const int& x, const int& y)
{
    int block_x = x / s, block_y = y / s;
    return block_x != block_y;
}

void data_preparation()
{
    for (int i = 1; i <= n; ++i)
        if (i + p[i] <= n) // Nếu từ i nhảy tới hố tiếp theo trong hàng thì đặt nxt[i] = i + p[i].
        {
            nxt[i] = i + p[i];
            cnt[i] = 1; // Trước tiên chỉ lưu một bước nhảy tới hố tiếp theo.
        }
        else
            nxt[i] = i; // Nếu từ i sẽ nhảy ra khỏi hàng thì đặt nxt[i] = i.

    // Quy hoạch động.
    for (int i = n; i >= 1; --i)
        if (i + p[i] > n || check_diff_block(i, i + p[i]))
            continue;
        else
        {
            nxt[i] = nxt[i + p[i]];
            cnt[i] = 1 + cnt[i + p[i]];
        }
}

void update(const int& a, const int& b)
{
    // Cập nhật lại sức mạnh của hố a thành b.
    p[a] = b;

    // Khi thay đổi sức mạnh của hố a thành b, cần phải update lại các giá trị nxt[i] và cnt[i]
    // của chính hố a và cả các hố i đứng trước hố a mà thuộc cùng một khối với hố a.
    int same_a = a;
    while (same_a >= 1 && !check_diff_block(same_a, a))
    {
        if (same_a + p[same_a] > n)
        {
            nxt[same_a] = same_a;
            cnt[same_a] = 0;
            --same_a;

            continue;
        }

        if (check_diff_block(same_a, same_a + p[same_a]))
        {
            nxt[same_a] = same_a + p[same_a];
            cnt[same_a] = 1;
        }
        else
        {
            nxt[same_a] = nxt[same_a + p[same_a]];
            cnt[same_a] = 1 + cnt[same_a + p[same_a]];
        }

        --same_a;
    }
}

void query(const int& a)
{
    int steps = 0, pos = a;
    while (pos != nxt[pos])
    {
        steps += cnt[pos];
        pos = nxt[pos];
    }

    cout << pos << ' ' << steps + 1 << '\n';
}

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

    cin >> n >> m;

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

    s = ceil(sqrt(n));
    data_preparation();

    while (m--)
    {
        int type;
        cin >> type;

        if (!type)
        {
            int a, b;
            cin >> a >> b;

            update(a, b);
        }
        else
        {
            int a;
            cin >> a;

            query(a);
        }
    }

    return 0;
}

Danh sách bài tập luyện tập

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