• 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

4

Tạp chí VNOI - Số 1: Giải trí

ntkwan, tuongpq đã đăng vào 10, Tháng 11, 2025, 13:00

Bạn cảm thấy lo sợ trong những kì thi chấm offline? Bạn bình thường làm bài rất tốt nhưng khi chấm offline lại bị điểm kém vì sai những lỗi vớ vẩn? Bạn code sai một bài rất khó và debug mãi không được vì không có test sai? Tất cả những vấn đề đó sẽ được giải quyết đơn giản với một chương trình chấm bài tự động, giúp bạn tự kiểm tra bài mình và phát hiện test sai. Bài viết này sẽ giới thiệu với bạn những bước cơ bản nhất để viết trình chấm - một kĩ thuật mà bạn nên thành thạo trước khi thi HSG: Viết trình chấm.


Đề thi HSGQG Tin học năm học 2024-2025 chứng kiến nhiều thí sinh sừng sỏ ngã ngựa bởi bài được đánh giá “dễ xơi" nhất đề. Bởi vì đề yêu cầu bắt đầu từ đỉnh 1, thí sinh cần đánh dấu (thông qua memset) các đỉnh khác để cho ra kết quả tối ưu như đề bài. Test mẫu của Bộ Giáo dục không bao gồm trường hợp này, cũng như do nhiều bạn đọc thiếu mất đi dữ kiện đó, dẫn đến việc các bạn không thể phát hiện ra được lỗi sai. Hãy đọc thật kĩ đề để không mất điểm trong kỳ VOI sắp tới nhé!

ntkwan, tuongpq
o10, Tháng 11, 2025, 13:00 0

3

Tạp chí VNOI - Số 1: Bạn hỏi - VNOI đáp!

ntkwan, tuongpq đã đăng vào 10, Tháng 11, 2025, 13:00

Xin chào các bạn!

Chào mừng các bạn đến với chuyên mục FAQ của tạp chí VNOI. Đây là nơi chúng mình sẽ giải đáp tất cả những thắc mắc, băn khoăn của các bạn về câu lạc bộ, về con người và về những dự án mà VNOI đang ấp ủ.

Trong số đầu tiên này, chúng mình đã nhận được rất nhiều câu hỏi thú vị. Hãy cùng VNOI đi tìm câu trả lời cho 3 câu hỏi được quan tâm nhất nhé!

1. VNOI được hình thành như thế nào?

VNOI (Viet Nam Olympiad in Informatics) là cộng đồng của các học sinh, sinh viên đam mê bộ môn tin học. Được thành lập từ năm 2008 bởi các cựu học sinh khối chuyên toán tin trên cả nước đã tham gia các kỳ thi học sinh giỏi quốc gia và quốc tế - với sứ mệnh lan toả, thúc đẩy và phát triển nền Tin học Việt Nam - VNOI đã nhận được sự tham gia, ủng hộ và đóng góp của đông đảo thế hệ học sinh, sinh viên.

Trái với suy nghĩ của nhiều người, VNOI không bắt đầu như một CLB có cơ cấu tổ chức, mà nó ra đời với tư cách là một diễn đàn. Người sáng lập ra VNOI lúc ban đầu là anh Ngô Minh Đức. Đồng hành cùng anh Đức là các admin đời đầu có thể kể đến như: anh Khúc Anh Tuấn, Đoàn Mạnh Hùng, Nguyễn Hoành Tiến, Nguyễn Minh Hiếu.

Cũng trong khoảng thời gian đó, ấp ủ ý tưởng về một trình chấm trực tuyến cho các bạn học sinh, sinh viên Việt Nam, nhóm các sinh viên bao gồm anh Nguyễn Đình Tư và Nguyễn Minh Hiếu đã lập ra VOJ với sự hỗ trợ của ban quản trị SPOJ, với mục đích ban đầu là để tạo sân chơi cho các bạn đội tuyển của THPT Chuyên Sư Phạm - Đại học Quốc Gia Hà Nội, và VOJ ngày ấy cũng chính là tiền thân của VNOJ sau này. Vào năm 2020, VNOI đã có những dự án chuyển một số bài tập từ VOJ lên hệ thống Codeforces (với nguyên nhân chính là VOJ dần xuất hiện những điểm yếu so với những hệ thống chấm bài hiện đại), tuy nhiên, dù việc tải các bài tập lên hệ thống Codeforces đã chính thức hoàn tất, ban quản trị vẫn nhận thấy sẽ có nhiều sự bất tiện khi Codeforces có thể bị sập hay lag khi diễn ra contest, gây ra hạn chế cho các bạn muốn luyện tập các nguồn bài từ VOJ. Chính vì lẽ đó, VNOI quyết định phát triển dự án VNOJ - VNOI Online Judge - với mong muốn tạo ra một hệ thống chấm bài riêng của Việt Nam: “do người Việt, vì người Việt”. Ngày 28/02/2021, Đại hội thành lập Câu lạc bộ Olympic Tin học Việt Nam (VNOI) đã được diễn ra dưới sự tham dự và chỉ đạo của đại diện hội Tin học Việt Nam (VAIP), đánh dấu thời khắc VNOI trở thành một nhánh của Hội, và ngày 07/04/2021 VNOI chính thức có bài đăng giới thiệu về VNOJ trên fanpage của CLB.

Trong gần 2 thập kỷ qua, VNOI đã phát triển từ một diễn đàn nhỏ trở thành một CLB vững mạnh với một hệ thống sinh thái học tập mở, có thể kể đến như:

  • VNOJ - VNOI Online Judge: hệ thống chấm bài trực tuyến hàng đầu tại Việt Nam, nơi đông đảo các bạn trẻ tin tưởng và lựa chọn cho quá trình ôn tập trước thềm các kỳ thi lớn như VOI, ICPC, và Olympic Tin học Sinh viên. Tính đến ngày 28/01/2024, VNOJ đã tiếp nhận và xử lý 4744496 lượt nộp bài, tương ứng khoảng 4688 lượt nộp bài trong một ngày.
  • VNOI Wiki: Thư viện VNOI được xây dựng với mục đích chia sẻ kiến thức Tin học đến với tất cả mọi người.
  • VNOI Roadmap: một lộ trình học tập hoàn hảo được đánh giá theo mức độ khó tăng dần - phù hợp cho tất cả các đối tượng học sinh, sinh viên, và đặc biệt hữu ích cho các bạn mới bắt đầu tiếp xúc với Tin học và Lập trình thi đấu.
  • Các giải đấu thường niên, uy tín như VNOI Cup (lần đầu tổ chức năm 2022) ra đời, tạo ra sân chơi cọ xát đỉnh cao cho cộng đồng.

Với việc cung cấp kho tàng kiến thức Wiki - phổ cập nhiều kiến thức chuyên sâu về thuật toán trong Lập trình thi đấu và trang chấm bài VNOJ, VNOi đã là người bạn đồng hành của nhiều bạn học sinh, sinh viên tham gia các đấu trường quốc tế như ICPC World Finals, IOI, …, góp phần nâng vị thế của Việt Nam trên đấu trường quốc tế và thường xuyên góp mặt trong top 10 quốc gia dẫn đầu tại IOI mỗi năm. Năm 2022 cũng đánh dấu một cột mốc đặc biệt với nền Tin học Việt Nam khi đội tuyển EggCentroy đến từ Trường Đại học Công nghệ - Đại học Quốc gia Hà Nội (UET) là đội tuyển Việt Nam đầu tiên giành được huy chương tại Kỳ thi Lập trình sinh viên quốc tế ICPC World Finals 2021 - cũng là tấm huy chương đầu tiên của Đông Nam Á.

Bên cạnh giao lưu kiến thức chuyên môn, VNOI còn là nơi giao lưu, kết nối giữa nhiều thế hệ. Hiện nay, nhiều cựu thành viên VNOI đã trở thành những tên tuổi lớn trong ngành lĩnh vực Tin học và Công nghệ thông tin như: anh Phạm Văn Hạnh (HCV IOI 2015), anh Lê Yên Thanh (người sáng lập Busmap), Phạm Nam Long (CEO Abivin), Lê Xuân Mạnh (CTO Kyber Network), Nguyễn Việt Dũng (CTO Krystal)... Bên cạnh đó là rất nhiều chuyên gia, kỹ sư đang làm việc tại các tập đoàn toàn cầu như Google, Facebook, Microsoft hay trở thành giáo sư, nhà nghiên cứu hàng đầu tại các trường Đại học.

Chính sự kết nối và "truyền lửa" liên tục qua các thế hệ này chính là sức mạnh và giá trị bền vững mà VNOI đã và đang hướng đến.

2. Làm sao để mình có thể tham gia đóng góp cho VNOI?

VNOI luôn chào đón mọi sự chung tay góp sức từ tất cả các bạn trên khắp mọi miền Tổ quốc! Có hai cách chính để bạn có thể tham gia đóng góp cho VNOI:

Cách 1: Trở thành Tình nguyện viên (TNV) chính thức qua các đợt tuyển:

VNOI vận hành như một CLB với nhiều phân nhóm (như team kĩ thuật, team contest, team wiki và team cộng đồng). Định kỳ, VNOI sẽ có các đợt tuyển TNV để tìm kiếm những thành viên mới, những người sẵn sàng dành thời gian và tâm huyết để cùng chúng mình xây dựng nên một cộng đồng ngày càng lớn mạnh.

Để có thể trở thành một TNV chính thức của VNOI, hãy theo dõi fanpage VNOI để cập nhật thông tin về các đợt tuyển quân nhé.

Cách 2: Đóng góp tự do thông qua các dự án hiện có của VNOI:

Nếu bạn không phải là TNV VNOI, nhưng vẫn rất yêu quý VNOI và muốn góp sức, bạn vẫn có thể:

  • Viết bài cho VNOI Wiki: Chia sẻ các kiến thức học thuật, bài giảng về những thuật toán thú vị,...
  • Đóng góp “tag problem” cho bài tập trên VNOJ: Giúp phân loại kho tàng bài tập khổng lồ của VNOJ, để các bạn đi sau dễ dàng tìm kiếm và luyện tập theo chủ đề.

Mọi sự đóng góp, dù là nhỏ nhất, đều vô cùng quý giá và góp phần trực tiếp xây dựng cộng đồng Tin học Việt Nam ngày một lớn mạnh.

3. Trong những năm qua, VNOI tự hào nhất về thành tựu hoặc tác động nào mà CLB đã tạo ra?

Ngay từ khi thành lập, chúng mình đã đặt ra sứ mệnh "thúc đẩy và phát triển nền Tin học Việt Nam" - một hành trình đầy tham vọng và cũng nhiều thách thức, đặc biệt là khi chúng mình quyết tâm rằng VNOI là một tổ chức phi lợi nhuận và hoạt động hoàn toàn dựa trên sức lực của các bạn Tình nguyện viên và các bạn trẻ mong muốn cống hiến. Điều VNOI tự hào nhất là đã xây dựng và duy trì thành công một hệ sinh thái học tập phi lợi nhuận, mở và hoàn toàn miễn phí.

  • Trước đây, tài liệu và môi trường luyện tập chất lượng cao chỉ tập trung ở các thành phố lớn hoặc các trường chuyên. Với VNOJ - hệ thống chấm bài tự động với hàng ngàn bài tập và các contest được diễn ra liên tục và chất lượng và VNOI Wiki - kho tàng kiến thức học thuật từ cơ bản đến nâng cao, đã phá vỡ rào cản đó. Giờ đây, bất kỳ học sinh nào, dù ở vùng sâu vùng xa, chỉ cần có kết nối Internet là có thể tiếp cận được nguồn tài liệu và nền tảng luyện tập ngang bằng với các bạn ở những thành phố lớn.
  • VNOI đã trở thành cầu nối vô giá giữa các thế hệ yêu Tin học. Những cựu học sinh chuyên Tin, những người đã thành danh hoặc đang theo học tại các trường đại học hàng đầu, nay quay về đóng góp với tư cách là admin, người ra đề, người viết bài… thậm chí có những admin VNOI còn vô cùng trẻ tuổi và tài năng. Điều này tạo ra một vòng tuần hoàn tri thức và đam mê, "cho đi và nhận lại", khiến ngọn lửa Tin học được truyền đi không bao giờ tắt.

Cuối cùng, mình chỉ muốn nói là điều VNOI tự hào nhất chính là đã góp phần tạo ra một sân chơi bình đẳng, nơi tất cả mọi người đều có cơ hội học hỏi, phát triển và kết nối với nhau bằng niềm đam mê chung. Xin chào các bạn!

ntkwan, tuongpq
o10, Tháng 11, 2025, 13:00 0

1

Tạp chí VNOI - Số 1: Coding Challenge #3

ntkwan, tuongpq đã đăng vào 15, Tháng 11, 2025, 23:42

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.

Sau 03 số đầu tiên, Coding Challenge đã ghi nhận những kết quả rất ấn tượng. Đã có tổng cộng 2316 lượt nộp bài trên hệ thống và 26 lời giải chất lượng được gửi về cho Tạp chí. Đây là nguồn động lực to lớn để đội ngũ Tạp chí VNOI tiếp tục phát triển và mở rộng sân chơi học thuật này trong thời gian tới.

Coding Challenge #3

Coding Challenge #3 là một bài toán về đường đi ngắn nhất, với tập cạnh có trọng số. Bài toán này là phiên bản tổng quát hoá trên đồ thị của bài toán đổ xăng nổi tiếng mà ắt hẳn mọi người đều đã trải qua một lần. Điểm thú vị của dạng bài này chính là việc chúng ta sẽ cần tư duy để tạo ra một đồ thị mới nhằm có thể nắm đủ thông tin bài toán. Trong 3 Coding Challenge, đây chính là bài toán thử thách nhất.

Bài toán này ghi nhận 21 bài giải thành công với lời giải nhanh nhất chỉ tốn 43 phút kể từ lúc đề bài Coding Challenge #3 được công bố. Đội ngũ Tạp chí VNOI đã chọn ra 2 lời giải cho bài toán này đến từ các độc giả của Tạp chí.

Đề Bài

Đề bài gốc có thể được đọc trên hệ thống VNOJ

Coding Challenge #3 - Đổ Xăng

Đổ Xăng

Vương quốc VNOI gồm ~n~ thành phố được liên kết với nhau bằng ~m~ con đường hai chiều, đảm bảo rằng mọi cặp thành phố đều có đường đi trực tiếp hoặc gián tiếp tới nhau. Con đường thứ ~i~ kết nối hai thành phố ~u_i, v_i~, và sẽ tốn ~w_i~ lít xăng để đi qua.

Là một người đam mê du lịch, bạn lên kế hoạch cho chuyến đi chơi của mình tới VNOI. Bạn xuất phát từ thành phố ~st~ và có dự định đi tới thành phố ~en~ bằng xe của mình. Tất nhiên để đi xe thì phải tốn nhiên liệu, và xe của bạn có một bình chứa được tối đa ~t~ lít xăng.

VNOI có ~s~ trạm xăng. Trạm xăng thứ ~i~ được phân bố ở thành phố ~p_i~ và có chi phí cho mỗi lít xăng là ~c_i~. Ở mỗi trạm xăng, bạn có thể mua không giới hạn số xăng (tất nhiên xe bạn chỉ mang tối đa được ~t~ lít xăng thôi).

