• SQRTOJ
  • Trang chủ
  • Bài
  • Các bài nộp
  • Thành viên
  • Các kỳ thi
  • Thông tin
    >
    • Máy chấm
    • Custom Checkers
    • Github
  • Fanpage
  • Group
  • shop
VI EN Đăng nhập  hoặc  Đăng ký

Blog - Trang 1

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

4

Thi thử HSG THCS - Xuân Bính Ngọ 2026

Sunlight đã đăng vào 7, Tháng 2, 2026, 4:49

Xin chào các bạn,

Thời điểm năm hết Tết đến cũng là lúc mà các contest thi thử HSG9 của câu lạc bộ SQRT quay trở lại. Với xuân Bính Ngọ năm nay, chúng mình mời các bạn tham gia chuỗi 4 contest của chúng mình để có thể nhận được những phong bao lì xì cực kỳ hấp dẫn nhân dịp đầu năm mới nhé.

Các bạn hãy cùng chúng mình điểm qua những thông tin chính của chuỗi contest năm nay nào :D

Thời gian diễn ra contest

Chuỗi contest sẽ kéo dài trong 4 ngày, từ ngày 10 đến 13 tháng 2 năm 2026. Contest của mỗi ngày sẽ bắt đầu vào 20 giờ và kết thúc vào 22 giờ 30.

  • Ngày thi thứ nhất: 20h00 - 22h30 ngày 10 tháng 2 năm 2026, diễn ra tại đây.
  • Ngày thi thứ hai: 20h00 - 22h30 ngày 11 tháng 2 năm 2026, diễn ra tại đây.
  • Ngày thi thứ ba: 20h00 - 22h30 ngày 12 tháng 2 năm 2026, diễn ra tại đây.
  • Ngày thi thứ tư: 20h00 - 22h30 ngày 13 tháng 2 năm 2026, diễn ra tại đây.

Kết quả sẽ được công bố vào tối ngày 14 tháng 2 năm 2026 trên server Discord SQRT Community.

Nộp bài và chấm điểm

  • Đề bài của mỗi ngày thi bao gồm nhiều bài tập. Với mỗi bài tập, thí sinh được nộp bài không giới hạn số lần.
  • Kết quả được ghi nhận là kết quả của lần nộp cuối cùng không bị Lỗi biên dịch (Compile Error).
  • Trong thời gian thi, bài nộp của thí sinh chỉ được chấm với test ví dụ. Sau khi kỳ thi kết thúc, các bài nộp sẽ được chấm lại với test chính thức.
  • Đề thi gồm từ 3 đến 5 bài, có tổng điểm tối đa là 20 điểm. Mỗi bài tập có nhiều test chấm. Điểm của mỗi bài tính theo từng test mà thí sinh đưa ra kết quả đúng.

Điểm chung cuộc và xét giải

  • Điểm chung cuộc của mỗi thí sinh được tính bằng tổng điểm của 4 vòng thi mà thí sinh tham gia, trong đó điểm của vòng thi có điểm số cao nhất sẽ được nhân đôi. (Ví dụ, nếu một thí sinh đạt được số điểm ở các vòng thi lần lượt là 11, 12, 15, 13 thì điểm chung cuộc của thí sinh đó là ~11 + 12 + 15 \times 2 + 13 = 67~ điểm).
  • Thí sinh được xét giải như sau:
    • Giải Nhất: không quá ~2\%~ số thí sinh tham dự kỳ thi, trị giá ~50.650~ VNĐ mỗi giải.
    • Giải Nhì: không quá ~4\%~ số thí sinh tham dự kỳ thi, trị giá ~30.390~ VNĐ mỗi giải.
    • Giải Ba: không quá ~6\%~ số thí sinh tham dự kỳ thi, trị giá ~20.260~ VNĐ mỗi giải.
    • Giải Khuyến khích: không quá ~8\%~ số thí sinh tham dự kỳ thi, trị giá ~10.130~ VNĐ mỗi giải.
  • Các giải phụ:
    • Lucky AC (tính cho mỗi bài nộp nhận kết quả Accepted): ~10~ giải, mỗi giải trị giá ~5.065~ VNĐ.
    • Lucky Solution (tính cho mỗi bài nộp nhận điểm lớn hơn ~0~): ~10~ giải, mỗi giải trị giá ~5.065~ VNĐ.
    • Các submission có mã số đẹp:
      • Mã số chia hết cho ~1000~ (57000, 58000, ...): ~5.065~ VNĐ mỗi giải.
      • Mã số chia hết cho ~10000~ (60000, ...): ~20.260~ VNĐ mỗi giải.
      • Mã số chỉ gồm các chữ số ~6, 9, 4, 2, 0~: ~5.065~ VNĐ mỗi giải, ~20.260~ nếu mã số gồm tất cả các chữ số trên.

(Một thí sinh được tính là dự thi nếu tham gia ít nhất một trong bốn contest và có điểm chung cuộc lớn hơn 0. Với các giải Lucky AC và Lucky Solution, nếu thí sinh nộp bài nhiều lần cho một bài tập thì chỉ tính lần nộp cuối cùng)

Minigame

Dự đoán số lượng thí sinh dự thi: Các bạn comment dưới bài viết này tổng số thí sinh dự thi theo dự đoán của các bạn. Bạn có kết quả tốt nhất (chênh lệch so với số lượng thực tế thấp nhất) sẽ nhận được giải thưởng ~20.260~ VNĐ.

Sunlight
o7, Tháng 2, 2026, 4:49 9

4

[CÔNG BỐ KỲ THI QHHOJ X SQRT CUP 2025]

Sunlight đã đăng vào 30, Tháng 6, 2025, 9:15

Xin chào các bạn,

Nếu các bạn từng theo dõi TLEOJ và câu lạc bộ TLE, chắc hẳn các bạn đã biết về TLEOJ Cup, giải đấu thường niên được tổ chức bởi Câu lạc bộ TLE. Năm nay, chúng mình, câu lạc bộ SQRT, cùng với sự hỗ trợ của câu lạc bộ Tin học trường THPT Chuyên Quốc học Huế mang đến cho các bạn một giải đấu mới mang tên QHHOJ x SQRT Cup 2025, hứa hẹn sẽ là một sân chơi chất lượng và bổ ích cho các bạn học sinh đam mê Lập trình thi đấu.

QHHOJ x SQRT Cup 2025 bao gồm 3 vòng thi:

  • Vòng loại thứ nhất: 20 giờ 00, ngày 05 tháng 7 năm 2025.
  • Vòng loại thứ hai: 20 giờ 00, ngày 19 tháng 7 năm 2025.
  • Vòng chung kết: dự kiến tổ chức vào ngày 11 và 12 tháng 8 năm 2025. Đặc biệt trong năm nay, vòng chung kết dự kiến sẽ được tổ chức trực tiếp tại trường THPT Chuyên Quốc học Huế.
  • Năm nay, giải đấu sẽ được chia làm 2 bảng, mỗi bảng chọn 15 thí sinh có thành tích xuất sắc nhất tham dự vòng chung kết:
  • Bảng Không chuyên: dành cho học sinh THCS và THPT không chuyên Tin (tính đến hết năm học 2024 - 2025).
  • Bảng Chuyên: dành cho học sinh THPT chuyên Tin và sinh viên Đại học. Còn chần chờ gì nữa, đăng ký ngay thôi nào!!!

Link đăng ký:

  • Bảng Chuyên: thi trên hệ thống SQRTOJ:
    • Vòng loại thứ nhất: https://sqrtoj.edu.vn/contest/028
    • Vòng loại thứ hai: https://sqrtoj.edu.vn/contest/029
  • Bảng Không chuyên: thi trên hệ thống QHHOJ:
    • Vòng loại thứ nhất: https://qhhoj.com/contest/sqrtcup25r1
    • Vòng loại thứ hai: https://qhhoj.com/contest/sqrtcup25r2

Thể lệ cuộc thi: https://drive.google.com/file/d/1UWPIX6Rii7aiJNLIqTvFr913j33Ydj1R/view?usp=sharing

Ngoài ra, Ban tổ chức cũng sẽ đăng các tài liệu / nội dung khác lên đây: https://drive.google.com/drive/folders/1nNQziKwG8OH6DWzAZTRd82SVLd6P8K4L?usp=drive_link

Sunlight
o30, Tháng 6, 2025, 9:15 0

2

Câu lạc bộ SQRT

Sunlight đã đăng vào 22, Tháng 6, 2025, 17:03

Xin chào các bạn, chúng mình là câu lạc bộ SQRT.

