• VNOJ
  • Trang chủ
  • Danh sách bài
  • Các bài nộp
  • Thành viên
    >
    • Tổ chức
  • Các kỳ thi
  • Wiki
  • Thông tin
    >
    • FAQ
    • Trình chấm ngoài
    • Tag
    • Máy chấm
    • Devlog
    • Github
    • Tickets
    • Thư viện đề thi
    • Đề xuất contest
  • Tạp chí
VI EN Đăng nhập  hoặc  Đăng ký

Blog - Trang 1

  • Thông tin
  • Thống kê
  • Blog

7

Tạp chí VNOI Xuân Bính Ngọ - Kỹ thuật thú vị

unknownnaciul đã đăng vào 9, Tháng 2, 2026, 1:00

Tạp chí Tết: Tổng quan về CPU Hardware Acceleration

Author:

  • Nguyễn Tấn Minh — Trường Đại học Khoa học Tự nhiên, ĐHQG-HCM.

Reviewer:

  • Nguyễn Minh Nhật — Georgia Institute of Technology.
  • Trần Xuân Bách — University of Chicago.
  • Nguyễn Minh Hiển — Trường Đại học Công nghệ, ĐHQGHN.

Mở đầu

The things that destroy performance tend to be things that are much easier to type, such as division operations, poor cache usage, bad instruction scheduling (which is a topic by itself), and mispredicted branches.

Andrew Bromage trên nền tảng Quora

Trong lập trình thi đấu, đôi lúc chúng ta vẫn thường gặp những trường hợp "chỉ sửa code một chút thôi" nhưng từ một lời giải TLE đã trở thành lời giải AC. Ví dụ như:

  • Thêm một dòng #pragma gì đó.
  • Thay đổi thứ tự của một vài dòng for.
  • Giảm bớt bộ nhớ của các mảng lớn hay khai báo bớt mảng.
  • Thay (a + b) % MOD thành a + b - (a + b < MOD ? 0 : MOD).

Những "tối ưu" kiểu như vậy thường được các CP-ers gọi vui là ma thuật đen (hay thậm chí là... trick bẩn). Tuy nhiên, bất cứ điều gì xảy ra đều có nguyên nhân, và những trường hợp nêu trên cũng không phải ngoại lệ.

Trong bài viết này, chúng ta sẽ cùng mổ xẻ quá trình thực thi các chương trình xuống cấp độ bộ nhớ máy tính, tìm hiểu xem khi chạy những dòng code, điều gì đang thực sự diễn ra trên phần cứng, và từ đó giúp bạn đọc giải thích vì sao code lại chạy nhanh hay chậm hơn ở các trường hợp khác nhau.

Qua bài viết này, mình hy vọng bạn đọc sẽ có cái nhìn tổng quát hơn về việc phân tích tốc độ xử lý của một chương trình, bên cạnh các phương pháp tính độ phức tạp thông thường. Từ đó, những tối ưu kiểu như trên cũng không còn là những ma thuật đen nữa mà sẽ được chúng ta sử dụng một cách chủ động, hiệu quả và có ý đồ hơn.

Trong thi đấu và đặc biệt là kì thi ICPC, những "tối ưu" kiểu như vậy lại chính là ranh giới giữa việc một đội có thể giải thêm một bài (và leo lên thêm vài chục bậc trên bảng xếp hạng) hay không. Bản thân mình cũng đã trải nghiệm một trường hợp như vậy, và đó cũng chính là một trong những động lực không nhỏ đã giúp mình đưa bài viết này đến với mọi người trong Tạp chí Tết năm nay.

Sơ lược về phần cứng máy tính

Để hiểu hơn những gì đang thực sự diễn ra ở cấp độ bộ nhớ máy tính khi chúng ta chạy những dòng code, mình sẽ dành một phần của bài viết này để giới thiệu sơ lược về phần cứng máy tính cũng như cấu trúc bộ nhớ máy tính.

Phép đo tốc độ trong phần cứng máy tính
Tần số & Chu kỳ

Rõ ràng, với tốc độ xử lý của các thao tác trên máy tính, ta không thể tiến hành đo đạc bằng các loại đồng hồ đo thông thường. Do đó, người ta sử dụng một thiết bị đặc biệt có thể tạo ra dao động với tần số cực kỳ lớn và ổn định; tần số của thiết bị này thường được ghi ngay trên tên của bộ xử lý, ví dụ như 13th Gen Intel(R) Core(TM) i7-1355U (1.70 GHz).

Các lệnh máy sẽ được đo bằng đơn vị chu kỳ, tức thời gian để thiết bị này thực hiện một dao động. Với tần số ~f = 1.70~ GHz, ta có chu kỳ:

~T = \frac{1}{f} = \frac{1}{1.70 \cdot 10^9} \approx 5.88 \cdot 10^{-10} \text{ (s)}~

Nói cách khác, một chu kỳ chỉ xấp xỉ ~0.588~ nano-giây!

Trong bài viết này, chúng ta sẽ sử dụng chu kỳ làm đơn vị chính cho việc đánh giá các lệnh máy khác nhau.

Độ trễ và thông lượng

Để đánh giá tốc độ của các lệnh máy, ta thường sử dụng hai đại lượng thời gian sau:

  • Độ trễ (latency): Đo khoảng thời gian giữa lúc bắt đầu đến khi kết thúc lệnh.
  • Thông lượng (throughput): Đo khoảng thời gian giữa lúc bắt đầu đến khi có thể thực hiện lệnh cùng loại tiếp theo.

Để dễ hình dung, ta xét hai ví dụ như sau, giả sử độ trễ và thông lượng của phép chia lần lượt là ~22~ và ~8~ chu kỳ:

  • Khi tính ~((x / a) / b) / c~, bắt đầu tại mốc chu kỳ ~0~, do kết quả của phép tính sau phụ thuộc vào phép tính trước nên phép tính sau sẽ được thực hiện ngay sau khi hoàn thành phép tính trước. Ta sẽ thu được kết quả của ~x / a~, ~(x / a) / b~ và ~((x / a) / b) / c~ lần lượt tại chu kỳ thứ ~22, 44, 66~.

  • Khi tính ~x/a~, ~y/a~ và ~z/a~, bắt đầu tại mốc chu kỳ ~0~, do kết quả của ba phép tính này độc lập với nhau, chúng sẽ được bắt đầu tại chu kỳ thứ ~0, 8, 16~. Ta sẽ thu được kết quả của ~x/a~, ~y/a~ và ~z/a~ lần lượt tại chu kỳ thứ ~22, 30, 38~.

Các loại bộ nhớ cơ bản của máy tính

Việc chúng ta sử dụng nhiều loại bộ nhớ khác nhau trong máy tính đến từ một quy luật rất tự nhiên: Bộ nhớ hỗ trợ truy cập càng nhanh thì cấu trúc vi mạch và chi phí sản xuất của nó càng cao. Do đó, trong một chiếc máy tính, ta thường tích hợp nhiều loại bộ nhớ khác nhau với những vai trò khác nhau. Các loại bộ nhớ có chi phí thấp thường được trang bị nhiều giúp ta lưu trữ được nhiều dữ liệu; các loại bộ nhớ có chi phí cao hơn thường được trang bị ít hơn và được sử dụng khi cần thực hiện tính toán nhanh.

Các loại bộ nhớ thường thấy trong một máy tính theo tốc độ truy cập giảm dần bao gồm:

  • Register: Là phần bộ nhớ nằm ngay trên CPU, có khả năng thực hiện tính toán nhanh nhất. Một register trong các máy tính hiện nay thường có ~64~ bit; một nhân CPU có nhiều register (khoảng vài chục) cho các mục đích tính toán khác nhau.
  • Cache (SRAM): Là phần bộ nhớ nằm rất gần CPU, đóng vai trò là phần bộ nhớ trung gian giúp tăng tốc độ truy cập dữ liệu từ main RAM. Khi cần thực hiện phép tính, các ô nhớ được quan tâm sẽ được đưa từ cache vào register, hoặc từ main RAM vào cache rồi vào register. Ta sẽ tìm hiểu cơ chế này ở phần sau.
  • Main RAM (DRAM): Là phần bộ nhớ chứa các thông tin đang được hệ điều hành xử lý khi chạy các chương trình trên máy tính hay các biến trung gian phát sinh. Giới hạn bộ nhớ của main RAM hiện nay rất đa dạng và tùy thuộc vào từng dòng máy tính, có thể dao động từ ~8~ GB đến hơn ~128~ GB. Khi cần thực hiện phép tính, các ô nhớ cần quan tâm sẽ được đưa từ main RAM vào cache rồi vào register.
  • Secondary storage: Là phần bộ nhớ chủ yếu được sử dụng để lưu trữ như ổ đĩa cứng, bộ nhớ đám mây,... Khác với 2 loại RAM ở trên, phần bộ nhớ này sẽ không bị mất thông tin khi máy tính không được cung cấp điện. Với cấu trúc vi mạch cực kỳ đơn giản, phần bộ nhớ này có thể lưu trữ lượng thông tin lên đến nhiều TB. Khi một chương trình được khởi động, các ô nhớ tương ứng sẽ được đưa từ Secondary storage vào main RAM.

Để dễ hình dung sự khác biệt về kích thước và tốc độ truy cập của từng loại bộ nhớ, chúng ta sẽ tìm hiểu số liệu dựa trên một máy tính cá nhân qua bảng dưới. Lưu ý rằng thời gian truy cập chỉ mang tính tương đối vì thời gian thực tế phụ thuộc vào rất nhiều yếu tố khác nhau.

Phần bộ nhớ Giới hạn bộ nhớ trong 1 nhân Thời gian truy cập (chu kì) Thời gian truy cập
(giây, tần suất 1.7 GHz)
Register ~64~ bit ~\times~ số register Không đáng kể Không đáng kể
L1 cache ~80~ KB (performance) / ~96~ KB (efficiency) ~3~ chu kì ~1.76~ nano-giây
L2 cache ~1280~ KB (performance) / ~2048~ KB cho 4 nhân dùng chung (efficiency) ~9~ chu kì ~5.29~ nano-giây
L3 cache ~12~ MB cho tất cả các nhân dùng chung ~43~ chu kì ~25.28~ nano-giây
Main RAM ~8~ đến ~16~ GB ~400~ chu kì ~235.20~ nano-giây
Local Disk (SSD) ~256~ GB đến ~2~ TB hơn ~3 \cdot 10^5~ chu kì hơn ~170~ micro-giây

Lưu ý, có hai loại nhân là performance core và efficiency core phục vụ hai mục đích khác nhau. Do đó, cấu trúc bộ nhớ trong hai loại nhân này cũng là khác nhau.

Trong lập trình thi đấu, ta thường sử dụng giới hạn ~10^8~ hay ~2 \cdot 10^8~ như một mức ước lượng "an toàn" về số thao tác tính toán ta có thể thực hiện trong ~1~ giây. Như vậy, ta đang giả sử một phép tính trung bình mất ~5~ đến ~10~ nano-giây -- một con số tương đối phù hợp với các dòng máy hiện hành.

Cache

Có thể thấy từ số liệu trên, tốc độ truy cập và tính toán của register là nhanh hơn rất nhiều (khoảng 300 lần) so với RAM. Nếu việc lấy dữ liệu để tính toán được diễn ra trực tiếp trên RAM, ta có thể thấy đây là một sự lãng phí vô cùng lớn. Cache được tạo ra để giải quyết vấn đề này, khi register yêu cầu một vùng nhớ để thực hiện tính toán, một trong hai điều sau sẽ xảy ra:

  • Nếu vùng nhớ mà register cần gọi đã có sẵn trong cache, máy tính chỉ việc lấy thông tin từ phần bộ nhớ này. Ta gọi đây là cache hit.
  • Nếu vùng nhớ mà register cần gọi chưa có trong cache, máy tính quay về main RAM và lấy một Cache Line (tức ~64~ bytes) ghi đè lên một vùng nhớ nào đó trong cache theo một chiến lược nhất định. Ta gọi đây là cache miss.

Trong các máy tính hiện nay, cache thường được chia ra thành nhiều lớp trung gian gọi là L1, L2, L3,... nhằm tối ưu hơn nữa quá trình vận chuyển thông tin từ main RAM sang register.

Đến đây, bạn đọc cũng có thể nhận ra, cụm từ cache-friendly coding chính là việc viết code giúp máy tính hạn chế cache miss một cách tối đa.

Lệnh máy (CPU instruction)

Để thực hiện các phép tính, phép gán, biến đổi,... tất cả các code khi chạy sẽ được biến thành lệnh máy (CPU instruction). Tùy vào mỗi dòng sản phẩm CPU và các lệnh khác nhau, hiệu suất thu được cũng là khác nhau. Để đánh giá các lệnh máy cơ bản trên bộ xử lý 13th Gen Intel(R) Core(TM) i7-1355U (1.70 GHz) (thuộc dòng Alder Lake), ta phân tích hai đại lượng là độ trễ và thông lượng qua bảng sau số liệu sau:

Lệnh Độ trễ (Alder Lake-P) Thông lượng (Alder Lake-P) Độ trễ (Alder Lake-E) Thông lượng (Alder Lake-E)
Phép cộng cơ bản (VPADDQ) 1 0.33 2 0.67
Phép nhân cơ bản (VMULPD) 4 0.5 6 0.5
Phép truy cập bộ nhớ cơ bản (VMOVDQA) 4; 12 0.5 7; 11 1.0
Phép chia cơ bản (VDIVPD) 13; 22 8.0 30; 33 32.0

Số liệu cụ thể được tham khảo tại đây và được đo theo đơn vị chu kì trên tần số 1.70 GHz.

Lưu ý, một số thao tác có đến hai giá trị cho độ trễ, lý do là vì ta xét tốc độ của thao tác trong các hoàn cảnh khác nhau.

Khi tính toán độ phức tạp, ta xem cả 4 thao tác trên có cùng độ phức tạp ~\mathcal{O}(1)~. Tuy nhiên, không khó để nhận ra rằng với cùng số thao tác, các loại lệnh máy khác nhau có thể cho ra chênh lệch về tốc độ một cách đáng kể.

! Important
Cần nhấn mạnh một điều rằng độ trễ của phép truy cập bộ nhớ và phép chia là tương đối lớn. Đáng nói, thông lượng của phép chia là rất tệ khi so sánh với 3 lệnh còn lại. Khi thực hiện tối ưu hiệu suất của các chương trình, ta cần xác định được đâu là yếu tố đang gây ra sự chậm trễ của chương trình.

Sơ lược về benchmarking

Đối với các code đơn giản như vòng lặp và gán đơn thuần, đôi khi, việc so sánh thời gian sẽ không được diễn ra một cách công bằng do trình biên dịch của C++ sẽ tối ưu bằng cách lược bỏ các dòng code không "hữu ích".

Để "ép" trình biên dịch C++ biên dịch toàn bộ đoạn code cần so sánh để mô phỏng các đoạn code thực tế, ta cần sử dụng một thư viện benchmarking đặc biệt. Trong bài viết này, mình sẽ sử dụng Google Benchmark – một thư viện benchmarking nổi tiếng dành cho C++.

Bạn đọc có thể cài đặt CMake và Google Benchmark theo hướng dẫn tại Github repository của thư viện này. Ngoài ra, bạn đọc cũng có thể tham khảo hướng dẫn sử dụng thư viện nếu muốn tự chạy lại các benchmark trong bài viết này. Do việc cài đặt và setup CMake cũng tương đối phức tạp, bạn đọc có thể tham khảo thêm các nguồn tài liệu sẵn có trên internet.

Ngoài ra, các benchmark trong bài viết này sẽ được chạy bằng bộ xử lý 13th Gen Intel(R) Core(TM) i7-1355U (1.70 GHz) trên máy tính cá nhân. Bạn đọc có thể tham khảo toàn bộ code được sử dụng để benchmark trong bài viết này ở phần Phụ lục ở cuối bài viết.

Cache-friendly coding

Như vậy, qua phần phân tích cấu trúc bộ nhớ máy tính, câu hỏi về việc vì sao cùng một thuật toán, nhưng hai cách cài đặt khác nhau lại có thể cho ra hai thời gian chạy rất khác nhau cũng đã phần nào được giải đáp -- có thể lý giải rằng chương trình chậm hơn đã gây ra nhiều cache miss hơn trong quá trình cài đặt.

Câu hỏi đặt ra bây giờ là: Khi cài đặt, ta cần lưu ý điều gì để code của chúng ta luôn cho ra "chương trình nhanh hơn" trong câu chuyện nêu trên?

Cục bộ hóa
Cục bộ hóa khi duyệt mảng

Tất nhiên, chiến lược caching của máy tính không thể quá cầu kì và phức tạp được. Máy tính sẽ có xu hướng cache một vùng nhớ liên tục trong main RAM. Đây là một lưu ý cực kỳ quan trọng đối với các lập trình viên khi xử lý mảng.

Ta sẽ thử nghiệm hai kiểu duyệt mảng (tuần tự và ngẫu nhiên), sau đó sử dụng Google Benchmark để so sánh hai kiểu duyệt trên các kích thước mảng khác nhau.

Kết quả benchmark cho ~N = 2^k~ với ~k \in [4; 24]~ như sau:

Bạn đọc lưu ý rằng trục ~y~ được thể hiện bằng thang ~\log~ với cơ số ~10~, do đó, độ cao của các cột chỉ mang tính tương đối.

Có thể thấy, kể từ mốc ~N = 2^{16} \approx 7 \cdot 10^4~, tốc độ của cách duyệt tuần tự đã là nhanh gấp 3 lần cách duyệt ngẫu nhiên. Ta có thể lý giải điều này là do cách duyệt ngẫu nhiên gây ra quá nhiều cache miss khi ~N~ đủ lớn; mặt khác, với các kích thước ~N \leq 2^{12}~, ta chưa thấy khác biệt rõ rệt là vì kích thước các cache L1, L2, L3 đủ lớn để gần như chứa cả mảng.

Cục bộ hóa khi khai báo bộ nhớ

Khi tự thiết kế các cấu trúc dữ liệu, hoặc sử dụng các cấu trúc dữ liệu thuộc thư viện chuẩn của C++, chúng ta cùng cần lưu ý đến tính phân mảnh (fragmentation) của bộ nhớ. Nói cách khác, ta cần quan tâm đến việc bộ nhớ của cấu trúc dữ liệu sẽ được lưu trong một dải các ô nhớ liên tiếp trong RAM (phân mảnh thấp) hay những ô nhớ rời rạc xa nhau (phân mảnh cao).

Ví dụ, các cấu trúc dữ liệu sau đây có tính phân mảnh thấp, phù hợp với cache-friendly coding:

Cấu trúc dữ liệu Giải thích
Mảng (nhiều chiều),
vector một chiều
Mảng (nhiều chiều) và vector một chiều được lưu trữ dưới dạng một dải bộ nhớ liên tục trong RAM, từ đó, việc caching trên mảng và vector cũng được diễn ra thuận tiện hơn.
Hàng đợi ưu tiên Vì cơ chế hoạt động của hàng đợi ưu tiên cho phép đánh số các nút trên cây nhị phân và lưu trữ chúng dưới dạng mảng, container được sử dụng mặc định là vector nên việc tổ chức bộ nhớ cũng cache-friendly tương tự vector.
Bitset và vector chứa kiểu dữ liệu bool Bitset là một cấu trúc dữ liệu cực kì cache-friendly vì dữ liệu được tổ chức một cách rất dày đặc (1 phần tử chiếm đúng 1 bit), vector<bool> là một trường hợp đặc biệt của vector và cũng có cơ chế lưu trữ tương tự. Lưu ý rằng bitset và vector<bool> hiệu quả hơn mảng về mặt lưu trữ vì một giá trị bool thông thường chiếm 1 byte (8 bit).

Mặt khác, các cấu trúc dữ liệu sau đây có tính phân mảnh trung bình/cao:

Cấu trúc dữ liệu Giải thích
Vector nhiều chiều Một vector ~d~ (với ~d \geq 2~) chiều lại được lưu trữ dưới dạng một dải bộ nhớ liên tục, mỗi ô nhớ lại là một con trỏ, trỏ đến một vector ~d - 1~ chiều khác. Ví dụ, nếu chỉ thao tác trên các ô thuộc một dòng của vector 2 chiều, CPU vẫn có thể thực hiện caching hiệu quả; nhưng nếu thao tác trên các dòng khác nhau, tính cục bộ sẽ không còn được đảm bảo do bộ nhớ của các dòng có thể được lưu trữ ở các vị trí rời rạc trên RAM.
Hàng đợi hai đầu,
stack,
queue
Hàng đợi hai đầu có tính phân mảnh trung bình vì dữ liệu được lưu dưới dạng các block rời rạc. Một điều bất ngờ là stack và queue đều sử dụng container mặc định là deque nên điều tương tự cũng xảy ra với 2 cấu trúc dữ liệu này.
Danh sách liên kết đơn,
danh sách liên kết đôi,
set,
map
Các cấu trúc dữ liệu dựa trên node (node-based data structures) có tính phân mảnh rất cao vì các phần tử được khai báo một cách rời rạc.

Để so sánh tốc độ chạy của một số cấu trúc dữ liệu nêu trên, mình thực hiện benchmark thời gian thực hiện ~10^9~ thao tác duyệt trên mảng, hàng đợi hai đầu và danh sách liên kết đơn. Kết quả như sau:

Có thể thấy, chênh lệch về hiệu suất của mảng/vector so với hàng đợi hai đầu là không quá lớn, do các ô nhớ của hàng đợi hai đầu vốn không quá phân mảnh. Tuy nhiên, danh sách liên kết đơn lại có tốc độ duyệt cực kì chậm khi số phần tử tăng cao, điều này là do các phần tử của danh sách liên kết đơn được lưu trữ rải rác trong RAM, khiến việc cục bộ hóa trở nên khó khăn.

Tất nhiên, những cấu trúc dữ liệu nêu trên, dù cho có cache-friendly hay không, đều có những ưu điểm riêng của nó. Tùy vào nhu cầu sử dụng trong từng trường hợp, ta chọn những cấu trúc dữ liệu khác nhau.

Tối ưu bộ nhớ

Trong rất nhiều trường hợp, khi cố gắng tối ưu bộ nhớ, ta cũng vô tình tối ưu thời gian chạy của thuật toán, dù trên lý thuyết, số thao tác duyệt không thay đổi. Qua số liệu benchmark ở phần trước, ta cũng thấy rằng, cùng là duyệt ~10^9~ thao tác trên cùng một loại cấu trúc dữ liệu, việc tăng/giảm kích thước của cấu trúc dữ liệu đó cũng ảnh hưởng nhiều đến tốc độ cuối cùng.

Điều này có thể được giải thích như sau: khi sử dụng càng ít bộ nhớ, vùng nhớ được đưa vào 1 trong 3 cache sẽ có xu hướng chiếm một phần lớn hơn của vùng nhớ cần thao tác; hơn nữa, khi random-access với vùng nhớ nhỏ, ta sẽ có xác suất truy cập lại vào các vùng nhớ cũ vẫn còn nằm ở trong cache, từ đó tăng số lần cache hit hơn.

Sau đây là một biểu đồ thể hiện số megabytes mà CPU có thể thực hiện random-access trong ~1~ giây trên một mảng gồm ~N = 2^k~ (với ~k \in [2; 22]~) phần tử:

Có thể thấy, tốc độ truy cập khá tương đồng ở nhóm ~N \in [2^3; 2^{11}]~ do toàn bộ dữ liệu đều được đưa vào L1 cache. Ở các kích thước lớn hơn, tỉ lệ cache hit giảm dần kéo theo tốc độ truy cập cũng giảm mạnh.

Khử đệ quy

Trong các tài liệu lập trình nói chung, ta vẫn thường được nghe đến cụm từ "khử đệ quy". Thật vậy, trong những trường hợp có thể thay thế đệ quy thành vòng lặp, chúng ta nên sử dụng vòng lặp vì cơ chế sử dụng bộ nhớ của hai cách cài đặt rất khác nhau và từ đó tạo ra một khác biệt đáng kể về tốc độ và bộ nhớ.

Cơ chế tổ chức bộ nhớ trong hàm đệ quy

Khi dùng hàm đệ quy, với mỗi lần gọi hàm, chương trình sẽ khai báo một stack frame dùng để lưu trữ các tham số, biến trung gian,... Ví dụ, với hàm đệ quy sau:

void iterate (int node = 1, int depth = 1) { // Hàm duyệt cây nhị phân
    if (depth == 3) return;
    iterate(node * 2, depth + 1); // duyệt cây con trái
    iterate(node * 2 + 1, depth + 1); // duyệt cây con phải
}

Khi duyệt đến nút số ~6~ ở tầng thứ ~3~, bộ nhớ stack đang phải lưu trữ bộ nhớ của cả ba lần gọi đệ quy chưa hoàn thành là ~\texttt{iterate}(1, 1)~, ~\texttt{iterate}(3, 2)~ và ~\texttt{iterate}(6, 3)~.

Như vậy, lượng bộ nhớ cần dùng là tích của độ sâu đệ quy và kích thước của một stack frame. Mặt khác, khi dùng vòng lặp, độ phức tạp bộ nhớ chỉ sẽ bằng độ phức tạp bộ nhớ của phần nằm bên trong vòng lặp.

Ví dụ, cả hai hàm sau đều có mục đích tương đương, nhưng hàm đệ quy có độ phức tạp bộ nhớ tuyến tính còn hàm sử dụng vòng lặp có độ phức tạp hằng số.

void recursiveFunction (int n, int i = 0) {
    if (i + 1 < n) recursiveFunction(n, i + 1);
    // ...
}

void iterativeFunction (int n) {
    for (int i = 0; i < n; i++) {
        // ...
    }
}
Bất lợi của hàm đệ quy

Với cơ chế hoạt động của đệ quy so với vòng lặp, ta có thể thấy một số nguyên nhân khiến đệ quy hoạt động kém hiệu quả hơn như sau:

  • Gọi hàm nhiều lần: Đối với các hàm thông thường, thời gian được sử dụng cho việc gọi hàm là cực kì ít so với phần còn lại của code. Tuy nhiên, khi phải gọi hàm nhiều lần như khi làm việc với hàm đệ quy, thời gian gọi hàm cũng đóng góp một lượng thời gian đáng kể vào tốc độ chạy của chương trình, bạn đọc có thể tham khảo thêm về cơ chế gọi hàm tại đây.
  • Cache miss: Từ việc sử dụng nhiều bộ nhớ hơn một cách đáng kể, ta dễ thấy rằng hàm đệ quy dễ gây ra cache miss hơn nhiều so với vòng lặp.
  • Một lý do khác khiến đệ quy chậm hơn vòng lặp mà ta không đào sâu trong khuôn khổ bài viết này là branching.

Ngoài ra, người ta thường khử đệ quy là vì bộ nhớ của đệ quy được khai báo trên bộ nhớ stack, vùng bộ nhớ này vốn có giới hạn nhỏ.

Tuy nhiên, như đã đề cập, đó là trong những trường hợp ta có thể thay thế đệ quy bằng vòng lặp. Trong lập trình nói chung, đệ quy vẫn là một công cụ cực kỳ mạnh mẽ và hữu ích trong nhiều trường hợp cụ thể.