Bạn bắt đầu từ thành phố ~st~ và xe bạn ban đầu không có xăng - may mắn rằng đảm bảo luôn có trạm xăng ở thành phố nơi bạn xuất phát. Hãy tính số tiền ít nhất cần bỏ ra để hoàn thành hành trình từ ~st~ tới ~en~.

Input
  • Dòng đầu gồm 3 số nguyên ~n, m, s~ (~2 \le n \le 1000; 1 \le m \le 10^4; 1 \le s \le 100~)
  • Dòng tiếp theo gồm một số nguyên dương ~t~ (~1 \le t \le 10^5~) mô tả sức chứa của bình xăng.
  • ~m~ dòng tiếp theo, dòng thứ ~i~ gồm ba số nguyên dương ~u_i, v_i, w_i~ (~1 \le u_i, v_i \le n; 1 \le w_i \le t~) miêu tả con đường thứ ~i~.
  • ~s~ dòng tiếp theo, dòng thứ ~i~ gồm hai số nguyên dương ~p_i, c_i~ (~1 \le p_i \le n; 1 \le c_i \le 100~) miêu tả trạm xăng thứ ~i~.
  • Dòng cuối cùng gồm hai số nguyên dương ~st, en~ (~1 \le st, en \le n~) miêu tả vị trí bắt đầu và đích đến của bạn.
Output
  • In ra số tiền ít nhất cần để đi chuyến đi.
Subtask
  • Subtask ~1~ (~30~\%): ~n \le 100~; ~m \le 100~; ~t \le 500~
  • Subtask ~2~ (~30~\%): ~t \le 500~
  • Subtask ~3~ (~40~\%): Không giới hạn gì thêm.
Sample Input 1
3 3 2
200
1 3 80
1 2 50
2 3 50
1 70
2 40
1 3
Sample Output 1
5500
Sample Input 2
5 5 3
100
1 2 80
2 5 80
1 3 40
3 4 60
4 5 60
1 8
2 9
3 2
1 5
Sample Output 2
1340
Sample Input 3
4 3 3
10
1 2 2
2 3 6
3 4 3
1 4
2 7
3 9
2 4
Sample Output 3
61
Minh Họa Sample:

Sample 1:

Slide1

Sample 2

Slide2

Sample 3

Slide3

Lời giải #1

Subtask 1: ~n, m \leq 100, t \leq 500~

Tags: DP, Dijkstra, Auxiliary Graph

Ta gọi ~f_{i, j}~ là số tiền ít nhất cần trả để đi đến đỉnh ~i~ và còn lại ~j~ lít xăng.

Với mỗi đỉnh ~f_{i, j}~ đã được tính, ta có thể chuyển trạng thái như sau:

  • Mua thêm lít xăng nếu như đỉnh i tồn tại cây xăng ~f_{i, next_j}~ ~(j < next_j <= t)~. ~f_{i, next_j} = f_{i, j} + (next_j - j) * minCost_i~ với ~minCost_i~ là chi phí nhỏ nhất để mua ~1~ lít xăng tại đỉnh ~i~.
  • Chuyển đến đỉnh ~v~ kề ~i~ nếu như ~j >= w~. ~f_{v, j - w} = f_{u, j}~

Độ phức tạp: ~O((n \times t + (m + n \times t \times t)) \times log_2(n \times t))~

Subtask 2: ~t \leq 500~

Tags: Dijkstra, Auxiliary Graph.

Vẫn từ ý tưởng cũ, để dễ xử lí, ta tạo đỉnh ảo cho mỗi ~f_{i, j}~. Ở subtask 1, ta thấy rằng việc chuyển trạng thái cho ~f_{i, next_j}~ là khá lớn. Vậy nên, thay vì phải duyệt hết những ~next_j~, ta chỉ cần chuyển đến trạng thái ~f_{i, j + 1}~. Lúc này, số lượng cạnh cần xét sẽ giảm đi đáng kể !

Độ phức tạp: ~O((n \times t + (m + n \times t)) \times log_2(n \times t))~

Subtask 3: Không có giới hạn gì thêm

Tags: Dijkstra, Auxiliary Graph.

Ta thấy rằng thay vì xem xét tất cả những đỉnh trong đồ thị gốc, ta chỉ quan tâm đến những đỉnh đặc biệt, những đỉnh này là những đỉnh mà có thể ảnh hưởng đến hàm ~f~ của chúng ta. Đầu tiên, ta định nghĩa những đỉnh đặc biệt là những đỉnh hoặc là ~st~, hoặc là ~en~, hoặc là tồn tại ít nhất ~1~ cây xăng ở nó, và khi ta đến đỉnh đặc biệt, ta buộc phải mua lít xăng (nếu có cây xăng). Ta dễ dàng thấy được số lượng đỉnh đặc biệt là rất là ít, chỉ khoảng ~\leq 101~!. Vậy nên, Ta sẽ xem xét xem với cặp đỉnh ~(u, v)~ đặc biệt, chi phí thấp nhất để từ ~u \to v~ là bao nhiêu. Điều này ta có thể dễ dàng làm được bằng việc dijkstra trên đồ thị gốc. Ta gọi ~minCost_{u, v}~ là số lít nhỏ nhất để có thể đi từ ~u \to v~. Lúc này, hàm quy hoạch động của chúng ta giảm số lượng thái từ ~n \times t~ về ~s \times t~. Nhưng... vẫn còn quá lớn ?. Ta có một nhận xét quan trọng như sau: nếu khi đến đỉnh đặc biệt ~u~, vì ta buộc phải mua lít xăng ở ~u~, ta có thể giảm số trạng thái chiều ~j~ không phải là ~t~ mà là ~2 \times s~ (khoảng ~2 \times 10^4~, bé hơn ~t~ nhiều). Đầu tiên, ta quan sát rằng, khi tại đỉnh ~u~, ta chỉ nên mua đủ để đi đến ~v~ hoặc mua đầy tại ~u~ sau đó đi đến ~v~. Vậy nên, ta chỉ cần những trạng thái ~j~ hoặc là nằm trong tập ~\{minCost_{u, v}\}~, hoặc là nằm trong tập ~\{t - minCost_{u, v} \}~. Mà ta thấy số lượng phần tử trong của tập ~\{minCost_{u, v}\} \leq 10^4~ (Ta sẽ gọi đây là tập ~j~ đặc biệt).

Chứng minh:

  • Xét hai đỉnh đặc biệt (u, v). Ta chia thành ~2~ trường hợp như sau:
    • Trường hợp ~1~: ~minCost_u < minCost_v~. Lúc này, để tối ưu, ta nên mua đầy lít xăng (là mua để làm đầy sức chứa) ở tại đỉnh ~u~, sao đó di chuyển đến đỉnh ~v~. Thì chi phí của chúng ta mới tối ưu được (thay vì từ đỉnh ~u~ đi đến đỉnh ~v~ xong rồi mua xăng). (Là cách chuyển trạng thái ~j~ từ ~t \to t - w~)
    • Trường hợp ~2~: ~minCost_u > minCost_v~. Lúc này, nếu như trường hợp ta còn đủ lít xăng, ta không nên mua tại ~u~ mà ta đi thẳng đến ~v~ (thì chắc chắn ~f_{v, x}~ không được tính từ ~f_{u, y}~. Trong chiều hướng ngược lại, ta nên mua đủ số lít xăng tại đỉnh ~u~ xong rồi đi đến đỉnh ~v~. (Là cách chuyển trạng thái ~j~ từ ~w \to 0~)

image Xét Sample Input 2 trong đề bài, từ các thông tin sau, ta có thể xây dựng đồ thị như trên:

  • ~SpecialNodes = \{ 1,2,3,5 \}~.
  • ~SpecialCosts = \{ 0,20,40,60,80,100 \}~.
  • Số lít xăng cần ít nhất từ ~1 \to 2~, ~1 \to 3~, ~2 \to 5~ lần lượt là ~80, 40, 80~. Chi phí mua xăng ít nhất tại đỉnh ~1, 2, 3~ lần lượt là ~8, 9, 2~. Ở đồ thị ảo ta xây dựng ở Sample Input 2, đường màu xanh là đường đi tối ưu, chính vì thế kết quả của chúng ta là ~1340~ !

Cuối cùng, ta sẽ có thuật toán như sau: Ta gọi:

  • ~specialNodes~, ~specialCosts~ lần lượt là ~i~ và tập ~j~ đặc biệt.
  • ~f_{i, j}~ là chi phí thấp nhất để đến đỉnh ~specialNode_i~ và còn dư ~specialCost_j~ lít.
  • ~minCost_i~: chi phí nhỏ nhất để mua ~1~ lít xăng tại đỉnh ~specialNode_i~.
  • ~minCost_{u, v}~: chi phí để đi từ ~u~ đến ~v~ mà mất ít lít xăng nhất.

Dễ dàng tính được ~minCost{u, v}~ bằng thuật toán dijkstra trên đồ thị ban đầu. Bước tiếp theo, ta sẽ thêm các cạnh vào đồ thị ảo. - Ta sẽ thêm tập cạnh ~f(u, minCost_{u, v}) \to f(v, 0)~ với trọng số là ~0~ nếu ~minCost_{u, v} \leq t~ (với ~u, v~ là những đỉnh đặc biệt) vào đồ thị ảo thể hiện mình sẽ đi đến ~v~ với đủ lít xăng. - Ta tiếp tục thêm tập cạnh ~f(u, t) \to f(v, t - minCost_{u, v})~ với trọng số là ~0~ nếu ~minCost_{u, v} \leq t~ (với ~u, v~ là những đỉnh đặc biệt) vào đồ thị ảo thể hiện mình đi đến ~v~ với đầy lít xăng. - Cuối cùng, ta thêm tập cạnh ~(u, t) \to (u, t')~ với trọng số là ~(t' - t) * minCost_u~ thể hiện ta mua lít xăng tại đỉnh ~u~.

Cuối cùng, ta sử dụng thuật toán dijkstra trong đồ thị ảo để tìm kết quả.

Lời giải #2

Subtask 1

Giới hạn: ~n \le 100~; ~m \le 100~; ~t \le 500~

Ý Tưởng:
  • Ta đặt ~\text{costs}[i]~ là chi phí nhỏ nhất để mua ~1~ lít xăng ở thành phố ~i~. Nếu thành phố thứ ~i~ không có trạm xăng nào thì ta có thể coi ~\text{cost}[i] = +\infty~.
  • Ta có thể mô hình bài toán bằng cách dựng một đồ thị ~G = (V, E)~ với mỗi đỉnh trong ~G~ đại diện cho một trạng thái của xe, gồm:
    • Thành phố xe đang dừng; và
    • Lượng xăng xe đang có. Nói cách khác, mỗi đỉnh có thể được biển diễn bằng một cặp số ~(i, f)~ với ~i~ là số hiệu thành phố và ~f~ là lượng xăng hiện tại của xe (~1 \leq i \leq n~, ~0 \leq f \leq t~).
  • Ta sẽ có hai loại cạnh dựa trên hai loại hành động:
    • Mua ~1~ lít xăng: đồ thị chứa cạnh từ ~(i, f)~ đến ~(i, f + 1)~ (với điều kiện~f < t~) có trọng số ~\text{costs}[i]~.
    • Di chuyển sang thành phố khác trên con đường thứ ~i~: đồ thị chứa cạnh từ ~(u_i, r)~ đến ~(v_i, r - w_i)~ (với điều kiện ~r \geq w_i~) có trọng số ~0~.
  • Kết quả bài toán ta cần tìm lúc này sẽ tương đương với việc tìm độ dài của đường đi ngắn nhất từ đỉnh ~(st, 0)~ đến một đỉnh ~(en, f)~ bất kì với ~0 \leq f \leq t~. Vì mọi trọng số đều không âm, ta dùng Dijkstra để tìm được độ dài ấy.
Độ 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((|V| + |E|)\log |V|)~ nếu ta cài đặt thuật toán Dijkstra với heap.
    • Vì với mỗi thành phố, ta có ~t + 1~ trạng thái khác nhau đại diện cho các mức xăng khác nhau, ta có ~V = O(n \cdot t)~ .
    • Vì với mỗi đường đi, ta sẽ có ~O(t)~ cạnh loại thứ hai được nêu trên và với mỗi thành phố, ta sẽ có ~O(t)~ cạnh loại thứ nhất được nếu trên, ta có ~E = O((n + m) \cdot t)~.
Code Minh Họa:
#include <bits/stdc++.h>
using namespace std;

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

constexpr int MAX_N = 1'003, MAX_S = 102, MAX_T = 1E5 + 5, INF = 0X3F3F3F3F;


vector<pair<int, int> > graph[MAX_N];
int n, m, s, t, st, en;
signed costs[MAX_N];

void input() {
    int u = 0, v = 0, w = 0, p = 0, c = 0;

    cin >> n >> m >> s >> t;

    for (int i = 1; i <= n; ++i)
        costs[i] = INF;

    for (int e = 1; e <= m; ++e) {
        cin >> u >> v >> w;
        graph[u].emplace_back(w, v);
        graph[v].emplace_back(w, u);
    }

    for (int i = 1; i <= s; ++i) {
        cin >> p >> c;
        minimize(costs[p], c);
    }

    cin >> st >> en;
}

namespace Dijkstra {

    signed minimumCosts[MAX_N][MAX_T];
    priority_queue<pair<int, pair<int, int> >, vector<pair<int, pair<int, int> > >, greater<pair<int, pair<int, int> > > > heap;

    void considerVertex(const int v, const int r, const int c) {
        if (minimize(minimumCosts[v][r], c))
            heap.emplace(c, make_pair(v, r));
    }

    void run() {
        memset(minimumCosts, INF, sizeof(minimumCosts));
        considerVertex(st, 0, 0);

        for (int x = 1; x <= n; ++x)
            sort(graph[x].begin(), graph[x].end());

        for (; !heap.empty();) {
            const auto [m, state] = heap.top();
            const auto &[x, r] = state;
            heap.pop();

            if (minimumCosts[x][r] != m)
                continue;

            if (x == en) {
                cout << m << '\n';
                return;
            }

            for (const auto &[w, y] : graph[x]) {
                if (r < w)
                    break;
                considerVertex(y, r - w, minimumCosts[x][r]);
            }

            if (r + 1 <= t)
                considerVertex(x, r + 1, minimumCosts[x][r] + costs[x]);
        }
    }

}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL

    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    Dijkstra::run();

    return 0;
}
Subtask 2

Giới hạn: ~n \le 1000~; ~m \le 10^4~; ~t \le 500~

Ý Tưởng:
  • Vì chương trình giải cần phải chạy trong ~1~ giây (tương đương với khoảng ~10^8~ lệnh), giới hạn của subtask 2 có thể khiến chương trình chạy quá thời gian khi sử dụng thuật toán Dijkstra như trong subtask 1. Một thuật toán thay thế để tìm đường đi ngắn nhất ta có thể sử dụng để tìm đường đi ngắn nhất là SPFA (Shortest Path Faster Algorithm).
Độ Phức Tạp:
  • Trong trường hợp tệ nhất, chương trình cài đặt với thuật toán SPFA có thể đạt độ phức tạp thời gian ~O(|V| \cdot |E|)~. Tuy nhiên, thời gian chạy trung bình của SPFA là rất nhanh, có thể được xem như thuật toán chạy với độ phức tạp ~O(|E|)~ trong trường hợp này.