SQRT được thành lập vào tháng 8 năm 2024 bởi một số bạn học sinh trên cả nước, với tiền thân là câu lạc bộ TLE. Đây là một câu lạc bộ phi lợi nhuận, được lập ra với mục đích chia sẻ kiến thức và phát triển cộng đồng Tin học Việt Nam. Chắc hẳn những bạn biết đến câu lạc bộ SQRT đều biết đến hệ thống chấm bài online của chúng mình – SQRT Online Judge (https://sqrtoj.edu.vn/), dự án chính của câu lạc bộ SQRT. Tuy nhiên, chúng mình vẫn có những nội dung khác mà các bạn có thể tìm thấy tại:

  • Fanpage: SQRT Online Judge (https://www.facebook.com/sqrtoj).
  • Discord: SQRT Community (https://discord.gg/R2hhGGDQvf) – đổi tên từ server Discord của câu lạc bộ TLE, hiện có hơn 1100 thành viên.
  • Facebook group: SQRT Online Judge – SQRTOJ (https://www.facebook.com/groups/1379271806818201) – đang phát triển.

Nếu các bạn là giáo viên dạy Tin học và muốn có một nền tảng chấm bài của riêng mình, các bạn có thể tham khảo SQRT Shop (https://sqrtoj.edu.vn/shop/). Chúng mình có cung cấp các dịch vụ như sử dụng tính năng tổ chức và cài đặt – duy trì một trang chấm bài riêng với giá cả phải chăng. Nếu các bạn muốn ủng hộ câu lạc bộ, hãy đón chờ những sản phẩm như áo, áo khoác SQRT trong tương lai. Nếu các bạn có ý kiến đóng góp cho câu lạc bộ, hãy comment dưới bài viết này, inbox fanpage hoặc nhắn trong server Discord và ping @sqrt.sunlight để được giải đáp.

Chúng mình xin chân thành cảm ơn sự ủng hộ của các bạn trong gần một năm qua. Hy vọng các bạn có thể tiếp tục đi cùng chúng mình trong thời gian tới.

Sunlight
o22, Tháng 6, 2025, 17:03 0

2

Tổng hợp đề thi tuyển sinh lớp 10 chuyên Tin trên SQRTOJ

Sunlight đã đăng vào 4, Tháng 6, 2025, 15:13

Xin chào các bạn,

Hiện nay rất nhiều tỉnh, thành phố, trường THPT chuyên đã tổ chức các kỳ thi tuyển sinh lớp 10, trong đó bao gồm cả môn chuyên Tin. Để giúp các bạn có thể luyện tập, câu lạc bộ SQRT đã đăng một số đề thi tuyển sinh lớp 10 chuyên Tin mới thi lên hệ thống, bao gồm các đề của các tỉnh / thành phố / trường sau:

  • Vĩnh Phúc: mã contest edu003.
  • Đà Nẵng: mã contest edu004.
  • Lâm Đồng: mã contest edu005.
  • Hà Tĩnh: mã contest edu006.
  • Quảng Nam: mã contest edu007.
  • Quảng Bình: mã contest edu008.
  • Bắc Giang: mã contest edu009.
  • Phú Thọ: mã contest edu010.
  • Quảng Ngãi: mã contest edu011.
  • Đắk Nông: mã contest edu012.
  • Tiền Giang: mã contest edu013.
  • Đắk Lắk: mã contest edu014.
  • Nghệ An: mã contest edu015.
  • Thái Nguyên: mã contest edu016.

Post này sẽ được cập nhật liên tục khi có đề thi mới.

Chúc các bạn luyện tập vui vẻ.

Sunlight
o4, Tháng 6, 2025, 15:13 0

0

Bài toán Kết nối động (Dynamic connectivity)

Sunlight đã đăng vào 29, Tháng 3, 2025, 6:39

1. Giới thiệu bài toán

Bài toán được phát biểu chi tiết tại VNOI - sqrt2_a. Tóm tắt như sau:

Cho một đồ thị vô hướng gồm ~n~ đỉnh và ~m~ cạnh ban đầu. Sau đó có ~k~ truy vấn, mỗi truy vấn thuộc một trong hai loại:

  1. Thêm cạnh ~(a, b)~.
  2. Xóa cạnh ~(a, b)~ (dữ liệu đảm bảo cạnh đang tồn tại).

Nhiệm vụ: sau mỗi truy vấn (và cả trước khi xử lý truy vấn nào), hãy in ra số thành phần liên thông của đồ thị.

Ràng buộc: ~(2 \le n \le 10^5), (1 \le m, k \le 10^5)~

Tổng số thao tác ~q = m + k~ cũng có thể lên tới ~2 \times 10^5~.

Đây là bài toán đồ thị động (dynamic graph) với cả hai thao tác thêm và xóa cạnh. Do kích thước lớn, cần những phương pháp tối ưu.


2. Các hướng tiếp cận

2.1. Hướng tiếp cận ngây thơ – BFS/DFS sau mỗi truy vấn

Ý tưởng:
Mỗi khi có thay đổi (thêm hoặc xóa cạnh), ta duyệt lại toàn bộ đồ thị bằng BFS hoặc DFS để đếm số thành phần liên thông.

Độ phức tạp:
Mỗi lần duyệt mất ~O(n + m')~ với ~m'~ là số cạnh hiện tại. Có ~q~ truy vấn, tổng độ phức tạp ~O(q \cdot (n + q))~ trong trường hợp xấu nhất.
Với ~n, q \le 2\times 10^5~, cách này quá chậm, không khả thi.

2.2. Hướng tiếp cận DSU cho trường hợp không có xóa

Nếu chỉ có thêm cạnh (đồ thị chỉ tăng dần), ta dùng Disjoint Set Union (DSU) rất hiệu quả:
Mỗi lần thêm cạnh, nếu hai đỉnh chưa cùng thành phần, ta hợp nhất và giảm số thành phần đi 1.
Độ phức tạp gần như ~O(1)~ cho mỗi thao tác.

Nhưng khi có xóa, DSU cơ bản không hỗ trợ tách rời các thành phần. Điều này đòi hỏi các kỹ thuật nâng cao hơn.

2.3. Hướng tiếp cận ~O(q \sqrt{q})~ – Chia nhóm truy vấn

Nguồn tham khảo: Editorial chính thức của đề bài

Phương pháp này dựa trên ý tưởng phân hoạch dãy thao tác thành các khối (block) có kích thước xấp xỉ ~\sqrt{q}~. Mỗi khối được xử lý độc lập, trong đó tận dụng tính chất: số lượng cạnh thay đổi trạng thái (thêm/xóa) trong một khối là nhỏ, từ đó giảm chi phí xử lý mỗi truy vấn.

2.3.1. Ý tưởng cốt lõi

Chia ~q~ thao tác (gồm ~m~ cạnh ban đầu và ~k~ truy vấn) thành các khối, mỗi khối có ~B = \lceil \sqrt{q} \rceil~ thao tác (có thể điều chỉnh ~B~ để tối ưu thực tế). Khi xét một khối cụ thể, ta phân các cạnh của đồ thị thành hai loại:

  • Cạnh tĩnh (static): những cạnh có mặt trong toàn bộ khối, nghĩa là chúng được thêm trước khối và không bị xóa trong khối (hoặc nếu có xóa thì sau khối). Các cạnh này không thay đổi trạng thái trong suốt khối.
  • Cạnh động (dynamic): những cạnh có ít nhất một thao tác thêm hoặc xóa nằm trong khối. Số lượng cạnh động trong một khối là ~O(B)~, vì mỗi thao tác trong khối chỉ ảnh hưởng đến một cạnh.

Xử lý một khối gồm ba bước chính:

  1. Xây dựng cấu trúc DSU cơ sở chỉ gồm các cạnh tĩnh (có thể dùng path compression để tăng tốc).
  2. Với mỗi truy vấn trong khối (theo thứ tự thời gian), ta cập nhật trạng thái của cạnh động tương ứng (thêm hoặc xóa), sau đó thêm tất cả các cạnh động đang tồn tại vào DSU cơ sở (chỉ thêm những cạnh chưa có) và ghi nhận số thành phần liên thông.
  3. Sau mỗi truy vấn, ta quay lui (rollback) các thao tác thêm cạnh động vừa thực hiện để DSU trở về trạng thái chỉ có cạnh tĩnh, chuẩn bị cho truy vấn tiếp theo trong khối.
2.3.2. Chi tiết các bước xử lý một khối

Giả sử khối hiện tại bao gồm các thao tác có chỉ số từ ~L~ đến ~R~ (đánh số 1..~q~).

Bước 1: Xác định cạnh tĩnh và cạnh động

  • Duyệt tất cả các cạnh đã biết (từ dữ liệu đầu vào). Mỗi cạnh có một khoảng thời gian tồn tại ~[l, r)~ (được xác định từ các cặp thêm/xóa).
  • Cạnh được gọi là tĩnh trong khối nếu ~l \le L~ và ~r > R~ (tức nó có mặt trong toàn bộ khối).
  • Cạnh được gọi là động nếu nó có giao với khối (~l \le R~ và ~r \ge L~) nhưng không phải là tĩnh.
  • Lưu ý: một cạnh có thể không xuất hiện trong khối (khi ~r \le L~ hoặc ~l > R~) – ta bỏ qua.

Bước 2: Xây dựng DSU cơ sở cho khối

  • Khởi tạo một DSU có hỗ trợ rollback (sẽ dùng cho cạnh động).
  • Duyệt tất cả cạnh tĩnh và thực hiện hợp nhất (union) trên DSU này. Vì các cạnh tĩnh không thay đổi trong khối, ta chỉ cần thực hiện một lần. Có thể dùng path compression để tăng tốc (nhưng phải cẩn thận vì DSU sẽ cần rollback cho cạnh động – thông thường ta dùng hai chế độ: một DSU riêng cho cạnh tĩnh với nén đường, và DSU động không nén để rollback; hoặc dùng chung một DSU nhưng chỉ nén khi thêm cạnh tĩnh và không nén khi thêm cạnh động). Để đơn giản, ta có thể dùng DSU với union by size và không nén đường cho toàn bộ, khi đó mỗi thao tác tốn ~O(\log n)~ – vẫn chấp nhận được.

Bước 3: Xử lý tuần tự từng truy vấn trong khối

Với mỗi truy vấn thứ ~i~ từ ~L~ đến ~R~:

  • Cập nhật trạng thái của cạnh liên quan đến truy vấn (nếu là thêm thì đánh dấu cạnh đó đang tồn tại, nếu xóa thì đánh dấu không tồn tại).
  • Lấy một snapshot (mốc) của DSU hiện tại (số lượng thay đổi đã lưu trong stack).
  • Duyệt qua tất cả cạnh động của khối: nếu cạnh đó đang tồn tại (trạng thái true), thực hiện hợp nhất hai đầu mút của nó trên DSU. Việc hợp nhất này phải được ghi lại để có thể rollback sau.
  • Số thành phần liên thông hiện tại của DSU chính là kết quả sau thao tác ~i~.
  • Rollback DSU về snapshot đã lưu (xóa tất cả các thay đổi vừa thực hiện do các cạnh động gây ra), đưa DSU về trạng thái chỉ chứa cạnh tĩnh.

Chú ý: Các cạnh động có thể có trạng thái thay đổi theo thời gian trong khối. Việc duyệt lại toàn bộ cạnh động mỗi lần truy vấn (thay vì chỉ cập nhật một cạnh) tuy tốn ~O(B)~ nhưng là chấp nhận được vì ~B~ nhỏ.

2.3.3. Kỹ thuật DSU có rollback

Để thực hiện rollback, DSU không được dùng path compression. Thay vào đó, ta chỉ dùng union by size (hoặc rank). Mỗi lần hợp nhất hai thành phần thành công, ta lưu lại các thông tin thay đổi vào một ngăn xếp (stack), ví dụ: đỉnh con bị đổi cha, kích thước cũ của gốc mới, và số thành phần liên thông trước khi hợp nhất. Khi cần rollback, ta lấy thông tin từ stack và khôi phục lại trạng thái cũ. Mỗi thao tác hợp nhất và rollback đều có chi phí ~O(1)~ (không tính chi phí tìm gốc – nhưng tìm gốc vẫn là ~O(\log n)~ do không nén).

Ngoài ra, để tăng tốc phần xử lý cạnh tĩnh, có thể tách riêng một DSU khác không cần rollback, sử dụng path compression. Khi đó trong mỗi truy vấn, ta sẽ kết hợp kết quả từ DSU tĩnh và các cạnh động tạm thời. Tuy nhiên, việc kết hợp này đòi hỏi phải ánh xạ các đỉnh về đại diện của DSU tĩnh, khá phức tạp. Trong khuôn khổ bài viết, ta giữ cách dùng chung một DSU rollback cho toàn bộ, vì độ phức tạp vẫn đạt yêu cầu.

2.3.4. Phân tích độ phức tạp
  • Số khối: ~\frac{q}{B} \approx \sqrt{q}~.
  • Chi phí xây dựng DSU cơ sở mỗi khối: duyệt qua tất cả cạnh để phân loại tĩnh/động và thực hiện union cho cạnh tĩnh. Mỗi cạnh được xét trong mỗi khối mà nó có giao với khối, và mỗi cạnh có thể là tĩnh trong nhiều khối liên tiếp. Tổng số lần xét cạnh trên tất cả các khối là ~O(q \sqrt{q})~ (vì mỗi cạnh với đoạn tồn tại độ dài ~d~ sẽ nằm trong ~O(d/B + 1)~ khối, tổng các ~d~ bằng ~O(q)~, suy ra tổng số lần xét là ~O(q \sqrt{q})~). Chi phí cho mỗi lần xét và union là ~O(\log n)~ (nếu không nén) hoặc ~O(\alpha(n))~ (nếu dùng nén riêng). Vậy tổng chi phí cho bước này là ~O(q \sqrt{q} \log n)~.
  • Chi phí xử lý truy vấn trong một khối: mỗi truy vấn phải duyệt qua tất cả cạnh động (số lượng ~O(B)~), thực hiện union (nếu cạnh đang tồn tại) và sau đó rollback. Mỗi union/rollback tốn ~O(\log n)~. Một khối có ~B~ truy vấn, do đó chi phí cho khối là ~O(B^2 \log n)~. Có ~\sqrt{q}~ khối, suy ra tổng chi phí cho bước này là ~O(q \sqrt{q} \log n)~.
  • Tổng độ phức tạp: ~O(q \sqrt{q} \log n)~.

Với ~q \le 2 \times 10^5~, ~\sqrt{q} \approx 447~, ~q \sqrt{q} \approx 9 \times 10^7~, nhân với ~\log n \approx 18~ cho khoảng 1,6 tỷ phép toán – lớn nhưng nếu cài đặt tối ưu (C++, dùng mảng tĩnh, tránh cấp phát động) vẫn có thể chạy trong 2-3 giây. Thực tế, hằng số nhỏ hơn do nhiều cạnh không cần xét ở mỗi khối. Nếu muốn an toàn hơn, có thể tăng kích thước khối lên một chút (ví dụ ~B = 700~) để giảm số khối, nhưng khi đó chi phí trong khối tăng lên. Cần cân nhắc.

2.3.5. Lưu ý khi cài đặt
  • Chọn kích thước khối: lý thuyết tối ưu là ~B = \sqrt{q}~, nhưng có thể thử ~B = 400~ hoặc ~500~ cho phù hợp với bộ nhớ đệm.
  • Xác định nhanh cạnh tĩnh/động: Nên tiền xử lý để biết với mỗi cạnh, khoảng thời gian tồn tại của nó dưới dạng ~[l, r)~ (giống như trong phương pháp segment tree). Sau đó, khi xét khối ~[L, R]~, chỉ cần so sánh ~l, r~ với ~L, R~.
  • Quản lý trạng thái hiện tại của cạnh động: Cần một mảng active[edge_id] để biết cạnh đó có đang tồn tại trong đồ thị tại thời điểm đang xét hay không. Mảng này được cập nhật liên tục qua các khối và qua các truy vấn trong khối.
  • DSU rollback: Cần lưu stack các thay đổi. Mỗi lần thêm cạnh động thành công, ta push một bản ghi (ví dụ: hai đỉnh, kích thước cũ, ...). Sau truy vấn, rollback đúng số lần đã push.
  • Khởi tạo lại DSU cho mỗi khối: Việc xây dựng lại DSU từ đầu cho mỗi khối (bằng cách thêm các cạnh tĩnh) tốn thời gian nhưng không quá lớn. Có thể cải tiến bằng cách chỉ cập nhật DSU giữa các khối nếu tập cạnh tĩnh thay đổi ít, nhưng điều này làm phức tạp code.
2.3.6. Code tham khảo
#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>

using namespace std;

void setupIO() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
}

// Cấu trúc đại diện cho một cạnh (luôn đảm bảo u < v để dễ so sánh)
struct Edge {
    int u, v;
    bool operator<(const Edge& other) const {
        if (u != other.u) return u < other.u;
        return v < other.v;
    }
    bool operator==(const Edge& other) const {
        return u == other.u && v == other.v;
    }
};

struct Query {
    int t, id;
};

// Cấu trúc DSU hỗ trợ Rollback
struct DSU {
    int components;
    vector<int> parent;
    vector<int> sz;

    struct State {
        int u, v;
        int sz_u, sz_v;
        int components;
    };
    vector<State> history;

    DSU(int n) : components(n), parent(n + 1), sz(n + 1, 1) {
        iota(parent.begin(), parent.end(), 0);
    }

    void reset(int n_verts) {
        components = n_verts;
        for(int i = 1; i <= n_verts; ++i) {
            parent[i] = i;
            sz[i] = 1;
        }
        history.clear();
    }

    // Gộp tĩnh: Có Path Compression
    int find_pc(int i) {
        if (parent[i] == i) return i;
        return parent[i] = find_pc(parent[i]);
    }

    void unite_static(int i, int j) {
        int root_i = find_pc(i);
        int root_j = find_pc(j);
        if (root_i != root_j) {
            if (sz[root_i] < sz[root_j]) swap(root_i, root_j);
            parent[root_j] = root_i;
            sz[root_i] += sz[root_j];
            components--;
        }
    }

    // Gộp động: KHÔNG Path Compression để có thể Rollback
    int find_no_pc(int i) {
        while (parent[i] != i) i = parent[i];
        return i;
    }

    void unite_dynamic(int i, int j) {
        int root_i = find_no_pc(i);
        int root_j = find_no_pc(j);

        if (root_i == root_j) {
            history.push_back({-1, -1, -1, -1, components});
            return;
        }

        if (sz[root_i] < sz[root_j]) swap(root_i, root_j);
        history.push_back({root_i, root_j, sz[root_i], sz[root_j], components});

        parent[root_j] = root_i;
        sz[root_i] += sz[root_j];
        components--;
    }

    void rollback() {
        if (history.empty()) return;
        State st = history.back();
        history.pop_back();

        if (st.u != -1) {
            parent[st.v] = st.v; 
            sz[st.u] = st.sz_u;  
            sz[st.v] = st.sz_v;  
            components = st.components; 
        }
    }
};

int main() {
    setupIO();
    int n, m, k;
    if (!(cin >> n >> m >> k)) return 0; // Đọc 3 số nguyên n, m, k 

    vector<Edge> all_edges;
    vector<Edge> initial_edges(m);

    // Đọc m cạnh ban đầu của đồ thị 
    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        if (u > v) swap(u, v);
        initial_edges[i] = {u, v};
        all_edges.push_back({u, v});
    }

    vector<Query> queries(k);
    vector<Edge> query_edges(k);

    // Đọc k truy vấn tiếp theo (t=1: thêm cạnh, t=2: xóa cạnh) 
    for (int i = 0; i < k; ++i) {
        int t, u, v;
        cin >> t >> u >> v;
        if (u > v) swap(u, v);
        queries[i] = {t, 0}; 
        query_edges[i] = {u, v};
        all_edges.push_back({u, v});
    }

    // Nén tọa độ cạnh
    sort(all_edges.begin(), all_edges.end());
    all_edges.erase(unique(all_edges.begin(), all_edges.end()), all_edges.end());

    int E = all_edges.size();
    vector<int> U(E), V(E);
    for (int i = 0; i < E; ++i) {
        U[i] = all_edges[i].u;
        V[i] = all_edges[i].v;
    }

    vector<bool> in_graph(E, false);

    // Đánh dấu các cạnh ban đầu đã tồn tại trong đồ thị 
    for (int i = 0; i < m; ++i) {
        int id = lower_bound(all_edges.begin(), all_edges.end(), initial_edges[i]) - all_edges.begin();
        in_graph[id] = true;
    }

    for (int i = 0; i < k; ++i) {
        int id = lower_bound(all_edges.begin(), all_edges.end(), query_edges[i]) - all_edges.begin();
        queries[i].id = id;
    }

    DSU dsu(n);

    // 1. In ra số thành phần liên thông trước khi các thao tác diễn ra 
    for (int i = 0; i < E; ++i) {
        if (in_graph[i]) dsu.unite_static(U[i], V[i]);
    }
    cout << dsu.components << " "; 

    // Bắt đầu xử lý truy vấn theo kỹ thuật Chia căn
    int B = 400; // Kích thước Block (căn bậc hai của 10^5)
    vector<bool> is_dynamic(E, false);

    for (int i = 0; i < k; i += B) {
        int L = i;
        int R = min(k - 1, i + B - 1);

        vector<int> dyn_edges;
        for (int j = L; j <= R; ++j) {
            int id = queries[j].id;
            if (!is_dynamic[id]) {
                is_dynamic[id] = true;
                dyn_edges.push_back(id);
            }
        }

        dsu.reset(n);
        // Xây dựng cây dựa trên các cạnh tĩnh
        for (int j = 0; j < E; ++j) {
            if (in_graph[j] && !is_dynamic[j]) {
                dsu.unite_static(U[j], V[j]);
            }
        }

        // Xử lý từng truy vấn trong block hiện tại
        for (int j = L; j <= R; ++j) {
            int id = queries[j].id;
            int type = queries[j].t;

            // Cập nhật trạng thái cạnh (thêm vào đồ thị nếu t=1, xóa khỏi đồ thị nếu t=2) [cite: 13]
            if (type == 1) in_graph[id] = true;  
            else if (type == 2) in_graph[id] = false;

            int unions_done = 0;
            for (int d_id : dyn_edges) {
                if (in_graph[d_id]) {
                    dsu.unite_dynamic(U[d_id], V[d_id]);
                    unions_done++;
                }
            }

            // 2. In ra số thành phần liên thông sau thao tác biến đổi 
            cout << dsu.components << (j == k - 1 ? "" : " "); 

            for (int c = 0; c < unions_done; ++c) {
                dsu.rollback();
            }
        }

        for (int id : dyn_edges) {
            is_dynamic[id] = false;
        }
    }
    cout << "\n";
    return 0;
}
2.4. Hướng tiếp cận ~O(q \log n \log q)~ – Segment Tree trên thời gian sống của cạnh

Nguồn tham khảo:

  • CP-Algorithms: Deleting from a data structure in O(T(n) log n)
  • USACO Guide: Offline Deletion

Đây là phương pháp tối ưu và phổ biến nhất cho bài toán đồ thị động với cả thêm và xóa cạnh. Ý tưởng cốt lõi là chuyển bài toán có cả thêm và xóa thành bài toán chỉ có thêm bằng cách xác định khoảng thời gian mà mỗi cạnh tồn tại, sau đó dùng một cấu trúc dữ liệu hỗ trợ thêm và quay lui (rollback) kết hợp với segment tree.

2.4.1. Xác định khoảng thời gian sống của mỗi cạnh

Giả sử ta có ~q~ thao tác (gồm ~m~ cạnh ban đầu và ~k~ truy vấn thêm/xóa). Ta đánh số các thao tác từ ~1~ đến ~q~. Với mỗi cạnh, ta xác định thời điểm nó được thêm vào (lần đầu tiên hoặc sau khi bị xóa) và thời điểm nó bị xóa (nếu có). Như vậy, mỗi cạnh sẽ tồn tại liên tục trong một hoặc nhiều đoạn thời gian. Do mỗi thao tác thêm/xóa đều tạo ra một cặp “thêm – xóa”, ta có thể biểu diễn mỗi cạnh bằng một đoạn ~[l, r)~ với ~l~ là thời điểm thêm, ~r~ là thời điểm xóa (hoặc ~q+1~ nếu không bị xóa). Nếu có nhiều lần thêm/xóa xen kẽ, mỗi lần thêm tạo một đoạn riêng.

Ví dụ:

  • Thêm cạnh (1,2) ở thời điểm 2, xóa ở thời điểm 5 → đoạn [2,5)
  • Cạnh ban đầu (3,4) không bị xóa → đoạn [1, q+1)

Việc xác định các đoạn này có thể thực hiện bằng cách duyệt qua các thao tác, dùng một map lưu thời điểm thêm gần nhất của mỗi cạnh, khi gặp thao tác xóa thì lấy thời điểm thêm tương ứng và tạo đoạn.

2.4.2. Xây dựng Segment Tree trên trục thời gian

Ta xây dựng một segment tree (cây phân đoạn) trên khoảng thời gian ~[1, q+1)~. Mỗi node của cây đại diện cho một đoạn thời gian. Với mỗi cạnh có đoạn tồn tại ~[l, r)~, ta chèn cạnh đó vào các node của segment tree sao cho đoạn ~[l, r)~ được phủ bởi các node con mà không bị dư thừa. Điều này tương tự như thao tác cập nhật đoạn trên segment tree: ta gọi hàm đệ quy, nếu đoạn của node nằm gọn trong ~[l, r)~ thì thêm cạnh vào node đó; ngược lại, tiếp tục xuống các node con. Nhờ vậy, mỗi cạnh được lưu trong ~O(\log q)~ node.

2.4.3. DSU có khả năng rollback

Vì chúng ta sẽ duyệt cây phân đoạn và thực hiện các thao tác thêm cạnh trên từng node, sau đó cần quay lui để xử lý các nhánh khác, nên DSU thông thường không đáp ứng được. Thay vào đó, ta sử dụng DSU có rollback (còn gọi là DSU với khả năng quay lui). Các đặc điểm của DSU này:

  • Không sử dụng path compression (vì nén đường làm thay đổi nhiều liên kết, khó rollback). Chỉ dùng union by size/rank.
  • Mỗi lần hợp nhất thành công, ta lưu lại các thay đổi vào một stack: ví dụ, khi gắn gốc ~a~ vào gốc ~b~, ta lưu (a, parent[a] cũ) và (b, size[b] cũ). Sau đó thực hiện gắn và cập nhật size.
  • Hàm rollback() sẽ pop hai phần tử khỏi stack và khôi phục lại trạng thái trước khi hợp nhất.
  • Ta cũng có thể lấy một snapshot (độ dài stack hiện tại) và rollback về snapshot đó.

Độ phức tạp của mỗi thao tác unite/rollback là ~O(\log n)~ (do chỉ dùng union by size và không nén).

2.4.4. Duyệt Segment Tree để trả lời truy vấn

Sau khi đã chèn tất cả các cạnh vào segment tree, ta thực hiện duyệt cây theo chiều sâu (DFS) từ node gốc, với một DSU rollback toàn cục.

void dfs(node, l, r) {
    snapshot = dsu.snapshot();
    for (cạnh trong node) {
        dsu.unite(u, v);               // thêm các cạnh của node này
    }
    if (l+1 == r) {                    // node lá, tương ứng thời điểm l
        ans[l] = dsu.components;       // ghi nhận số thành phần liên thông
    } else {
        int mid = (l+r)/2;
        dfs(node*2, l, mid);
        dfs(node*2+1, mid, r);
    }
    dsu.rollback_to(snapshot);         // quay lui để trả về trạng thái trước khi vào node
}

Giải thích:

  • Khi xuống một node, ta thực hiện tất cả các union ứng với các cạnh được lưu tại node đó.
  • Nếu node lá (ứng với một thời điểm cụ thể), lúc đó DSU đã chứa tất cả các cạnh có hiệu lực tại thời điểm đó (vì mọi cạnh có đoạn thời gian chứa thời điểm này đều đã được thêm ở một node tổ tiên nào đó).
  • Sau khi xử lý xong node con, ta rollback về trạng thái trước khi thêm các cạnh của node hiện tại, để các nhánh khác không bị ảnh hưởng.

Kết thúc quá trình, mảng ans chứa số thành phần liên thông sau mỗi thao tác (tại các thời điểm từ 1 đến q). Bài toán yêu cầu in trạng thái ban đầu (sau m cạnh ban đầu) và sau mỗi truy vấn tiếp theo, ta chỉ cần lấy các giá trị tương ứng.

2.4.5. Phân tích độ phức tạp
  • Mỗi cạnh được chèn vào ~O(\log q)~ node của segment tree.
  • Tổng số lần gọi unite trong quá trình duyệt DFS là tổng số lần cạnh xuất hiện trong các node, tức ~O((m+k) \log q)~.
  • Mỗi unite có chi phí ~O(\log n)~ do tìm gốc (không nén) và thao tác rollback cũng tốn ~O(\log n)~.
  • Do đó tổng độ phức tạp: ~O((m+k) \log q \log n)~. Với ~n, m, k \le 2\times 10^5~, thuật toán chạy rất nhanh trong thời gian cho phép.
2.4.6. Lưu ý khi cài đặt
  • Không dùng path compression: Vì nén đường sẽ thay đổi nhiều liên kết, không thể rollback hiệu quả. Chỉ dùng union by size để đảm bảo độ phức tạp ~O(\log n)~ cho find.
  • Xử lý cạnh vô hướng: Cần chuẩn hóa thứ tự hai đỉnh (ví dụ u < v) để việc lưu trong map được nhất quán.
  • Chú ý chỉ số: Nên dùng đoạn [1, q+1) để dễ quản lý, trong đó q+1 là thời điểm “sau thao tác cuối”.
  • Stack rollback: Cần đảm bảo mỗi unite thành công lưu đúng hai thay đổi, và khi rollback phải lấy đúng hai phần tử. Có thể dùng cấu trúc struct chứa loại thay đổi (parent hay size) hoặc lưu chung trong một mảng cặp.
  • Không gian bộ nhớ: Segment tree cần lưu danh sách cạnh tại mỗi node. Số lượng cạnh được lưu là ~O((m+k) \log q)~, với ~q \le 2\times 10^5~ thì bộ nhớ khoảng vài chục MB, hoàn toàn chấp nhận được.
2.4.7. Code tham khảo
#include <bits/stdc++.h>
using namespace std;

// Cấu trúc DSU hỗ trợ rollback (không dùng path compression)
struct DSUWithRollback {
    vector<int> parent, sz;   // parent và size (union by size)
    int comps;                 // số thành phần liên thông hiện tại

    // Lưu lịch sử thay đổi để rollback
    struct Change {
        int v;        // đỉnh bị thay đổi
        int old;      // giá trị cũ (parent cũ hoặc size cũ)
        bool is_parent; // true nếu thay đổi parent, false nếu thay đổi size
    };
    stack<Change> history;

    DSUWithRollback(int n) : parent(n+1), sz(n+1, 1), comps(n) {
        for (int i = 1; i <= n; ++i) parent[i] = i;
    }

    // Tìm đại diện (không nén)
    int find(int x) {
        while (parent[x] != x) x = parent[x];
        return x;
    }

    // Hợp nhất hai đỉnh, trả về true nếu có sự thay đổi
    bool unite(int a, int b) {
        a = find(a); b = find(b);
        if (a == b) return false;
        // Đảm bảo a có size nhỏ hơn để gắn a vào b
        if (sz[a] > sz[b]) swap(a, b);
        // Lưu thay đổi (push 2 change)
        history.push({a, parent[a], true});   // parent của a
        history.push({b, sz[b], false});      // size của b
        parent[a] = b;
        sz[b] += sz[a];
        --comps;
        return true;
    }

    // Rollback một lần hợp nhất (pop 2 change và tăng comps)
    void rollback() {
        Change sz_ch = history.top(); history.pop(); // size change
        Change p_ch  = history.top(); history.pop(); // parent change
        parent[p_ch.v] = p_ch.old;
        sz[sz_ch.v] = sz_ch.old;
        ++comps;
    }

    // Lấy trạng thái hiện tại (số lượng change đã lưu)
    int snapshot() const {
        return history.size();
    }

    // Rollback về trạng thái snapshot
    void rollback_to(int snap) {
        while ((int)history.size() > snap) rollback();
    }
};

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

    int n, m, k;
    cin >> n >> m >> k;
    int Q = m + k;                     // tổng số thao tác (1-indexed)

    // Đọc tất cả thao tác: (type, u, v)
    vector<tuple<int,int,int>> ops(Q+1);
    for (int i = 1; i <= m; ++i) {
        int u, v; cin >> u >> v;
        ops[i] = {1, u, v};
    }
    for (int i = 1; i <= k; ++i) {
        int t, u, v; cin >> t >> u >> v;
        ops[m+i] = {t, u, v};
    }

    // Xác định khoảng thời gian sống của mỗi cạnh (u,v)
    map<pair<int,int>, int> last_add; // lần thêm gần nhất
    vector<tuple<int,int,int,int>> edges; // u, v, l, r  (tồn tại trong [l, r))
    for (int i = 1; i <= Q; ++i) {
        auto [type, u, v] = ops[i];
        if (u > v) swap(u, v); // chuẩn hóa cạnh vô hướng
        if (type == 1) {       // thêm cạnh
            last_add[{u, v}] = i;
        } else {               // xóa cạnh
            auto it = last_add.find({u, v});
            if (it != last_add.end()) {
                int l = it->second;
                int r = i;
                edges.emplace_back(u, v, l, r);
                last_add.erase(it);
            }
        }
    }
    // Các cạnh còn lại không bị xóa, tồn tại đến hết
    for (auto &[uv, l] : last_add) {
        auto [u, v] = uv;
        edges.emplace_back(u, v, l, Q+1);
    }

    // Xây dựng segment tree trên các thời điểm [1, Q+1)
    int seg_sz = Q + 2;                     // đủ để bao [1, Q+1)
    vector<vector<pair<int,int>>> seg(4 * seg_sz); // mỗi node lưu danh sách cạnh (u,v)

    // Thêm cạnh vào segment tree
    function<void(int,int,int,int,int,int,int)> add_edge = [&](int node, int l, int r, int ql, int qr, int u, int v) {
        if (ql <= l && r <= qr) {
            seg[node].emplace_back(u, v);
            return;
        }
        int mid = (l + r) / 2;
        if (ql < mid) add_edge(node*2, l, mid, ql, qr, u, v);
        if (qr > mid) add_edge(node*2+1, mid, r, ql, qr, u, v);
    };
    for (auto &[u, v, l, r] : edges) {
        if (l < r) {
            add_edge(1, 1, seg_sz, l, r, u, v);
        }
    }

    DSUWithRollback dsu(n);
    vector<int> ans(Q+2, 0); // ans[i] = số thành phần liên thông sau thao tác i

    // Duyệt segment tree theo DFS
    function<void(int,int,int)> dfs = [&](int node, int l, int r) {
        int snap = dsu.snapshot();
        // Thêm tất cả cạnh tại node này
        for (auto &[u, v] : seg[node]) {
            dsu.unite(u, v);
        }
        if (r - l == 1) {       // node lá → ứng với thời điểm l
            ans[l] = dsu.comps;
        } else {
            int mid = (l + r) / 2;
            dfs(node*2, l, mid);
            dfs(node*2+1, mid, r);
        }
        dsu.rollback_to(snap); // khôi phục trạng thái trước khi vào node
    };
    dfs(1, 1, seg_sz);

    // In kết quả: sau m thao tác đầu và sau mỗi truy vấn
    cout << ans[m];
    for (int i = 1; i <= k; ++i) {
        cout << ' ' << ans[m + i];
    }
    cout << '\n';

    return 0;
}
Sunlight
o29, Tháng 3, 2025, 6:39 0

0

Hàm tự định nghĩa trong C++ - Đệ quy - Sinh tổ hợp - Chia để trị - Đệ quy có nhớ

Sunlight đã đăng vào 17, Tháng 1, 2020, 17:30

1. Khai báo và sử dụng hàm tự định nghĩa trong C++

1.1. Tại sao cần hàm tự định nghĩa?

Khi chương trình phức tạp, việc viết tất cả mã trong main() sẽ gây khó khăn:

  • Khó đọc, khó bảo trì
  • Lặp lại mã nhiều lần
  • Khó tái sử dụng

Hàm tự định nghĩa giúp chia chương trình thành các phần nhỏ, mỗi phần thực hiện một nhiệm vụ cụ thể.

1.2. Cấu trúc hàm cơ bản

// Cú pháp tổng quát
kiểu_trả_về tên_hàm(danh_sách_tham_số) {
    // Thân hàm
    return giá_trị; // (nếu kiểu trả về khác void)
}
Ví dụ 1: Hàm tính giai thừa
#include <iostream>
using namespace std;

// Khai báo hàm (prototype)
int tinhGiaiThua(int n);

int main() {
    int n = 5;
    int ketQua = tinhGiaiThua(n);
    cout << n << "! = " << ketQua << endl;
    return 0;
}

// Định nghĩa hàm
int tinhGiaiThua(int n) {
    int giaiThua = 1;
    for (int i = 1; i <= n; i++) {
        giaiThua *= i;
    }
    return giaiThua;
}

1.3. Các loại hàm thường gặp

Loại hàm Mô tả Ví dụ
Không trả về Dùng void, không có return void inMenu()
Có trả về Trả về giá trị xác định int tong(int a, int b)
Không tham số Không nhận đầu vào int nhapSo()
Có tham số Nhận giá trị đầu vào double tinhDienTich(double r)

1.4. Truyền tham số

Bảng so sánh truyền tham trị và tham chiếu:
Đặc điểm Truyền tham trị Truyền tham chiếu
Cú pháp void ham(int x) void ham(int &x)
Bản sao Tạo bản sao của giá trị Không tạo bản sao
Thay đổi Không ảnh hưởng biến gốc Ảnh hưởng trực tiếp biến gốc
Hiệu suất Chậm với kiểu dữ liệu lớn Nhanh với kiểu dữ liệu lớn
Ứng dụng Khi không cần thay đổi giá trị gốc Khi cần thay đổi giá trị gốc
Ví dụ 2: So sánh truyền tham trị và tham chiếu
#include <iostream>
using namespace std;

// Truyền tham trị - KHÔNG thay đổi giá trị gốc
void tangGiaTriThamTri(int x) {
    x = x + 10;
    cout << "Trong ham (tham tri): x = " << x << endl;
}

// Truyền tham chiếu - CÓ thay đổi giá trị gốc
void tangGiaTriThamChieu(int &x) {
    x = x + 10;
    cout << "Trong ham (tham chieu): x = " << x << endl;
}

int main() {
    int a = 5;

    cout << "Truoc khi goi ham:" << endl;
    cout << "a = " << a << endl << endl;

    // Gọi hàm tham trị
    tangGiaTriThamTri(a);
    cout << "Sau khi goi ham tham tri:" << endl;
    cout << "a = " << a << endl << endl;

    // Gọi hàm tham chiếu
    tangGiaTriThamChieu(a);
    cout << "Sau khi goi ham tham chieu:" << endl;
    cout << "a = " << a << endl;

    return 0;
}

Kết quả:

Truoc khi goi ham:
a = 5

Trong ham (tham tri): x = 15
Sau khi goi ham tham tri:
a = 5

Trong ham (tham chieu): x = 15
Sau khi goi ham tham chieu:
a = 15

1.5. Hàm trả về nhiều giá trị (sử dụng tham chiếu)

#include <iostream>
using namespace std;

// Hàm trả về nhiều giá trị qua tham chiếu
void tinhToan(int a, int b, int &tong, int &hieu, int &tich) {
    tong = a + b;
    hieu = a - b;
    tich = a * b;
}

int main() {
    int x = 10, y = 4;
    int tong, hieu, tich;

    tinhToan(x, y, tong, hieu, tich);

    cout << "x = " << x << ", y = " << y << endl;
    cout << "Tong: " << tong << endl;
    cout << "Hieu: " << hieu << endl;
    cout << "Tich: " << tich << endl;

    return 0;
}

1.6. Quy tắc đặt tên hàm tốt

  1. Mô tả rõ ràng: Tên hàm nên nói lên chức năng
  2. Sử dụng động từ: calculate, display, find, validate
  3. Quy tắc lạc đà: tinhTongHaiSo()
  4. Nhất quán: Cùng một phong cách đặt tên

1.7. Lưu đồ hoạt động của hàm

┌─────────────────────────────────────────┐
│         Chương trình chính              │
│  ┌───────────────────────────────────┐  │
│  │ Gọi hàm: ketQua = tinhTong(a, b)  │  │
│  └───────────────────────────────────┘  │
│                  ↓                      │
│  ┌───────────────────────────────────┐  │
│  │   Thực thi hàm tinhTong()         │  │
│  │   1. Nhận tham số a, b            │  │
│  │   2. Tính tổng = a + b            │  │
│  │   3. Trả về tổng                  │  │
│  └───────────────────────────────────┘  │
│                  ↑                      │
│  ┌───────────────────────────────────┐  │
│  │ Nhận kết quả và tiếp tục xử lý    │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

1.8. Bài tập thực hành

Bài tập 1: Viết hàm kiểm tra số nguyên tố

#include <iostream>
#include <cmath>
using namespace std;

bool laSoNguyenTo(int n) {
    if (n < 2) return false;
    for (int i = 2; i <= sqrt(n); i++) {
        if (n % i == 0) return false;
    }
    return true;
}

int main() {
    int n;
    cout << "Nhap so can kiem tra: ";
    cin >> n;

    if (laSoNguyenTo(n)) {
        cout << n << " la so nguyen to" << endl;
    } else {
        cout << n << " khong la so nguyen to" << endl;
    }

    return 0;
}

Bài tập 2: Viết hàm đổi chỗ hai số

#include <iostream>
using namespace std;

void doiCho(int &a, int &b) {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 5, y = 10;
    cout << "Truoc khi doi: x = " << x << ", y = " << y << endl;

    doiCho(x, y);

    cout << "Sau khi doi: x = " << x << ", y = " << y << endl;

    return 0;
}

Tổng kết phần 1

Hàm tự định nghĩa là công cụ mạnh mẽ giúp:

  • Tổ chức mã nguồn rõ ràng, dễ đọc
  • Tái sử dụng mã nhiều lần
  • Giảm lỗi và dễ bảo trì
  • Chia nhỏ vấn đề phức tạp thành các phần đơn giản

Mẹo nhớ:

  • Khai báo hàm trước khi sử dụng (hoặc đặt prototype)
  • Chọn kiểu trả về phù hợp
  • Phân biệt rõ tham trị và tham chiếu
  • Đặt tên hàm rõ ràng, dễ hiểu

2. Phương pháp đệ quy và bài toán sinh/duyệt cấu hình tổ hợp

2.1. Đệ quy là gì?

Đệ quy là kỹ thuật mà một hàm gọi chính nó để giải quyết bài toán. Mỗi lần gọi sẽ xử lý một phiên bản nhỏ hơn của bài toán ban đầu.

Ví dụ thực tế về đệ quy:
  • Matryoshka dolls (búp bê Nga): Búp bê lớn chứa búp bê nhỏ hơn, cứ thế tiếp tục
  • Hình ảnh phản chiếu trong gương: Gương chiếu vào gương tạo ra hình ảnh lặp vô hạn
  • Tìm đường ra mê cung: Tại mỗi ngã rẽ, áp dụng cùng chiến lược

2.2. Cấu trúc cơ bản của hàm đệ quy

Một hàm đệ quy cần có 2 phần:

  1. Phần cơ sở (base case): Điều kiện dừng
  2. Phần đệ quy (recursive case): Gọi chính hàm đó với bài toán nhỏ hơn
void hamDeQuy(thamsố) {
    // 1. Kiểm tra điều kiện dừng
    if (điều_kiện_dừng) {
        return;
    }

    // 2. Xử lý công việc hiện tại

    // 3. Gọi đệ quy với bài toán nhỏ hơn
    hamDeQuy(thamsố_mới);
}

2.3. Ví dụ 1: Tính giai thừa bằng đệ quy

Công thức toán học:

~ n! = \begin{cases} 1 & \text{nếu } n = 0 \\ n \times (n-1)! & \text{nếu } n > 0 \end{cases} ~

Mã nguồn:
#include <iostream>
using namespace std;

// Hàm đệ quy tính giai thừa
int giaiThua(int n) {
    // Phần cơ sở
    if (n == 0 || n == 1) {
        return 1;
    }

    // Phần đệ quy
    return n * giaiThua(n - 1);
}

int main() {
    int n = 5;
    cout << n << "! = " << giaiThua(n) << endl;

    // Hiển thị quá trình đệ quy
    cout << "\nQua trinh de quy:" << endl;
    for (int i = 0; i <= n; i++) {
        cout << "giaiThua(" << i << ") = " << giaiThua(i) << endl;
    }

    return 0;
}
Sơ đồ thực thi giaiThua(5):
giaiThua(5)
│
├─ return 5 * giaiThua(4)
│           │
│           ├─ return 4 * giaiThua(3)
│           │           │
│           │           ├─ return 3 * giaiThua(2)
│           │           │           │
│           │           │           ├─ return 2 * giaiThua(1)
│           │           │           │           │
│           │           │           │           └─ return 1
│           │           │           │
│           │           │           └─ return 2 * 1 = 2
│           │           │
│           │           └─ return 3 * 2 = 6
│           │
│           └─ return 4 * 6 = 24
│
└─ return 5 * 24 = 120

2.4. Ví dụ 2: Dãy Fibonacci

Công thức toán học:

~ F_n = \begin{cases} 0 & \text{nếu } n = 0 \\ 1 & \text{nếu } n = 1 \\ F_{n-1} + F_{n-2} & \text{nếu } n > 1 \end{cases} ~

#include <iostream>
using namespace std;

int fibonacci(int n) {
    // Phần cơ sở
    if (n == 0) return 0;
    if (n == 1) return 1;

    // Phần đệ quy
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int main() {
    int n = 6;
    cout << "Day Fibonacci den phan tu thu " << n << ":" << endl;

    for (int i = 0; i <= n; i++) {
        cout << "F(" << i << ") = " << fibonacci(i) << endl;
    }

    return 0;
}

2.5. Call Stack (Ngăn xếp gọi hàm)

Khi hàm đệ quy được gọi, mỗi lần gọi được lưu vào call stack:

Thời gian → 
↓
┌─────────────┐
│ giaiThua(1) │ ← Trả về 1
├─────────────┤
│ giaiThua(2) │ ← Chờ giaiThua(1)
├─────────────┤
│ giaiThua(3) │ ← Chờ giaiThua(2)
├─────────────┤
│ giaiThua(4) │ ← Chờ giaiThua(3)
├─────────────┤
│ giaiThua(5) │ ← Chờ giaiThua(4)
├─────────────┤
│    main()   │ ← Chờ giaiThua(5)
└─────────────┘

2.6. Bài toán sinh/duyệt cấu hình tổ hợp

2.6.1. Sinh dãy nhị phân độ dài n

Bài toán: Liệt kê tất cả dãy nhị phân có độ dài n

#include <iostream>
using namespace std;

int n;
int x[100]; // Mảng lưu dãy nhị phân

// Hàm in dãy nhị phân
void inKetQua() {
    for (int i = 1; i <= n; i++) {
        cout << x[i];
    }
    cout << endl;
}

// Hàm đệ quy sinh dãy nhị phân
void sinhNhiPhan(int i) { // i là vị trí đang xét
    // Duyệt tất cả khả năng cho vị trí thứ i
    for (int j = 0; j <= 1; j++) {
        x[i] = j; // Thử đặt bit thứ i = j

        if (i == n) {
            // Nếu đã xét hết n vị trí thì in kết quả
            inKetQua();
        } else {
            // Nếu chưa xét hết thì tiếp tục đệ quy
            sinhNhiPhan(i + 1);
        }
    }
}

int main() {
    cout << "Nhap n: ";
    cin >> n;

    cout << "\nCac day nhi phan do dai " << n << ":" << endl;
    sinhNhiPhan(1); // Bắt đầu từ vị trí thứ 1

    return 0;
}

Khi n = 3, cây đệ quy sẽ như sau:

sinhNhiPhan(1)
├─ x[1]=0
│  ├─ sinhNhiPhan(2)
│  │   ├─ x[2]=0
│  │   │   ├─ sinhNhiPhan(3)
│  │   │   │   ├─ x[3]=0 → In: 000
│  │   │   │   └─ x[3]=1 → In: 001
│  │   │   └─ ...
│  │   └─ x[2]=1
│  │       ├─ sinhNhiPhan(3)
│  │       │   ├─ x[3]=0 → In: 010
│  │       │   └─ x[3]=1 → In: 011
│  └─ ...
└─ x[1]=1
   ├─ sinhNhiPhan(2)
   │   ├─ x[2]=0
   │   │   ├─ sinhNhiPhan(3)
   │   │   │   ├─ x[3]=0 → In: 100
   │   │   │   └─ x[3]=1 → In: 101
   │   │   └─ ...
   │   └─ x[2]=1
   │       ├─ sinhNhiPhan(3)
   │       │   ├─ x[3]=0 → In: 110
   │       │   └─ x[3]=1 → In: 111
   └─ ...

2.6.2. Sinh hoán vị (Permutations)

Bài toán: Liệt kê tất cả hoán vị của n phần tử (1, 2, ..., n)

#include <iostream>
using namespace std;

int n;
int x[100];      // Mảng lưu hoán vị hiện tại
bool used[100];  // Mảng đánh dấu phần tử đã dùng

void inKetQua() {
    for (int i = 1; i <= n; i++) {
        cout << x[i] << " ";
    }
    cout << endl;
}

void sinhHoanVi(int i) {
    for (int j = 1; j <= n; j++) {
        if (!used[j]) { // Nếu số j chưa được dùng
            x[i] = j;   // Chọn j cho vị trí i
            used[j] = true; // Đánh dấu đã dùng

            if (i == n) {
                inKetQua(); // Đủ n phần tử thì in
            } else {
                sinhHoanVi(i + 1); // Tiếp tục đệ quy
            }

            used[j] = false; // Quay lui (bỏ đánh dấu)
        }
    }
}

int main() {
    cout << "Nhap n: ";
    cin >> n;

    cout << "\nCac hoan vi cua " << n << " phan tu:" << endl;
    sinhHoanVi(1);

    return 0;
}

2.6.3. Sinh tổ hợp (Combinations)

Bài toán: Liệt kê tất cả tổ hợp chập k của n phần tử

#include <iostream>
using namespace std;

int n, k;
int x[100]; // Mảng lưu tổ hợp hiện tại

void inKetQua() {
    for (int i = 1; i <= k; i++) {
        cout << x[i] << " ";
    }
    cout << endl;
}

void sinhToHop(int i) {
    // Giá trị thử cho x[i] từ x[i-1] + 1 đến n-k+i
    for (int j = x[i-1] + 1; j <= n - k + i; j++) {
        x[i] = j;

        if (i == k) {
            inKetQua(); // Đủ k phần tử thì in
        } else {
            sinhToHop(i + 1); // Tiếp tục đệ quy
        }
    }
}

int main() {
    cout << "Nhap n, k: ";
    cin >> n >> k;

    x[0] = 0; // Khởi tạo giá trị cho x[0]

    cout << "\nCac to hop chap " << k << " cua " << n << " phan tu:" << endl;
    sinhToHop(1);

    return 0;
}

2.7. Mô hình backtracking (Quay lui)

Backtracking là kỹ thuật duyệt tất cả khả năng có thể xảy ra:

procedure backtrack(c):
    if c là cấu hình đầy đủ then:
        xử_lý(c)
    else:
        for each khả_năng cho vị_trí_tiếp_theo do:
            if khả_năng này chấp_nhận_được then:
                chọn khả_năng này
                backtrack(cập_nhật_c)
                bỏ_chọn (quay lui)

2.8. Bài tập ứng dụng: Bài toán 8 hậu

Bài toán: Đặt 8 quân hậu trên bàn cờ 8×8 sao cho không có 2 quân nào ăn nhau.

#include <iostream>
using namespace std;

int n = 8;
int x[100]; // x[i] = cột đặt hậu ở hàng i
bool cot[100], cheoXuoi[100], cheoNguoc[100];
int dem = 0;

void inBanCo() {
    cout << "\nCach dat thu " << ++dem << ":" << endl;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            if (x[i] == j) {
                cout << "Q ";
            } else {
                cout << ". ";
            }
        }
        cout << endl;
    }
}

void datHau(int i) { // Đặt hậu ở hàng thứ i
    for (int j = 1; j <= n; j++) {
        // Kiểm tra có thể đặt hậu ở hàng i, cột j không
        if (!cot[j] && !cheoXuoi[i - j + n] && !cheoNguoc[i + j]) {
            x[i] = j; // Đặt hậu hàng i vào cột j

            // Đánh dấu
            cot[j] = true;
            cheoXuoi[i - j + n] = true;
            cheoNguoc[i + j] = true;

            if (i == n) {
                inBanCo(); // Đã đặt đủ 8 hậu
            } else {
                datHau(i + 1); // Đặt hậu tiếp theo
            }

            // Quay lui (bỏ đánh dấu)
            cot[j] = false;
            cheoXuoi[i - j + n] = false;
            cheoNguoc[i + j] = false;
        }
    }
}

int main() {
    cout << "Cac cach dat 8 quan hau tren ban co 8x8:" << endl;
    datHau(1);
    cout << "\nTong so cach: " << dem << endl;

    return 0;
}

2.9. Ưu điểm và nhược điểm của đệ quy

Ưu điểm:
Ưu điểm Mô tả
Mã ngắn gọn Giải quyết bài toán phức tạp bằng ít dòng mã
Dễ hiểu Phản ánh trực tiếp cấu trúc tự nhiên của bài toán
Phù hợp CTDL đệ quy Lý tưởng cho cây, đồ thị, chia để trị
Nhược điểm:
Nhược điểm Mô tả Giải pháp
Tốn bộ nhớ Mỗi lần gọi đệ quy tốn stack frame Dùng vòng lặp hoặc đệ quy có nhớ
Hiệu suất Có thể tính lại cùng giá trị nhiều lần Memoization (phần 4)
Khó debug Khó theo dõi luồng thực thi In log, dùng debugger
Tràn stack Gọi đệ quy quá sâu gây stack overflow Tăng kích thước stack hoặc đổi sang vòng lặp

2.10. Khi nào nên dùng đệ quy?

  1. Bài toán có cấu trúc đệ quy tự nhiên
  2. Duyệt cây/thư mục
  3. Bài toán chia để trị
  4. Quay lui, sinh cấu hình
  5. Định nghĩa toán học đệ quy (Fibonacci, giai thừa)

2.11. Bài tập thực hành

Bài 1: Đếm số cách bước cầu thang

Có n bậc thang, mỗi bước có thể đi 1 hoặc 2 bậc. Đếm số cách đi.

#include <iostream>
using namespace std;

int demCach(int n) {
    if (n <= 1) return 1;
    return demCach(n - 1) + demCach(n - 2);
}

int main() {
    int n;
    cout << "Nhap so bac thang: ";
    cin >> n;
    cout << "So cach di: " << demCach(n) << endl;
    return 0;
}
Bài 2: Tìm ước số chung lớn nhất (USCLN)
#include <iostream>
using namespace std;

int USCLN(int a, int b) {
    if (b == 0) return a;
    return USCLN(b, a % b);
}

int main() {
    int a, b;
    cout << "Nhap hai so: ";
    cin >> a >> b;
    cout << "USCLN(" << a << ", " << b << ") = " << USCLN(a, b) << endl;
    return 0;
}

Tổng kết phần 2

Đệ quy là công cụ mạnh mẽ với đặc điểm:

  • Tư duy chia để trị: Chia bài toán lớn thành bài toán nhỏ tương tự
  • Cần điều kiện dừng rõ ràng: Tránh đệ quy vô hạn
  • Phù hợp với bài toán tổ hợp: Sinh hoán vị, tổ hợp, chỉnh hợp

Lưu ý quan trọng:

  1. Luôn xác định điều kiện dừng trước khi viết hàm đệ quy
  2. Đảm bảo mỗi lần đệ quy tiến gần đến điều kiện dừng
  3. Cẩn thận với tràn stack khi đệ quy quá sâu
  4. Với bài toán tính toán, xem xét đệ quy có nhớ để tối ưu

3. Phương pháp chia để trị (Divide and Conquer)

3.1. Tổng quan về chia để trị

Chia để trị là phương pháp thiết kế thuật toán bằng cách chia bài toán lớn thành các bài toán nhỏ hơn, giải quyết từng bài toán nhỏ, rồi kết hợp kết quả để giải bài toán ban đầu.

Quy trình 3 bước:
          CHIA ĐỂ TRỊ
              │
      ┌───────┴────────┐
      │                │
1. CHIA             3. TRỊ
Chia bài toán       Kết hợp
thành các           lời giải
phần nhỏ            từ các
      │              phần nhỏ
      └───────┬────────┘
              │
          2. TRỊ
          Giải quyết
          từng phần
          nhỏ (đệ quy)

3.2. Mô hình tổng quát

KiểuDữLiệu chiaDeTri(DữLiệu đầu_vào) {
    // 1. Kiểm tra điều kiện dừng (trường hợp cơ sở)
    if (kích_thước(đầu_vào) đủ_nhỏ) {
        return giải_trực_tiếp(đầu_vào);
    }

    // 2. CHIA: Chia bài toán thành k phần nhỏ hơn
    DữLiệu phần_1 = chia(đầu_vào, 1);
    DữLiệu phần_2 = chia(đầu_vào, 2);
    // ...
    DữLiệu phần_k = chia(đầu_vào, k);

    // 3. TRỊ: Giải quyết đệ quy từng phần
    KếtQuả kq_1 = chiaDeTri(phần_1);
    KếtQuả kq_2 = chiaDeTri(phần_2);
    // ...
    KếtQuả kq_k = chiaDeTri(phần_k);

    // 4. KẾT HỢP: Tổng hợp kết quả
    return kết_hợp(kq_1, kq_2, ..., kq_k);
}

3.3. Ví dụ 1: Tìm kiếm nhị phân (Binary Search)

Mô tả:

Cho một mảng đã sắp xếp, tìm vị trí của phần tử x.

Ý tưởng:
  1. So sánh x với phần tử giữa mảng
  2. Nếu bằng → tìm thấy
  3. Nếu x nhỏ hơn → tìm trong nửa trái
  4. Nếu x lớn hơn → tìm trong nửa phải
Sơ đồ hoạt động:
Tìm số 23 trong mảng [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]

Bước 1: [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
        ↑               ↑                     ↑
        left=0          mid=4                 right=9
        arr[mid] = 16 < 23 → tìm bên phải

Bước 2: [23, 38, 56, 72, 91]
        ↑        ↑        ↑
        left=5   mid=7    right=9
        arr[mid] = 56 > 23 → tìm bên trái

Bước 3: [23, 38]
        ↑   ↑   ↑
        l=5 m=6 r=7
        arr[mid] = 38 > 23 → tìm bên trái

Bước 4: [23]
        ↑
        Tìm thấy tại vị trí 5
Mã nguồn:
#include <iostream>
#include <vector>
using namespace std;

// Hàm tìm kiếm nhị phân (đệ quy)
int binarySearch(vector<int>& arr, int left, int right, int x) {
    // Điều kiện dừng: khoảng tìm kiếm không hợp lệ
    if (left > right) {
        return -1; // Không tìm thấy
    }

    // Tìm phần tử giữa
    int mid = left + (right - left) / 2;

    // So sánh
    if (arr[mid] == x) {
        return mid; // Tìm thấy
    } else if (arr[mid] > x) {
        // Tìm bên trái
        return binarySearch(arr, left, mid - 1, x);
    } else {
        // Tìm bên phải
        return binarySearch(arr, mid + 1, right, x);
    }
}

// Hàm tìm kiếm nhị phân (không đệ quy - vòng lặp)
int binarySearchIterative(vector<int>& arr, int x) {
    int left = 0;
    int right = arr.size() - 1;

    while (left <= right) {
        int mid = left + (right - left) / 2;

        if (arr[mid] == x) {
            return mid;
        } else if (arr[mid] > x) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }

    return -1;
}

int main() {
    vector<int> arr = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91};
    int x = 23;

    // Sử dụng đệ quy
    int result = binarySearch(arr, 0, arr.size() - 1, x);

    if (result != -1) {
        cout << "Tim thay " << x << " tai vi tri (de quy): " << result << endl;
    } else {
        cout << "Khong tim thay " << x << endl;
    }

    // Sử dụng vòng lặp
    result = binarySearchIterative(arr, x);
    if (result != -1) {
        cout << "Tim thay " << x << " tai vi tri (vong lap): " << result << endl;
    }

    return 0;
}

3.4. Ví dụ 2: Sắp xếp trộn (Merge Sort)

Nguyên lý hoạt động:
                 [38, 27, 43, 3, 9, 82, 10]
                         CHIA
                ┌───────────────────────┐
           [38, 27, 43, 3]        [9, 82, 10]
                CHIA                    CHIA
          ┌─────────────┐          ┌─────────┐
       [38, 27]     [43, 3]     [9, 82]    [10]
          CHIA         CHIA        CHIA      │
        ┌─────┐     ┌─────┐     ┌─────┐      │
      [38]  [27]  [43]  [3]   [9]  [82]    [10]
        │     │     │     │     │     │      │
        TRỘN  │     TRỘN  │     TRỘN  │      │
        └─────┘     └─────┘     └─────┘      │
       [27, 38]     [3, 43]     [9, 82]      │
            │           │           │        │
            └───────────┼───────────┘        │
                  TRỘN  │                    │
                 ┌──────┘                    │
           [3, 27, 38, 43]                   │
                 │                           │
                 └──────────────┼──────────-─┘
                          TRỘN  │
                   [3, 9, 10, 27, 38, 43, 82]
Mã nguồn:
#include <iostream>
#include <vector>
using namespace std;

// Hàm trộn hai mảng đã sắp xếp
void merge(vector<int>& arr, int left, int mid, int right) {
    int n1 = mid - left + 1; // Kích thước mảng trái
    int n2 = right - mid;    // Kích thước mảng phải

    // Tạo mảng tạm
    vector<int> L(n1), R(n2);

    // Copy dữ liệu vào mảng tạm
    for (int i = 0; i < n1; i++) {
        L[i] = arr[left + i];
    }
    for (int j = 0; j < n2; j++) {
        R[j] = arr[mid + 1 + j];
    }

    // Trộn hai mảng vào arr
    int i = 0, j = 0, k = left;

    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k] = L[i];
            i++;
        } else {
            arr[k] = R[j];
            j++;
        }
        k++;
    }

    // Copy phần còn lại của L (nếu có)
    while (i < n1) {
        arr[k] = L[i];
        i++;
        k++;
    }

    // Copy phần còn lại của R (nếu có)
    while (j < n2) {
        arr[k] = R[j];
        j++;
        k++;
    }
}