Ứng dụng trong lập trình thi đấu

Quy hoạch động nhiều chiều
Sử dụng cài đặt bottom-up khi có thể

Khi tìm hiểu Quy hoạch động cơ bản, ta vẫn thường được nghe đến hai cách cài đặt bottom-up và top-down. Tuy cả hai cách trông có vẻ như sẽ cho kết quả giống nhau, nhưng điểm khác biệt lại nằm ở chỗ cách cài đặt bottom-up sử dụng vòng lặp và cách còn lại sử dụng hàm đệ quy.

Do đó, trong những trường hợp mà thứ tự duyệt trạng thái Quy hoạch động đơn giản là duyệt một biến từ bé đến lớn (hoặc ngược lại), cách cài đặt bottom-up sẽ là lựa chọn mang lại hiệu quả về thời gian chạy vượt trội so với cách cài đặt còn lại. Ta chỉ thường sử dụng top-down trong những trường hợp mà việc xác định thứ tự duyệt các trạng thái là phức tạp như DAG, vì bản chất việc sử dụng DFS để xác định thứ tự topo và hàm Quy hoạch động sử dụng đệ quy là tương đương nhau.

Giảm chiều Quy hoạch động

Đối với các dạng Quy hoạch động mà khoảng cách giữa các tham số truy hồi trong cùng một chiều là không quá lớn, ta hoàn toàn có thể rút bớt số chiều Quy hoạch động xuống để tiết kiệm bộ nhớ. Ví dụ, với công thức truy hồi cho bài toán Quy hoạch động cái túi (bỏ qua các trường hợp biên):

~\texttt{solve}(i, j) = \max(\texttt{solve}(i - 1, j), \texttt{solve}(i - 1, j - w_i) + v_i)~

Ta thấy rằng để tính bất kì giá trị nào trên dòng ~i~ trên bảng Quy hoạch động, ta chỉ cần xét đến các giá trị trên dòng ngay trên nó (tức dòng ~i - 1~); hơn nữa, kết quả cuối cùng mà ta quan tâm chỉ là các giá trị nằm trên dòng thứ ~n~. Từ đó, ta có thể chỉ duy trì hai dòng liên tiếp của bảng Quy hoạch động. Dòng thứ ~i~ trên công thức định nghĩa bây giờ sẽ được lưu trên dòng thứ ~i \bmod 2~ trên bảng Quy hoạch động, và dòng ~i - 1~ trên công thức ứng với dòng ~(i - 1) \bmod 2~ của bảng.

Thậm chí, với đặc điểm của thuật toán Quy hoạch động cái túi, ta có thể giảm bộ nhớ xuống còn đúng 1 dòng, tức chỉ dùng mảng 1 chiều gồm ~C~ phần tử!

Tổng quát hơn, nếu hàm Quy hoạch động ~\texttt{solve}(i, \dots)~ chỉ truy hồi đến một giá trị ~\texttt{solve}(i - d, \dots)~ nào đó với ~d~ là một hằng số đủ nhỏ, ta hoàn toàn có thể chỉ quan tâm ~d + 1~ dòng liên tiếp của bảng Quy hoạch động.

Lưu ý rằng cách làm trên vẫn giữ nguyên độ phức tạp thời gian của Quy hoạch động cái túi là ~\mathcal{O}(nC)~, tuy nhiên, bộ nhớ bây giờ đã được giảm xuống ~\mathcal{O}(C)~ -- cực kỳ có lợi cho việc caching vùng bộ nhớ được sử dụng cho Quy hoạch động.

So sánh

Để so sánh, mình sẽ giải bài tập Knapsack 1 thuộc AtCoder Educational DP Contest trên nền tảng VNOJ bằng 3 lời giải khác nhau. Kết quả so sánh như sau:

Tiêu chí đánh giá Top-down Bottom-up, bộ nhớ ~\mathcal{O}(nC)~, duyệt ~i~ trước Bottom-up, bộ nhớ ~\mathcal{O}(C)~
Thời gian ~0,538~ giây ~0,106~ giây ~0,012~ giây
Bộ nhớ ~89,88~ MB ~80,19~ MB ~3,95~ MB

Lưu ý, nền tảng VNOJ chỉ cho phép xem bài nộp của người dùng khác khi bạn đã giải được bài tập đó. Bạn đọc có thể tham khảo bài viết về Quy hoạch động cái túi ở nền tảng VNOI Wiki.

Nhân ma trận

Nhắc lại, phép nhân ma trận ~\mathbf{A} \times \mathbf{B}~ (với kích thước của ~\mathbf{A}, \mathbf{B}~ lần lượt là ~n \times m~ và ~m \times q~) cho ra ma trận ~\mathbf{C}~ có kích thước ~n \times q~ được định nghĩa theo công thức:

~\mathbf{C}_{i, j} = \sum_{1 \leq k \leq m} \mathbf{A}_{i, k} \cdot \mathbf{B}_{k, j}~

Trong bài viết này, ta sẽ xét bài toán nhân ma trận có modulo -- dạng nhân ma trận thường gặp trong lập trình thi đấu. Nói cách khác, ta cần trả về ma trận ~\mathbf{C}~ có giá trị đã được chia lấy dư cho một modulo ~M~ cho trước. Trong các code được dùng trong bài viết này, mình sử dụng ~M = 998 \space 244 \space 353~.

Sử dụng ma trận chuyển vị

Đối với các mảng hai chiều, bộ nhớ thường được trải phẳng theo dòng, nói cách khác, các phần tử thuộc cùng một dòng sẽ được tổ chức thành một đoạn liên tiếp trên mảng trải phẳng. Do đó, trên các ma trận lớn, ta thường ưu tiên duyệt dòng rồi cột hơn là cột rồi dòng trên mảng hai chiều.

Ta thấy rằng khi thực hiện phép tích chập để tính ~\mathbf{C}_{i, j}~, ta đang duyệt các phần tử trên dòng thứ ~i~ của ~\mathbf{A}~ (cache-friendly), nhưng lại duyệt các phần tử trên cột ~j~ của ~\mathbf{B}~ (cache-unfriendly).

Để khắc phục điều này, ta có thể tính trước ma trận ~\mathbf{B}^\intercal~ (với ~\mathbf{B}^\intercal_{i, j} = \mathbf{B}_{j, i}~) để có thể duyệt các phần tử trên dòng thứ ~j~ của ~\mathbf{B}^\intercal~. Công thức nhân ma trận trở thành:

~\mathbf{C}_{i, j} = \sum_{1 \leq k \leq m} \mathbf{A}_{i, k} \cdot \mathbf{B}^\intercal_{j, k}~
Phương pháp phân khối (tiling)

Có thể thấy, phép nhân ma trận thông thường gặp phải 2 vấn đề khiến tốc độ thực hiện trở nên chậm:

  • Không có tính cục bộ cao nên caching không tốt.
  • Sử dụng đúng ~n \times m \times q~ thao tác modulo -- một điểm trừ cực kỳ lớn của nhân ma trận có modulo.

Trong phần này, ta sẽ tìm hiểu phương pháp phân khối -- một phương pháp nhân ma trận khắc phục được hai điểm yếu trên tương đối tốt.

Trước tiên, ta dựa vào nhận xét rằng với các ma trận nhỏ, cho dù có duyệt các phần tử theo thứ tự nào, tốc độ nhân ma trận vẫn thường rất nhanh. Ta có thể lý giải đơn giản là kích thước của cả ba ma trận ~\mathbf{A}, \mathbf{B}, \mathbf{C}~ đủ nhỏ để lưu trữ trong L1 cache.

Bây giờ, ta chia nhỏ một phép nhân ma trận vuông ~8 \times 8~ thành các phép nhân ma trận nhỏ ~2 \times 2~. Ví dụ, để tính giá trị của một ma trận con ~2 \times 2~ nằm ở góc trên trái của ~\mathbf{C}~, ta thực hiện 4 bước nhân ma trận ~2 \times 2~ như sau:

Lưu ý, các ô có cùng màu sẽ được thực hiện trong cùng một lần nhân ma trận.

Do kích thước của ba ma trận con ~2 \times 2~ là đủ nhỏ để chứa trong L1 cache và truy cập nhanh, ta xem như chi phí truy cập một phần tử nào đó chỉ bằng lần truy cập đầu tiên trong bước nhân ma trận ~2 \times 2~ tương ứng. Dễ thấy, để thực hiện phép nhân ma trận trên cả ma trận ~8 \times 8~, ta cần thực hiện ~\left( \frac{8}{2} \right)^3 = 64~ bước, mỗi bước thực hiện tốn ~3 \times 2^2 = 12~ lần đưa bộ nhớ vào L1 cache. Như vậy, tổng số lần truy cập bộ nhớ ngoài L1 cache là ~64 \times 12 = 768~, nhỏ hơn tổng số bước truy cập dữ liệu trong nhân ma trận thông thường là ~3 \times 8^3 = 1536~ gấp hai lần.

Tổng quát hơn, nếu kích thước của ma trận con là ~B \times B~ và kích thước của ma trận lớn là ~N \times N~ thì ta cần ~\frac{3N^3}{B}~ lần truy cập bộ nhớ ngoài L1 cache, tức ít hơn gấp ~B~ lần so với tổng số bước truy cập dữ liệu trong nhân ma trận thông thường.

Lưu ý, đây vẫn chỉ là một phép ước lượng khi bỏ qua chi phí truy cập các ô nhớ đã nằm trong L1 cache và bỏ qua những tối ưu khác trong quá trình truy cập dữ liệu của CPU; trên thực tế, cả hai cách tiếp cận đều cần truy cập bộ nhớ đúng ~3N^3~ lần. Nói cách khác, cách làm này không giúp giảm số lần truy cập bộ nhớ mà làm cho mỗi lần truy cập bộ nhớ được tối ưu hơn.

Ta cần chọn một giá trị ~B~ vừa đủ lớn vừa đảm bảo cả ba ma trận con ~B \times B~ đều có thể nằm vừa trong L1 hoặc L2 cache. Trên thực tế, giá trị ~B~ thường dùng cho phương pháp phân khối khi nhân ma trận là ~B = 16~ hay ~B = 32~, đủ để chứa cả ba ma trận con có mỗi phần tử chiếm ~8~ bytes (như long long hay double trong C++) trong một L1 cache thông thường có kích thước ~32~ KB.

Hơn nữa, với ~B = 16~, ta thấy rằng ~M^2 \cdot B < 2^{64}~ vẫn không gây tràn số trên số nguyên ~64~-bit, với các giá trị ~M \approx 10^9~ thường gặp trong lập trình thi đấu. Như vậy, ta chỉ cần thực hiện modulo sau khi đã thực hiện xong mỗi phép tích chập.

Có thể thấy, ta đã xử lý một lúc hai trở ngại thời gian lớn nhất được nêu trên trong phép nhân ma trận.

Ở phần cài đặt (xem phần Phụ lục ở dưới), mình thực hiện thêm một tối ưu nhỏ bằng cách chuyển vị ma trận ~\mathbf{B}~ ngay khi trích xuất ma trận con của nó. Trên nền tảng Library Checker, cách cài đặt của mình thực hiện được phép nhân ma trận ~1024 \times 1024~ trong dưới ~400~ mili-giây.

So sánh

Để so sánh hai cách tối ưu nêu trên, mình sẽ thực hiện benchmark cả 3 cách nhân ma trận trên ma trận vuông có độ dài cạnh ~N \in 2^k~ (với ~k \in [1; 10]~). Lưu ý, ma trận được sử dụng trong phần cài đặt là ma trận đã được trải phẳng thành vector 1 chiều nhằm tối ưu cache.

Bạn đọc lưu ý rằng trục ~y~ được thể hiện bằng thang ~\log~ với cơ số ~10~, do đó, độ cao của các cột chỉ mang tính tương đối.

Ngoài ra, bạn đọc có thể tham khảo animation sau được thực hiện bởi Jukka Suomela, so sánh phương pháp phân khối (bên phải ngoài cùng) với các phương pháp nhân ma trận khác.

Pragma

Một chi tiết được chú ý và bàn luận nhiều trên các nền tảng mạng xã hội về đề thi Học sinh giỏi Quốc gia môn Tin học năm nay (năm học 2025-2026) là dòng ghi chú ở đầu đề bài ở cả hai ngày thi như sau:

Nguồn: Fanpage VNOI

Trước khi tìm hiểu lý do dòng ghi chú này lại xuất hiện trong đề thi Học sinh giỏi Quốc gia, ta cùng tìm hiểu sơ lược về định hướng biên dịch chương trình (hay từ khóa pragma) và ứng dụng của nó trong lập trình C++.

Sơ lược về pragma

Từ khóa pragma là những chỉ thị đặc biệt dành cho trình biên dịch nhằm làm thay đổi một số hành vi của chương trình khi biên dịch chương trình. Từ khóa này thường được sử dụng kèm với các chỉ thị như once hay GCC poison hay GCC target và thường đi kèm các từ khóa như O2, unroll-loops, avx2,... tùy vào chỉ thị được dùng. Ta có thể sử dụng một lúc nhiều từ khóa như sau:

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

Nếu muốn sử dụng chỉ thị cho đúng một hàm của chương trình, ta có thể sử dụng thêm từ khóa __attribute__, ví dụ:

__attribute__((optimize("O3", "unroll-loops"))) void function { /* ... */ }

Một lưu ý cực kỳ quan trọng khi sử dụng pragma đó là chúng ta cần hiểu những chỉ thị được dùng sẽ hoạt động như thế nào và hoạt động một cách hiệu quả trong những hoàn cảnh nào. Bởi việc sử dụng từ khóa pragma một cách mù quáng sẽ làm cho chương trình không chỉ không thể được tối ưu mà còn bị tệ đi nhiều lần.

Một trong những ví dụ của việc sử dụng pragma một cách mù quáng đó là copy/paste 3 dòng code sau vào đầu của mọi chương trình, đây là một thói quen không được khuyến khích, mặc dù trong phạm vi lập trình thi đấu, chúng rất ít khi làm chương trình tệ đi:

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

Do đây là bài viết tập trung vào tối ưu tốc độ và bộ nhớ, chúng ta chỉ sẽ tìm hiểu về hai chỉ thị liên quan đến tối ưu là:

#pragma GCC optimize(...)
#pramge GCC target(...)
Chỉ thị #pragma GCC optimize

Đúng như cái tên, chỉ thị này sẽ ra lệnh cho trình biên dịch thay đổi một vài chi tiết trong quá trình biên dịch nhằm thực hiện các tối ưu phần cứng cho chương trình cuối cùng. Một số từ khóa thường dùng với chỉ thị GCC optimize là:

Từ khóa Công dụng Lưu ý
O2 Thực hiện tất cả các loại tối ưu "an toàn" -- tức sẽ không phản tác dụng trong mọi trường hợp. Ví dụ: sắp xếp các lệnh độc lập để hạn chế thời gian CPU không làm gì, loại bỏ các phép tính trung gian trùng lặp,... Đã được tích hợp trong máy chấm Themis, VNOJ, Codeforces và đa số các Online Judges nên thường không cần dùng trong code.
O3 Bao gồm các tối ưu thuộc nhóm O2 và các tối ưu khác như tự động vector hóa các phép tính độc lập với SIMD, sử dụng nhiều hàm nội tuyến hơn, gộp một nhóm các thao tác liên tiếp trong vòng lặp để giảm số lần kiểm tra điều kiện lặp (loop unroll),... Trong khuôn khổ lập trình thi đấu, từ khóa O3 không mạo hiểm vì các code thường có độ dài không lớn hơn kích thước instruction cache.
Ofast Bao gồm các tối ưu thuộc nhóm O3 và các tối ưu có phần mạo hiểm hơn, như bỏ qua tiêu chuẩn của ngôn ngữ lập trình về xử lý số thực, nguyên tắc quản lý con trỏ,... Việc sử dụng từ khóa Ofast làm chương trình mất đi sự chính xác khi thao tác với số thực, khó caching và debug hơn. Từ khóa Ofast vẫn luôn là từ khóa gây tranh cãi vì bất lợi mà nó đem lại là không nhỏ. Đặc biệt khi làm việc với số thực, ta thường chấp nhận bỏ qua Ofast để chương trình được thực hiện một cách chặt chẽ hơn.
unroll-loops Trình biên dịch sẽ gộp một nhóm các thao tác liên tiếp trong lặp để giảm số lần kiểm tra điều kiện lặp.
Chỉ thị #pragma GCC target

Các chỉ thị GCC target giúp chúng ta thực hiện các chỉ thị liên quan để tập lệnh máy, ví dụ như sử dụng SIMD để thực hiện các lệnh liên tiếp không phụ thuộc vào nhau một cách song song. Một số từ khóa thường dùng với chỉ thị GCC target là:

Từ khóa Công dụng Lưu ý
avx, avx2 Chỉ định CPU sử dụng SIMD cho các lệnh có thể thực hiện song song (vectorization) nếu có. Sử dụng cùng optimize("O3") hay optimize("unroll-loops")
popcnt, lzcnt Làm cho tốc độ của __builtin_popcount() và __builtin_clz() nhanh hơn Lưu ý, độ phức tạp thời gian của hai hàm trên vẫn là ~\mathcal{O}(1)~ bất kể có sử dụng pragma hay không.
abm, bmi, bmi2 Các tối ưu nhắm vào các thao tác trên bit nói chung.
fma Thực hiện các phép tính nhân rồi cộng ~d = a \cdot b + c~ trong một lần (fused multiply-add).
omit-frame-pointer Giúp tăng kích thước register có thể sử dụng để tính toán, tiện lợi hơn cho việc caching. Đã được tích hợp vào chỉ thị optimize("O2").

Đây là một bài nộp trên nền tảng oj.uz đã có tận dụng tối đa các chỉ thị pragma để làm cho một thuật toán trâu có thể chạy vừa giới hạn thời gian. Tuy nhiên, đây là một bài tập có giới hạn khá lỏng để thí sinh sử dụng nhiều thuật toán khác nhau (chia căn, đường quét, chia để trị CDQ,...) và bản thân máy chấm của nền tảng này là tương đối mạnh.

Vì sao không nên sử dụng pragma trong thi cử?

Các chỉ thị pragma, ngoại trừ optimize("O2") đã được tích hợp trong máy chấm, nhìn chung là những tối ưu tạo ra nhiều biến số khi thực thi chương trình trên từng máy tính khác nhau; như đã đề cập, đôi khi việc sử dụng pragma một cách mù quáng không chỉ không thể tối ưu mà còn làm chậm tốc độ chương trình; chỉ thị Ofast thậm chí dẫn đến sai số tính toán và các hành vi không được kiểm soát (undefined behaviour).

Hơn nữa, trong môi trường lập trình thi đấu, việc xây dựng tư duy để thiết kế các thuật toán tối ưu được chú trọng hơn rất nhiều so với việc sử dụng những tối ưu không đáng kể để cố gắng ép các chương trình chưa tối ưu có thể vượt qua giới hạn thời gian.

Vì vậy, không chỉ tại kì thi Học sinh giỏi Quốc gia, nhiều kì thi lập trình thi đấu nói chung thường không cho phép sử dụng pragma để các thí sinh có thể tập trung vào các mặt tư duy, thiết kế thuật toán và cài đặt hơn là tối ưu phần cứng -- những bước tối ưu chỉ nên thực hiện sau khi đã có thuật toán tốt. Ngay ở những kì thi cho phép sử dụng pragma như ICPC hay IEEExtreme, các bài tập cũng được thiết kế sao cho thí sinh hoàn toàn có thể giải quyết mà không sử dụng từ khóa này.

Bài tập Codeforces 1146E -- Hot is Cold và lời giải vét cạn

Đây là một bài tập thú vị nhất thuộc vòng loại kì thi Forethought Future Cup, nếu xem các lời giải hiện đã nhận Accepted trên Codeforces, ta thấy các lời giải hầu hết rơi vào 1 trong 2 trường hợp sau:

  • Có thời gian chạy dưới 200ms.
  • Có thời gian chạy trên 950ms.

Nguyên nhân là do giới hạn của bài tập này là tương đối lỏng, hơn nữa, ta có thể tận dụng các nhận xét về tối ưu phần cứng để làm lời giải "vét cạn" trở nên nhanh hơn 1 giây. Trước tiên, ta cùng nghiên cứu đề bài.

Đề bài

Cho một dãy ~A~ gồm ~N~ phần tử và ~Q~ thao tác có dạng:

  • Cho hai số nguyên ~s_j, x_j~ với ~s_j \in \{<, >\}~ và ~x_j~ là một số nguyên bất kỳ. Với mọi ~1 \leq i \leq N~, ta đổi dấu ~A_i~ nếu bất đẳng thức ~A_is_jx_j~ đúng.

Yêu cầu in ra mảng ~A~ sau khi thực hiện ~Q~ thao tác.

Giới hạn:

  • ~1 \leq N \leq 10^5~
  • ~-10^5 \leq A_i, x_j \leq 10^5~.
  • Thời gian: ~1~ giây.
  • Bộ nhớ: ~256~ MB.
Phân tích

Mình cực kì khuyến khích các bạn tự giải quyết bài toán bằng lời giải chuẩn trước khi đọc tiếp phần này.

Trong bài viết này, chúng ta sẽ không bàn luận về lời giải chuẩn của bài toán, mà sẽ phân tích các lời giải có thời gian chạy hơn 950ms -- một lời giải vét cạn vừa đủ giới hạn thời gian.

Có thể thấy, với các thao tác trong lời giải vét cạn (so sánh, gán, và truy cập mảng), thao tác truy cập mảng là nguyên nhân chính cho tốc độ chậm của thuật toán. Đây sẽ là cơ sở để ta thực hiện tối ưu phần cứng.

Trong lời giải ~\mathcal{O}(NQ)~, ta duyệt các thao tác từ ~1~ đến ~Q~ rồi duyệt mảng từ ~1~ đến ~N~ (hoặc ngược lại, duyệt mảng rồi duyệt thao tác). Khi này, thời gian truy cập mảng ~A~ là vô cùng lớn, vì phải cách ~N~ lần truy cập mảng thì ta mới truy cập lại một ô nhớ cũ. Điều này làm cache miss diễn ra rất nhiều nếu kích thước mảng lớn hơn sức chứa của các cache L1, L2, L3.

Thay vào đó, sử dụng một ý tưởng tương tự phương pháp phân khối trong nhân ma trận, ta chỉ giải bài toán cho một phần nhỏ của mảng ~A~ đủ để chứa trong L1 cache. Sau đó, lặp lại ý tưởng với phần còn lại của mảng. Nói cách khác, ta sẽ chia mảng ~A~ thành các nhóm gồm ~B~ phần tử rồi áp dụng lời giải vét cạn lên từng nhóm. Ta chọn ~B = 1024~ để mọi phần tử thuộc cùng một nhóm có thể nằm trọn trong L1 cache.

Dĩ nhiên, số thao tác truy cập phần tử vẫn là ~NQ~, tuy nhiên, khác biệt nằm ở thứ tự truy cập. Có thể thấy, ở cách ban đầu, thứ tự truy cập là như sau:

Tuy nhiên, ở cách chia nhóm, ta tận dụng rất triệt để việc các phần tử đã được đưa vào L1 cache sau thao tác đầu tiên:

Lưu ý, màu sắc của mỗi phần tử dùng để ước lượng tốc độ truy cập chỉ mang tính tương đối, tốc độ truy cập các phần tử phụ thuộc vào rất nhiều yếu tố khác nhau đang diễn ra trong bộ nhớ máy tính.

Nếu kết hợp ý tưởng trên với chỉ định sử dụng SIMD (avx và avx2), chỉ định gộp phép nhân và phép cộng (fma), tối ưu Ofast, unroll-loops và input/output nhanh (sử dụng scanf), ta đã có thể giải quyết bài toán với lời giải "vét cạn".

Lời kết

Việc hiểu được cơ chế hoạt động của máy tính ở cấp độ bộ nhớ phần nào cũng sẽ giúp các lập trình viên có được cho mình những thói quen cài đặt hiệu quả hơn. Tuy nhiên, theo quan điểm cá nhân, mình nghĩ rằng nền tảng lớn nhất của một chương trình tốt vẫn luôn nằm ở ý tưởng thuật toán khôn ngoan, ở một độ phức tạp thời gian/bộ nhớ đủ tốt; chỉ khi đó, những tối ưu phần cứng như trên mới thực sự có ý nghĩa và tạo ra được một chương trình tối ưu.

Cảm ơn các bạn đã theo dõi bài viết. Xin chúc tất cả các bạn đọc của Tạp chí VNOI một mùa Tết thật ấm no, đầy đủ và hạnh phúc cùng gia đình, người thân và bạn bè!

Phụ lục

Code benchmark và vẽ biểu đồ

Bạn đọc có thể tham khảo các code đã được sử dụng để benchmark trong bài viết này:

  • So sánh tốc độ truy cập của CPU theo từng cách duyệt
  • So sánh tốc độ duyệt ~10^9~ thao tác với các CTDL khác nhau
  • So sánh khả năng random-access trên các mảng có kích thước khác nhau
  • So sánh tốc độ nhân ma trận

Các biểu đồ trong bài viết được thực hiện dựa trên thư viện Matplotlib.

Tài liệu tham khảo
  • Igor Ostrovsky, Gallery of Processor Cache Effects.
  • Jonathan Müller, Cache Friendly C++, Meeting C++ 2025.
  • Prof. Peter YK Cheung, Introduction to Memories and Computer Architecture, Imperial College London.
  • Nhiều tác giả, What does it mean for code to be "cache-friendly"?, Stack overflow.
  • Tianqi Chen, Hardware Acceleration, Carnegie Mellon University.
  • nor, GCC Optimization Pragmas.
  • Options That Control Optimizations, GCC, the GNU Compiler Collection.
  • Algorithmica, Contract Programming.
  • Algorithmica, AoS and SoA.
  • Alvin Wan, How to tile matrix multiplication.
unknownnaciul
o9, Tháng 2, 2026, 1:00 1

5

Tạp chí VNOI Xuân Bính Ngọ - Coding Challenge #04

unknownnaciul đã đăng vào 10, Tháng 2, 2026, 1:00

Coding Challenge

Coding Challenge là một chuyên mục thuộc Tạp chí VNOI, được xây dựng với mục tiêu mang đến cho cộng đồng lập trình thi đấu Việt Nam một sân chơi hữu ích và thiết thực nhằm giúp các độc giả củng cố kiến thức thuật toán và thúc đẩy tinh thần học hỏi, chia sẻ trong cộng đồng.

Hoạt động được tổ chức định kỳ hai lần mỗi tháng trên Fanpage VNOI, Group Facebook “Diễn đàn Tin học Việt Nam” và nền tảng chấm bài trực tuyến VNOJ. Toàn bộ các bài toán trong Coding Challenge được biên soạn bởi đội ngũ Tình nguyện viên thuộc Team Contest của VNOI. Chất lượng các bài toán Coding Challenge được đảm bảo về tính chuyên môn, có sự đa dạng về chủ đề và độ khó cũng như đáp ứng yêu cầu về tính chặt chẽ và chính xác.