Code Minh Họa:
#include <bits/stdc++.h>
using namespace std;

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

constexpr int MAX_N = 1'003, MAX_S = 102, MAX_T = 1E5 + 5, INF = 0X3F3F3F3F;


vector<pair<int, int> > graph[MAX_N];
int n, m, s, t, st, en;
signed costs[MAX_N];

void input() {
    int u = 0, v = 0, w = 0, p = 0, c = 0;

    cin >> n >> m >> s >> t;

    for (int i = 1; i <= n; ++i)
        costs[i] = INF;

    for (int e = 1; e <= m; ++e) {
        cin >> u >> v >> w;
        graph[u].emplace_back(w, v);
        graph[v].emplace_back(w, u);
    }

    for (int i = 1; i <= s; ++i) {
        cin >> p >> c;
        minimize(costs[p], c);
    }

    cin >> st >> en;
}

namespace SPFA {

    signed minimumCosts[MAX_N][MAX_T];
    bool inqueue[MAX_N][MAX_T];
    queue<pair<int, int> > q;
    int result = INF;

    void considerVertex(const int v, const int r, const int c) {
        if (minimize(minimumCosts[v][r], c) && c < result) {
            if (v == en)
                minimize(result, c);
            if (!inqueue[v][r]) {
                q.emplace(v, r);
                inqueue[v][r] = true;
            }
        }
    }

    void run() {
        memset(minimumCosts, INF, sizeof(minimumCosts));
        considerVertex(st, 0, 0);

        for (int x = 1; x <= n; ++x)
            sort(graph[x].begin(), graph[x].end());

        for (; !q.empty(); q.pop()) {
            const auto &[x, r] = q.front();
            inqueue[x][r] = false;

            for (const auto &[w, y] : graph[x]) {
                if (r < w)
                    break;
                considerVertex(y, r - w, minimumCosts[x][r]);
            }

            if (r + 1 <= t)
                considerVertex(x, r + 1, minimumCosts[x][r] + costs[x]);
        }

        cout << result << '\n';
    }

}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL

    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    SPFA::run();

    return 0;
}
Subtask 3

Giới hạn: ~n \le 1000~; ~m \le 10^4~; ~t \le 10^5~

Ý Tưởng:
  • Cách dựng đồ thị như trong hai Subtask đầu đều sẽ dẫn đến số lượng đỉnh và cạnh lớn trong Subtask 3 (trong trường hợp tệ nhất, ~|V|~ và ~|E|~ có thể lần lượt đạt khoảng ~10^8~ và hơn ~10^9~).
  • Tạm không xét đến chi tiết giá xăng, ta đặt ~G_0 = (V_0, E_0)~ là đồ thị vô hướng có trọng số với mỗi đỉnh đại diện cho một thành phố và mỗi cạnh đại diện cho một đường đi ban đầu (~|V_0| = n~, ~|E_0| = m~).
  • Ta nhận thấy rằng không phải thành phố nào cũng có trạm xăng. Vì vậy để giảm số đỉnh cần xem xét, ta có thể mô hình bài toán theo một cách khác như sau: Ta đặt ~G_1 = (V_1, E_1)~ là một đầy đủ với:
    • mỗi đỉnh đại diện cho một thành phố có trạm xăng hoặc là đich đến. ~\Rightarrow |V_1| = O(s)~
    • mỗi cạnh có trọng số đại diện cho lượng xăng tối thiểu cần dùng để di chuyển giữa hai đỉnh nếu bình chứa là vô hạn (trọng số này tương đương với đường đi ngắn nhất trong ~G_0~). ~\Rightarrow |E_1| = O(s^2)~
  • Nếu ta xét thêm chi tiết giá xăng, ta có thể dựng một đồ thị ~G' = (V', E')~ từ ~G_1~ như cách ta có thể dựng đồ thị ~G~ từ ~G_0~ (bằng cách thêm chi tiết lượng xăng vào trạng thái, ...).
    • Khi này, số lượng đỉnh tối đa sẽ được giảm xuống còn khoảng ~10^7~ (~|V'| = O(s \cdot t)~).
    • Tuy nhiên, số lượng cạnh vẫn quá lớn để xử lý (~|E'| = O(s^2 \cdot t)~ lớn hơn ~10^9~)
  • Để giảm số lượng cạnh cần xét, ta có một số nhận xét như sau: Trong ~G_1~, xét một cung từ ~x~ tới ~y~ với trọng số ~w~,
    • Nếu ~\text{costs}[x] \geq \text{costs}[y]~ hoặc ~y~ là đích đến thì trong ~G'~, ngoại trừ cạnh từ ~(x, w)~ đến ~(y, 0)~, ta không cần phải xem xét bất kì cạnh nào khác giữa từ thành phố ~x~ đến ~y~.
Giải thích:
  Trong cả hai trường hợp, ta không có lý do để cần có nhiều hơn ~w~ lít xăng ở thành phố ~x~ bởi:
  - Với trường hợp đầu, ta có thể mua xăng với giá rẻ hơn ở thành phố ~y~ (nếu ta không mua xăng ở thành phố ~y~ thì điều này đồng nghĩa với việc, ta muốn đến thành phố khác trong ~V_1~ từ ~x~).
  - Với trường hợp sau, ta không đi thêm bất kì đâu khi đã đến đích.
  • Nếu ~\text{costs}[x] < \text{costs}[y]~ thì trong ~G'~, ngoại trừ cạnh từ ~(x, t)~ đến ~(y, t - w)~, ta không cần phải xem xét bất kì cạnh nào khác từ thành phố ~x~ đến ~y~.
Giải thích:
  Ta không có lý do để cần có ít hơn ~t~ lít xăng ở thành phố ~x~ bởi:
  - Nếu ta không mua xăng ở thành phố ~y~ thì điều này đồng nghĩa với việc, ta muốn đến thành phố khác trong ~V_1~ từ ~x~ (vì ~G_1~ là một đồ thị đầy đủ được định nghĩa như trên).
  - Nếu ta mua xăng ở thành phố ~y~ nhưng lại rời thành phố ~x~ với lượng xăng không đầy bình, chi phí sẽ không được tối ưu bởi thay vì mua ~1~ lít xăng ở ~y~, ta có thể làm điều tương tự với chi phí rẻ hơn ở thành phố ~x~.

  • Với những nhất xét này, số lượng cạnh cần xét sẽ giảm xuống ~|E_2| = O(s^2 + s \cdot t)~ bởi ta chỉ xét tối đa một cạnh giữa hai thành phố khác nhau trong ~G_1~.
Mở rộng:

Số lượng cạnh và đỉnh cần xét có thể tiếp tục được giảm xuống ~O(s^2)~ bằng cách chỉ xét những đỉnh ~(x, f)~ có tối thiểu một cạnh đến đỉnh đại diện cho một thành phố khác ~x~.

Độ Phức Tạp:
  • Để dựng được đồ thị ~G_1~, ta có thể cài đặt bằng cách chạy thuật toán Dijkstra nhiều lần với các thành phố có trạm xăng làm đỉnh nguồn. Vì vậy, bước này sẽ có độ phức tạp thời gian ~O(s \cdot n \cdot \log (n + m))~.
  • Nếu ta tiếp tục sử dụng thuật toán SPFA trên đồ thị ~G_2 = (V_1, E_2)~, bước tìm kết quả cuối cùng sẽ có độ phức tạp ~O(s^2 + s \cdot t)~. Vì thế độ phức tạp tổng cho các bước xử lý chính có thể được xem như ~O(s \cdot n \cdot \log(n + m) + s^2 + s \cdot t)~.
Code Minh Họa:
#include <bits/stdc++.h>
using namespace std;

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

constexpr int MAX_N = 1'003, MAX_S = 102, MAX_T = 1E5 + 5, INF = 0X3F3F3F3F;

vector<pair<int, int> > graph[MAX_N];
long long minimumCost = INF;
int n, m, s, t, st, en;
signed costs[MAX_N];

void input() {
    int u = 0, v = 0, w = 0, p = 0, c = 0;

    cin >> n >> m >> s >> t;

    for (int i = 1; i <= n; ++i)
        costs[i] = INF;

    for (int e = 1; e <= m; ++e) {
        cin >> u >> v >> w;
        graph[u].emplace_back(w, v);
        graph[v].emplace_back(w, u);
    }

    for (int i = 1; i <= s; ++i) {
        cin >> p >> c;
        minimize(costs[p], c);
        minimize(minimumCost, c);
    }

    cin >> st >> en;
}

namespace DijkstraAlgorithm {

    int vertexCount, vertexID[MAX_N], minimumDistances[MAX_S][MAX_S];
    vector<int> vertices, prices;

    void findMinimumDistances(const int source) {
        priority_queue<pair<int, int>, vector<pair<int, int> >, greater<pair<int, int> > > heap;
        vector<int> distances(n + 1, INF);
        heap.emplace(distances[source] = 0, source);
        for (; !heap.empty();) {
            const auto [d, x] = heap.top();
            heap.pop();
            for (const auto &[w, y] : graph[x])
                if (minimize(distances[y], distances[x] + w))
                    heap.emplace(distances[y], y);
        }
        for (const int &destination : vertices)
            minimumDistances[vertexID[source]][vertexID[destination]] = distances[destination];
    }

    void run() {
        for (int i = 1; i <= n; ++i)
            if (costs[i] < INF || i == en) {
                vertexID[i] = (vertexCount++);
                vertices.push_back(i);
            } else
                vertexID[i] = -1;
        prices.resize(vertexCount);
        for (int i = 0; i < vertexCount; ++i) {
            findMinimumDistances(vertices[i]);
            prices[i] = costs[vertices[i]];
        }
    }

}

namespace SPFA {

    using namespace DijkstraAlgorithm;

    unordered_map<int, unordered_map<int, vector<int> > > graph;
    int endVertex, minimumCosts[MAX_S][MAX_T];
    bool inqueue[MAX_S][MAX_T];
    queue<pair<int, int> > q;
    int result = INF;

    void considerVertex(const int v, const int r, const int c) {
        if (minimize(minimumCosts[v][r], c) && c < result) {
            if (v == endVertex)
                result = c;
            else if (!inqueue[v][r]) {
                inqueue[v][r] = true;
                q.emplace(v, r);
            }
        }
    }

    void buildGraph() {
        endVertex = vertexID[en];
        for (int x = 0, y = 0; x < vertexCount; ++x)
            for (y = 0; y < vertexCount; ++y) {
                if (x == y || minimumDistances[x][y] > t)
                    continue;
                if (prices[x] >= prices[y] || y == endVertex) {
                    graph[x][minimumDistances[x][y]].push_back(y);
                    continue;
                }
                graph[x][t].push_back(y);
            }
    }

    void run() {
        buildGraph();
        memset(minimumCosts, INF, sizeof(minimumCosts));
        considerVertex(vertexID[st], 0, 0);

        for (; !q.empty(); q.pop()) {
            const auto &[x, r] = q.front();
            inqueue[x][r] = false;

            const auto outerElement = graph.find(x);

            if (outerElement != graph.end()) {
                const auto &innerElements = (outerElement -> second);
                const auto innerElement = innerElements.find(r);

                if (innerElement != innerElements.end()) {
                    for (const auto &y : (innerElement -> second))
                        considerVertex(y, r - minimumDistances[x][y], minimumCosts[x][r]);
                }
            }

            if (r + 1 <= t)
                considerVertex(x, r + 1, minimumCosts[x][r] + prices[x]);
        }

        cout << result << '\n';
    }

}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL

    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    DijkstraAlgorithm::run();
    SPFA::run();

    return 0;
}

Xin trân trọng cảm ơn các tác giả đã đồng hành cùng chuyên mục. Các tác giả có bài giải được đăng trong Tạp chí VNOI - Số 1 đã được đội ngũ Tạp chí VNOI liên hệ để nhận phần quà tri ân từ VNOI.

ntkwan, tuongpq
o15, Tháng 11, 2025, 23:42 0

0

Tạp chí VNOI - Số 1: Coding Challenge #2

ntkwan, tuongpq đã đăng vào 14, Tháng 11, 2025, 4:17

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.

Sau 03 số đầu tiên, Coding Challenge đã ghi nhận những kết quả rất ấn tượng. Đã có tổng cộng 2316 lượt nộp bài trên hệ thống và 26 lời giải chất lượng được gửi về cho Tạp chí. Đây là nguồn động lực to lớn để đội ngũ Tạp chí VNOI tiếp tục phát triển và mở rộng sân chơi học thuật này trong thời gian tới.

Coding Challenge #2

Coding Challenge #2 tiếp tục là một bài toán đồ thị, với chủ đề về Cây Khung Nhỏ Nhất. Điểm đặc biệt của bài toán chính là ở việc chúng ta sẽ không chỉ đi tìm một cây khung, mà cần liên tiếp thực hiện thao tác tìm kiếm để cho ra K cây khung lớn nhất (một biến thể ngược của bài toán MST nổi tiếng).

Bài toán này ghi nhận 116 độc giả tham gia giải và 28 bài giải được trọn vẹn 100/100 điểm từ bộ test. Đội ngũ Tạp chí VNOI đã chọn ra 2 lời giải cho bài toán này đến từ các độc giả của Tạp chí.

Đề Bài

Đề bài gốc có thể được đọc trên hệ thống VNOJ

Coding Challenge #2 - Đấu Thầu

Nước VNOI gồm ~n~ thành phố và đang có kế hoạch liên kết chúng bằng ~m~ con đường cao tốc hai chiều. Các con đường được đánh số từ 1 tới ~m~, con đường thứ ~i~ liên kết thành phố ~u_i~ với ~v_i~, và có lợi nhuận là ~w_i~ (các giá trị ~w_i~ phân biệt). Các con đường được thiết kế để tất cả các cặp thành phố đều có thể trực tiếp hoặc gián tiếp đi tới nhau.

Để xây dựng các con đường đó, chính quyền VNOI quyết định giao việc này cho ~k~ nhà thầu. Sau những vòng bỏ phiếu căng thẳng, các nhà thầu này được xếp ở các vị trí từ ~1~ tới ~k~ thể hiện sự ưu tiên trong việc chọn tuyến đường thi công.

Mỗi nhà thầu sẽ được chọn một tập hợp các con đường trong ~m~ đường cao tốc có sẵn trong kế hoạch, sao cho chúng không tạo ra bất kì chu trình nào và con đường không bị nhà thầu nào trước đó chọn. Giả sử, nếu nhà thầu chọn con đường ~\{1 \to 2; 2 \to 3; 3 \to 1; 4 \to 5\}~ là không thoả mãn, bởi vì có chu trình ~1 \to 2 \to 3 \to 1~. Lưu ý rằng ở ví dụ trên mũi tên được đánh hướng cho mục đích biểu diễn, còn trên thực tế thì các con đường là hai chiều.

Đầu tiên, nhà thầu thứ ~1~ sẽ được chọn trước, sau đó tới nhà thầu thứ ~2~, ... tới nhà thầu thứ ~k~. Tất nhiên với cách này, một số con đường sẽ không được xây dựng, nhưng ở giai đoạn đầu của dự án thì đây là chuyện bình thường.

Tất nhiên, mỗi nhà thầu đều muốn tối đa hoá lợi ích của mình, vậy nên khi được chọn các con đường, họ sẽ chọn tập con đường mang tới tổng lợi nhuận lớn nhất có thể.

Nhiệm vụ của bạn là tính toán trước cho chính quyền VNOI xem mỗi nhà thầu từ ~1~ tới ~k~ sẽ có lợi nhuận là bao nhiêu khi tất cả đều chọn tập con đường một cách tối ưu.

Input

  • Dòng đầu tiên gồm 3 số nguyên ~n, m, k~ miêu tả số thành phố, số con đường cần xây dựng và số nhà thầu;
  • ~m~ dòng tiếp theo, dòng thứ ~i~ gồm ~3~ số nguyên ~u_i, v_i, w_i~ miêu tả con đường thứ ~i~ trong kế hoạch, kết nối thành phố ~u_i~ với ~v_i~

Output

  • Gồm ~k~ dòng, dòng thứ ~i~ chứa giá trị là tổng lợi nhuận của nhà thầu thứ ~i~ trong cách chia tối ưu.

Subtask

  • Trong tất cả các test ~w_i \le 10^9~
  • Có ~20~% test tương ứng ~20~% số điểm có ~n \le 10^5, m \le 5 \times 10^5; k = 1~.
  • Có ~25~% test tương ứng ~25~% số điểm có ~n \le 10^3, m \le 10^4; k \le 1000~.
  • Có ~35~% test tương ứng ~35~% số điểm có ~n \le 10^3, m \le 5 \times 10^5; k \le 10^4~.
  • ~20~% số test còn lại tương ứng với ~20~ số điểm có ~n \le 10^5, m \le 5 \times 10^5; k \le 10^4~.

Sample

Sample 1
Input
4 5 1
1 2 5   
2 3 4   
3 4 3   
4 1 2   
1 3 1
Output
12
Sample 2
Input
5 8 3
1 2 9
2 3 8
3 4 7
2 5 4
1 3 3
2 4 2
4 5 6
1 5 5
Output
30
14
0
Tóm Tắt Đề Bài
  • Cho một đồ thị vô hướng liên thông ~G = (V, E)~ có ~n~ đỉnh và ~m~ cạnh mang trọng số dương.
  • Thực hiện thao tác sau ~k~ lần:
    • Tìm tập cạnh ~E' \subseteq E~ sao cho:
      • ~E'~ không chứa chu trình;
      • ~E'~ có tổng trọng số lớn nhất có thể.
    • Xóa các cạnh trong ~E'~ khỏi ~E~ (~E \leftarrow E \setminus E'~).
    • Cho biết tổng trọng số của ~E'~ sau mỗi lần thực hiện thao tác.