// Hàm sắp xếp trộn (đệ quy)
void mergeSort(vector<int>& arr, int left, int right) {
    // Điều kiện dừng: chỉ còn 1 phần tử
    if (left >= right) {
        return;
    }

    // Tìm điểm giữa
    int mid = left + (right - left) / 2;

    // Sắp xếp nửa trái
    mergeSort(arr, left, mid);

    // Sắp xếp nửa phải
    mergeSort(arr, mid + 1, right);

    // Trộn hai nửa đã sắp xếp
    merge(arr, left, mid, right);
}

// Hàm in mảng
void printArray(const vector<int>& arr) {
    for (int num : arr) {
        cout << num << " ";
    }
    cout << endl;
}

int main() {
    vector<int> arr = {38, 27, 43, 3, 9, 82, 10};

    cout << "Mang ban dau: ";
    printArray(arr);

    mergeSort(arr, 0, arr.size() - 1);

    cout << "Mang sau khi sap xep: ";
    printArray(arr);

    return 0;
}

3.5. Ví dụ 3: Sắp xếp nhanh (Quick Sort)

Nguyên lý:
  1. Chọn pivot (phần tử chốt)
  2. Phân hoạch: Đưa các phần tử nhỏ hơn pivot về bên trái, lớn hơn về bên phải
  3. Đệ quy áp dụng cho hai mảng con