Độc giả của Tạp chí VNOI có thể tham gia giải các bài toán thuộc Coding Challenge và gửi lời giải về cho Tạp chí. Những bài giải chất lượng sẽ được chọn lọc để đăng tải trong các số Tạp chí tiếp theo. Bên cạnh việc được ghi nhận và giới thiệu rộng rãi đến cộng đồng, các tác giả có lời giải được đăng tải sẽ nhận những phần quà tri ân từ VNOI nhằm khuyến khích tinh thần ham học hỏi và chia sẻ kiến thức đến cộng đồng

Các lời giải được chọn lọc để đăng công khai sẽ được đánh giá dựa trên tiêu chí về sự rõ ràng, mạch lạc, sáng tạo; có sự đầu tư trong diễn giải tư duy, cấu trúc và tính dễ hiểu. Đối với các bài toán có nhiều subtask, tác giả cần trình bày đầy đủ ý tưởng và hướng giải quyết cho tất cả các phần.

Phần thưởng dành cho mỗi lời giải được đăng tải trên Tạp chí là một bộ quà tặng gồm sổ tay, bút, sticker và móc khóa. Đặc biệt, với các tác giả có từ 03 bài giải trở lên được đăng trong chuyên mục, Ban biên tập Tạp chí sẽ gửi tặng một chiếc áo VNOI như lời tri ân cho những đóng góp tích cực đối với Coding Challenge.

Tiếp nối 03 số đầu tiên, các Coding Challenge #04, #05, #06 và #07 tiếp tục ghi nhận sự ủng hộ nhiệt tình của cộng đồng lập trình thi đấu. Hệ thống đã ghi nhận tổng cộng 2086 lượt nộp bài và ~x~ lời giải được gửi về cho Tạp chí.

Đội ngũ tạp chí VNOI xin gửi lời cảm ơn chân thành đến tất cả các bạn đã tích cực tham gia các Coding Challenge và đóng góp những lời giải vô cùng chất lượng trong thời gian qua!

Coding Challenge #4

Coding Challenge #4 có tên là Cân xu. Đây là bài toán mở rộng từ bài toán Quy hoạch động trên đồ thị có hướng không chu trình (DAG). Để giải quyết được bài toán lần này, yêu cầu các bạn thí sinh phải đưa ra nhiều nhận xét một cách khéo léo để biến đổi bài toán về một bài tập cấu trúc dữ liệu kinh điển.

Bài toán Cân xu đã được 362 thí sinh thử sức và có 9 lời giải xuất sắc đạt được số điểm tối đa.

Để hiểu rõ hơn về lời giải của bài toán, mời các bạn theo dõi lời giải xuất sắc do bạn Nguyễn Hoàng Minh biên soạn.

Đề bài

Bạn đọc có thể theo dõi đề bài gốc và subtasks tại nền tảng VNOJ. Sau đây là bản tóm tắt của đề bài.

Cho ~n~ đồng xu có trọng lượng phân biệt và ~m~ mối liên hệ không mâu thuẫn nhau có dạng ~x > y~, cho biết đồng xu ~x~ nặng hơn đồng xu ~y~. Gọi cấp bậc của một đồng xu là thứ tự của nó khi sắp xếp các đồng xu theo trọng lượng.

Với mỗi đồng xu, ta cần xác định giá trị ~k~ sao cho có thể xác định chính xác cấp bậc của đồng xu đó mà chỉ sử dụng ~k~ mối liên hệ đầu tiên; hoặc cho biết không thể xác định cấp bậc của đồng xu khi sử dụng cả ~m~ mối liên hệ.

Giới hạn:

  • ~2 \leq n \leq 2 \cdot 10^5~.
  • ~1 \leq m \leq 8 \cdot 10^5~.

Lời giải

Subtask 1

Giới hạn: ~n \leq 7~; ~m \leq 20~

Ý Tưởng
  • Đặt ~P~ là thứ tự của các đồng xu được sắp xếp tăng dần theo cấp bậc của chúng. Với định nghĩa này, ta có thể biểu diễn ~P~ bằng một hoán vị của ~n~ số tự nhiên đầu tiên.

    Ví dụ Với ~n = 3~, ~P = (2, 3, 1)~ có thể được hiểu như:

    • Đồng xu ~2~ có cấp bậc là ~1~;
    • Đồng xu ~3~ có cấp bậc là ~2~;
    • Đồng xu ~1~ có cấp bậc là ~3~.
    • Đặt ~e_i = (x_i, y_i)~ là cặp đồng xu được cân ở lần cân thứ ~i~ (~1 \leq i \leq m~; đồng xu ~x_i~ nặng hơn đồng xu ~y_i~). Đặt ~E_j = \{e_i \vert 1 \leq i \leq j \}~ là tập hợp cặp đồng xu của ~i~ lần cân đầu tiên.
    • Ta gọi một thứ tự ~P~ là hợp lệ với một tập hợp lần cân ~E~ nếu không tồn tại một cặp đồng xu nào mà thứ tự của chúng không thỏa kết quả của một lần cân nào đó trong ~E~.

      Nói cách khác, nếu ~(x, y) \in E~ thì vị trí của đồng xu ~y~ phải nằm phía trước vị trí của đồng xu ~x~ trong ~P~ (đồng xu ~x~ có cấp bậc cao hơn đồng xu ~y~).

      Vì dữ liệu đầu vào đảm bảo rằng các kết quả cân không mâu thuẫn với nhau, ít nhất một thứ tự hợp lệ phải tồn tại với tập hợp ~m~ lần cân (~E_m~).

    • Cấp bậc của đồng xu ~z~ là có thể chắc chắn xác định được với tập hợp lần cân ~E~ nếu trong mọi thứ tự ~P~ hợp lệ so với ~E~, cấp bậc của ~z~ không đổi (tạm gọi đây là điều kiện ~(1)~).

      Vì vậy, nếu ta chỉ kiểm tra rằng cấp bậc của từng đồng xu có thể hay không thể được xác định (và đạt ~50~% số điểm của subtask 1), ta có thể:

      • Duyệt qua tất cả ~n!~ hoán vị để tìm các thứ tự hợp lệ; và
      • Với mỗi đồng xu, ta kiểm tra liệu có tồn tại hai thứ tự hợp lệ khác nhau mà cấp bậc của đồng xu trong hai thứ tự đó là khác nhau.

        • Với mỗi đồng xu ~z~, việc tìm được thời điểm sớm nhất mà có thể chắc chắn xác định cấp bậc của đồng xu đó sẽ tương đương với việc tìm ra tập hợp gồm các lần cân đầu tiên với kích thước nhỏ nhất sao cho điều kiện ~(1)~ vẫn thỏa.

Nói cách khác, ta cần tìm ~i~ nhỏ nhất sao cho với ~E_i~, cấp bậc ~z~ có thể chắc chắn xác định.

Để thực hiện điều này, ta có thể duyệt qua một tập hợp lần cân và thực hiện kiểm tra như trên.

Độ Phức Tạp
  • Ý tưởng nêu trên có thể được cài đặt với độ phức tạp thời gian ~O(m \cdot n! \cdot (m + n))~.
    • Ta cần duyệt qua ~O(m)~ tập hợp lần cân.
    • Với mỗi tập hợp, ta cần duyệt qua ~O(n!)~ hoán vị để tìm các thứ tự hợp lệ.
    • Với mỗi thứ tự, ta thực hiện kiểm tra tính hợp lệ bằng cách duyệt qua ~O(m)~ lần cân. Nếu thứ tự hợp lệ, chúng ta cần phải kiểm tra cấp bậc của ~O(n)~ đồng xu có khác so với thứ tự hợp lệ khác không.
Code Minh Họa

Code C++ minh họa

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

template<class A, class B> bool maximize(A &a, const B &b) { return (a < b) ? (a = b, true) : false; }
template<class A, class B> bool minimize(A &a, const B &b) { return (a > b) ? (a = b, true) : false; }

constexpr int MAX_N = 200'005, MAX_M = 800'005, INF = 0X3F3F3F3F, NINF = 0XC0C0C0C0;

int n, m, x[MAX_M], y[MAX_M];

void input() {
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        cin >> x[i] >> y[i];
        --x[i];
        --y[i];
    }
}

vector<int> findPositions(const vector<int> &permutation) {
    const int n = permutation.size();
    vector<int> positions(n);
    for (int i = 0; i < n; ++i)
        positions[permutation[i]] = i;
    return positions;
}

bool checkValid(const vector<int> &order, const int e) {
    const vector<int> &positions(findPositions(order));
    for (int i = 0; i < e; ++i)
        if (positions[x[i]] > positions[y[i]])
            return false;
    return true;
}

bool checkInvalid(const vector<int> &order, const int e) {
    return !checkValid(order, e);
}

vector<int> findLevels(const int e) {
    vector<int> levels(n, n), permutation(n);
    /**
        level = -1      :   The level cannot be determined
        level = n       :   The level has not been determined yet
                            (as there is no contradiction, there should no final result with this value)
        0 <= level < n  :   The level is currently determined
    **/

    iota(permutation.begin(), permutation.end(), 0);

    do {
        if (checkInvalid(permutation, e))
            continue;
        for (int i = 0; i < n; ++i) {
            const auto &value = permutation[i];
            auto &level = levels[value];
            if (level < 0)
                continue;
            if (level == n) {
                level = i;
                continue;
            }
            if (level != i)
                level = -1;
        }
    } while (next_permutation(permutation.begin(), permutation.end()));

    return levels;
}

vector<int> findWhen() {
    vector<int> result(n, -1);
    for (int e = 1, i = 0; e <= m; ++e) {
        const auto &levels(findLevels(e));
        for (i = 0; i < n; ++i) {
            if (result[i] >= 0 || levels[i] < 0)
                continue;
            result[i] = e;
        }
    }
    return result;
}

int main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();

    for (const int &e : findWhen())
        cout << e << ' ';
    cout << '\n';

    return 0;
}

Subtask 2

Giới hạn: ~n \leq 100~; ~m \leq 400~

Ý Tưởng
  • Ta có thể biểu diễn mối quan hệ trọng lượng tương đối bằng cách dựng một đồ thị. Cụ thể hơn, đặt ~G = (V, E)~ với:
    • ~V~ là tập hợp các đồng xu
    • ~E~ là tập hợp các lần cân: ~(x, y) \in E~ (tồn tại một cung trực tiếp từ ~x~ tới ~y~) nếu như tồn tại một lần cân xác định được rằng đồng xu ~x~ nặng hơn đồng xu ~y~.
  • Ta nhận thấy rằng: đồng xu ~x~ có thể được xác định nặng hơn đồng xu ~y~ khi đồ thị ~G~ phải chứa ít nhất một đường đi từ ~x~ tới ~y~.

Giải thích

  • Giả sử đường đi từ ~x~ tới ~y~ là một dãy các đỉnh ~U_1, ..., U_k~ (với ~k \geq 2~, ~U_1 = x~, ~U_k = y~) thì theo cách ta dựng đồ thị, đồng xu ~U_i~ phải nặng hơn đồng xu ~U_{i + 1}~ (với ~1 \leq i < k~). Vì thế, dựa vào tích chất bắc cầu, ta có đồng xu ~U_1~ (hay ~x~) phải nặng hơn đồng xu ~U_k~ (hay ~y~).

  • Tập hợp các đỉnh mà có thể đến được từ đỉnh ~x~ cũng đồng thời là tập hợp các đỉnh đại diện cho đồng xu có thể xác định nhẹ hơn đồng xu ~x~.
    • Ta có thể xác định được tập hợp này bằng cách sử dụng các thuật toán duyệt đồ thị như BFS hoặc DFS trên đồ thị ~G~.
  • Tương tự, tập hợp các đỉnh mà có thể đi tới được đỉnh ~x~ cũng đồng thời là tập hợp các đỉnh đại diện cho đồng xu nhẹ có thể xác định nặng đồng xu ~x~.
    • Để xác định được tập hợp này, ta cũng có thể sử dụng các thuật toán duyệt đồ thị như trên nhưng trên một đồ thị có chiều các cạnh được đảo so với ~G~.
    • Cụ thể hơn, ta có thể đặt ~G^T = (V, E^T)~ với ~E^T = \{(y, x) \vert (x, y) \in E\}~

Minh họa đồ thị được dựng từ sample - Đồ thị được dựng từ sample ~1~ - Đồ thị được dựng từ sample ~2~

  • Vì vậy, nếu ta chỉ muốn kiểm tra rằng cấp bậc của một đồng xu ~z~ có thể hay không thể được xác định (và đạt ~50~% số điểm của subtask 2, ta có thể thực hiện bằng cách kiểm tra phép hợp của hai tập hợp đến được từ ~z~ và có thể đi đến được ~z~ có chứa mọi đỉnh của đồ thị đã cho không (cấp bậc của đồng xu ~z~ chỉ có thể xác định khi trong lượng tương đối giữa nó và các đồng xu khác đều có thể xác định). Tạm gọi điều kiện của việc kiểm tra này là điều kiện ~(2)~.
  • Tương tự như subtask 1, với mỗi đồng xu ~z~, việc tìm được thời điểm sớm nhất mà có thể chắc chắn xác định cấp bậc của đồng xu đó sẽ tương đương với việc tìm ra tập hợp gồm các lần cân đầu tiên với kích thước nhỏ nhất sao cho điều kiện ~(2)~ vẫn thỏa.
Độ Phức Tạp
  • Ý tưởng nêu trên có thể được cài đặt với độ phức tạp thời gian ~O(n \cdot m \cdot (n + m))~.
    • Ta cần duyệt qua ~O(n)~ đỉnh đại diện cho các đồng xu.
    • Với mỗi đỉnh, ta cần duyệt qua ~O(m)~ tập hợp lần cân.
    • Với mỗi tập hợp lần cân, việc duyệt đồ thị có thể được thể được hiện tại với thuật toán BFS hoặc DFS với độ phức tạp thời gian ~O(n + m)~.
Code Minh Họa

Code C++ minh họa

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

template<class A, class B> bool maximize(A &a, const B &b) { return (a < b) ? (a = b, true) : false; }
template<class A, class B> bool minimize(A &a, const B &b) { return (a > b) ? (a = b, true) : false; }

constexpr int MAX_N = 200'005, MAX_M = 800'005, INF = 0X3F3F3F3F, NINF = 0XC0C0C0C0;

vector<pair<int, int> > graph[MAX_N], reversedGraph[MAX_N];
int n, m, x[MAX_M], y[MAX_M];

void input() {
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        cin >> x[i] >> y[i];
        graph[x[i]].emplace_back(i, y[i]);
        reversedGraph[y[i]].emplace_back(i, x[i]);
    }
}

namespace ReachabilityChecker {

    bool visited[MAX_N];
    int limit;

    void runFirstDFS(const int x) {
        visited[x] = true;
        for (const auto &[e, y] : graph[x]) {
            if (visited[y] || e > limit)
                continue;
            runFirstDFS(y);
        }
    }

    void runSecondDFS(const int x) {
        visited[x] = true;
        for (const auto &[e, y] : reversedGraph[x]) {
            if (visited[y] || e > limit)
                continue;
            runSecondDFS(y);
        }
    }

    int check(const int source) {
        for (int i = 1; i <= n; ++i)
            visited[i] = false;
        runFirstDFS(source);
        runSecondDFS(source);
        for (int i = 1; i <= n; ++i)
            if (!visited[i])
                return false;
        return true;
    }

}

void determineWhen() {
    vector<int> result(n + 1, -1);

    for (int e = 1, x = 0; e <= m; ++e) {
        ReachabilityChecker::limit = e;
        for (x = 1; x <= n; ++x) {
            if (result[x] > 0)
                continue;
            if (ReachabilityChecker::check(x))
                result[x] = e;
        }
    }

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

int main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    determineWhen();

    return 0;
}

Subtask 3

Giới hạn: ~n \leq 1000~; ~m \leq 4000~

Ý Tưởng
  • Subtask 3có thể được giải quyết theo nhiều cách khác nhau.
Cách Thứ Nhất
  • Ta có nhận xét rằng, với đồng xu ~z~, nếu cấp bậc của ~z~ có thể được xác định với tập hợp ~E_i~, tập hợp của ~i~ lần cân đầu tiên, thì cũng có thể được xác định với ~E_j~ (với ~i < j \leq m~) vì ~E_i \subset E_j~. Vì vậy, thay vì duyệt tuần tự từng tập hợp lần cân (mỗi lần xét một thêm kết quả cân) như trong subtask 2, ta có thể sử dụng thuật toán chặt nhị phân để tìm được thời điểm sớm nhất.
Cách Thứ Hai
  • Ý tưởng của cách thứ hai gần giống với thuật toán Prim (sử dụng trong cho bài toán tìm cây khung nhỏ nhất). Với mỗi cạnh ~e_i~ trong đồ thị ~G~ được định nghĩa trước đó, ta đặt trọng số của cạnh ~e_i~ là ~i~ (thời điểm cặp đồng xu mà ~e_i~ đại diện được cân).
  • Với một đỉnh ~z~, ta đặt ~V^{+}_{z}~ là tập hợp các đỉnh có thể đến được từ ~z~ trong ~G~ (bao gồm cả ~z~). Ta nhận thấy rằng việc tìm thời điểm sớm nhất mà tập hợp ~V^{+}_{z}~ cũng sẽ tương đương với việc tối thiểu hóa trọng số lớn nhất trong cây chứa các cạnh ~V^{+}_{z}~.
  • Tương tự, ta đặt ~V^{-}_{z}~ là tập hợp các đỉnh có thể đến được từ ~z~ trong ~G^{T}~ (bao gồm cả ~z~). Ta nhận thấy rằng việc tìm thời điểm sớm nhất mà tập hợp ~V^{-}_{z}~ cũng sẽ tương đương với việc tối thiểu hóa trọng số lớn nhất trong cây chứa các cạnh ~V^{-}_{z}~.
  • Để tối thiểu hóa trong cả hai trường hợp, ta đều có thể thực hiện bằng cách:
    • Khởi đầu với cây gồm một đỉnh ~z~ duy nhất;
    • Lần lượt chọn cạnh với trọng số nhỏ nhất nối từ một đỉnh đã thuộc cây và một đỉnh chưa thuộc cây; Thêm cạnh và đỉnh của cạnh đó và cây; Thực hiện nhiều lần cho đến khi ta không thể mở rộng được cây nữa.
  • Nếu ~V^{+}_{z} \cup V^{-}_{z} \neq V~ thì cấp bậc của ~z~ không thể xác định. Ngược lại, cấp bậc của ~z~ sẽ là giá trị lớn nhất trong hai trường hợp.
Độ Phức Tạp
  • Ý tưởng trong cách thứ nhất có thể được cài đặt với độ phức tạp thời gian ~O(n \cdot \log m \cdot (n + m))~.
    • Độ phức tạp có thể được phân tích gần như tương tự như trong Subtask 2 nhưng khi này, ta chỉ xét ~O(\log m)~ thay vì ~O(m)~ tập hợp lần cân.
  • Ý tưởng trong cách thứ hai có thể được cài đặt với độ phức tạp thời gian ~O(n \cdot (n + m \log m))~.
    • Ta phải xét qua ~O(n)~ đỉnh.
    • Với mỗi đỉnh, việc xử lý thông tin các đỉnh có độ phức tạp ~O(n)~. Nếu chúng ta sử dụng heap để xác định cạnh với trọng số nhỏ nhất, bước xử lý sẽ có độ phức tạp ~O(m \log m)~ bởi chúng ta thêm ~O(m)~ cạnh vào heap với mỗi thao có độ phức tạp ~O(\log m)~.
  • Ngoài ra, ý tưởng trong cách thứ hai cũng có thể được cài đặt với độ phức tạp thời gian ~O(n \cdot (n + m))~ nếu ta sử dụng một mảng danh sách đánh số theo chỉ số cạnh thay vì dùng heap bởi trọng số cạnh chỉ nằm trong đoạn từ ~1~ tới ~m~.
Code Minh Họa

Code C++ minh họa cho cách thứ nhất

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

template<class A, class B> bool maximize(A &a, const B &b) { return (a < b) ? (a = b, true) : false; }
template<class A, class B> bool minimize(A &a, const B &b) { return (a > b) ? (a = b, true) : false; }

constexpr int MAX_N = 200'005, MAX_M = 800'005, INF = 0X3F3F3F3F, NINF = 0XC0C0C0C0;

vector<pair<int, int> > graph[MAX_N], reversedGraph[MAX_N];
int n, m, x[MAX_M], y[MAX_M];

void input() {
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        cin >> x[i] >> y[i];
        graph[x[i]].emplace_back(i, y[i]);
        reversedGraph[y[i]].emplace_back(i, x[i]);
    }
}

namespace ReachabilityChecker {

    bool visited[MAX_N];
    int limit;

    void runFirstDFS(const int x) {
        visited[x] = true;
        for (const auto &[e, y] : graph[x]) {
            if (visited[y] || e > limit)
                continue;
            runFirstDFS(y);
        }
    }

    void runSecondDFS(const int x) {
        visited[x] = true;
        for (const auto &[e, y] : reversedGraph[x]) {
            if (visited[y] || e > limit)
                continue;
            runSecondDFS(y);
        }
    }

    int check(const int source) {
        for (int i = 1; i <= n; ++i)
            visited[i] = false;
        runFirstDFS(source);
        runSecondDFS(source);
        for (int i = 1; i <= n; ++i)
            if (!visited[i])
                return false;
        return true;
    }

}

void determineWhen() {
    for (int x = 1, low = 0, middle = 0, high = m; x <= n; ++x) {
        ReachabilityChecker::limit = high = m;
        if (!ReachabilityChecker::check(x)) {
            cout << -1 << ' ';
            continue;
        }
        low = 0;
        while (low + 1 < high) {
            middle = low + high >> 1;
            ReachabilityChecker::limit = middle;
            if (ReachabilityChecker::check(x))
                high = middle;
            else
                low = middle;
        }
        cout << high << ' ';
    }
    cout << '\n';
}

int main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    determineWhen();

    return 0;
}

Code C++ minh họa cho cách thứ hai

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

template<class A, class B> bool maximize(A &a, const B &b) { return (a < b) ? (a = b, true) : false; }
template<class A, class B> bool minimize(A &a, const B &b) { return (a > b) ? (a = b, true) : false; }

constexpr int MAX_N = 200'005, MAX_M = 800'005, INF = 0X3F3F3F3F, NINF = 0XC0C0C0C0;

vector<pair<int, int> > graph[MAX_N], reversedGraph[MAX_N];
int n, m, x[MAX_M], y[MAX_M];
bool visited[MAX_N];

void input() {
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        cin >> x[i] >> y[i];
        graph[x[i]].emplace_back(i, y[i]);
        reversedGraph[y[i]].emplace_back(i, x[i]);
    }
}

int findEarliestTime(const int source, vector<pair<int, int> > graph[]) {
    priority_queue<pair<int, int>, vector<pair<int, int> >, greater<pair<int, int> > > heap;
    int maximum = 0;

    heap.emplace(0, source);
    visited[source] = false;

    for (; !heap.empty();) {
        const auto [t, x] = heap.top();
        heap.pop();
        if (visited[x])
            continue;
        maximize(maximum, t);
        visited[x] = true;
        for (const auto &[t, y] : graph[x]) {
            if (visited[y])
                continue;
            heap.emplace(t, y);
        }
    }

    return maximum;
}

int findEarliestTime(const int source) {
    for (int i = 1; i <= n; ++i)
        visited[i] = false;
    const int maximum = max(findEarliestTime(source, graph), findEarliestTime(source, reversedGraph));
    for (int i = 1; i <= n; ++i)
        if (!visited[i])
            return -1;
    return maximum;
}

void determineWhen() {
    for (int x = 1; x <= n; ++x)
        cout << findEarliestTime(x) << ' ';
    cout << '\n';
}

int main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    determineWhen();

    return 0;
}

Code C++ minh họa cho cách thứ hai

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

template<class A, class B> bool maximize(A &a, const B &b) { return (a < b) ? (a = b, true) : false; }
template<class A, class B> bool minimize(A &a, const B &b) { return (a > b) ? (a = b, true) : false; }

constexpr int MAX_N = 200'005, MAX_M = 800'005, INF = 0X3F3F3F3F, NINF = 0XC0C0C0C0;

vector<pair<int, int> > graph[MAX_N], reversedGraph[MAX_N];
int n, m, x[MAX_M], y[MAX_M];
bool visited[MAX_N];

void input() {
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        cin >> x[i] >> y[i];
        graph[x[i]].emplace_back(i, y[i]);
        reversedGraph[y[i]].emplace_back(i, x[i]);
    }
}

int findEarliestTime(const int source, vector<pair<int, int> > graph[]) {
    static queue<int> queues[MAX_M];
    int maximum = 0;

    visited[source] = false;
    queues[0].push(source);

    for (int t = 0; t <= m; ++t)
        for (; !queues[t].empty(); queues[t].pop()) {
            const auto &x = queues[t].front();
            if (visited[x])
                continue;
            maximize(maximum, t);
            visited[x] = true;
            for (const auto &[e, y] : graph[x]) {
                if (visited[y])
                    continue;
                ((e <= t) ? queues[t] : queues[e]).push(y);
            }
        }

    return maximum;
}

int findEarliestTime(const int source) {
    for (int i = 1; i <= n; ++i)
        visited[i] = false;
    const int maximum = max(findEarliestTime(source, graph), findEarliestTime(source, reversedGraph));
    for (int i = 1; i <= n; ++i)
        if (!visited[i])
            return -1;
    return maximum;
}

void determineWhen() {
    for (int x = 1; x <= n; ++x)
        cout << findEarliestTime(x) << ' ';
    cout << '\n';
}

int main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    determineWhen();

    return 0;
}

Subtask 4

Giới hạn: ~n \leq 200,000~; ~m \leq 800,000~

Ý Tưởng
  • Vì dữ liệu đầu vào đảm bảo rằng các kết quả cân không mâu thuẫn với nhau, đồ thị ~G~ được dựng trước đó sẽ không chứa chu trình. Điều này đồng nghĩa với việc ~G~ là một DAG (Directed acyclic graph).
  • Ta đặt ~S~ là một thứ tự Tô-pô của ~G~ và đặt ~T_x~ là vị trí của ~x~ trong thứ tự đó (~T_x~ có thể được xem như chỉ số của ~x~ sau khi ta thực hiện sắp xếp Tô-pô).
  • Để miêu tả phần còn lại của ý tưởng dễ hơn, ta sẽ đánh số đỉnh đồ thị lại theo thứ tự Tô-pô (với mọi cung ~(x, y)~ trong ~G~ sau khi đã đánh số lại, ta có ~x < y~).
  • Ta đặt ~R_x~ là chỉ số đỉnh nhỏ nhất mà có cung từ trực tiếp từ ~x~ đến đỉnh đó (có thể xem như ~R_x = +\infty~ nếu như ~x~ không có cung đi ra). Ta có nhận xét rằng: với mọi đỉnh ~z < x~, nếu như ~R_u \leq x~ với mọi ~z \leq u \leq x~ thì phải tồn tại ít nhất một đường đi từ ~z~ tới ~x~.