Subtask Điểm Giới hạn
~1~ ~20~% ~n \le 10^5, m \le 5 \times 10^5; k = 1~
~2~ ~25~% ~n \le 10^3, m \le 10^4; k \le 1000~
~3~ ~35~% ~n \le 10^3, m \le 5 \times 10^5; k \le 10^4~
~4~ ~20~% ~n \le 10^5, m \le 5 \times 10^5; k \le 10^4~
Minh Họa Sample
Sample 1:
  • Minh hoạ đồ thị sau mỗi lần thực hiện thao tác

sample_01

  • Minh họa tập cạnh được chọn bởi các thao tác

sample<em>01</em>summary

Sample 2:
  • Minh họa đồ thị sau mỗi lần thực hiện thao tác

sample__02

  • Minh họa tập cạnh được chọn bởi các thao tác

sample<em>02</em>summary

Lời giải #1

Một số lưu ý

Đồ thị được cho trên đề bài không nhắc đến việc không tồn tại cạnh lặp.

Subtask 1: ~n\le 10^5,m\le 5\times 10^5,k=1~

Theo đề bài, các cạnh mà mỗi nhà thầu chọn không được tạo thành một chu trình. Điều này tương ứng với việc các cạnh đó phải là các cạnh của một rừng (forest).

Trong subtask 1, chỉ có duy nhất một nhà thầu nên ta có thể tóm tắt yêu cầu của subtask này như sau: Chọn ra một tập cạnh có tổng trọng số lớn nhất sao cho tập cạnh đó phải tạo thành một rừng.

Đây là dạng bài toán tìm cây khung lớn nhất trên đồ thị. Về cơ bản thì cách làm không khác gì cách làm bài toán tìm cây khung nhỏ nhất.

Về thuật toán tìm cây khung nhỏ nhất, các bạn có thể tìm hiểu tại đây.

Độ phức tạp: ~O(m\times log(m)+m\times log(n))~ (Kruskal) hoặc ~O((m+n)\times log(n))~ (Prim).

Subtask 2: ~n\le 10^3,m\le 10^4,k\le 1000~

Trong trường hợp có nhiều nhà đấu thầu hơn, rõ ràng:

  • Nhà đấu thầu thứ nhất sẽ chọn các cạnh để tạo thành cây khung lớn nhất (như subtask 1).
  • Từ nhà đấu thầu thứ hai, họ cũng sẽ chọn các cạnh để tạo thành cây khung lớn nhất từ các cạnh còn lại mà họ có thể chọn.

Như vậy, subtask này chỉ là tìm cây khung lớn nhất ~k~ lần tương ứng với ~k~ nhà đấu thầu. Với mỗi lần chọn được các cạnh cho một nhà đấu thầu, ta bỏ các cạnh đó đi để xét cạnh trên đồ thị mới cho các nhà đấu thầu sau.

Độ phức tạp: ~O(T\times k)~ với ~T~ là độ phức tạp cho mỗi lần tìm cây khung lớn nhất (subtask 1).

Subtask 3: ~n\le 10^3,m\le 5\times 10^5,k\le 10^4~

Vì giới hạn lớn nên ta không thể áp dụng cách tìm cây khung lớn nhất như cách làm của subtask 1/2.

Dựa trên thuật toán Kruskal, ta có một cách làm khác để giải subtask 2 như sau:

  • Ta tạo ~k~ cây khung tương ứng với ~k~ nhà đấu thầu. Sắp xếp lại các cạnh theo trọng số giảm dần.
  • Khi xét từng cạnh, nếu cạnh đó có thể thêm vào cây khung thứ nhất thì ta sẽ thêm. Trong trường hợp ngược lại, ta sẽ xét cây khung thứ hai, và cứ vậy đến cây khung thứ ~k~ (chọn cây khung có chỉ số nhỏ nhất mà ta có thể thêm cạnh).
  • Cách duyệt này vẫn đảm bảo việc chọn ra được cây khung lớn nhất cho tất cả ~k~ nhà đấu thầu.

Tới đây ta có một nhận xét:

  • Gọi ~f(i,j,p)~ là hàm kiểm tra tính liên thông của cặp đỉnh ~(i,j)~ trên cây khung thứ ~p~. ~f(i,j,p)=1~ khi hai đỉnh này cùng nằm trên một TPLT của cây khung thứ ~p~ và ~f(i,j,p)=0~ trong trường hợp ngược lại.
  • Khi đó, ~f(i,j,p)\ge f(i,j,p+1)\forall 1\le i,j\le n,1\le p<k>
  • Nói cách khác, tính liên thông của mọi cặp đỉnh ~(i,j)~ giảm dần theo chỉ số ~p~ của cây khung.
  • </k>

Với tính chất của hàm ~f(i,j,p)~, ta có thể thực hiện tìm kiếm nhị phân để tìm cây khung có chỉ số nhỏ nhất mà ~f(i,j,p)=0~ mỗi khi xét cạnh ~(i,j)~.

Độ phức tạp cho mỗi lần xét cạnh sẽ là ~O(log(n)\times log(k))~ hoặc có thể nhỏ hơn nếu thực hiện tối ưu như nén đường đi hay gộp theo kích cỡ.

Độ phức tạp: ~O(m\times log(n)\times log(k))~.

Subtask 4: ~n\le 10^5,m\le 5\times 10^5,k\le 10^4~

Việc dựng ~k~ cây khung một cách thủ công sẽ không thể thực hiện như subtask 3 vì ~n\times k\le 10^9~, giới hạn này là rất lớn để có thể lưu trữ.

Trên thực tế, ta không cần phải lưu hết trạng thái của các đỉnh vì nếu có đỉnh không phải là một trong hai đầu của bất kỳ cạnh nào của cây khung thì không cần phải lưu trữ. Vì thế nên ta có thể sử dụng std::map hoặc cây tiền tố (trie) để lưu tất cả những nút cần lưu trữ thông tin. Điều này giúp việc thực hiện các thao tác trên DSU cũng chỉ mất ~O(log(n))~.

Lưu ý: Cần phải cài đặt cẩn thận vì có thể sẽ bị TLE (time limit exceed).

Độ phức tạp: ~O(m\times log(n)\times log(k))~.

Lời giải #2

Subtask 1: Giới hạn: ~n \le 10^5, m \le 5 \times 10^5; k = 1~

Ý Tưởng:
  • Vì số thao tác cần phải thực hiện ~k~ là ~1~, bài toán có thể được quy về tìm cây khung lớn nhất (ngược với bài toán tìm cây khung nhỏ nhất) trong đồ thị đã cho bởi:
    • Vì tập cạnh được chọn, tạm gọi là ~E'~, không được chứa chu trình, ~E'~ phải là một rừng.
    • Hơn nữa, vì đồ thị ban đầu liên thông, để tổng trọng số là lớn nhất, ~E'~ phải là một cây khung (rừng liên thông gồm ~n - 1~ cạnh). Nếu ~E'~ không liên thông, ta có thể thêm một cạnh mới mà không tạo ra chu trình, làm tăng tổng trọng số. Điều này mâu thuẫn với giả thiết tối ưu.
  • Để tìm cây khung lớn nhất, ta có thể sử dụng thuật toán Kruskal với một thay đổi nhỏ: Thay vì sắp xếp các cạnh theo thứ tự tăng dần trọng số, ta sắp xếp giảm dần.
Cách chứng minh thứ nhất (ngắn gọn):
  • Bài toán tìm cây khung lớn nhất trên đồ thị ~G = (V, E)~ có thể được quy về bài toán tìm cây khung nhỏ nhất trên đồ thị ~G' = (V, E)~ với cùng tập đỉnh và cạnh, nhưng với trọng số mới được đảo dấu (~w'_e = -w_e~ cho mọi cạnh ~e \in E~). Khi đó, cây khung nhỏ nhất của ~G'~ cũng chính là cây khung lớn nhất của ~G~.
  • Thuật toán Kruskal có thể được dùng để tìm cây khung nhỏ nhất với các cạnh được duyệt theo thứ tự trọng số tăng dần. Khi áp dụng thuật toán trên đồ thị ~G'~, thứ tự này tương đương với việc duyệt các cạnh của ~G~ theo thứ tự trọng số giảm dần.
Cách chứng minh thứ hai (chi tiết):

Lưu ý: Cách chứng minh này phần lớn giống với cách minh tính đúng đắn của thuật toán Kruskal.

  • Ta có thể chứng minh tính đúng đắn của ý tưởng trên bằng cách chứng minh quy nạp theo số lượng cạnh đã chọn với mệnh đề sau: "Với tập cạnh ~M~ được chọn bởi thuật toán tại mọi thời điểm, đồ thị luôn chứa một cây khung lớn nhất với tập cạnh ~T~ chứa mọi cạnh của ~M~".

    • Trường hợp cơ sở: Mệnh đề trên hiển nhiên đúng với trường hợp ban đầu, khi ~M~ không chứa bất kì cạnh nào bởi tập rỗng luôn là tập con của bất kì cây khung lớn nhất nào.
    • Bước quy nạp: Khi ta xem xét có nên thêm cạnh ~e~ vào ~M~ đang thỏa mệnh đề trên không, ta có ba trường hợp sau:
    1. ~M \cup \{e\}~ chứa chu trình (thêm cạnh ~e~ vào ~M~ tạo nên một chu trình): Thuật toán sẽ không thêm ~e~ vào ~M~. Vì vậy, mệnh đề sẽ vẫn thỏa bởi ~T~ cũng không thể chứa ~e~ (~T~ là một cây khung nên không chứa chu trình).
    2. ~T~ chứa ~e~: Mệnh đề trên tiếp tục thỏa.
    3. ~T~ không chứa ~e~: Khi thêm cạnh ~e~ vào ~T~, ta có ~T \cup {e}~ chứa một chu trình duy nhất có chứa cạnh ~e~. Chu trình này phải chứa ít nhất một cạnh ~f~ không ở trong ~M~. Nếu không, việc thêm ~e~ vào ~M~ đã tạo chu trình, tức rơi vào trường hợp đầu tiên.

    Vì cạnh ~f~ đã không được chọn bởi thuật toán trước đó, trọng số của cạnh ~f~ không thể lớn hơn trọng số cạnh ~e~ (~w_f \leq w_e~). Khi này, ta nhận thấy rằng cây khung với tập cạnh ~T' = T \cup \{e\} \setminus \{f\}~ cũng là cây khung lớn nhất bởi tổng trọng số của ~T'~ không nhỏ hơn tổng trọng số của ~T~. Mệnh đề vì thế vẫn tiếp tục được thỏa bởi ~M \cup \{e\}~ là tập con của tập cạnh ~T'~ của một cây khung lớn nhất.

Độ Phức Tạp:
  • Ý tưởng trên có thể được cài đặt với độ phức tạp thời gian ~O(m \log m + n)~ bằng cách sử dụng cấu trúc dữ liệu Disjoint Set Union để quản lý các thành phần liên thông như trong bài toán tìm cây khung nhỏ nhất.

Giải thích:

  • Bước khởi tạo cấu trúc dữ liệu Disjoint Set Union có thể có độ phức tạp ~O(n)~.
  • Bước sắp xếp cạnh có độ phức tạp ~O(m \log m)~.
  • Với mỗi lần xem xét một cạnh ~e = (u, v)~, ta cần kiểm tra hai đỉnh của cạnh ~e~ có cùng thuộc một thành phần liên thông không và nếu không thuộc thì ta có gộp hai thành phần liên thông chứa ~u~ và ~v~ lại. Nếu ta cài đặt cấu trúc dữ liệu Disjoint Set Union với cả hai phương pháp gộp theo kính cỡ và nén đường đi, một lần xem xét thêm cạnh như trên có độ phức tạp ~O(\alpha(n))~ với ~\alpha~ là hàm Ackermann nghịch đảo.

Vì ta duyệt ~m~ cạnh, bước xém xét hết tất cả các cạnh sẽ có độ phức tạp ~O(m \cdot \alpha(n))~.

Độ phức tạp tổng lúc này sẽ là ~O(m \log m) + O(m \cdot \alpha(n)) + O(n) = O(m \log m + n)~.

Lưu ý: Nếu ta chỉ dùng một trong phương pháp nêu trên khi cài đặt cấu trúc dữ liệu Disjoint Set Union, một lần xem xét thêm cạnh như trên có độ phức tạp ~O(\log(n))~. Khi này, độ phức tạp tổng sẽ là ~O(m \cdot (\log(m) + \log(n)) + n)~.

  • Với cách cài đặt này, độ phức tạp bộ nhớ là ~O(n + m)~.

Giải thích:

  • Lưu danh sách cạnh chiếm cần ~O(m)~ bộ nhớ.
  • Cấu trúc dữ liệu Disjoint Set Union cần ~O(n)~ bộ nhớ.