Mã nguồn:
#include <iostream>
#include <vector>
using namespace std;

// Hàm phân hoạch: chọn pivot, sắp xếp và trả về vị trí pivot
int partition(vector<int>& arr, int low, int high) {
    // Chọn pivot là phần tử cuối cùng
    int pivot = arr[high];

    // Chỉ số của phần tử nhỏ hơn pivot
    int i = low - 1;

    for (int j = low; j < high; j++) {
        // Nếu phần tử hiện tại nhỏ hơn hoặc bằng pivot
        if (arr[j] <= pivot) {
            i++;
            swap(arr[i], arr[j]);
        }
    }

    // Đưa pivot vào đúng vị trí
    swap(arr[i + 1], arr[high]);
    return i + 1;
}

// Hàm sắp xếp nhanh
void quickSort(vector<int>& arr, int low, int high) {
    if (low < high) {
        // pi là chỉ số nơi pivot đã đúng vị trí
        int pi = partition(arr, low, high);

        // Sắp xếp đệ quy các phần bên trái và bên phải pivot
        quickSort(arr, low, pi - 1);
        quickSort(arr, pi + 1, high);
    }
}

// Hàm in mảng
void printArray(const vector<int>& arr) {
    for (int num : arr) {
        cout << num << " ";
    }
    cout << endl;
}