Chứng minh Nhận xét trên có thể được chứng minh bằng cách quy nạp:

  • Bước cơ sở: với ~z = x - 1~
    • Vì ~z < R_z~, ta có ~x - 1 < R_z \leq x~. Nói cách khác, ~R_z = x~ hay từ ~z~ đến ~x~ có một cung trực tiếp.
  • Bước quy nạp: giả định nhận xét trên thỏa với mọi ~z < u \leq x~, ta cần chứng minh nhận xét trên cũng thỏa với ~z~.
    • Vì ~z~ có cung trực tiếp từ nó đến ~R_z~ (theo như định nghĩa của ~R_z~) và ~R_z~ có đường đi từ nó tới ~x~ (theo như giả định và ~z < R_z \leq x~), ~z~ có đường đi từ nó tới ~x~.
  • Tương tự, ta đặt ~L_x~ là chỉ số đỉnh lớn nhất mà có cung từ trực tiếp từ đỉnh đó đến ~x~ (có thể xem như ~L_x = -\infty~ nếu như không có cung đi đến ~x~). Ta có nhận xét rằng: với mọi đỉnh ~x< z~, nếu như ~x \leq L_u~ với mọi ~x \leq u \leq z~ thì phải tồn tại ít nhất một đường đi từ ~x~ tới ~z~.

Chứng minh Nhận xét trên có thể được chứng minh bằng cách quy nạp như nhận xét trước đó:

  • Bước cơ sở: với ~z = x + 1~
    • Vì ~L_z < z~, ta có ~x \leq L_z < x + 1~. Nói cách khác, ~L_z = x~ hay từ ~x~ đến ~z~ có một cung trực tiếp.
  • Bước quy nạp: giả định nhận xét trên thỏa với mọi ~x \leq u < z~, ta cần chứng minh nhận xét trên cũng thỏa với ~z~.
    • Vì ~L_z~ có cung trực tiếp từ nó đến ~z~ (theo như định nghĩa của ~L_z~) và ~L_z~ có đường đi từ ~x~ tới nó (theo như giả định và ~x \leq L_z < z~), ~z~ có đường đi từ ~x~ tới nó.
  • Dựa trên hai nhận xét này, nếu ta chỉ muốn kiểm tra rằng cấp bậc của một đồng xu ~z~ (sau khi đã đánh số lại) có thể hay không thể được xác định (và đạt ~50~% số điểm của subtask 4), ta cần kiểm tra cả hai điều kiện sau có thỏa không:
    • Với mọi đỉnh ~u~ có chỉ số nhỏ hơn ~z~, ~R_u \leq z~.
    • Với mọi đỉnh ~u~ có chỉ số lớn hơn ~z~, ~z \leq L_u~.
  • Để xác định chính xác thời điểm sớm nhất cấp bậc của một đỉnh có thể được xác định, nếu ta thực hiện kiểm tra nêu trên nhiều lần với các tập hợp lần cân khác nhau, chi phí tính sẽ quá lớn.
    • Ta đặt ~C_z~ là số lượng đỉnh ~u~ thỏa mãn điều kiện nếu ta đang xét đỉnh ~z~ (~R_u \leq z~ nếu ~u < z~ hoặc ~z \leq L_u~ nếu ~z < u~). Cấp bậc của đỉnh ~z~ có thể xác định được ở thời điểm ~C_z = n - 1~ sớm nhất.
    • Ta nhận thấy rằng, nếu ta bắt đầu với một đồ thị chưa có cạnh nào và thêm dần các cạnh theo thứ tự đề bài, giả sử cạnh đang xét là ~(x, y)~ thì ta sẽ chứng kiến những thay đổi này sau đây:
      • ~R_x~ có thể giảm thành ~y~: Khi này, giá trị ~C_u~ tăng thêm một đơn vị với ~u~ trong đoạn từ ~y~ tới ~R_x - 1~.
      • ~L_y~ có thể tăng thành ~x~: Khi này, giá trị ~C_u~ tăng thêm một đơn vị với ~u~ trong đoạn từ ~L_x + 1~ tới ~y~.
    • Sau khi cập nhật các giá trị ~C_u~ sau mỗi lần thêm cạnh, giá trị ~C_u~ mà ta cần để ý cũng chính là giá trị ~C_u~ lớn nhất (bởi ~n - 1~ cũng là giá trị lớn nhất có thể của ~C_u~).
    • Dựa vào những nhận xét này, ta nhận thấy ta có thể sử dụng cấu trúc dữ liệu Segment Tree với kỹ thuật Lazy Propogation để quản lý thông tin bởi:
      • Ta cần thực hiện update (tăng giá trị) các đoạn khác nhau
      • Ta cần tìm giá trị lớn nhất trên đoạn.
Độ Phức Tạp
  • Ý tưởng nêu trên có thể được cài đặt với độ phức tạp thời gian ~O((n + m) \log n)~.
    • Việc tìm thứ tự Tô-pô có thể được thực hiện trong ~O(n + m)~.
    • Vì ta cần phải duyệt qua ~O(m)~ cạnh và với mỗi cạnh, ta có thể update đoạn với độ phức tạp ~O(\log n)~.
    • Ngoài ra, mỗi khi cấp bậc của một đỉnh được xác định, ta phải "xóa" đỉnh đó khỏi cấu trúc dữ liệu để có thể tìm được những đỉnh khác. Ta sẽ có ~O(n)~ thao tác "xóa" như vậy và mỗi thao tác có thể có độ phức tạp ~O(\log n)~.
Code Minh Họa

Code C++ minh họa

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

template<class A, class B> bool maximize(A &a, const B &b) { return (a < b) ? (a = b, true) : false; }
template<class A, class B> bool minimize(A &a, const B &b) { return (a > b) ? (a = b, true) : false; }

constexpr int MAX_N = 200'005, MAX_M = 800'005, INF = 0X3F3F3F3F, NINF = 0XC0C0C0C0;

class SegmentTree {
private:

    vector<pair<int, int> > maximum;
    vector<int> lazyValue;
    int length;

    void pushDown(const int i) {
        if (lazyValue[i]) {
            int j = i << 1;
            maximum[j].first += lazyValue[i];
            lazyValue[j] += lazyValue[i];
            j |= 1;
            maximum[j].first += lazyValue[i];
            lazyValue[j] += lazyValue[i];
            lazyValue[i] = 0;
        }
    }

    void modify(const int q, const int value, const int i, const int l, const int r) {
        if (q < l || r < q)
            return;
        if (l == r) {
            maximum[i] = make_pair(value, r);
            return;
        }
        const int m = l + r >> 1;
        pushDown(i);
        modify(q, value, i << 1, l, m);
        modify(q, value, i << 1 | 1, m + 1, r);
        maximum[i] = max(maximum[i << 1], maximum[i << 1 | 1]);
    }

    void update(const int ql, const int qr, const int value, const int i, const int l, const int r) {
        if (qr < l || r < ql)
            return;
        if (ql <= l && r <= qr) {
            maximum[i].first += value;
            lazyValue[i] += value;
            return;
        }
        const int m = l + r >> 1;
        pushDown(i);
        update(ql, qr, value, i << 1, l, m);
        update(ql, qr, value, i << 1 | 1, m + 1, r);
        maximum[i] = max(maximum[i << 1], maximum[i << 1 | 1]);
    }

    void build(const int i, const int l, const int r) {
        if (l == r) {
            maximum[i].second = l;
            return;
        }
        const int m = l + r >> 1;
        build(i << 1, l, m);
        build(i << 1 | 1, m + 1, r);
    }

public:

    SegmentTree(int length, const int value): length(length) {
        maximum.resize(length + 1 << 2, make_pair(value, -1));
        lazyValue.resize(maximum.size());
        build(1, 1, length);
    };

    void modify(const int q, const int value) {
        modify(q, value, 1, 1, length);
    }

    void update(const int ql, const int qr, const int value) {
        update(ql, qr, value, 1, 1, length);
    }

    pair<int, int> getTop() const {
        return maximum[1];
    }

};

signed position[MAX_N], degree[MAX_N], order[MAX_N];
int n, m, x[MAX_M], y[MAX_M];
vector<int> graph[MAX_N];

void input() {
    cin >> n >> m;
    for (int i = 1; i <= m; ++i) {
        cin >> x[i] >> y[i];
        graph[x[i]].push_back(y[i]);
        ++degree[y[i]];
    }
}

void determineTopologicalOrder() {
    queue<int> q;
    int t = 0;

    for (int u = 1; u <= n; ++u)
        if (degree[u] == 0)
            q.push(u);

    for (; !q.empty(); q.pop()) {
        const int &u = q.front();
        order[position[u] = ++t] = u;
        for (const auto &v : graph[u])
            if ((--degree[v]) == 0)
                q.push(v);
    }
}

void determineTime() {
    SegmentTree segmentTree(n, 0);
    vector<int> result(n + 1, -1), maximum(n + 1, NINF), minimum(n + 1, INF);

    for (int i = 1, u = 0, v = 0; i <= m; ++i) {
        u = position[x[i]];
        v = position[y[i]];
        if (v <= minimum[u]) {
            segmentTree.update(v, minimum[u] - 1, 1);
            minimum[u] = v;
        }
        if (maximum[v] <= u) {
            segmentTree.update(maximum[v] + 1, u, 1);
            maximum[v] = u;
        }
        for (;;) {
            const auto &[f, z] = segmentTree.getTop();
            if (f < n - 1)
                break;
            result[order[z]] = i;
            segmentTree.modify(z, NINF);
        }
    }

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

int main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    determineTopologicalOrder();
    determineTime();

    return 0;
}

unknownnaciul
o10, Tháng 2, 2026, 1:00 0

3

Tạp chí VNOI Xuân Bính Ngọ - Coding Challenge #05

unknownnaciul đã đăng vào 10, Tháng 2, 2026, 1:00

Coding Challenge

Coding Challenge là một chuyên mục thuộc Tạp chí VNOI, được xây dựng với mục tiêu mang đến cho cộng đồng lập trình thi đấu Việt Nam một sân chơi hữu ích và thiết thực nhằm giúp các độc giả củng cố kiến thức thuật toán và thúc đẩy tinh thần học hỏi, chia sẻ trong cộng đồng.

Hoạt động được tổ chức định kỳ hai lần mỗi tháng trên Fanpage VNOI, Group Facebook “Diễn đàn Tin học Việt Nam” và nền tảng chấm bài trực tuyến VNOJ. Toàn bộ các bài toán trong Coding Challenge được biên soạn bởi đội ngũ Tình nguyện viên thuộc Team Contest của VNOI. Chất lượng các bài toán Coding Challenge được đảm bảo về tính chuyên môn, có sự đa dạng về chủ đề và độ khó cũng như đáp ứng yêu cầu về tính chặt chẽ và chính xác.

Độc giả của Tạp chí VNOI có thể tham gia giải các bài toán thuộc Coding Challenge và gửi lời giải về cho Tạp chí. Những bài giải chất lượng sẽ được chọn lọc để đăng tải trong các số Tạp chí tiếp theo. Bên cạnh việc được ghi nhận và giới thiệu rộng rãi đến cộng đồng, các tác giả có lời giải được đăng tải sẽ nhận những phần quà tri ân từ VNOI nhằm khuyến khích tinh thần ham học hỏi và chia sẻ kiến thức đến cộng đồng

Các lời giải được chọn lọc để đăng công khai sẽ được đánh giá dựa trên tiêu chí về sự rõ ràng, mạch lạc, sáng tạo; có sự đầu tư trong diễn giải tư duy, cấu trúc và tính dễ hiểu. Đối với các bài toán có nhiều subtask, tác giả cần trình bày đầy đủ ý tưởng và hướng giải quyết cho tất cả các phần.

Phần thưởng dành cho mỗi lời giải được đăng tải trên Tạp chí là một bộ quà tặng gồm sổ tay, bút, sticker và móc khóa. Đặc biệt, với các tác giả có từ 03 bài giải trở lên được đăng trong chuyên mục, Ban biên tập Tạp chí sẽ gửi tặng một chiếc áo VNOI như lời tri ân cho những đóng góp tích cực đối với Coding Challenge.

Tiếp nối 03 số đầu tiên, các Coding Challenge #04, #05, #06 và #07 tiếp tục ghi nhận sự ủng hộ nhiệt tình của cộng đồng lập trình thi đấu. Hệ thống đã ghi nhận tổng cộng 2086 lượt nộp bài và ~x~ lời giải được gửi về cho Tạp chí.

Đội ngũ tạp chí VNOI xin gửi lời cảm ơn chân thành đến tất cả các bạn đã tích cực tham gia các Coding Challenge và đóng góp những lời giải vô cùng chất lượng trong thời gian qua!

Coding Challenge #5

Coding Challenge #5 có tên là Vô hạn. Đây là bài toán yêu cầu các thí sinh sử dụng nhiều kỹ năng số học để xử lý. Bài toán Vô hạn đã được 392 thí sinh thử sức và có 36 lời giải xuất sắc đạt được số điểm tối đa.

Để hiểu rõ hơn về lời giải của bài toán, mời các bạn theo dõi 2 lời giải xuất sắc do bạn Nguyễn Hoàng Minh (lời giải 1) và Lê Minh Tuấn (lời giải 2) biên soạn.

Đề bài

Bạn đọc có thể theo dõi đề bài gốc và subtasks tại nền tảng VNOJ. Sau đây là bản tóm tắt của đề bài.

Cho ~\mathcal{T}~ câu hỏi có dạng sau:

  • Cho số nguyên dương ~n~, đếm số cặp số nguyên ~a, b~ thỏa ~1 \leq a, b \leq n~ và ~\frac{a}{b}~ là một số thập phân vô hạn.

Giới hạn:

  • ~1 \leq \mathcal{T} \leq 10~
  • ~1 \leq n \leq 10^{12}~

Lời giải 1

Subtask 1

Giới hạn: ~1 \leq n \leq {10}^{3}~

Ý Tưởng
  • Một phân số ~\frac{a}{b}~ (với ~a~ và ~b~ là số nguyên dương) có thể được biểu diễn bằng số thập phân hữu hạn nếu như tồn tại hai số nguyên không âm ~k~ và ~e~ sao cho:

~\frac{a}{b} = \frac{k}{{10}^e}~
  • Nếu ta đặt ~\frac{x}{y}~ là phân số tối giản bằng với ~\frac{a}{b}~ (tức ~\gcd(x, y) = 1~ và ~\frac{x}{y} = \frac{a}{b}~), điều kiện trên sẽ tương đương với việc tồn tại các số nguyên không âm ~c~, ~k~ và ~e~ sao cho:

~\begin{cases} x \cdot c = k\\[4pt] y \cdot c = {10}^e \end{cases}~

Vì ~{10}^e~ không thể chứa ước nguyên tố ngoài ~2~ và ~5~, ~y~ (và ~c~ được chọn) cũng phải thỏa mãn điều kiện tương tự (~1~).

  • Mặt khác, nếu ~y~ không chứa ước nguyên ngoài ~2~ và ~5~ thì ~y~ có thể được viết dưới dạng ~2^{u} \cdot 5^{v}~ với ~u~ và ~v~ là hai số nguyên không âm. Khi này, ta có thể đặt ~e = \max(u, v)~, ~c = 2^{e - u} \cdot 5^{e - v}~, và ~k = x \cdot c~, ta nhận thấy:

~\frac{a}{b} = \frac{x}{y} = \frac{x \cdot c}{y \cdot c} = \frac{k}{{10}^e}~

Vì thế ~\frac{a}{b}~ có thể được biểu diễn bằng số thập phân hữu hạn (~2~).

  • Từ (~1~) và (~2~), ta thấy được rằng phân số ~\frac{a}{b}~ là một số thập phân hữu hạn nếu như mẫu số của phân số tối giản thu được từ việc rút gọn ~\frac{a}{b}~ không chứa bất kì một ước số nguyên tố nào ngoài ~2~ và ~5~.
    • Với giới hạn của subtask 1, chúng ta có thể đếm số lượng phân số là số thập phân hữu hạn trong thời gian cho phép bằng cách duyệt qua mọi cặp số nguyên dương ~(a, b)~ (~1 \leq a, b \leq n~) và thực hiện kiểm tra như trên.
    • Số lượng cặp số nguyên dương ~(a, b)~ (~1 \leq a, b \leq n~) mà ~\frac{a}{b}~ là số thập phân vô hạn bằng tổng số ~n^2~ cặp trừ đi số lượng các phân số cho kết quả hữu hạn trên.
Độ Phức Tạp
  • Ý tưởng trên có thể được cài đặt với độ phức tạp thời gian ~O(t \cdot n^2 \cdot \log(n))~.
    • Chúng ta cần duyệt qua ~n^2~ cặp số; và
    • Với mỗi cặp số ~(a, b)~, chúng ta có thể:
      • xác định phân số tối giản tương đương trong ~O(\log n)~ bằng cách chia tử và mẫu với ước chung lớn nhất của chúng; và
      • xác định mẫu có chứa ước nguyên tố ngoài ~2~ và ~5~ bằng cách rút một trong hai ước nguyên tố này ra cho đến khi không thể thực hiện được nữa (khi này, phân số sẽ là một số thập phân hữu hạn nếu số được rút gọn bằng một; và một số thập phân vô hạn trong trường hợp còn lại). Chúng ta sẽ có thể phải thực hiện ~O(\log_2(n) + \log_5(n)) = \log(n)~ bước rút ước số ra.
  • Về độ phức tạp bộ nhớ, ý tưởng có thể được cài đặt với độ phức tạp ~O(1)~.
Code Minh Họa

Code C++ minh họa

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

int countFractions(const int n) {
    int result = n * n;
    for (int a = 1, b = 1, d = 1, g = 1; a <= n; ++a)
        for (b = 1; b <= n; ++b) {
            g = __gcd(a, b);
            d = b / g;
            for (; !(d & 1); d >>= 1);
            for (; d % 5 == 0; d /= 5);
            if (d == 1)
                --result;
        }
    return result;
}

signed main() {
    int t = 0, n = 0;

    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    cin >> t;

    for (int i = 1; i <= t; ++i) {
        cin >> n;
        cout << countFractions(n) << '\n';
    }

    return 0;
}

Subtask 2

Giới hạn: ~1 \leq n \leq {10}^{6}~

Ý Tưởng
  • Giới hạn của subtask 2 không cho phép chúng ta duyệt qua mọi phân số như subtask 1 nữa nhưng vẫn cho phép chúng ta có thể duyệt qua mọi giá trị mà tử số hoặc mẫu số có thể có.
  • Với một phân số ~\frac{a}{b}~ (~1 \leq a, b \leq n~ và ~a, b~ là số nguyên), ta nhận thấy rằng để ~\frac{a}{b}~ là một số thập phân hữu hạn thì với mọi lũy thừa ~p^z~ (~z \in \mathbb{N}~) của một số nguyên tố ~p~ bất kì khác ~2~ và ~5~, nếu mẫu số ~b~ chia hết cho ~p^z~ thì ~a~ cũng phải chia hết cho ~p^z~ bởi như vậy, ta mới triệt tiêu được ước số nguyên tố khác ~2~ và ~5~ khỏi mẫu khi rút gọn phân số ~\frac{a}{b}~ thành phân số tối giản.

~\forall p \in \mathbb{P} \setminus \{2,5\}, \; \forall z \in \mathbb{N}, \; (p^z \mid b \;\Rightarrow\; p^z \mid a)~

với ~\mathbb{P}~ là tập hợp các số nguyên tố.

  • Một cách tổng quát hơn, nếu ta đặt ~b = 2^u \cdot 5^v \cdot w~ với ~u~ và ~v~ là số tự nhiên, và ~w~ là số tự nhiên không chia hết cho ~2~ và ~5~, thì ta có ~a = w \cdot s~ với ~s~ là số nguyên dương sao cho ~1 \leq a \leq n~. Vì vậy, với một số tự nhiên ~b~ xác định là mẫu số ~(1 \leq b \leq n)~, ta có thể chọn ~\lfloor \frac{n}{w} \rfloor~ giá trị hợp lệ khác nhau cho ~a~ bởi ~1 \leq s~ và ~w \cdot s \leq n \Rightarrow s \leq \frac{n}{w}~.
  • Dựa trên quan sát này, ta có thể đếm số lượng phân số là số thập phân hữu hạn bằng cách duyệt qua mọi mẫu số ~b~ (từ ~1~ tới ~n~), xác định thành phần ~w~ được định nghĩa như trên và tính tổng các giá trị ~\lfloor \frac{n}{w} \rfloor~.
Độ Phức Tạp
  • Ý tưởng trên có thể được cài đặt với độ phức tạp thời gian ~O(t \cdot n \cdot \log(n))~.
    • Chúng ta cần duyệt qua ~n~ mẫu số; và
    • Với mỗi mẫu số ~b~, chúng ta sẽ có thể phải thực hiện ~O(\log_2(n) + \log_5(n)) = \log(n)~ bước rút ước số ra khỏi ~b~ để xác định ~w~.
  • Về độ phức tạp bộ nhớ, ý tưởng có thể được cài đặt với độ phức tạp ~O(1)~.
Code Minh Họa

Code C++ minh họa

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

constexpr int MOD = 1E9 + 7;

int countFractions(const int n) {
    long long result = 1LL * n * n;
    for (int a = 0, b = 1; b <= n; ++b) {
        for (a = b; !(a & 1); a >>= 1);
        for (; a % 5 == 0; a /= 5);
        result -= n / a;
    }
    return result % MOD;
}

signed main() {
    int t = 0, n = 0;

    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    cin >> t;

    for (int i = 1; i <= t; ++i) {
        cin >> n;
        cout << countFractions(n) << '\n';
    }

    return 0;
}

Subtask 3

Giới hạn: ~1 \leq n \leq {10}^{12}~

Ý Tưởng
  • Giới hạn của subtask 3 không cho phép chúng ta duyệt qua mọi mẫu số như subtask 2. Tuy nhiên, ta có thể duyệt qua tất cả thành phần ~2^u \cdot 5^v~ khác nhau trong mẫu số (được đề cập trong subtask 2). Cụ thể hơn, ta có ~O(\log_2 n \cdot \log_5 n)~ trường hợp khác nhau bởi điều kiện ~2^u \cdot 5^v \leq n~ phải thỏa:
    • ~u~ có thể đạt giá trị lớn nhất khi ~v = 0~ ~\Rightarrow~ ~2^u \leq n~ hay ~u \leq \log_2 n~ Vì ~u \geq 0~, ~u~ có thể có ~O(\log_2 n)~ giá trị khác nhau;
    • Tương tự, ~v~ có thể đạt giá trị lớn nhất khi ~u = 0~ ~\Rightarrow~ ~5^v \leq n~ hay ~v \leq \log_5 n~ Vì ~v \geq 0~, ~v~ có thể có ~O(\log_5 n)~ giá trị khác nhau.
  • Để đếm số lượng phân số là số thập phân hữu hạn, với mỗi thành phần ~2^u \cdot 5^v~, ta cần đếm số lượng cặp số ~(s, w)~ đại diện cho phân số ~\frac{s \cdot w}{2^u \cdot 5^v \cdot w}~ sao cho tất cả điều kiện sau thỏa:

    • ~\gcd(w, 10) = 1~ (tức ~2 \nmid w~ và ~5 \nmid w~): ~w~ không chia hết cho ~2~ và ~5~;
    • ~1 \leq s \leq \lfloor \frac{n}{w} \rfloor~ (bởi ~1 \leq s \cdot w \leq n~);
    • ~1 \leq w \leq \lfloor \frac{n}{2^u \cdot 5^v} \rfloor~ (bởi ~1 \leq 2^u \cdot 5^w \cdot w \leq n~)

    Nói cách khác, nếu ta đặt:

    • ~R(x) = \{z \vert (z \in N) \land (1 \leq z \leq x)\}~ là tập các số tự nhiên từ ~1~ đến ~x~
    • ~W(u, v) = R(\lfloor \frac{n}{2^u \cdot 5^v} \rfloor)~

    thì số lượng cặp số trên tương đương với:

~T(u, v) = \Sigma_{(w \in W(u, v)) \land (\gcd(w, 10) = 1)} \lfloor \frac{n}{w}\rfloor~

Để đơn giản hóa việc tính toán, ta có thể áp dụng nguyên tắc bao hàm - loại trừ. Cụ thể hơn, nếu ta đặt:

~N_x(u, v) = \Sigma_{(w \in W(u, v))\land(x \mid w)}\lfloor \frac{n}{w}\rfloor~

Khi này, ta có ~T(u, v) = N_1(u, v) - N_2(u, v) - N_5(u, v) + N_{10}(u, v)~ ~\Rightarrow~ Tổng các giá trị ~T(u, v)~ với mọi ~u~ và ~v~ sẽ chính là số lượng phân số là số thập phân hữu hạn.

  • Bây giờ, yêu cầu còn lại cần phải được giải quyết là tính các giá trị ~N_x(u, v)~ một cách hiệu quả.
    • Ta không thể thực hiện bằng cách duyệt qua từng giá trị ~w~ vì ta có thể phải duyệt qua ~O(n)~ giá trị khác nhau.
    • Tuy nhiên, ta có nhận xét rằng chỉ có ~O(\sqrt n)~ giá trị ~\lfloor \frac{n}{w}\rfloor~ khác nhau.

Giải thích chi tiết

  • Với ~w \leq \sqrt n~, ta hiển nhiên có ~O(\sqrt n)~ giá trị ~\lfloor \frac{n}{w}\rfloor~ khác nhau vì mỗi giá trị ~w~ ứng với một giá trị ~\lfloor \frac{n}{w}\rfloor~;
  • Với ~w \geq \sqrt n~, ta có ~\lfloor \frac{n}{w}\rfloor \leq \sqrt n~ và vì thế, ta có ~O(\sqrt n)~ giá trị ~\lfloor \frac{n}{w}\rfloor~ khác nhau;

Dựa vào nhận xét này, thay vì xem xét từng giá trị ~w~, ta xem xét gộp các giá trị ~w~ mà có cùng giá trị ~\lfloor \frac{n}{w} \rfloor~ vào cùng một nhóm. Cụ thể, ta sẽ có ~O(\sqrt n)~ nhóm gồm các giá trị liên tiếp nhau.

Ví dụ Với ~n = 20~, ta có:

  • ~w = 1 \Rightarrow \lfloor \frac{n}{w}\rfloor = 20~
  • ~w = 2 \Rightarrow \lfloor \frac{n}{w}\rfloor = 10~
  • ~w = 3 \Rightarrow \lfloor \frac{n}{w}\rfloor = 6~
  • ~w = 4 \Rightarrow \lfloor \frac{n}{w}\rfloor = 5~
  • ~w = 5 \Rightarrow \lfloor \frac{n}{w}\rfloor = 4~
  • ~w = 6 \Rightarrow \lfloor \frac{n}{w}\rfloor = 3~
  • ~7 \leq w \leq 10 \Rightarrow \lfloor \frac{n}{w}\rfloor = 2~
  • ~11 \leq w \leq 20 \Rightarrow \lfloor \frac{n}{w}\rfloor = 1~