Code Minh Họa:
#include <bits/stdc++.h>
using namespace std;

class DisjointSetUnion {
private:

    mutable vector<int> roots;

public:

    DisjointSetUnion() {};

    DisjointSetUnion(const int n): roots(n, -1) {};

    int getRoot(const int x) const {
        return (roots[x] < 0) ? x : (roots[x] = getRoot(roots[x]));
    }

    bool checkConnected(const int x, const int y) const {
        return getRoot(x) == getRoot(y);
    }

    bool unite(int x, int y) {
        x = getRoot(x);
        y = getRoot(y);
        if (x == y)
            return false;
        if (roots[x] > roots[y])
            swap(x, y);
        roots[x] += roots[y];
        roots[y] = x;
        return true;
    }

};

constexpr int MAX_N = 1E5 + 5, MAX_M = 1E5 + 5, MAX_K = 1E4 + 4;

int n, m, k;
vector<tuple<int, int, int> > edges;

void input() {
    cin >> n >> m >> k;

    edges.resize(m);

    for (auto &[w, u, v] : edges)
        cin >> u >> v >> w;
}

void solve() {
    DisjointSetUnion dsu(n + 1);
    long long result = 0;

    sort(edges.rbegin(), edges.rend());

    for (const auto &[w, u, v] : edges)
        if (dsu.unite(u, v))
            result += w;

    cout << result << '\n';
}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL

    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    solve();

    return 0;
}

Subtask 2: Giới hạn: ~n \le 10^3, m \le 10^4; k \le 1000~

Ý Tưởng:
  • Giới hạn của Subtask 2 cho phép chúng ta thực hiện ý tưởng đề cập trong Subtask 1 ~k~ lần mà vẫn đảm bảo được độ phức tạp thời gian cho phép. Cụ thể hơn, với mỗi thao tác, ta có thể thực hiện lại ý tưởng trong Subtask 1 rồi xóa tập cạnh được chọn bởi thao tác khỏi đồ thị để ta có thể xử lý các thao tác sau. >[!Note] Lưu ý: >Vì sau khi xóa cạnh khỏi đồ thị, đồ thị có thể không liên thông nữa, tập cạnh mà chúng ta cần tìm lúc này có thể là một rừng thay vì chỉ một cây khung: với mỗi thành phần liên thông trong đồ thị hiện tại, ta cần tìm cây khung lớn nhất trong thành phần liên thông đó; tập cạnh chúng ta cần tìm sẽ là tập cạnh được tạo bởi các cạnh của các khung đó.
Độ Phức Tạp:
  • Ý tưởng trên có thể được cài đặt với độ phức tạp ~O(m \log m + k \cdot (n + m \cdot \alpha(n)))~.
  • Độ phức tạp bộ nhớ sẽ tương tự như trong Subtask 1, tức ~O(n + m)~.
Code Minh Họa:
#include <bits/stdc++.h>
using namespace std;

class DisjointSetUnion {
private:

    mutable vector<int> roots;

public:

    DisjointSetUnion(const int n): roots(n, -1) {};

    void reload() {
        fill(roots.begin(), roots.end(), -1);
    }

    int getRoot(const int x) const {
        return (roots[x] < 0) ? x : (roots[x] = getRoot(roots[x]));
    }

    bool checkConnected(const int x, const int y) const {
        return getRoot(x) == getRoot(y);
    }

    bool unite(int x, int y) {
        x = getRoot(x);
        y = getRoot(y);
        if (x == y)
            return false;
        if (roots[x] > roots[y])
            swap(x, y);
        roots[x] += roots[y];
        roots[y] = x;
        return true;
    }

};

constexpr int MAX_N = 1E5 + 5, MAX_M = 1E5 + 5, MAX_K = 1E4 + 4;

int n, m, k;
vector<tuple<int, int, int> > edges;

void input() {
    cin >> n >> m >> k;

    edges.resize(m);

    for (auto &[w, u, v] : edges)
        cin >> u >> v >> w;
}

void solve() {
    DisjointSetUnion dsu(n + 1);
    vector<bool> removed(m);
    long long result = 0;

    sort(edges.rbegin(), edges.rend());

    for (int t = 0; t < k; ++t) {
        result = 0;
        dsu.reload();

        for (int i = 0; i < m; ++i) {
            if (removed[i])
                continue;
            const auto &[w, u, v] = edges[i];
            if (dsu.unite(u, v)) {
                removed[i] = true;
                result += w;
            }
        }

        cout << result << '\n';
    }
}

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL

    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    solve();

    return 0;
}

Subtask 3: Giới hạn: ~n \le 10^3, m \le 5 \times 10^5; k \le 10^4~

Ý Tưởng:
  • Đặt ~S_{i, j}~ (với ~1 \leq i \leq k~ và ~0 \leq j \leq m~) là tập cạnh được chọn trong thao tác thứ ~i~ sau khi đã xét ~j~ cạnh trong danh sách các cạnh được sắp xếp theo trọng số giảm dần. Chi tiết và ví dụ:

    Lưu ý: Phần này trình bày chi tiết cách xác định tập cạnh ~S_{i, j}~ theo định nghĩa trên. Nội dung này có thể không bắt buộc phải đọc để hiểu ý tưởng chính, mà chỉ nhằm làm rõ hơn định nghĩa ~S_{i, j}~.

    Chi Tiết

    • Với ~j = 0~, ta có ~S_{i,j} = \emptyset~ vì chưa có cạnh nào được xét ở thời điểm bắt đầu thao tác thứ ~i~.
    • Với ~1 \leq j \leq m~, đặt ~e~ là cạnh thứ ~j~. Khi đó, ta có thể xác định ~S_{i, j}~ như sau:
      • ~S_{i, j} = S_{i, j - 1}~ nếu một trong hai điều kiện sau thỏa:
        • Cạnh ~e~ đã được chọn trước đó: ~\exists z ((1 \leq z < i) \land (e \in S_{z,j}))~
        • Việc thêm cạnh ~e~ sẽ tạo thành một chu trình trong tập hiện tại: ~S_{i, j - 1} \cup \{e\}~ chứa ít nhất một chu trình.
      • ~S_{i, j} = S_{i, j - 1} \cup \{e\}~ trong trường hợp còn lại.

    Ví dụ

    • Với test ví dụ thứ hai, các cạnh sau khi được sắp xếp giảm dần theo trọng số có thứ tự như sau:

      ~ \begin{array}{|c|*{8}{c|}} \hline \textbf{i} & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 \\ \hline e_i & (1,2) & (2,3) & (3,4) & (4,5) & (1,5) & (2,5) & (1,3) & (2,4) \\ \hline w_i & 9 & 8 & 7 & 6 & 5 & 4 & 3 & 2 \\ \hline \end{array} ~

    • Giá trị ~S_{i, j}~ được tính như bảng sau:

      ~ \begin{array}{|c|*{3}{l|}} \hline \textbf{ j\i } & \textbf{1} & \textbf{2} & \textbf{3} \\ \hline 0 & \emptyset & \emptyset & \emptyset \\ \hline 1 & \{(1,2)\} & \emptyset & \emptyset \\ \hline 2 & \{(1,2), (2,3)\} & \emptyset & \emptyset \\ \hline 3 & \{(1,2), (2,3), (3,4)\} & \emptyset & \emptyset \\ \hline 4 & \{(1,2), (2,3), (3,4), (4,5)\} & \emptyset & \emptyset \\ \hline 5 & \{(1,2), (2,3), (3,4), (4,5)\} & \{(1, 5)\} & \emptyset \\ \hline 6 & \{(1,2), (2,3), (3,4), (4,5)\} & \{(1, 5), (2, 5)\} & \emptyset \\ \hline 7 & \{(1,2), (2,3), (3,4), (4,5)\} & \{(1, 5), (2, 5), (1, 3)\} & \emptyset \\ \hline 8 & \{(1,2), (2,3), (3,4), (4,5)\} & \{(1, 5), (2, 5), (1, 3), (2, 4)\} & \emptyset \\ \hline \end{array} ~

  • Ta nhận thấy rằng nếu cạnh thứ ~j~, gọi là ~e_j~, được chọn trong bất kì một thao tác ~i~ nào thì ~i~ chính là giá trị nhỏ nhất sao cho với mọi thao tác ~z~ trước ~i~ (~1 \leq z < i~), việc thêm ~e_j~ vào ~S_{z, j - 1}~ sẽ tạo thành chu trình. Nếu không, cạnh ~e_j~ đã nên được chọn trước thao tác ~i~. Ta có: ~i = \min\{x \mid \forall z ((1 \leq z < x) \rightarrow C(S_{z, j - 1} \cup \{e_j\})) \}~ với ~C~ là mệnh đề "đồ thị con tạo bởi tập cạnh ~A~ chứa ít nhất một chu trình".

  • Dựa vào nhận xét trên, ta có thể duyệt qua từng cạnh và dùng kỹ thuật chặt nhị phân để xác định thao tác sớm nhất có thể chứa cạnh đó mà không tạo chu trình. Để kiểm tra việc một chu trình có thể xuất hiện hay không khi thêm một cạnh nào đó, với mỗi thao tác, ta sử dụng một cấu trúc dữ liệu để quản lý thành phần liên thông trong suốt quá trình duyệt qua các cạnh. Ý tưởng cài đặt: Ta có thể sử dụng ~k~ cấu trúc dữ liệu Disjoint Set Union để quản lý thành phần liên thông giữa ~n~ đỉnh cho ~k~ thao tác.

Độ Phức Tạp:
  • Ý tưởng trên có thể được cài đặt với độ phức tạp ~O(m \log m + k \cdot n + m \log k \cdot \alpha(n))~.

    Giải thích:
    • Bước sắp xếp cạnh có độ phức tạp ~O(m \log m)~ (tương tự như trong Subtask 1).
    • Việc khởi tạo ~k~ cấu trúc dữ liệu Disjoint Set Union để quản lý thành phần liên thông trên ~n~ đỉnh có độ phức tạp ~O(k \cdot n)~.
    • Bước xử lý các cạnh có độ phức tạp ~O(m \log k \cdot \alpha(n))~.
      • Với mỗi lần xét cạnh, chúng ta tìm được thao tác (nếu tồn tại) mà chứa được cạnh đang xét sau tối đa ~O(\log k)~ bước chặt nhị phân.
      • Với mỗi lần chặt nhị phân, việc kiểm tra việc hai đỉnh của cạnh đang xét có đang thuộc cùng một thành phần liên thông có độ phức tạp ~O(\alpha(n))~ (tương tự như trong Subtask 1).
      • Chúng ta phải xét qua ~O(m)~ cạnh.
      • Ngoài ra, chúng ta có thể phải cập nhật thành phần liên thông cuối mỗi lần xét cạnh với độ phức tạp ~O(\alpha(n))~.

Độ phức tạp tổng vì thế là ~O(m \cdot (\log k \cdot \alpha(n) + \alpha(n))) = O(m \log k \cdot \alpha(n))~

  • Với cách cài đặt này, độ phức tạp bộ nhớ là ~O(m + k \cdot n)~. Giải thích:
    • Lưu danh sách cạnh chiếm cần ~O(m)~ bộ nhớ (tương tự như trong Subtask 1).
    • Lưu ~k~ cấu trúc dữ liệu Disjoint Set Union cần ~O(k \cdot n)~ bộ nhớ.
Code Minh Họa:
#include <bits/stdc++.h>
using namespace std;

class DisjointSetUnion {
private:

    mutable vector<int> roots;

public:

    DisjointSetUnion() {};

    DisjointSetUnion(const int n): roots(n, -1) {};

    int getRoot(const int x) const {
        return (roots[x] < 0) ? x : (roots[x] = getRoot(roots[x]));
    }

    bool checkConnected(const int x, const int y) const {
        return getRoot(x) == getRoot(y);
    }

    bool unite(int x, int y) {
        x = getRoot(x);
        y = getRoot(y);
        if (x == y)
            return false;
        if (roots[x] > roots[y])
            swap(x, y);
        roots[x] += roots[y];
        roots[y] = x;
        return true;
    }

};

constexpr int MAX_N = 1E5 + 5, MAX_M = 1E5 + 5, MAX_K = 1E4 + 4;

int n, m, k;
long long result[MAX_K];
vector<DisjointSetUnion> graphs;
vector<tuple<int, int, int> > edges;

void input() {
    cin >> n >> m >> k;

    edges.resize(m);

    for (auto &[w, u, v] : edges)
        cin >> u >> v >> w;
}

void solve() {
    int low = 0, middle = 0, high = 0;

    graphs.resize(k + 1, DisjointSetUnion(n + 1));

    sort(edges.rbegin(), edges.rend());

    for (const auto &[w, u, v] : edges) {
        low = 0;
        high = k + 1;
        while (low + 1 < high) {
            middle = low + high >> 1;
            if (graphs[middle].checkConnected(u, v))
                low = middle;
            else
                high = middle;
        }
        if (high <= k) {
            graphs[high].unite(u, v);
            result[high] += w;
        }
    }

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

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL

    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    solve();

    return 0;
}

Subtask 4: Giới hạn: ~n \le 10^5, m \le 5 \times 10^5; k \le 10^4~

Ý Tưởng:

Lưu ý: Ý tưởng chính sử dụng trong Subtask này cũng giống như trong Subtask 3. Điểm khác biệt chính nằm ở hướng cài đặt ý tưởng.

  • Nhận xét về độ phức tạp của cách cài đặt cho Subtask 3: Với độ phức tạp của cách cài đặt cho Subtask 3, ta nhận thấy rằng việc quản lý các thành phần liên thông cho từng thao tác, là phần có chi phí tính toán nặng trong trường hợp tệ nhất (với độ phức tạp ~O(k \cdot n)~). Mặt khác, các bước xử lý còn lại có chi phí tính toán chấp nhận được. Vì vậy, nếu ta muốn cải thiện ý tưởng đó, ta cần phải tối ưu những bước xử lý liên quan tới quản lý thành phần liên thông.

  • Ta có một số nhận xét như sau:

    • Với cách cài đặt đề cập trong Subtask 3, ta cần phải quan lý tổng cộng ~k \cdot n~ đỉnh (mỗi cấu trúc dữ liệu Disjoint Set Union quản lý ~n~ đỉnh). Tuy nhiên, trong suốt quá trình xử lý ~m~ cạnh, chỉ có tối đa ~2 \cdot m~ đỉnh thuộc các thành phần liên thông có kích thước lớn hơn ~1~. Giải thích: Mỗi cạnh được xét nếu được thêm vào chỉ có thể làm tăng số lượng đỉnh nằm trong một thành phần liên thông không đơn lẻ thêm tối đa ~2~ đỉnh.

    • Khi kiểm tra hai đỉnh ~u~ và ~v~ khác nhau có thuộc cùng một thành phần liên thông hay không, ta nhận thấy rằng: nếu một trong hai đỉnh thuộc một thành phần có kích thước bằng ~1~ thì ~u~ và ~v~ chắc chắn không cùng thành phần liên thông. ~\Rightarrow~ Dựa trên những nhận xét trên, ta chỉ cần quản lý các thành phần liên thông có kích thước ít nhất là ~2~ đỉnh, và xử lý riêng các trường hợp thành phần liên thông chỉ có một đỉnh. Về mặt cài đặt, ta chỉ cần thêm đỉnh vào cấu trúc dữ liệu Disjoint Set Union khi ta cần gộp thành phần liên thông của đỉnh đó với đỉnh khác.