int main() {
    vector<int> arr = {10, 7, 8, 9, 1, 5};

    cout << "Mang ban dau: ";
    printArray(arr);

    int n = arr.size();
    quickSort(arr, 0, n - 1);

    cout << "Mang sau khi sap xep nhanh: ";
    printArray(arr);

    return 0;
}

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

Bảng so sánh thuật toán chia để trị:
Thuật toán Chia Trị (đệ quy) Kết hợp Độ phức tạp
Merge Sort O(1) 2T(n/2) O(n) O(n log n)
Quick Sort O(n) T(k) + T(n-k-1) O(1) O(n log n) trung bình
Binary Search O(1) T(n/2) O(1) O(log n)
Công thức tổng quát:

Nếu bài toán kích thước n được chia thành a bài toán con kích thước n/b, và việc chia/kết hợp tốn D(n)/C(n) thời gian, thì:

~ T(n) = \begin{cases} \Theta(1) & \text{nếu } n \leq n_0 \\ aT(n/b) + D(n) + C(n) & \text{nếu } n > n_0 \end{cases} ~

3.9. Ưu điểm và nhược điểm

Ưu điểm:
Ưu điểm Mô tả
Hiệu quả Giảm độ phức tạp từ O(n²) xuống O(n log n)
Song song hóa Dễ chia để xử lý song song
Tư duy rõ ràng Phản ánh cách tiếp cận tự nhiên
Tối ưu cache Làm việc tốt với bộ nhớ đệm
Nhược điểm:
Nhược điểm Giải pháp
Overhead đệ quy Có thể dùng vòng lặp hoặc stack
Khó cài đặt Cần xác định đúng cách chia/kết hợp
Không phải lúc nào cũng tốt nhất Tùy bài toán có thể có thuật toán tốt hơn

