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 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à 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 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 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 gồm phần tử . 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 bất kì chỉ trong "
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Ý tưởng cơ bản của Chia căn chính là tiền xử lý. Ta sẽ chia nhỏ mảng thành các khối (block) có độ dài là (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ứ ta gọ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à . Lúc này, mảng sẽ được chia thành khối như sau:
Riêng khối cuối cùng có thể sẽ không có đủ phần tử, nếu như không phải là một bội của . 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ứ sẽ được lưu trong phần tử :
Việc tính toán các có thể thực hiện dễ dàng trong bằng đoạn code dưới đây:
Giờ giả sử ta đã tính xong các giá trị liệu nó có thể giúp gì cho việc tính tổng đoạn
Nếu như một đoạn đủ độ 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 - 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 thì nó sẽ được chia như sau:
Xét một khối thứ gồm đầy đủ phần tử mà nằm trong đoạn thì tổng của nó hẳn nhiên đã được lưu trong . Gọi chỉ số của khối chứa và khối chứa lần lượt là và ta có công thức:
Các khối độ dài đầy đủ sẽ có chỉ số nằm trong đoạn . Còn các phần tử nằm ở khối chứa và khối chứa - 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 và phần tử đầu tiên trong khối lần lượt là và
Như vậy, tổng của đoạn lúc này sẽ tính bằng công thức:
Tuy nhiên, công thức này sẽ sai nếu như một truy vấn có và 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.
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ị 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.
Giả sử số đoạn chia ra là vậy mỗi đoạn có độ dài là .
Đố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:
Như vậy, mỗi truy vấn ta mất tổng đpt . Áp dụng bất đẳng thức Cauchy với hai số không âm và ta thấy đạt giá trị nhỏ nhất bằng khi và chỉ khi tức là và độ phức tạp sẽ tiến tới cho mỗi truy vấn.
Các mảng sử dụng chỉ tốn tối đa cho không gian lưu trữ. Vì thế, độ phức tạp không gian tổng quát cũng là .
Đ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 thì ta chỉ cần cập nhật lại khối chứa là sau đó cập nhật lại .
Thao tác này chỉ mất . 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 (bài toán RMQ) - ta chỉ cần xây dựng lại mảng 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 bị thay đổi - mất độ phức tạp chỉ là :
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ên đơn vị, hai là in ra giá trị của phần tử . Lúc này, ta vận dụng Chia căn như sau:
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 đ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ư hoặc ràng buộc thời gian lớn như 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 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 ý:
Cho dãy gồm số nguyên dương . Với mỗi dãy con và số nguyên dương đặt là số lần xuất hiện của trong dãy con đó.
Ví dụ, cho dãy gồm số nguyên . Dãy con với có còn với thì .
Yêu cầu: Cho dãy con dạng hãy xác định giá trị của mỗi dãy con?
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Để đế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ó và thì ta dễ dàng giải quyết nó bằng cách gọi là số lần xuất hiện của giá trị trong mảng.
Áp dụng ý tưởng trên, ta sẽ tạo ra mảng để kiểm soát số lần xuất hiện của các phần tử trong đoạn của mảng . Đặt là số lần xuất hiện của giá trị trong khối thứ ta dễ dàng tính ra toàn bộ các mảng chỉ trong .
Lúc này với mỗi truy vấn ta sẽ tính tổng tất cả các thỏa mãn là các khối đầy đủ thuộc đoạn . Còn các phần tử bị dư ra ở hai đầu đoạn 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 .
Tuy nhiên, do các giá trị nên để thực hiện được đếm phân phối, trước hết mảng cần được rời rạc hóa. Khi đó với mỗi truy vấn ta sẽ đưa về bài toán đếm số lượng giá trị trong đoạn - với là giá trị của sau khi rời rạc hóa.
Độ phức tạp: .
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:
Có cái hố nằm trên đường thẳng được đánh số từ tới hố thứ có sức mạnh . Nếu như Tý ném một quả bóng vào hố thứ thì ngay lập tức nó sẽ nhảy sang hố thứ 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 lượt chơi, ở mỗi lượt cậu sẽ làm một trong hai thao tác:
Yêu cầu: Cho danh sách 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ố
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Ta sẽ chia các hố thành khối liên tiếp.
Với mỗi hố , ta sẽ lưu trữ thêm hai giá trị sau:
Hai mảng này sẽ được xây dựng bằng Quy hoạch động như sau:
Bây giờ ta xét các loại truy vấ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 .
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ử và 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ừ.