Ta thấy rằng tổng các giá trị ~\lfloor \frac{n}{w} \rfloor~ trong cùng một nhóm có thể được xác định dễ dàng với độ phức tạp thời gian ~O(1)~. Việc tính giá trị ~N_x(u, v)~ sẽ dựa vào việc ta thao tác trên các nhóm này.

Cách tính chi tiết

  • Ta đặt ~M(l, r, d)~ là số lượng số nguyên trong đoạn từ ~l~ tới ~r~ chia hết cho ~d~. Giá trị hàm này có thể được xác định như sau: ~M(l, r, d) = \max(0, \lfloor \frac{r}{d} \rfloor - \lceil \frac{l}{d} \rceil + 1)~
  • Ta đặt ~S(l, r, d)~ là tổng ~\lfloor \frac{n}{w}\rfloor~ với mọi số nguyên ~w~ trong đoạn từ ~l~ tới ~r~ chia hết cho ~d~. Nếu ~l~ và ~r~ thuộc cùng một nhóm, giá trị hàm này có thể được xác định như sau: ~G(l, r, d) = M(l, r, d) \cdot \lfloor \frac{n}{l}\rfloor~
  • Giá trị ~N_x(u, v)~ có thể được tính bằng cách tách các giá trị ~w~ ra các nhóm và tính giá trị ~S~ trên các nhóm đó.
Độ Phức Tạp
  • Ý tưởng nêu trên có thể được cài đặt với độ phức tạp thời gian ~O(t \cdot (\sqrt n + \log^2 n))~:
    • Bước xác định nhóm có độ phức tạp ~O(\sqrt n)~ bởi ta có ~O(\sqrt n)~ nhóm.
    • Bước duyệt qua từng thành phần ~2^u \cdot 5^v~ nêu trên và đếm số lượng có độ phức tạp ~O(\log^2 n)~ nếu ta thực hiện offline (ta thực hiện duyệt và đếm cùng lúc với xác định từng nhóm theo thứ tự).
  • Về bộ nhớ, ý tưởng nêu trên có thể được cài đặt với độ phức tạp ~O(\log^2 n)~ để lưu các thành phần ~2^u \cdot 5^v~ nêu trên.
  • Lưu ý: Ý tưởng trên vẫn có thể được cài đặt theo nhiều cách khác với độ phức tạp khác với độ phức tạp nêu trên.
Code Minh Họa

Code C++ minh họa

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

constexpr int FIXED_NUMBER_COUNT = 371;
constexpr long long MOD = 1E9 + 7, FIXED_NUMBERS[] = {1, 2, 4, 5, 8, 10, 16, 20, 25, 32, 40, 50, 64, 80, 100, 125, 128, 160, 200, 250, 256, 320, 400, 500, 512, 625, 640, 800, 1000, 1024, 1250, 1280, 1600, 2000, 2048, 2500, 2560, 3125, 3200, 4000, 4096, 5000, 5120, 6250, 6400, 8000, 8192, 10000, 10240, 12500, 12800, 15625, 16000, 16384, 20000, 20480, 25000, 25600, 31250, 32000, 32768, 40000, 40960, 50000, 51200, 62500, 64000, 65536, 78125, 80000, 81920, 100000, 102400, 125000, 128000, 131072, 156250, 160000, 163840, 200000, 204800, 250000, 256000, 262144, 312500, 320000, 327680, 390625, 400000, 409600, 500000, 512000, 524288, 625000, 640000, 655360, 781250, 800000, 819200, 1000000, 1024000, 1048576, 1250000, 1280000, 1310720, 1562500, 1600000, 1638400, 1953125, 2000000, 2048000, 2097152, 2500000, 2560000, 2621440, 3125000, 3200000, 3276800, 3906250, 4000000, 4096000, 4194304, 5000000, 5120000, 5242880, 6250000, 6400000, 6553600, 7812500, 8000000, 8192000, 8388608, 9765625, 10000000, 10240000, 10485760, 12500000, 12800000, 13107200, 15625000, 16000000, 16384000, 16777216, 19531250, 20000000, 20480000, 20971520, 25000000, 25600000, 26214400, 31250000, 32000000, 32768000, 33554432, 39062500, 40000000, 40960000, 41943040, 48828125, 50000000, 51200000, 52428800, 62500000, 64000000, 65536000, 67108864, 78125000, 80000000, 81920000, 83886080, 97656250, 100000000, 102400000, 104857600, 125000000, 128000000, 131072000, 134217728, 156250000, 160000000, 163840000, 167772160, 195312500, 200000000, 204800000, 209715200, 244140625, 250000000, 256000000, 262144000, 268435456, 312500000, 320000000, 327680000, 335544320, 390625000, 400000000, 409600000, 419430400, 488281250, 500000000, 512000000, 524288000, 536870912, 625000000, 640000000, 655360000, 671088640, 781250000, 800000000, 819200000, 838860800, 976562500, 1000000000, 1024000000, 1048576000, 1073741824, 1220703125, 1250000000, 1280000000, 1310720000, 1342177280, 1562500000, 1600000000, 1638400000, 1677721600, 1953125000, 2000000000, 2048000000, 2097152000, 2147483648, 2441406250, 2500000000, 2560000000, 2621440000, 2684354560, 3125000000, 3200000000, 3276800000, 3355443200, 3906250000, 4000000000, 4096000000, 4194304000, 4294967296, 4882812500, 5000000000, 5120000000, 5242880000, 5368709120, 6103515625, 6250000000, 6400000000, 6553600000, 6710886400, 7812500000, 8000000000, 8192000000, 8388608000, 8589934592, 9765625000, 10000000000, 10240000000, 10485760000, 10737418240, 12207031250, 12500000000, 12800000000, 13107200000, 13421772800, 15625000000, 16000000000, 16384000000, 16777216000, 17179869184, 19531250000, 20000000000, 20480000000, 20971520000, 21474836480, 24414062500, 25000000000, 25600000000, 26214400000, 26843545600, 30517578125, 31250000000, 32000000000, 32768000000, 33554432000, 34359738368, 39062500000, 40000000000, 40960000000, 41943040000, 42949672960, 48828125000, 50000000000, 51200000000, 52428800000, 53687091200, 61035156250, 62500000000, 64000000000, 65536000000, 67108864000, 68719476736, 78125000000, 80000000000, 81920000000, 83886080000, 85899345920, 97656250000, 100000000000, 102400000000, 104857600000, 107374182400, 122070312500, 125000000000, 128000000000, 131072000000, 134217728000, 137438953472, 152587890625, 156250000000, 160000000000, 163840000000, 167772160000, 171798691840, 195312500000, 200000000000, 204800000000, 209715200000, 214748364800, 244140625000, 250000000000, 256000000000, 262144000000, 268435456000, 274877906944, 305175781250, 312500000000, 320000000000, 327680000000, 335544320000, 343597383680, 390625000000, 400000000000, 409600000000, 419430400000, 429496729600, 488281250000, 500000000000, 512000000000, 524288000000, 536870912000, 549755813888, 610351562500, 625000000000, 640000000000, 655360000000, 671088640000, 687194767360, 762939453125, 781250000000, 800000000000, 819200000000, 838860800000, 858993459200, 976562500000, 1000000000000};

long long findCeil(const long long x, const long long y) {
    return (x + y - 1) / y;
}

long long countMultiples(const long long l, const long long r, const long long d) {
    return max(r / d - findCeil(l, d) + 1, 0LL);
}

long long n, f, l, r, query, sum1, sum2, sum5, sum10, result, queries[FIXED_NUMBER_COUNT];
int t, q, i;

int findSum1() {
    return (sum1 + f * ((query - l + 1) % MOD)) % MOD;
};

int findSum2() {
    return (sum2 + f * (((query >> 1) - (l + 1 >> 1) + 1) % MOD)) % MOD;
};

int findSum5() {
    return (sum5 + f * (countMultiples(l, query, 5) % MOD)) % MOD;
};

int findSum10() {
    return (sum10 + f * (countMultiples(l, query, 10) % MOD)) % MOD;
};

int countFractions() {
    result = n % MOD;
    q = 0;
    sum1 = sum2 = sum5 = sum10 = 0;
    l = 1;

    (result *= result) %= MOD;

    for (i = 0; i < FIXED_NUMBER_COUNT && FIXED_NUMBERS[i] <= n; ++i)
        queries[q++] = n / FIXED_NUMBERS[i];

    reverse(queries, queries + q);

    for (i = 0; l <= n; l = r + 1) {
        f = n / l;
        r = n / f;
        for (f %= MOD; i < q && queries[i] <= r; ++i) {
            query = queries[i];
            if ((result += findSum2()) >= MOD)
                result -= MOD;
            if ((result += findSum5()) >= MOD)
                result -= MOD;
            if ((result -= findSum1()) < 0)
                result += MOD;
            if ((result -= findSum10()) < 0)
                result += MOD;
        }
        query = r;
        sum1 = findSum1();
        sum2 = findSum2();
        sum5 = findSum5();
        sum10 = findSum10();
    }

    return result;
}

signed main() {
    int t = 0;

    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    cin >> t;

    for (int i = 1; i <= t; ++i) {
        cin >> n;
        cout << countFractions() << '\n';
    }

    return 0;
}

Lời giải 2

Cho số nguyên dương ~n~, đếm số cặp (~a, b~) (~1 \le a, b \le n~) thoả mãn ~\frac{a}{b}~ là số thập phân vô hạn.

Subtask 1

~\frac{a}{b}~ là số thập phân vô hạn ~\Leftrightarrow~ mẫu số của ~\frac{a}{b}~ sau khi rút gọn tồn tại ít nhất một thừa số nguyên tố khác ~2~ và ~5~. Chứng minh:

  • Để đơn giản ta xét các phân số ~\frac{a}{b}~ là phân số tối giản, nghĩa là ~\text{gcd}(a,b)=1~.
  • Chiều thuận: Giả sử ~\frac{a}{b}~ có biểu diễn thập phân hữu hạn. Khi đó tồn tại số nguyên không âm ~k~ sao cho ~10^k \cdot \frac{a}{b}~ là số nguyên, nghĩa là ~10^k~ chia hết cho ~b~ (do ~(a,b)=1~). Từ đó suy ra ~b~ chỉ chứa các ước nguyên tố ~2~ và ~5~.
  • Chiều ngược: Giả sử ~b=2^x 5^y~, chọn ~k = \max(x,y)~, khi đó ta có ~10^k \cdot \frac{a}{b}~ là số nguyên ~\Rightarrow~ ~\frac{a}{b}~ là số thập phân hữu hạn.

Từ đó với mỗi test, ta có thể duyệt toàn bộ cặp ~(a, b)~ với ~1 \le a, b \le n~, và kiểm tra điều kiện bằng cách:

  • Rút gọn phân số ~\frac{a}{b}~ bằng cách gán ~a \leftarrow \frac{a}{\text{gcd}(a, b)}~, ~b \leftarrow \frac{b}{\text{gcd}(a, b)}~
  • Trong khi ~b~ còn chia hết cho ~2~, chia ~b~ cho ~2~. Tương tự với ~5~.
  • Nếu ~b>1~ tức là ~b~ còn chứa một thừa số nguyên tố khác ~2~ và ~5~, ta tăng đáp án lên ~1~.

Độ phức tạp của việc duyệt là ~\mathcal{O}(n^2 \log n)~, tuy nhiên không phải việc tính ~\text{gcd}~ lúc nào cũng mất ~\log n~ nên có thể vẫn qua được hết test. Tuy nhiên ta có thể tính trước các mảng:

  • ~g(i, j)~ là ước chung lớn nhất của hai số ~i~ và ~j~,
  • ~f(i)~ trả về ~\texttt{true}~ nếu ~i~ chỉ chứa thừa số nguyên tố ~2~ và ~5~, ~\texttt{false}~ nếu ngược lại.

để việc kiểm tra với mỗi test chỉ còn ~\mathcal{O}(n^2)~, đưa độ phức tạp về ~\mathcal{O}(tn^2)~

Cài đặt mẫu:

#include <bits/stdc++.h>

using namespace std;

const int N = 1005;

int g[N][N];
bool f[N];

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);

  for (int i = 1; i < N; i++) {
    g[i][i] = i;
    for (int j = i + 1; j < N; j++) {
      g[i][j] = g[j][i] = __gcd(i, j);
    }
  }

  for (int i = 1; i < N; i++) {
    int x = i;
    while (x % 2 == 0) x /= 2;
    while (x % 5 == 0) x /= 5;
    f[i] = x == 1;
  }

  int tc;
  cin >> tc;
  while (tc--) {
    int n;
    cin >> n;
    int cnt = 0;
    for (int i = 1; i <= n; i++) {
      for (int j = 1; j <= n; j++) {
        if (!f[j / g[i][j]]) {
          cnt++;
        }
      }
    }
    cout << cnt << '\n';
  }

  return 0;
}
Subtask 2

Thay vì đếm số lượng số thập phân vô hạn, ta có thể đếm phần bù bằng cách tính số lượng số thập phân hữu hạn, sau đó lấy ~n^2~ trừ đi là ra đáp án cần tìm.

Nhận thấy việc rút gọn phân số như ở subtask 1 là quá tổng quát và không cần thiết cho việc đếm, mà ta chỉ cần biểu diễn phân số ~\frac{a}{b}~ dưới dạng:

~\frac{a}{b} = \frac{a' c}{b' c}~

với:

  • ~c~ không chứa thừa số nguyên tố ~2~ và ~5~,
  • ~b'~ chỉ chứa thừa số nguyên tố ~2~ và ~5~,
  • ~a'~ bất kì.

là đủ, vì ta chỉ cần biết mẫu số sau khi rút gọn chỉ còn các thừa số ~2~ và ~5~ hay không.

Từ đó thay vì duyệt ~a~ và ~b~, ta sẽ duyệt ~c~ và đếm số cặp (~a',b'~) có dạng như trên. Với mỗi ~c~:

  • Số lượng ~a'~ thoả mãn là ~\lfloor \frac{n}{c} \rfloor~
  • Số lượng ~b'~ thoả mãn là: số lượng số có dạng ~2^i \times 5^j~ và có giá trị ~\le \lfloor \frac{n}{c} \rfloor~
    • Ta có thể chuẩn bị trước các số có dạng ~2^i \times 5^j~ theo thứ tự tăng dần vào một mảng. Từ đó với mỗi ~c~, ta có thể dùng hai con trỏ để đếm số lượng số ~b'~ thoả mãn do ~c~ tăng dần ~\Rightarrow~ ~\lfloor \frac{n}{c} \rfloor~ giảm dần.
  • Từ đó ta cộng vào đáp án một lượng: (Số lượng ~a'~ thoả mãn) ~\times~ (Số lượng ~b'~ thoả mãn).

Đáp án không vượt quá ~10^{12}~, nên ta có thể dùng kiểu ~\texttt{int64}~ và tiến hành mod sau khi đã tính toán xong.

Độ phức tạp: ~\mathcal{O}(tn)~

Cài đặt mẫu:

#include <bits/stdc++.h>

using namespace std;

const int N = 1e6 + 5;
const int mod = 1e9 + 7;

bool f[N];
vector<int> cand;

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);

  for (int i = 1; i < N; i++) {
    int x = i;
    while (x % 2 == 0) x /= 2;
    while (x % 5 == 0) x /= 5;
    f[i] = x == 1;
    if (f[i]) {
      cand.emplace_back(i);
    }
  }

  int tc;
  cin >> tc;
  while (tc--) {
    int n;
    cin >> n;
    int64_t ans = 0;
    int ptr = int(cand.size()) - 1;
    for (int c = 1; c <= n; c++) {
      if (c % 2 == 0 || c % 5 == 0) {
        continue;
      }
      // x <= n/c
      // y <= n/c and f[y] == 1
      while (ptr >= 0 && 1ll * cand[ptr] * c > n) --ptr;
      ans += 1ll * (n / c) * (ptr + 1);
    }
    cout << (1ll * n * n - ans) % mod << '\n';
  }

  return 0;
}
Subtask 3

Nhận xét:

  • Chỉ có tối đa ~2\sqrt{n}~ giá trị ~\lfloor \frac{n}{c} \rfloor~ phân biệt.
    • Nếu ~c \le \sqrt{n}~, rõ ràng số lượng giá trị phân biệt tương ứng với số lượng số ~c~, là ~\sqrt{n}~
    • Nếu ~c > \sqrt{n}~, thì ~\lfloor \frac{n}{c} \rfloor~ chỉ nhận các giá trị nguyên từ ~1~ đến ~\sqrt{n}~.
    • Tổng lại ta có số lượng giá trị phân biệt tối đa là ~2\sqrt{n}~.
  • Các giá trị ~c~ có cùng kết quả khi tính ~\lfloor \frac{n}{c} \rfloor~ nằm trên một đoạn liên tiếp của trục số.
    • Điều này là dễ thấy dựa vào tính chất đơn điệu của dãy ~\lfloor \frac{n}{c} \rfloor~ như đã trình bày ở subtask 2.
  • Nếu cố định ~\lfloor \frac{n}{c} \rfloor~, thì đáp án là giống nhau cho dù ~c~ có thay đổi.

Như vậy ta chỉ cần thay đổi phần duyệt ở subtask 2:

  • Ta sẽ duyệt qua các đoạn ~[l,r]~ sao cho ~\lfloor \frac{n}{c} \rfloor~ là cố định với mọi ~c~ thuộc đoạn ~[l,r]~.
  • Với mỗi đoạn ~[l,r]~ như vậy, cộng vào đáp án một lượng ~\text{số lượng số trong đoạn [l, r] không chia hết cho 2 hoặc 5}~ ~\times~ ~\lfloor \frac{n}{l} \rfloor~ ~\times~ ~\text{số lượng giá trị b' thoả mãn}~
    • Sử dụng bao hàm loại trừ để tính số lượng số trong đoạn ~[l, r]~ không chia hết cho ~2~ hoặc ~5~.
    • Cách tính số lượng giá trị ~b'~ thoả mãn giống như ở subtask 2. Tuy nhiên việc chuẩn bị cần phải tinh tế hơn: thay vì duyệt từ ~1~ đến ~n~ rồi kiểm tra, ta chỉ duyệt các cặp ~(i, j)~ và tính giá trị ~2^i \times 5^j~. Nhận thấy ~i, j \le \log_2 (n)~ nên ta có thể duyệt trâu và sắp xếp được.

Vấn đề cuối cùng là duy trì tập các đoạn ~[l,r]~:

  • Ta có thể duyệt ~l~ tăng dần từ ~1~. Với mỗi ~l~, ta chặt nhị phân tìm ~r~ xa nhất sao cho ~\lfloor \frac{n}{l} \rfloor = \lfloor \frac{n}{r} \rfloor~. Sau khi tính toán xong, gán ~l \leftarrow r+1~.
  • Tuy nhiên độ phức tạp cho việc trên có thể lên đến ~\mathcal{O}(t \sqrt{n} \log {n})~.
  • Ta có thể tìm ~r~ trong ~\mathcal{O}(1)~. Đặt ~q = \lfloor \frac{n}{l} \rfloor~, thì ~r = \lfloor \frac{n}{q} \rfloor~.
    • Do nếu ta có ~i~ thoả mãn ~\lfloor \frac{n}{i} \rfloor = q~, thì ~i \le \frac{n}{q}~, từ đó ta chọn được số lớn nhất mà vẫn thoả mãn điều kiện.

Độ phức tạp: ~\mathcal{O}(t \sqrt{n})~

Cài đặt mẫu:

#include <bits/stdc++.h>

using namespace std;

const int MOD = 1e9 + 7;

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);

  vector<int64_t> cand;
  for (int64_t i = 1; i <= 1e12; i *= 2) {
    for (int64_t j = 1; j <= 1e12 / i; j *= 5) {
      cand.emplace_back(i * j);
    }
  }
  sort(cand.begin(), cand.end());

  int tc;
  cin >> tc;
  while (tc--) {
    int64_t n;
    cin >> n;
    __int128_t ans = 0;
    int ptr = int(cand.size()) - 1;
    auto f = [&](int64_t l, int64_t r, int64_t d) {
      return (r / d - (l - 1) / d);
    };
    auto count_numerator = [&](int64_t l, int64_t r) {
      return f(l, r, 1) - f(l, r, 2) - f(l, r, 5) + f(l, r, 10);
    };
    for (int64_t lc = 1; lc <= n;) {
      int64_t nc = n / lc;
      int64_t rc = n / nc;

      while (ptr >= 0 && cand[ptr] > nc) --ptr;
      // so so trong doan [lc, rc] khong chia het cho 2 va 5
      ans += __int128_t(count_numerator(lc, rc)) * nc * (ptr + 1);

      lc = rc + 1;
    }
    cout << (int)((__int128_t(n) * n - ans) % MOD) << '\n';
  }

  return 0;
}
unknownnaciul
o10, Tháng 2, 2026, 1:00 0

3

Tạp chí VNOI Xuân Bính Ngọ - Coding Challenge #06

unknownnaciul đã đăng vào 10, Tháng 2, 2026, 1:00

Coding Challenge

Coding Challenge là một chuyên mục thuộc Tạp chí VNOI, được xây dựng với mục tiêu mang đến cho cộng đồng lập trình thi đấu Việt Nam một sân chơi hữu ích và thiết thực nhằm giúp các độc giả củng cố kiến thức thuật toán và thúc đẩy tinh thần học hỏi, chia sẻ trong cộng đồng.

Hoạt động được tổ chức định kỳ hai lần mỗi tháng trên Fanpage VNOI, Group Facebook “Diễn đàn Tin học Việt Nam” và nền tảng chấm bài trực tuyến VNOJ. Toàn bộ các bài toán trong Coding Challenge được biên soạn bởi đội ngũ Tình nguyện viên thuộc Team Contest của VNOI. Chất lượng các bài toán Coding Challenge được đảm bảo về tính chuyên môn, có sự đa dạng về chủ đề và độ khó cũng như đáp ứng yêu cầu về tính chặt chẽ và chính xác.

Độc giả của Tạp chí VNOI có thể tham gia giải các bài toán thuộc Coding Challenge và gửi lời giải về cho Tạp chí. Những bài giải chất lượng sẽ được chọn lọc để đăng tải trong các số Tạp chí tiếp theo. Bên cạnh việc được ghi nhận và giới thiệu rộng rãi đến cộng đồng, các tác giả có lời giải được đăng tải sẽ nhận những phần quà tri ân từ VNOI nhằm khuyến khích tinh thần ham học hỏi và chia sẻ kiến thức đến cộng đồng

Các lời giải được chọn lọc để đăng công khai sẽ được đánh giá dựa trên tiêu chí về sự rõ ràng, mạch lạc, sáng tạo; có sự đầu tư trong diễn giải tư duy, cấu trúc và tính dễ hiểu. Đối với các bài toán có nhiều subtask, tác giả cần trình bày đầy đủ ý tưởng và hướng giải quyết cho tất cả các phần.

Phần thưởng dành cho mỗi lời giải được đăng tải trên Tạp chí là một bộ quà tặng gồm sổ tay, bút, sticker và móc khóa. Đặc biệt, với các tác giả có từ 03 bài giải trở lên được đăng trong chuyên mục, Ban biên tập Tạp chí sẽ gửi tặng một chiếc áo VNOI như lời tri ân cho những đóng góp tích cực đối với Coding Challenge.

Tiếp nối 03 số đầu tiên, các Coding Challenge #04, #05, #06 và #07 tiếp tục ghi nhận sự ủng hộ nhiệt tình của cộng đồng lập trình thi đấu. Hệ thống đã ghi nhận tổng cộng 2086 lượt nộp bài và ~x~ lời giải được gửi về cho Tạp chí.

Đội ngũ tạp chí VNOI xin gửi lời cảm ơn chân thành đến tất cả các bạn đã tích cực tham gia các Coding Challenge và đóng góp những lời giải vô cùng chất lượng trong thời gian qua!

Coding Challenge #6

Coding Challenge #6 có tên là Mua hàng. Đây là bài toán có format đặc biệt trong các số coding challenge vừa qua khi là một bài toán interactive, tức chương trình của thí sinh tương tác với chương trình của giám khảo để đưa ra đáp án. Để có được số điểm tối đa cho bài toán, thí sinh cần áp dụng một vài nhận xét về xác suất vào mô hình chia để trị để xây dựng lời giải. Bài toán Mua hàng đã được 344 thí sinh thử sức và có 14 lời giải xuất sắc đạt được số điểm tối đa.

Để hiểu rõ hơn về lời giải của bài toán, mời các bạn theo dõi lời giải xuất sắc do bạn Nguyễn Hoàng Minh biên soạn.

Đề bài

Bạn đọc có thể theo dõi đề bài gốc và subtasks tại nền tảng VNOJ. Sau đây là bản tóm tắt của đề bài.

Cho một hoán vị ~p~ bị ẩn đi và một số nguyên ~k~. Yêu cầu tìm chỉ số của ~k~ phần tử có giá trị lớn nhất bằng cách sử dụng ít nhất số lượng câu hỏi có dạng như sau:

  • Chọn hai số nguyên ~x, y~ (thỏa ~1 \leq x, y \leq n~ và ~x \neq y~), máy chấm sẽ cho biết ~p_x < p_y~ hay ~p_x > p_y~.

Điểm số của thí sinh sẽ là tối đa nếu số lượng câu hỏi ít hơn số câu hỏi mà giám khảo sử dụng; ngược lại, thí sinh càng sử dụng ít câu hỏi hơn sẽ càng nhận được điểm số cao hơn.

Giới hạn:

  • ~1 \leq n \leq 1000~

Lời giải