3.10. Mẹo thiết kế thuật toán chia để trị

  1. Xác định trường hợp cơ sở: Bài toán nhỏ nhất có thể giải trực tiếp
  2. Chia đều nếu có thể: Chia thành các phần bằng nhau
  3. Tránh trùng lặp: Đảm bảo không giải cùng bài toán nhiều lần
  4. Kết hợp hiệu quả: Thiết kế bước kết hợp tối ưu

3.11. Bài tập thực hành

Bài 1: Tìm phần tử lớn nhất trong mảng
#include <iostream>
#include <vector>
#include <climits>
using namespace std;

// Phương pháp chia để trị
int findMaxDivideConquer(vector<int>& arr, int left, int right) {
    // Trường hợp cơ sở: chỉ có 1 phần tử
    if (left == right) {
        return arr[left];
    }

    // Trường hợp cơ sở: có 2 phần tử
    if (right == left + 1) {
        return max(arr[left], arr[right]);
    }

    // Chia mảng thành hai nửa
    int mid = left + (right - left) / 2;

    // Tìm max trong mỗi nửa
    int maxLeft = findMaxDivideConquer(arr, left, mid);
    int maxRight = findMaxDivideConquer(arr, mid + 1, right);

    // Kết hợp kết quả
    return max(maxLeft, maxRight);
}