Độ Phức Tạp
  • Ý tưởng trên có thể được cài đặt với độ phức tạp ~O(m \log m + k + m \log k \cdot \alpha(n))~. Giải thích:

    • Việc quản lý các thành phần liên thông giờ đây chỉ có độ phức tạp ~O(k)~ để khởi tạo và ~O(m)~ để thêm đỉnh khi xử lý tất cả ~m~ cạnh. Vì thế độ phức tạp tổng của cách cài đặt là ~O(m \log m) + O(k) + O(m) + O(m \log k \cdot \alpha(n)) = O(m \log m + k + m \log k \cdot \alpha(n))~
  • Với cách cài đặt này, độ phức tạp bộ nhớ là ~O(m + k)~. Giải thích:

    • Lưu danh sách cạnh chiếm cần ~O(m)~ bộ nhớ như trong Subtask 1.
    • Lưu ~k~ cấu trúc dữ liệu Disjoint Set Union cần ~O(k)~ bộ nhớ và cần thêm ~O(m)~ bộ nhớ do chúng ta có thể thêm tối đa ~2 \cdot m~ đỉnh. ~\Rightarrow~ Độ phức tạp tổng của cách cài đặt là ~O(m) + O(m) + O(k) = O(m + k)~.
Code Minh Họa:
#include <bits/stdc++.h>
using namespace std;

class DisjointSetUnion {
private:

    mutable vector<int> roots;

public:

    DisjointSetUnion() {};

    int getRoot(const int x) const {
        return (roots[x] < 0) ? x : (roots[x] = getRoot(roots[x]));
    }

    bool checkConnected(const int x, const int y) const {
        return getRoot(x) == getRoot(y);
    }

    int addVertex() {
        const int result = roots.size();
        roots.push_back(-1);
        return result;
    }

    bool unite(int x, int y) {
        x = getRoot(x);
        y = getRoot(y);
        if (x == y)
            return false;
        if (roots[x] > roots[y])
            swap(x, y);
        roots[x] += roots[y];
        roots[y] = x;
        return true;
    }

};

class UndirectedGraph {
private:

    unordered_map<int, int> ids;
    DisjointSetUnion dsu;

public:

    bool checkConnected(const int x, const int y) const {
        const auto a = ids.find(x);
        if (a == ids.end())
            return false;
        const auto b = ids.find(y);
        if (b == ids.end())
            return false;
        return dsu.checkConnected(a -> second, b -> second);
    }

    int getID(const int x) {
        const auto a = ids.find(x);
        if (a == ids.end())
            return ids[x] = dsu.addVertex();
        return a -> second;
    }

    bool unite(const int x, const int y) {
        return dsu.unite(getID(x), getID(y));
    }

};

constexpr int MAX_N = 1E5 + 5, MAX_M = 1E5 + 5, MAX_K = 1E4 + 4;

int n, m, k;
long long result[MAX_K];
vector<UndirectedGraph> graphs;
vector<tuple<int, int, int> > edges;

void input() {
    cin >> n >> m >> k;

    edges.resize(m);

    for (auto &[w, u, v] : edges)
        cin >> u >> v >> w;
}

void solve() {
    int low = 0, middle = 0, high = 0;

    graphs.resize(k + 1);

    sort(edges.rbegin(), edges.rend());

    for (const auto &[w, u, v] : edges) {
        low = 0;
        high = k + 1;
        while (low + 1 < high) {
            middle = low + high >> 1;
            if (graphs[middle].checkConnected(u, v))
                low = middle;
            else
                high = middle;
        }
        if (high <= k) {
            graphs[high].unite(u, v);
            result[high] += w;
        }
    }

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

signed main() {
    #ifdef LOCAL
    freopen("input.INP", "r", stdin);
    #endif // LOCAL

    cin.tie(0) -> sync_with_stdio(0);
    cout.tie(0);

    input();
    solve();

    return 0;
}
ntkwan, tuongpq
o14, Tháng 11, 2025, 4:17 0

5

Tạp chí VNOI - Số 1: Coding Challenge #1

ntkwan, tuongpq đã đăng vào 10, Tháng 11, 2025, 13:38

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.

Sau 03 số đầu tiên, Coding Challenge đã ghi nhận những kết quả rất ấn tượng. Đã có tổng cộng 2316 lượt nộp bài trên hệ thống và 26 lời giải chất lượng được gửi về cho Tạp chí. Đây là nguồn động lực to lớn để đội ngũ Tạp chí VNOI tiếp tục phát triển và mở rộng sân chơi học thuật này trong thời gian tới.

Coding Challenge #1

Coding Challenge #1 có tên là Đường đi. Đây là một bài toán về chủ đề đường đi ngắn nhất trên đồ thị, với điểm khác biệt là cách xây dựng tập cạnh thông qua các truy vấn nối và tô màu đỉnh.

Bài toán này chứng kiến 182 độc giả tham gia giải và 30 bài giải đạt điểm tuyệt đối. Đội ngũ Tạp chí VNOI xin phép chọn lọc ra 2 lời giải được các độc giả gửi về cho Tạp chí.

Đề Bài

Đề bài gốc có thể được đọc trên hệ thống VNOJ Coding Challenge #1 - Đường đi

Cho một đồ thị vô hướng gồm ~N~ đỉnh, ban đầu đồ thị này không có cạnh nào. Mỗi đỉnh thứ ~i~ được gán một màu ~c_i~. Bạn cần thực hiện lần lượt ~Q~ thao tác, mỗi thao tác thuộc một trong hai loại sau:

  • Loại 1: 1 x y - nối toàn bộ các đỉnh có màu ~x~ với toàn bộ các đỉnh có màu ~y~.

  • Loại 2: 2 u v - đổi màu của toàn bộ các đỉnh ~i~ có ~c_i = u~ thành ~c_i = v~.

Sau khi thực hiện xong tất cả các thao tác, hãy in ra độ dài đường đi ngắn nhất từ đỉnh ~1~ tới toàn bộ các đỉnh trong đồ thị.

Input

  • Dòng đầu tiên chứa hai số nguyên ~N, Q~ ~(1 \le N, Q \le 3 \times 10^5)~.

  • Dòng thứ hai chứa ~N~ số nguyên ~c_1, c_2, ..., c_N~ - màu ban đầu của từng đỉnh ~(1 \le c_i \le N)~.

  • ~Q~ dòng tiếp theo, mỗi dòng chứa 3 số nguyên ~t, x, y~ mô tả các thao tác ~(1 \le t \le 2, 1 \le x, y \le N)~.

Output

  • In ra ~N~ số nguyên, số thứ ~i~ là độ dài đường đi ngắn nhất từ đỉnh ~1~ tới đỉnh ~i~. Nếu không tồn tại đường đi, in ra ~-1~.

Scoring

Subtask Điểm Giới hạn
~1~ 20% ~N, Q \le 100~
~2~ 30% Chỉ có thao tác loại ~1~
~3~ 20% Tất cả thao tác loại ~2~ xuất hiện trước thao tác loại ~1~
~4~ 30% Không có giới hạn bổ sung

Sample Input 1

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

Sample Output 1

0 1 -1 1 2

Notes

• Ban đầu: chưa có cạnh nào;

• Thao tác 1 1 2: Nối toàn bộ đỉnh màu ~1~ với toàn bộ đỉnh màu ~2~ ~\Rightarrow~ Tạo cạnh giữa các đỉnh ~{1, 5}~ với ~{2, 4}~;

• Thao tác 2 3 2: Đổi toàn bộ đỉnh màu ~3~ thành màu ~2~ ~\Rightarrow~ Đỉnh ~3~ có màu ~2~;

• Thao tác 1 2 3: Không có thay đổi gì vì không còn đỉnh nào có màu ~3~;

• Thao tác 1 1 3: Không có thay đổi gì vì không còn đỉnh nào có màu ~3~.

Sau cùng, đường đi ngắn nhất từ đỉnh ~1~ đến các đỉnh khác là:

• Đỉnh ~1~ ~\rightarrow~ chính nó: ~0~

• Đỉnh ~2~: ~1~ ~\rightarrow~ ~2~ (độ dài ~1~)

• Đỉnh ~3~: Không tồn tại đường đi (~-1~)

• Đỉnh ~4~: ~1~ ~\rightarrow~ ~4~ (độ dài ~1~)

• Đỉnh ~5~: ~1 \rightarrow 2 \rightarrow 5~ (độ dài ~2~)

Minh họa test ví dụ

Minh<em>hoa</em>vi_du

Lời giải #1

Subtask 1
  • Giới hạn bổ sung: ~N, Q \le 100~
Ý tưởng:
  • Vì giới hạn của subtask này là nhỏ, chúng ta có thể giải quyết subtask này bằng cách thực hiện chính xác như những gì đề yêu cầu.

    • Với thao tác loại ~1~ với hai màu ~x~ và ~y~, chúng ta duyệt tuần tự để tìm tập các đỉnh có màu ~x~ và tập các đỉnh có màu ~y~ rồi với mỗi cặp đỉnh từ hai tập, ta thêm cạnh nối hai chiều giữa hai đỉnh.

    • Với thao tác loại ~2~ với hai màu ~u~ và ~v~, chúng ta duyệt tuần tự và gán màu ~v~ cho các đỉnh có màu ~u~

      ~ c[i] \leftarrow v \quad \forall \, (c[i] = u) ~

  • Sau khi thực hiện xong tất cả các thao tác, để tìm ra độ dài đường đi ngắn nhất từ đỉnh ~1~ tới toàn bộ các đỉnh trong đồ thị, ta có thể sử dụng thuật toán BFS từ đỉnh ~1~ trên đồ thị không trọng số dựng được.

Độ phức tạp:
  • Cách giải như trên có độ phức tạp về thời gian là ~O(Q \cdot N^2)~ trong trường hợp xấu nhất

    • Quá trình đọc dữ liệu có độ phức tạp về thời gian là ~O(N + Q)~

    • Với mỗi thao tác loại ~1~, vì mỗi màu có ~O(N)~ đỉnh có màu đó, ta sẽ phải xem xét qua ~O(N^2)~ cặp đỉnh cho hai màu ~x~ và ~y~.

    • Với mỗi thao tác loại ~2~, ta phải quyệt qua ~O(N)~ đỉnh.

    • Bước chạy thuật toán BFS sẽ có độ phức tạp về thời gian là ~O(N + |E|)~ với ~|E|~ là số cạnh của đồ thị sau khi ta đã dựng xong. Vì dữ liệu có thể chứa ~O(Q)~ thao tác loại ~1~, ta có ~|E| = O(Q \cdot N^2)~. Nói cách khác, bước xử lý tìm đường đi ngắn nhất có độ phức tạp ~O(Q \cdot N^2)~

  • Tương tự, độ phức tạp bộ nhớ của cách giải trên là ~O(Q \cdot N^2)~ trong trường hợp tệ nhất bởi ta phải lưu ~O(Q \cdot N^2)~ cạnh đồng thời cùng một lúc

Code minh họa:
#include <bits/stdc++.h>

using namespace std;

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

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

constexpr int MAX_N = 300'005, INF = 0X3F3F3F3F;

int N, Q, c[MAX_N];
vector<int> graph[MAX_N];
signed minimumDistance[MAX_N];

void addEdges(const int x, const int y) {
    for (int i = 1; i <= N; ++i) {
        if (c[i] != x)
            continue;
        for (int j = 1; j <= N; ++j) {
            if (c[j] != y)
                continue;
            graph[i].push_back(j);
            graph[j].push_back(i);
        }
    }
}

void changeColors(const int u, const int v) {
    for (int i = 1; i <= N; ++i)
        if (c[i] == u)
            c[i] = v;
}

void runBFS() {
    queue<int> q;

    minimumDistance[1] = 0;
    q.push(1);

    for (; !q.empty(); q.pop()) {
        const int &x = q.front();
        for (const int &y : graph[x])
            if (minimize(minimumDistance[y], minimumDistance[x] + 1))
                q.push(y);
    }
}

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

    cin >> N >> Q;

    for (int i = 1; i <= N; ++i) {
        minimumDistance[i] = INF;
        cin >> c[i];
    }

    for (int q = 1, t = 0, x = 0, y = 0; q <= Q; ++q) {
        cin >> t >> x >> y;
        if (t == 1)
            addEdges(x, y);
        else
            changeColors(x, y);
    }

    runBFS();

    for (int i = 1; i <= N; ++i)
        cout << ((minimumDistance[i] < INF) ? minimumDistance[i] : (-1)) << ' ';

    cout << '\n';

    return 0;
}
Subtask 2
  • Giới hạn bổ sung: Chỉ có thao tác loại ~1~