Ý Tưởng
  • Bài toán trên có thể được đưa về tìm phần tử lớn thứ ~k~ trong một mảng giá trị với thứ tự bất kì. Cụ thể hơn, nếu ta gọi ~B~ là phần tử lớn thứ ~k~ trong mảng ~n~ phần tử được cho, ~k - 1~ phần tử lớn nhất còn lại (từ phẩn tử lớn thứ nhất đến phần tử lớn thứ ~k - 1~) có thể được xác định bằng cách so sánh giá trị ~B~ với ~n - 1~ phần tử còn lại:
    • Nếu phần tử ~A~ lớn hơn ~B~ thì ~A~ là một trong ~k~ giá trị lớn nhất.
    • Ngược lại, ~A~ thuộc tập ~n - k~ giá trị còn lại.
  • Để có thể xác định được giá trị ~B~, chúng ta cần phải thực hiện ít nhất ~\Omega(n)~ phép so sánh bởi mỗi phần tử còn phải được tham gia vào ít nhất một phép so sánh và mỗi phép so sánh chỉ có thể xác định mối quan hệ giữa hai phần tử.
  • Một thuật toán mà ta có thể dùng để tìm được giá trị ~B~ nêu trên là thuật toán Quickselect. Thuật toán có thể được miêu tả như sau:
    • Cho một mảng ~P~ gồm các phần tử phân biệt và một số nguyên ~k~; mục tiêu là tìm phần tử lớn thứ ~k~ trong ~P~.
    • Chọn một phần tử làm pivot từ mảng ~P~, sau đó phân hoạch mảng thành hai tập con:
      • ~L~ là tập gồm các phần tử lớn hơn pivot; và
      • ~R~ là tập gồm các phần tử nhỏ hơn pivot.
    • Khi đó:
      • Nếu ~k \leq |L|~, bài toán tương đương với việc tìm phần tử lớn thứ ~k~ trong ~L~.
      • Nếu ~k = |L| + 1~, kết quả bài toán sẽ là pivot.
      • Nếu ~k > |L| + 1~, bài toán tương đương với việc tìm phần tử lớn thứ ~k - |L| - 1~ trong ~R~.
  • Nếu ta chọn pivot là một phần tử ngẫu nhiên trong mảng đang xét thì trong trường hợp trung bình, giá trị kỳ vọng của số phép so sánh mà thuật toán trên sử dụng sẽ là ~O(n)~. Trong trường hợp tệ nhất, số phép so sánh mà thuật toán nêu trên có thể dùng là ~O(n^2)~ (ví dụ khi pivot được chọn là phần tử nhỏ nhất hoặc lớn nhất trong mảng, ...). Tuy nhiên, xác suất để thuật toán sử dụng nhiều hơn ~C \cdot n~ phép so sánh, với ~C~ là một hằng số, giảm rất nhanh khi ~C~ tăng dần.
  • Việc xác định ~k - 1~ phần tử còn lại có thể được thực hiện với ~O(n)~ thao tác so sánh như đã đề cập ở phần trên. Vì vậy, trong trường hợp trung bình, ý tưởng nêu trên sẽ có giá trị kỳ vọng của số thao tác sử dụng là ~O(n)~, gần tương ứng với cận dưới của số thao tác tối thiểu phải sử dụng nêu trên.
Cài Đặt
  • Sau khi xác định phần tử lớn thứ ~k~, ta có thể không cần so sánh giá trị này với từng phần tử trong ~n - 1~ phần tử còn lại để tìm ~k - 1~ phần tử lớn nhất tiếp theo. Điều này là bởi ~k - 1~ phần tử đó có thể được xác định cùng lúc với khi ta thực hiện chia tập trong quá trình tìm kiếm phần tử lớn thứ ~k~. Cụ thể hơn, nếu ta đặt ~X(P, k)~ là tập hợp ~K~ phần tử lớn nhất của tập hợp ~P~ gồm các giá trị phân biệt. Nếu ta chia ~P~ thành hai tập ~L~ và ~R~ theo pivot được định nghĩa như trên, ~X(P, k)~ có thể được xác định một cách đệ quy như sau:
    • Nếu ~k \leq |L|~, ~X(P, k) = X(L, k)~
    • Nếu ~k = |L| + 1~, ~X(P, k) = L \cup \{\text{pivot}\}~
    • Nếu ~k > |L| + 1~, ~X(P, k) = L \cup \{\text{pivot}\} \cup X(R, k - |L| - 1)~
  • Dù xác suất để thuật toán nêu trên thực hiện số lượng phép so sánh gần với ~O(n^2)~ là thấp nhưng số lượng này vẫn có thể nhiều hơn số lượng thao tác mà giám khảo để sử dụng. Để có thể đặt được số điểm tối đa ở mỗi test, ta có thể cần phải thử nhiều seed khác nhau cho việc sinh số giả ngẫu nhiên cho đến khi đặt số điểm tối đa toàn bộ test.

Code C++ minh họa

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

constexpr int MAX_N = 1'003;

namespace Interactor {

    int queryResults[MAX_N][MAX_N];

    int query(const int x, const int y) {
        if (x == y)
            return x;
        int result = 0;
        cout << '?' << ' ' << x << ' ' << y << endl;
        cin >> result;
        return result;
    }

    int getQueryResult(const int x, const int y) {
        return queryResults[x][y] ? queryResults[x][y] : (queryResults[x][y] = queryResults[y][x] = query(x, y));
    }

    bool checkSmaller(const int x, const int y) {
        return getQueryResult(x, y) == y;
    }

    bool checkGreater(const int x, const int y) {
        return getQueryResult(x, y) == x;
    }

    int findMaximum(const int x, const int y) {
        return checkGreater(x, y) ? x : y;
    }

    int findMinimum(const int x, const int y) {
        return checkSmaller(x, y) ? x : y;
    }

    int findMaximum(const vector<int> &indices) {
        int result = indices.front();
        for (const int &i : indices) {
            if (i == result)
                continue;
            result = findMaximum(result, i);
        }
        return result;
    }

    int findMinimum(const vector<int> &indices) {
        int result = indices.front();
        for (const int &i : indices) {
            if (i == result)
                continue;
            result = findMinimum(result, i);
        }
        return result;
    }

    void printAnswer(const vector<int> &indices) {
        cout << '!' << ' ' << 1 << ' ' << 1 << endl;
        for (const auto &i : indices)
            cout << i << ' ';
        cout << endl;
        exit(0);
    }
}

template<class T>
vector<T> operator + (vector<T> a, const vector<T> &b) {
    a.insert(a.end(), b.begin(), b.end());
    return a;
}

template<class T>
vector<T> operator - (vector<T> a, const vector<T> &b) {
    const unordered_set<T> s(b.begin(), b.end());
    int length = 0;
    for (const auto &value : a)
        if (s.find(value) == s.end())
            a[length++] = value;
    a.resize(length);
    return a;
}

int getRandomInteger(const int n) {
    return rand() % n;
}

int getRandomSample(const vector<int> &values) {
    return values[getRandomInteger(values.size())];
}

vector<int> getRange(const int l, const int r) {
    vector<int> indices(r - l);
    iota(indices.begin(), indices.end(), l);
    return indices;
}

vector<int> getShuffledList(vector<int> values) {
    random_shuffle(values.begin(), values.end());
    return values;
}

pair<vector<int>, vector<int> > partitionList(const vector<int> &values, const int pivot) {
    vector<int> left, right;
    for (const int &value : values) {
        if (value == pivot)
            continue;
        (Interactor::checkSmaller(value, pivot) ? left : right).push_back(value);
    }
    return make_pair(left, right);
}

class QuickSelect {
private:

    const int n;

    vector<int> runRecursion(const vector<int> &indices, const int k) const {
        if (k == 1)
            return indices;
        const int indexCount = indices.size(), R = indexCount - k + 1;
        if (R == 1)
            return vector<int>(1, Interactor::findMaximum(indices));
        const int L = indexCount - R;
        if (L == 1)
            return indices - (vector<int>(1, Interactor::findMinimum(indices)));
        const int pivot = getRandomSample(indices);
        const auto &[left, right] = partitionList(indices, pivot);
        const int m = left.size() + 1;
        if (m < k)
            return runRecursion(right, k - m);
        const vector<int> includedIndices = (vector<int>(1, pivot)) + right;
        return (m == k) ? includedIndices : (runRecursion(left, k) + includedIndices);
    }

public:

    QuickSelect(const int n): n(n) {};

    vector<int> operator () (const int k) const {
        return runRecursion(getShuffledList(getRange(1, n + 1)), n - k + 1);
    }

};

int main() {
    int n = 0, k = 0;

    cin >> n >> k;

    srand((k <= 400) ? ((n ^ k) + (n & k)) : ((n ^ k) + n + k)); // Update this seed until all tests passed

    Interactor::printAnswer(QuickSelect(n)(k));

    return 0;
}

Độ Phức Tạp
  • Các bước xử lý chính của một cách cài đặt cho ý tưởng nêu trên sẽ có có độ phức tạp thời gian tương ứng với số lượng thao tác sử dụng, tức:
    • ~O(n)~ trong trường hợp trung bình; và
    • ~O(n^2)~ trong trường hợp tệ nhất.
  • Về độ phức tạp bộ nhớ, tùy vào cách cài đặt mà ta có thể có độ phức tạp ~O(n)~ hoặc ~O(n^2)~.

Một Số Ý Tưởng Khác

Ngoài ra ý tưởng nêu trên để giải bài toán, ta có thể xem xét một số ý tưởng khác. Tuy nhiên, những ý tưởng này có thể không đạt toàn bộ số điểm cho mọi test.

Sắp Xếp
  • Ta có thể dùng các thuật toán sắp xếp như heapsort, merge sort, ... để sắp xếp toàn bộ phần tử với ~O(n \log n)~ thao tác. Ta có thể xác định ~k~ phần tử lớn nhất sau khi đã sắp xếp.
  • Tuy nhiên so với thuật toán quickselect nêu trên, ý tưởng sử dụng sắp xếp này có thể sử dụng số lượng thao tác lớn hơn nhiều lần trong nhiều trường hợp.
Heap
  • Ta có thể duyệt qua từng phần tử và sử dụng một min-heap để lưu tối đa ~k~ phần tử lớn nhất trong các phần tử đã duyệt qua. Cụ thể hơn, khi duyệt qua một phần tử, ta thêm phần tử đó vào heap và loại bỏ phần tử nhỏ nhất khỏi heap nếu heap có nhiều hơn ~k~ phần tử. Mỗi lần thêm một phần tử vào heap hoặc xóa phần tử nhỏ nhất khỏi heap, ta sẽ thực hiện tối đa ~O(\log k)~ thao tác (vì trong trường hợp này, số lượng phần tử tối đa của heap là ~O(k)~).
  • Vì vậy, ý tưởng này sử dụng ~O(n \log k)~ thao tác trong trường hợp tệ nhất.
  • Để giảm số thao tác sử dụng, nếu ~k > \frac{n}{2}~, ta có thể tìm ~n - k~ phần tử nhỏ nhất (bằng cách sử dụng max-heap) thay vì tìm ~k~ phần tử lớn nhất trực tiếp. Tuy nhiên, cách này chỉ giúp giảm nếu xét về mặt hằng số, số lượng thao tác sẽ vẫn là ~O(n \log k)~.
  • Cũng tương tự như ý tưởng dùng thuật toán sắp xếp, ý tưởng dùng heap này có thể sử dụng số lượng thao tác lớn hơn nhiều lần trong nhiều trường hợp, đặc biệt khi ~k~ càng gần với giá trị ~\frac{n}{2}~.
Biến Thể của Quickselect
  • Một số biến thể khác của thuật toán quickselect có thể được xem xét cho bài toán này. Điểm khác nhau chính giữa các biến thể này chủ yếu ở cách chọn pivot.
Median of Medians
  • Thay vì chọn pivot là một phần tử ngẫu nhiên như ý tưởng được ở phần trên, ta ước tính trung vị của mảng được cho với phương pháp median of medians rồi dùng trung vị này làm pivot.
  • Để ước tính trung vị, phương pháp median of medians chia mảng thành các block gồm tối đa ~5~ phần tử liên tiếp và tìm trung vị của từng block. Trung vị của mảng được cho sẽ được ước lượng bằng trung vị của các trung vị của các block.
    • Để tìm trung vị của một block tối đa ~5~ phần tử, ta có thể sắp xếp mọi phần tử trong block (với tối đa ~7~ thao tác so sánh). Khi này, phần tử ở giữa sẽ là trung vị ta cần tìm. Ngoài ra, ta cũng có cách để tìm trung vị trực tiếp với tối đa ~6~ thao tác so sánh.
    • Để tìm trung vị của các trung vị của các block, ta có thể tiếp tục sử dụng thuật toán quickselect để tìm phần tử lớn thứ ~\frac{n}{10}~ trong các trung vị của block (vì ta có ~\frac{n}{5}~ block, phần tử lớn thứ ~\frac{n}{5} \cdot \frac{1}{2} = \frac{n}{10}~ sẽ là phần tử gần trung vị của toàn bộ mảng).
  • Trong trường hợp trung bình và tệ nhất, thuật toán này sử dụng ~O(n)~ thao tác so sánh. Tuy nhiên, so với cách chọn pivot là một phần tử ngẫu nhiên, yếu tố hằng số trong việc phân tích số lượng thao tác là lớn hơn nhiều. Vì vậy, cách dùng phương pháp median of medians có thể có số lượng thao tác sử dụng lớn hơn số lượng giám khảo sử dụng trong khi cách chọn ngẫu nhiên lại ít hơn.
Thuật toán Floyd–Rivest
  • Một thuật toán khác có thể được xem xét là thuật toán Floyd–Rivest. So với cách chọn pivot là phần tử ngẫu nhiên hoặc trung vị ước lượng, thuật toán Floyd–Rivest có thể có cách cài đặt phức tạp hơn.
unknownnaciul
o10, Tháng 2, 2026, 1:00 0

3

Tạp chí VNOI Xuân Bính Ngọ - Coding Challenge #07

unknownnaciul đã đăng vào 10, Tháng 2, 2026, 1:00

Coding Challenge

Coding Challenge là một chuyên mục thuộc Tạp chí VNOI, được xây dựng với mục tiêu mang đến cho cộng đồng lập trình thi đấu Việt Nam một sân chơi hữu ích và thiết thực nhằm giúp các độc giả củng cố kiến thức thuật toán và thúc đẩy tinh thần học hỏi, chia sẻ trong cộng đồng.

Hoạt động được tổ chức định kỳ hai lần mỗi tháng trên Fanpage VNOI, Group Facebook “Diễn đàn Tin học Việt Nam” và nền tảng chấm bài trực tuyến VNOJ. Toàn bộ các bài toán trong Coding Challenge được biên soạn bởi đội ngũ Tình nguyện viên thuộc Team Contest của VNOI. Chất lượng các bài toán Coding Challenge được đảm bảo về tính chuyên môn, có sự đa dạng về chủ đề và độ khó cũng như đáp ứng yêu cầu về tính chặt chẽ và chính xác.

Độc giả của Tạp chí VNOI có thể tham gia giải các bài toán thuộc Coding Challenge và gửi lời giải về cho Tạp chí. Những bài giải chất lượng sẽ được chọn lọc để đăng tải trong các số Tạp chí tiếp theo. Bên cạnh việc được ghi nhận và giới thiệu rộng rãi đến cộng đồng, các tác giả có lời giải được đăng tải sẽ nhận những phần quà tri ân từ VNOI nhằm khuyến khích tinh thần ham học hỏi và chia sẻ kiến thức đến cộng đồng

Các lời giải được chọn lọc để đăng công khai sẽ được đánh giá dựa trên tiêu chí về sự rõ ràng, mạch lạc, sáng tạo; có sự đầu tư trong diễn giải tư duy, cấu trúc và tính dễ hiểu. Đối với các bài toán có nhiều subtask, tác giả cần trình bày đầy đủ ý tưởng và hướng giải quyết cho tất cả các phần.

Phần thưởng dành cho mỗi lời giải được đăng tải trên Tạp chí là một bộ quà tặng gồm sổ tay, bút, sticker và móc khóa. Đặc biệt, với các tác giả có từ 03 bài giải trở lên được đăng trong chuyên mục, Ban biên tập Tạp chí sẽ gửi tặng một chiếc áo VNOI như lời tri ân cho những đóng góp tích cực đối với Coding Challenge.

Tiếp nối 03 số đầu tiên, các Coding Challenge #04, #05, #06 và #07 tiếp tục ghi nhận sự ủng hộ nhiệt tình của cộng đồng lập trình thi đấu. Hệ thống đã ghi nhận tổng cộng 2086 lượt nộp bài và ~x~ lời giải được gửi về cho Tạp chí.

Đội ngũ tạp chí VNOI xin gửi lời cảm ơn chân thành đến tất cả các bạn đã tích cực tham gia các Coding Challenge và đóng góp những lời giải vô cùng chất lượng trong thời gian qua!

Coding Challenge #7

Coding Challenge #7 có tên là Tổng mod. Đây là bài toán truy vấn yêu cầu thí sinh phải vận dụng được kỹ thuật đường quét và các cấu trúc dữ liệu cần thiết để xây dựng lời giải. Bài toán Tổng mod đã được 334 thí sinh thử sức và có 10 lời giải xuất sắc đạt được số điểm tối đa.

Để hiểu rõ hơn về lời giải của bài toán, mời các bạn theo dõi 2 lời giải xuất sắc do bạn Nguyễn Hoàng Minh (lời giải 1) và Lê Minh Tuấn (lời giải 2) biên soạn.

Đề bài

Bạn đọc có thể theo dõi đề bài gốc và subtasks tại nền tảng VNOJ. Sau đây là bản tóm tắt của đề bài.

Cho dãy ~a~ gồm ~n~ phần tử và ~q~ truy vấn có dạng:

  • Cho hai số nguyên ~l, r~, tính tổng sau:
    ~\sum_{i=l}^r a_i \bmod (i - l + 1)~

Giới hạn:

  • ~1 \leq n, q \leq 2 \cdot 10^5~
  • ~1 \leq a_i \leq 2 \cdot 10^5~

Lời giải 1

Subtask 1

Giới hạn: ~n, q \le 7000~

Ý Tưởng
  • Ta đặt tổng cần tìm cho mỗi truy vấn như sau:

~S(l, r) = \sum_{i=l}^{r}\left(a_i \bmod \left(i - l + 1\right)\right)~
  • Với giới hạn nhỏ của ~n~ và ~q~ được cho, subtask 1 cho phép chúng ta có thể xử lý từng truy vấn ~(l, r)~ và tính ~S(l, r)~ bằng cách duyệt qua các vị trí từ ~l~ tới ~r~ và trực tiếp tính ~S(l, r)~ theo định nghĩa như trên.
Độ Phức Tạp
  • Ý tưởng nêu trên có thể được cài đặt với độ phức tạp thời gian ~O(n \cdot q)~ bởi ta sẽ duyệt qua ~O(q)~ truy vấn và với mỗi truy vấn, ta duyệt qua ~O(n)~ vị trí để tính tổng ~S~. Với giới hạn thời gian được cho (~2.5~ giây), chương trình được cài đặt theo ý tưởng nêu trên có thể chạy trong thời gian cho phép.
  • Về độ phức tạp bộ nhớ, ý tưởng có thể được cài đặt với độ phức tạp ~O(n)~ bởi ta cần lưu ~O(n)~ phần tử của mảng ~a~ được cho.
Code Minh Họa

Code C++ minh họa

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

constexpr int MAX_N = 200'005, MAX_Q = 200'005;

int n, q, a[MAX_N];

namespace FirstSubtask {

    int computeValue(const int l, const int r) {
        int result = 0;
        for (int i = l, h = 1; i <= r; ++i, ++h)
            result += a[i] % h;
        return result;
    }

    void solve() {
        for (int i = 1, l = 1, r = n; i <= q; ++i) {
            cin >> l >> r;
            cout << computeValue(l, r) << '\n';
        }
    }

}

void input() {
    cin >> n >> q;
    for (int i = 1; i <= n; ++i)
        cin >> a[i];
}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    FirstSubtask::solve();

    return 0;
}

Subtask 2

Giới hạn: ~a_i \le 200~

Ý Tưởng
  • Ta có thể viết lại công thức tính tổng cần tìm như sau:

~S(l, r) = \sum_{m = 1}^{r - l + 1}\left(a[l + m - 1] \bmod m\right)~
  • Ta nhận thấy rằng với hai số nguyên dương ~x~ và ~y~ bất kì, nếu ~x < y~, ta có: ~x \bmod y = x~.
  • Nếu ta đặt ~A~ là giá trị lớn nhất mà một phần tử của mảng ~a~ có thể có (~A = 200~ trong subtask 2), với một truy vấn ~(l, r)~ mà ~r - l + 1 > A~, ta có:

~S(l, r) = \sum_{m = 1}^{A}\left(a[l + m - 1] \bmod m\right) + \sum_{m = A + 1}^{r - l + 1}\left(a[l + m - 1] \bmod m\right)~

Dựa vào tính chất đề cập được nêu trên, ta có:

~S(l, r) = \sum_{m = 1}^{A}\left(a[l + m - 1] \bmod m\right) + \sum_{m = A + 1}^{r - l + 1}\left(a[l + m - 1]\right)~
  • Ta nhận thấy rằng, việc tính thành phần tổng mà không phải xét tới phép chia lấy dư tương đương với việc tính tổng các phẩn tử của một mảng con liên tiếp. Vì dãy số ~a~ không thay đổi trong suốt quá trình trả lời truy vấn, ta có thể tính thành phần này bằng cách dùng mảng cộng dồn. Cụ thể hơn, nếu ta đặt ~P[x] = \sum_{i = 1}^{x}a[i]~ là tổng các phần tử của dãy số ~a~ từ vị trí ~1~ đến vị trí ~x~, ta có:

~\sum_{m = A + 1}^{r - l + 1}\left(a[l + m - 1]\right) = P[r] - P[l + A - 1]~
  • Mặt khác, giới hạn ~A~ của subtask 2 cho phép ta tính thành phần phải sử dụng đến phép chia lấy dư (thành phần ~\sum_{m = 1}^{A}(a[l + m - 1] \bmod m)~ trong công thức trên) bằng cách duyệt qua từng vị trí như trong subtask 1. Tương tự, nếu ~r - l + 1 \leq A~, ta có thể tính tổng ~S(l, r)~ như trong subtask 1.
Độ Phức Tạp
  • Ý tưởng nêu trên có thể được cài đặt với độ phức tạp thời gian ~O(n + q \cdot A)~ bởi:
    • Ta cần đọc dữ liệu dãy số ~a~ với ~O(n)~ phần tử và có thể tính mảng cộng dồn với độ phức tạp ~O(n)~.
    • Với mỗi truy vấn ~(l, r)~, vì tổng các phần tử liên tiếp có thể được tính trong ~O(1)~ sau khi đã xây dựng mảng cộng dồn và ta có thể chỉ cần duyệt thêm qua tối đa ~O(A)~ vị trí để thực hiện việc tính tổng ~S(l, r)~, việc xử lý mỗi truy vấn có thể được thực hiện với độ phức tạp ~O(A)~. Vì ta có ~O(q)~ truy vấn, việc xử lý hết tất cả truy vấn có thể thực hiện với độ phức tạp ~O(q \cdot A)~.
  • Về độ phức tạp bộ nhớ, ý tưởng có thể được cài đặt với độ phức tạp ~O(n)~ bởi ta có thể chỉ cần lưu thêm mảng cộng dồn với ~O(n)~ phần tử so với subtask 1.
Code Minh Họa

Code C++ minh họa

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

constexpr int MAX_N = 200'005, MAX_Q = 200'005;

int n, q, a[MAX_N];

namespace SecondSubtask {

    constexpr int MAX_A = 200;

    void solve() {
        int prefixSums[n + 1]{}, result = 0;

        for (int i = 1; i <= n; ++i)
            prefixSums[i] = prefixSums[i - 1] + a[i];

        for (int i = 1, z = 0, h = 0, l = 1, r = n; i <= q; ++i) {
            result = 0;
            cin >> l >> r;
            for (z = l, h = 1; z <= r; ++z, ++h) {
                if (h > MAX_A) {
                    result += prefixSums[r] - prefixSums[z - 1];
                    break;
                }
                result += a[z] % h;
            }
            cout << result << '\n';
        }
    }

}

void input() {
    cin >> n >> q;
    for (int i = 1; i <= n; ++i)
        cin >> a[i];
}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    SecondSubtask::solve();

    return 0;
}

Subtask 3

Giới hạn: ~r = n~

Ý Tưởng
  • Vì biên trái của mọi truy vấn trong subtask 3 luôn nằm ở vị trí cuối cùng, vỡi mỗi vị trí ~i~, phần tử ~a[i]~ sẽ có đóng góp vào tổng ~S(l, r)~ nếu ~l \leq i~ (~r = n~). Để giải quyết subtask này, ta có thể phân tích xem, với một vị trí ~i~, ~a[i]~ có đóng góp như thế nào tới các tổng ~S(j, n)~ với ~1 \leq j \leq n~.
  • Nếu ~x~ và ~y~ là hai số nguyên dương, ta có:

~x \bmod y = x - y \cdot \lfloor \frac{x}{y} \rfloor~

Áp dụng điều này vào ~S(l, r)~, ta có:

~ \begin{align} S(l, r) &= \sum_{m = 1}^{r - l + 1} \left(a[i + m - 1] - m \cdot \left\lfloor \frac{a[i + m - 1]}{m} \right\rfloor \right) \\ &= \sum_{m = 1}^{r - l + 1} a[i + m - 1] - \sum_{m = 1}^{r - l + 1} \left( m\cdot \left\lfloor \frac{a[i + m - 1]}{m} \right\rfloor \right) \\ &= \sum_{i = l}^{r} a[i] - \sum_{m = 1}^{r - l + 1} \left( m \cdot \left\lfloor \frac{a[i + m - 1]}{m} \right\rfloor \right) \end{align} ~

Tương tự như subtask 2, thành phần ~\sum_{i = l}^{r}a[i]~ có thể được tính hiệu quả bằng cách sử dụng mảng cộng dồn. Bài toán con còn lại ta phải giải quyết là làm sao để tính thành phần ~\sum_{m = 1}^{r - l + 1} \left(m \cdot \left\lfloor \frac{a[i + m - 1]}{m} \right\rfloor \right)~ một cách hiệu quả.

  • Để dễ mô tả phần còn lại của ý tưởng, ta sẽ đặt ~b[i][j] = \left(j - i + 1\right) \left\lfloor \frac{a[i]}{j - i + 1} \right\rfloor~ với ~i, j~ là hai số tự nhiên sao cho ~1 \leq i \leq j \leq n~. Đồng thời, ta đặt ~B[i][j] = \sum_{z = i}^{j}b[i][z]~. Như vậy, với một truy vấn ~(l, r)~, ta cần xác định giá trị ~B[l][r]~.
  • Ta nhận thấy rằng với một số nguyên dương cố định ~x~ và một số nguyên dương ~y~, giá trị ~\left\lfloor \frac{x}{y} \right\rfloor~ chỉ có thể có tối đa ~O(\sqrt x)~ khác nhau.

Giải thích

  • Với ~y \leq \sqrt x~, ta chỉ có tối đa ~O(\sqrt x)~ giá trị khác nhau cho ~\lfloor \frac{x}{y} \rfloor~ bởi:
    • Mỗi giá ~y~ sẽ chỉ dẫn tới một giá trị ~\lfloor \frac{x}{y} \rfloor~;
    • Ta có tối đa ~O(\sqrt x)~ giá trị ~y~.
    • Với ~y > \sqrt x~, ta có ~\frac{x}{y} < \sqrt x~. Vì vậy, ~\lfloor \frac{x}{y} \rfloor < \sqrt x~ (vì ~\lfloor \frac{x}{y} \rfloor \leq \frac{x}{y}~). Mặt khác, vì ~\lfloor \frac{x}{y} \rfloor~ là số nguyên, ~\lfloor \frac{x}{y} \rfloor~ chỉ cũng có thể có tối đa ~O(\sqrt x)~ giá trị khác nhau trong trường hợp này.

Dựa trên điều này, ta nhận thấy rằng với một vị trí ~i~, ta có thể phân các vị trí từ ~1~ đến ~i - 1~ thành ~O\left(\sqrt {a[i]}\right)~ nhóm gồm các phân tử liên tiếp trong ~b~ sao cho hiệu giữa hai phần tử liên tiếp là như nhau.