int main() {
    vector<int> arr = {3, 7, 2, 9, 1, 5, 8, 4};

    cout << "Mang: ";
    for (int num : arr) {
        cout << num << " ";
    }
    cout << endl;

    int maxVal = findMaxDivideConquer(arr, 0, arr.size() - 1);
    cout << "Phan tu lon nhat: " << maxVal << endl;

    return 0;
}
Bài 2: Tính lũy thừa a^n
#include <iostream>
using namespace std;

// Tính a^n bằng chia để trị
double power(double a, int n) {
    // Trường hợp cơ sở
    if (n == 0) return 1;
    if (n == 1) return a;

    // Chia bài toán
    double half = power(a, n / 2);

    // Kết hợp kết quả
    if (n % 2 == 0) {
        return half * half;          // a^n = a^(n/2) * a^(n/2)
    } else {
        return half * half * a;      // a^n = a^(n/2) * a^(n/2) * a
    }
}

int main() {
    double a = 2;
    int n = 10;

    cout << a << "^" << n << " = " << power(a, n) << endl;

    // So sánh với cách tính thông thường
    double result = 1;
    for (int i = 0; i < n; i++) {
        result *= a;
    }
    cout << "Kiem tra: " << result << endl;

    return 0;
}

Tổng kết phần 3

Chia để trị là kỹ thuật mạnh mẽ với các đặc điểm:

Các bước cơ bản:

  1. CHIA: Chia bài toán thành các bài toán con nhỏ hơn
  2. TRỊ: Giải quyết từng bài toán con (thường bằng đệ quy)
  3. KẾT HỢP: Tổng hợp lời giải từ các bài toán con

Ứng dụng điển hình:

  • Sắp xếp: Merge Sort, Quick Sort
  • Tìm kiếm: Binary Search
  • Bài toán hình học: Tìm cặp điểm gần nhất
  • Tính toán: Nhân ma trận Strassen, tính lũy thừa

Lưu ý quan trọng:

  1. Chọn điểm chia hợp lý: Thường chia đôi hoặc chia theo tỷ lệ cố định
  2. Hiệu quả bước kết hợp: Quyết định độ phức tạp của thuật toán
  3. Cân nhắc overhead: Đệ quy có thể tốn bộ nhớ, cân nhắc dùng vòng lặp

Quy tắc ngón tay cái:

  • Nếu bài toán có thể chia thành các phần độc lập → chia để trị
  • Nếu kích thước giảm theo cấp số nhân (n → n/2 → n/4...) → hiệu quả cao
  • Nếu bước kết hợp đơn giản (O(1) hoặc O(n)) → khả thi

4. Phương pháp đệ quy có nhớ (Memoization)

4.1. Khái niệm về đệ quy có nhớ

Đệ quy có nhớ (Memoization) là kỹ thuật tối ưu hóa đệ quy bằng cách lưu trữ kết quả của các bài toán con đã được tính toán để tránh tính lại nhiều lần.

4.2. Vấn đề của đệ quy thông thường

Xét lại bài toán Fibonacci với đệ quy thông thường:

int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n-1) + fibonacci(n-2);
}
Cây đệ quy cho fibonacci(5):
                         fibonacci(5)
                         /          \
              fibonacci(4)          fibonacci(3)
              /          \          /          \
      fibonacci(3)  fibonacci(2)  fibonacci(2) fibonacci(1)
      /          \   /      \     /      \
  fibonacci(2) f(1) f(1)   f(0) f(1)    f(0)
  /      \
f(1)    f(0)

Nhận xét:

  • fibonacci(3) được tính 2 lần
  • fibonacci(2) được tính 3 lần
  • fibonacci(1) được tính 5 lần
  • Độ phức tạp: O(2^n) - rất kém hiệu quả

4.3. Nguyên lý hoạt động của đệ quy có nhớ

  1. Tạo bảng nhớ (thường là mảng hoặc bảng băm) để lưu kết quả
  2. Trước khi tính toán, kiểm tra xem kết quả đã có trong bảng nhớ chưa
  3. Nếu đã có, trả về kết quả từ bảng nhớ
  4. Nếu chưa có, tính toán, lưu vào bảng nhớ, rồi trả về

4.4. Cài đặt Fibonacci với đệ quy có nhớ

#include <iostream>
#include <vector>
using namespace std;

// Biến toàn cục để lưu kết quả đã tính
vector<long long> memo(100, -1); // -1 nghĩa là chưa được tính

long long fibonacciMemo(int n) {
    // Nếu đã tính trước đó, trả về kết quả từ bảng nhớ
    if (memo[n] != -1) {
        return memo[n];
    }

    // Trường hợp cơ sở
    if (n <= 1) {
        memo[n] = n;
        return n;
    }

    // Tính toán và lưu vào bảng nhớ
    memo[n] = fibonacciMemo(n-1) + fibonacciMemo(n-2);
    return memo[n];
}