Ý tưởng:
  • Với giới hạn của subtask này, ta có nhận xét rằng với hai đỉnh ~x~ và ~y~ đều khác ~1~ và có cùng màu thì đường đi ngắn nhất từ đỉnh ~1~ đến đỉnh ~x~ và ~y~ nên bằng nhau. Nếu ta đặt ~d[i]~ là độ dài đường đi ngắn nhất từ đỉnh ~1~ đến đỉnh ~i~, ta có: ~\forall x, y \, (c[x] = c[y]) \land (x \neq 1) \land (y \neq 1) \rightarrow d[x] = d[y]~

    Chứng minh chi tiết:

    • Giả sử tồn tại hai đỉnh ~x~ và ~y~ đều khác ~1~ và có cùng màu nhưng đường đi ngắn nhất từ đỉnh ~1~ đến hai đỉnh ~x~ và ~y~ lại khác nhau. Không làm mất tính tổng quát, ta xét trường hợp ~d[x] < d[y]~
    • Xét một đường đi ngắn nhất bất kì từ ~1~ đến ~x~, gọi ~z~ là đỉnh ngay trước ~x~ trên đường đi đó (~1 \rightarrow ... \rightarrow z \rightarrow x~). Vì đồ thị chứa cạnh từ ~z~ tới ~x~, danh sách thao tác phải bao gồm ít nhất một thác như sau: 1 c[z] c[x]. Vì đỉnh ~x~ và ~y~ có cùng màu (~c[x] = c[y]~), đồ thị cũng chứa cạnh từ ~z~ tới ~y~. Điều này đồng nghĩa với việc tồn tại đường đi từ ~1~ tới ~y~ với độ dài ~d[z] + 1 = d[x]~ mà ~d[x] < d[y]~ (theo giả định trước đó) ~\Rightarrow~ Tồn tại đường đi từ ~1~ tới ~y~ với độ dài ngắn hơn ~d[y]~; mâu thuẫn với định nghĩa của ~d[y]~
  • Từ nhận xét này, ta nhận thấy rằng trừ đỉnh ~1~, các đỉnh chỉ có thể được phân biệt với nhau bằng màu (nói cách khác, không có sự phân biệt giữa các đỉnh cùng màu). Dựa vào đây, ta có thể dùng màu để đại diện cho các đỉnh đó. Cụ thể hơn, thay vì xây dựng đồ thị như đề bài (với số cạnh trong trường hợp tệ nhất đạt xấp xỉ ~Q \cdot N^2~), ta có thể xây dựng một đồ thị khác ~G' = (V', E')~ có các đỉnh là các màu và có cạnh giữa ~x'~ và ~y'~ nếu tồn tại thao tác 1 x' y'. ~\Rightarrow~ Số cạnh của đồ thị ~G'~ sẽ không vượt quá ~Q~ (~|E'| = O(Q)~), cho phép chúng ta tìm đường đi ngắn nhất giữa đỉnh đại diện cho màu của đỉnh ~1~ và các đỉnh còn lại trong ~V'~ với độ phức tạp cho phép. Nếu đặt ~T = \{(t_i, x_i, y_i) \, | \, 1 \leq i \leq Q\}~ là tập hợp các thao tác sử dụng, ta có:

    • ~V' = \{c[u] \, | \, 1 \leq u \leq N \}~
    • ~E' = \{(x, y) \, | \, ((1, x, y) \in T) \land (x, y \in V') \}~
  • Với các đỉnh ~x~ có màu khác với màu của đỉnh ~1~ (~c[x] \neq c[1]~), độ dài đường đi ngắn nhất từ ~1~ tới ~x~ sẽ bằng với độ dài đường đi ngắn nhất từ ~c[1]~ tới ~c[x]~ trong ~G'~.

  • Với các đỉnh ~x~ không phải là đỉnh ~1~ nhưng có cùng màu với đỉnh ~1~ (~c[x] = c[1]~), ta sẽ có ba trường hợp sau:

    • Tồn tại thao tác 1 c[1] c[1] (nối giữa các đỉnh có màu ~c[1]~ với nhau) ~\Rightarrow~ Độ dài đường đi ngắn nhất từ ~1~ tới ~x~ bằng ~1~.
    • Không tồn tại thao tác như trên nhưng ~c[1]~ có cạnh nối tới màu khác (trong ~G'~) ~\Rightarrow~ Độ dài đường đi ngắn nhất từ ~1~ tới ~x~ bằng ~2~ (ta tốn một bước để từ đỉnh ~1~ đi đến một đỉnh bất kì có màu kề với ~c[1]~ rồi đi đến ~x~).
    • Các điều kiện trên đều không thỏa ~\Rightarrow~ Không tồn tại đường đi giữa ~1~ và ~x~
Độ phức tạp:
  • Cách giải như trên có độ phức tạp về thời gian là ~O(N + Q)~ trong trường hợp xấu nhất bởi việc dựng đồ thị và tìm đường đi ngắn nhất (với thuật toán BFS) có độ phức tạp ~O(N + Q)~.

  • Tương tự, độ phức tạp bộ nhớ của cách giải trên là ~O(N + Q)~ trong trường hợp tệ nhất.

Code minh họa:
#include <bits/stdc++.h>

using namespace std;

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

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

constexpr int MAX_N = 300'005, INF = 0X3F3F3F3F;

int N, Q, c[MAX_N];
bool existed[MAX_N];
vector<int> graph[MAX_N];
signed minimumDistance[MAX_N];

void runBFS() {
    queue<int> q;

    minimumDistance[c[1]] = 0;
    q.push(c[1]);

    for (; !q.empty(); q.pop()) {
        const int &x = q.front();
        for (const int &y : graph[x])
            if (minimize(minimumDistance[y], minimumDistance[x] + 1))
                q.push(y);
    }
}

int main() {
    bool sourceFlag = false;

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

    cin >> N >> Q;

    for (int i = 1; i <= N; ++i) {
        minimumDistance[i] = INF;
        cin >> c[i];
        existed[c[i]] = true;
    }

    for (int q = 1, t = 0, x = 0, y = 0; q <= Q; ++q) {
        cin >> t >> x >> y;
        if (!existed[x] || !existed[y])
            continue;
        graph[x].push_back(y);
        graph[y].push_back(x);
        if (x == c[1] && y == c[1])
            sourceFlag = true;
    }

    runBFS();

    cout << 0 << ' ';

    for (int i = 2; i <= N; ++i) {
        if (c[i] != c[1]) {
            cout << ((minimumDistance[c[i]] < INF) ? minimumDistance[c[i]] : (-1)) << ' ';
            continue;
        }
        cout << (sourceFlag ? 1 : ((!graph[c[1]].empty()) ? 2 : (-1))) << ' ';
    }

    cout << '\n';

    return 0;
}
Subtask 3
  • Giới hạn bổ sung: Tất cả thao tác loại ~2~ xuất hiện trước thao tác loại ~1~
Ý tưởng:
  • Ta nhận thấy rằng, nếu ta có thể cập nhật được màu các đỉnh sau khi xử lý xong các thao tác loại ~2~ (với độ phức tạp hợp lý), bài toán sẽ được đưa về subtask ~2~.

  • Ta có nhận xét rằng mỗi thao tác loại ~2~ có thể được xem như gồm hai bước:

    • Gộp hai tập đỉnh lại thành một tập đỉnh.
    • Đánh số (tên màu) lại tập đỉnh mới. Ví dụ như trong sample test được đưa ra, thao tác 2 3 2 (thao tác thứ ~2~ trong danh sách các thao tác) có thể được xem như việc gộp tập đỉnh ~\{2, 4\}~ (có màu ~2~) và ~\{3\}~ (có màu ~3~) thành tập đỉnh mới ~\{2, 3, 4\}~ và đánh màu ~2~ cho tập đỉnh này.
  • Dựa trên nhật xét này và giới hạn của subtask, chúng ta có thể sử dụng cấu trúc dữ liệu Disjoint Set Union để thực hiện các thao tác loại ~2~ và cập nhật màu của đỉnh.

Lưu Ý: Ý tưởng chỉ có thể giải quyết subtask ~3~ (và subtask ~2~), không thể áp dụng trong mọi trường hợp. Khi các thao tác loại ~1~ và loại ~2~ được thực hiện đan xen, một màu có thể có tập đỉnh thay đổi theo thời gian.

Độ phức tạp:
  • Độ phức tạp về thời gian của ý tưởng nêu trên có thể là ~O(Q \cdot \alpha(N) + N)~ hoặc ~O(Q \cdot \log(N) + N)~ (tùy thuộc vào cách ta cài đặt cấu trúc dữ liệu Disjoint Set Union):

    • Nếu cấu trúc dữ liệu Disjoint Set Union được cài đặt với cả hai phương pháp gộp theo kích cỡ và nén đường đi thì độ phức tạp trung bình cho mỗi lần gộp tập đỉnh là ~O(\alpha(N))~ (với ~\alpha(N)~ là hàm Ackermann nghịch đảo). Nếu chỉ một trong hai phương pháp được sử dụng thì độ phức tạp cho mỗi lần gộp tập đỉnh là ~O(\log(N))~.
    • Độ phức tạp của các bước xử lý còn lại của cách giải đã nêu sẽ tương tự với cách giải cho subtask ~2~ trước đó, tức ~O(N + Q)~.
  • Độ phức tạp về bộ nhớ của cách giải trên sẽ tương tự với subtask ~2~, tức ~O(N + Q)~.

Code minh họa:
#include <bits/stdc++.h>

using namespace std;

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

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

constexpr int MAX_N = 300'005, INF = 0X3F3F3F3F;

class DisjointSetUnion {
private:

    mutable vector<int> roots;

public:

    DisjointSetUnion() {};

    void resize(const int n) {
        roots.resize(n, -1);
    }

    int getRoot(const int x) const {
        return (roots[x] < 0) ? x : (roots[x] = getRoot(roots[x]));
    }

    int unite(int x, int y) {
        x = getRoot(x);
        y = getRoot(y);
        if (x == y)
            return x;
        if (roots[x] > roots[y])
            swap(x, y);
        roots[x] += roots[y];
        return roots[y] = x;
    }

};

signed roots[MAX_N], minimumDistance[MAX_N];
vector<int> graph[MAX_N];
DisjointSetUnion dsu;
int N, Q, c[MAX_N];

void runBFS() {
    queue<int> q;

    minimumDistance[c[1]] = 0;
    q.push(c[1]);

    for (; !q.empty(); q.pop()) {
        const int &x = q.front();
        for (const int &y : graph[x])
            if (minimize(minimumDistance[y], minimumDistance[x] + 1))
                q.push(y);
    }
}

int main() {
    bool firstTypeOperationPhase = false, sourceFlag = false;

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

    cin >> N >> Q;

    dsu.resize(N + 1);

    for (int i = 1; i <= N; ++i) {
        cin >> c[i];
        minimumDistance[i] = INF;
        int &root = roots[c[i]];
        root = (root ? dsu.unite(root, i) : i);
    }

    for (int q = 1, t = 0, x = 0, y = 0; q <= Q; ++q) {
        cin >> t >> x >> y;
        if (t == 2) {
            int &rootX = roots[x];
            if (rootX == 0)
                continue;
            int &rootY = roots[y];
            rootY = (rootY ? dsu.unite(rootX, rootY) : rootX);
            rootX = 0;
            continue;
        }
        if (!firstTypeOperationPhase) {
            for (int i = 1; i <= N; ++i)
                if (roots[i])
                    c[dsu.getRoot(roots[i])] = i;
            for (int i = 1; i <= N; ++i)
                c[i] = c[dsu.getRoot(i)];
            firstTypeOperationPhase = true;
        }
        if (roots[x] == 0 || roots[y] == 0)
            continue;
        graph[x].push_back(y);
        graph[y].push_back(x);
        if (x == c[1] && y == c[1])
            sourceFlag = true;
    }

    runBFS();

    cout << 0 << ' ';

    for (int i = 2; i <= N; ++i) {
        if (c[i] != c[1]) {
            cout << ((minimumDistance[c[i]] < INF) ? minimumDistance[c[i]] : (-1)) << ' ';
            continue;
        }
        cout << (sourceFlag ? 1 : ((!graph[c[1]].empty()) ? 2 : (-1))) << ' ';
    }

    cout << '\n';

    return 0;
}
Subtask 4
Ý tưởng:
  • Việc xử lý trên đồ thị xây dựng như ở subtask ~1~ yêu cầu độ phức tạp thời gian lớn bởi đồ thị sẽ chứa rất nhiều cạnh (~O(Q \cdot N^2)~ cạnh). Tuy nhiên, ta nhận thấy rằng các thao tác này chỉ phụ thuộc vào mối quan hệ giữa các tập đỉnh chứ không bắt buộc phải giữa từng cặp đỉnh cụ thể. Do đó, ta có thể tìm cách xây dựng một đồ thị nén, trong đó:

    • Khoảng cách ngắn nhất từ đỉnh ~1~ đến cách đỉnh khác trong đồ thị gốc vẫn được giữ nguyên trong đồ thị mới này.
    • Số lượng cạnh không quá lớn.
  • Để xây dựng một đồ thị có ít cạnh hơn nhưng vẫn giữ nguyên khoảng cách ngắn nhất, ta có thể tạo các đỉnh đại diện cho từng tập đỉnh cùng màu (ở một thời điểm).

    Chúng ta có thể xem xét một đồ thị ví dụ như sau để dễ hình dung ý tưởng hơn. Slide1 Giả sử ta cần thêm các cạnh nối từ tất cả các đỉnh xanh đến tất cả các đỉnh cam. Mục tiêu của ta không phải là tạo đủ mọi cạnh giữa hai tập, mà chỉ cần đảm bảo rằng tồn tại đường đi có độ dài 1 từ tập xanh đến tập cam. Thay vì thêm ~r \times b~ cạnh (với ~r~ là số đỉnh xanh và ~b~ là số đỉnh cam), ta tạo hai đỉnh mới:

    • Đỉnh ~B~ với ý nghĩa là với mọi đỉnh có thể đến được từ đỉnh ~B~ thì cũng có thể đến được từ các đỉnh xanh tới tổng trọng số không đổi.
    • Đỉnh ~R~ với ý nghĩa là với mọi đỉnh có thể đến được đỉnh ~R~ thì cũng có thể đến được các đỉnh cam tới tổng trọng số không đổi. Sau đó:
    • Thêm các cạnh có trọng số ~0~ từ mỗi đỉnh xanh tới ~B~, và từ ~R~ tới mỗi đỉnh cam.
    • Thêm một cạnh có trọng số ~1~ từ ~B~ tới ~R~. Khi đó, đi từ một đỉnh xanh ~x~ tới một đỉnh cam ~y~ tương đương với đi qua đường ~x \rightarrow B \rightarrow R \rightarrow y~ có tổng trọng số ~1~. Bằng cách nén như vậy, số cạnh giảm từ ~r \times b~ xuống còn ~r + b + 1~.
  • Mở rộng từ ý tưởng như trên, với mỗi tập đỉnh tại một điểm trong quá trình thực hiện các thao tác, ta sẽ tạo hai đỉnh đi và về (tương tự đỉnh ~B~ và ~R~ trong ví dụ trên) để đại diện cho tập đó. Cụ thể hơn:

    • Với mỗi màu và tập đỉnh của nó ở thời điểm ban đầu (khi chưa thực hiện bất kì thao tác nào trong danh sách), ta tạo hai đỉnh cùng cạnh trọng số ~0~ tương ứng (như ví dụ trên).
    • Khi ta xử lý thao tác loại ~2~, ta (có thể) sẽ có một tập đỉnh mới. Trong trường hợp đó, ta cũng tạo hai đỉnh mới đại diện cho tập đỉnh của màu sau khi được gộp lại và thực hiện nối như sau:
      • Ta nối các đỉnh đi của hai tập cũ đến đỉnh đi mới với trọng số ~0~.
      • Ta nối đỉnh về mới đến các đỉnh về của hai tập cũ với trọng số ~0~.
    • Khi ta xử lý thao tác loại ~1~, ta chỉ cần thêm cạnh trọng số ~1~ giữa đỉnh đến và đỉnh đi của các tập đỉnh được xem xét trong thác tác (ta sẽ thêm tối đa hai cạnh bởi cạnh bởi cạnh trong đồ thị gốc là vô hướng).

slow<em>subtask</em>04

  • Sau khi ta dựng xong đồ thị (sau khi xử lý xong hết các thao tác trong danh sách), để tìm đường đi ngắn nhất từ đỉnh ~1~ đến các đỉnh còn lại, ta có thể sử dụng thuật toán BFS 0-1 bởi các cạnh của đồ thị có trọng số ~0~ hoặc ~1~ (hoặc thuật toán Dijkstra nhưng với độ phức tạp lớn hơn).

Ý tưởng cho subtask này có nhiều điểm tương đồng với cách xây dựng Kruskal Reconstruction Tree.

Độ phức tạp:
  • Độ phức tạp về thời gian của ý tưởng nêu trên là ~O(N + Q)~

    • Việc dựng đồ thị có độ phức tạp ~O(N + Q)~ bởi:
      • Khi dựng đỉnh mới và cạnh ở thời điểm ban điểm, ta sẽ thêm ~C~ đỉnh và ~2 \cdot N~ cạnh với ~C~ là số màu phân biệt ban đầu (~1 \leq C \leq N~; với mỗi đỉnh ban đầu, ta sẽ thêm hai cạnh mới).
      • Với mỗi thao tác loại ~2~, ta thêm tối đa hai đỉnh và bốn cạnh. Ta có ~O(Q)~ thao tác loại ~2~.
      • Với mỗi thao tác loại ~1~, ta thêm tối đa hai cạnh. Ta có ~O(Q)~ thao tác loại ~1~.
    • Thuật toán BFS 0-1 có độ phức tạp ~O(N + Q)~ vì đồ thị được dựng lên có ~O(N + Q)~ đỉnh và cạnh (nếu ta sử dụng thuật toán Dijkstra, độ phức tạp sẽ là ~O((N + Q)\log(N + Q) + N + Q)~).
  • Tương tự, ý tưởng nêu trên có độ phức tạp về bộ nhớ là ~O(N + Q)~.

Code minh họa:
#include <bits/stdc++.h>

using namespace std;

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

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

constexpr int MAX_N = 3E5 + 5, MAX_VERTEX_COUNT = 1E6 + 6, INF = 0X3F3F3F3F;

int N, Q, c[MAX_N];
bool minimized[MAX_VERTEX_COUNT];
pair<int, int> vertexPairs[MAX_N];
vector<pair<int, bool> > graph[MAX_VERTEX_COUNT];
signed vertexCount, minimumDistance[MAX_VERTEX_COUNT];

void runBFS() {
    deque<int> q;

    for (int i = 2; i <= vertexCount; ++i)
        minimumDistance[i] = INF;

    q.push_back(1);

    for (; !q.empty();) {
        const int x = q.front();
        q.pop_front();
        if (minimized[x])
            continue;
        minimized[x] = true;
        for (const auto &[y, w] : graph[x])
            if (minimize(minimumDistance[y], minimumDistance[x] + w)) {
                if (w)
                    q.push_back(y);
                else
                    q.push_front(y);
            }
    }
}

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

    cin >> N >> Q;

    vertexCount = N;

    for (int i = 1; i <= N; ++i) {
        cin >> c[i];
        auto &[source, sink] = vertexPairs[c[i]];
        if (source <= 0) {
            source = ++vertexCount;
            sink = ++vertexCount;
        }
        graph[i].emplace_back(source, 0);
        graph[sink].emplace_back(i, 0);
    }

    for (int q = 1, t = 0; q <= Q; ++q) {
        cin >> t;
        if (t == 1) {
            int x = 0, y = 0;
            cin >> x >> y;
            const auto &[sourceX, sinkX] = vertexPairs[x];
            const auto &[sourceY, sinkY] = vertexPairs[y];
            if (sourceX && sinkY) {
                graph[sourceX].emplace_back(sinkY, 1);
                graph[sourceY].emplace_back(sinkX, 1);
            }
        } else {
            int u = 0, v = 0;
            cin >> u >> v;
            const auto [sourceU, sinkU] = vertexPairs[u];
            const auto [sourceV, sinkV] = vertexPairs[v];
            auto &[source, sink] = vertexPairs[v];
            vertexPairs[u] = make_pair(0, 0);
            source = ++vertexCount;
            sink = ++vertexCount;
            graph[sourceU].emplace_back(source, 0);
            graph[sourceV].emplace_back(source, 0);
            graph[sink].emplace_back(sinkU, 0);
            graph[sink].emplace_back(sinkV, 0);
        }
    }

    runBFS();

    for (int i = 1; i <= N; ++i)
        cout << ((minimumDistance[i] < INF) ? minimumDistance[i] : (-1)) << ' ';

    cout << '\n';

    return 0;
}
Trình Chấm (tự viết):
import time
import shutil
import random
import subprocess
from abc import ABC
from pathlib import Path
from typing import List, Tuple