Ví dụ

  • Giả sử tại vị trí ~67~, giá trị của ~a[67]~ là ~24~. Khi này, với ~m~ là số tự nhiên, ta có:

    • ~m = 1~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 24~, tức ~b[67][67] = 24~.
    • ~m = 2~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 12~, tưc ~b[66][67] = 24~.
    • ~m = 3~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 8~, tức ~b[65][67] = 24~.
    • ~m = 4~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 6~, tức ~b[64][67] = 24~.
    • ~5 \leq m \leq 6~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 4~, tức ~b[62[67] = 24~, ~b[63][67] = 20~.
    • ~7 \leq m \leq 8~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 3~, tức ~b[60][67] = 24~, ~b[61][67] = 21~.
    • ~9 \leq m \leq 12~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 2~, tức ~b[56][67] = 24~, ~b[57][67] = 22~, ~b[58][67] = 20~, ~b[59][67] = 18~.
    • ~13 \leq m \leq 24~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 1~, tức ~b[44][67] = 24~, ~b[45][67] = 23~, ..., ~b[55][67] = 14~.
    • ~24 < m~, ~\left\lfloor \frac{a[i]}{m} \right\rfloor = 0~ tức ~b[l][r] = 0~

    • Ta đặt ~s[i] = S(i, r)~, tức mỗi truy vấn ~(l, r)~ trong subtask 3 sẽ có kết quả là ~s[l]~. Dựa trên phân tích trên, các giá trị ~s[i]~ có thể được tính như sau:

      • Ban đầu, với mọi vị trí ~i~, ~s[i] = 0~
      • Với mỗi vị trí ~i~, ta sẽ thực hiện ~O\left(\sqrt{a[i]}\right)~ truy vấn cập nhật bậc thang trên ~s[i]~ với mỗi truy vấn cập nhật bậc thang sẽ tương đương với một nhóm đề cập như trên. Cụ thể hơn, mỗi truy vấn có thể được đại diện bởi bốn số nguyên ~(l, r, x, y)~, mang ý nghĩa như sau:
    • Ta cần tăng ~s[r]~ thêm ~x~;
    • Ta cần tăng ~s[r - 1]~ thêm ~x + y~;
    • ...
    • Ta cần tăng ~s[l]~ thêm ~x + y \times (r - l)~.

Ví dụ Nếu ta quay trở lại ví dụ trên, một số truy vấn cập nhật ta phải thực hiện sẽ là:

  • ~(56, 58, 18, 2)~: Ta cần tăng các phần tử ~s~ từ ~56~ tới ~58~ với giá trị ở vị trí trái nhất là ~18~ và step là ~2~.
  • ~(44, 55, 14, 1)~: Ta cần tăng các phần tử ~s~ từ ~44~ tới ~55~ với giá trị đầu tiên là ~14~ và step là ~1~.
  • ...
  • Vì ta không phải thực hiện các truy vấn cập nhật online, ta có thể tính giá trị ~s~ bằng một cách xử lý offline với mảng hiệu:
    • Ta đặt ~D_2[i]~ là giá trị của mảng hiệu đại diện tổng hiệu các step của các truy vấn cập nhật có bao gồm vị trí ~i~.
    • Ta đặt ~D_1[i]~ là tổng các thay đổi cho các truy vấn bắt đầu hoặc kết thúc tại ~i~.
Độ Phức Tạp
  • Ý tưởng trên có thể được cài đặt với độ phức tạp thời gian ~O\left(q + n \cdot \sqrt{A}\right)~ với ~A~ là giá trị lớn nhất của mảng ~a~.
    • Ta cần duyệt qua ~O(n)~ vị trí và với mỗi vị trí, ta cần duyệt qua ~O\left(\sqrt{a}\right)~ truy vấn cập nhật. Việc cập nhật hai ~D_1~ và ~D_2~ nêu trên cho mỗi truy vấn có độ phức tạp ~O(1)~.
    • Ta cần duyệt qua ~O(n)~ vị trí để tính mảng ~s~ với mỗi vị trí có thể được xử lý trong ~O(1)~.
    • Ta cần duyệt qua ~O(q)~ truy vấn để xử lý các truy vấn. Mỗi truy vấn có thể được xử lý trong ~O(1)~ sau khi đã thực tính mảng ~s~ trên.
  • Xét với độ phức tạp bộ nhớ, ý tưởng có thể được cài đặt với độ phức tạp ~O(n)~ bởi ta có thể chỉ cần lưu thêm mảng hiệu với ~O(n)~ phần tử so với subtask 2.
Code Minh Họa

Code C++ minh họa

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

constexpr int MAX_N = 200'005, MAX_Q = 200'005;

int n, q, a[MAX_N];

namespace ThirdSubtask {

    void solve() {
        long long d[n + 1][2]{}, results[n + 1]{}, sum = 0, change = 0;

        for (int i = n, l = 1, r = 1, j = 1, delta = 0, value = 0; i >= 1; --i) {
            value = a[i];
            for (l = 1; l <= value; l = r + 1) {
                delta = value / l;
                if (i < l)
                    break;
                j = i - l + 1;
                d[j][0] += delta * (l - 1);
                d[j][1] += delta;
                r = value / delta;
                if (i <= r)
                    break;
                j = i - r;
                d[j][0] -= delta * r;
                d[j][1] -= delta;
            }

            change += d[i][1];
            sum += a[i] - d[i][0] - change;
            results[i] = sum;
        }

        for (int i = 1, l = 1, r = n; i <= q; ++i) {
            cin >> l >> r;
            cout << results[l] << '\n';
        }
    }

}

void input() {
    cin >> n >> q;
    for (int i = 1; i <= n; ++i)
        cin >> a[i];
}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    ThirdSubtask::solve();

    return 0;
}

Subtask 4

Giới hạn: ~n, q \le 2 \times 10^5~

Ý Tưởng
  • Để mở rộng hướng giải quyết của subtask 3 cho subtask 4, ta nhận thấy rằng để nếu ta đã tính được ~B[l][r]~ quả cho truy vấn ~(l, r)~ và mảng hiệu ~D_1~ và ~D_2~ nếu chỉ xét các vị trí từ ~1~ tới ~r~, ta có thể mở rộng hoặc thu hẹp biên ~l~ và ~r~ như sau:
    • Để xử lý cho truy vấn ~(l - 1, r)~, ta chỉ cần dựa vào ~B[l][r]~, ~D_1[l - 1]~ và ~D_2[l - 1]~ để xác định giá trị ~B[l - 1][r]~ (mảng ~D_1~ và ~D_2~ giữ nguyên).
    • Tương tự, để xử lý cho truy vấn ~(l + 1, r)~, ta chỉ cần dựa vào ~B[l][r]~, ~D_1[l]~ và ~D_2[l]~ để xác định giá trị ~B[l + 1][r]~ (mảng ~D_1~ và ~D_2~ giữ nguyên).
    • Để xử lý cho truy vấn ~(l, r + 1)~, ta có thể tính ~b[l][r + 1]~ để cùng với giá trị ~B[l][r]~ tính giá trị ~B[l][r + 1]~. Đồng thời, để cập nhật mảng ~D_1~ và ~D_2~, ta cần duyệt qua mọi truy vấn cập nhật tạo bởi vị trí ~r + 1~.
    • Để xử lý cho truy vấn ~(l, r - 1)~, ta có thể tính ~b[l][r]~ để cùng với giá trị ~B[l][r]~ tính giá trị ~B[l][r - 1]~. Đồng thời, để cập nhật mảng ~D_1~ và ~D_2~, ta cần duyệt qua mọi truy vấn cập nhật tạo bởi vị trí ~r~.
  • Ta nhận thấy rằng trong cả bốn trường hợp nêu trên, việc tính giá trị ~B~ mới có thể được xử lý ~O(1)~. Vì vậy, việc mở rộng hoặc thu hẹp biên biên trái (bởi một đơn vị) có thể được xử lý trong ~O(1)~. Tuy nhiên, việc mở rộng hoặc thu hẹp biên phải (bởi một đơn vị) có thể yêu cầu độ phức tạp ~O\left(\sqrt{A}\right)~ bởi ta sẽ cập nhật ~D_1~ và ~D_2~ theo cách trên.
  • Nếu ta giả định việc duyệt qua các thao tác cập nhật là các thao tác tối thiểu, ta có thể xem xét áp dụng thuật toán Mo bởi:
    • Độ phức tạp thời gian sẽ là ~O\left(n \sqrt{A} + (n + q) \sqrt{n} Z \right)~ với ~O(Z)~ là độ phức tạp của việc mở rộng hoặc thu hẹp một trong hai biên. Vì vậy, nếu ta xử lý việc thay đổi biên đủ hiệu quả, thời gian chạy của chương trình vẫn có thể trong giới hạn cho phép.
    • Ta có thể trả lời các truy vấn một cách offline.
    • Việc gộp hai cấu trúc dữ liệu (giá trị ~B~ của biên đang xét, mảng ~D_1~ và ~D_2~ được định nghĩa như trên) có thể phức tạp, không đáp giới hạn được đề ra.
  • Một cách sắp xếp xử lý truy vấn để có thể áp dụng thuật toán Mo là ta sắp xếp theo chỉ số block của biên ~l~ giảm dần và biên ~r~ giảm dần nếu chỉ số block của biên ~l~ là giống nhau. Cách sắp xếp này có thể giúp ý tưởng có độ phức tạp trong giới hạn cho phép bởi giữa hai truy vấn có biên phải trong cùng một block:
    • Ta sẽ đi qua tối đa ~O\left(\sqrt{n}\right)~ vị trí khi thay đổi biên trái. Mặt khác, việc cập nhật giá trị ~B~ khi chỉ thay đổi biên phải là ~O(1)~ như đã đề cập trước đó.
    • Khi thay đổi (giảm) biên phải, ta sẽ duyệt qua các truy vấn cập nhật có ít nhất một vị trí nằm trong block đang xét. Độ phức tạp cho một lần giảm biên phải như vậy sẽ là ~O\left(\sqrt A\right)~. Ta có thể đưa ra nhận xét là với một vị trí ~r~, tổng số truy vấn cập nhật ta phải duyệt qua khi giảm ở biên phải đang ở vị trí ~r~ là ~O\left(\sqrt{n \cdot A}\right)~. Tuy nhiên, ta có thể có một nhận xét chặt hơn: số lượng thật sự sẽ là ~O\left(\sqrt{A} + \sqrt{n}\right)~ bởi:
      • Các truy vấn cập nhật cho giá trị ~a[r]~ không có chung vị trí giữa chúng;
      • Ở mỗi block, ta chỉ duyệt qua các truy vấn cập nhật có ít nhất một vị trí trong block như đã đề cập ở trên.
Độ Phức Tạp
  • Ý tưởng nêu trên có thể được cài đặt với độ phức tạp thời gian ~O\left(n\sqrt{A} + (n + q)\sqrt{n}\right)~ bởi:
    • Việc sắp xếp ~q~ truy vấn có thể được thực hiện trong ~O(n + q)~ nếu ta dùng thuật toán sắp xếp đếm phân phối (vì đây là thuật toán sắp xếp ổn định, ta có thể sắp xếp các truy vấn theo biên phải ~r~ trước rồi sắp xếp theo vị trí block của biên trái ~l~).
    • Độ phức tạp của bước xử lý chính của thuật toán Mo sẽ là ~O\left(n \sqrt{A} + (n + q)\sqrt{n}\right)~ dựa trên phân tích đề cập trong phần trước:
      • Ta sẽ thay đổi biên trái ~l~ tối đa ~O\left(q \sqrt {n} \right)~ lần khi duyệt hết ~q~ truy vấn và độ phức tạp cho mỗi lần thay đổi là ~O(1)~.
      • Tổng độ phức tạp cho việc thay đổi biên phải ~r~ sau khi duyệt ~q~ truy vấn là ~O\left(n(\sqrt{A} + \sqrt{n})\right)~ vì ta có ~O(n)~ vị trí cho biên phải và tổng phức tạp cho bước xử lý một vị trí biên phải là ~O\left(\sqrt{A} + \sqrt{n}\right)~ như đã đề cập ở trên.
  • Về độ phức tạp bộ nhớ, ý tưởng có thể được cài đặt với độ phức tạp ~O(n + q)~ bởi ta có thể chỉ cần lưu thêm các chỉ số của truy vấn với ~O(q)~ phần tử so với subtask 3 để có thể sắp xếp và xử lý theo một thứ tự nhất định.
Code Minh Họa
  • Khi cài đặt ý tưởng, chúng ta nên cố gắng tránh sử dụng phép chia nhiều nhất có thể bởi phép chia có thể làm chậm chương trình (nếu số chia không phải là một hằng số có thể xác định khi biên dịch chương trình, phép chia có thời gian thực thi chậm hơn đáng kể so với phép cộng, trừ, hoặc nhân).

Code C++ minh họa

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

constexpr int MAX_N = 200'005, MAX_Q = 200'005;

int n, q, a[MAX_N], l[MAX_Q], r[MAX_Q];

namespace FourthSubtask {

    constexpr int BLOCK_SIZE = 447, BLOCK_COUNT = 450;

    long long results[MAX_Q], prefixSums[MAX_N], d[MAX_N][2];
    int queryIndices[MAX_Q], ranges[MAX_N][3];

    void sortQueries() {
        int i = 0, lefts[n + 1]{}, indices[q + 1]{};
        /**
            Sort by ascending L
        **/
        for (i = 1; i <= q; ++i)
            ++lefts[l[i] + 1];
        for (i = 1; i <= n; ++i)
            lefts[i] += lefts[i - 1];
        for (i = 1; i <= q; ++i)
            queryIndices[++lefts[l[i]]] = i;
        /**
            Sort by ascending R
        **/
        for (i = 1; i <= n; ++i)
            lefts[i] = 0;
        for (i = 1; i <= q; ++i)
            ++lefts[r[i] + 1];
        for (i = 1; i <= n; ++i)
            lefts[i] += lefts[i - 1];
        for (i = 1; i <= q; ++i)
            indices[++lefts[r[queryIndices[i]]]] = queryIndices[i];
        /**
            Sort by descending block ID of L
        **/
        for (i = 1; i <= n; ++i)
            lefts[i] = 0;
        for (i = 1; i <= q; ++i)
            ++lefts[n - (l[i] - 1) / BLOCK_SIZE + 1];
        for (i = 1; i <= n; ++i)
            lefts[i] += lefts[i - 1];
        for (i = 1; i <= q; ++i)
            queryIndices[++lefts[n - (l[indices[i]] - 1) / BLOCK_SIZE]] = indices[i];
    }

    void solve() {
        long long sum = 0, change = 0;
        int i = 1;

        sortQueries();

        for (; i <= n; ++i) {
            prefixSums[i] = prefixSums[i - 1] + a[i];
            ranges[i][0] = ranges[i][2] = 1;
            ranges[i][1] = a[i];
        }

        i = 1;

        for (int blockLeft = (n - 1) / BLOCK_SIZE * BLOCK_SIZE + 1, blockRight = n, start = 0, last = 0, left = 0, delta = 0, right = 0, t = 0, value = 0, rangeLeft = 0, rangeRight = 0, queryLeft = 0, queryRight = 0; blockLeft >= 1 && i <= q; blockRight = blockLeft - 1, blockLeft -= BLOCK_SIZE) {
            sum = change = 0;
            start = blockLeft;
            last = start - 1;
            for (; i <= q; ++i) {
                t = queryIndices[i];
                queryLeft = l[t];
                if (queryLeft < blockLeft)
                    break;
                queryRight = r[t];
                while (queryLeft < start) {
                    --start;
                    change += d[start][1];
                    sum += d[start][0] + change;
                }
                for (; start < queryLeft; ++start) {
                    sum -= d[start][0] + change;
                    change -= d[start][1];
                }
                for (queryRight = r[t]; last < queryRight;) {
                    ++last;
                    left = ranges[last][0];
                    value = a[last];
                    if (left > value)
                        continue;
                    delta = ranges[last][1];
                    right = ranges[last][2];
                    while (true) {
                        rangeRight = last - left + 1;
                        if (rangeRight < blockLeft)
                            break;
                        rangeLeft = max(last - right, 0);
                        if (rangeLeft < blockRight) {
                            /**
                                (rangeLeft, rangeRight]
                            **/
                            if (rangeLeft < start && start <= rangeRight) {
                                sum += delta * (last - start + 1);
                                change += delta;
                            }
                            if (rangeRight <= blockRight) {
                                d[rangeRight][0] += delta * (last - rangeRight);
                                d[rangeRight][1] += delta;
                            }
                            if (rangeLeft < blockLeft)
                                break;
                            d[rangeLeft][0] -= delta * (last - rangeLeft);
                            d[rangeLeft][1] -= delta;
                        }
                        left = right + 1;
                        if (left > value)
                            break;
                        delta = value / left;
                        right = value / delta;
                    }
                    ranges[last][0] = left;
                    ranges[last][1] = delta;
                    ranges[last][2] = right;
                }
                results[t] = prefixSums[r[t]] - prefixSums[l[t] - 1] - sum;
            }
        }

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

void input() {
    cin >> n >> q;
    for (int i = 1; i <= n; ++i)
        cin >> a[i];
    for (int i = 1; i <= q; ++i)
        cin >> l[i] >> r[i];
}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL
    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    FourthSubtask::solve();

    return 0;
}

Lời giải 2

Subtask 1: ~n, q \le 7000~

Ta chỉ cần làm đúng những gì đề bảo. Với mỗi truy vấn, ta duyệt từ ~l~ đến ~r~, và cộng giá trị ~a_i \bmod (i-l+1)~ vào biến kết quả. Độ phức tạp ~\mathcal{O}(q \cdot n)~.

#include <bits/stdc++.h>

using namespace std;

int a[200005];
int n, q;

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  cin >> n >> q;
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
  }
  while (q--) {
    int l, r;
    cin >> l >> r;
    int64_t ans = 0;
    for (int i = l; i <= r; i++) {
      ans += a[i] % (i - l + 1);
    }
    cout << ans << '\n';
  }
  return 0;
}
Subtask 2: ~a_i \le 200~

Nhận xét: ~x \bmod y = x~ nếu ~x < y~. Đặt ~k = i - l + 1~ là số chia. Với mỗi truy vấn ~[l, r]~, ta chia làm 2 phần:

  • Với ~k \le 200~ (tương ứng với 200 phần tử đầu tiên của đoạn, tức ~i \in [l, \min(r, l+199)]~), ta tính trực tiếp tổng ~a_i \bmod k~ bằng cách duyệt như subtask 1.
  • Với ~k > 200~, do ~a_i \le 200~ nên ~a_i < k~, suy ra ~a_i \bmod k = a_i~. Phần này ta có thể tính nhanh bằng prefix sum.

Độ phức tạp: ~\mathcal{O}(q \cdot 200)~. Trong cài đặt mẫu dưới đây, tác giả sử dụng Sparse Table để tìm max đoạn ~[l,r]~ nằm tối ưu thời gian chạy với những test có ~\max a~ nhỏ hơn. Còn trên thực tế, ta duyệt đến ~l+200~ vẫn đủ để qua được subtask này.

#include <bits/stdc++.h>

using namespace std;

template <typename T, typename F>
class SparseTable {
 public:
  int n;
  vector<vector<T>> mat;
  F func;

  SparseTable(const vector<T>& a, const F& f) : func(f) {
    n = static_cast<int>(a.size());
    int max_log = 32 - __builtin_clz(n);
    mat.resize(max_log);
    mat[0] = a;
    for (int j = 1; j < max_log; j++) {
      mat[j].resize(n - (1 << j) + 1);
      for (int i = 0; i <= n - (1 << j); i++) {
        mat[j][i] = func(mat[j - 1][i], mat[j - 1][i + (1 << (j - 1))]);
      }
    }
  }

  T get(int from, int to) const {
    assert(0 <= from && from <= to && to <= n - 1);
    int lg = 32 - __builtin_clz(to - from + 1) - 1;
    return func(mat[lg][from], mat[lg][to - (1 << lg) + 1]);
  }
};

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  int n, q;
  cin >> n >> q;
  vector<int> a(n);
  vector<int> pref(n);
  for (auto& it : a) {
    cin >> it;
  }
  pref[0] = a[0];
  for (int i = 1; i < n; i++) {
    pref[i] = pref[i - 1] + a[i];
  }
  auto f = [&](int x, int y) { return max(x, y); };
  SparseTable<int, decltype(f)> st(a, f);
  while (q--) {
    int l, r;
    cin >> l >> r;
    --l, --r;
    int ma = st.get(l, r);
    int64_t ans = 0;
    for (int i = l; i <= min(r, l + ma - 1); i++) {
      ans += a[i] % (i - l + 1);
    }
    if (l + ma <= r) {
      ans += pref[r] - pref[l + ma - 1];
    }
    cout << ans << '\n';
  }
  return 0;
}
Subtask 3: ~r = n~

Trong lời giải sau, đặt ~A = \max(a_i)~. Ta có:

~a_i \bmod k = a_i - k \times \left\lfloor \frac{a_i}{k} \right\rfloor~

Do đó:

~\sum_{i=l}^{n} a_i \bmod (i-l+1) = \sum_{i=l}^{n} a_i - \sum_{i=l}^{n} (i-l+1) \left\lfloor \frac{a_i}{i-l+1} \right\rfloor~

Phần tổng ~a_i~ cố định có thể tính bằng Prefix Sum. Lời giải sau đây chỉ quan tâm đến việc tính tổng ở vế sau (~\sum_{i=l}^{n} (i-l+1) \left\lfloor \frac{a_i}{i-l+1} \right\rfloor~).

Nhận xét:

  • Chỉ có tối đa ~2\sqrt{n}~ giá trị ~\lfloor \frac{n}{c} \rfloor~ phân biệt.
    • Nếu ~c \le \sqrt{n}~, rõ ràng số lượng giá trị phân biệt tương ứng với số lượng số ~c~, là ~\sqrt{n}~
    • Nếu ~c > \sqrt{n}~, thì ~\lfloor \frac{n}{c} \rfloor~ chỉ nhận các giá trị nguyên từ ~1~ đến ~\sqrt{n}~.
    • Tổng lại ta có số lượng giá trị phân biệt tối đa là ~2\sqrt{n}~.
  • Các giá trị ~c~ có cùng kết quả khi tính ~\lfloor \frac{n}{c} \rfloor~ nằm trên một đoạn liên tiếp của trục số.
    • Điều này là dễ thấy dựa vào tính chất đơn điệu của ~\lfloor \frac{n}{c} \rfloor~.

Từ các nhận xét trên, ta có thể rút ra một cách làm offline cho subtask này. Thay vì duyệt đoạn ~[l,n]~ để trả lời truy vấn, ta duyệt từng phần tử ~a_i~, và xem nó đóng góp bao nhiêu vào kết quả. Nhận thấy ~a_i~ sẽ đóng góp vào các truy vấn ~[l,n]~ với ~l \le i~.

Xét một phần tử ~a_i~, ta duyệt các giá trị ~k = i-l+1~.

  • Ta duyệt các đoạn ~[u, v]~ của số chia ~k~ sao cho ~\lfloor \frac{a_i}{k} \rfloor~ có cùng giá trị là ~val~.
    • Từ nhận xét trên, ta thấy phần này chỉ duyệt qua ~2 \sqrt{a_i}~ giá trị khác nhau.
    • Ta có thể duyệt ~u~ tăng dần từ ~1~. Với mỗi ~u~, ta chặt nhị phân tìm ~v~ xa nhất sao cho ~\lfloor \frac{a_i}{u} \rfloor = \lfloor \frac{a_i}{v} \rfloor~. Sau khi tính toán xong, gán ~u \leftarrow v+1~.
    • Tuy nhiên độ phức tạp cho việc trên có thể lên đến ~\mathcal{O}(\sqrt{a_i} \log {a_i})~.
    • Ta có thể tìm ~v~ trong ~\mathcal{O}(1)~ bằng công thức ~v=\frac{a_i}{val}~. Do nếu ta có ~x~ thoả mãn ~\lfloor \frac{a_i}{x} \rfloor = val~, thì ~x \le \frac{a_i}{val}~, từ đó ta chọn được số lớn nhất mà vẫn thoả mãn điều kiện.
  • Với ~k \in [u, v]~, ta có ~l = i - k + 1~. Như vậy ~a_i~ sẽ đóng góp vào các truy vấn có ~l \in [i-v+1, i-u+1]~ một lượng: ~(i-l+1) \times val = (i+1) \times val - l \times val~
  • Như vậy ta cần xử lý các truy vấn: Trừ ~(i+1) \times val~ và cộng ~l \times val~ vào đáp án của các ~l~ trong đoạn. Đây là một bài toán cổ điển, có thể giải bằng Prefix Sum:
    • Duy trì 2 mảng ~D_1~ dùng để cập nhật lượng hằng số ~(i+1) \times val~, và ~D_2~ dùng để cập nhật hệ số của ~L~ tăng lên một lượng là ~val~.
    • Với mỗi yêu cầu tăng đoạn ~[L,R]~, ta tăng ~D_1[L]~ và giảm ~D_1[R+1]~ một lượng ~(i+1) \times val~, đồng thời tăng ~D_2[L]~ và giảm ~D_2[R+1]~ một lượng ~val~.
    • Cuối cùng, cộng dồn hai mảng ~D_1~ và ~D_2~, khi đó đáp án cho truy vấn ~[L,n]~ sẽ là ~D_1[L] - D_2[L] \times L~.

Độ phức tạp: ~\mathcal{O}(n\sqrt{A})~. Cài đặt mẫu:

#include <bits/stdc++.h>

using namespace std;

const int N = 2e5 + 5;

int a[N];
int n, q;
int64_t add_i[N];
int64_t suff[N];
int64_t add_c[N];
int64_t ans[N];

// a[i] += i * val_i + val_c
void add(int l, int r, int val_i, int64_t val_c) {
  add_i[l] += val_i;
  add_i[r + 1] -= val_i;
  add_c[l] += val_c;
  add_c[r + 1] -= val_c;
}

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  cin >> n >> q;
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
  }
  for (int i = n; i >= 1; i--) {
    suff[i] = suff[i + 1] + a[i];
  }
  for (int i = 1; i <= n; i++) {
    for (int l = 1; l <= a[i];) {
      int k = a[i] / l;
      int r = a[i] / k;
      int low = i - r + 1, high = i - l + 1;
      low = max(low, 1);
      high = min(high, n);
      if (low <= high) {
        add(low, high, k, 1ll * i * k);
      }
      l = r + 1;
    }
  }
  for (int i = 1; i <= n; i++) {
    add_i[i] += add_i[i - 1];
    add_c[i] += add_c[i - 1];
    ans[i] = add_c[i] - 1ll * (i - 1) * add_i[i];
  }
  while (q--) {
    int l, r;
    cin >> l >> r;
    cout << suff[l] - ans[l] << '\n';
  }
  return 0;
}
Subtask 4: Không có giới hạn gì thêm

Ý tưởng của subtask này vẫn dựa trên việc tính đóng góp của từng phần tử ~a_i~ giống subtask 3, nhưng ~r~ thay đổi qua các truy vấn, nên ta sử dụng kỹ thuật sweepline: duyệt ~r~ từ ~1~ đến ~n~. Khi xét đến ~a_r~, ta cập nhật đóng góp của nó vào các vị trí ~l~, sau đó trả lời các truy vấn kết thúc tại ~r~.