int main() {
    int n;
    cout << "Nhap n: ";
    cin >> n;

    // Khởi tạo bảng nhớ
    fill(memo.begin(), memo.end(), -1);

    cout << "Fibonacci(" << n << ") = " << fibonacciMemo(n) << endl;

    // Hiển thị bảng nhớ
    cout << "\nBang nho sau khi tinh:" << endl;
    for (int i = 0; i <= n; i++) {
        cout << "memo[" << i << "] = " << memo[i] << endl;
    }

    return 0;
}

4.5. So sánh hiệu suất

#include <iostream>
#include <vector>
#include <chrono>
using namespace std;
using namespace std::chrono;

// Đệ quy thông thường
int fibNormal(int n) {
    if (n <= 1) return n;
    return fibNormal(n-1) + fibNormal(n-2);
}

// Đệ quy có nhớ
vector<long long> memo(100, -1);
long long fibMemo(int n) {
    if (memo[n] != -1) return memo[n];
    if (n <= 1) {
        memo[n] = n;
        return n;
    }
    memo[n] = fibMemo(n-1) + fibMemo(n-2);
    return memo[n];
}

int main() {
    int n = 40;

    // Đo thời gian đệ quy thông thường
    auto start1 = high_resolution_clock::now();
    long long result1 = fibNormal(n);
    auto stop1 = high_resolution_clock::now();
    auto duration1 = duration_cast<milliseconds>(stop1 - start1);

    // Đo thời gian đệ quy có nhớ
    fill(memo.begin(), memo.end(), -1);
    auto start2 = high_resolution_clock::now();
    long long result2 = fibMemo(n);
    auto stop2 = high_resolution_clock::now();
    auto duration2 = duration_cast<milliseconds>(stop2 - start2);

    cout << "So sanh hieu suat voi n = " << n << ":" << endl;
    cout << "----------------------------------------" << endl;
    cout << "De quy thong thuong:" << endl;
    cout << "  Ket qua: " << result1 << endl;
    cout << "  Thoi gian: " << duration1.count() << " ms" << endl;
    cout << "\nDe quy co nho:" << endl;
    cout << "  Ket qua: " << result2 << endl;
    cout << "  Thoi gian: " << duration2.count() << " ms" << endl;

    return 0;
}

4.6. Bài toán cái túi (Knapsack) với đệ quy có nhớ

Bài toán: Cho n đồ vật, mỗi đồ vật có trọng lượng w[i] và giá trị v[i]. Túi có sức chứa tối đa W. Tìm cách chọn các đồ vật để tổng giá trị lớn nhất mà không vượt quá trọng lượng W.

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

const int MAX_N = 100;
const int MAX_W = 1000;
int memo[MAX_N][MAX_W]; // Bảng nhớ

// Khởi tạo bảng nhớ với -1
void initializeMemo() {
    for (int i = 0; i < MAX_N; i++) {
        for (int j = 0; j < MAX_W; j++) {
            memo[i][j] = -1;
        }
    }
}

// Hàm đệ quy có nhớ
int knapsackMemo(int W, vector<int>& weight, vector<int>& value, int n) {
    // Trường hợp cơ sở
    if (n == 0 || W == 0) {
        return 0;
    }

    // Nếu đã tính trước đó, trả về kết quả
    if (memo[n][W] != -1) {
        return memo[n][W];
    }

    // Nếu trọng lượng vật thứ n lớn hơn sức chứa, bỏ qua
    if (weight[n-1] > W) {
        memo[n][W] = knapsackMemo(W, weight, value, n-1);
        return memo[n][W];
    }

    // Trường hợp 1: Không lấy vật thứ n
    int notTake = knapsackMemo(W, weight, value, n-1);

    // Trường hợp 2: Lấy vật thứ n
    int take = value[n-1] + knapsackMemo(W - weight[n-1], weight, value, n-1);

    // Lấy giá trị lớn hơn và lưu vào bảng nhớ
    memo[n][W] = max(notTake, take);
    return memo[n][W];
}

// Hàm truy vết để biết cần lấy những vật nào
void traceSolution(int W, vector<int>& weight, vector<int>& value, int n) {
    cout << "\nCac vat duoc chon:" << endl;
    int i = n, w = W;
    int totalWeight = 0, totalValue = 0;

    while (i > 0 && w > 0) {
        if (memo[i][w] != memo[i-1][w]) {
            cout << "Vat " << i << " (trong luong: " << weight[i-1] 
                 << ", gia tri: " << value[i-1] << ")" << endl;
            totalWeight += weight[i-1];
            totalValue += value[i-1];
            w -= weight[i-1];
        }
        i--;
    }

    cout << "\nTong trong luong: " << totalWeight << endl;
    cout << "Tong gia tri: " << totalValue << endl;
}

int main() {
    vector<int> value = {60, 100, 120};
    vector<int> weight = {10, 20, 30};
    int W = 50; // Sức chứa túi
    int n = value.size();

    initializeMemo();

    int maxValue = knapsackMemo(W, weight, value, n);

    cout << "Bai toan cai tui:" << endl;
    cout << "Suc chua: " << W << endl;
    cout << "So vat: " << n << endl;
    cout << "\nGia tri lon nhat co the dat duoc: " << maxValue << endl;

    // Truy vết
    traceSolution(W, weight, value, n);

    return 0;
}

4.7. Bài toán đường đi ngắn nhất (Grid Path)

Bài toán: Tìm số đường đi từ góc trên bên trái đến góc dưới bên phải trong lưới m×n, chỉ được di chuyển xuống hoặc sang phải.

#include <iostream>
#include <vector>
using namespace std;

const int MAX = 100;
long long memo[MAX][MAX];

// Khởi tạo bảng nhớ
void initializeGridMemo(int m, int n) {
    for (int i = 0; i <= m; i++) {
        for (int j = 0; j <= n; j++) {
            memo[i][j] = -1;
        }
    }
}

// Đệ quy có nhớ
long long countPathsMemo(int m, int n) {
    // Trường hợp cơ sở
    if (m == 0 || n == 0) {
        return 1; // Chỉ có 1 cách đi trên biên
    }

    // Kiểm tra bảng nhớ
    if (memo[m][n] != -1) {
        return memo[m][n];
    }

    // Số đường đi = đường đi từ ô trên + đường đi từ ô trái
    memo[m][n] = countPathsMemo(m-1, n) + countPathsMemo(m, n-1);
    return memo[m][n];
}

// Hiển thị bảng nhớ
void printMemo(int m, int n) {
    cout << "\nBang nho:" << endl;
    for (int i = 0; i <= m; i++) {
        for (int j = 0; j <= n; j++) {
            cout << memo[i][j] << "\t";
        }
        cout << endl;
    }
}

int main() {
    int m = 3, n = 3;

    initializeGridMemo(m, n);

    cout << "So duong di trong luoi " << m << "x" << n << ": ";
    cout << countPathsMemo(m, n) << endl;

    printMemo(m, n);

    return 0;
}

4.8. Các dạng bài toán phù hợp với đệ quy có nhớ

  1. Bài toán có cấu trúc con trùng lặp: Fibonacci, tổ hợp C(n,k)
  2. Bài toán tối ưu: Cái túi, đường đi ngắn nhất
  3. Bài toán đếm: Số cách bước thang, số đường đi
  4. Bài toán chuỗi: Xâu con chung dài nhất (LCS)

4.9. Bài tập thực hành

Bài 1: Tính tổ hợp C(n, k) với đệ quy có nhớ
#include <iostream>
#include <vector>
using namespace std;

const int MAX = 100;
vector<vector<long long>> combMemo(MAX, vector<long long>(MAX, -1));

long long combinationMemo(int n, int k) {
    // Trường hợp cơ sở
    if (k == 0 || k == n) {
        return 1;
    }

    // Kiểm tra bảng nhớ
    if (combMemo[n][k] != -1) {
        return combMemo[n][k];
    }

    // Công thức: C(n,k) = C(n-1,k-1) + C(n-1,k)
    combMemo[n][k] = combinationMemo(n-1, k-1) + combinationMemo(n-1, k);
    return combMemo[n][k];
}

int main() {
    int n = 10, k = 5;

    // Khởi tạo trường hợp cơ sở
    for (int i = 0; i <= n; i++) {
        combMemo[i][0] = combMemo[i][i] = 1;
    }

    cout << "C(" << n << ", " << k << ") = " << combinationMemo(n, k) << endl;

    return 0;
}
Bài 2: Bài toán đổi tiền (Coin Change)
#include <iostream>
#include <vector>
#include <climits>
using namespace std;

const int MAX = 1000;
vector<int> coinMemo(MAX, -1);

// Đổi số tiền amount bằng các đồng xu coins
int coinChangeMemo(vector<int>& coins, int amount) {
    // Trường hợp cơ sở
    if (amount == 0) return 0;
    if (amount < 0) return -1;

    // Kiểm tra bảng nhớ
    if (coinMemo[amount] != -1) {
        return coinMemo[amount];
    }

    int minCoins = INT_MAX;

    // Thử từng đồng xu
    for (int coin : coins) {
        int subResult = coinChangeMemo(coins, amount - coin);
        if (subResult >= 0) {
            minCoins = min(minCoins, subResult + 1);
        }
    }

    // Lưu kết quả vào bảng nhớ
    coinMemo[amount] = (minCoins == INT_MAX) ? -1 : minCoins;
    return coinMemo[amount];
}

int main() {
    vector<int> coins = {1, 2, 5};
    int amount = 11;

    fill(coinMemo.begin(), coinMemo.end(), -1);

    int result = coinChangeMemo(coins, amount);

    cout << "Doi " << amount << " dong bang cac dong xu {1, 2, 5}" << endl;
    cout << "So dong xu it nhat: " << result << endl;

    return 0;
}

4.10. Bảng tổng hợp ứng dụng

Bài toán Phương pháp tốt nhất Độ phức tạp Ghi chú
Tính Fibonacci Đệ quy có nhớ O(n) Tránh dùng đệ quy thường (O(2^n))
Sắp xếp mảng Chia để trị (Merge/Quick Sort) O(n log n) Nhanh hơn sắp xếp chọn (O(n²))
Tìm kiếm Chia để trị (Binary Search) O(log n) Yêu cầu mảng đã sắp xếp
Bài toán cái túi Đệ quy có nhớ/Quy hoạch động O(n×W) W là sức chứa túi
Sinh hoán vị Đệ quy quay lui O(n!) Duyệt toàn bộ
Tìm đường đi Đệ quy có nhớ O(m×n) m×n là kích thước lưới

4.11. Lời khuyên thực hành

  1. Bắt đầu đơn giản: Luôn viết giải pháp đệ quy đơn giản trước
  2. Phân tích tính trùng lặp: Xác định xem bài toán con có bị tính lại không
  3. Chọn cấu trúc lưu trữ:
    • Mảng 1D cho bài toán 1 tham số
    • Mảng 2D cho bài toán 2 tham số
    • Bảng băm cho tham số phức tạp
  4. Khởi tạo giá trị đặc biệt: Dùng -1, INT_MIN, v.v. để đánh dấu "chưa tính"
  5. Kiểm tra bảng nhớ trước: Luôn kiểm tra trước khi tính toán

4.12. Công thức chuyển đổi từ đệ quy thường sang đệ quy có nhớ

  • Bước 1: Thêm tham số bảng nhớ vào hàm
  • Bước 2: Kiểm tra bảng nhớ ở đầu hàm
  • Bước 3: Lưu kết quả vào bảng nhớ trước khi return
  • Bước 4: Khởi tạo bảng nhớ ở hàm gọi
Sunlight
o17, Tháng 1, 2020, 17:30 0

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