class TestGenerator(ABC):

    MIN_N: int = 1
    MAX_N: int = 300_000
    MIN_Q: int = 1
    MAX_Q: int = 300_000

    def __init__(self):
        self.N: int = 0
        self.Q: int = 0
        self.c: List[int] = []
        self.operations: List[Tuple[int, int, int]] = []
        self.generate()

    def generate(self):
        self.N = random.randint(self.MIN_N, self.MAX_N)
        self.Q = random.randint(self.MIN_Q, self.MAX_Q)
        self.c = [random.randint(1, self.N) for _ in range(self.N)]
        self.operations = self._generate_operations()

    def _generate_operations(self) -> List[Tuple[int, int, int]]:
        return [self._generate_random_operation(random.randint(1, 2)) for _ in range(self.Q)]

    def _generate_random_operation(self, operation_type: int) -> Tuple[int, int, int]:
        return (operation_type, random.randint(1, self.N), random.randint(1, self.N))

    def write_to_file(self, filename: str) -> None:
        path = Path(filename)
        path.parent.mkdir(parents = True, exist_ok = True)
        with path.open('w') as f:
            f.write(f"{self.N} {self.Q}\n")
            f.write(" ".join(map(str, self.c)) + "\n")
            for t, x, y in self.operations:
                f.write(f"{t} {x} {y}\n")

class Subtask1TestGenerator(TestGenerator):
    MAX_N = 100
    MAX_Q = 100

class Subtask2TestGenerator(TestGenerator):
    def _generate_operations(self) -> List[Tuple[int, int, int]]:
        return [self._generate_random_operation(1) for _ in range(self.Q)]

class Subtask3TestGenerator(TestGenerator):
    def _generate_operations(self) -> List[Tuple[int, int, int]]:
        second_type_operation_count = random.randint(0, self.Q)
        return [self._generate_random_operation(2 if i < second_type_operation_count else 1) for i in range(self.Q)]

class Subtask4TestGenerator(TestGenerator):
    pass

class Validator:

    TIME_LIMIT_IN_SECONDS = 1
    VERDICTS = ["AC", "WA", "TLE", "RE"]

    def __init__(self, tested_program_path: str, correct_program_path: str):
        self.tested_program = Path(tested_program_path)
        self.correct_program = Path(correct_program_path)
        for program in (self.tested_program, self.correct_program):
            if not program.exists():
                raise FileNotFoundError(f"Program not found: {program}")
            if not program.suffix.lower() == '.exe':
                raise ValueError(f"Program must be an .EXE file: {program}")

    def run_program(self, program_path: Path, input_file: Path, output_file: Path) -> Tuple[str, float, bool]:
        program_directory = program_path.parent
        destination_input = program_directory / "input.INP"
        shutil.copy(input_file, destination_input)
        destination_output = program_directory / output_file.name
        if destination_output.exists():
            destination_output.unlink()
        start_time = time.time()
        try:
            process = subprocess.run(
                [str(program_path)],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                timeout=self.TIME_LIMIT_IN_SECONDS,
                text=True,
                cwd=str(program_directory)
            )
            execution_time = time.time() - start_time

            if process.returncode != 0:
                return "RE", execution_time, False

            if destination_output.exists():
                with destination_output.open('r') as f:
                    output = f.read().strip()
            else:
                output = process.stdout.strip()

            return "OK", execution_time, True, output

        except subprocess.TimeoutExpired:
            return "TLE", self.TIME_LIMIT_IN_SECONDS, False, ""
        except Exception as e:
            return "RE", time.time() - start_time, False, str(e)
        return "IE", time.time() - start_time, False, ""

    def compare_outputs(self, tested_output: str, correct_output: str) -> str:
        tested_lines = [line.strip() for line in tested_output.splitlines() if line.strip()]
        correct_lines = [line.strip() for line in correct_output.splitlines() if line.strip()]

        if tested_lines == correct_lines:
            return "AC"
        return "WA"

    def validate_test(self, input_file: str) -> Tuple[str, float]:
        input_path = Path(input_file)
        input_directory = input_path.parent

        correct_verdict, correct_time, correct_success, correct_output = self.run_program(
            self.correct_program, input_path, self.correct_program.parent / "output.OUT"
        )
        if not correct_success:
            return f"Correct program failed: {correct_verdict}", correct_time

        answer_file = input_directory / "answer.txt"
        with answer_file.open('w') as f:
            f.write(correct_output)

        tested_verdict, tested_time, tested_success, tested_output = self.run_program(
            self.tested_program, input_path, self.tested_program.parent / "output.OUT"
        )
        if not tested_success:
            return tested_verdict, tested_time

        output_file = input_directory / "output.out"
        with output_file.open('w') as f:
            f.write(tested_output)

        verdict = self.compare_outputs(tested_output, correct_output)
        return verdict, tested_time

SUBTASK_GENERATORS = [
    Subtask1TestGenerator,
    Subtask2TestGenerator,
    Subtask3TestGenerator,
    Subtask4TestGenerator,
]

SUBTASK_TEST_PERCENTAGES = [20, 30, 20, 30]

TOTAL_TEST_COUNT = 100

if __name__ == "__main__":
    random.seed(20251004)
    subtask_test_counts = [TOTAL_TEST_COUNT * percentage // 100 for percentage in SUBTASK_TEST_PERCENTAGES]
    validator = Validator(
        tested_program_path = "programs/tested_program/main.exe",
        correct_program_path = "programs/correct_program/main.exe",
    )
    overall_summary = {}
    for verdict in Validator.VERDICTS:
        overall_summary[verdict] = 0
    for subtask_index in range(len(SUBTASK_GENERATORS)):
        generator = SUBTASK_GENERATORS[subtask_index]()
        subtask_summary = {}
        for verdict in Validator.VERDICTS:
            subtask_summary[verdict] = 0
        for test_index in range(subtask_test_counts[subtask_index]):
            filename = f"tests/subtask{subtask_index + 1:02d}/test{test_index + 1:02d}/input.INP"
            generator.generate()
            generator.write_to_file(filename)
            verdict, tested_time = validator.validate_test(filename)
            subtask_summary[verdict] += 1
            print(f"Subtask {subtask_index + 1}, Test {test_index + 1}: Verdict = {verdict}, Time = {tested_time:.3f}s")
        print(f"Subtask {subtask_index + 1} Summary:")
        for verdict, count in subtask_summary.items():
            overall_summary[verdict] += count
            print(f"\t{verdict}: {count} tests")
    print("Overall Summary:")
    for verdict, count in overall_summary.items():
        print(f"\t{verdict}: {count} tests")

Lời giải #2

Subtask ~1~: ~N, Q \le 100~

Đây là subtask đơn giản nhất của bài toán. Đối với subtask này, ta chỉ cần xây dựng đồ thị theo yêu cầu của bài toán là giải được.

Việc tìm đường đi ngắn nhất từ đỉnh ~1~ có thể được thực hiện bằng thuật toán BFS.

Độ phức tạp của thuật toán và các subtask nói chung là bằng ~\mathcal{O}(A + B)~ với ~A~ là thời gian xây dựng đồ thị, còn ~B~ là thời gian thực hiện tìm đường đi ngắn nhất tới các đỉnh.

Đối với subtask này, ta có độ phức tạp ~\mathcal{O}(A + B)~ với ~A = \mathcal{O}(Q \times N^2), B = \mathcal{O}(|V| + |E|)~ với ~|V| = \mathcal{O}(N), |E| = \mathcal{O}(N^2)~.

Subtask ~2~: chỉ có thao tác loại ~1~

Cách nối các cặp đỉnh là không hiệu quả với giới hạn cao (~N \le 3 \times 10^5~) nên ta cần một hướng tiếp cận khác.

Ta sẽ chia các đỉnh dựa trên màu sắc của chúng. Mỗi nhóm màu sắc sẽ có hai đỉnh ảo: một đỉnh ảo đi vào và một đỉnh ảo đi ra.

Ở hình minh hoạ dưới đây, các đỉnh được nhóm theo màu, các đỉnh màu xanh lá là các đỉnh ảo đi ra còn màu đỏ là các đỉnh ảo đi vào nhóm màu đó.

chia nhóm

Với mỗi truy vấn loại ~1~, ta sẽ xây dựng các cung nối các đỉnh ảo như sau:

  • Nối đỉnh ảo đi ra của màu ~x~ với đỉnh ảo đi vào của màu ~y~
  • Nối đỉnh ảo đi ra của màu ~y~ với đỉnh ảo đi vào của màu ~x~

Hình minh hoạ dưới đây là đồ thị sau truy vấn 1 1 2.

nối hai màu

Trọng số của các cung được phân định như nhau: các cung nối đỉnh thực với đỉnh ảo hoặc đỉnh ảo với đỉnh thực có trọng số là ~0~, các cung nối các đỉnh ảo với nhau có trọng số là ~1~. Với các giá trị trọng số này, việc tìm đường đi ngắn nhất từ đỉnh ~1~ có thể được thực hiện bằng thuật toán BFS 0 - 1.

Đối với subtask ~2~, ta có độ phức tạp ~\mathcal{O}(A + B)~ với ~A = \mathcal{O}(Q), B = \mathcal{O}(|V| + |E|)~ với ~|V| = \mathcal{O}(N), |E| = \mathcal{O}(Q)~.

Subtask ~3~: Tất cả thao tác loại ~2~ xuất hiện trước thao tác loại ~1~

Các thao tác loại ~2~ là các thao tác thay đổi màu sắc của các đỉnh trên đồ thị. Vì vậy, với các thao tác loại ~2~ xuất hiện trước, ta chỉ cần thay đổi các giá trị màu sắc thành màu mới tương ứng. Sau đó, khi còn lại các thao tác loại ~1~, ta thực hiện giống như subtask ~2~.

Giống như subtask ~2~, ta có độ phức tạp ~\mathcal{O}(A + B)~ với ~A = \mathcal{O}(Q), B = \mathcal{O}(|V| + |E|)~ với ~|V| = \mathcal{O}(N), |E| = \mathcal{O}(Q)~.

Subtask ~4~: không có giới hạn bổ sung

Đối với subtask cuối cùng, hướng giải quyết sẽ tương tự với subtask ~2~, tuy nhiên sẽ có đôi chút điểm khác biệt.

Đối với các thao tác loại ~1~, việc thêm các cạnh và trọng số sẽ giống hệt như subtask ~2~.

Còn với các thao tác loại ~2~, ta xử lí như sau: tạo hai đỉnh ảo vào và ra mới cho màu ~v~. Sau đó, ta tạo cung nối đỉnh ra cũ của hai màu ~u, v~ với đỉnh ra mới của màu ~v~, và tạo cung nối đỉnh vào mới của màu ~v~ với đỉnh ra cũ của hai màu ~u, v~. Các cung mới này có trọng số là ~0~.

Ở hình minh hoạ này mô tả đồ thị sau truy vấn thao tác loại ~2~ 2 2 3.

Nhóm màu

Sau khi nối xong, ta coi đỉnh ảo vào và ra cũ của ~u, v~ "hết hạn sử dụng" - không coi các đỉnh ảo này là đại diện cho các nhóm các đỉnh chung màu nữa.

Một lưu ý nữa là nếu không có nhóm điểm màu ~u~ hoặc ~v~ hoặc cả hai thì ta không cần thiết phải nối các đỉnh ảo của nhóm màu đấy với các đỉnh ảo mới của ~v~.

Sau khi xây dựng xong, ta có thể tìm đường đi ngắn nhất từ đỉnh ~1~ bằng BFS 0 - 1.

Giống như subtask ~2~ và ~3~, ta có độ phức tạp ~\mathcal{O}(A + B)~ với ~A = \mathcal{O}(Q), B = \mathcal{O}(|V| + |E|)~ với ~|V| = \mathcal{O}(N), |E| = \mathcal{O}(Q)~.

Xin trân trọng cảm ơn các tác giả đã đồng hành cùng chuyên mục. Các tác giả có bài giải được đăng trong Tạp chí VNOI - Số 1 đã được đội ngũ Tạp chí VNOI liên hệ để nhận phần quà tri ân từ VNOI.

ntkwan, tuongpq
o10, Tháng 11, 2025, 13:38 0

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