Vì phải trả lời truy vấn online trong khi đang sweepline, ta không thể dùng mảng cộng dồn tĩnh. Thay vào đó, ta cần một cấu trúc dữ liệu hỗ trợ cập nhật điểm và truy vấn tổng tiền tố. Một trong những cấu trúc hỗ trợ là Fenwick Tree.

Cài đặt sử dụng Fenwick Tree, độ phức tạp ~\mathcal{O}(n \sqrt{A} \log n)~:

#include <bits/stdc++.h>

using namespace std;

template <class T>
struct FT {
  vector<T> ft;
  FT(int _n = 0) { ft.assign(_n + 5, 0); }
  void upd(int id, T val) {
    for (; id < (int)ft.size(); id += id & -id) ft[id] += val;
  }
  T get(int id) {
    T ans = 0;
    for (; id > 0; id -= id & -id) ans += ft[id];
    return ans;
  }
};

struct S {
  int64_t add_c, add_i;
  S(int64_t c = 0, int64_t i = 0) : add_c(c), add_i(i) {}
  S& operator+=(const S& rhs) {
    add_c += rhs.add_c;
    add_i += rhs.add_i;
    return *this;
  }
};

const int N = 2e5 + 5;

int a[N];
int n, q;
int64_t pref[N];
int64_t ans[N];
vector<pair<int, int>> que[N];

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  cin >> n >> q;
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
  }
  for (int i = 1; i <= n; i++) {
    pref[i] = pref[i - 1] + a[i];
  }
  for (int i = 1; i <= q; i++) {
    int l, r;
    cin >> l >> r;
    que[r].emplace_back(l, i);
  }
  FT<S> ft(n);
  for (int i = 1; i <= n; i++) {
    for (int l = 1; l <= a[i];) {
      int k = a[i] / l;
      int r = a[i] / k;
      int low = i - r + 1, high = i - l + 1;
      low = max(low, 1);
      high = min(high, n);
      if (low <= high) {
        ft.upd(low, S(1ll * i * k, k));
        ft.upd(high + 1, S(-1ll * i * k, -k));
      } else {
        break;
      }
      l = r + 1;
    }
    for (auto [l, idx] : que[i]) {
      auto [add_c, add_i] = ft.get(l);
      ans[idx] = pref[i] - pref[l - 1] - (add_c - 1ll * (l - 1) * add_i);
    }
  }
  for (int i = 1; i <= q; i++) {
    cout << ans[i] << "\n";
  }
  return 0;
}

Tuy nhiên, với giới hạn ~2 \cdot 10^5~ thì thuật toán trên không thể chạy được trong thời gian cho phép. Nhận thấy bài toán có đặc thù là số lượng truy vấn cập nhật rất nhiều (~n\sqrt{A}~), nhưng số truy vấn hỏi lại ít hơn (~q~), nên ta sẽ đổi sang sử dụng một cấu trúc dữ liệu khác, hỗ trợ cập nhật nhanh nhưng truy vấn chậm hơn một chút, đó là sử dụng chia căn:

  • Chọn một hằng số ~B~, và chia mảng thành ~\frac{n}{B}~ đoạn.
  • Lưu 2 mảng: ~\text{blk}_i~ thể hiện tổng của các phần tử trong khối thứ ~i~, và ~f_i~ thể hiện giá trị của phần tử thứ ~i~. Lưu ý ta cần duy trì 2 cấu trúc riêng biệt cho ~D_1~ và ~D_2~.
  • Với mỗi truy vấn tăng một điểm ~i~, ta tăng ~\text{blk}_{\frac{i}{B}}~ và ~f_i~. Độ phức tạp ~\mathcal{O}(1)~.
  • Với mỗi truy vấn tìm tổng của ~i~ phần tử đầu tiên, ta duyệt trâu từng block theo chỉ số ~b~ tăng dần:
    • Nếu block hiện tại nằm bên trái ~i~, ta cộng ~\text{blk}_b~ vào kết quả.
    • Nếu block hiện tại chứa ~i~, ta duyệt trâu từ đầu block (~b \times B~) đến ~i~, và cộng kết quả với ~f_i~. Sau đó dừng duyệt block.
    • Vậy việc truy vấn mất ~\mathcal{O}(B + \frac{n}{B})~.
  • Để tối ưu ta chọn ~B \approx \sqrt{n}~. Độ phức tạp cho toàn bài toán chỉ còn ~\mathcal{O}(n \sqrt{A} + q \sqrt{n})~.

Cài đặt sử dụng chia căn:

#include <bits/stdc++.h>

using namespace std;

const int N = 2e5 + 5;
const int B = 700;
const int NB = N / B + 5;

struct S {
  int64_t add_c, add_i;
  S(int64_t c = 0, int64_t i = 0) : add_c(c), add_i(i) {}
  S& operator+=(const S& rhs) {
    add_c += rhs.add_c;
    add_i += rhs.add_i;
    return *this;
  }
};

struct Sqrt {
  S buc[NB];
  S a[N];
  Sqrt() {
    for (int i = 0; i < N; i++) a[i] = S();
    for (int i = 0; i < NB; i++) buc[i] = S();
  }
  void add(int pos, S val) {
    int blk = pos / B;
    a[pos] += val;
    buc[blk] += val;
  }
  S get(int pos) {
    S tot = S();
    int blk = pos / B;
    for (int i = 0; i < blk; i++) {
      tot += buc[i];
    }
    for (int i = blk * B; i <= pos; i++) {
      tot += a[i];
    }
    return tot;
  }
} ds;

int a[N];
int n, q;
int64_t pref[N];
int64_t ans[N];
vector<pair<int, int>> que[N];

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
#ifdef LOCAL
#define task "a"
#else
#define task ""
#endif
  if (fopen(task ".inp", "r")) {
    freopen(task ".inp", "r", stdin);
    freopen(task ".out", "w", stdout);
  }
  cin >> n >> q;
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
  }
  for (int i = 1; i <= n; i++) {
    pref[i] = pref[i - 1] + a[i];
  }
  for (int i = 1; i <= q; i++) {
    int l, r;
    cin >> l >> r;
    que[r].emplace_back(l, i);
  }
  for (int i = 1; i <= n; i++) {
    for (int l = 1; l <= a[i];) {
      int k = a[i] / l;
      int r = a[i] / k;
      int low = i - r + 1, high = i - l + 1;
      low = max(low, 1);
      high = min(high, n);
      if (low <= high) {
        ds.add(low, S(1ll * i * k, k));
        ds.add(high + 1, S(-1ll * i * k, -k));
      } else {
        break;
      }
      l = r + 1;
    }
    for (auto [l, idx] : que[i]) {
      auto [add_c, add_i] = ds.get(l);
      ans[idx] = pref[i] - pref[l - 1] - (add_c - 1ll * (l - 1) * add_i);
    }
  }
  for (int i = 1; i <= q; i++) {
    cout << ans[i] << "\n";
  }
  return 0;
}
unknownnaciul
o10, Tháng 2, 2026, 1:00 0

19

Tạp chí VNOI - Số 1: Kỹ thuật thú vị

unknownnaciul đã đăng vào 11, Tháng 11, 2025, 13:00

Quy hoạch động cái túi trên cây

Người viết:

  • Nguyễn Tấn Minh - Trường Đại học Khoa học Tự nhiên, ĐHQG-HCM.

Reviewer:

  • Nguyễn Quang Minh - Michigan State University.
  • Võ Khắc Triệu - National University of Singapore.

Mở đầu

Đối với nhiều người theo đuổi Lập trình thi đấu, hai lớp bài toán quy hoạch động cái túi và quy hoạch động trên cây chắc hẳn là hai lớp bài toán vô cùng quen thuộc.

Bên cạnh đó, ta còn có dạng bài toán kết hợp ý tưởng quy hoạch động cái túi lên cấu trúc cây, cho ra đời lớp bài toán quy hoạch động cái túi trên cây. Đây tưởng chừng như một dạng bài tập rất hóc búa và đòi hỏi các thuật toán phức tạp. Tuy nhiên, chỉ với một vài lập luận tương đối đơn giản thì ta đã thấy được rằng, chỉ cần duyệt cây một cách khéo léo, ta đã có thể giải quyết bài toán quy hoạch động cái túi trên cây đơn giản hơn nhiều.

Trong mục Kỹ thuật thú vị của Tạp chí VNOI lần này, chúng ta sẽ cùng điểm qua các phân tích liên quan đến lớp bài toán quy hoạch động cái túi này.

Trường hợp chính

Bài toán: CEOI 2017 - Museum

Cho cây gồm ~n~ đỉnh (~1 \leq n \leq 10^4~), ~n - 1~ cạnh có trọng số cho biết thời gian di chuyển qua cạnh đó, và hai số nguyên ~k, x~. Tìm lộ trình tốn ít thời gian nhất để di chuyển từ ~x~ qua ~k~ đỉnh khác nhau kể cả ~x~ (kết thúc tại đỉnh bất kỳ).

Phân tích bài toán

Để đơn giản hóa bài toán, ta tạm thời biến đổi ràng buộc bài toán thành lộ trình xuất phát từ ~x~ phải quay về đỉnh ~x~.

Đặt gốc cây tại ~x~. Ta thấy rằng với mỗi cây con ~v_{x, i}~ của ~x~, ta cần thăm ~s_{x, i}~ đỉnh sao cho ~s_{x, 1} + s_{x, 2} + \dots + s_{x, t} = k - 1~. Hơn nữa, tập các đỉnh được thăm tạo thành một thành phần liên thông.

Từ đó, ta có ý tưởng đặt trạng thái quy hoạch động ~\texttt{dp}(u, j)~ là tổng thời gian để thăm ~j~ đỉnh của cây con gốc ~u~ và quay lại đỉnh ~u~. Dễ thấy ~j \leq \texttt{sz}[u]~ với ~\texttt{sz}[u]~ là kích thước của cây con gốc ~u~. Gọi ~v_1, v_2, \dots, v_t~ và ~w_1, w_2, \dots, w_t~ lần lượt là các đỉnh con của ~u~ và trọng số các cạnh nối tới chúng. Khi đó, ta có công thức truy hồi:

$$ \texttt{dp}(u, j) = \min_{s_1 + s_2 + \dots + s_t = j - 1} \left( \sum_{1 \leq i \leq t} \texttt{dp}(v_i, s_i) + [s_i \geq 1] \cdot 2w_i \right) $$

Nếu xem một cây con ~v_i~ là "một loại món đồ" và cây con ~u~ là "cái túi", ta có thể biến đổi chúng thành một bài toán quy hoạch động cái túi: Với mỗi loại món đồ, ta có thể chọn ~s_i~ cái với chi phí ~\texttt{dp}(v_i, s_i) + [s_i \geq 1] \cdot 2w_i~, cần chọn các món đồ sao cho tổng số lượng được chọn là ~j - 1~. Ở đây ~[X]~ là ký hiệu của Inverson bracket, mang giá trị ~1~ khi mệnh đề ~X~ đúng và ~0~ nếu ngược lại.

Để xử lý bài toán con trên, ta gọi ~\texttt{knap}(i, j)~ là tổng chi phí tối thiểu nếu chọn ~j~ món đồ ở ~i~ loại món đồ đầu tiên. Ta có công thức truy hồi

$$ \texttt{knap}(i, j) = \min_{0 \leq c \leq \texttt{sz}[v_i]} (\texttt{knap}(i - 1, j - c) + \texttt{dp}(v_i, c) + [c \geq 1] \cdot 2w_i) $$

Để tính mọi trạng thái ~(i, j)~, độ phức tạp của hàm quy hoạch động cái túi này là ~\mathcal{O}(\texttt{sz}[u]^2)~, dẫn đến độ phức tạp chung của bài toán lên đến ~\mathcal{O}(n^3)~. Tuy nhiên, nếu chỉ duyệt những trạng thái "có nghĩa" một cách khéo léo, độ phức tạp của thuật toán đã có thể giảm xuống ~\mathcal{O}(n^2)~!

Cải tiến & Phân tích độ phức tạp

Để ý rằng khi duyệt đến ~i~ cây con đầu tiên, ~v_1, v_2, v_3, \dots, v_i~, chỉ có các trạng thái ~\texttt{knap}(i, j)~ thỏa ~j \leq \texttt{sz}[v_1] + \texttt{sz}[v_2] + \dots + \texttt{sz}[v_i]~ mới có giá trị xác định, còn lại là các giá trị vô cực, không đóng góp vào công thức truy hồi. Do đó, để tiết kiệm tối đa số lần duyệt, ta có thể cài đặt quy hoạch động trên cây như sau:

int knap[2020], dp[2020][2020];

void dfs (int u, int p) {
    // khởi tạo mảng knap[...]
    // ...

    int knapSize = 0;
    for (int v : adj[u]) {
        if (v == p) continue;
        dfs(v, u);
        for (int preTake = knapSize; preTake >= 0; preTake--)
            for (int curTake = 0; curTake <= sz[v]; curTake++)
                // tính knap[preTake + curTake] dựa trên
                // knap[preTake] và dp[v][take]
        knapSize += sz[v];
    }
    sz[u] = 1 + knapSize;

    // cập nhật dp[u][...] dựa trên knap[...]
    // ...
}

Để phân tích độ phức tạp, ta có thể mô hình hóa quá trình duyệt các cặp ~(\texttt{preTake}, \texttt{curTake})~ thành duyệt các cặp đỉnh ~(s_1, s_2)~ với ~s_1~ thuộc một trong các cây con trước đó ~v_1, v_2, \dots, v_{i-1}~ hoặc ~s_1 = u~, và ~s_2~ thuộc cây con hiện tại ~v_i~. Đặc biệt, các cặp ~(s_1, v_i)~ sẽ được tính 2 lần.

Cả hai mô hình duyệt đều có đúng ~(\texttt{knapSize} + 1) \cdot (\texttt{sz}[v] + 1)~ bước duyệt.

colored tree

Ví dụ, ở hình trên, tại bước duyệt cây con ~v_i~, ta có thể hình dung việc duyệt các cặp ~(\texttt{preTake}, \texttt{curTake})~ giống như việc duyệt các cặp ~(s_1, s_2)~ sao cho ~s_1~ thuộc nhóm màu xanh/tím và ~s_2~ thuộc nhóm màu đỏ.

Một nhận xét quan trọng là hai đỉnh ~s_1, s_2~ chỉ sẽ được bắt cặp tại lượt DFS của đỉnh ~u~ thỏa ~\texttt{lca}(s_1, s_2) = u~. Do đó, với hai đỉnh ~z_1 < z_2~, dễ thấy tổng số lần duyệt của cặp ~(z_1, z_2)~ và ~(z_2, z_1)~ chỉ là tối đa ~2~ lần.

Tổng kết lại, số lần duyệt tối đa của cách cài đặt quy hoạch động như trên là:

$$ 2 \cdot C_n^2 = n \cdot (n - 1) = \mathcal{O}(n^2) $$

Có thể thấy, số lần duyệt tối đa thậm chí còn chưa đến ~n^2~!

Thuật toán

Quay lại bài toán ban đầu, ta gọi ~\texttt{dp}_0(u, j)~ và ~\texttt{dp}_1(u, j)~ lần lượt là tổng thời gian để thăm ~j~ đỉnh thuộc cây con ~u~ rồi quay về ~u~ (trạng thái 0) hoặc kết thúc ở một đỉnh bất kỳ là con của ~u~ (trạng thái 1). Khi đó, ta điều chỉnh công thức truy hồi thành:

$$ \begin{align*} \texttt{dp}_0(u, j) &= \min_{s_1 + s_2 + \dots + s_t = j - 1} \left( \sum_{1 \leq i \leq t} \texttt{dp}_0(v_i, s_i) + [s_i \geq 1] \cdot 2 w_i \right) \\ \texttt{dp}_1(u, j) &= \min_{\substack{s_1 + s_2 + \dots + s_t = j - 1 \\ b_1 + b_2 + \dots + b_t = 1}} \left( \sum_{1 \leq i \leq t} \texttt{dp}_{b_i}(v_i, s_i) + [s_i \geq 1] \cdot (2 - b_i) \cdot w_i \right) \end{align*} $$

Nói cách khác, với ~\texttt{dp}_0(u, j)~, mọi cây con sẽ được thăm rồi quay về ~u~. Với ~\texttt{dp}_1(u, j)~, ta chọn một cây con để kết thúc hành trình, các cây con còn lại sẽ được thăm rồi quay về ~u~. Việc thực hiện quy hoạch động cái túi sẽ diễn ra tương tự phần phân tích như trên. Như vậy, ta có lời giải quy hoạch động trong ~\mathcal{O}(n^2)~.

Code tham khảo

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

using pii = pair<int,int>;

const int mn = 1e4 + 1;
int dp[2][mn][mn], knap[2][mn], sz[mn], n, k, x;
vector<pii> adj[mn];

void dfs (int u, int p) {
    // gọi DFS và tính kích thước cây con (sz[u])
    sz[u] = 1;
    for (auto [v, w] : adj[u])
        if (v != p) dfs(v, u), sz[u] += sz[v];

    // khởi tảo mảng knap[...]
    knap[0][0] = knap[1][0] = 0;
    for (int i = 1; i <= sz[u]; i++)
        knap[0][i] = knap[1][i] = INT_MAX;

    // chạy DP Knapsack trên các cây con v[1], v[2], v[3],..., v[t]
    int knapSize = 0;
    for (auto [v, w] : adj[u]) {
        if (v == p) continue;
        for (int preTake = knapSize; preTake >= 0; preTake--) {
            for (int curTake = 1; curTake <= sz[v]; curTake++) {
                int sum = preTake + curTake;
                knap[0][sum] = min(knap[0][sum], knap[0][preTake] + dp[0][v][curTake] + 2 * w);
                knap[1][sum] = min(knap[1][sum], knap[0][preTake] + dp[1][v][curTake] + w);
                knap[1][sum] = min(knap[1][sum], knap[1][preTake] + dp[0][v][curTake] + 2 * w);
            }
        }
        knapSize += sz[v];
    }

    // trả các giá trị từ hàm knap[...] sang dp[...]
    for (int i = 0; i < sz[u]; i++)
        dp[0][u][i + 1] = knap[0][i], dp[1][u][i + 1] = knap[1][i];
}

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

    int n, k, x; cin >> n >> k >> x;
    for (int i = 1; i < n; i++) {
        int a, b, c; cin >> a >> b >> c;
        adj[a].emplace_back(b, c);
        adj[b].emplace_back(a, c);
    }
    dfs(x, -1);
    cout << dp[1][x][k] << "\n";

    return 0;
}

Trường hợp có cận trên cố định

Bài toán: VNOJ - Bài toán cái túi trên cây

Cho cây gồm ~n~ đỉnh (~1 \leq n \leq 10^5~) và một số nguyên ~k~ (~1 \leq k \leq 200~). Các đỉnh trên cây được gán một trọng số được cho bởi dãy ~a_1, a_2, a_3, \dots, a_n~ (~-10^9 \leq a_i \leq 10^9~).

Tìm một thành phần liên thông gồm đúng ~k~ đỉnh trên cây sao cho tổng trọng số của các đỉnh thuộc thành phần liên thông đó là lớn nhất.

Phân tích bài toán

Tương tự với bài toán ở trên, ta cũng định nghĩa hàm quy hoạch động ~\texttt{dp}(u, j)~ là tổng trọng số tối đa của thành phần liên thông gồm đỉnh ~u~ và các nút con của ~u~. Công thức truy hồi của quy hoạch động là:

$$ \texttt{dp}(u, j) = \max_{s_1 + s_2 + \dots + s_t = j - 1} \left( \sum_{1 \leq i \leq t} \texttt{dp}(v_i, s_i) \right) + a_u $$

Ta cũng coi các cây con ~v_1, v_2, \dots, v_t~ như những "món đồ" trong bài toán quy hoạch động cái túi và tính hàm ~\texttt{knap}(i, j)~ như sau:

$$ \texttt{knap}(i, j) = \max_{0 \leq c \leq \min(k, \texttt{sz}[v_i])} ( \texttt{knap}(i - 1, j - c) + \texttt{dp}(v_i, c)) $$

Thoạt nhìn như thuật toán có độ phức tạp ~\mathcal{O}(nk^2)~ hay ~\mathcal{O}(n^2)~. Nhưng cũng tương tự trường hợp như trên, nếu duyệt một cách khéo léo, ta có thể giảm độ phức tạp xuống ~\mathcal{O}(nk)~!

Cải tiến & Giảm độ phức tạp

Khi duyệt để tính hàm ~\texttt{knap}(i, j)~, ta giới hạn các giá trị ~\texttt{preTake}~ bởi ~\min(\texttt{knapSize}, k)~ và các giá trị ~\texttt{curTake}~ bởi ~\min(\texttt{sz}[v_i], k - \texttt{preTake})~. Nói cách khác, ta có thể cài đặt quy hoạch động như sau:

long long knap[202], dp[100'002][202];
int k;

void dfs (int u, int p) {
    // khởi tạo mảng knap[...]
    // ...

    int knapSize = 0;
    for (int v : adj[u]) {
        if (v == p) continue;
        dfs(v, u);
        for (int preTake = min(knapSize, k); preTake >= 0; preTake--)
            for (int curTake = 0; curTake <= min(sz[v], preTake); curTake++)
                // tính knap[preTake + curTake] dựa trên
                // knap[preTake] và dp[v][take]
        knapSize += sz[v];
    }
    sz[u] = 1 + knapSize;

    // cập nhật dp[u][...] dựa trên knap[...]
    // ...
}

Để phân tích độ phức tạp, ta lại sử dụng ý tưởng biến đổi mô hình for như trên thành duyệt các cặp đỉnh ~(s_1, s_2)~ có ~\texttt{lca}(s_1, s_2) = u~. Tuy nhiên, trong trường hợp số lượng đỉnh có thể chọn làm ~s_1~ hoặc ~s_2~ là nhiều hơn ~k + 1~, ta sẽ ưu tiên chọn ~s_1~ có thứ tự thăm DFS muộn nhất và ~s_2~ có thứ tự thăm DFS sớm nhất. Tương tự với phân tích ở phần trước, nếu đã chọn đủ ~\texttt{sz}[v_i]~ đỉnh làm ~s_2~, ta quay về chọn ~v_i~ làm đỉnh thứ ~\texttt{sz}[v_i] + 1~.

colored tree bounded

Ví dụ, với trường hợp trên, khi duyệt đến ~u = 1~ và ~v_i = 9~, với ~k = 2~, ta chỉ chọn các đỉnh ~6, 7, 8~ làm ~s_1~ và ~9, 10, 11~ làm ~s_2~. Để dễ phân tích, các đỉnh đã được đánh số theo thứ tự DFS.

Một nhận xét vô cùng quan trọng đó là khoảng cách của ~s_1, s_2~ trên dãy thứ tự DFS với cách chọn này sẽ là không quá ~2k + 1~. Trên thực tế, với điều kiện ~\texttt{preTake} + \texttt{curTake} \leq k~, ta còn có thể kết luận rằng khoảng cách thực sự là không quá ~k~.

Từ đó, ta cũng lập luận được rằng một cặp ~(s_1, s_2)~ xuất hiện không quá ~2~ lần, cộng với việc khoảng cách của ~s_1, s_2~ trên dãy thứ tự DFS là không quá ~k~, ta có thể kết luận rằng với mọi ~s_1~, ta chỉ có tối đa ~2k~ đỉnh có thể chọn làm ~s_2~.

sliding window on dfs array

Nói cách khác, số lần duyệt tối đa trong hàm quy hoạch động như trên là:

$$ 2nk = \mathcal{O}(nk) $$

Code tham khảo

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

using ll = long long;
using ld = long double;
using pl = pair<ll,ll>;
using pii = pair<int,int>;
using tpl = tuple<int,int,int>;

#define all(a) a.begin(), a.end()
#define filter(a) a.erase(unique(all(a)), a.end())

const int mn = 1e5 + 5;
ll knap[202], dp[mn][202];
int a[mn], sz[mn], n, k;
vector<int> adj[mn];

void dfs (int u, int p) {
    // tiền xử lý và khởi tạo mảng knap[...]
    sz[u] = 1;
    for (int v : adj[u])
        if (v != p) dfs(v, u), sz[u] += sz[v];

    for (int i = 1; i <= min(sz[u], k); i++) knap[i] = LLONG_MIN;
    knap[0] = 0;

    // thực hiện QHĐ cái túi trên các cây con của u
    int knapSize = 0;
    for (int v : adj[u]) {
        if (v == p) continue;
        for (int preTake = min(k, knapSize); preTake >= 0; preTake--)
            for (int curTake = 0; curTake <= min(k - preTake, sz[v]); curTake++)
                knap[preTake + curTake] = max(knap[preTake + curTake], knap[preTake] + dp[v][curTake]);
        knapSize += sz[v];
    }

    // cập nhật dp[u][...] dựa trên knap[...]
    for (int i = 0; i < min(sz[u], k); i++) dp[u][i + 1] = a[u] + knap[i];
}

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

    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i < n; i++) {
        int a, b; cin >> a >> b;
        adj[a].push_back(b);
        adj[b].push_back(a);
    }
    dfs(1, -1);

    ll ans = LLONG_MIN;
    for (int i = 1; i <= n; i++)
        if (sz[i] >= k) ans = max(ans, dp[i][k]);
    cout << ans << "\n";

    return 0;
}

Công thức biến thể

Bên cạnh đó, nếu công thức quy hoạch động cái túi có dạng:

$$ \texttt{dp}(u, j) = \min_{\substack{s_1 \neq s_2 \\ j_1 + j_2 = j}} (\texttt{dp}(s_1, j_1) + \texttt{dp}(s_2, j_2)) $$

Ta cũng có thể thực hiện quy hoạch động trong ~\mathcal{O}(n^2)~ (không có cận trên cố định) và ~\mathcal{O}(nk)~ (có cận trên ~k~ cố định). Phần chứng minh có thể được tìm thấy tại đây và tại đây.

Bài tập áp dụng

  • CEOI 2017, Museum
  • COCI 2019, Dzumbus
  • Codeforces Round 419, C, Karen and Supermarket
  • Codeforces Testting Round 10, D, Berland Federalization
  • AtCoder Beginner Contest 207, F, Tree Patrolling
  • Codefroces Round 607, D, Miss Punyverse

Đóng góp từ cộng đồng

📌 Các bạn cũng có thể đóng góp bằng cách gửi bài viết và câu hỏi cho VNOI qua: Form

📌 Phần thưởng dành cho những đóng góp xuất sắc từ cộng đồng: lựa chọn một bộ merch độc quyền của VNOI gồm sổ, bút và sticker, hoặc nhận tiền nhuận bút. Ngoài ra, các bạn có nhiều đóng góp sẽ được nhận thêm áo VNOI.

unknownnaciul
o11, Tháng 11, 2025, 13:00 1

dựa trên nền tảng DMOJ | theo dõi VNOI trên Github và Facebook