Skip to main content

Command Palette

Search for a command to run...

Syllabus 110 ngày C++

Updated
212 min read

60 ngày học C++

Phần 1: Kiến thức nền tảng

Ngày 1: Cơ chế biên dịch, liên kết và vòng đời của một chương trình C++

  • Pipeline Biên dịch

    • Preprocessing (tiền xử lý): Cách #include, #define, #ifdef hoạt động. Hiểu về "Translation Unit".

    • Compilation (biên dịch): Phân tích cú pháp (parsing), Code Generation. Cách trình biên dịch chuyển mã nguồn thành mã Assembly.

    • Assembling: Chuyển mã Assembly thành mã máy (Object file - .o, .obj). Cấu trúc của một Object file.

    • Linking (liên kết): Sự khác biệt giữa static linking (liên kết tĩnh) và dynamic linking (liên kết động - DLL/SO). Hiểu về symbol table và lỗi undefined reference.

  • Thực hành và quan sát

    • Sử dụng CLI (Command Line): Tập biên dịch bằng tay với g++ hoặc clang++ (dùng các flag E, S, c để xem kết quả của từng bước).

    • Thực hành tách chương trình thành nhiều file .cpp.h. Tự tạo một thư viện tĩnh (.a hoặc .lib) và liên kết nó.

  • Advanced concepts & optimization

    • Học về One Definition Rule (ODR): Tại sao nó quan trọng để tránh lỗi liên kết.

    • Tìm hiểu về Link-Time Optimization (LTO): Cách trình biên dịch tối ưu hóa mã nguồn xuyên suốt các file khác nhau.

Ngày 2: Hệ thống kiểu dữ liệu tĩnh, type safety và kiến trúc bộ nhớ

  • Hệ thống kiểu dữ liệu

    • Fundamental types: int, char, float, double, bool. Kích thước (size) và miền giá trị (range).

    • Memory alignment & padding: Tại sao một struct có 1 char và 1 int lại tốn 8 bytes thay vì 5 bytes? (yếu tố cực quan trọng để tối ưu hóa bộ nhớ).

    • Static vs dynamic typing: Tại sao kiểm tra kiểu lúc biên dịch (static) lại giúp C++ chạy nhanh hơn các ngôn ngữ thông dịch.

  • Type safety & initialization

    • Initialization: Sự khác biệt giữa ={} (uniform initialization). Tại sao {} giúp ngăn chặn lỗi "narrowing conversion" (mất dữ liệu khi ép kiểu).

    • Type conversions: Ép kiểu ngầm định (implicit) vs tường minh (explicit). Sự nguy hiểm của C-style cast.

    • Const-correctness: Sử dụng const mọi nơi có thể. Tại sao const giúp trình biên dịch tối ưu hóa code tốt hơn.

  • Nghiên cứu

    • Internal representation: Xem giá trị biến dưới dạng hexadecimal (Hệ 16) thông qua debugger.

    • Tìm hiểu về undefined behavior (UB): Tại sao việc tràn số (overflow) hoặc dùng biến chưa khởi tạo lại khiến chương trình chạy không ổn định.

Ngày 3: Object, value và L-value vs R-value

  • Objects và values

    • Định nghĩa về object: Một vùng nhớ có tên, có kiểu và có tuổi thọ.

    • L-value (left value): Những thứ có danh tính (identity), có địa chỉ bộ nhớ cố định (biến, thành viên của struct).

    • R-value (right value): Những giá trị tạm thời, không có danh tính (số literal, kết quả trung gian của phép tính).

  • Tham chiếu (references)

    • L-value reference (T&): Cách dùng bí danh cho đối tượng hiện có.

    • R-value reference (T&&): (Giới thiệu sơ lược) Khả năng "chiếm đoạt" tài nguyên từ các đối tượng tạm thời.

    • Const reference: Tại sao truyền tham số qua const T& là tiêu chuẩn vàng để tối ưu hiệu năng cho các đối tượng lớn.

  • Optimization mindset

    • Copy elision & return value optimization (RVO): Cách trình biên dịch tự động xóa bỏ các bước copy không cần thiết.

    • Bài tập thực hành: Viết các hàm hoán đổi (swap) giá trị và quan sát cách dữ liệu được di chuyển trong bộ nhớ.

Tổng kết kiến thức cần đạt được sau 3 ngày

  1. Giải thích được chính xác chuyện gì xảy ra từ lúc nhấn "Build" đến khi chương trình chạy.

  2. Biết cách sắp xếp các thành viên trong một struct để tốn ít RAM nhất (Alignment).

  3. Phân biệt được ngay lập tức một biểu thức là L-value hay R-value.

  4. Luôn khởi tạo biến bằng dấu {} và dùng const một cách bản năng.

Ngày 4: Biểu thức (expressions) & toán tử

  • Toán tử và thứ tự ưu tiên

    • Toán tử số học & Bitwise: Học sâu về các toán tử bit (&, |, ^, ~, <<, >>). Đây là chìa khóa của optimization.

    • Short-circuit evaluation: Cách &&|| hoạt động. Làm thế nào để sắp xếp điều kiện giúp chương trình chạy nhanh hơn.

    • Type promotion: Chuyện gì xảy ra khi cộng một char với một int? Hiểu về "usual arithmetic conversions".

  • Biểu thức hằng và tối ưu hóa

    • L-value vs R-value trong biểu thức: Cách các toán tử như ++i (tiền tố) khác với i++ (hậu tố) về mặt hiệu năng. Tại sao nên dùng ++i cho các kiểu dữ liệu phức tạp.

    • Constant Expressions (constexpr): Ép trình biên dịch tính toán kết quả ngay khi biên dịch thay vì đợi đến lúc chạy chương trình.

  • Thực hành

    • Thử thách: Viết các phép nhân/chia bằng toán tử dịch bit (<<, >>) và so sánh tốc độ.

    • Nghiên cứu về operator precedence để tránh các lỗi logic khó tìm (bug).

Ngày 5: luồng điều khiển & bộ điều phối nhánh (branch predictor)

  • Câu lệnh điều kiện

    • If-else vs switch-case: Khi nào switch nhanh hơn if? Hiểu về "jump tables".

    • Selection statements với initializer (C++17): if (init; condition).

    • Tối ưu hóa nhánh (branch prediction): Tại sao xử lý một mảng đã sắp xếp lại nhanh hơn mảng chưa sắp xếp? Cách viết code để giảm thiểu "branch misprediction".

  • Vòng lặp

    • For, While, Do-while: Sự khác biệt về mã máy.

    • Range-for loop (C++11/17/20): Cách hoạt động bên dưới thông qua Iterators.

    • Loop unrolling: Tìm hiểu cách trình biên dịch "trải" vòng lặp để tăng tốc độ.

  • Thực hành

    • Viết thuật toán tìm kiếm trên mảng lớn và đo thời gian khi dữ liệu được sort vs chưa sort.

    • Sử dụng lệnh [[likely]][[unlikely]] (C++20) để hướng dẫn trình biên dịch tối ưu hóa các nhánh điều kiện.

Ngày 6: hàm (functions)

  • Khai báo, định nghĩa và overloading

    • Function signature: Các thành phần tạo nên danh tính của hàm.

    • Argument matching: Cách trình biên dịch chọn hàm phù hợp nhất trong nạp chồng hàm (Overloading resolution).

    • Default arguments: Cách chúng được triển khai ở mức mã máy.

  • Cơ chế truyền tham số

    • Pass-by-value: Khi nào là tốt? (Cho các kiểu dữ liệu nhỏ như int, double).

    • Pass-by-reference (T&): Tránh copy dữ liệu.

    • Pass-by-const-reference (const T&): Tiêu chuẩn vàng để truyền các đối tượng lớn (string, vector, struct).

    • Pass-by-pointer (T*): Khi nào dùng con trỏ thay vì tham chiếu? (C++ Core Guidelines).

  • Phân tích

    • So sánh hiệu năng giữa việc trả về một object lớn theo kiểu truyền thống vs trả về qua tham số.

    • Tìm hiểu về copy elisionreturn value optimization (RVO).

Ngày 7: call stack, inlining và recursion optimization

  • Ngăn xếp gọi hàm (the call stack)

    • Stack frame: Chuyện gì xảy ra khi gọi một hàm? (Lưu địa chỉ trả về, lưu biến cục bộ, push tham số).

    • Stack overflow: Nguyên nhân và cách phòng tránh.

    • Hiểu về calling conventions (__cdecl, __stdcall, __fastcall) - Cách các tham số được đưa vào thanh ghi hoặc stack.

  • Inlining và đệ quy

    • Inline functions: Cách dùng từ khóa inline và tại sao trình biên dịch có thể lờ nó đi. Lợi ích của việc giảm chi phí gọi hàm (function call overhead).

    • Recursion vs Iteration: Khi nào dùng đệ quy?

    • Tail call optimization (TCO): Cách chuyển đổi đệ quy đuôi thành vòng lặp để tránh tràn stack và tăng tốc.

  • Tổng kết và thực hành

    • Viết hàm tính Fibonacci theo 3 cách: Đệ quy thường, đệ quy đuôi, và vòng lặp.

    • Sử dụng công cụ như compiler explorer (godbolt.org) để xem mã Assembly của hàm khi có và không có inline.

Chiến lược

  1. Compiler Explorer (godbolt.org): Đây là công cụ "phải dùng" trong 4 ngày này. Hãy dán đoạn code vòng lặp hoặc hàm của bạn vào đó, chọn trình biên dịch (GCC/Clang) và xem mã máy sinh ra. Bạn sẽ thấy sự khác biệt khi bật flag tối ưu hóa O2 hoặc O3.

  2. Đo đạc thực tế: Luôn dùng thư viện <chrono> để đo thời gian chạy của các đoạn code khác nhau. Optimization mà không có số liệu đo đạc thì chỉ là phỏng đoán.

  3. Tập trung vào Cache Locality: Khi học về vòng lặp, hãy để ý cách truy cập mảng theo hàng vs theo cột (row-major vs column-major). Điều này ảnh hưởng cực lớn đến hiệu năng do cơ chế Cache của CPU.

Ngày 8: Con trỏ & sự phân mảnh bộ nhớ

Mục tiêu: Hiểu con trỏ dưới góc độ vật lý và chi phí truy xuất.

  • Lý thuyết địa chỉ & định dạng

    • Memory Address Space: Hiểu về không gian địa chỉ ảo (virtual address space).

    • Pointer types: Tại sao int*double* có cùng kích thước (thường 8-byte trên hệ thống 64-bit) nhưng cách giải mã dữ liệu lại khác nhau.

    • Const pointers: Phân biệt rõ 4 loại kết hợp giữa const và con trỏ. Tại sao dùng const giúp trình biên dịch thực hiện constant folding.

  • Indirection & overhead

    • Pointer indirection: Chi phí khi CPU phải "nhảy" từ địa chỉ con trỏ đến địa chỉ dữ liệu (pointer chasing).

    • Void & reinterpret_cast:

      • Cách dùng con trỏ thô để can thiệp vào tầng byte dữ liệu.
  • Lab

    • Thực hành: Đo thời gian truy cập một biến trực tiếp vs truy cập qua 3 tầng con trỏ (triple pointer). Hiểu về độ trễ (latency).

Ngày 9: mảng kiểu c, cache line & memory locality

Mục tiêu: Hiểu tại sao Mảng là cấu trúc dữ liệu nhanh nhất nhờ cơ chế Cache của CPU.

  • Mảng & CPU cache

    • Cache line: CPU không load 1 byte, nó load một khối (thường là 64 byte). Nếu bạn dùng mảng, các phần tử tiếp theo đã nằm sẵn trong cache (cache hit).

    • Spatial locality: Tại sao duyệt mảng tuần tự lại "vắt kiệt" được sức mạnh của CPU.

    • False sharing: Khi hai con trỏ trỏ vào cùng một cache line trên đa nhân.

  • Mảng đa chiều & row-major order

    • Cách C++ trải phẳng mảng A[M][N] vào bộ nhớ.

    • Challenge: So sánh tốc độ khi duyệt mảng 2 chiều theo hàng (row) vs theo cột (column). Giải thích dựa trên cache line.

  • Rủi ro của mảng kiểu C

    • Buffer overflow: Viết một chương trình nhỏ tự gây tràn mảng để ghi đè giá trị biến lân cận (hiểu để phòng tránh).

NGÀY 10: PHÉP TOÁN CON TRỎ (ARITHMETIC) & ALIGNMENT (SẮP XẾP BỘ NHỚ)

Mục tiêu: Thao tác dữ liệu ở tốc độ cao và tối ưu kích thước cấu trúc dữ liệu.

  • Pointer arithmetic & scaling

    • Sự khác biệt giữa ptr++ trên các kiểu dữ liệu có sizeof khác nhau.

    • Sử dụng con trỏ để duyệt mảng thay vì dùng chỉ số [i] (Phân tích mã Assembly để xem trình biên dịch tối ưu cái nào tốt hơn).

  • Data alignment & padding

    • Tại sao CPU truy cập dữ liệu tại địa chỉ chia hết cho 4 hoặc 8 nhanh hơn.

    • Struct padding: Cách sắp xếp thứ tự các biến trong struct để giảm kích thước (ví dụ: đặt các biến lớn lên trước).

  • Thực hành

    • Sử dụng alignofoffsetof để kiểm tra vị trí các biến trong bộ nhớ.

    • Bài tập: Thiết kế một struct chứa 5 kiểu dữ liệu khác nhau sao cho tổng kích thước là nhỏ nhất.

NGÀY 11: QUẢN LÝ VÙNG NHỚ TỰ DO (FREE STORE) & RAW MEMORY

Mục tiêu: Kiểm soát tuyệt đối việc cấp phát và thu hồi.

  • Heap allocation

    • Cơ chế hoạt động của newdelete. Chuyện gì xảy ra với bảng quản lý bộ nhớ của hệ điều hành.

    • Memory fragmentation: Tại sao cấp phát/giải phóng quá nhiều mảnh nhỏ lại làm chậm hệ thống.

  • Resource management & raw memory

    • Placement new: Cấp phát đối tượng tại một địa chỉ bộ nhớ có sẵn (Kỹ thuật cực cao trong tối ưu hóa memory pool).

    • Phân tích lỗi: Dangling pointers, wild pointers, và memory leaks.

  • Lab

    • Viết một hàm SlowCopy vs FastCopy (sử dụng kiến thức về cache line và pointer arithmetic).

    • Tổng hợp: Tại sao std::vector (dùng mảng liên tục) lại vượt trội hơn std::list (dùng con trỏ nhảy cóc) về mặt hiệu năng thực tế.

Chiến lược

  1. Visual Studio / GDB Debugger: Hãy dùng cửa sổ "Memory View" để nhìn trực tiếp các byte trong RAM khi bạn thực hiện phép toán con trỏ.

  2. Benchmark: Với mỗi kỹ thuật tối ưu (như sắp xếp lại struct hay duyệt mảng theo hàng), hãy viết một đoạn code test với 1 triệu phần tử và dùng std::chrono để thấy sự khác biệt về milli giây.

  3. Đọc thêm: Tra cứu từ khóa "Data-Oriented Design" – đây là xu hướng tối ưu hóa hiện đại dựa trên việc hiểu sâu về cache line mà bạn vừa được học.

NGÀY 12: STACK VS HEAP

Mục tiêu: Hiểu sự khác biệt về bản chất vật lý giữa hai vùng nhớ và tại sao Stack lại luôn nhanh hơn.

  • Cơ chế stack (automatic storage)

    • Stack pointer: Cách CPU quản lý bộ nhớ bằng cách tăng/giảm thanh ghi stack.

    • Scope & lifetime: Tại sao biến local tự hủy? Hiểu về "unwinding the stack".

    • Optimization: Tại sao dữ liệu trên stack có xác suất cache hit gần như 100%.

  • Cơ chế heap (free store)

    • Malloc/free vs new/delete: Cách hệ điều hành tìm kiếm khoảng trống trên RAM (freelist, bitmaps).

    • Overhead: Tại sao cấp phát trên heap tốn hàng trăm chu kỳ CPU trong khi stack chỉ tốn 1 chu kỳ.

  • Thực hành

    • Sử dụng công cụ đo để thấy sự chênh lệch tốc độ khi khởi tạo 1 triệu object trên stack vs heap.

Câu hỏi trắc nghiệm cuối ngày 12

Nếu bạn có một mảng 1000 phần tử kiểu int, việc đặt nó trong một hàm (Stack) hay dùng new (Heap) sẽ ít có nguy cơ gây lỗi "stack overflow" hơn? Tại sao?

NGÀY 13: NEW, DELETE VÀ MEMORY LEAKS

Mục tiêu: Học cách quản lý thủ công để biết tại sao chúng ta cần tự động hóa.

  • Các lỗi bộ nhớ kinh điển

    • Memory leaks: Quên delete. Cách dùng công cụ Valgrind hoặc Diagnostic Tools trong Visual Studio để tìm leak.

    • Dangling pointers: Truy cập vùng nhớ đã bị xóa.

    • Double free: Xóa một vùng nhớ hai lần và hậu quả làm sập chương trình.

  • Mảng trên heap và xử lý ngoại lệ

    • new[]delete[]: Tại sao dùng sai cặp toán tử này sẽ gây lỗi bộ nhớ nghiêm trọng.

    • Exception safety: Chuyện gì xảy ra nếu new thành công nhưng một hàm sau đó bị crash? (rò rỉ bộ nhớ ngay cả khi có lệnh delete).

  • Lab

    • Viết một chương trình cố tình gây "fragmentation" (phân mảnh bộ nhớ) và quan sát hiệu năng hệ thống giảm sút.

NGÀY 14: STD::VECTOR VÀ STD::STRING

Mục tiêu: Hiểu cách các container chuẩn quản lý bộ nhớ thông minh để bắt chước.

  • Cơ chế reallocation & capacity

    • Sự khác biệt giữa sSizecapacity.

    • Amortized complexity: Tại sao vector lại nhân đôi kích thước khi đầy? Chứng minh toán học về hiệu năng.

    • Data locality: Tại sao vector lại "vô đối" về tốc độ duyệt so với list.

  • Small string optimization (SSO)

    • Khám phá kỹ thuật tối ưu của std::string: Với các chuỗi ngắn, nó không dùng Heap mà lưu trực tiếp trên Stack.

    • Thực hành: Viết code kiểm tra địa chỉ của chuỗi để tìm ra ngưỡng SSO của trình biên dịch bạn đang dùng.

  • Lab

    • Sử dụng reserve() để tối ưu hóa vector. So sánh tốc độ khi có và không có reserve().

NGÀY 15: MINI PROJECT - XÂY DỰNG SMART POINTER (MY_UNIQUE_PTR)

Mục tiêu: Tổng hợp kiến thức về Pointer, Heap, và Destructor để tạo ra công cụ quản lý bộ nhớ tự động.

  • Thiết kế lớp Wrapper

    • Sử dụng template để smart pointer có thể chứa bất kỳ kiểu dữ liệu nào.

    • Triển khai destructor: Tự động gọi delete khi smart pointer ra khỏi phạm vi (RAII).

  • Chống copy & di chuyển tài nguyên (move semantics)

    • Tại sao unique_ptr không được phép copy? (xóa copy constructor).

    • Triển khai cơ chế transfer of ownership: Cách chuyển quyền sở hữu từ pointer này sang pointer khác mà không làm rò rỉ bộ nhớ.

  • Kiểm chứng

    • Dùng lớp MyUniquePtr vừa viết để giải quyết bài toán memory leak ở Ngày 13.

    • Kiểm tra bằng Valgrind để đảm bảo 0 bytes rò rỉ.

Chiến lược

Trong giai đoạn này, hãy luôn đặt câu hỏi: "Ai là chủ sở hữu (owner) của vùng nhớ này?".

  • Nếu là stack: Hệ thống là chủ.

  • Nếu là heap (new): Bạn là chủ (và bạn rất hay quên).

  • Nếu là smart pointer: Đối tượng đó là chủ (tự động hóa).

Phần 2: trừu tượng hóa & quản lý tài nguyên

NGÀY 16: INTERFACE VS. IMPLEMENTATION

Mục tiêu: Tách biệt hoàn toàn "cái gì" (interface) và "làm như thế nào" (implementation) để tối ưu khả năng bảo trì và hiệu năng biên dịch.

  • Lý thuyết về tính đóng gói

    • Access control: public, private, protected - Không chỉ là bảo mật mã nguồn mà là tạo ra ranh giới cho trình biên dịch.

    • Invariants (tính bất biến): Cách dùng Constructor để đảm bảo đối tượng luôn ở trạng thái hợp lệ. Nếu không thể tạo trạng thái hợp lệ -> Throw exception.

    • Struct vs class: Hiểu về quy ước và sự khác biệt thực sự trong C++.

  • Kỹ thuật che dấu thông tin

    • Interface class: Cách dùng các hàm pure virtual (sơ lược) để tạo giao diện.

    • Pimpl idiom (pointer to implementation): Kỹ thuật "xịn" nhất để giảm thiểu thời gian biên dịch (compilation firewall) và che dấu chi tiết thực thi.

  • Thực hành

    • Thiết kế lại lớp Date từ sách PPP (programming: principles and practice using c++). Thêm các hàm kiểm tra logic (Invariants) để đảm bảo không có ngày 32 tháng 13.

NGÀY 17: CONSTRUCTORS & DESTRUCTORS

Mục tiêu: Kiểm soát quá trình sinh ra và mất đi của đối tượng.

  • Constructor

    • Default, parameterized, delegating constructors.

    • Member initializer list: Tại sao phải khởi tạo biến tại đây thay vì gán trong thân hàm {}? (ddây là điểm tối ưu hóa cực quan trọng: tránh gọi default constructor rồi mới gán).

    • Explicit constructors: Ngăn chặn việc ép kiểu ngầm định (implicit conversion) gây lỗi logic và giảm hiệu năng.

  • Destructors & vòng đời đối tượng

    • Thứ tự khởi tạo (từ trên xuống dưới) và thứ tự hủy (ngược lại).

    • Resource cleanup: Đảm bảo giải phóng toàn bộ tài nguyên (file, memory, socket) trong destructor.

  • Optimization Insight

    • Tìm hiểu về static objectsthread-local storage. Tại sao nên hạn chế biến toàn cục phức tạp.

NGÀY 18: COPY SEMANTICS

Mục tiêu: Hiểu về deep copy vs shallow copy và cách nó "giết chết" hiệu năng nếu làm sai.

  • Copy constructor & Copy assignment

    • Cơ chế mặc định của trình biên dịch (member-wise copy).

    • Khi nào cần tự viết copy? (khi lớp có quản lý tài nguyên/con trỏ).

    • Deep copy: Cách cấp phát vùng nhớ mới và sao chép dữ liệu để hai đối tượng độc lập hoàn toàn.

  • The Rule of three

    • Mối quan hệ mật thiết giữa: destructor, copy constructor, copy assignment. Nếu cần 1, bạn thường cần cả 3.

    • Self-assignment protection: Cách kiểm tra if (this == &other) để tránh lỗi khi gán một đối tượng cho chính nó.

  • Thực hành

    • Nâng cấp lớp My_Vector của bạn ở ngày 11 để hỗ trợ deep copy hoàn chỉnh.

NGÀY 19: MOVE SEMANTICS

  • R-value references & std::move

    • Nhắc lại L-value vs R-value.

    • Move constructor: Cách chuyển con trỏ tài nguyên từ đối tượng cũ sang đối tượng mới trong 1 bước (O(1)) thay vì copy toàn bộ mảng (O(n)).

    • Move assignment: Giải phóng tài nguyên cũ và "chiếm hữu" tài nguyên mới.

  • The rule of five & rule of zero

    • Hoàn thiện bộ 5: destructor, copy (constructor/assign), move (constructor/assign).

    • Rule of zero: Tại sao C++ hiện đại khuyến khích bạn thiết kế lớp sao cho không cần tự viết hàm nào trong bộ 5 (bằng cách dùng smart pointers và STL containers).

  • Lab

    • Viết code so sánh thời gian chạy khi truyền một vector 100MB qua hàm bằng copy vs move.

NGÀY 20: TỔNG KẾT THIẾT KẾ LỚP & DỰ ÁN MINI

Mục tiêu: Áp dụng tất cả kiến thức để tạo ra một Class.

  • Các kỹ thuật bổ trợ

    • Const member functions: Tại sao phải đánh dấu const cho các hàm đọc dữ liệu? (Giúp tối ưu hóa và cho phép gọi từ đối tượng const).

    • Friend functions/classes: Khi nào nên phá vỡ tính đóng gói (có kiểm soát) để tăng hiệu năng.

  • Dự án Mini: "high-performance string class"

    • Tự viết một lớp MyString (không dùng std::string).

    • Yêu cầu: Hỗ trợ deep copy, move semantics, small string optimization (SSO) cơ bản.

    • Kiểm tra: Đảm bảo không leak bộ nhớ và tốc độ xử lý ngang ngửa hoặc tiệm cận std::string.

  • Review code

    • Đọc chương 16-19 của PPP để xem Bjarne Stroustrup hướng dẫn cách làm vector chuẩn mực.

Chiến lược

  1. Luôn hỏi "tại sao?": Tại sao move lại nhanh hơn? (Vì nó chỉ thay đổi giá trị con trỏ, không chạm vào dữ liệu thực trên heap).

  2. Xem mã assembly: Sử dụng godbolt.org để xem move constructor sinh ra ít lệnh máy thế nào so với copy constructor.

  3. Tư duy tài nguyên: Luôn coi mỗi object là một "người quản lý tài nguyên". Quyền sở hữu (ownership) là từ khóa quan trọng nhất.

NGÀY 21: KẾ THỪA (INHERITANCE) & PHÂN CẤP ĐỐI TƯỢNG

Mục tiêu: Hiểu cách dữ liệu được sắp xếp trong bộ nhớ khi một lớp kế thừa từ lớp khác.

  • Kế thừa

    • Mối quan hệ "Is-a" (Là một).

    • Quyền truy cập protected và các kiểu kế thừa (public, private, protected).

    • Thứ tự gọi constructor và destructor trong chuỗi kế thừa (base trước, derived sau và ngược lại).

  • Sắp xếp bộ nhớ (memory layout)

    • Một đối tượng của lớp con trông như thế nào trong RAM? (Sự nối tiếp các vùng nhớ).

    • Object slicing (cắt lát đối tượng): Tại sao truyền giá trị (pass-by-value) cho lớp cơ sở lại cực kỳ nguy hiểm và làm mất dữ liệu của lớp con.

  • Thực hành

    • Xây dựng hệ thống lớp Shape cơ bản (Circle, Triangle) từ sách PPP. Vẽ sơ đồ bộ nhớ cho từng đối tượng.

NGÀY 22: ĐA HÌNH (POLYMORPHISM) & HÀM ẢO (VIRTUAL FUNCTIONS)

Mục tiêu: Hiểu cơ chế Runtime Binding - cách C++ quyết định gọi hàm nào khi chương trình đang chạy.

  • Cơ chế Virtual

    • Từ khóa virtual. Tại sao cần con trỏ hoặc tham chiếu để kích hoạt đa hình?

    • Virtual destructor: Tại sao thiếu nó sẽ gây rò rỉ bộ nhớ (memory leak) nghiêm trọng khi làm việc với kế thừa.

  • Từ khóa overridefinal

    • override: Ép trình biên dịch kiểm tra lỗi chính tả hoặc sai lệch chữ ký hàm.

    • final: Chặn kế thừa hoặc chặn ghi đè hàm.

    • Optimization: Tại sao final giúp trình biên dịch thực hiện devirtualization (chuyển lời gọi ảo thành lời gọi trực tiếp) để tăng tốc độ.

  • Lab

    • Thực hiện các ví dụ gây lỗi nếu thiếu virtual ở Destructor để thấy hậu quả trực tiếp.

NGÀY 23: DEEP DIVE - V-TABLE & V-PTR (CƠ CHẾ NỘI TẠI)

Mục tiêu: Hiểu về mặt kỹ thuật tại saođa hình lại có chi phí về hiệu năng.

  • V-Table (Bảng ảo)

    • V-Table: Một mảng các con trỏ hàm được tạo ra cho mỗi lớp có hàm ảo.

    • V-Ptr: Một con trỏ ẩn được thêm vào mỗi đối tượng để trỏ tới V-Table.

    • Chi phí bộ nhớ: Mỗi đối tượng tốn thêm 8-byte (trên 64-bit) cho V-Ptr.

  • Chi phí runtime lookup

    • Quy trình gọi hàm ảo: Object -> V-Ptr -> V-Table -> Function Address -> Execute.

    • Tại sao hàm ảo không thể là inline (thông thường).

    • Ảnh hưởng đến CPU pipeline & branch prediction: Tại sao gọi hàm ảo lại khó tối ưu hóa hơn hàm thường.

  • Lab

    • Sử dụng sizeof để kiểm tra sự thay đổi kích thước đối tượng khi có và không có hàm ảo.

    • Dùng godbolt.org xem mã Assembly của một lời gọi hàm ảo.

NGÀY 24: ABSTRACT CLASSES & INTERFACE GIAO DIỆN HỆ THỐNG

Mục tiêu: Thiết kế các "hợp đồng" (contracts) cho hệ thống phần mềm lớn.

  • Pure virtual functions

    • Hàm ảo thuần túy (= 0).

    • Lớp cơ sở trừu tượng (abstract base class - ABC). Tại sao không thể khởi tạo đối tượng từ ABC.

  • Thiết kế interface kiểu C++

    • Sử dụng ABC để tạo interface.

    • Phân biệt interface kế thừa (public inheritance) và kế thừa thực thi (private inheritance).

    • Ứng dụng: Xây dựng một plugin system đơn giản nơi các lớp con phải tuân thủ Interface của lớp cha.

  • Thinking

    • Nghiên cứu về SOLID Principles (đặc biệt là Liskov Substitution Principle) ứng dụng trong C++.

NGÀY 25: TỐI ƯU HÓA THIẾT KẾ & DỰ ÁN MINI

Mục tiêu: Tổng hợp kiến thức để xây dựng hệ thống linh hoạt nhưng vẫn "nhanh như chớp".

  • Kỹ thuật

    • Dynamic cast vs static cast: Khi nào cần ép kiểu xuống (downcasting) và tại sao dynamic_cast lại rất chậm (runtime type information - RTTI).

    • Cách tắt RTTI để tối ưu hóa bộ nhớ và tốc độ.

  • Dự án Mini: "hệ thống quản lý tài chính/vật phẩm"

    • Thiết kế một lớp cha Item với các hàm ảo như use(), getPrice().

    • Tạo ra 10-20 lớp con (Weapon, Armor, Potion...).

    • Yêu cầu: Sử dụng std::vector<Item*> để quản lý danh sách vật phẩm hỗn hợp, dùng đa hình để thực thi logic.

    • Task: Đoán trước các hàm sẽ không được kế thừa nữa và đánh dấu final để trình biên dịch tối ưu hóa.

  • Review & Meta-cognition

    • Tự trả lời: "Nếu tôi cần hiệu năng tuyệt đối, tôi có nên dùng hàm ảo không? Có giải pháp thay thế nào (ví dụ: template/CRTP) không?".

Chiến lược

Trong 5 ngày này, hãy luôn khắc sâu trong đầu: "Virtual = Indirection = Latency".

Đa hình rất tốt cho việc tổ chức code, nhưng trong các hệ thống đòi hỏi thời gian thực (như game engine hay high-frequency trading), người ta thường tìm cách hạn chế hàm ảo trong các vòng lặp "nóng" (hot loops).

Câu hỏi:

Nếu bạn có một mảng 1 triệu đối tượng, và bạn gọi một hàm ảo cho từng đối tượng đó trong vòng lặp. Tại sao việc này có thể làm chậm chương trình của bạn hơn rất nhiều so với việc gọi hàm thường, ngay cả khi nội dung hàm là giống hệt nhau?

NGÀY 26: RAII

Mục tiêu: Mở rộng tư duy RAII ra ngoài phạm vi bộ nhớ (file, mutex, socket, database connection).

  • RAII nâng

    • Resource leakage: Tại sao Exception làm hỏng mọi nỗ lực delete thủ công.

    • Scope-bound resource management: Cách đóng gói tài nguyên vào đối tượng cục bộ.

    • Thực hành: Viết một lớp FileHandler tự động đóng file và một lớp LockGuard đơn giản để quản lý Mutex.

  • Exception safety levels

    • No-throw guarantee, strong guarantee, basic guarantee.

    • Làm thế nào để viết code mà nếu có lỗi xảy ra, hệ thống vẫn giữ nguyên trạng thái cũ (rollback).

    • Copy-and-swap idiom: Kỹ thuật kinh điển để đạt được strong exception safety.

  • Optimization Insight

    • Tại sao RAII giúp trình biên dịch tối ưu hóa tốt hơn nhờ xác định rõ vòng đời đối tượng (scope).

NGÀY 27: STD::UNIQUE_PTR

Mục tiêu: Làm chủ công cụ quản lý bộ nhớ nhanh nhất, hiệu quả nhất.

  • Unique_ptr

    • Zero-overhead abstraction: Tại sao unique_ptr không chậm hơn con trỏ thô.

    • Custom deleters: Cách dùng unique_ptr để quản lý các tài nguyên không phải là bộ nhớ (ví dụ: dùng fclose thay vì delete).

  • Move-only

    • Cách truyền unique_ptr vào hàm và trả về từ hàm.

    • Sử dụng std::make_unique (C++14): Tại sao nó an toàn hơn và nhanh hơn new.

  • Lab

    • Chuyển đổi toàn bộ project "Vật phẩm" ngày 25 từ con trỏ thô sang unique_ptr. Quan sát việc mã nguồn trở nên sạch sẽ như thế nào.

NGÀY 28: STD::SHARED_PTR & WEAK_PTR

Mục tiêu: Quản lý các tài nguyên phức tạp có nhiều đối tượng cùng sử dụng.

  • Reference counting (Bộ đếm tham chiếu)

    • Cơ chế hoạt động của control block. Tại sao shared_ptr tốn bộ nhớ gấp đôi con trỏ thô.

    • Thread-safety của bộ đếm tham chiếu: Nguyên tử (atomic) nhưng có chi phí.

    • Sử dụng std::make_shared: Tại sao nó chỉ cấp phát bộ nhớ 1 lần duy nhất (optimization).

  • Circular dependency (vòng lặp tham chiếu)

    • Tại sao hai shared_ptr trỏ vào nhau sẽ gây leak bộ nhớ vĩnh viễn.

    • std::weak_ptr: Cách "quan sát" tài nguyên mà không làm tăng bộ đếm tham chiếu. Giải pháp bẻ gãy vòng lặp.

  • Lab

    • Đo chi phí hiệu năng khi copy shared_ptr (tăng bộ đếm) so với unique_ptr.

NGÀY 29: TỐI ƯU HÓA QUẢN LÝ TÀI NGUYÊN (ADVANCED RAII)

Mục tiêu: Áp dụng các kỹ thuật khó nhất để đạt hiệu năng thực tế.

  • Đa hình với smart pointers

    • Tại sao std::vector<std::unique_ptr<Base>> là cách tốt nhất để quản lý danh sách đối tượng đa hình.

    • Cơ chế std::static_pointer_caststd::dynamic_pointer_cast.

  • Tài nguyên không thể sao chép

    • Thiết kế hệ thống quản lý socket hoặc database connection pool dựa trên RAII.

    • Small object optimization trong quản lý tài nguyên.

  • Code Review

    • Đọc chương 19 của cuốn PPP để hiểu cách Bjarne Stroustrup thiết kế lại các lớp quản lý tài nguyên từ cơ bản lên nâng cao.

NGÀY 30: DỰ ÁN HỆ THỐNG QUẢN LÝ TÀI NGUYÊN

  • Dự án "Simple memory pool & smart resource manager"

    • Xây dựng một hệ thống quản lý tài nguyên tập trung.

    • Yêu cầu: 1. Sử dụng RAII cho mọi thứ (memory, console color, logger).

      1. Áp dụng unique_ptr làm mặc định, chỉ dùng shared_ptr khi thực sự cần chia sẻ.

      2. Triển khai move semantics để chuyển tài nguyên giữa các thành phần hệ thống mà không copy.

      3. Tích hợp custom deleter để quản lý một tài nguyên giả lập (ví dụ: một mảng bộ nhớ thô được cấp phát bằng malloc).

  • Ôn tập

    • Kiểm tra lại 30 ngày: Bạn đã đi từ "Hello World" đến việc hiểu "cache line", "V-table" và "move semantics".

Lời khuyên

  1. Dùng unique_ptr theo mặc định: Trong C++ chuyên nghiệp, 90% trường hợp bạn chỉ cần unique_ptr. Đừng lạm dụng shared_ptr vì nó có chi phí hiệu năng (atomic increment/decrement).

  2. Valgrind là bạn thân: Ở ngày 30, hãy chạy dự án qua Valgrind. Nếu kết quả là "0 bytes lost", bạn đã tốt nghiệp tháng đầu tiên một cách xuất sắc.

  3. Tư duy ownership: Luôn tự hỏi "Ai sở hữu con trỏ này?". Nếu không trả lời được rõ ràng, kiến trúc của bạn đang có vấn đề.

Phần 3: STL & generic programming

NGÀY 31: TEMPLATES & DEDUCTION

Mục tiêu: Hiểu cơ chế "Instantiation" – trình biên dịch viết code hộ bạn như thế nào.

  • Function templates

    • Cú pháp cơ bản, template parameters và arguments.

    • Template argument deduction: Cách trình biên dịch tự suy luận kiểu dữ liệu.

    • Overloading resolution với templates: Quy tắc ưu tiên giữa hàm thường và hàm template.

    • Optimization: Tại sao template nhanh hơn hàm dùng void* hay đa hình (vì không có chi phí gọi hàm ảo và trình biên dịch có thể inline hoàn hảo).

  • Class templates

    • Thiết kế các container tổng quát (giống std::vector<T>).

    • Default template arguments.

    • Member function templates.

  • Thực hành

    • Viết lại hàm swap và lớp Stack<T> thủ công. Sử dụng godbolt.org để xem cách trình biên dịch sinh ra 2 hàm khác nhau hoàn toàn cho intdouble.

NGÀY 32: TEMPLATE SPECIALIZATION

Mục tiêu: Tối ưu hóa thuật toán cho từng kiểu dữ liệu cụ thể.

  • Full specialization (chuyên biệt hóa toàn phần)

    • Tại sao cần specialization? (Ví dụ: vector<bool> cần tối ưu bộ nhớ khác với vector<int>).

    • Cú pháp template<> class MyClass<specific_type>.

  • Partial specialization (chuyên biệt hóa một phần)

    • Chỉ chuyên biệt hóa cho con trỏ (T*), cho tham chiếu (T&) hoặc cho một bộ tham số.

    • Sự khác biệt giữa class template (cho phép partial) và function template (không cho phép partial specialization - phải dùng overloading).

  • Lab

    • Thiết kế một hàm copy tổng quát. Sử dụng specialization để nếu dữ liệu là kiểu "plain old data" (như int), hàm sẽ dùng memcpy (tốc độ cao) thay vì vòng lặp for.

NGÀY 33: VARIADIC TEMPLATES

Mục tiêu: Làm chủ kỹ thuật "parameter pack" (C++11/14/17).

  • Parameter packs

    • Cú pháp dấu ba chấm ... (ellipsis).

    • Cơ chế đệ quy template để giải nén (unpack) đối số.

    • Hàm printf an toàn kiểu (typesafe printf) dùng variadic templates.

  • Fold expressions (C++17)

    • Cách rút gọn việc giải nén parameter pack từ 10 dòng code xuống còn 1 dòng.

    • Các toán tử fold (unary/binary fold).

  • Thực hành

    • Viết một hàm sum(a, b, c, d...) có thể nhận bất kỳ số lượng tham số nào và cộng chúng lại tại thời điểm biên dịch.

NGÀY 34: NON-TYPE TEMPLATE PARAMETERS & COMPILE-TIME LOGIC

Mục tiêu: Đưa các giá trị số vào template để tối ưu hóa bộ nhớ tĩnh.

  • Non-type parameters

    • Truyền hằng số (int, size_t) vào template.

    • Ứng dụng: std::array<T, N> – Tại sao nó nhanh hơn std::vector (vì kích thước cố định trên stack).

  • Giới thiệu template metaprogramming (TMP)

    • Sử dụng template để tính toán.

    • Tính giai thừa hoặc dãy Fibonacci ngay trong lúc biên dịch (Kết quả là một hằng số khi chương trình chạy).

  • Lab

    • Xây dựng một lớp Matrix<T, Rows, Cols>. So sánh hiệu năng với Matrix dùng con trỏ động.

NGÀY 35: ĐÓNG GÓI THƯ VIỆN TEMPLATE

Mục tiêu: Giải quyết các vấn đề thực tế khi viết thư viện Template.

  • Keyword typename vs class, dependent names

    • Hiểu về lỗi expected 'typename' before... – Một trong những thứ gây ức chế nhất khi mới học template.

    • Lớp lồng nhau (nested templates).

  • Tách file với templates

    • Tại sao không thể tách template thành .h.cpp như hàm thường?

    • Giải pháp: "inclusion model" và từ khóa extern template (C++11) để giảm thời gian biên dịch.

  • Tổng kết & Dự án Mini

    • Dự án: Xây dựng một lớp EventSystem (hoặc Signal/Slot) đơn giản sử dụng variadic templates để cho phép đăng ký các hàm callback với số lượng tham số bất kỳ.

    • Kiểm tra: Đảm bảo hệ thống an toàn kiểu (nếu truyền sai kiểu tham số, trình biên dịch phải báo lỗi ngay lập tức).

Chiến lược

  1. Hiểu về Code Bloat: Template rất nhanh nhưng mỗi lần bạn dùng một kiểu mới, trình biên dịch lại sinh ra một đoạn mã mới. Hãy học cách cân bằng để tránh file thực thi (.exe) quá lớn.

  2. Lỗi Template rất dài: Đừng hoảng sợ khi thấy 100 dòng lỗi. Hãy tập trung vào dòng đầu tiên và dòng cuối cùng để tìm ra kiểu dữ liệu nào đang bị xung đột.

  3. Static Assert: Sử dụng static_assert (C++11) để đưa ra các thông báo lỗi dễ hiểu khi người dùng truyền sai kiểu vào Template của bạn.

NGÀY 36: SEQUENCE CONTAINERS & CƠ CHẾ QUẢN LÝ BỘ NHỚ LIÊN TỤC

Mục tiêu: Hiểu tại sao std::vector là "ông vua" hiệu năng trong C++.

  • std::vector & std::deque

    • Vector: Cơ chế cấp phát lại (reallocation), số mũ tăng trưởng (growth factor).

    • Deque (double-ended queue): Cấu trúc phân đoạn (segmented buffer) và tại sao nó không đảm bảo bộ nhớ liên tục như vector.

    • Optimization: Khi nào dùng vector::reserve() để triệt tiêu chi phí copy dữ liệu.

  • std::list & std::forward_list

    • Doubly linked list: Cấu trúc node và chi phí của các con trỏ next/prev.

    • The "slow" truth: Tại sao std::list thường chậm hơn std::vector ngay cả khi chèn vào giữa (do cache miss).

  • Lab

    • Viết code đo thời gian duyệt (traversal) 1 triệu phần tử của vector vs list. Bạn sẽ thấy sức mạnh của sự liên tục bộ nhớ.

NGÀY 37: ASSOCIATIVE CONTAINERS – CÂY NHỊ PHÂN & SẮP XẾP

Mục tiêu: Làm chủ các cấu trúc dữ liệu dựa trên cây cân bằng (red-black tree).

  • std::set & std::map

    • Cấu trúc cây nhị phân tìm kiếm cân bằng (red-black tree).

    • Độ phức tạp O(log n) cho tìm kiếm, chèn, xóa.

    • Invariants: Tại sao key trong map/set luôn là const.

  • Multi-containers & custom comparators

    • std::multimapstd::multiset: Cách xử lý trùng lặp key.

    • Viết custom comparators (function objects/lambdas) để thay đổi quy tắc sắp xếp.

  • Lab

    • Phân tích chi phí bộ nhớ của std::map: Mỗi node tốn bao nhiêu byte cho con trỏ cây? So sánh với vector<pair>.

NGÀY 38: UNORDERED CONTAINERS – BẢNG BĂM (HASH TABLES)

Mục tiêu: Hiểu về kỹ thuật băm dữ liệu để đạt tốc độ truy xuất O(1).

  • std::unordered_map & std::unordered_set

    • Cấu trúc hash table: buckets, load factor, và rehash.

    • Hàm băm (hash functions) và xử lý xung đột (collision resolution).

  • Tối ưu hóa hash tTable

    • Khi nào unordered_map chậm hơn map? (Tệ nhất là O(n) khi có quá nhiều xung đột).

    • Viết hàm băm tùy chỉnh (custom hash) cho các kiểu dữ liệu tự định nghĩa (user-defined types).

  • Thực hành

    • Xây dựng một hệ thống từ điển đơn giản. So sánh tốc độ tìm kiếm giữa mapunordered_map với dữ liệu 500,000 từ.

NGÀY 39: ĐỘ PHỨC TẠP THUẬT TOÁN (BIG O) & CPU CACHE (DEEP OPTIMIZATION)

Mục tiêu: Kết nối lý thuyết thuật toán với thực tế phần cứng.

  • Big O

    • Phân tích độ phức tạp thời gian và không gian của tất cả STL containers.

    • Sự khác biệt giữa "Độ phức tạp lý thuyết" và "Hiệu năng thực tế".

  • Kiến trúc CPU cache & locality

    • Spatial locality: Tại sao CPU thích dữ liệu nằm cạnh nhau.

    • Cache line (64 bytes): Cách std::vector tận dụng tối đa mỗi lần CPU load dữ liệu từ RAM.

    • Pointer chasing: Tại sao std::liststd::map khiến CPU phải đợi dữ liệu từ RAM (high latency).

  • Research

    • Đọc về "Data-Oriented Design". Tìm hiểu tại sao trong lập trình Game, người ta thường dùng "flat maps" (map triển khai trên vector) thay vì std::map truyền thống.

NGÀY 40: TỔNG KẾT & CHỌN LỰA CONTAINER (DECISION MAKING)

  • Container adaptors & views (C++20)

    • std::stack, std::queue, std::priority_queue.

    • Giới thiệu std::span (C++20): Cách truy cập bộ nhớ liên tục một cách an toàn mà không cần copy.

  • Dự án Mini: "high-performance log processor"

    • Xây dựng hệ thống đọc file log khổng lồ.

    • Yêu cầu: 1. Thống kê số lần xuất hiện của các IP (Dùng unordered_map).

      1. Sắp xếp IP theo số lượng (Dùng vector + std::sort).

      2. Tối ưu hóa bộ nhớ bằng cách dùng std::string_view thay vì sao chép std::string.

  • Review & Final Test

    • Tự vẽ sơ đồ quyết định: "Khi nào dùng container nào?". Ví dụ: Cần chèn nhanh ở đầu? Cần tìm kiếm cực nhanh? Cần dữ liệu luôn sắp xếp?

Chiến lược

Hãy luôn nhớ câu thần chú của Bjarne Stroustrup: "By default, use vector." Chỉ khi bạn có lý do cực kỳ thuyết phục (như yêu cầu chèn/xóa ở giữa liên tục với số lượng rất lớn), bạn mới cân nhắc các container khác.

NGÀY 41: ITERATORS

Mục tiêu: Hiểu cách C++ trừu tượng hóa việc truy cập dữ liệu để thuật toán có thể chạy trên mọi cấu trúc.

  • Phân loại Iterator

    • Input/output iterators: Truy cập một lần.

    • Forward/bidirectional iterators: Duyệt tới/lui.

    • Random access iterators: Nhảy cóc đến vị trí bất kỳ (Sức mạnh của std::vector).

    • Contiguous iterators (C++20): Đảm bảo dữ liệu nằm liên tiếp trong RAM.

  • Iterator adapters & operations

    • std::advance, std::distance, std::next, std::prev.

    • Insert iterators: back_inserter, front_inserter.

    • Stream iterators: Đọc/Ghi dữ liệu trực tiếp từ file/console vào container qua iterator.

  • Lab

    • Viết một thuật toán my_find chạy được trên cả std::liststd::vector. Phân tích sự khác biệt về hiệu năng khi dịch chuyển Iterator.

NGÀY 42: FUNCTION OBJECTS (FUNCTORS) & LAMBDAS

Mục tiêu: Đưa logic xử lý vào trong thuật toán một cách tối ưu.

  • Function objects (functors)

    • Định nghĩa lớp nạp chồng operator().

    • Tại sao functor lại nhanh hơn con trỏ hàm (function pointers)? (Vì trình biên dịch có thể inline dễ dàng).

    • Các functors chuẩn: std::less, std::greater, std::plus.

  • Lambda expressions (C++11/14/17/20)

    • Cú pháp capture clause [], parameter list (), và mutable lambdas.

    • Generic lambdas (C++14): Dùng auto trong tham số lambda.

    • Capturing this & this: Các vấn đề về vòng đời đối tượng trong lập trình bất đối xứng.

  • Lab

    • So sánh mã Assembly giữa việc dùng lambda và dùng hàm thường (std::function vs template lambda).

NGÀY 43: STL ALGORITHMS – THAY THẾ VÒNG LẶP THỦ CÔNG (PHẦN 1)

Mục tiêu: Làm chủ các thuật toán tìm kiếm và biến đổi dữ liệu.

  • Non-modifying & modifying algorithms

    • std::for_each, std::find, std::count, std::all_of/any_of.

    • std::transform, std::copy, std::move (algorithm version), std::replace.

  • Tại sao dùng algorithms tốt hơn vòng lặp?

    • Trình biên dịch hiểu được "ý định" (intent) của thuật toán để áp dụng loop unrolling.

    • Tránh các lỗi "off-by-one" (lỗi thiếu hoặc thừa 1 phần tử) kinh điển.

  • Thực hành

    • Chuyển đổi một đoạn code cũ đầy rẫy vòng lặp lồng nhau sang sử dụng std::transformstd::find_if.

NGÀY 44: SORTING, SEARCHING & PARTITIONING (PHẦN 2)

Mục tiêu: Làm chủ các thuật toán phức tạp đòi hỏi sự sắp xếp dữ liệu.

  • Sorting & Partitioning

    • std::sort vs std::stable_sort vs std::partial_sort.

    • std::partitionstd::nth_element (cực kỳ nhanh để tìm phần tử lớn thứ K mà không cần sort cả mảng).

  • Binary search & merge

    • std::lower_bound, std::upper_bound, std::equal_range.

    • Set operations: std::set_intersection, std::set_union.

  • Lab

    • Benchmark: Tìm kiếm trên 10 triệu phần tử bằng std::find (O(n)) vs std::binary_search (O(log n)) trên mảng đã sort.

NGÀY 45: TỐI ƯU HÓA THUẬT TOÁN & SONG SONG HÓA (PARALLEL STL)

Mục tiêu: Tận dụng đa nhân CPU để tăng tốc thuật toán lên gấp nhiều lần.

  • Execution policies (C++17)

    • std::execution::seq (tuần tự).

    • std::execution::par (song song - parallel).

    • std::execution::par_unseq (song song và vector hóa).

  • Dự án Mini: "image processor (simulated)"

    • Giả lập một ma trận điểm ảnh (pixels).

    • Yêu cầu: 1. Sử dụng std::transform với parallel policy để chỉnh độ sáng/tương phản.

      1. Sử dụng std::partition để tách các điểm ảnh "nhiễu" ra khỏi ảnh chính.

      2. Sử dụng lambdas để định nghĩa các bộ lọc (filters).

  • Tổng kết Giai đoạn

    • Đọc tài liệu về SIMD (Single Instruction, Multiple Data) và cách STL Algorithms tận dụng nó để xử lý nhiều dữ liệu cùng lúc trong 1 chu kỳ CPU.

Chiến lược

Khi học Algorithms, hãy luôn đặt câu hỏi: "Độ phức tạp là bao nhiêu? Thuật toán này có làm mất tính ổn định (stable) của dữ liệu không?".

Mẹo Optimization: Nếu bạn sắp xếp (sort) dữ liệu một lần và tìm kiếm (search) nhiều lần, hãy dùng std::vector + std::sort + std::lower_bound. Đây thường là cách nhanh nhất trên kiến trúc CPU hiện đại do tận dụng được cache line.

Phần 4: Advanced topics & optimization

NGÀY 46: CONSTEXPR

Mục tiêu: Triệt tiêu thời gian thực thi bằng cách tính toán trước mọi thứ có thể.

  • Sức mạnh của constexpr

    • Biến constexpr vs Biến const: Sự khác biệt về mặt lưu trữ và tính toán.

    • Hàm constexpr: Các điều kiện để một hàm có thể chạy lúc biên dịch.

    • C++14/17/20 improvements: Sự nới lỏng các quy tắc (vòng lặp, điều kiện trong hàm constexpr).

  • Constexpr containers & algorithms

    • std::array và các cấu trúc dữ liệu tĩnh.

    • Thực hiện các thuật toán tìm kiếm trên mảng hằng số ngay lúc biên dịch.

  • Lab

    • Viết hàm tính giai thừa (factorial) và sin/cos bằng constexpr. Sử dụng static_assert để chứng minh kết quả đã có sẵn trước khi chương trình chạy.

NGÀY 47: TYPE TRAITS

Mục tiêu: Viết mã nguồn có khả năng "tự nhận thức" và thay đổi hành vi dựa trên kiểu dữ liệu.

  • Thư viện <type_traits>

    • Kiểm tra đặc tính kiểu: is_integral, is_floating_point, is_pointer.

    • Biến đổi kiểu: remove_reference, add_const, decay.

  • Compile-time decisions

    • Sử dụng std::conditional để chọn kiểu dữ liệu tại thời điểm biên dịch (giống như if cho kiểu dữ liệu).

    • Ứng dụng: Tạo một lớp Buffer tự động chọn int hoặc long long dựa trên kích thước yêu cầu.

  • Thực hành

    • Dùng static_assert kết hợp với type traits để ngăn chặn người dùng truyền các kiểu dữ liệu không hợp lệ vào template của bạn.

NGÀY 48: SFINAE

Mục tiêu: Làm chủ cơ chế chọn lọc hàm phức tạp nhất của C++.

  • SFINAE

    • Cơ chế: "thất bại trong việc thay thế không phải là lỗi".

    • Cách trình biên dịch duyệt qua các ứng viên hàm template.

  • std::enable_if (the workhorse of SFINAE)

    • Sử dụng enable_if để kích hoạt hoặc vô hiệu hóa các hàm template dựa trên điều kiện logic.

    • Ví dụ: Viết một hàm process() chỉ tồn tại nếu kiểu truyền vào là một class, và một hàm khác nếu là kiểu số.

  • Lab

    • Giải mã các thông báo lỗi kinh điển khi SFINAE thất bại. Hiểu cách trình biên dịch "từ chối" một ứng viên.

NGÀY 49: C++20 CONCEPTS

Mục tiêu: Học cách viết template hiện đại, sạch sẽ và dễ hiểu hơn 10 lần so với SFINAE.

  • Concepts & constraints (C++20)

    • Cú pháp requires clause.

    • Định nghĩa một concept (ví dụ: template<typename T> concept Hashable = ...).

  • Refactoring với concepts

    • Thay thế toàn bộ std::enable_if phức tạp bằng concepts.

    • Cách concepts cải thiện thông báo lỗi của trình biên dịch (error messages ngắn gọn, đi thẳng vào vấn đề).

  • Lab

    • Sử dụng concepts để tạo ra các hàm đa hình tại thời điểm biên dịch (static polymorphism).

NGÀY 50: TỔNG KẾT METAPROGRAMMING

Mục tiêu: Kết hợp tất cả để tạo ra một hệ thống Zero-cost.

  • Dự án "compile-time unit converter"

    • Xây dựng hệ thống chuyển đổi đơn vị (met, kilomet, giờ, giây).

    • Yêu cầu: 1. Việc chuyển đổi (nhân/chia) phải xảy ra hoàn toàn lúc biên dịch.

      1. Nếu người dùng cộng "mét" với "giây", trình biên dịch phải báo lỗi ngay lập tức bằng static_assert hoặc concepts.

      2. Sử dụng constexpr để đảm bảo không tốn một chu kỳ CPU nào lúc runtime cho việc tính toán tỷ lệ chuyển đổi.

  • Meta-cognition & review

    • So sánh: Khi nào dùng metaprogramming (compile-time) và khi nào dùng đa hình (runtime).

    • Hiểu về sự đánh đổi giữa: Thời gian thực thi cực nhanh vs thời gian biên dịch lâu và file thực thi nặng.


Chiến lược

Ở cấp độ này, bạn không còn nhìn code dưới dạng các dòng lệnh chạy tuần tự nữa. Bạn phải nhìn nó dưới dạng cấu trúc dữ liệu của trình biên dịch.

Mẹo Optimization: Hãy luôn cố gắng đẩy logic về phía trình biên dịch. Một lỗi ở thời điểm biên dịch (compile-time error) luôn luôn tốt hơn một lỗi ở thời điểm chạy (runtime bug), và một hằng số (constant) luôn luôn nhanh hơn một biến số (variable).

Câu hỏi thử thách: Tại sao việc tính toán Sin(45°) bằng constexpr lại có thể giúp tiết kiệm năng lượng pin cho thiết bị di động hơn là tính nó lúc chương trình đang chạy?

NGÀY 51: QUẢN LÝ LUỒNG (THREADS) & DỮ LIỆU CHIA SẺ

Mục tiêu: Hiểu cách tạo luồng và các rủi ro khi nhiều luồng cùng chạm vào một vùng nhớ.

  • Lớp std::thread

    • Vòng đời của luồng: join() vs detach(). Tại sao để luồng "bơ vơ" lại gây sập chương trình.

    • Truyền tham số cho luồng: Cẩn thận với tham chiếu cục bộ (Dangling references).

    • std::jthread (C++20): Cơ chế tự động join và dừng luồng an toàn.

  • Race conditions

    • Định nghĩa data race: Khi ít nhất một luồng ghi vào bộ nhớ trong khi luồng khác đang đọc/ghi.

    • Sử dụng mutex (std::mutex, std::recursive_mutex) để tạo vùng tranh chấp (critical cection).

    • RAII cho mutex: std::lock_guard, std::unique_lock, và std::scoped_lock (C++17).

  • Lab

    • Viết một chương trình tăng biến đếm lên 1 triệu lần bằng 4 luồng mà không dùng mutex -> quan sát kết quả sai. Sau đó dùng mutex để sửa lỗi.

NGÀY 52: ĐỒNG BỘ HÓA LUỒNG & DEADLOCKS

Mục tiêu: Học cách các luồng giao tiếp với nhau và cách tránh các trạng thái "chết chùm".

  • Deadlocks & chiến lược phòng tránh

    • Deadlock là gì? Tại sao việc chiếm giữ nhiều mutex sai thứ tự lại gây treo chương trình.

    • Sử dụng std::lock để khóa nhiều mutex cùng lúc mà không gây deadlock.

  • Giao tiếp luồng (condition variables)

    • std::condition_variable: Cách một luồng "ngủ" và chờ tín hiệu từ luồng khác.

    • Vấn đề spurious wakeups: Tại sao luôn phải dùng vòng lặp while khi chờ điều kiện.

    • Mô hình producer-consumer: Xây dựng một hàng đợi (Queue) an toàn đa luồng.

  • Lab

    • Tìm hiểu về lock granularity: Khóa quá lâu làm giảm hiệu năng, khóa quá ít gây lỗi dữ liệu. Làm thế nào để cân bằng?

NGÀY 53: LẬP TRÌNH BẤT ĐỐI XỨNG (ASYNC, FUTURE, PROMISE)

Mục tiêu: Lấy kết quả từ luồng khác một cách dễ dàng mà không cần quản lý luồng thủ công.

  • std::async & std::future

    • Tại sao std::async lại tiện hơn std::thread trong nhiều trường hợp.

    • Các chính sách thực thi: std::launch::async vs std::launch::deferred.

  • std::promise & std::packaged_task

    • Cách "hứa" trả về một giá trị và cách gửi dữ liệu từ luồng con về luồng chính.

    • std::shared_future: Khi nhiều luồng cùng chờ một kết quả.

  • Thực hành

    • Viết chương trình tính toán một ma trận lớn bằng cách chia nhỏ thành các task chạy async.

NGÀY 54: C++ MEMORY MODEL & ATOMIC OPERATIONS

Mục tiêu: Tối ưu hóa hiệu năng bằng cách loại bỏ mutex (lock-free cơ bản).

  • std::atomic

    • Tại sao std::atomic<int> lại nhanh hơn int + mutex.

    • Các thao tác nguyên tử: fetch_add, exchange, compare_exchange_weak/strong (CAS).

  • Memory ordering

    • Hiểu về cách CPU và Trình biên dịch đảo thứ tự lệnh (instruction reordering).

    • Memory barriers (fences): memory_order_relaxed, acquire, release, seq_cst.

    • Tại sao seq_cst (mặc định) là an toàn nhất nhưng chậm nhất.

  • Lab

    • Đọc về hiện tượng false sharing: Khi các biến atomic nằm quá gần nhau trên cùng một cache line làm giảm hiệu năng đa nhân.

NGÀY 55: LẬP TRÌNH KHÔNG KHÓA (LOCK-FREE) & ĐA LUỒNG

  • Dự án "high-performance thread pool"

    • Xây dựng một lớp ThreadPool quản lý một số lượng luồng cố định.

    • Yêu cầu: 1. Luồng chính đẩy task vào một hàng đợi an toàn.

      1. Các luồng con (worker threads) tự động lấy task ra để xử lý.

      2. Sử dụng std::atomic<bool> để quản lý trạng thái dừng của Pool.

      3. Tối ưu hóa: Thử nghiệm thay thế mutex bằng một spinlock tự viết bằng std::atomic_flag.

  • Review & meta-cognition

    • Tự hỏi: "Khi nào thì lock-free thực sự mang lại lợi ích?". (Cảnh báo: Đừng lạm dụng lock-free trừ khi bạn là chuyên gia tối ưu hóa, vì nó cực kỳ khó debug).

Chiến lược

Đa luồng không chỉ là chạy nhiều việc cùng lúc, mà là quản lý sự hỗn loạn có kiểm soát.

Mẹo: Hãy ưu tiên chia nhỏ dữ liệu sao cho mỗi luồng làm việc trên một vùng nhớ riêng biệt (data parallelism). Đây là cách tốt nhất để tránh tranh chấp (contention) và tận dụng CPU cache.

NGÀY 56: ALIGNMENT, PADDING & CACHE-FRIENDLY

Mục tiêu: Thiết kế cấu trúc dữ liệu sao cho CPU có thể truy cập với tốc độ nhanh.

  • Data alignment & structure padding

    • Alignment requirement: Tại sao một biến double (8 bytes) phải nằm ở địa chỉ chia hết cho 8?

    • Struct padding: Cách trình biên dịch tự chèn các "byte rác" để thỏa mãn alignment.

    • Optimization: Kỹ thuật sắp xếp lại thành viên trong struct (từ lớn đến nhỏ) để thu nhỏ kích thước object, giúp nhồi nhét được nhiều dữ liệu hơn vào cache.

  • Cache-Friendly data structures

    • AOS vs SOA (array of structures vs atructure of arrays): Kỹ thuật tối ưu hóa cực hạn cho xử lý dữ liệu lớn và SIMD.

    • Cache line contention: Tránh việc dữ liệu không liên quan nằm chung một Cache Line gây ra hiện tượng "nạp thừa".

    • Prefetching: Cách viết code để CPU đoán trước được dữ liệu bạn sắp dùng.

  • Lab

    • Sử dụng alignasalignof để ép kiểu sắp xếp bộ nhớ.

    • Viết một class Matrix và đo sự khác biệt hiệu năng khi thay đổi cách sắp xếp dữ liệu (row-major vs column-major).


NGÀY 57: EXPRESSION TEMPLATES

Mục tiêu: Triệt tiêu các đối tượng tạm thời trong các biểu thức toán học phức tạp.

  • Vấn đề của nạp chồng toán tử thông thường

    • Tại sao Result = A + B + C + D lại tạo ra 3 đối tượng tạm thời và 3 vòng lặp for dư thừa.

    • Phân tích chi phí cấp phát bộ nhớ và băng thông bộ nhớ của cách làm truyền thống.

  • Kỹ thuật expression templates

    • Xây dựng các lớp "Proxy" (như VecPlusVec) để trì hoãn việc tính toán.

    • Lazy evaluation: Chỉ thực hiện tính toán khi kết quả thực sự được gán vào một biến.

    • Kết quả cuối cùng: Biến biểu thức phức tạp thành một vòng lặp for duy nhất, không có đối tượng tạm thời (Hiệu năng tương đương mã viết tay bằng C thô).

  • Thực hành

    • Xây dựng một khung (skeleton) đơn giản cho Expression Templates để thực hiện cộng các vector lớn.

NGÀY 58: PROFILING & DEBUGGING

Mục tiêu: Sử dụng công cụ để biết chính xác code của bạn đang chậm ở đâu, thay vì đoán mò.

  • Memory profiling với Valgrind

    • Memcheck: Săn tìm rò rỉ bộ nhớ, sử dụng vùng nhớ đã giải phóng.

    • Massif: Phân tích biểu đồ sử dụng Heap theo thời gian.

    • Cachegrind: Đo tỷ lệ cache-miss của chương trình.

  • Performance profiling & GDB

    • Sử dụng perf hoặc các bộ Profiler tích hợp (Visual Studio Profiler) để tìm "Hot spots" (những hàm chiếm nhiều CPU nhất).

    • GDB Advanced: Debug đa luồng, kiểm tra giá trị thanh ghi, và xem mã Assembly trực tiếp khi chương trình đang chạy.

  • Lab

    • Lấy một bài tập cũ từ giai đoạn 1, chạy Profiler để tìm điểm yếu, và dùng tất cả kiến thức đã học để tối ưu hóa nó tăng tốc ít nhất 2 lần.

Chiến lược

Trong 3 ngày này, bạn phải tuân thủ quy tắc: "Đừng tối ưu hóa khi chưa có số liệu đo đạc" (Don't optimize without measuring).

Mẹo: 80% thời gian chạy của chương trình nằm ở 20% mã nguồn (thường là các vòng lặp sâu nhất). Hãy tập trung toàn lực vào 20% đó thay vì làm rối rắm toàn bộ mã nguồn.

NGÀY 59-60: CAPSTONE PROJECT - HIGH-PERFORMANCE ORDER BOOK

1. Yêu cầu hệ thống (requirements)

Hệ thống phải quản lý danh sách lệnh mua (bids) và bán (asks) của một mã chứng khoán:

  • Hỗ trợ các lệnh: limit order (mua/bán tại mức giá xác định).

  • Khớp lệnh tự động: Khi giá mua cao hơn hoặc bằng giá bán thấp nhất.

  • Hiệu năng: Phải xử lý được ít nhất 1.000.000 lệnh/giây.

  • An toàn: Không rò rỉ bộ nhớ, không race condition khi chạy đa luồng.

2. Kiến trúc kỹ thuật (technical architecture)

Bạn cần áp dụng các kiến thức sau:

  • Memory pool: Sử dụng Placement New hoặc một mảng tĩnh để tránh new/delete liên tục gây phân mảnh bộ nhớ.

  • Data structures: * std::map<Price, std::list<Order>> (để lấy lệnh theo giá nhanh) hoặc tốt hơn là std::flat_map (tối ưu cache).

    • Sử dụng std::unordered_map để tra cứu nhanh OrderID.
  • RAII & smart pointers: Quản lý vòng đời của từng Order bằng std::unique_ptr.

  • Move semantics: Khi lệnh được khớp, di chuyển dữ liệu vào "trade history" thay vì copy.

  • Concurrency: Luồng nhận lệnh và luồng khớp lệnh phải chạy song song (sử dụng std::atomicstd::mutex tối ưu).


HƯỚNG DẪN THỰC HIỆN CHI TIẾT

Bước 1: Thiết kế object OrderOrderBook
  • Định nghĩa struct Order với các trường: ID, Price, Quantity, Side (buy/sell).

  • Sử dụng data alignment: Sắp xếp các trường trong Order để kích thước nhỏ nhất (ví dụ: Side dùng enum char đặt cạnh Quantity).

  • Viết lớp OrderBook sử dụng unique_ptr để quản lý danh sách lệnh.

Bước 2: Công cụ khớp lệnh - matching engine
  • Sử dụng triết lý RAII: Khi một lệnh khớp hết, nó phải tự động được dọn dẹp khỏi bộ nhớ.

  • Áp dụng algorithms: Sử dụng std::lower_bound trên các container để tìm mức giá khớp nhanh nhất.

  • Tối ưu hóa: Sử dụng constexpr cho các hằng số tính toán phí giao dịch.

Bước 3: Đa luồng và tối ưu hóa
  • Sử dụng std::thread để tạo một bộ sinh lệnh (generator) giả lập hàng triệu lệnh đổ vào hệ thống.

  • Sử dụng lock-free queue (hoặc std::mutex với scope nhỏ nhất) để đưa lệnh vào OrderBook.

  • Profiling: Chạy chương trình qua Valgrind để đảm bảo 0 bytes bị leak.

Bước 4: Kiểm tra Hiệu năng & refactoring
  • Đo thời gian xử lý trung bình cho 1 lệnh.

  • Áp dụng expression templates (nếu bạn có phần tính toán khối lượng giao dịch phức tạp).

  • Kiểm tra cache miss: Nếu hiệu năng thấp, hãy thử chuyển từ std::list sang một Buffer liên tục để tận dụng cache line.


CHECKLIST

  1. Move semantics: Bạn có dùng std::move khi chuyển lệnh vào OrderBook không?

  2. No copy: Trong toàn bộ quá trình khớp lệnh, có bước nào bị copy dữ liệu thừa không?

  3. Smart pointers: Có con trỏ thô () nào đang quản lý new không? (Nếu có, chuyển ngay sang unique_ptr).

  4. Cache-friendly: Dữ liệu có nằm liên tiếp trong bộ nhớ không?

  5. Exception safety: Nếu hệ thống hết bộ nhớ, OrderBook có giữ được trạng thái ổn định không?

Lời khuyên:

C++ là một hành trình dài. Sau dự án này, hãy tiếp tục đọc mã nguồn của các thư viện lớn như Boost hoặc LLVM để thấy các bậc thầy áp dụng những gì bạn vừa học vào thực tế như thế nào.

20 ngày cấu trúc dữ liệu và giải thuật

GIAI ĐOẠN 1: ĐỘ PHỨC TẠP & CẤU TRÚC DỮ LIỆU TUYẾN TÍNH

Ngày 1: Phân tích thuật toán & độ phức tạp (Big O)

Lý thuyết và phân tích cấp độ tăng trưởng

  • Tại sao cần Big O? Sự khác biệt giữa việc đo bằng giây (runtime) và đo bằng số bước thực hiện (operations).

  • Các quy tắc tính toán:

    • Quy tắc cộng: Khi thực hiện các công việc tuần tự.

    • Quy tắc nhân: Khi thực hiện các công việc lồng nhau.

    • Quy tắc hằng số: Tại sao O(2n) hay O(100n) vẫn chỉ là O(n).

  • Phân loại các độ phức tạp phổ biến:

    • O(1): Hằng số (Ví dụ: truy cập mảng qua chỉ số).

    • O(log n): Logarit (Ví dụ: Chặt nhị phân).

    • O(n): Tuyến tính (Ví dụ: Vòng lặp đơn).

    • O(n log n): Tuyến tính nhân logarit (Ví dụ: quick Sort, merge Sort).

    • O(n^2): Bậc hai (Ví dụ: Vòng lặp lồng nhau).

    • O(2^n) và O(n!): Cấp số nhân và Giai thừa (cực kỳ chậm, cần tránh).

Memory & Hidden Constants

  • Space complexity (Độ phức tạp không gian):

    • Bộ nhớ tĩnh và bộ nhớ động.

    • Phân tích bộ nhớ khi dùng đệ quy (stack memory).

  • Hidden constants (hằng số ẩn):

    • Tại sao một thuật toán O(n) đôi khi lại chậm hơn O(n^2) với dữ liệu nhỏ?

    • Chi phí của các phép toán (nhân/chia chậm hơn cộng/trừ).

  • Cache locality:

    • Tại sao duyệt mảng (memory liên tục) luôn nhanh hơn duyệt danh sách liên kết (memory phân tán) dù cả hai đều là O(n)?

    • CPU cache line hoạt động như thế nào.

Thực hành đo lường với C++

  • Sử dụng thư viện chrono:

    • Cách bắt đầu clock và kết thúc clock.

    • Tính toán thời gian trôi qua bằng miliseconds hoặc microseconds.

  • Bài tập thực hành:

    • Viết thuật toán tìm số lớn nhất trong mảng theo 2 cách: dùng 1 vòng lặp và dùng 2 vòng lặp (cố ý làm chậm).

    • Dùng chrono để đo sự khác biệt khi n = 10.000 và n = 100.000.

  • Thử thách: Tìm cách tính tổng từ 1 đến n mà không dùng vòng lặp (dùng công thức toán học) và so sánh thời gian với cách dùng vòng lặp khi n = 1.000.000.000.


Chiến lược

Khi bạn nhìn vào bất kỳ đoạn code nào từ nay về sau, hãy tự hỏi: "Nếu dữ liệu tăng gấp 10 lần, thời gian chạy sẽ tăng bao nhiêu lần?"

  • Nếu tăng 10 lần -> O(n).

  • Nếu tăng 100 lần -> O(n^2).

  • Nếu không tăng -> O(1).

Ngày 2: Danh sách liên kết đơn (singly linked list)

Cấu trúc và cơ chế vận hành

  • Định nghĩa node: Một đối tượng chứa dữ liệu (data) và một con trỏ (next) trỏ đến vị trí tiếp theo trong bộ nhớ.

  • Tại sao cần SLL? So sánh với mảng:

    • Mảng: Cấp phát liên tục, khó thay đổi kích thước.

    • SLL: Cấp phát phân tán, chèn/xóa cực nhanh ở đầu danh sách.

  • Các thao tác cơ bản (vẽ sơ đồ trước khi code):

    • Duyệt danh sách (traversing).

    • Tìm kiếm một giá trị (search).

    • Tính độ dài danh sách.

Cài đặt thủ công

Đây là phần quan trọng nhất để rèn luyện tư duy logic. Bạn phải tự tay cài đặt các hàm sau:

  • Insertion (chèn):

    • Chèn vào đầu (push front): O(1).

    • Chèn vào cuối (push back): O(n).

    • Chèn vào sau một node bất kỳ.

  • Deletion (xóa):

    • Xóa đầu danh sách.

    • Xóa cuối danh sách: Tại sao xóa cuối lại khó hơn chèn cuối? (Cần tìm Node áp chót).

    • Xóa một Node có giá trị X.

  • Quản lý bộ nhớ: Sử dụng new để tạo Node và delete để giải phóng. Đây là lúc bạn sẽ gặp các lỗi memory leak nếu không cẩn thận.

Optimization & C++ modernization

  • Vấn đề của con trỏ thô: Nếu bạn quên delete, bộ nhớ sẽ bị rò rỉ. Nếu bạn xóa nhầm, chương trình sẽ sập.

  • RAII với std::unique_ptr: * Sử dụng unique_ptr thay cho con trỏ thô để quản lý Next.

    • Khi một Node bị xóa hoặc danh sách bị hủy, các Node tiếp theo sẽ tự động được giải phóng nhờ cơ chế Destructor của unique_ptr.
  • Tối ưu hóa cấu trúc: Thêm con trỏ Tail để biến thao tác chèn cuối (push back) từ O(n) thành O(1).

  • Phân tích memory locality: Tại sao SLL lại gây ra nhiều "cache miss" hơn mảng? (Vì các Node nằm rải rác trong RAM, CPU không thể đoán trước để nạp vào cache).


BÀI TẬP

Nhiệm vụ 1: Cài đặt LinkedList cơ bản

Tạo class SinglyLinkedList với các hàm: insertHead, insertTail, deleteValue, printList.

Yêu cầu: Sử dụng new và delete. Hãy viết thêm hàm ~SinglyLinkedList() (Destructor) để giải phóng toàn bộ các Node.

Nhiệm vụ 2:

Viết hàm reverseList() để đảo ngược danh sách liên kết đơn (Ví dụ: 1->2->3 thành 3->2->1) chỉ trong một lần duyệt và độ phức tạp không gian là O(1). Đây là câu hỏi phỏng vấn kinh điển!

Nhiệm vụ 3: Modern C++

Thay đổi toàn bộ con trỏ thô trong bài tập trên bằng std::unique_ptr.

Lưu ý: Khi đảo ngược danh sách hoặc xóa Node với unique_ptr, bạn sẽ cần dùng hàm std::move().


Câu hỏi

Nếu danh sách liên kết của bạn có 1 triệu Node, và bạn sử dụng đệ quy để xóa toàn bộ danh sách bằng unique_ptr, chuyện gì có thể xảy ra với bộ nhớ stack? (Gợi ý: Nhớ lại bài học "stack overflow").

Thử thách

Tạo Skeleton Code (khung mã nguồn) mẫu sử dụng unique_ptr

Ngày 3: Danh sách liên kết đôi & vòng (doubly & circular linked list)

Danh sách liên kết đôi (doubly linked list - DLL)

  • Cấu trúc Node DLL: Gồm data, con trỏ next và con trỏ prev.

  • Ưu điểm vượt trội:

    • Có thể duyệt ngược danh sách.

    • Xóa một Node cực nhanh khi đã biết địa chỉ (không cần duyệt từ đầu để tìm Node trước đó).

  • Cài đặt các hàm cốt lõi:

    • Chèn: insertHead, insertTail, insertAfter, insertBefore.

    • Xóa: deleteNode, deleteHead, deleteTail.

  • Thử thách quản lý con trỏ: Bạn phải cập nhật 4 liên kết cho một thao tác chèn/xóa thay vì 2 như SLL. Đây là nơi dễ gây bug nhất.

Danh sách liên kết vòng (circular linked list)

  • Cơ chế: Node cuối trỏ về Node đầu (single) hoặc Node đầu và cuối trỏ lẫn nhau (double).

  • Ứng dụng thực tế:

    • Quản lý các task chạy luân phiên trong hệ điều hành (round robin).

    • Danh sách phát nhạc lặp lại.

  • Kỹ thuật cài đặt:

    • Làm thế nào để dừng vòng lặp duyệt? (dùng con trỏ head làm điểm dừng).

    • Thao tác chèn/xóa ở vị trí đầu và cuối khi danh sách chỉ có 1 phần tử.

Optimization - phân tích "pointer chasing" & CPU cache

  • Pointer chasing là gì? Để đến được Node thứ 10, CPU phải đọc địa chỉ từ Node 1, nhảy sang RAM lấy Node 2, rồi lại nhảy sang RAM lấy Node 3...

  • Vấn đề cache miss:

    • Vector/Array: Nằm liên tiếp. Khi CPU nạp phần tử 1, nó nạp luôn cả phần tử 2, 3, 4 vào cache (cache hit).

    • Linked list: Nằm rải rác. CPU nạp Node 1, nhưng Node 2 nằm ở vùng nhớ khác hoàn toàn -> CPU phải đợi RAM (Llatency cao).

  • So sánh hiệu năng: Tại sao duyệt 1 triệu phần tử trên std::vector nhanh hơn 10-50 lần so với std::list (DLL của C++).

  • Khi nào thực sự nên dùng Linked List? Khi kích thước phần tử cực lớn và việc di chuyển (copy) phần tử trong mảng quá tốn kém.


BÀI TẬP

Nhiệm vụ 1: Triển khai DLL với smart pointers

Yêu cầu: Hãy thử suy nghĩ: Nếu dùng unique_ptr cho cả next và prev, bạn sẽ gặp lỗi gì? (Gợi ý: Circular Reference - hai thằng sở hữu lẫn nhau thì không bao giờ chết được).

Giải pháp: Dùng unique_ptr cho next và con trỏ thô hoặc weak_ptr cho prev. Hãy cài đặt lớp DoublyLinkedList hoàn chỉnh.

Nhiệm vụ 2: Bài toán Josephus (Ứng dụng circular list)

Đề bài: Có n người đứng thành vòng tròn. Bắt đầu từ người thứ 1, đếm đến người thứ m thì người đó bị loại. Quá trình tiếp tục với người kế tiếp cho đến khi chỉ còn 1 người.

Yêu cầu: Sử dụng Circular Linked List để mô phỏng và tìm người sống sót cuối cùng.

Nhiệm vụ 3: Benchmark cache

Viết chương trình đo thời gian duyệt (tổng giá trị các phần tử) của:

  1. std::vector<int> có 1 triệu phần tử.

  2. std::list<int> có 1 triệu phần tử.

    In ra sự chênh lệch thời gian. Bạn sẽ thấy "nỗi đau" của Pointer Chasing!


Chiến lược

Hôm nay, khi code DLL, bạn hãy tập thói quen "vẽ trước, code sau". Mỗi khi thay đổi liên kết giữa các Node, hãy vẽ chúng ra giấy. Nếu bạn làm sai một liên kết prev hoặc next, toàn bộ cấu trúc dữ liệu sẽ bị "gãy".

Câu hỏi trắc nghiệm cuối ngày 3

Tại sao trong cấu trúc std::list của C++, người ta thường dùng Danh sách liên kết đôi vòng có một Node "Sentinel" (Node canh gác rỗng)? Nó giúp đơn giản hóa code xử lý các trường hợp biên (danh sách rỗng hoặc có 1 phần tử) như thế nào?

Thử thách: Viết đoạn mã để giải quyết vấn đề "sở hữu vòng" khi dùng smart pointers trong DLL

Ngày 4: Ngăn xếp (stack) & hàng đợi (queue)

Stack (LIFO - Last In First Out)

  • Cơ chế: Thêm vào cuối và lấy ra ở chính cuối đó (giống như chồng đĩa).

  • Cài đặt bằng mảng (array-based):

    • Sử dụng một biến top để quản lý vị trí hiện tại.

    • Ưu điểm: Tốc độ cực nhanh nhờ Cache Locality.

    • Nhược điểm: Giới hạn kích thước (trừ khi dùng std::vector để tự động giãn nở).

  • Cài đặt bằng Linked List (Linked-based):

    • Thực chất là thao tác insertHeaddeleteHead của SLL.
  • Ứng dụng thực tế:

    • Cơ chế Undo/Redo trong các phần mềm.

    • Quản lý Function Calls (Call Stack) của CPU.

Queue (FIFO - First In First Out)

  • Cơ chế: Thêm vào cuối nhưng lấy ra ở đầu (giống như xếp hàng mua vé).

  • Vấn đề của mảng tuyến tính: Khi bạn lấy phần tử ở đầu mảng, toàn bộ các phần tử sau phải dịch chuyển lên -> O(n). Cực chậm!

  • Cài đặt bằng Linked List: Thao tác insertTaildeleteHead.

  • Ứng dụng thực tế:

    • Hàng đợi in ấn, xử lý tin nhắn trong hệ thống phân tán.

    • Thuật toán duyệt đồ thị theo chiều rộng (BFS).

Optimization & advanced applications

  • Thiết kế ring buffer (circular queue):

    • Biến mảng thành một vòng tròn bằng toán tử chia lấy dư %.

    • Sử dụng hai con trỏ frontrear.

    • Optimization: Triệt tiêu hoàn toàn việc dịch chuyển phần tử, giúp thao tác Dequeue đạt O(1) trên mảng.

  • Tính toán biểu thức (Prefix/Infix/Postfix):

    • Cách dùng Stack để chuyển từ biểu thức người đọc (2 + 3 * 4) sang biểu thức máy tính đọc (2 3 4 * +).
  • Khử đệ quy:

    • Mọi bài toán đệ quy đều có thể giải bằng vòng lặp kết hợp với một Stack tự tạo. Điều này giúp tránh lỗi Stack Overflow của hệ thống.

BÀI TẬP

Nhiệm vụ 1: Triển khai Ring Buffer (Tối ưu nhất)

Viết class CircularQueue sử dụng mảng tĩnh.

Yêu cầu: Phải xử lý được trường hợp mảng đầy và mảng rỗng một cách chính xác mà không lãng phí ô nhớ.

Nhiệm vụ 2: Kiểm tra tính hợp lệ của dấu ngoặc

Viết hàm sử dụng Stack để kiểm tra một chuỗi các dấu ngoặc (, [, { có đóng mở đúng quy tắc hay không.

Ví dụ: {[()]} là đúng, {[(])} là sai.

Nhiệm vụ 3: Tính toán hậu tố (Postfix Evaluation)

Viết chương trình nhận vào một biểu thức hậu tố (ví dụ: 5 2 + 3 *) và trả về kết quả (21).


Chiến lược

Khi học về Stack và Queue, hãy luôn tư duy về "Chi phí dịch chuyển".

  • Nếu bạn thấy mình đang dùng for để đẩy các phần tử đi chỗ khác trong mảng, bạn đang làm sai cấu trúc dữ liệu.

  • Mục tiêu của ngày hôm nay là mọi thao tác thêm/xóa đều phải đạt O(1).

Câu hỏi suy ngẫm: Tại sao trong lập trình nhúng (Embedded) người ta cực kỳ ưa chuộng Ring Buffer thay vì dùng Linked List để làm Queue? (Gợi ý: Liên quan đến việc cấp phát bộ nhớ động new/delete và sự phân mảnh bộ nhớ).

Ngày 5: Review & Lab 1

Stack & Queue

  • Hệ thống Undo/Redo (Stack): * Hiểu cơ chế sử dụng hai ngăn xếp: một cho Undo (lưu các thao tác đã thực hiện) và một cho Redo (lưu các thao tác đã bị Undo).

    • Quản lý trạng thái đối tượng khi thực hiện thao tác.
  • Hệ thống Print Buffer (Queue): * Mô phỏng hàng đợi in ấn: Các tài liệu đến từ nhiều nguồn khác nhau được đưa vào hàng đợi và xử lý theo thứ tự FIFO.

    • Xử lý ưu tiên (Priority - Sơ lược): Điều gì xảy ra nếu có một lệnh in khẩn cấp? (Gợi ý cho cấu trúc dữ liệu Priority Queue sau này).

Quản lý bộ nhớ và Công cụ Valgrind

  • Memory Leak là gì? Hậu quả của việc dùng new mà không delete trong các cấu trúc dữ liệu tự viết.

  • Giới thiệu Valgrind:

    • Cách cài đặt và chạy Valgrind trên Linux/WSL.

    • Đọc log của Valgrind: Phân biệt "definitely lost" (chắc chắn rò rỉ) và "indirectly lost" (rò rỉ gián tiếp do node cha bị xóa nhưng node con thì không).

  • Fixing Leaks: Quay lại các bài tập Ngày 2, 3 và thực hiện "dọn dẹp" sạch sẽ toàn bộ các con trỏ thô.

Lab 1

Thử thách cuối cùng của chương Cấu trúc dữ liệu tuyến tính:

  • Bài toán Đảo ngược hàng đợi (Reverse Queue): Làm thế nào để đảo ngược một Queue chỉ bằng cách sử dụng một Stack hỗ trợ?

  • Kiểm tra tính đối xứng (Palindrome): Sử dụng kết hợp Stack và Queue để kiểm tra xem một chuỗi có phải là chuỗi đối xứng hay không.

  • Đánh giá hiệu năng: So sánh lại lần cuối giữa mảng và danh sách liên kết cho các bài toán trên.


NỘI DUNG BÀI TẬP LAB 1

Nhiệm vụ 1: Xây dựng trình soạn thảo văn bản mini (Undo/Redo)

Viết chương trình cho phép người dùng:

  1. Nhập một ký tự (Add).

  2. Nhấn 'U' để Undo.

  3. Nhấn 'R' để Redo.

    Yêu cầu: Sử dụng 2 Stack std::stack hoặc tự viết để quản lý.

Nhiệm vụ 2: Mô phỏng hàng đợi ATM

Viết chương trình mô phỏng một hàng đợi khách hàng tại máy ATM. Mỗi khách hàng có một mã số và số tiền cần rút. Sau khi một người rút xong (Dequeue), in ra thông tin người đó và số tiền còn lại trong máy.

Nhiệm vụ 3: Tấn công Memory Leak

  1. Lấy mã nguồn Danh sách liên kết đơn (SLL) sử dụng con trỏ thô bạn đã viết ở Ngày 2.

  2. Cố tình bỏ hàm Destructor (không giải phóng memory).

  3. Chạy lệnh: valgrind --leak-check=full ./program

  4. Quan sát thông báo lỗi, sau đó viết lại Destructor và chạy lại Valgrind để thấy kết quả "All heap blocks were freed".


Chiến lược:

Hôm nay, mục tiêu của bạn là "Tìm lỗi của chính mình". Đừng sợ khi thấy Valgrind báo hàng nghìn lỗi rò rỉ bộ nhớ. Mỗi lỗi bạn sửa được hôm nay sẽ giúp bạn tiết kiệm hàng tuần debug khi đi làm thực tế sau này.

Câu hỏi suy ngẫm: Tại sao khi dùng std::unique_ptr để làm Linked List, chúng ta đôi khi vẫn phải viết hàm Destructor thủ công để xóa vòng lặp while thay vì để nó tự xóa đệ quy? (Gợi ý: Nhớ lại vấn đề Stack Overflow khi danh sách quá dài ở Ngày 2).

GIAI ĐOẠN 2: THUẬT TOÁN SẮP XẾP & TÌM KIẾM

Ngày 6: Tìm kiếm & Sắp xếp cơ bản

Thuật toán Tìm kiếm - Từ tuyến tính đến Nhị phân

  • Linear Search (Tìm kiếm tuyến tính):

    • Cơ chế: Duyệt qua từng phần tử.

    • Độ phức tạp: O(n).

    • Optimization (Sentinel Linear Search): Kỹ thuật đặt "lính canh" ở cuối mảng để giảm bớt một phép so sánh điều kiện trong vòng lặp (giúp tăng tốc 10-15% trên tập dữ liệu lớn).

  • Binary Search (Tìm kiếm nhị phân):

    • Điều kiện tiên quyết: Mảng phải được sắp xếp.

    • Cơ chế: Chia để trị (Divide and Conquer).

    • Độ phức tạp: O(log n).

    • Các biến thể quan trọng:

      • Tìm vị trí đầu tiên của X (Lower bound).

      • Tìm vị trí cuối cùng của X (Upper bound).

    • Optimization: Cách tính mid = left + (right - left) / 2 để tránh lỗi tràn số (integer overflow) thay vì (left + right) / 2.

Ba thuật toán Sắp xếp kinh điển

Bạn cần thực hiện "đào bới" từng thuật toán theo 3 bước: Ý tưởng -> Code tay -> Phân tích ưu nhược điểm.

  • Bubble Sort (Sắp xếp nổi bọt):

    • Cơ chế: Đẩy phần tử lớn nhất về cuối mảng qua từng cặp so sánh.

    • Optimization: Dùng biến swapped để dừng thuật toán ngay lập tức nếu mảng đã được sắp xếp xong (Best case O(n)).

  • Selection Sort (Sắp xếp chọn):

    • Cơ chế: Tìm phần tử nhỏ nhất và hoán đổi về đầu.

    • Đặc điểm: Số lần hoán đổi (Swap) là ít nhất (tối đa n-1 lần). Dùng khi chi phí ghi vào bộ nhớ đắt hơn chi phí so sánh.

  • Insertion Sort (Sắp xếp chèn):

    • Cơ chế: Lấy từng phần tử và chèn vào đúng vị trí trong dãy đã sắp xếp phía trước (giống xếp bài tây).

    • Tại sao cực kỳ quan trọng? Insertion Sort chạy nhanh nhất trên các mảng có kích thước nhỏ (n < 20) hoặc mảng gần như đã sắp xếp. Các thuật toán cao cấp như QuickSort hay MergeSort thường chuyển sang Insertion Sort ở giai đoạn cuối để tối ưu.

Phân tích & So sánh hiệu năng

  • Phân tích độ phức tạp:

    • Lập bảng so sánh Best/Average/Worst Case của cả 3 thuật toán.

    • Hiểu về Stability (Tính ổn định): Thuật toán nào giữ nguyên thứ tự tương đối của các phần tử bằng nhau? (Tại sao Insertion Sort ổn định còn Selection Sort thì không?).

    • Hiểu về In-place (Sắp xếp tại chỗ): Cả 3 đều dùng O(1) bộ nhớ phụ.

  • Thực hành đo lường (Chrono):

    • Tạo mảng ngẫu nhiên 10.000 phần tử.

    • Đo thời gian chạy của Bubble vs Selection vs Insertion.

    • Thử nghiệm với mảng đã sắp xếp một nửa để thấy sức mạnh của Insertion Sort.


BÀI TẬP

Nhiệm vụ 1: Cài đặt "Binary Search hoàn hảo"

Viết hàm binarySearch không chỉ trả về có hay không, mà phải trả về vị trí đầu tiên xuất hiện của phần tử X nếu trong mảng có nhiều phần tử trùng nhau.

Nhiệm vụ 2: Tối ưu hóa Insertion Sort

Thay vì dùng hàm std::swap (tốn 3 phép gán), hãy viết Insertion Sort theo cách "dịch chuyển" (Shifting) để giảm số lượng phép gán xuống còn 1/3.

Nhiệm vụ 3: Sắp xếp danh sách liên kết (SLL)

Hãy thử cài đặt Bubble Sort hoặc Insertion Sort trên Danh sách liên kết đơn đã học ở Ngày 2. Đây là bài tập cực tốt để luyện tư duy về con trỏ và cấu trúc dữ liệu.


Chiến lược

Hôm nay, khi học các thuật toán sắp xếp, bạn đừng chỉ nhìn vào code. Hãy lấy một bộ bài tây hoặc các quân cờ, tự tay thực hiện các bước tráo đổi theo đúng logic của thuật toán. Khi bạn "thấy" được thuật toán vận hành trong thế giới thực, việc chuyển nó thành code sẽ không còn sai sót.

Câu hỏi suy ngẫm: Tại sao thư viện chuẩn std::sort của C++ lại không dùng Bubble Sort hay Selection Sort ngay cả khi mảng nhỏ, mà thường dùng kết hợp giữa QuickSort, HeapSort và Insertion Sort (Introsort)?

Ngày 7: Sắp xếp nâng cao (Divide and Conquer)

Merge Sort

  • Tư duy Chia để trị:

    • Divide: Chia mảng thành 2 nửa cho đến khi mỗi mảng chỉ còn 1 phần tử.

    • Conquer: Sắp xếp các mảng con (mặc định 1 phần tử là đã sắp xếp).

    • Combine: Trộn (Merge) hai mảng đã sắp xếp thành một mảng lớn hơn.

  • Phân tích kỹ thuật:

    • Độ phức tạp thời gian: Luôn là O(n log n) trong mọi trường hợp (Tốt, Xấu, Trung bình).

    • Độ phức tạp không gian: O(n) vì cần một mảng phụ để chứa dữ liệu khi trộn.

  • Ưu điểm: Cực kỳ ổn định (Stable) và hiệu quả với dữ liệu rất lớn không thể nạp hết vào RAM (External Sorting).


Quick Sort

  • Cơ chế Phân hoạch (Partitioning):

    • Chọn một phần tử làm chốt (Pivot).

    • Đưa các phần tử nhỏ hơn Pivot sang trái, lớn hơn sang phải.

  • Phân tích kỹ thuật:

    • Độ phức tạp trung bình: O(n log n).

    • Độ phức tạp xấu nhất: O(n^2) - Xảy ra khi chọn Pivot sai lầm (ví dụ chọn luôn phần tử đầu tiên của mảng đã sắp xếp).

    • Độ phức tạp không gian: O(log n) cho Stack đệ quy (In-place sorting).

  • Sự khác biệt: Quick Sort thường nhanh hơn Merge Sort trong thực tế vì hằng số ẩn nhỏ và thân thiện với CPU Cache hơn.


Optimization - Pivot và Hybrid Sorting

  • Kỹ thuật chọn Pivot để tránh O(n^2):

    • Random Pivot: Chọn ngẫu nhiên một vị trí.

    • Median-of-Three: Lấy phần tử đầu, cuối và giữa, sau đó chọn số ở giữa (trung vị) của 3 số đó làm Pivot. Cách này cực kỳ hiệu quả để triệt tiêu trường hợp xấu nhất trên mảng gần như đã sắp xếp.

  • Tail Recursion Optimization: Tối ưu đệ quy đuôi để tiết kiệm bộ nhớ Stack.

  • Hybrid Sort (Tối ưu cuối cùng): Khi mảng con có kích thước nhỏ (thường < 15 phần tử), việc gọi đệ quy trở nên đắt đỏ. Trình biên dịch sẽ chuyển sang dùng Insertion Sort để xử lý nốt. Đây chính là cách std::sort trong C++ vận hành.


BÀI TẬP

Nhiệm vụ 1: Cài đặt Merge Sort an toàn bộ nhớ

  • Viết hàm mergeSort sử dụng std::vector phụ.

  • Yêu cầu: Đảm bảo sau khi trộn xong, bộ nhớ phụ phải được giải phóng đúng cách (Sử dụng kiến thức RAII đã học).

Nhiệm vụ 2: Quick Sort với Median-of-Three

  • Cài đặt Quick Sort dùng phân hoạch kiểu Hoare hoặc Lomuto.

  • Triển khai hàm chọn Pivot theo quy tắc trung vị của 3 phần tử (Đầu, Giữa, Cuối).

  • Thử nghiệm: Chạy thuật toán trên một mảng đã sắp xếp tăng dần 100.000 phần tử. Nếu bạn chọn Pivot đầu tiên, máy sẽ treo (Stack Overflow). Nếu dùng Median-of-Three, nó sẽ chạy trong tích tắc.

Nhiệm vụ 3: Benchmark so sánh trực tiếp

  • Tạo mảng ngẫu nhiên 1.000.000 phần tử.

  • Đo thời gian chạy của: Merge Sort, Quick Sort (Pivot đầu), Quick Sort (Median-of-Three), và std::sort của C++.

  • Quan sát sự chênh lệch và giải thích tại sao std::sort vẫn là nhanh nhất.


Câu hỏi

  1. Tại sao Merge Sort cần O(n) bộ nhớ phụ nhưng lại được ưu tiên dùng cho Danh sách liên kết (Linked List)?

  2. Trong hệ thống tài chính cần độ trễ thấp và ổn định, giữa một thuật toán nhanh nhưng có lúc chậm bất thường (Quick Sort) và một thuật toán luôn ổn định thời gian (Merge Sort), bạn sẽ chọn cái nào?

Ngày 8: Heap Sort & Priority Queue

Cấu trúc dữ liệu Heap

  • Định nghĩa Binary Heap:

    • Là một cây nhị phân hoàn chỉnh (Complete Binary Tree).

    • Max-Heap: Giá trị của nút cha luôn lớn hơn hoặc bằng nút con. (Dùng để sắp xếp tăng dần).

    • Min-Heap: Giá trị của nút cha luôn nhỏ hơn hoặc bằng nút con. (Dùng để sắp xếp giảm dần).

  • Cơ chế "Vun đống" (Heapify):

    • Cách đưa một phần tử "vi phạm" quy tắc Heap về đúng vị trí của nó (Shift Up và Shift Down).
  • Optimization: Array-based Heap (Cực kỳ quan trọng):

    • Tại sao không dùng con trỏ (Node) như Linked List?

    • Công thức liên hệ chỉ số mảng:

      • Nút cha ở vị trí i.

      • Con trái ở vị trí 2*i + 1.

      • Con phải ở vị trí 2*i + 2.

      • Nút cha của i ở vị trí (i-1) / 2.

    • Lợi ích: Tận dụng tối đa CPU Cache do dữ liệu nằm liên tiếp, không tốn bộ nhớ cho con trỏ.


Thuật toán Heap Sort

Thuật toán gồm 2 giai đoạn chính:

  • Giai đoạn 1: Build-Max-Heap

    • Biến một mảng ngẫu nhiên thành Max-Heap.

    • Kỹ thuật tối ưu: Chỉ cần chạy Heapify từ các nút không phải là lá ngược lên gốc (Độ phức tạp tổng thể chỉ là O(n)).

  • Giai đoạn 2: Sắp xếp (Extract-Max)

    • Đổi chỗ phần tử lớn nhất (gốc) với phần tử cuối cùng của mảng.

    • Thu hẹp phạm vi Heap lại 1 đơn vị.

    • Vun đống lại gốc để đưa phần tử lớn thứ hai lên đầu.

    • Lặp lại cho đến khi mảng cạn kiệt.

  • Phân tích:

    • Thời gian: O(n log n) cho mọi trường hợp.

    • Không gian: O(1) phụ (In-place).


Priority Queue & Ứng dụng

  • Priority Queue (Hàng đợi ưu tiên):

    • Khác với Queue thông thường (FIFO), Priority Queue luôn cho phép lấy ra phần tử có ưu tiên cao nhất (Max hoặc Min) trong O(log n).
  • Cài đặt các hàm:

    • insert(x): Thêm phần tử và vun đống ngược lên.

    • extractMax(): Lấy phần tử lớn nhất và vun đống lại.

    • increaseKey(i, val): Tăng giá trị ưu tiên của một phần tử.

  • Ứng dụng thực tế:

    • Thuật toán Dijkstra (Đường đi ngắn nhất).

    • Lập lịch tiến trình trong Hệ điều hành (CPU Scheduling).

    • Giải thuật nén dữ liệu Huffman.


BÀI TẬP

Nhiệm vụ 1: Cài đặt Heapify "Sạch"

  • Viết hàm heapify(arr, n, i) theo cách vòng lặp (Iterative) thay vì đệ quy để tối ưu bộ nhớ Stack.

  • Yêu cầu: Hàm này phải cực kỳ hiệu quả vì nó là "trái tim" của Heap Sort.

Nhiệm vụ 2: Tự xây dựng MyPriorityQueue

  • Thiết kế một Class PriorityQueue sử dụng std::vector làm bộ nhớ nền.

  • Triển khai đầy đủ các hàm push(), pop(), top().

  • Thử thách: Hãy làm cho class này có tính tổng quát bằng cách dùng Template để chứa được bất kỳ kiểu dữ liệu nào.

Nhiệm vụ 3: Bài toán "K phần tử lớn nhất"

  • Cho một luồng dữ liệu khổng lồ (không thể nạp hết vào mảng), hãy tìm K số lớn nhất đã xuất hiện.

  • Gợi ý: Sử dụng một Min-Heap kích thước đúng bằng K. Khi gặp số mới lớn hơn phần tử nhỏ nhất trong Heap, hãy thay thế và vun đống lại. Đây là kỹ thuật xử lý Big Data cực kỳ phổ biến.


Câu hỏi

  1. Tại sao Build-Heap từ mảng ngẫu nhiên lại chỉ tốn O(n) thay vì O(n log n)? (Gợi ý: Hãy tính tổng số bước nhảy của tất cả các nút dựa trên độ cao của chúng).

  2. So sánh Heap Sort và Quick Sort: Cả hai đều là O(1) bộ nhớ phụ, nhưng tại sao Quick Sort thường nhanh hơn trong thực tế? (Gợi ý: Liên quan đến số lượng phép so sánh và cách CPU nạp Cache Line).

Ngày 9: Các thuật toán sắp xếp đặc biệt

Shell Sort - Cải tiến của Insertion Sort

  • Lý thuyết nền tảng:

    • Tại sao Insertion Sort chậm? Vì nó chỉ hoán đổi các phần tử cạnh nhau (khoảng cách 1). Nếu phần tử nhỏ nhất nằm ở cuối mảng, nó mất n bước để về đầu.

    • Ý tưởng của Donald Shell: Cho phép hoán đổi các phần tử ở xa nhau (khoảng cách g - gap).

  • Cơ chế g-sort:

    • Chia mảng thành các nhóm con dựa trên khoảng cách g.

    • Thực hiện Insertion Sort trên từng nhóm con.

    • Giảm dần g về 1 (khi g = 1, nó chính là Insertion Sort nhưng mảng lúc này đã "gần như đã sort").

  • Optimization: Bài toán chọn Gap Sequence:

    • Dãy Shell nguyên bản: n/2, n/4, ..., 1.

    • Dãy Knuth: (3^k - 1) / 2.

    • Dãy Sedgewick (mạnh nhất): Hiệu năng thực tế cực cao.

  • Phân tích: Độ phức tạp phụ thuộc vào dãy Gap, có thể đạt xấp xỉ O(n^1.25) hoặc O(n log^2 n).


Radix Sort - Sắp xếp không cần so sánh

  • Counting Sort (Tiền đề):

    • Học cách sắp xếp dựa trên việc đếm số lần xuất hiện của các giá trị.

    • Độ phức tạp cực ấn tượng: O(n + k) với k là phạm vi giá trị.

  • Radix Sort (Sắp xếp theo chữ số):

    • Cơ chế: Thay vì so sánh giá trị toàn bộ, ta sắp xếp theo từng chữ số từ hàng đơn vị, hàng chục, hàng trăm (LSD - Least Significant Digit).

    • Tại mỗi chữ số, ta sử dụng một thuật toán ổn định (thường là Counting Sort) để sắp xếp.

  • Phân tích:

    • Thời gian: O(d * (n + b)) với d là số chữ số, b là cơ số (thường là 10).

    • Không gian: O(n + b).

  • Optimization: Cách áp dụng Radix Sort cho số âm, chuỗi ký tự (Strings) và số thực.


So sánh thực tế & Lựa chọn thuật toán

Đây là phần "đào bới" để bạn hiểu sâu về bản chất dữ liệu:

  • Trường hợp mảng gần như đã sắp xếp (Nearly Sorted):

    • Tại sao Insertion Sort và Shell Sort lại là "vua" ở đây?

    • Ứng dụng: Sắp xếp lại danh sách khi chỉ có 1 vài phần tử bị thay đổi.

  • Trường hợp dữ liệu có phạm vi nhỏ (Small Range):

    • Tại sao Radix Sort/Counting Sort nghiền nát Quick Sort/Merge Sort? (Ví dụ: sắp xếp tuổi của 100 triệu người dân).
  • Trường hợp dữ liệu là chuỗi ký tự (Strings):

    • Cách dùng Radix Sort để sắp xếp từ điển hiệu quả.
  • Bộ nhớ và Tính ổn định:

    • Đánh giá lại: Thuật toán nào In-place (không tốn bộ nhớ)? Thuật toán nào Stable (giữ thứ tự)?

BÀI TẬP

Nhiệm vụ 1: Cài đặt Shell Sort với các dãy Gap khác nhau

  • Viết 1 hàm Shell Sort nhận vào một vector các khoảng cách (gap sequence).

  • So sánh thời gian chạy khi dùng dãy Shell (n/2) và dãy Knuth (3k+1) trên mảng 100.000 phần tử.

Nhiệm vụ 2: Cài đặt Radix Sort cho số nguyên 32-bit

  • Triển khai hàm countingSort hỗ trợ sắp xếp theo chữ số thứ exp.

  • Triển khai radixSort gọi countingSort lặp lại.

  • Thử thách: Làm cho nó chạy được với mảng chứa cả số âm.

Nhiệm vụ 3: Bài toán "Sắp xếp tên sinh viên"

  • Cho danh sách 10.000 tên sinh viên (chỉ gồm chữ cái A-Z).

  • Hãy cài đặt một thuật toán sắp xếp theo thứ tự bảng chữ cái (Alphabetical order).

  • Gợi ý: Dùng Radix Sort sắp xếp từ ký tự cuối cùng lên ký tự đầu tiên.


Câu hỏi

  1. Radix Sort có độ phức tạp O(n) về mặt lý thuyết (nếu số chữ số d là hằng số). Tại sao nó không thay thế hoàn toàn std::sort (O(n log n)) trong C++? (Gợi ý: Hãy nghĩ về hằng số ẩn và chi phí truy cập bộ nhớ không liên tục).

  2. Nếu bạn có một mảng 1 triệu phần tử nhưng chỉ có khoảng 100 phần tử nằm sai vị trí, bạn sẽ chọn thuật toán nào để tối ưu tốc độ nhất?

Ngày 10: Review & Lab 2

Tư duy chọn lựa và So sánh thực tế

  • Ma trận quyết định (Decision Matrix):

    • Dữ liệu nhỏ (n < 20): Ưu tiên Insertion Sort.

    • Dữ liệu rất lớn, cần sự ổn định: Merge Sort.

    • Dữ liệu rất lớn, cần tốc độ trung bình nhanh nhất, bộ nhớ hạn chế: Quick Sort.

    • Hệ thống thời gian thực, yêu cầu đảm bảo thời gian Worst-case: Heap Sort.

  • Đặc tính dữ liệu:

    • Dữ liệu trùng lặp nhiều: Quick Sort gặp vấn đề (cần 3-way partition).

    • Dữ liệu gần như đã sắp xếp: Insertion Sort và Shell Sort là vô đối.

  • Tối ưu hóa bộ nhớ tĩnh (In-place vs Out-of-place):

    • Hiểu sâu về chi phí cấp phát mảng phụ của Merge Sort ảnh hưởng thế nào đến bộ nhớ.

Kỹ thuật chọn lọc - Tìm phần tử lớn thứ K

Đây là bài toán kinh điển trong phỏng vấn tại các tập đoàn lớn (Big Tech). Bạn cần "đào bới" 2 hướng tiếp cận tối ưu nhất:

  • Hướng 1: Sử dụng Min-Heap (O(n log k))

    • Duy trì một Min-Heap kích thước K.

    • Duyệt qua n phần tử, nếu phần tử hiện tại > gốc của Heap, thay thế và Heapify.

    • Phù hợp cho luồng dữ liệu (Data Stream).

  • Hướng 2: Thuật toán Quick Select (O(n) trung bình)

    • Dựa trên cơ chế Partition của Quick Sort.

    • Thay vì đệ quy cả 2 bên, ta chỉ đệ quy vào bên chứa vị trí thứ K.

    • Đây là cách tối ưu nhất về thời gian cho mảng có sẵn.

Bài tập lab 2

Bạn sẽ đóng vai một kỹ sư tối ưu hóa hệ thống để giải quyết các bài toán sau:

  • Bài tập 1: Benchmark toàn diện

    • Tạo 3 loại dữ liệu (100.000 phần tử): Ngẫu nhiên, Đã sắp xếp, Sắp xếp ngược.

    • Chạy và đo thời gian của tất cả các thuật toán đã học.

    • Vẽ biểu đồ hoặc lập bảng so sánh để thấy sự sụp đổ của các thuật toán O(n^2) trên dữ liệu lớn.

  • Bài tập 2: Tối ưu hóa bộ nhớ cho mảng khổng lồ

    • Giả sử bạn có 1 tỷ số nguyên 32-bit (khoảng 4GB RAM) nhưng máy chỉ có 2GB RAM trống.

    • Tìm hiểu và cài đặt sơ bộ ý tưởng External Sorting (Sắp xếp ngoại vi): Chia nhỏ mảng -> Sort từng phần bằng Quick Sort -> Merge lại bằng Merge Sort.

  • Bài tập 3: Cài đặt Quick Select

    • Viết hàm findKthLargest(vector<int>& nums, int k).

BÀI TẬP

Nhiệm vụ 1: Hiện thực hóa Quick Select

Yêu cầu: Tự viết hàm Partition theo kiểu Hoare và dùng nó để tìm phần tử lớn thứ K. Đừng dùng std::sort rồi lấy chỉ số, vì độ phức tạp sẽ bị đẩy lên O(n log n).

Nhiệm vụ 2: Đối đầu giữa Heap và Quick Select

  • Tạo mảng 1 triệu phần tử. Tìm K = 100 số lớn nhất.

  • Cách A: Dùng std::priority_queue (Max-Heap).

  • Cách B: Dùng Quick Select.

  • Đo thời gian và rút ra kết luận: Khi nào K nhỏ thì dùng Heap, khi nào K lớn thì dùng Select?

Nhiệm vụ 3: Tối ưu hóa mảng trùng lặp (3-Way Partitioning)

  • Quick Sort truyền thống chạy rất chậm khi mảng có hàng ngàn số giống nhau.

  • Hãy cài đặt Dutch National Flag Algorithm (Phân hoạch 3 vùng: < Pivot, == Pivot, > Pivot) để tối ưu hóa Quick Sort trong trường hợp này.


Câu hỏi

  1. Nếu bạn đang làm hệ thống cho túi khí ô tô, bạn sẽ chọn thuật toán sắp xếp nào để xử lý tín hiệu cảm biến? Tại sao không được chọn Quick Sort dù nó nhanh nhất? (Gợi ý: Nghĩ về độ trễ xấu nhất).

  2. Tại sao std::sort trong thư viện chuẩn C++ lại được gọi là Introsort? Nó kết hợp những gì để tránh được O(n^2) của Quick Sort?

GIAI ĐOẠN 3: CẤU TRÚC DỮ LIỆU CÂY & BẢNG BĂM

Ngày 11: Cây nhị phân tìm kiếm (Binary Search Tree - BST)

Kiến trúc Node và Tư duy Phân cấp

  • Định nghĩa Cây nhị phân (Binary Tree): Mỗi nút có tối đa 2 con (trái và phải).

  • Đặc tính BST (Quy tắc Vàng):

    • Tất cả các nút thuộc cây con bên trái phải có giá trị nhỏ hơn nút cha.

    • Tất cả các nút thuộc cây con bên phải phải có giá trị lớn hơn nút cha.

  • Cài đặt Node hiện đại:

    • Sử dụng std::unique_ptr<Node> left, right; để tự động quản lý vòng đời của cây.

    • Hiểu tại sao dùng unique_ptr ở đây lại an toàn (không có sở hữu vòng như DLL).

  • Thao tác Tìm kiếm (Search):

    • So sánh giá trị X với nút gốc.

    • X nhỏ hơn -> sang trái. X lớn hơn -> sang phải.

    • Độ phức tạp: trung bình O(log n).

Thêm, Xóa và Những bài toán "Cận biên"

  • Thêm phần tử (Insert): Luôn thêm vào vị trí lá để duy trì tính chất BST.

  • Xóa phần tử (Delete) - Thử thách lớn nhất:

    • Trường hợp 1: Nút lá (Xóa trực tiếp).

    • Trường hợp 2: Nút có 1 con (Nối con trực tiếp lên cha của nút bị xóa).

    • Trường hợp 3: Nút có 2 con. Kỹ thuật: Tìm In-order Successor (phần tử nhỏ nhất bên phải) hoặc In-order Predecessor (phần tử lớn nhất bên trái) để thay thế.

  • Optimization:

    • Phân tích trường hợp xấu nhất: Khi dữ liệu đầu vào đã sắp xếp, BST biến thành "danh sách liên kết" O(n). Đây là lý do ta phải đào sâu vào khái niệm chiều cao của cây (height).

Nghệ thuật Duyệt cây (Traversals)

Duyệt cây không chỉ là in dữ liệu, nó là cách bạn "đi bộ" qua toàn bộ cấu trúc dữ liệu.

  • Depth-First Search (DFS) - Duyệt theo chiều sâu:

    • In-order (Trái - Cha - Phải): Luôn trả về một dãy tăng dần (Cực kỳ quan trọng để kiểm tra tính đúng đắn của BST).

    • Pre-order (Cha - Trái - Phải): Dùng để tạo bản sao của cây (Clone tree).

    • Post-order (Trái - Phải - Cha): Dùng để xóa cây từ dưới lên (Destructor) hoặc tính toán biểu thức.

  • Breadth-First Search (BFS) / Level-order - Duyệt theo tầng:

    • Sử dụng Queue để hỗ trợ. Duyệt từ trên xuống dưới, từ trái sang phải.
  • Optimization: Thực hành chuyển đổi từ Duyệt đệ quy sang Duyệt dùng vòng lặp (Iterative) bằng cách sử dụng Stack tự tạo để tối ưu bộ nhớ.


BÀI TẬP

Nhiệm vụ 1: Xây dựng bộ khung BST hoàn chỉnh

Viết class BST với các hàm: insert(int), search(int), remove(int).

Yêu cầu: Sử dụng unique_ptr. Hãy viết thêm hàm ~BST() (hoặc dựa vào mặc định) và dùng Valgrind kiểm tra xem khi xóa gốc thì toàn bộ cây có bị rò rỉ bộ nhớ không.

Nhiệm vụ 2: Level-order traversal

Viết hàm printLevelOrder(Node* root). Sử dụng std::queue để in các phần tử theo từng tầng. Đây là bài toán nền tảng cho các giải thuật đồ thị sau này.

Nhiệm vụ 3: Tìm "Khoảng cách"

Viết hàm tìm phần tử nhỏ nhất (findMin) và lớn nhất (findMax) trong BST. Sau đó, viết hàm tìm chiều cao (Height) của cây.

Thử thách: Chèn 1000 phần tử ngẫu nhiên và 1000 phần tử tăng dần. So sánh chiều cao của 2 cây này để thấy sự "mất cân bằng".


Câu hỏi

  1. Tại sao thao tác xóa nút có 2 con lại thường ưu tiên chọn In-order Successor thay vì một nút ngẫu nhiên bất kỳ?

  2. Nếu dùng unique_ptr, khi cây quá sâu (ví dụ 100,000 nút dạng sợi dây), hàm hủy mặc định có thể gây ra Stack Overflow. Bạn sẽ xử lý Destructor như thế nào để tránh điều này? (Gợi ý: Duyệt Post-order bằng vòng lặp).

Ngày 12: Cây cân bằng AVL

Khái niệm Cân bằng và Chỉ số Balance Factor

  • Vấn đề của BST: Nhắc lại tại sao BST có thể bị suy biến thành O(n).

  • Định nghĩa cây AVL: Là BST mà với mọi nút, độ chênh lệch chiều cao giữa cây con trái và cây con phải không quá 1.

  • Chỉ số cân bằng (Balance Factor - BF):

    • Công thức: BF = height(left_child) - height(right_child).

    • Một nút được gọi là mất cân bằng nếu BF > 1 hoặc BF < -1.

  • Cập nhật cấu trúc Node:

    • Thêm biến height vào mỗi Node để tránh việc tính toán lại chiều cao từ đầu (O(n) -> O(1)).

    • Tại sao dùng biến height lại tối ưu hơn việc tính height bằng đệ quy mỗi lần?


Nghệ thuật "Xoay" cây (The Magic of Rotations)

Đây là phần tốn nhiều "năng lượng não" nhất. Bạn cần vẽ tay trước khi code.

  • Xoay đơn (Single Rotations):

    • Left-Left (LL): Mất cân bằng bên trái của con bên trái. Giải pháp: Xoay phải (Right Rotate).

    • Right-Right (RR): Mất cân bằng bên phải của con bên phải. Giải pháp: Xoay trái (Left Rotate).

  • Xoay kép (Double Rotations):

    • Left-Right (LR): Mất cân bằng bên phải của con bên trái. Giải pháp: Xoay trái con trái, sau đó xoay phải gốc.

    • Right-Left (RL): Mất cân bằng bên trái của con bên phải. Giải pháp: Xoay phải con phải, sau đó xoay trái gốc.

  • Cài đặt đệ quy: Cách tích hợp hàm balance() vào sau mỗi thao tác insertremove.


Optimization & Ứng dụng thực tế

  • Tại sao AVL tốt cho ứng dụng "Đọc nhiều - Ghi ít"?

    • So sánh với Red-Black Tree (sẽ học sau): AVL cân bằng chặt chẽ hơn, nên chiều cao thấp hơn -> Tìm kiếm (Đọc) nhanh hơn.

    • Tuy nhiên, vì cân bằng chặt nên mỗi lần Chèn/Xóa (Ghi) có thể phải xoay nhiều lần -> Chi phí ghi cao hơn.

  • Memory Optimization:

    • Thay vì lưu int height (4 bytes), ta có thể dùng char height hoặc kỹ thuật bit để tiết kiệm bộ nhớ nếu cây có hàng triệu nút.
  • Thực hành kiểm chứng:

    • Tạo một dãy 10.000 số tăng dần.

    • Chèn vào BST thường: Cây cao 10.000 tầng.

    • Chèn vào AVL: Cây chỉ cao khoảng 14 tầng (log2 của 10.000).


BÀI TẬP

Nhiệm vụ 1: Cài đặt các hàm xoay (Rotation Functions)

  • Viết hàm rightRotate(Node* y)leftRotate(Node* x).

  • Yêu cầu: Cập nhật biến height ngay trong các hàm xoay này.

Nhiệm vụ 2: Hoàn thiện AVL Tree với unique_ptr

  • Tích hợp cơ chế tự cân bằng vào hàm insert của BST ngày hôm qua.

  • Thử thách: Viết hàm xóa remove cho AVL. Lưu ý: Xóa trong AVL cực khó vì sau khi xóa một nút, bạn phải kiểm tra cân bằng ngược lên tận gốc.

Nhiệm vụ 3: Phân tích hiệu năng thực tế

  • Viết chương trình chèn 1.000.000 phần tử ngẫu nhiên vào AVL.

  • Sử dụng <chrono> đo thời gian tìm kiếm 100.000 phần tử ngẫu nhiên trong cây đó.

  • So sánh thời gian này với việc tìm kiếm trong std::vector đã sắp xếp (Binary Search).


Câu hỏi

  1. Nếu một nút trong AVL có BF = 2, có bao nhiêu khả năng dẫn đến việc này và bạn sẽ quyết định dùng xoay đơn hay xoay kép dựa trên điều kiện gì?

  2. Trong kỷ nguyên dữ liệu lớn (Big Data), tại sao người ta ít dùng AVL cho dữ liệu trên ổ đĩa (Disk) mà lại chuyển sang dùng B-Tree? (Gợi ý: Liên quan đến số lần truy cập nút và tốc độ đọc của đầu từ).

Ngày 13: Bảng băm (Hash Table)

Hàm băm (Hash Function) và Nguyên lý

  • Ý tưởng cốt lõi: Làm sao để biến một "khóa" (Key - ví dụ một cái tên) thành một "chỉ số" (Index - một con số) để truy cập trực tiếp vào mảng.

  • Thiết kế Hàm băm (Hash Function):

    • Tính chất cần có: Tính toán nhanh, phân phối đều (Uniform distribution), và tính xác định (Deterministic).

    • Các phương pháp phổ biến:

      • Division Method: index = key % array_size.

      • Multiplication Method: Dùng số thực để phân tán khóa.

      • Hashing cho String: Kỹ thuật Polynomial Rolling Hash (nhân với số nguyên tố để giảm xung đột).

  • Optimization: Thiết kế hàm băm tối ưu:

    • Tại sao kích thước bảng (Table Size) nên là số nguyên tố?

    • Tìm hiểu về hằng số 31, 33 trong các hàm băm chuỗi của Java/C++.


Giải quyết xung đột (Collision Resolution)

Xung đột là khi hai khóa khác nhau qua hàm băm lại cho ra cùng một chỉ số. Đây là phần "đào bới" sâu nhất của ngày hôm nay.

  • Chaining (Sử dụng danh sách liên kết):

    • Tại mỗi vị trí của mảng, ta lưu một std::list hoặc std::vector.

    • Ưu điểm: Dễ cài đặt, không sợ bảng đầy.

    • Nhược điểm: Tốn bộ nhớ cho con trỏ, không thân thiện với Cache.

  • Open Addressing (Dò địa chỉ mở):

    • Nếu ô bị chiếm, ta tìm ô tiếp theo.

    • Linear Probing: Dò tuyến tính (index + 1). (Dễ bị hiện tượng Clustering - tụ tập một chỗ).

    • Quadratic Probing: Dò theo hàm bậc hai (index + i^2).

    • Double Hashing: Dùng một hàm băm thứ hai để quyết định bước nhảy (Cách tốt nhất để tránh Clustering).


Load Factor và Rehash

  • Load Factor (Hệ số nạp):

    • Công thức: alpha = n / m (n là số phần tử, m là kích thước bảng).

    • Khi alpha vượt quá một ngưỡng (thường là 0.7 - 0.75), hiệu năng sẽ giảm cực nhanh.

  • Rehashing (Băm lại):

    • Khi bảng quá đầy, ta phải tạo một bảng mới lớn gấp đôi.

    • Tại sao không thể dùng memcpy? Vì key % size_cu sẽ khác hoàn toàn key % size_moi. Ta phải duyệt lại toàn bộ phần tử và băm lại từ đầu.

  • Optimization trong C++:

    • Sử dụng reserve() trong std::unordered_map để tránh Rehash nhiều lần nếu biết trước số lượng phần tử.

BÀI TẬP

Nhiệm vụ 1: Cài đặt Hash Table bằng Chaining

  • Tự viết một class MyHashTable dùng mảng các std::list<pair<string, int>>.

  • Cài đặt hàm băm cho string (Polynomial Rolling Hash).

  • Yêu cầu: Đạt độ phức tạp O(1) trung bình cho thao tác get(key)put(key, value).

Nhiệm vụ 2: Cài đặt Hash Table bằng Linear Probing (Open Addressing)

  • Thử thách: Xử lý thao tác Xóa (Delete).

  • Lưu ý: Bạn không được để ô đó trống trơn vì sẽ làm gãy chuỗi dò tìm. Bạn phải dùng một đánh dấu đặc biệt gọi là "Deleted" (Tombstone).

Nhiệm vụ 3: Benchmark hiệu năng

  • Tạo 100.000 chuỗi ngẫu nhiên.

  • So sánh tốc độ tìm kiếm giữa:

    1. std::map (Cây đỏ đen - O(log n)).

    2. std::unordered_map (Bảng băm - O(1)).

    3. MyHashTable (Tự viết).

  • Quan sát sự khác biệt kinh khủng về thời gian khi số lượng phần tử tăng lên.


Câu hỏi

  1. Tại sao trong các hệ thống yêu cầu bảo mật cao, người ta không dùng các hàm băm đơn giản mà phải dùng Cryptographic Hash Functions (như SHA-256) hoặc thêm "Salt" vào khóa?

  2. Nếu tất cả các khóa đều bị băm vào cùng một vị trí (Worst case), độ phức tạp của Bảng băm sẽ là bao nhiêu? Bạn sẽ làm gì để ngăn chặn cuộc tấn công "Hash Flooding" này? (Gợi ý: Chuyển Chaining từ Linked List sang Balanced BST).

Ngày 14: Cây Đỏ-Đen (Red-Black Tree) & B-Tree

Cây Đỏ-Đen (Red-Black Tree)

  • Bản chất và 5 Quy tắc Vàng:

    • Tại sao lại là Đỏ và Đen? (Cơ chế đánh dấu để duy trì cân bằng tương đối).

    • Quy tắc 1: Mỗi nút chỉ có thể là Đỏ hoặc Đen.

    • Quy tắc 2: Gốc luôn là Đen.

    • Quy tắc 3: Các lá (NIL) là Đen.

    • Quy tắc 4: Nếu một nút Đỏ, cả hai con của nó phải là Đen (Không có 2 nút Đỏ cạnh nhau).

    • Quy tắc 5: Mọi con đường từ một nút đến các lá con phải có cùng số lượng nút Đen.

  • Thao tác cân bằng (Rebalancing):

    • Khi chèn một nút mới (luôn là Đỏ), ta xử lý các vi phạm bằng cách:

      • Recoloring (Đổi màu): Giải pháp nhanh, không cần xoay cây.

      • Rotation (Xoay): Tương tự AVL nhưng ít lần xoay hơn.

  • So sánh AVL vs Red-Black:

    • AVL cân bằng hơn -> Tìm kiếm nhanh hơn một chút.

    • Red-Black ít phải xoay hơn khi Chèn/Xóa -> Hiệu năng tổng thể tốt hơn cho các tập dữ liệu thay đổi liên tục.

    • Ứng dụng: Giải phẫu thư viện C++ để thấy std::mapstd::set dùng Red-Black Tree.


B-Tree - Cấu trúc dữ liệu cho ổ đĩa

Khi dữ liệu quá lớn không thể nạp hết vào RAM, các cây nhị phân (AVL, Red-Black) trở nên chậm chạp vì mỗi lần truy cập một nút là một lần đọc ổ cứng (Disk I/O). B-Tree ra đời để giải quyết việc này.

  • Đặc điểm của B-Tree:

    • Là cây đa nhánh (m-way tree): Một nút có thể chứa nhiều khóa và nhiều con.

    • Cây luôn cân bằng hoàn hảo (mọi lá ở cùng một độ cao).

    • Các nút được thiết kế để vừa khít với một Disk Block (thường là 4KB).

  • Cơ chế hoạt động:

    • Tìm kiếm: Duyệt trong một nút (mảng đã sort) rồi nhảy xuống con tương ứng.

    • Tách nút (Splitting): Khi một nút quá đầy.

    • Gộp nút (Merging): Khi một nút quá trống sau khi xóa.

  • Ứng dụng: Tại sao SQL Server, MySQL (InnoDB), và hệ thống file (NTFS, APFS) lại dùng B-Tree/B+ Tree?


Thực hành và Phân tích hệ thống

  • Phân tích std::map :

    • Tại sao std::map có thời gian tìm kiếm O(log n) nhưng thực tế lại chậm hơn std::unordered_map?

    • Khi nào bắt buộc dùng std::map? (Khi cần duyệt dữ liệu theo thứ tự tăng dần, hoặc tìm các phần tử trong khoảng lower_bound, upper_bound).

  • Mô phỏng B-Tree:

    • Vẽ sơ đồ cách chèn các phần tử vào một B-Tree bậc 3 (2-3 Tree).

    • Hiểu sự khác biệt giữa B-Tree và B+ Tree (B+ Tree chỉ lưu dữ liệu ở lá, các nút nội bộ chỉ lưu khóa điều hướng - cực kỳ tối ưu cho tìm kiếm dải).


BÀI TẬP

Nhiệm vụ 1: Thao tác với std::mapstd::set

  • Viết chương trình quản lý danh sách sinh viên bằng std::map<int, string> (MSSV là khóa).

  • Sử dụng hàm map::lower_bound để tìm tất cả sinh viên có MSSV trong khoảng từ 1000 đến 2000.

  • So sánh tốc độ của thao tác này với việc dùng std::unordered_map (phải duyệt toàn bộ bảng).

Nhiệm vụ 2: Vẽ tay cơ chế chèn Red-Black Tree

  • Cho dãy số: 10, 20, 30, 15, 25.

  • Hãy vẽ các bước chèn, đổi màu và xoay để duy trì 5 quy tắc của cây Đỏ-Đen.

Nhiệm vụ 3: Bài toán bộ nhớ đệm (Cache-conscious)

  • Viết chương trình đo thời gian truy cập ngẫu nhiên 1 triệu phần tử trong std::map.

  • Giải thích tại sao tốc độ này kém xa so với việc duyệt mảng, dựa trên kiến thức về Cache Line và Memory Fragmentation (Phân mảnh bộ nhớ).


Câu hỏi

  1. Nếu Cây Đỏ-Đen có chiều cao tối đa là 2 log(n + 1), trong khi AVL là 1.44 log(n + 2), tại sao các kỹ sư C++ vẫn chọn Đỏ-Đen cho thư viện chuẩn?

  2. Trong hệ quản trị cơ sở dữ liệu, nếu ta tăng bậc (order) của B-Tree lên thật lớn (ví dụ mỗi nút chứa 1000 khóa), chiều cao của cây sẽ giảm xuống rất thấp. Vậy tại sao chúng ta không làm bậc lớn vô hạn? (Gợi ý: Chi phí tìm kiếm bên trong một nút).

Ngày 15: Review & Lab

Review Hệ thống Tìm kiếm

  • Ôn tập lý thuyết so sánh:

    • BST/AVL/Red-Black Tree: Tìm kiếm \(O(\log n)\), dữ liệu luôn có thứ tự, bộ nhớ tiết kiệm cho các node lẻ.

    • Hash Table: Tìm kiếm $O(1)$ trung bình, không có thứ tự, tốn bộ nhớ cho các ô trống (load factor) và chi phí Rehash.

  • Đào sâu kỹ thuật Memory Locality:

    • Tại sao mảng (Vector) + Binary Search đôi khi vẫn nhanh hơn Linked-based Tree?

    • Phân tích hiện tượng Cache Miss khi duyệt cây.

  • Tổng hợp Complexity: Lập bảng so sánh Time & Space cho tất cả các thao tác (Insert, Delete, Search, Range Query) của 4 loại: Array, Linked List, BST, Hash Table.


Lab 3 - Xây dựng "Dictionary Engine"

Bạn sẽ xây dựng một ứng dụng từ điển có khả năng xử lý khoảng 100,000 từ vựng.

Yêu cầu kỹ thuật:

  1. Nạp dữ liệu: Đọc file văn bản chứa từ vựng và nghĩa.

  2. Triển khai 2 phiên bản:

    • Version A: Sử dụng std::map (Cây Đỏ-Đen).

    • Version B: Sử dụng std::unordered_map (Bảng băm).

  3. Tính năng nâng cao:

    • Tìm kiếm chính xác: Nhập "Apple" -> Trả về nghĩa.

    • Tìm kiếm theo dải (Range Query): Liệt kê tất cả các từ bắt đầu từ "A" đến "C" (Chỉ làm trên Version A).

    • Gợi ý từ (Auto-complete): Tìm các từ có tiền tố bắt đầu bằng chuỗi nhập vào.


Stress Test & Phân tích tối ưu

Đây là lúc bạn "đào bới" hiệu năng thực sự:

  • Stress Test: * Thực hiện 1 triệu lần truy vấn tìm kiếm ngẫu nhiên trên cả 2 phiên bản.

    • Dùng <chrono> để đo tổng thời gian và thời gian trung bình cho mỗi truy vấn.
  • Phân tích bộ nhớ: * Sử dụng các công cụ hoặc tính toán tay để xem mỗi cấu trúc tiêu tốn bao nhiêu RAM.

  • Báo cáo kết quả (Lab Report): * Giải thích tại sao unordered_map nhanh hơn trong tìm kiếm đơn lẻ.

    • Giải thích tại sao map lại vượt trội khi cần in ra danh sách từ vựng theo thứ tự A-Z.
  • Optimization: Thử nghiệm với các hàm băm khác nhau (đã học ở Ngày 13) để xem tốc độ thay đổi thế nào.


BÀI TẬP

Nhiệm vụ 1: Hoàn thiện ứng dụng Dictionary

  • Dữ liệu: Bạn có thể tải file words.txt trên mạng (thường có khoảng 300,000 từ tiếng Anh).

  • Yêu cầu: Code phải xử lý được các từ có nghĩa dài (dùng std::string hoặc std::vector<std::string>).

Nhiệm vụ 2: Đo lường sự sụp đổ của Hash Table

  • Cố tình thiết kế một hàm băm cực tệ (ví dụ: luôn trả về mã ASCII của ký tự đầu tiên).

  • Quan sát xem tốc độ của unordered_map sẽ chậm đến mức nào khi xảy ra quá nhiều xung đột (Collision).

Nhiệm vụ 3: Tối ưu bộ nhớ cho "Từ điển khổng lồ"

  • Nếu từ điển có 10 triệu từ và không thể nạp hết vào RAM, hãy viết một bài phân tích ngắn về cách bạn sẽ áp dụng B-Tree hoặc External Hashing để lưu trữ trên ổ đĩa.

Câu hỏi

  1. Trong ứng dụng thực tế, người dùng thường gõ sai chính tả. Cấu trúc dữ liệu nào (Map hay Hash) dễ mở rộng để hỗ trợ tìm kiếm "gần đúng" (Fuzzy Search) nhất?

  2. Tại sao khi nạp dữ liệu vào std::map, nếu file đầu vào đã được sắp xếp sẵn theo thứ tự A-Z, tốc độ nạp có thể bị ảnh hưởng (đối với cây không cân bằng) và cây AVL/Red-Black đã giải quyết nó như thế nào?

GIAI ĐOẠN 4: ĐỒ THỊ & TỐI ƯU HÓA

Ngày 16: Đồ thị cơ bản (Graphs)

Thuật ngữ & Biểu diễn Đồ thị

  • Các khái niệm nền tảng:

    • Đỉnh (Vertex/Node) và Cạnh (Edge).

    • Đồ thị có hướng (Directed) vs Vô hướng (Undirected).

    • Đồ thị có trọng số (Weighted) vs Không trọng số (Unweighted).

    • Khái niệm Bậc (Degree), Chu trình (Cycle), và Liên thông (Connected).

  • Biểu diễn Đồ thị (Cực kỳ quan trọng để tối ưu bộ nhớ):

    • Ma trận kề (Adjacency Matrix): Dùng mảng 2 chiều A[i][j].

      • Ưu điểm: Kiểm tra nhanh xem 2 đỉnh có nối nhau không O(1).

      • Nhược điểm: Tốn bộ nhớ O(V^2), không hiệu quả với đồ thị thưa (ít cạnh).

    • Danh sách kề (Adjacency List): Dùng vector<int> adj[V].

      • Ưu điểm: Tiết kiệm bộ nhớ O(V+E), duyệt các đỉnh láng giềng nhanh.

      • Nhược điểm: Kiểm tra 2 đỉnh có nối nhau không mất O(bậc của đỉnh)


Duyệt Đồ thị - BFS & DFS

Duyệt đồ thị khác với duyệt cây vì đồ thị có thể có chu trình, bạn cần cơ chế để không bị lặp vô hạn.

  • Breadth-First Search (BFS) - Duyệt theo chiều rộng:

    • Sử dụng Queue.

    • Nguyên lý: "Loang" ra theo từng lớp.

    • Ứng dụng: Tìm đường đi ngắn nhất trên đồ thị không trọng số.

  • Depth-First Search (DFS) - Duyệt theo chiều sâu:

    • Sử dụng Stack (hoặc Đệ quy).

    • Nguyên lý: Đi sâu nhất có thể trước khi quay lui.

    • Ứng dụng: Kiểm tra tính liên thông, tìm chu trình, giải mê cung.

  • Kỹ thuật mảng visited[]: Cách đánh dấu các đỉnh đã thăm để tránh vòng lặp vô hạn.


Thực hành & Phân tích Độ phức tạp

  • Phân tích chi tiết:

    • Tại sao độ phức tạp của cả BFS và DFS đều là \(O(V + E)\) khi dùng Danh sách kề?

    • So sánh: Khi nào dùng BFS (tìm đường gần nhất), khi nào dùng DFS (duyệt hết mọi khả năng).

  • Bài tập Lab:

    1. Cài đặt đồ thị bằng std::vector<std::vector<int>>.

    2. Viết hàm BFS(int start_node).

    3. Viết hàm DFS(int start_node) bằng đệ quy.


BÀI TẬP

Nhiệm vụ 1: Biến đổi biểu diễn

  • Cho một ma trận kề, hãy viết hàm chuyển nó sang danh sách kề và ngược lại. Giải thích trong trường hợp nào thì việc chuyển đổi này gây lãng phí tài nguyên.

Nhiệm vụ 2: Tìm đường đi ngắn nhất (BFS)

  • Cho một bản đồ các thành phố (đồ thị không trọng số), hãy dùng BFS để tìm số chặng ít nhất để đi từ thành phố A đến thành phố B.

Nhiệm vụ 3: Đếm số thành phần liên thông (DFS)

  • Một mạng xã hội có nhiều nhóm người chơi với nhau. Hãy dùng DFS để đếm xem có bao nhiêu nhóm tách biệt (mỗi nhóm là một thành phần liên thông).

Câu hỏi

  1. Nếu đồ thị cực lớn (hàng tỷ đỉnh như Web Graph), ma trận kề có còn khả thi không? Tại sao hầu hết các thuật toán đồ thị hiện đại đều ưu tiên Danh sách kề?

  2. BFS dùng Queue, DFS dùng Stack. Điều gì xảy ra nếu bạn dùng Priority Queue thay cho Queue trong BFS? (Gợi ý: Đây chính là tiền đề cho thuật toán Dijkstra ngày mai).

Ngày 17: Cây bao trùm tối thiểu (Minimum Spanning Tree)

Định nghĩa MST và Thuật toán Kruskal

  • Khái niệm Cây bao trùm (Spanning Tree):

    • Là một đồ thị con chứa tất cả các đỉnh của đồ thị gốc.

    • Phải là một "Cây" (tức là liên thông và không có chu trình).

    • Với $V$ đỉnh, cây bao trùm luôn có đúng \(V-1\) cạnh.

  • Thuật toán Kruskal (Tiếp cận theo cạnh):

    • Tư duy tham lam (Greedy): Luôn chọn cạnh có trọng số nhỏ nhất trước.

    • Các bước:

      1. Sắp xếp tất cả các cạnh theo thứ tự trọng số tăng dần.

      2. Duyệt qua từng cạnh: Nếu thêm cạnh này vào không tạo thành chu trình thì ta nhận cạnh đó.

      3. Dừng lại khi đã chọn đủ \(V-1\) cạnh.

  • Cấu trúc dữ liệu bổ trợ: Disjoint Set Union (DSU):

    • Làm sao để kiểm tra nhanh 2 đỉnh có thuộc cùng một thành phần liên thông hay không (để tránh tạo chu trình)?

    • Học kỹ thuật Path CompressionUnion by Rank để DSU đạt tốc độ gần như $O(1)$.

Thuật toán Prim (Tiếp cận theo đỉnh)

  • Nguyên lý hoạt động:

    • Bắt đầu từ một đỉnh bất kỳ (nút gốc).

    • Tại mỗi bước, chọn một cạnh ngắn nhất nối từ một đỉnh đã nằm trong cây tới một đỉnh chưa nằm trong cây.

    • Tiếp tục "loang" cho đến khi tất cả các đỉnh đều được nạp vào cây.

  • Sự khác biệt với Kruskal:

    • Kruskal có thể tạo ra các "rừng" (nhiều phần rời rạc) rồi mới nối lại.

    • Prim luôn giữ cho cây phát triển liên tục từ một gốc.

  • Optimization:

    • Sử dụng Priority Queue (Min-Heap) để lấy ra cạnh nhỏ nhất trong \(O(\log E)\).

    • Độ phức tạp: O(E log V).

Ứng dụng & So sánh thực tế

  • Khi nào dùng thuật toán nào?

    • Kruskal: Tốt cho đồ thị thưa (ít cạnh), vì nó dựa trên việc sắp xếp cạnh.

    • Prim: Tốt cho đồ thị dày (nhiều cạnh), đặc biệt khi dùng với Fibonacci Heap.

  • Ứng dụng thực tế:

    • Thiết kế mạng lưới điện: Nối các trạm biến áp với tổng độ dài dây ít nhất.

    • Thiết kế mạng máy tính (Lan/Wan).

    • Thuật toán phân cụm (Clustering) trong học máy (Machine Learning).

  • Bài tập Lab:

    1. Cài đặt DSU tối ưu.

    2. Triển khai Kruskal bằng std::sort và DSU.

    3. Triển khai Prim bằng std::priority_queue.


BÀI TẬP

Nhiệm vụ 1: Cài đặt DSU "Thần tốc"

  • Viết class DisjointSet với hai hàm find()unite(). Áp dụng Path Compression để nén đường đi. Đây là kỹ thuật giúp Kruskal chạy cực nhanh trên dữ liệu lớn.

Nhiệm vụ 2: Xây dựng mạng lưới điện

  • Cho một danh sách các tọa độ $(x, y)$ của các hộ gia đình. Chi phí nối dây giữa 2 nhà là khoảng cách Euclidean giữa chúng. Hãy tìm tổng chiều dài dây ngắn nhất để tất cả các nhà đều có điện.

  • Gợi ý: Đây là đồ thị đầy đủ (mọi cặp đỉnh đều có cạnh), hãy thử dùng Prim.

Nhiệm vụ 3: So sánh hiệu năng

  • Tạo đồ thị ngẫu nhiên với 10.000 đỉnh và 50.000 cạnh. Chạy cả Kruskal và Prim để so sánh thời gian thực thi.

Câu hỏi

  1. Tại sao thuật toán Kruskal lại cần sắp xếp cạnh, còn Prim thì không cần sắp xếp trước?

  2. Nếu đồ thị có các cạnh với trọng số bằng nhau, liệu MST có là duy nhất không? Nếu đồ thị có các trọng số đôi một khác nhau thì sao?

  3. Điều gì xảy ra nếu đồ thị không liên thông? (Gợi ý: Khái niệm Minimum Spanning Forest).

Ngày 18: Đường đi ngắn nhất (Shortest Path)

Thuật toán Dijkstra

  • Bản chất: Tìm đường đi ngắn nhất từ 1 đỉnh đến tất cả các đỉnh còn lại (Single Source Shortest Path).

  • Điều kiện: Trọng số cạnh không được âm.

  • Cơ chế: Sử dụng Priority Queue để luôn chọn đỉnh có khoảng cách tạm thời nhỏ nhất để tối ưu hóa.

  • Độ phức tạp: \(O((E + V) \log V)\).

Thuật toán Bellman-Ford

  • Tại sao cần Bellman-Ford? Khi đồ thị có cạnh mang trọng số âm (ví dụ: đi qua một đoạn đường được cộng thêm tiền/năng lượng).

  • Cơ chế: "Relax" (Thả lỏng) toàn bộ $E$ cạnh trong \(V-1\) lần.

  • Tính năng đặc biệt: Phát hiện Chu trình âm (Negative Cycle) - nơi mà bạn càng đi vòng quanh thì quãng đường càng ngắn vô tận (một lỗi logic trong hệ thống).

Lab

  • Cài đặt Dijkstra bằng std::priority_queue.

  • Ứng dụng: Tìm đường đi ngắn nhất trên bản đồ thành phố đơn giản.


Thử thách

Tại sao thuật toán Dijkstra lại "thất bại" hoàn toàn nếu trong đồ thị xuất hiện dù chỉ một cạnh có trọng số âm?

Ngày 19: Quy hoạch động (Dynamic Programming) cơ bản

Triết lý DP và Các đặc điểm cốt lõi

  • Hai tính chất bắt buộc của bài toán DP:

    • Overlapping Subproblems (Bài toán con gối nhau): Bài toán lớn được chia thành các bài toán con, và các bài toán con này được lặp lại nhiều lần.

    • Optimal Substructure (Cấu trúc tối ưu): Lời giải tối ưu của bài toán lớn được xây dựng từ lời giải tối ưu của các bài toán con.

  • Hai cách triển khai:

    • Top-down (Memoization): Đi từ trên xuống bằng Đệ quy + Mảng lưu trữ (Cache).

    • Bottom-up (Tabulation): Đi từ dưới lên bằng cách lấp đầy bảng (Mảng 1D hoặc 2D).

  • Bài toán nhập môn: Dãy Fibonacci và bài toán Leo cầu thang (Climbing Stairs).

Các bài toán kinh điển

Đây là lúc bạn học cách "khớp" tư duy vào các mô hình toán học:

  • Bài toán Cái túi (0/1 Knapsack Problem):

    • Chọn các đồ vật có khối lượng và giá trị cho trước sao cho tổng khối lượng không vượt quá giới hạn và tổng giá trị là lớn nhất.

    • Trọng tâm: Xây dựng bảng dp[i][w] (Giá trị lớn nhất khi xét đến đồ vật thứ i với trọng lượng w).

  • Chuỗi con chung dài nhất (Longest Common Subsequence - LCS):

    • Tìm độ dài chuỗi dài nhất xuất hiện trong cả hai chuỗi cho trước (các ký tự không cần liên tiếp).

    • Trọng tâm: Công thức truy hồi dựa trên việc so sánh ký tự cuối s1[i]s2[j].

Thực hành và Biến thể

  • Dãy con tăng dài nhất (Longest Increasing Subsequence - LIS):

    • Tìm dãy con (không nhất thiết liên tiếp) có giá trị tăng dần dài nhất.
  • Bài toán Đổi tiền (Coin Change):

    • Tìm số cách để tạo ra một số tiền từ các mệnh giá cho trước, hoặc số tờ tiền ít nhất.
  • Lab:

    • Triển khai Knapsack 0/1 bằng cả hai cách Top-down và Bottom-up.

    • Phân tích sự khác biệt về bộ nhớ (Space Complexity) giữa mảng 2D và mảng 1D (Kỹ thuật tối ưu bộ nhớ).


BÀI TẬP

Nhiệm vụ 1: 0/1 Knapsack (Thực tế)

  • Một tên trộm đột nhập vào tiệm vàng với cái túi đựng được tối đa 50kg. Có 3 món đồ:

    1. Vàng: 60$ (10kg)

    2. Bạc: 100$ (20kg)

    3. Kim cương: 120$ (30kg)

  • Hãy lập bảng DP để tìm ra bộ đồ vật tối ưu nhất.

Nhiệm vụ 2: Edit Distance (Độ đo sự khác biệt)

  • Tìm số bước ít nhất (thêm, sửa, xóa) để biến chuỗi "kitten" thành "sitting". Đây là thuật toán đứng sau tính năng kiểm tra chính tả.

Nhiệm vụ 3: Tối ưu bộ nhớ

  • Với bài toán Fibonacci, thay vì dùng mảng dp[n], hãy dùng chỉ 3 biến a, b, c để đạt độ phức tạp không gian $O(1)$.

Câu hỏi

  1. Tại sao nhiều bài toán DP có thể giải bằng Đệ quy có nhớ (Memoization) nhưng các lập trình viên thi đấu lại ưu tiên dùng Bảng (Tabulation)? (Gợi ý: Stack Overflow và hằng số thời gian).

  2. Làm thế nào để phân biệt một bài toán dùng Tham lam (Greedy) với một bài toán dùng Quy hoạch động (DP)? (Gợi ý: Bài toán cái túi dạng phân số vs bài toán cái túi 0/1).

Ngày 20: Tổng kết & Capstone Lab

Triển khai Capstone Project

Chúng ta sẽ xây dựng hệ thống "City Navigator Engine".

  • Yêu cầu hệ thống:

    • Dữ liệu đầu vào: File văn bản chứa danh sách hàng ngàn thành phố (Tên) và các con đường nối giữa chúng (Khoảng cách).

    • Mapping: Sử dụng std::unordered_map<string, int> để ánh xạ tên thành phố thành ID số (để thuật toán Graph chạy nhanh hơn).

    • Graph Engine: Sử dụng Danh sách kề (Adjacency List) để lưu trữ mạng lưới giao thông.

    • Algorithm: Triển khai Dijkstra để tìm đường đi ngắn nhất giữa hai thành phố bất kỳ do người dùng nhập vào.

  • Thách thức: Hệ thống phải phản hồi gần như tức thì với dữ liệu lớn.


Kỹ thuật Tối ưu hóa "Nâng cao" (Final Optimization)

Đây là phần giúp code của bạn chuyển từ "chạy được" sang "chạy nhanh nhất có thể".

  • Compiler Flags (-O3, -march=native):

    • O3: Kích hoạt tất cả các tối ưu hóa của trình biên dịch (loop unrolling, vectorization).

    • march=native: Tối ưu hóa code cho đúng kiến trúc CPU mà bạn đang dùng (tận dụng các tập lệnh như AVX/SSE).

  • Profiling (Định hồ sơ mã nguồn):

    • Sử dụng công cụ gprof hoặc Valgrind --tool=callgrind để tìm "điểm nghẽn" (Bottleneck).

    • Xác định hàm nào tốn thời gian nhất (thường là hàm lấy phần tử từ Priority Queue hoặc hàm Hash).

  • Code-level Optimization:

    • Thay đổi std::list thành std::vector để tận dụng Cache Locality.

    • Sử dụng reserve() cho vector và map để tránh cấp phát bộ nhớ lại nhiều lần.

    • Truyền tham chiếu (const &) thay vì copy đối tượng.


Review Tổng thể & Mock Interview

  • Hệ thống hóa ma trận giải thuật:

    | Vấn đề | Cấu trúc dữ liệu / Giải thuật ưu tiên |

    | :--- | :--- |

    | Tìm kiếm nhanh nhất | Hash Table (O(1)) |

    | Tìm kiếm theo dải (Range) | Balanced BST / AVL (O(log n)) |

    | Quản lý ưu tiên | Heap / Priority Queue |

    | Tìm đường ngắn nhất | Dijkstra / BFS |

    | Tối ưu hóa tài nguyên | Quy hoạch động (DP) |

  • Kỹ năng phỏng vấn: Cách phân tích Big-O trước nhà tuyển dụng, cách đề xuất phương án "Brute force" trước rồi mới tối ưu sau.


NỘI DUNG DỰ ÁN CAPSTONE: CITY NAVIGATOR

Nhiệm vụ 1: Thiết kế cấu trúc dữ liệu hỗn hợp

  • Tạo lớp CityGraph.

  • Bên trong dùng unordered_map<string, int> cityToIndex để tra cứu nhanh.

  • Dùng vector<string> indexToCity để tra ngược lại khi in kết quả.

  • Dùng vector<vector<pair<int, int>>> adj cho đồ thị.

Nhiệm vụ 2: Benchmark & Optimize

  1. Viết code chạy Dijkstra bình thường, biên dịch không dùng flag (g++ main.cpp). Đo thời gian.

  2. Biên dịch với g++ -O3 -march=native main.cpp. Đo lại thời gian và quan sát sự khác biệt kinh ngạc.

  3. Thử thay đổi unordered_map thành một hàm băm tự viết (Ngày 13) xem có nhanh hơn không.

Nhiệm vụ 3: Đóng gói tài liệu

  • Viết một bản tóm tắt ngắn về độ phức tạp của toàn bộ hệ thống:

    • Nạp dữ liệu: O(V + E).

    • Truy vấn: O(E log V).


Tổng kết 20 ngày

Bạn đã đi từ những khái niệm cơ bản nhất như con trỏ và mảng, qua những cấu trúc phức tạp như Cây AVL, Đồ thị, và kết thúc bằng Quy hoạch động cùng kỹ thuật tối ưu hóa phần cứng.

Lời khuyên cuối cùng: Giải thuật không phải là thứ để học thuộc lòng. Nó là một "bộ công cụ". Khi đối mặt với một vấn đề thực tế, hãy tự hỏi:

  1. Dữ liệu của tôi có thứ tự không?

  2. Tôi cần ưu tiên tốc độ tìm kiếm hay tốc độ ghi?

  3. Bộ nhớ có phải là giới hạn không?

30 ngày OOP

GIAI ĐOẠN 1: CLEAN CODE & TƯ DUY LẬP TRÌNH HIỆN ĐẠI

Ngày 1: Ý nghĩa của Mã sạch & Đặt tên (Meaningful Names)

Tư duy Clean Code & Quy tắc Đặt tên Cơ bản

  • Tại sao phải Clean Code?

    • Phân tích biểu đồ: Chi phí bảo trì code tỷ lệ thuận với độ "bẩn" của code.

    • Tư duy: "Viết code là để cho người sau đọc, không phải cho máy chạy."

  • Quy tắc 1: Đặt tên có ý nghĩa (Intention-Revealing Names).

    • Xấu: int d; // elapsed time in days

    • Tốt: int elapsedTimeInDays;, int daysSinceCreation;

  • Quy tắc 2: Tránh gây nhiễu (Avoid Disinformation).

    • Đừng đặt tên accountList nếu nó không phải là một List (trong C++ là std::list). Hãy dùng accounts hoặc accountGroup.

    • Tránh các ký tự gần giống nhau: O0, l1.

  • Quy tắc 3: Có sự phân biệt rõ ràng (Make Meaningful Distinctions).

    • Tránh đặt tên kiểu: a1, a2, aN.

    • Tránh dùng từ "noise": ProductInfo, ProductData (vì Info và Data không khác gì nhau). Hãy dùng tên cụ thể hơn.

Quy tắc Nâng cao & Áp dụng vào OOP C++

  • Quy tắc 4: Tên có thể phát âm và tìm kiếm được (Pronounceable & Searchable).

    • Xấu: genymdhms (Generation Year Month Day Hour Minute Second).

    • Tốt: generationTimestamp.

    • Hạn chế biến 1 ký tự (chỉ dùng cho biến chạy i, j, k trong vòng lặp cực ngắn).

  • Quy tắc 5: Danh từ cho Lớp, Động từ cho Hàm.

    • Class/Object: Nên là danh từ hoặc cụm danh từ: Customer, WikiPage, Account. Tránh dùng: Manager, Processor, Data, Info.

    • Methods/Functions: Nên là động từ hoặc cụm động từ: postPayment(), deletePage(), save().

    • Sử dụng các tiền tố chuẩn: is..., has..., get..., set....

  • Quy tắc 6: Một từ cho một khái niệm (One Word per Concept).

    • Đừng lúc thì dùng fetch, lúc dùng retrieve, lúc dùng get cho cùng một hành động lấy dữ liệu. Hãy chọn 1 và dùng nhất quán toàn project.

Thực hành "Refactoring" & Best Practices

  • Tránh dùng mã hóa (Avoid Encodings).

    • Trong C++ hiện đại, không cần dùng Hungarian notation (ví dụ: strName, iCount). Kiểu dữ liệu đã có IDE lo.

    • Bỏ qua tiền tố m_ cho member variable (trừ khi team quy định bắt buộc).

  • Ngữ cảnh rõ ràng (Meaningful Context).

    • Nếu bạn có các biến state, city, zipcode, hãy gom chúng vào một struct Address để tạo ngữ cảnh rõ ràng.
  • Lab 1: Code Cleanup.

    • Tôi sẽ đưa cho bạn một đoạn code C++ "hỗn loạn" với các tên biến như a, b, temp, data1.

    • Nhiệm vụ: Viết lại đoạn code đó sao cho không cần comment mà người đọc vẫn hiểu thuật toán đang làm gì.


BÀI TẬP

Nhiệm vụ 1: Phân tích và Sửa lỗi tên

Cho đoạn code sau, hãy liệt kê ít nhất 5 lỗi đặt tên và sửa lại theo chuẩn Clean Code:

class Manager {
    int d; // ngày bắt đầu
    vector<string> list; 
    void f(); 
};

Nhiệm vụ 2: Thiết kế Interface cho ứng dụng Ngân hàng

Hãy đặt tên cho các Class và Method cho tính năng: Chuyển tiền, Kiểm tra số dư, và Lịch sử giao dịch. Đảm bảo tuân thủ quy tắc "Một từ cho một khái niệm".

Nhiệm vụ 3: Đọc chương 1 & 2 sách Clean Code

Ghi chú lại 3 ví dụ về cách đặt tên mà bạn thấy tâm đắc nhất từ tác giả Robert C. Martin.


Câu hỏi

  1. Tại sao việc đặt tên dài nhưng rõ nghĩa (ví dụ totalScoreOfAllRegisteredUsers) lại tốt hơn đặt tên ngắn (ví dụ total) dù tốn công gõ hơn?

  2. Khi nào thì một biến 1 ký tự (như x, y) được chấp nhận trong code sạch?

Ngày 2: Hàm (Functions) & Quy tắc nhỏ gọn

Kích thước và Trách nhiệm

  • Quy tắc "Nhỏ gọn" (Small!):

    • Tại sao hàm không nên quá 20 dòng? (Lý do về khả năng tập trung của não bộ và màn hình IDE).

    • Quy tắc "Khối lệnh" (Blocks): Các khối lệnh trong if, else, while nên chỉ là 1 dòng gọi hàm.

  • Nguyên lý Đơn nhiệm (Do One Thing - Single Responsibility):

    • Làm sao để biết hàm đang làm "quá nhiều việc"?

    • Kỹ thuật phân tách theo "Mức độ trừu tượng" (One Level of Abstraction per Function).

  • Quy tắc "Đọc từ trên xuống" (Step-down Rule):

    • Code phải đọc như một bài báo: Hàm cấp cao ở trên, hàm chi tiết ở dưới.

Tham số và Tác dụng phụ

  • Số lượng tham số lý tưởng (Function Arguments):

    • 0 tham số (Niladic): Lý tưởng nhất.

    • 1 tham số (Monadic): Rất tốt.

    • 2 tham số (Dyadic): Có thể chấp nhận.

    • 3 tham số (Triadic): Nên hạn chế tối đa.

    • 3 tham số (Polyadic): Cực xấu, cần đóng gói vào một struct hoặc class.

  • Tham số logic (Flag Arguments):

    • Tại sao truyền bool vào hàm (ví dụ: render(true)) là "tội ác"? Hãy tách thành 2 hàm: renderForAdmin()renderForGuest().
  • Nói không với Tác dụng phụ (Side Effects):

    • Hàm mang tên checkPassword thì không được phép kiêm luôn việc session.initialize(). Tác dụng phụ ngầm là nguồn cơn của các bug khó tìm nhất.

Lệnh vs Truy vấn & Refactoring

  • Phân tách Lệnh và Truy vấn (Command Query Separation - CQS):

    • Một hàm nên: Hoặc là thay đổi trạng thái đối tượng (Command), hoặc là trả về thông tin (Query). Đừng làm cả hai.

    • Ví dụ xấu: if (set("username", "unclebob")) ... -> Vừa gán vừa kiểm tra.

  • Nguyên tắc DRY (Don't Repeat Yourself):

    • Phát hiện và loại bỏ code trùng lặp bằng cách trích xuất hàm (Extract Method).
  • Lab 2: "Cắt nhỏ hàm khổng lồ".

    • Thực hành Refactoring một hàm dài 100 dòng chứa đầy logic lồng ghép thành 5-7 hàm nhỏ, mỗi hàm chỉ làm một việc.

BÀI TẬP

Nhiệm vụ 1:

Hãy liệt kê 3 lỗi vi phạm Clean Code trong hàm sau và sửa lại:

// Hàm này làm gì? Vừa kiểm tra, vừa lưu, vừa in?
bool saveUser(User u, bool isAdmin, bool sendEmail) {
    if (u.name != "") {
        db.save(u);
        if (isAdmin) {
            cout << "Admin saved";
        }
        if (sendEmail) {
            emailSystem.send(u.email, "Welcome");
        }
        return true;
    }
    return false;
}

Nhiệm vụ 2: Đóng gói tham số

Viết lại hàm sau bằng cách sử dụng một struct hoặc class để giảm số lượng tham số xuống còn 1:

void makeCircle(double x, double y, double radius, string color, int borderThickness);

Nhiệm vụ 3: Đọc chương 3 sách Clean Code

Tóm tắt lại định nghĩa của Uncle Bob về "Một cấp độ trừu tượng" (One level of abstraction).


Câu hỏi

  1. Nếu tách một hàm lớn thành nhiều hàm nhỏ, liệu chương trình có bị chậm đi do chi phí gọi hàm (Function Call Overhead) không? (Gợi ý: Hãy nghĩ về trình biên dịch hiện đại và từ khóa inline).

  2. Làm sao để đặt tên cho các hàm nhỏ sau khi tách để chúng vẫn giữ được sự liên kết mạch lạc?

Ngày 3: Chú thích (Comments) & Định dạng (Formatting)

Nghệ thuật dùng Chú thích (Comments)

  • Triết lý về Chú thích:

    • Chú thích là sự thừa nhận thất bại trong việc diễn đạt bằng mã nguồn.

    • Code thay đổi theo thời gian, nhưng chú thích thường bị "bỏ quên", dẫn đến sự dối trá trong mã nguồn.

  • Những chú thích "Tốt" (Cần thiết):

    • Chú thích pháp lý (Legal comments/Copyright).

    • Giải thích ý định (Informative comments) cho những thuật toán cực kỳ phức tạp (như Regex).

    • Cảnh báo hậu quả (Warning of consequences) – ví dụ: "Hàm này mất 10 phút để chạy, đừng gọi tùy tiện".

    • Chú thích TODO (Những việc cần làm nhưng chưa làm).

  • Những chú thích "Xấu" (Nên xóa ngay):

    • Chú thích lảm nhảm (Mumbling).

    • Chú thích dư thừa (Redundant comments) – ví dụ: i++; // tăng i lên 1.

    • Chú thích đánh dấu vị trí (Position markers) – ví dụ: // ********** MAIN **********.

    • Đặc biệt: Chú thích code (Commented-out code). Đây là thứ gây ô nhiễm mã nguồn nhất. Nếu code không dùng, hãy xóa nó, Git sẽ giữ lại cho bạn.

Định dạng dọc (Vertical Formatting)

  • Phép ẩn dụ "Tờ báo" (The Newspaper Metaphor):

    • Tên file đơn giản nhưng mô tả được nội dung.

    • Phần quan trọng, tổng quát ở trên đầu. Chi tiết cài đặt ở bên dưới.

  • Khoảng cách và Mật độ:

    • Vertical Openness: Dùng dòng trống để phân tách các ý niệm (concepts) khác nhau (giữa các hàm, giữa các khối logic).

    • Vertical Density: Những dòng code liên quan chặt chẽ nên nằm sát nhau.

  • Khoảng cách biến và hàm:

    • Biến nên được khai báo gần nơi nó được sử dụng.

    • Hàm gọi và hàm được gọi nên nằm gần nhau (Caller - Callee).

Định dạng ngang (Horizontal Formatting)

  • Độ dài dòng code: Bao nhiêu là đủ? (Quy tắc 80 hoặc 120 ký tự).

  • Horizontal Openness & Density:

    • Dùng khoảng trắng để làm nổi bật các toán tử: total = a + b; (Dễ đọc hơn total=a+b;).

    • Căn lề ngang (Horizontal Alignment): Tại sao Clean Code khuyên không nên căn lề các biến theo cột (vì nó khiến mắt tập trung vào tên biến mà bỏ qua kiểu dữ liệu).

  • Thụt lề (Indentation): Hệ thống cấp bậc của code. Ngay cả những khối lệnh if 1 dòng cũng nên thụt lề thay vì viết trên cùng một dòng.

  • Lab 3: "Code Makeover".

    • Chỉnh sửa một file code C++ "rối nùi" về định dạng và lạm dụng comment sai cách.

BÀI TẬP

Nhiệm vụ 1: Thay thế Chú thích bằng Code sạch

Hãy sửa lại đoạn code sau sao cho không cần comment mà vẫn rõ nghĩa:

// Kiểm tra xem khách hàng có đủ điều kiện nhận ưu đãi không
// Phải trên 18 tuổi và có thẻ thành viên vàng
if (c.a > 18 && c.s == "GOLD") {
    // ...
}

(Gợi ý: Trích xuất điều kiện vào một hàm hoặc một biến mang tên isEligibleForReward).

Nhiệm vụ 2: Dọn dẹp "Code rác"

Tìm trong các project cũ của bạn hoặc một mã nguồn mở:

  1. Xóa tất cả các đoạn code bị comment (//).

  2. Định dạng lại khoảng cách giữa các hàm (Vertical Openness).

Nhiệm vụ 3: Thiết lập .clang-format

Tìm hiểu về công cụ tự động định dạng code clang-format trong C++. Hãy tạo một file cấu hình theo chuẩn Google hoặc LLVM và áp dụng nó vào project của bạn.


Câu hỏi

  1. Nếu một đồng nghiệp trong team luôn bắt bạn phải viết comment cho mọi hàm (như chuẩn Javadoc), bạn sẽ dùng lập luận nào trong Clean Code để thuyết phục họ rằng "Code tự giải thích" tốt hơn?

  2. Tại sao việc khai báo biến ngay trước khi dùng lại tốt hơn việc khai báo tất cả biến ở đầu hàm (phong cách C cũ)?

Ngày 4: Xử lý lỗi (Error Handling) & Biên (Boundaries)

Tư duy Xử lý lỗi

  • Mã lỗi (Return Codes) vs. Ngoại lệ (Exceptions):

    • Tại sao dùng if (status == ERROR_NOT_FOUND) lại làm bẩn code? (Nó bắt người gọi phải xử lý lỗi ngay lập tức, làm đứt gãy mạch logic chính).

    • Lợi ích của Exception: Tách biệt logic xử lý thành công và logic xử lý thất bại.

  • Cấu trúc Try-Catch-Finally chuẩn Clean Code:

    • Hãy coi try như một "giao dịch" (transaction).

    • Cách viết các khối catch cụ thể thay vì bắt mọi lỗi (catch (...)).

  • Sử dụng Unchecked Exceptions:

    • Trong C++, mặc dù không có từ khóa throws như Java, nhưng tư duy về việc không nên khai báo quá nhiều Exception ở phần chữ ký hàm (Function Signature) giúp giảm sự phụ thuộc giữa các lớp.

Kỹ thuật xử lý lỗi trong C++

  • Xác định ngữ cảnh lỗi (Provide Context with Exceptions):

    • Tạo các lớp Exception tùy chỉnh (Custom Exceptions) kế thừa từ std::exception.

    • Mỗi lỗi ném ra phải chứa thông tin: "Điều gì đã xảy ra?" và "Nó xảy ra ở đâu?".

  • Định nghĩa luồng hoạt động bình thường (Special Case Pattern):

    • Khi nào thì không nên ném Exception? Nếu lỗi đó là một phần của logic kinh doanh thông thường, hãy dùng Null Object hoặc Special Case để trả về một giá trị mặc định an toàn thay vì bắt Client phải xử lý ngoại lệ.
  • Đừng trả về NULL, Đừng truyền NULL:

    • Trả về nullptr là nguồn cơn của hàng triệu lỗi Null Pointer Exception.

    • Giải pháp: Trả về một đối tượng rỗng hoặc ném Exception ngay lập tức.

Boundaries - Làm việc với "Thế giới bên ngoài"

Đây là phần "đào bới" về cách tích hợp thư viện của bên thứ ba (Third-party) mà không làm hỏng code của mình.

  • Bao bọc thư viện (Wrapping Third-party code):

    • Tại sao không nên dùng trực tiếp API của bên thứ ba ở khắp nơi trong project? (Nếu thư viện đó thay đổi hoặc có lỗi, bạn phải sửa hàng trăm chỗ).

    • Kỹ thuật Adapter Pattern: Tạo một lớp Wrapper của riêng bạn để bao bọc thư viện đó. Code của bạn chỉ giao tiếp với Wrapper.

  • Sử dụng các API chưa hoàn thiện (Learning Tests):

    • Thay vì thử nghiệm thư viện ngay trong code chính, hãy viết các Unit Test nhỏ để học cách thư viện đó hoạt động.
  • Nguyên tắc "Biết về ranh giới":

    • Cách giữ cho code sạch ở những điểm giao thoa giữa code cũ (Legacy) và code mới.

BÀI TẬP

Nhiệm vụ 1: Chuyển đổi mã lỗi sang Exception

Hãy Refactor đoạn code sau từ phong cách C (Return codes) sang phong cách C++ Clean Code (Exceptions):

int openConnection(string connStr) {
    if (connStr == "") return -1; // Empty string error
    if (serverBusy) return -2;    // Busy error
    return 0; // Success
}
// Client gọi:
int res = openConnection("...");
if (res == -1) { /* log error */ }

Nhiệm vụ 2: Xây dựng lớp Wrapper cho một thư viện giả định

Giả sử bạn dùng thư viện LoggingThirdParty có các hàm rất phức tạp và khó dùng. Hãy viết một lớp MyLogger bao bọc nó, sao cho code chính của bạn chỉ cần gọi logger.info("message"). Nếu sau này bạn đổi sang thư viện khác, code chính không cần thay đổi.

Nhiệm vụ 3: Đọc chương 7 & 8 sách Clean Code

Ghi chú lại định nghĩa về "The Special Case Pattern" và tìm một ví dụ thực tế trong C++ (Ví dụ: Trả về một std::vector rỗng thay vì nullptr).


Câu hỏi

  1. Chi phí hiệu năng: Việc ném một Exception trong C++ tốn kém hơn nhiều so với trả về một số nguyên int. Tại sao Clean Code vẫn khuyên dùng Exception? (Gợi ý: Hãy nghĩ về tần suất lỗi xảy ra so với luồng chạy chính và giá trị của việc bảo trì code).

  2. Sự lệ thuộc: Nếu bạn dùng std::shared_ptr để tránh trả về NULL, liệu đó có phải là cách xử lý lỗi tốt nhất chưa?

Ngày 5: Unit Tests (TDD - Test Driven Development)

3 Luật của TDD và Quy trình đỏ-xanh

  • Tư duy TDD (Test Driven Development):

    • Tại sao lại viết Test trước khi viết Code? (Giúp thiết kế code linh hoạt hơn, dễ dàng tách biệt các thành phần).
  • 3 Luật của TDD (The Three Laws of TDD):

    1. Bạn không được phép viết bất kỳ mã nguồn (production code) nào cho đến khi bạn viết một Unit Test bị lỗi (fail).

    2. Bạn không được phép viết Unit Test nhiều hơn mức cần thiết để nó bị lỗi (không biên dịch được cũng là lỗi).

    3. Bạn không được phép viết mã nguồn nhiều hơn mức cần thiết để vượt qua (pass) Unit Test đang lỗi.

  • Quy trình Red - Green - Refactor:

    • Red: Viết một test lỗi.

    • Green: Viết code nhanh nhất có thể để test pass.

    • Refactor: Tối ưu hóa cả code chính và code test sau khi đã pass.

Giữ cho Test luôn Sạch

  • Mã Test cũng cần sạch như Mã chính:

    • Sai lầm lớn nhất là coi Test là "công dân hạng hai" và viết cẩu thả. Test bẩn sẽ khiến bạn mất nhiều thời gian bảo trì hơn cả code chính.
  • Quy tắc "Một Assert cho mỗi Test":

    • Giúp bạn biết chính xác tại sao một test bị fail mà không cần phải debug.
  • Quy tắc "Một khái niệm cho mỗi Test":

    • Đừng nhồi nhét kiểm tra quá nhiều thứ vào trong một hàm test duy nhất.
  • Cấu trúc GIVEN - WHEN - THEN (hoặc AAA: Arrange - Act - Assert):

    • Giúp bản thiết kế của Unit Test trở nên cực kỳ dễ đọc.

Quy tắc F.I.R.S.T cho Unit Test

Đầy là 5 tiêu chuẩn vàng để đánh giá chất lượng của một bộ Unit Test:

  • F (Fast) - Nhanh: Test phải chạy trong vài mili giây. Nếu test chậm, bạn sẽ lười chạy chúng.

  • I (Independent) - Độc lập: Test này không được phụ thuộc vào kết quả của test kia.

  • R (Repeatable) - Có thể lặp lại: Test phải chạy đúng trong mọi môi trường (Máy của bạn, máy đồng nghiệp, server CI/CD) mà không cần kết nối DB hay Internet.

  • S (Self-Validating) - Tự xác thực: Kết quả chỉ có thể là Pass hoặc Fail. Bạn không cần đọc log để đoán kết quả.

  • T (Timely) - Kịp thời: Unit Test phải được viết ngay trước khi viết code chính (theo TDD).


BÀI TẬP

Nhiệm vụ 1: Làm quen với Framework (Google Test - gTest)

  • Cài đặt thư viện Google Test cho C++.

  • Viết một test đơn giản cho hàm sum(a, b).

Nhiệm vụ 2: Thực hành TDD (Bài toán FizzBuzz)

Hãy áp dụng đúng 3 luật của TDD để viết chương trình:

  • Nếu số chia hết cho 3, trả về "Fizz".

  • Nếu số chia hết cho 5, trả về "Buzz".

  • Nếu chia hết cho cả 3 và 5, trả về "FizzBuzz".

  • Yêu cầu: Tuyệt đối không viết code xử lý trước khi có test lỗi cho trường hợp đó.

Nhiệm vụ 3: Refactor Test Bẩn

Tìm một đoạn code Test viết cẩu thả (nhiều assert, tên hàm khó hiểu). Nhiệm vụ của bạn là dọn dẹp nó theo quy tắc AAA và F.I.R.S.T.


Câu hỏi

  1. Lợi ích kinh tế: Viết Test tốn thêm khoảng 30% thời gian code ban đầu. Tại sao các công ty lớn vẫn bắt buộc làm điều này? (Gợi ý: Tính chi phí sửa bug ở môi trường Production so với môi trường Dev).

  2. Code không thể Test (Untestable code): Nếu một hàm phụ thuộc vào thời gian hệ thống hoặc dữ liệu ngẫu nhiên, làm sao để viết Unit Test cho nó? (Gợi ý: Tìm hiểu về MockingDependency Injection).

Ngày 6: Lớp (Classes) & Tổ chức hệ thống

Tổ chức nội dung lớp (Class Organization)

  • Thứ tự các thành phần (Standard Class Layout):

    • Tại sao nên đặt biến hằng (public static constants) lên đầu?

    • Thứ tự: Variables (Private, then Protected) -> Public Functions -> Private Utilities.

    • Quy tắc "Ẩn đi chi tiết": Các hàm private hỗ trợ cho một hàm public nên nằm ngay sau hàm public đó (đọc theo quy tắc tờ báo).

  • Kích thước lớp (Class Size):

    • Quy tắc số 1: Lớp phải NHỎ.

    • Đếm trách nhiệm thay vì đếm dòng code. Một lớp không nên có quá nhiều "lý do để thay đổi".

  • Nguyên lý Đơn nhiệm (Single Responsibility Principle - SRP):

    • Cách nhận diện một lớp đang làm quá nhiều việc thông qua tên lớp (Nếu tên lớp chứa các từ như Manager, Processor, Super, nó thường vi phạm SRP).

Tính đóng gói (Encapsulation)

  • Bản chất của Đóng gói:

    • Không chỉ là đặt private cho biến. Đó là việc bảo vệ "Trạng thái nội tại" (Internal State) của đối tượng.

    • Tại sao Getter/Setter đôi khi là "kẻ thù" của đóng gói? (Quy tắc Tell, Don't Ask: Hãy bảo đối tượng làm việc thay vì lấy dữ liệu từ nó ra để tự tính toán).

  • Sự gắn kết (Cohesion):

    • Một lớp có độ gắn kết cao là lớp mà hầu hết các hàm đều sử dụng hầu hết các biến thành viên.

    • Khi độ gắn kết giảm xuống, đó là dấu hiệu bạn nên tách lớp lớn thành 2 hoặc nhiều lớp nhỏ hơn.

  • Duy trì sự thay đổi (Organizing for Change):

    • Giới thiệu về cấu trúc lớp giúp dễ dàng mở rộng mà không cần sửa đổi code cũ (Open-Closed Principle sơ lược).

Thực hành C++ OOP & Refactoring

  • Làm việc với Constructor & Destructor:

    • Cách khởi tạo đối tượng sạch sẽ. Tránh viết quá nhiều logic trong Constructor.
  • Tách biệt dữ liệu và hành vi:

    • Khi nào dùng struct (Data Transfer Object - DTO) và khi nào dùng class.
  • Lab 6: "The Great Split".

    • Tôi sẽ đưa cho bạn một lớp "Vạn năng" (God Class) làm đủ thứ việc từ tính toán, lưu file đến in báo cáo. Nhiệm vụ của bạn là chia nó thành các lớp nhỏ phối hợp với nhau.

BÀI TẬP

Nhiệm vụ 1: Nhận diện vi phạm SRP

Phân tích lớp sau và liệt kê các lý do tại sao nó vi phạm nguyên lý đơn nhiệm:

class Employee {
public:
    double calculatePay();
    void saveToDatabase();
    string reportHours();
    void printPayStub();
};

(Gợi ý: Ai là người quan tâm đến từng hàm này? Kế toán, DB Admin hay Nhân sự?)

Nhiệm vụ 2: Tối ưu hóa sự gắn kết (Cohesion)

Chia lớp sau thành 2 lớp nhỏ hơn để tăng độ gắn kết:

class MultiTool {
    double x, y; // Tọa độ
    string buffer; // Dữ liệu xử lý văn bản
    void move(int dx, int dy);
    void appendText(string t);
    void clearBuffer();
    void draw();
};

Nhiệm vụ 3: Đọc chương 10 sách Clean Code

Ghi chú lại kỹ thuật "Tách lớp để hỗ trợ thay đổi" mà Uncle Bob trình bày thông qua ví dụ lớp Sql.


Câu hỏi

  1. Nếu chúng ta tách một lớp lớn thành nhiều lớp nhỏ, số lượng file trong project sẽ tăng vọt. Điều này có làm hệ thống khó hiểu hơn không?

  2. Tính đóng gói vs. Hiệu năng: Việc gọi qua nhiều lớp trung gian (Delegation) có làm chậm chương trình không? (Gợi ý: Trình biên dịch C++ xử lý việc này như thế nào thông qua tối ưu hóa bộ nhớ).

Ngày 7: Ôn tập

Ôn tập các kiến thức đã học từ ngày 1 - 6

GIAI ĐOẠN 2: LẬP TRÌNH HƯỚNG ĐỐI TƯỢNG (OOP)

Ngày 8: Đối tượng & Lớp (Objects & Classes)

Phân tích bộ nhớ Stack vs Heap

  • Vùng nhớ Stack (Tĩnh):

    • Cơ chế LIFO (Last In First Out).

    • Cách các biến cục bộ và tham số hàm được giải phóng tự động.

    • Tốc độ truy cập cực nhanh nhưng dung lượng hạn chế (Stack Overflow).

  • Vùng nhớ Heap (Động):

    • Quản lý bởi lập trình viên thông qua từ khóa newdelete.

    • Tốc độ chậm hơn Stack nhưng linh hoạt, tồn tại cho đến khi bị xóa thủ công.

    • Hiểm họa về Memory Leak (Rò rỉ bộ nhớ).

  • Vòng đời của Đối tượng (Object Lifetime):

    • Đối tượng trên Stack: Tự hủy khi ra khỏi phạm vi (scope).

    • Đối tượng trên Heap: Tồn tại mãi mãi nếu bạn quên delete.

Con trỏ tới Đối tượng (Pointer to Object)

  • Khai báo và Khởi tạo:

    • Cách dùng con trỏ để quản lý đối tượng trên Heap: Product* p = new Product();.

    • Phân biệt giữa p (địa chỉ - con trỏ) và p (giá trị - đối tượng).

  • Toán tử mũi tên (>):

    • Tại sao dùng p->show() thay vì (*p).show().
  • Mảng Đối tượng Động (Array of Objects):

    • Cách cấp phát mảng trên Heap và tầm quan trọng của delete[].
  • Con trỏ this:

    • Bản chất thực sự của this: Một con trỏ hằng (constant pointer) ẩn danh trỏ đến chính địa chỉ của đối tượng đang thực thi hàm thành viên.

Constructor, Destructor & Deep Copy

  • Constructor & Member Initializer List:

    • Tại sao nên dùng danh sách khởi tạo thay vì gán giá trị trong thân Constructor (Hiệu năng và tính đúng đắn).
  • Destructor:

    • Vai trò "dọn dẹp" tài nguyên (đóng file, giải phóng bộ nhớ động bên trong class).
  • Shallow Copy vs Deep Copy (Vấn đề cực khó):

    • Điều gì xảy ra khi bạn gán Object A = Object B nếu bên trong có con trỏ?

    • Cách viết Copy Constructor để thực hiện Deep Copy, tránh lỗi "Double Free".


BÀI TẬP

Nhiệm vụ 1: Giải mã địa chỉ bộ nhớ

Viết một chương trình khởi tạo 1 biến trên Stack và 1 biến trên Heap. In địa chỉ của chúng ra. Nhận xét về sự chênh lệch giữa các địa chỉ (Vùng nhớ Stack thường bắt đầu từ địa chỉ cao và giảm dần, Heap ngược lại).

Nhiệm vụ 2: Quản lý bộ nhớ động cho Class

Xây dựng lớp DynamicArray thủ công (không dùng std::vector):

  • Thuộc tính: int* data, int size.

  • Constructor: Cấp phát mảng động trên Heap.

  • Destructor: Giải phóng bộ nhớ bằng delete[].

  • Thử thách: Viết Copy Constructor để khi gán 2 đối tượng DynamicArray, chương trình không bị crash.

Nhiệm vụ 3: Đọc chương 4 trong sách OOP-C++.pdf (Joyce Farrell)

Tập trung vào phần "Understanding Pointers and Objects" để hiểu cách C++ quản lý địa chỉ của các instance.


Câu hỏi

  1. Cache Locality: Tại sao việc tạo hàng ngàn đối tượng bằng new (trên Heap) lại làm chương trình chạy chậm hơn rất nhiều so với tạo mảng đối tượng trên Stack? (Gợi ý: CPU Cache thích dữ liệu nằm sát nhau).

  2. Smart Pointers (Giới thiệu): Tại sao trong C++ hiện đại, người ta khuyên hạn chế dùng new/delete thủ công và thay bằng std::unique_ptr?

Ngày 9: Tính đóng gói (Encapsulation) & Access Specifiers

Access Specifiers

  • Public (Công khai): Interface của đối tượng. Chỉ những gì cần thiết cho thế giới bên ngoài mới được đặt ở đây.

  • Private (Riêng tư): Trái tim và chi tiết cài đặt. Mọi biến thành viên (data members) mặc định phải là private.

  • Protected (Bảo vệ): "Vùng xám" dành cho kế thừa. Tại sao Clean Code khuyên nên hạn chế protected vì nó làm rò rỉ chi tiết cài đặt xuống lớp con.

  • Bản chất kỹ thuật: Cách trình biên dịch C++ kiểm tra quyền truy cập (Access control không diễn ra ở thời điểm thực thi - runtime, mà ở thời điểm biên dịch - compile time).

Getter & Setter

Đây là phần "đào bới" sâu vào các sai lầm phổ biến mà ngay cả lập trình viên kinh nghiệm cũng mắc phải.

  • Cái bẫy của Getter/Setter tự động: Tại sao việc viết get/set cho mọi biến private thực chất là phá hủy tính đóng gói (vì bạn đang lộ hết dữ liệu ra ngoài).

  • Nguyên lý "Tell, Don't Ask" (Hãy ra lệnh, đừng hỏi):

    • Sai: Lấy số dư, tự trừ tiền, rồi set lại số dư.

    • Đúng: Bảo đối tượng tài khoản thực hiện hàm withdraw(amount).

  • Mutable vs Immutable Objects: Cách thiết kế các đối tượng không thể thay đổi trạng thái sau khi khởi tạo để tăng tính an toàn trong lập trình đa luồng.

  • Validation (Xác thực): Setter không chỉ là gán giá trị, đó là "người gác cổng". Ví dụ: setAge(int a) phải kiểm tra a > 0.

Encapsulation & Refactoring

  • Hàm truy cập Hằng (Const Member Functions):

    • Tầm quan trọng của từ khóa const sau tên hàm: void print() const;.

    • Giúp bảo vệ đối tượng không bị thay đổi trạng thái ngoài ý muốn.

  • Lớp ẩn (Inner/Nested Classes): Cách đóng gói một lớp bên trong một lớp khác để che giấu hoàn toàn logic phụ trợ.

  • Lab 9: "Smart Wallet". * Xây dựng hệ thống ví điện tử. Bạn sẽ thấy nếu để lộ dữ liệu (Public balance), hệ thống dễ bị hack hoặc lỗi logic như thế nào.


BÀI TẬP

Nhiệm vụ 1: Phân tích sự rò rỉ (Data Leaking)

Hãy chỉ ra tại sao lớp sau vi phạm tính đóng gói nghiêm trọng và sửa lại nó:

class UserProfile {
public:
    string username;
    int age;
    void updateAge(int newAge) { age = newAge; }
};

Nhiệm vụ 2: Áp dụng "Tell, Don't Ask"

Viết một lớp Engine có thuộc tính temperature. Thay vì dùng getTemperature() để kiểm tra xem động cơ có quá nhiệt không, hãy viết hàm bool isOverheating() bên trong lớp Engine.

Nhiệm vụ 3: Đọc chương 5 trong sách OOP-C++.pdf (Joyce Farrell)

Tập trung vào phần "Encapsulation" và "Class Features". Ghi chú lại cách tác giả giải thích về việc "Che giấu dữ liệu" (Data Hiding).


LAB 9: THE SECURE WALLET (VÍ ĐIỆN TỬ BẢO MẬT)

Bạn cần xây dựng lớp Wallet với các quy tắc đóng gói sau:

  1. Biến balance (số dư) phải là private.

  2. Không có setBalance: Số dư chỉ được thay đổi thông qua các hành động deposit(amount)withdraw(amount).

  3. Xác thực:

    • deposit: Số tiền phải dương.

    • withdraw: Số tiền phải dương và không được vượt quá số dư hiện tại.

  4. Hàm getBalance(): Phải là hàm const.

  5. Thử thách: Thêm một biến private: int transactionCount để đếm mỗi lần giao dịch thành công. Biến này không được có cả Getter lẫn Setter, chỉ được dùng nội bộ cho hàm printSummary().


Câu hỏi

  1. Nếu một biến là private, nhưng bạn lại trả về một con trỏ hoặc tham chiếu tới biến đó thông qua Getter, thì tính đóng gói có còn tồn tại không?

  2. Trong C++, từ khóa friend cho phép một lớp khác truy cập vào vùng private. Vậy friend là "anh hùng" hay "tội đồ" đối với tính đóng gói?

Ngày 10: Tính kế thừa (Inheritance) & Thành phần (Composition)

Tính kế thừa - Quy tắc "Is-a"

  • Bản chất của Inheritance:

    • Cơ chế tái sử dụng mã nguồn bằng cách tạo ra lớp con (Derived class) từ lớp cha (Base class).

    • Cú pháp và ý nghĩa của các kiểu kế thừa trong C++: public, protected, private.

  • Quy tắc "Is-a" (Là một):

    • Chỉ kế thừa khi lớp con thực sự là một biến thể của lớp cha. Ví dụ: Car is-a Vehicle, Dog is-a Animal.
  • Vấn đề của Kế thừa:

    • Tight Coupling (Gắn kết chặt): Thay đổi ở lớp cha có thể làm hỏng tất cả các lớp con.

    • Fragile Base Class Problem: Lớp cha quá phức tạp khiến lớp con khó kiểm soát.

Tính thành phần - Quy tắc "Has-a"

  • Bản chất của Composition:

    • Tạo ra các lớp phức tạp bằng cách kết hợp các đối tượng của các lớp khác làm thành viên.
  • Quy tắc "Has-a" (Có một):

    • Sử dụng khi một đối tượng chứa hoặc sở hữu đối tượng khác. Ví dụ: Car has-a Engine, House has-a Room.
  • Tại sao nên ưu tiên Composition?

    • Loose Coupling (Gắn kết lỏng): Bạn có thể thay đổi lớp Engine mà không ảnh hưởng đến cấu trúc của lớp Car.

    • Linh hoạt trong Runtime: Bạn có thể thay đổi "linh kiện" (đối tượng thành viên) khi chương trình đang chạy, điều mà kế thừa (tĩnh) không làm được.

Thiết kế hệ thống & Đa kế thừa

  • Vấn đề Đa kế thừa (Multiple Inheritance) trong C++:

    • The Diamond Problem (Lỗi hình kim cương): Khi một lớp kế thừa từ hai lớp có cùng một lớp cha.

    • Giải pháp: Virtual Inheritance.

  • Lớp cơ sở trừu tượng (Abstract Base Class) sơ lược: Tiền đề cho tính đa hình ngày mai.

  • Lab 10: "The Robot Builder". Thiết kế một hệ thống Robot. Bạn sẽ so sánh việc tạo Robot bằng kế thừa (Robot chiến đấu kế thừa từ Robot cơ bản) với việc dùng Composition (Robot lắp thêm module vũ khí).


BÀI TẬP

Nhiệm vụ 1: Nhận diện sai lầm thiết kế

Phân tích xem quan hệ sau đây có nên dùng kế thừa không? Nếu không, hãy chuyển sang thành phần:

  • Lớp Stack kế thừa từ lớp ArrayList.

  • (Gợi ý: Stack có thực sự là một ArrayList không? Hay Stack chỉ "cần dùng" một ArrayList bên trong để lưu dữ liệu?)

Nhiệm vụ 2: Diamond Problem

Viết một đoạn code nhỏ gây ra lỗi Diamond Problem trong C++ và sử dụng từ khóa virtual để sửa nó.

Nhiệm vụ 3: Đọc chương 9 trong sách OOP-C++.pdf (Joyce Farrell)

Tập trung vào "Inheritance" và các ví dụ về việc mở rộng lớp.


LAB 10: ROBOT SIMULATOR (COMPOSITION VS INHERITANCE)

Bạn cần xây dựng một hệ thống Robot. Hãy thử làm theo 2 cách và so sánh:

Cách 1 (Inheritance):

  • Lớp Robot.

  • Lớp CombatRobot kế thừa Robot.

  • Lớp WorkerRobot kế thừa Robot.

  • Vấn đề: Nếu bạn muốn một Robot vừa biết chiến đấu vừa biết làm việc (CombatWorkerRobot), bạn sẽ phải dùng đa kế thừa phức tạp.

Cách 2 (Composition - KHUYÊN DÙNG):

  • Lớp Robot.

  • Lớp WeaponSystem (Module vũ khí).

  • Lớp ToolSystem (Module công cụ).

  • Lớp Robot chứa một vector các Module. Bạn có thể thêm bất kỳ module nào vào bất kỳ lúc nào.


Câu hỏi

  1. Nếu bạn kế thừa chỉ để sử dụng lại một vài hàm của lớp cha, nhưng lớp con không thực sự "là một" lớp cha, thì điều này vi phạm nguyên tắc nào trong Clean Code?

  2. Làm thế nào để quyết định khi nào dùng protected thay vì private cho các thành viên của lớp cha?

Ngày 11: Tính đa hình (Polymorphism) - Virtual Functions

Khái niệm Đa hình & Hàm ảo

  • Bản chất của Đa hình (Runtime Polymorphism):

    • Khả năng một thông điệp (lời gọi hàm) được thực hiện theo nhiều cách khác nhau tùy thuộc vào đối tượng nhận thông điệp.
  • Từ khóa virtual:

    • Tại sao cần hàm ảo? Sự khác biệt giữa ghi đè (Overriding) và nạp chồng (Overloading).

    • Quy tắc ghi đè: Chữ ký hàm (signature) phải giống hệt nhau ở lớp cha và lớp con.

  • Từ khóa overridefinal (C++11 trở lên):

    • Tại sao nên luôn dùng override để tránh những lỗi ngớ ngẩn khi sai lệch tham số.

    • final: Ngăn chặn việc tiếp tục ghi đè hàm hoặc kế thừa lớp.

"Dưới nắp capo" - V-Table và V-Pointer

Đây là phần "đào bới" sâu nhất về mặt kỹ thuật để giải thích cách C++ thực hiện đa hình.

  • V-Table (Virtual Method Table):

    • Một bảng tĩnh được trình biên dịch tạo ra cho mỗi lớp có ít nhất một hàm ảo. Nó chứa địa chỉ của các hàm ảo của lớp đó.
  • V-Pointer (vptr):

    • Một con trỏ ẩn được thêm vào mỗi đối tượng của lớp. Nó trỏ tới V-Table của lớp tương ứng.
  • Cơ chế gọi hàm (Dynamic Binding):

    • Khi gọi object->virtualFunction(), chương trình sẽ:

      1. Lấy vptr từ đối tượng.

      2. Truy cập vào V-Table.

      3. Tìm địa chỉ hàm chính xác để thực thi.

  • Kích thước của đối tượng: Giải thích tại sao một lớp có hàm ảo lại có kích thước lớn hơn lớp không có hàm ảo (do tốn thêm dung lượng cho vptr).

Destructor ảo & Slicing

  • Virtual Destructor (Cực kỳ quan trọng):

    • Tại sao Destructor của lớp cha luôn luôn phải là virtual?

    • Hậu quả của việc thiếu virtual destructor: Rò rỉ bộ nhớ (Memory Leak) khi xóa đối tượng lớp con thông qua con trỏ lớp cha.

  • Object Slicing (Cắt cụt đối tượng):

    • Chuyện gì xảy ra khi bạn truyền đối tượng theo giá trị (pass-by-value) thay vì theo tham chiếu/con trỏ? (Tính đa hình sẽ bị mất).
  • Lab 11: "The Shape Manager". * Xây dựng hệ thống quản lý hình học (Circle, Square, Triangle). Bạn sẽ viết một hàm nhận vào danh sách Shape* và gọi draw() mà không cần quan tâm đó là hình gì.


BÀI TẬP

Nhiệm vụ 1: So sánh kích thước (sizeof)

Viết chương trình so sánh sizeof của hai lớp: Một lớp không có hàm ảo và một lớp có hàm ảo. Giải thích sự chênh lệch (thường là 4 hoặc 8 bytes tùy hệ điều hành).

Nhiệm vụ 2: Thảm họa Destructor

  1. Viết một lớp BaseDerived. Trong Derived có cấp phát mảng động.

  2. Dùng con trỏ Base* b = new Derived().

  3. Xóa delete b khi Base KHÔNG có virtual destructor. Dùng công cụ check leak hoặc in ra màn hình để thấy destructor của Derived không được gọi.

  4. Thêm virtual và quan sát sự thay đổi.

Nhiệm vụ 3: Đọc chương 10 trong sách OOP-C++.pdf (Joyce Farrell)

Tập trung vào phần "Virtual Functions" và "Dynamic Binding".


LAB 11: HỆ THỐNG QUẢN LÝ THANH TOÁN (PAYMENT SYSTEM)

Bạn cần xây dựng một hệ thống xử lý lương cho nhân viên:

  1. Lớp cơ sở Employee có hàm ảo calculateSalary().

  2. Các lớp con: FullTimeEmployee (Lương cứng), Contractor (Lương theo giờ).

  3. Viết một hàm printPayroll(vector<Employee*> employees):

    • Hàm này duyệt qua danh sách và gọi calculateSalary().

    • Yêu cầu: Không được dùng if-else hay switch-case để kiểm tra kiểu nhân viên. Hãy để V-Table tự quyết định.


Câu hỏi

  1. Chi phí hiệu năng: Việc gọi một hàm ảo chậm hơn hàm thường bao nhiêu lần? (Gợi ý: Do phải truy cập gián tiếp qua con trỏ và bảng ảo, cộng thêm việc không thể inline hàm).

  2. Khi nào chúng ta không nên dùng hàm ảo mặc dù có kế thừa?

Ngày 12: Lớp trừu tượng (Abstract Classes) & Interface

Lớp trừu tượng (Abstract Classes)

  • Hàm ảo thuần túy (Pure Virtual Functions):

    • Cú pháp: virtual void doSomething() = 0;.

    • Ý nghĩa: Ép buộc các lớp con phải cài đặt hàm này nếu muốn khởi tạo đối tượng.

  • Định nghĩa Lớp trừu tượng:

    • Một lớp chứa ít nhất một hàm ảo thuần túy là lớp trừu tượng.

    • Bạn không thể khởi tạo đối tượng từ lớp trừu tượng (ví dụ: Animal a; sẽ bị lỗi nếu Animal là trừu tượng).

  • Vai trò của Constructor trong Lớp trừu tượng:

    • Tại sao lớp trừu tượng vẫn cần Constructor dù không thể tạo đối tượng trực tiếp? (Để khởi tạo các thuộc tính chung cho lớp con).

Interface trong C++

  • Khái niệm Interface:

    • Một lớp chỉ chứa các hàm ảo thuần túy và không có biến thành viên (data members).

    • Nó định nghĩa một "Hợp đồng" (Contract): "Bất kỳ lớp nào triển khai Interface này đều hứa sẽ có các hành vi này".

  • Mô phỏng Interface trong C++:

    • C++ không có từ khóa interface như Java hay C#, chúng ta dùng lớp thuần ảo.
  • Đa kế thừa Interface:

    • Tại sao đa kế thừa lớp thì nguy hiểm (Diamond Problem) nhưng đa kế thừa Interface lại là Best Practice?

    • Cách một lớp có thể đóng nhiều "vai" khác nhau (ví dụ: SmartPhone vừa là ICamera, vừa là IPhone, vừa là IGpsDevice).

Tư duy thiết kế "Program to an Interface"

  • Lợi ích của việc lập trình dựa trên Interface:

    • Decoupling (Ngắt kết nối): Lớp gọi (Client) không cần biết lớp thực thi (Server) là ai, chỉ cần biết nó tuân thủ Interface nào.

    • Dễ dàng thay thế phụ thuộc (ví dụ: Đổi từ SqlDatabase sang MongoDatabase mà không sửa code nghiệp vụ).

  • Quy tắc đặt tên: Thói quen dùng tiền tố I (ví dụ: IShape, ILogger) để phân biệt Interface.

  • Lab 12: "The Plugin System". * Xây dựng một hệ thống phát nhạc. Bạn định nghĩa Interface IMediaPlayer. Sau đó cài đặt các lớp Mp3Player, WavPlayer, FlacPlayer. Hệ thống chính chỉ gọi IMediaPlayer->play().


BÀI TẬP

Nhiệm vụ 1: Phân biệt trừu tượng và cụ thể

Trong các danh từ sau, cái nào nên là lớp cụ thể (Concrete), cái nào nên là lớp trừu tượng (Abstract), cái nào nên là Interface (I):

  • LivingThing, Bird, Eagle, Flyable, Swimmable, Penguin.

  • Hãy vẽ sơ đồ mối quan hệ giữa chúng.

Nhiệm vụ 2: Cài đặt Interface đa năng

  1. Định nghĩa Interface IShape với hàm getArea()getPerimeter().

  2. Định nghĩa Interface IDrawable với hàm draw().

  3. Tạo lớp Circle kế thừa từ cả hai Interface trên.

Nhiệm vụ 3: Đọc chương 10 (phần cuối) trong sách OOP-C++.pdf (Joyce Farrell)

Tập trung vào phần "Abstract Classes and Pure Virtual Functions". Ghi chú lại lý do tác giả nói lớp trừu tượng giúp ngăn chặn lỗi logic trong thiết kế.


LAB 12: HỆ THỐNG GỬI THÔNG BÁO (NOTIFICATION SYSTEM)

Đây là bài tập kinh điển để bạn hiểu sức mạnh của Interface:

  1. Định nghĩa Interface IMessageService với hàm ảo thuần túy sendMessage(string msg).

  2. Cài đặt 3 lớp thực thi:

    • EmailService: In ra "Sending Email: [msg]".

    • SmsService: In ra "Sending SMS: [msg]".

    • PushNotificationService: In ra "Sending Push: [msg]".

  3. Viết lớp UserAlert:

    • Lớp này chứa một con trỏ tới IMessageService.

    • Hàm notify(string text) sẽ gọi service->sendMessage(text).

  4. Thực thi trong main:

    • Thử truyền các loại Service khác nhau vào UserAlert và quan sát kết quả.

    • Bạn sẽ thấy UserAlert hoàn toàn không quan tâm nó đang dùng Email hay SMS (Tính ngắt kết nối).


Câu hỏi

  1. Tại sao chúng ta lại ưu tiên dùng Interface thay vì kế thừa từ một lớp cha có sẵn các hàm đã cài đặt (Concrete Base Class)? (Gợi ý: Liên quan đến sự linh hoạt và tránh "Fragile Base Class").

  2. Trong C++, một lớp có thể có thân hàm (body) cho một hàm ảo thuần túy không? Nếu có thì để làm gì?

Ngày 13: Operator Overloading

Khái niệm & Toán tử Số học

  • Triết lý của Overloading:

    • Tại sao dùng complex1 + complex2 lại tốt hơn complex1.add(complex2)?

    • Các toán tử không thể nạp chồng: . (dot), :: (scope), ?: (ternary), sizeof.

  • Cú pháp hàm operator:

    • Cách định nghĩa toán tử như một hàm thành viên (Member function).

    • Cách định nghĩa toán tử như một hàm tự do (Non-member/Friend function).

  • Toán tử số học (+, -, , /):

    • Quy tắc trả về giá trị (Return by value) thay vì tham chiếu cho các phép toán tạo ra kết quả mới.

Toán tử So sánh, Nhập xuất & Gán

  • Toán tử So sánh (==, !=, <, >):

    • Luôn trả về kiểu bool.

    • Tại sao khi nạp chồng <, bạn nên nạp chồng cả == để đảm bảo tính nhất quán logic.

  • Toán tử Nhập/Xuất (<<, >>):

    • Tại sao các toán tử này bắt buộc phải là friend function (vì đối tượng bên trái là ostream hoặc istream, không phải lớp của bạn).
  • Toán tử gán (=):

    • Phân biệt với Copy Constructor (đã học Ngày 8).

    • Quy tắc kiểm tra tự gán (if (this == &other)) để tránh lỗi bộ nhớ.

  • Toán tử tăng/giảm (++, --):

    • Cách phân biệt giữa tiền tố (Prefix: ++obj) và hậu tố (Postfix: obj++) bằng tham số int giả.

Các quy tắc an toàn & Best Practices

  • Sự nhất quán (Consistency):

    • Đừng nạp chồng toán tử + để làm phép trừ. Hãy giữ đúng ý nghĩa trực quan của toán tử.

    • Nếu nạp chồng +, hãy nạp chồng cả +=.

  • R-value references & Move Assignment (Sơ lược):

    • Giới thiệu cách tối ưu hóa toán tử gán để tránh copy dữ liệu lớn (tiền đề cho Ngày 25).
  • Sách tham khảo: Đọc chương 8 trong sách OOP-C++.pdf (Joyce Farrell) về "Operator Overloading".

  • Lab 13: "Complex Number Engine". Xây dựng lớp số phức (Số thực + Số ảo) hỗ trợ đầy đủ các phép tính toán học và nhập xuất.


BÀI TẬP

Nhiệm vụ 1: Lỗi logic toán tử

Giải thích tại sao đoạn code sau lại nguy hiểm và vi phạm quy tắc Clean Code:

Vector operator+(const Vector& a, const Vector& b) {
    Vector result;
    result.x = a.x * b.x; // Sai lệch ý nghĩa toán tử
    return result;
}

Nhiệm vụ 2: Implement toán tử so sánh

Xây dựng lớp Student có thuộc tính gpa. Nạp chồng toán tử < để có thể dùng std::sort sắp xếp danh sách sinh viên theo điểm trung bình.

Nhiệm vụ 3: Toán tử nhập xuất chuyên nghiệp

Viết lớp Point2D(x, y). Nạp chồng << sao cho khi in ra sẽ có định dạng (x, y) và >> để nhập trực tiếp từ bàn phím.


LAB 13: THE FRACTION CALCULATOR (MÁY TÍNH PHÂN SỐ)

Bạn cần xây dựng lớp Fraction (Phân số) gồm numerator (tử số) và denominator (mẫu số) với các yêu cầu:

  1. Toán tử +, , , /: Thực hiện tính toán và trả về một phân số mới đã tối giản (rút gọn).

  2. Toán tử ==, !=: So sánh hai phân số.

  3. Toán tử <<>>: Cho phép viết cout << frac1;cin >> frac2;.

  4. Hàm tiện ích private: gcd(int a, int b) để tìm ước chung lớn nhất phục vụ rút gọn phân số.


Câu hỏi

  1. Tại sao chúng ta thường truyền tham số cho các toán tử là const ClassName& (tham chiếu hằng) thay vì truyền giá trị trực tiếp?

  2. Nếu bạn nạp chồng toán tử [] (subscript), bạn nên trả về cái gì để người dùng có thể viết được lệnh myObj[0] = 10;?

Ngày 14: Friend Functions & Friend Classes

Khái niệm & Cơ chế kỹ thuật

  • Friend Function là gì?

    • Một hàm không phải là thành viên của lớp nhưng có quyền truy cập vào các thành viên privateprotected của lớp đó.

    • Tại sao dùng friend thay vì tạo Getter/Setter công khai? (Để tránh lộ dữ liệu cho toàn bộ thế giới).

  • Friend Class là gì?

    • Cho phép toàn bộ các hàm thành viên của Lớp A truy cập vào dữ liệu riêng tư của Lớp B.
  • Các đặc điểm quan trọng của tình bạn (Friendship properties):

    • Không có tính bắc cầu: A là bạn của B, B là bạn của C -> Không có nghĩa A là bạn của C.

    • Không có tính hai chiều: A khai báo B là bạn -> B có quyền vào nhà A, nhưng A không tự nhiên có quyền vào nhà B.

    • Không có tính kế thừa: Lớp cha có bạn, không có nghĩa lớp con cũng có người bạn đó.

Khi nào nên dùng Friend? (Chiến lược tối ưu)

Đây là phần "đào bới" để bạn không trở thành một lập trình viên cẩu thả.

  • Trường hợp 1: Nạp chồng toán tử nhập xuất (<<, >>).

    • Đây là ứng dụng phổ biến nhất (đã nhắc ở Ngày 13). ostream cần truy cập dữ liệu để in nhưng không thể là thành viên của lớp.
  • Trường hợp 2: Tối ưu hóa hiệu năng (Performance Optimization).

    • Thay vì gọi một chuỗi các hàm Getter/Setter (tốn chi phí gọi hàm và copy dữ liệu), một hàm friend có thể truy cập trực tiếp vào vùng nhớ. Điều này cực kỳ quan trọng trong các thư viện tính toán đồ họa hoặc xử lý ma trận.
  • Trường hợp 3: Bridge Pattern (Cầu nối giữa 2 lớp liên quan chặt chẽ).

    • Ví dụ: Lớp Matrix và lớp Vector. Hàm nhân ma trận với vector cần truy cập dữ liệu lõi của cả hai để đạt tốc độ tối đa.

Bảo mật & Refactoring

  • Nguy cơ phá vỡ Encapsulation:

    • Làm sao để dùng friend mà vẫn giữ code "Sạch"? (Nguyên tắc: Chỉ dùng friend khi nó làm cho Interface của lớp trở nên tự nhiên hơn hoặc khi hiệu năng là ưu tiên sống còn).
  • Friend Member Function:

    • Thay vì cho cả một lớp là bạn, chỉ cho phép duy nhất một hàm cụ thể của lớp khác làm bạn (Kỹ thuật tinh vi hơn để hạn chế quyền truy cập).
  • Sách tham khảo: Đọc chương 6 trong sách OOP-C++.pdf (Joyce Farrell) về "Friend Functions".

  • Lab 14: "The Private Vault". * Xây dựng một hệ thống Két sắt (Vault) và lớp Thám tử (Detective). Chỉ những thám tử được cấp quyền friend mới có thể kiểm tra số dư bí mật mà không cần mật khẩu.


BÀI TẬP

Nhiệm vụ 1: So sánh Hiệu năng (Tư duy Optimization)

Giả sử bạn có lớp BigData chứa mảng 1 triệu phần tử.

  1. Viết hàm in mảng bằng cách dùng Getter gọi 1 triệu lần.

  2. Viết hàm in mảng bằng cách dùng friend function truy cập trực tiếp.

    Đánh giá về độ phức tạp và khả năng tối ưu hóa của trình biên dịch.

Nhiệm vụ 2: Thiết kế quan hệ bạn bè hạn chế

Tạo lớp Bank và lớp Auditor (Kiểm toán). Hãy cấu hình sao cho chỉ có hàm Auditor::verifyTransaction() được quyền xem biến private: totalAssets của Bank, các hàm khác của Auditor thì không.

Nhiệm vụ 3: Đọc chương 10 sách Clean Code

Liên hệ kiến thức: Việc dùng friend có vi phạm quy tắc "Classes should be small" và "Single Responsibility" không? Tại sao?


LAB 14: MATRIX-VECTOR MULTIPLIER (TỐI ƯU HÓA TÍNH TOÁN)

Bạn cần thực hiện hệ thống tính toán sau:

  1. Lớp Vector: Chứa mảng động double* values.

  2. Lớp Matrix: Chứa mảng 2 chiều double** data.

  3. Hàm friend Vector multiply(const Matrix& m, const Vector& v):

    • Hàm này phải được khai báo là friend trong cả 2 lớp.

    • Nó sẽ truy cập trực tiếp vào m.datav.values để tính toán tích ma trận.

  4. Yêu cầu: Không sử dụng bất kỳ hàm Getter nào trong quá trình tính toán để đạt tốc độ truy cập vùng nhớ nhanh nhất.


Câu hỏi

  1. Tại sao nói friend làm tăng tính đóng gói trong một số trường hợp cụ thể (thay vì làm giảm)? (Gợi ý: Nếu không có friend, bạn buộc phải để một số hàm là public, điều này còn nguy hiểm hơn).

  2. Trong thiết kế hệ thống lớn, nếu một lớp có quá nhiều "người bạn" (friends), đó là dấu hiệu của vấn đề gì? (Gợi ý: Cohesion và Coupling).

Ngày 15: Templates & Generic Programming

Function Templates

  • Khái niệm Lập trình tổng quát:

    • Tại sao phải dùng Template thay vì Overloading? (Tránh lặp lại code cho các kiểu dữ liệu khác nhau).
  • Cú pháp Function Template:

    • Sử dụng template <typename T> hoặc template <class T>.

    • Cách trình biên dịch tạo ra code: Quá trình Template Instantiation (Trình biên dịch thực tế sẽ sinh ra các hàm cụ thể cho từng kiểu dữ liệu bạn dùng).

  • Template với nhiều tham số:

    • Cách viết hàm nhận nhiều kiểu khác nhau: template <typename T, typename U>.
  • Nạp chồng Function Template:

    • Khi nào một hàm cụ thể (Regular function) được ưu tiên hơn một Template?

Class Templates & Specialization

  • Class Templates:

    • Xây dựng các cấu trúc dữ liệu tổng quát như Stack<T>, Queue<T>, Box<T>.

    • Cách định nghĩa các hàm thành viên của Class Template bên ngoài lớp (Lưu ý cú pháp phức tạp).

  • Tham số Template không phải kiểu dữ liệu (Non-type Template Parameters):

    • Truyền giá trị hằng số vào Template: template <typename T, int Size> class Array { ... };.
  • Template Specialization (Chuyên biệt hóa):

    • Cách viết một cài đặt riêng biệt cho một kiểu dữ liệu cụ thể (ví dụ: class Box<bool> cần tối ưu bộ nhớ hơn so với các kiểu khác).
  • Lưu ý về biên dịch: Tại sao code Template thường phải đặt hoàn toàn trong file .h (Header-only)?

Tư duy nâng cao & Thư viện chuẩn

  • Mối liên hệ giữa Template và Hiệu năng (Optimization):

    • Khác với Đa hình (Runtime), Template được xử lý tại Compile-time.

    • Ưu điểm: Không tốn chi phí gọi hàm ảo (V-Table), trình biên dịch có thể inline code cực tốt.

    • Nhược điểm: Tăng thời gian biên dịch và làm "phình" kích thước file thực thi (Code Bloat).

  • Giới thiệu về STL (Standard Template Library):

    • Nhìn lại std::vector<T>, std::map<K, V> dưới góc nhìn Template.
  • Sách tham khảo: Đọc chương 13 trong sách OOP-C++.pdf (Joyce Farrell) về "Templates".

  • Lab 15: "The Generic Container". Xây dựng một cấu trúc dữ liệu SmartArray<T> có khả năng tự co giãn và hoạt động với mọi kiểu dữ liệu.


BÀI TẬP

Nhiệm vụ 1: Chuyển đổi hàm sang Template

Hãy chuyển đổi hàm tìm giá trị lớn nhất sau đây thành một Template hoạt động được cho cả int, double, char và string:

int findMax(int a, int b) {
    return (a > b) ? a : b;
}

Nhiệm vụ 2: Lớp lưu trữ tổng quát

Viết một Class Template Pair<T, U> có khả năng lưu trữ hai giá trị thuộc hai kiểu khác nhau. Viết hàm swap() để đổi chỗ giá trị nếu chúng cùng kiểu.

Nhiệm vụ 3: Đọc chương 17 sách Clean Code (Smells and Heuristics)

Tìm hiểu mục G28: Encapsulate Conditionals và liên hệ với việc dùng Template để làm code sạch hơn, tránh các câu lệnh switch-case dựa trên kiểu dữ liệu.


LAB 15: GENERIC STACK (NGĂN XẾP TỔNG QUÁT)

Bạn cần xây dựng một cấu trúc dữ liệu Ngăn xếp (Stack) hoàn chỉnh:

  1. Định nghĩa template <typename T> class Stack:

    • Sử dụng mảng động hoặc liên kết nội bộ để lưu trữ phần tử.
  2. Các hàm thành viên:

    • push(T value): Thêm phần tử vào đỉnh.

    • T pop(): Lấy phần tử từ đỉnh và xóa nó (ném ngoại lệ nếu stack rỗng - ôn lại Ngày 4).

    • T peek(): Xem phần tử đỉnh.

    • bool isEmpty(): Kiểm tra rỗng.

  3. Thực thi trong main:

    • Tạo một Stack<int> để quản lý số nguyên.

    • Tạo một Stack<string> để quản lý danh sách tên.

    • Tạo một Stack<SimpleString> (Sử dụng lớp bạn đã viết ở Ngày 8) để kiểm tra xem Template có hoạt động với class tự định nghĩa không.


Câu hỏi

  1. Tại sao các thư viện yêu cầu hiệu năng cực cao (như xử lý đồ họa, AI) lại ưu tiên dùng Template hơn là dùng Đa hình (Virtual Functions)?

  2. Code Bloat: Nếu bạn dùng Stack<int>, Stack<float>, và Stack<long>, trình biên dịch sẽ sinh ra bao nhiêu bản sao của class Stack? Điều này ảnh hưởng gì đến bộ nhớ Instruction Cache của CPU?

GIAI ĐOẠN 3: SOLID PRINCIPLES & DESIGN PATTERNS

Ngày 16: SOLID - S (Single Responsibility) & O (Open/Closed)

S - Single Responsibility Principle (SRP)

Mặc dù chúng ta đã nhắc đến SRP ở Ngày 2 và Ngày 6, nhưng hôm nay chúng ta sẽ học ở cấp độ "kiến trúc".

  • Định nghĩa chuẩn của Robert C. Martin: "Một lớp chỉ nên có một lý do để thay đổi."

  • Phân tích "Lý do để thay đổi":

    • Ai là người yêu cầu thay đổi? (Tư duy theo Actor).

    • Ví dụ: Một lớp Employee chứa logic tính lương (cho kế toán) và logic lưu trữ (cho DB Admin) là vi phạm SRP vì có 2 Actor tác động.

  • Dấu hiệu nhận biết vi phạm:

    • Lớp quá lớn (God Class).

    • Quá nhiều sự phụ thuộc (Dependencies).

    • Các phương thức không liên quan đến nhau dùng chung biến thành viên.

  • Kỹ thuật tách lớp: Sử dụng Facade Pattern hoặc Proxy Pattern để gom nhóm các trách nhiệm đã được tách nhỏ.

O - Open/Closed Principle (OCP)

Đây là nguyên lý quan trọng nhất để hệ thống có thể mở rộng mãi mãi mà không sinh ra bug mới.

  • Định nghĩa: "Phần mềm nên mở cho việc mở rộng (Open for extension) nhưng đóng cho việc sửa đổi (Closed for modification)."

  • Cơ chế thực hiện:

    • Làm sao để thêm tính năng mới mà không sửa một dòng code cũ nào?

    • Giải pháp: Abstraction (Trừu tượng hóa).

  • Ứng dụng Đa hình vào OCP:

    • Thay vì dùng if-else hoặc switch-case để xử lý các loại khách hàng, hãy tạo một Interface DiscountStrategy và thêm các lớp cụ thể. Khi có loại khách hàng mới, ta chỉ cần tạo thêm class mới, không sửa code cũ.
  • Vấn đề của OCP: Khi nào thì không nên áp dụng? (Tránh thiết kế quá phức tạp cho những tính năng không bao giờ thay đổi).

Thực hành Refactoring & Đào sâu

  • Phân tích Case Study:

    • Xem xét một module xử lý file log. Nếu muốn thêm lưu log vào Cloud thay vì File, ta phải sửa những gì?
  • Mối liên hệ giữa S và O:

    • Tại sao nếu không làm tốt SRP (S), bạn sẽ không bao giờ đạt được OCP (O)?
  • Sách tham khảo: Đọc chương 10 (phần Classes) trong sách Clean Code và các tài liệu về SOLID.

  • Lab 16: "The Extensible Report System". Xây dựng hệ thống xuất báo cáo hỗ trợ PDF, Excel. Sau đó thử thêm định dạng HTML mà không sửa code của lớp quản lý chính.


BÀI TẬP

Nhiệm vụ 1: Phân tích Actor (SRP)

Cho một lớp Book: getTitle(), getAuthor(), printToConsole(), saveToDatabase().

  1. Hãy chỉ ra các Actor liên quan đến từng phương thức.

  2. Vẽ sơ đồ các lớp sau khi đã tách đúng chuẩn SRP.

Nhiệm vụ 2: Phá vỡ sự phụ thuộc (OCP)

Viết một hàm calculateArea(vector<Circle> circles). Bây giờ khách hàng muốn tính diện tích cả Square.

  1. Tại sao hàm cũ vi phạm OCP?

  2. Hãy refactor lại bằng cách dùng Interface Shape để hàm calculateArea không bao giờ phải thay đổi nữa dù có thêm 100 loại hình khác.

Nhiệm vụ 3: Đọc chương 17 sách Clean Code (Smells and Heuristics)

Tập trung vào các mục: G5: Duplication và G6: Code at Wrong Level of Abstraction.


LAB 16: SMART TAX CALCULATOR (MÁY TÍNH THUẾ MỞ RỘNG)

Bạn cần xây dựng một hệ thống tính thuế cho các quốc gia khác nhau:

  1. Thiết kế sai (Vi phạm OCP): Viết lớp TaxCalculator với hàm calculate(double amount, string country) sử dụng if (country == "VN") ... else if (country == "US") ....

  2. Thiết kế đúng (SOLID):

    • Tạo Interface ITaxStrategy có hàm calculateTax(double amount).

    • Cài đặt VietnamTax, USTax, EuropeTax.

    • Lớp TaxService sẽ nhận vào một ITaxStrategy* (Dependency Injection) và thực hiện tính toán.

  3. Thử thách: Hãy thêm loại thuế LuxuryTax (Thuế hàng xa xỉ) bằng cách tạo class mới và kiểm chứng xem bạn có phải sửa dòng nào trong TaxService không.


Câu hỏi

  1. Nếu áp dụng OCP triệt để, số lượng lớp trong hệ thống sẽ tăng lên rất nhiều. Làm sao để cân bằng giữa "Code sạch, dễ mở rộng" và "Sự đơn giản của hệ thống"?

  2. SRP nói một lớp chỉ làm một việc. Vậy một lớp Customer vừa có Name, vừa có Address, vừa có Email có vi phạm SRP không? (Gợi ý: Hãy nghĩ về khái niệm "Cohesion").

Ngày 17: SOLID - L (Liskov Substitution) & I (Interface Segregation)

L - Liskov Substitution Principle (LSP)

Đây là nguyên lý về sự thay thế, được đặt tên theo Barbara Liskov.

  • Định nghĩa: "Các đối tượng của lớp cha có thể được thay thế bằng các đối tượng của lớp con mà không làm thay đổi tính đúng đắn của chương trình."

  • Bản chất của LSP:

    • Kế thừa không chỉ là chia sẻ code, mà là chia sẻ Hành vi (Behavior).

    • Lớp con không được phép "phản bội" lại kỳ vọng của người dùng về lớp cha.

  • Các dấu hiệu vi phạm LSP:

    • Lớp con ném ra một ngoại lệ (Exception) cho một hàm mà lớp cha vốn xử lý bình thường.

    • Lớp con để trống (để hàm rỗng) một phương thức kế thừa từ cha vì nó "không dùng đến".

    • Ép kiểu (Type checking/Downcasting) bằng dynamic_cast để kiểm tra kiểu của lớp con trước khi gọi hàm.

  • Ví dụ kinh điển: Hình chữ nhật và Hình vuông.

    • Tại sao Square kế thừa từ Rectangle lại vi phạm LSP? (Vì hành vi setWidth của Hình vuông làm thay đổi cả Height, phá vỡ logic của Hình chữ nhật).

I - Interface Segregation Principle (ISP)

Nguyên lý này tập trung vào việc thiết kế các Interface (Giao diện) nhỏ gọn và chuyên biệt.

  • Định nghĩa: "Không nên ép buộc Client (người dùng Interface) phải phụ thuộc vào các phương thức mà họ không sử dụng."

  • Vấn đề của "Fat Interfaces" (Giao diện béo phì):

    • Một Interface chứa quá nhiều hàm khiến lớp thực thi phải cài đặt cả những hàm vô nghĩa.

    • Khi một hàm trong Interface thay đổi, tất cả các lớp thực thi (ngay cả những lớp không dùng hàm đó) đều phải biên dịch lại.

  • Giải pháp: Chia để trị.

    • Tách một Interface lớn thành nhiều Interface nhỏ hơn, đại diện cho từng vai trò cụ thể.
  • Mối liên hệ với Đa kế thừa Interface:

    • Một lớp có thể implements (kế thừa) nhiều Interface nhỏ thay vì một Interface khổng lồ.

Thực hành Refactoring

  • Refactoring Code vi phạm:

    • Phân tích một hệ thống Robot: Interface IRobotwalk(), fly(), swim(). Một Robot công nghiệp không thể bay nhưng vẫn phải cài đặt fly(). Hãy dùng ISP để sửa lại.
  • LSP trong C++:

    • Tại sao từ khóa virtualoverride là công cụ đắc lực để duy trì LSP?
  • Sách tham khảo: Đọc chương 11 (System) của sách Clean Code.

  • Lab 17: "The Smart Device Ecosystem". Thiết kế hệ thống thiết bị thông minh (SmartDevice) áp dụng ISP và LSP để quản lý Máy in, Máy Scan và Máy Photocopy.


BÀI TẬP

Nhiệm vụ 1: Phân tích lỗi thay thế (LSP)

Cho đoạn code sau, hãy chỉ ra tại sao nó vi phạm LSP và hậu quả là gì:

class Bird {
    virtual void fly() { /* logic bay */ }
};

class Penguin : public Bird {
    void fly() override { throw logic_error("Chim cánh cụt không biết bay!"); }
};

(Gợi ý: Nếu một hàm nhận vào Bird*, nó kỳ vọng mọi con chim đều bay được. Penguin đã phá vỡ hợp đồng này).

Nhiệm vụ 2: Chia tách Interface (ISP)

Tách Interface IMultiFunctionDevice (Print, Scan, Fax) thành 3 Interface độc lập và cài đặt lớp OldPrinter chỉ biết Print.

Nhiệm vụ 3: Đọc chương 17 sách Clean Code (Smells and Heuristics)

Tìm hiểu mục G11: Inconsistency và G32: Feature Envy.


LAB 17: WORKER MANAGEMENT SYSTEM (HỆ THỐNG QUẢN LÝ NHÂN VIÊN)

Bạn cần thiết kế một hệ thống quản lý công việc cho các loại nhân viên:

  1. Thiết kế sai (Vi phạm ISP): Tạo Interface IWorker với các hàm work(), eat(), sleep().

    • Chuyện gì xảy ra nếu bạn có một lớp RobotWorker? Robot không biết ăn và ngủ.
  2. Thiết kế đúng (SOLID):

    • Tách thành IWorkable, IEatable, ISleepable.

    • Lớp HumanWorker kế thừa cả 3.

    • Lớp RobotWorker chỉ kế thừa IWorkable.

  3. Kiểm chứng LSP:

    • Viết một hàm manageWork(IWorkable* w) nhận vào bất kỳ ai biết làm việc. Đảm bảo hàm này chạy đúng với cả Human và Robot mà không cần biết họ là ai.

Câu hỏi

  1. LSP vs OCP: Nếu bạn vi phạm LSP (ví dụ: dùng if (typeid(*obj) == typeid(Derived))), tại sao điều đó cũng thường dẫn đến vi phạm OCP?

  2. ISP trong thư viện lớn: Tại sao các thư viện chuẩn như STL của C++ lại có rất nhiều lớp nhỏ (như std::iterator, std::allocator) thay vì gom vào một lớp lớn?

Ngày 18: SOLID - D (Dependency Inversion)

Bản chất của Dependency Inversion

  • Định nghĩa chuẩn của Uncle Bob:

    1. Các module cấp cao không nên phụ thuộc vào các module cấp thấp. Cả hai nên phụ thuộc vào cái trừu tượng (Abstraction).

    2. Cái trừu tượng không nên phụ thuộc vào chi tiết. Chi tiết nên phụ thuộc vào cái trừu tượng.

  • Tại sao gọi là "Đảo ngược" (Inversion)?

    • Phân tích luồng phụ thuộc truyền thống: UI -> Business Logic -> Database. Nếu Database thay đổi, toàn bộ hệ thống bị ảnh hưởng.

    • Luồng đảo ngược: UI -> Interface <- Business Logic -> Interface <- Database.

  • Hệ quả của việc vi phạm DIP:

    • Rigidity (Cứng nhắc): Khó thay đổi vì một thay đổi nhỏ gây tác động dây chuyền.

    • Immobility (Khó di chuyển): Không thể tái sử dụng module cấp cao vì nó dính chặt với module cấp thấp cụ thể.

Dependency Injection (DI) trong C++

DI là kỹ thuật để thực hiện nguyên lý DIP. Thay vì một lớp tự khởi tạo sự phụ thuộc của nó, chúng ta "bơm" (inject) sự phụ thuộc đó từ bên ngoài vào.

  • 3 Hình thức Injection phổ biến:

    1. Constructor Injection: Truyền phụ thuộc qua Constructor (Khuyên dùng vì đảm bảo đối tượng luôn đủ dữ liệu để chạy).

    2. Setter Injection: Truyền qua các hàm set.

    3. Interface Injection: Sử dụng một interface để bơm phụ thuộc.

  • DI với Con trỏ và Tham chiếu:

    • Cách dùng std::unique_ptr hoặc std::shared_ptr để truyền Interface vào lớp cấp cao.
  • Inversion of Control (IoC) Container:

    • Giới thiệu khái niệm về các thư viện tự động quản lý việc khởi tạo và bơm phụ thuộc (Dành cho các hệ thống cực lớn).

Thực hành & Quản lý vòng đời

  • Vấn đề quyền sở hữu (Ownership):

    • Khi bơm một đối tượng vào lớp khác, ai là người chịu trách nhiệm xóa (delete) nó? (Vận dụng kiến thức Ngày 8 & Ngày 22).
  • Viết Unit Test với DI:

    • Tại sao DI lại là "vua" của Unit Test? Cách dùng Mock Objects để giả lập Database/Network giúp test chạy cực nhanh mà không cần kết nối thật.
  • Sách tham khảo: Đọc chương 11 (Systems) và chương 17 (Smells) của sách Clean Code.

  • Lab 18: "The Modular Logger". Xây dựng hệ thống ứng dụng có thể đổi từ ghi log ra File sang ghi log vào Database hoặc gửi qua Email chỉ bằng cách thay đổi một dòng code ở main.


BÀI TẬP THỰC CHIẾN NGÀY 18

Nhiệm vụ 1: Nhận diện "Sự phụ thuộc cứng"

Tại sao lớp sau vi phạm DIP và làm thế nào để sửa nó?

class PasswordAnalyzer {
    MySQLDatabase db; // Phụ thuộc trực tiếp vào class cụ thể
public:
    void analyze() {
        auto data = db.getPasswords();
        // logic...
    }
};

Nhiệm vụ 2: Triển khai Constructor Injection

Hãy refactor lại Nhiệm vụ 1:

  1. Tạo Interface IDatabase.

  2. Cho MySQLDatabase kế thừa IDatabase.

  3. Sửa PasswordAnalyzer để nhận IDatabase* qua Constructor.

Nhiệm vụ 3: Đọc chương 17 sách Clean Code

Tìm hiểu mục G13: Artificial Coupling và G14: Feature Envy.


LAB 18: THE SMART HOME CONTROLLER (BỘ ĐIỀU KHIỂN NHÀ THÔNG MINH)

Bạn cần thiết kế một bộ điều khiển có thể bật/tắt các thiết bị điện:

  1. Thiết kế vi phạm DIP: Lớp Switch chứa trực tiếp đối tượng LightBulb. Khi muốn dùng Switch cho Fan (Quạt), bạn phải sửa code của lớp Switch.

  2. Thiết kế theo DIP & DI:

    • Tạo Interface ISwitchable có hàm turnOn()turnOff().

    • Cho LightBulb, Fan, AirConditioner kế thừa ISwitchable.

    • Lớp Switch nhận một ISwitchable& qua Constructor.

  3. Thực thi:

    • Trong main, hãy thử "bơm" một cái Bóng đèn vào Công tắc.

    • Sau đó, hãy thử tạo một lớp MockDevice (Thiết bị giả) để test lớp Switch mà không cần thiết bị thật.


Câu hỏi

  1. Nếu mọi thứ đều dùng Interface và DI, code sẽ trở nên rất rời rạc. Làm sao để người mới vào dự án biết được một Interface thực sự được triển khai bởi lớp nào? (Gợi ý: Tìm hiểu về tính năng Go to Implementation của IDE và tài liệu hóa kiến trúc).

  2. Performance: Việc truy cập qua Interface (hàm ảo) và dùng con trỏ có làm chậm hệ thống không? Khi nào thì sự linh hoạt của DIP đáng giá hơn 1% hiệu năng bị mất?

Ngày 19: Design Patterns - Creational (Khởi tạo)

Singleton Pattern

  • Mục đích: Đảm bảo một lớp chỉ có duy nhất một thực thể (instance) và cung cấp một điểm truy cập toàn cục cho nó.

  • Cài đặt trong C++:

    • Giấu Constructor bằng private.

    • Sử dụng biến static để lưu instance duy nhất.

    • Hàm getInstance() trả về con trỏ/tham chiếu đến instance đó.

  • Vấn đề nâng cao (Optimization & Safety):

    • Thread-safety: Chuyện gì xảy ra nếu 2 luồng cùng gọi getInstance() lần đầu? (Cách dùng std::mutex hoặc kỹ thuật "Magic Statics" trong C++11).

    • Anti-pattern: Tại sao Singleton bị coi là "code thối" nếu lạm dụng? (Nó che giấu sự phụ thuộc và gây khó khăn khi làm Unit Test).

Factory Method

  • Mục đích: Định nghĩa một interface để tạo đối tượng, nhưng để các lớp con quyết định lớp nào sẽ được khởi tạo.

  • Tại sao cần dùng?

    • Khi bạn không biết trước loại đối tượng cụ thể nào sẽ được tạo ra cho đến khi chương trình chạy (Runtime).

    • Tuân thủ nguyên lý OCP (Open/Closed): Khi thêm loại sản phẩm mới, bạn chỉ cần tạo Factory mới, không sửa code cũ.

  • Cơ chế: Sử dụng tính đa hình. Lớp cha định nghĩa hàm ảo createProduct(), lớp con ghi đè để trả về ProductA, ProductB...

Abstract Factory

  • Mục đích: Cung cấp một interface để tạo ra một họ các đối tượng có liên quan với nhau (Related Objects) mà không cần chỉ định lớp cụ thể.

  • Sự khác biệt với Factory Method:

    • Factory Method tạo ra một loại sản phẩm.

    • Abstract Factory tạo ra một bộ sản phẩm phối hợp với nhau.

  • Ví dụ thực tế: Hệ thống giao diện (UI Framework).

    • WindowsFactory tạo ra WindowsButtonWindowsCheckbox.

    • MacFactory tạo ra MacButtonMacCheckbox.

    • Client dùng IFactory để lấy được cả bộ nút và ô tích tương ứng mà không bị nhầm lẫn.


BÀI TẬP

Nhiệm vụ 1: Cài đặt Singleton "Xịn"

Hãy viết một lớp DatabaseConnection theo mẫu Singleton. Đảm bảo rằng:

  1. Không thể copy đối tượng (Sử dụng = delete cho Copy Constructor).

  2. Nó phải an toàn trong môi trường đa luồng (Thread-safe).

Nhiệm vụ 2: Phân tích Factory Method

Giả sử bạn đang viết game. Có các loại quái vật: Orc, Dragon, Zombie.

  1. Tạo Interface Enemy với hàm attack().

  2. Tạo lớp Level với hàm ảo spawnEnemy().

  3. Tạo EasyLevel (chỉ ra Zombie) và HardLevel (ra Dragon).

Nhiệm vụ 3: Đọc chương 11 sách Clean Code

Tìm mục về "Dependency Injection" và xem cách DI liên quan mật thiết như thế nào đến các Creational Patterns (Factory).


LAB 19: THE THEME BUILDER (ABSTRACT FACTORY)

Bạn cần xây dựng hệ thống thay đổi giao diện (Theme) cho một ứng dụng:

  1. Interface Sản phẩm: IButton, ITextBox.

  2. Sản phẩm cụ thể:

    • DarkButton, DarkTextBox (Dùng cho Dark Mode).

    • LightButton, LightTextBox (Dùng cho Light Mode).

  3. Interface Factory: IThemeFactory có hàm createButton()createTextBox().

  4. Cài đặt Factory: DarkThemeFactoryLightThemeFactory.

  5. Thực thi: Trong main, người dùng nhập vào "Dark" hoặc "Light". Chương trình sẽ khởi tạo đúng Factory và tạo ra bộ UI đồng bộ.


Câu hỏi

  1. Singleton vs Dependency Injection: Tại sao ngày nay người ta thích "bơm" (Inject) một đối tượng dùng chung vào thay vì dùng Singleton?

  2. Abstract Factory: Nếu sau này bộ giao diện cần thêm một sản phẩm thứ 3 là ISlider, bạn sẽ phải sửa bao nhiêu lớp? Có cách nào hạn chế việc này không?

Ngày 20: Design Patterns - Structural (Cấu trúc)

Adapter Pattern

  • Mục đích: Cho phép các Interface không tương thích có thể làm việc với nhau. Nó đóng vai trò như một cái phích cắm chuyển đổi (từ 3 chấu sang 2 chấu).

  • Vấn đề giải quyết: Khi bạn có một lớp cũ (Legacy) hoặc thư viện bên thứ ba rất tốt nhưng Interface của nó không khớp với hệ thống hiện tại của bạn.

  • Cài đặt trong C++:

    • Object Adapter (Khuyên dùng): Sử dụng thành phần (Composition). Lớp Adapter chứa một instance của lớp cần chuyển đổi.

    • Class Adapter: Sử dụng đa kế thừa (Inheritance).

  • Ứng dụng: Kết nối hệ thống mới với Database cũ, hoặc dùng thư viện Logging khác nhau dưới một Interface chung.

Decorator Pattern

  • Mục đích: Gán thêm trách nhiệm cho đối tượng một cách năng động (at runtime) mà không cần thay đổi cấu trúc của lớp đó.

  • Tại sao không dùng Kế thừa? Kế thừa là tĩnh (static). Nếu bạn có 10 tính năng phụ, dùng kế thừa sẽ tạo ra sự bùng nổ số lượng lớp con (Class Explosion). Decorator cho phép bạn "lắp ghép" các tính năng như chơi Lego.

  • Cơ chế: Lớp Decorator kế thừa cùng một Interface với đối tượng nó trang trí, đồng thời chứa chính đối tượng đó bên trong.

  • Ví dụ: Hệ thống Order Cafe. Lớp cơ bản là SimpleCoffee. Bạn có các Decorator: WithMilk, WithSugar, WithWhip. Bạn có thể tạo ra Coffee + Milk + Sugar dễ dàng.

Facade Pattern

  • Mục đích: Cung cấp một Interface đơn giản và duy nhất cho một hệ thống con (subsystem) phức tạp bên trong.

  • Triết lý: "Đừng bắt khách hàng phải biết quá nhiều chi tiết".

  • Cách thực hiện: Tạo một lớp Facade chứa các tham chiếu đến tất cả các lớp phức tạp bên dưới. Khách hàng chỉ cần gọi một hàm của Facade, Facade sẽ tự điều phối các lớp bên dưới.

  • Ví dụ: Hệ thống Home Theater. Để xem phim, bạn phải: Bật đèn mờ, bật máy chiếu, hạ màn hình, bật âm ly, chạy đầu đĩa. Thay vì bắt người dùng làm 5 bước, lớp HomeTheaterFacade cung cấp hàm watchMovie().


BÀI TẬP

Nhiệm vụ 1: Implement Adapter (Thực tế)

Hệ thống của bạn đang dùng Interface ITarget có hàm request(). Bạn mua một thư viện có lớp Adaptee với hàm specificRequest(). Hãy viết lớp Adapter để hệ thống của bạn có thể gọi thư viện này thông qua ITarget.

Nhiệm vụ 2: Coffee Shop Decorator

Xây dựng hệ thống tính giá cà phê:

  1. Interface ICoffee có hàm cost()description().

  2. Lớp SimpleCoffee giá 10$.

  3. Các Decorator MilkDecorator (+2\(), SugarDecorator (+1\)).

  4. Test: Tạo một ly cà phê có cả sữa và đường, in ra tổng giá và mô tả.

Nhiệm vụ 3: Đọc chương 8 sách Clean Code (Boundaries)

Liên hệ: Tại sao việc sử dụng Adapter Pattern lại là cách tốt nhất để quản lý các "Ranh giới" (Boundaries) giữa code của bạn và code thư viện bên thứ ba?


LAB 20: THE COMPUTER STARTUP FACADE (HỆ THỐNG KHỞI ĐỘNG MÁY TÍNH)

Hệ thống con gồm các lớp: CPU, Memory, HardDrive. Mỗi lớp có các hàm rất phức tạp như freeze(), jump(long position), execute(), read(long lba, int size).

  1. Nhiệm vụ: Viết lớp ComputerFacade.

  2. Hàm chính: startComputer().

  3. Logic bên trong: Facade sẽ gọi CPU.freeze(), Memory.load(), HardDrive.read(), CPU.execute().

  4. Kết quả:main, bạn chỉ cần một dòng computer.startComputer() thay vì phải tìm hiểu cách vận hành chi tiết của phần cứng.


Câu hỏi

  1. Adapter vs Decorator: Cả hai đều bao bọc (wrap) một đối tượng khác. Sự khác biệt cốt lõi về mục đích của chúng là gì? (Gợi ý: Một cái đổi Interface, một cái thêm tính năng).

  2. Facade vs Encapsulation: Facade có phải là một hình thức đóng gói không? Nó có ngăn chặn Client truy cập trực tiếp vào các lớp bên dưới không?

Ngày 21: Design Patterns - Behavioral (Hành vi)

Strategy Pattern

  • Mục đích: Định nghĩa một họ các thuật toán, đóng gói từng thuật toán lại và làm cho chúng có thể thay thế lẫn nhau.

  • Tại sao cần dùng?

    • Khi bạn có một hành vi (ví dụ: Tính thuế, Sắp xếp, Thanh toán) nhưng có nhiều cách thực hiện khác nhau.

    • Thay vì dùng if-else khổng lồ trong một hàm, bạn tách mỗi cách thực hiện vào một lớp "Strategy".

  • Cơ chế: Sử dụng Composition. Lớp chính (Context) chứa một con trỏ tới Interface IStrategy. Bạn có thể đổi chiến lược bất cứ lúc nào.

  • Mối liên hệ với SOLID: Đây là ví dụ hoàn hảo nhất cho nguyên lý OCP (Open/Closed).

Observer Pattern

  • Mục đích: Định nghĩa mối quan hệ 1-nhiều. Khi một đối tượng (Subject) thay đổi trạng thái, tất cả các đối tượng phụ thuộc (Observers) sẽ được tự động thông báo và cập nhật.

  • Ứng dụng:

    • Hệ thống chứng khoán: Khi giá mã cổ phiếu đổi, tất cả các App của người dùng phải cập nhật.

    • Xử lý sự kiện (Event Handling) trong UI: Nút bấm (Button) thông báo cho các hàm xử lý khi được click.

  • Cài đặt trong C++:

    • Lớp Subject giữ một danh sách std::vector<IObserver*>.

    • Hàm notify() duyệt danh sách và gọi update() trên từng Observer.

    • Lưu ý: Cần quản lý việc attach (đăng ký) và detach (hủy đăng ký) để tránh gọi vào đối tượng đã bị xóa (Dangling Pointer).

State Pattern

  • Mục đích: Cho phép một đối tượng thay đổi hành vi của nó khi trạng thái nội bộ của nó thay đổi. Đối tượng sẽ giống như thể nó đã thay đổi lớp của mình.

  • Vấn đề giải quyết: Loại bỏ các máy trạng thái (State Machine) phức tạp chứa đầy các câu lệnh switch(currentState).

  • Cơ chế: Mỗi trạng thái là một lớp riêng biệt kế thừa Interface IState. Lớp chính (Context) sẽ ủy quyền (delegate) mọi hành vi cho đối tượng State hiện tại.

  • Ví dụ: Một cái máy bán hàng tự động (Vending Machine). Hành vi của nút "Nhấn nút lấy nước" sẽ khác nhau tùy vào trạng thái: NoMoneyState, HasMoneyState, OutOfStockState.


BÀI TẬP

Nhiệm vụ 1: Implement Strategy (Thanh toán)

Xây dựng lớp Cart (Giỏ hàng).

  1. Interface IPaymentStrategy có hàm pay(int amount).

  2. Cài đặt CreditCardStrategyPaypalStrategy.

  3. Test: Cho phép người dùng chọn phương thức thanh toán tại lúc thanh toán (Runtime).

Nhiệm vụ 2: News Channel Observer

  1. Lớp NewsAgency (Subject) có các thuộc tính: headline.

  2. Interface IChannel (Observer) có hàm update(string news).

  3. Cài đặt TVChannelMobileApp.

  4. Khi NewsAgency cập nhật tin mới, tất cả các kênh phải in ra thông báo.

Nhiệm vụ 3: Đọc chương 13 sách Clean Code (Concurrency)

Liên hệ: Tại sao Observer Pattern lại là một thách thức lớn trong lập trình đa luồng (Multithreading)? (Gợi ý: Vấn đề Race Condition khi thông báo cho danh sách Observer).


LAB 21: THE TRAFFIC LIGHT SYSTEM (STATE PATTERN)

Bạn cần mô phỏng một cái Đèn giao thông (Traffic Light):

  1. Interface: ITrafficLightState có hàm handle()getNextState().

  2. Cài đặt các lớp trạng thái: RedState, GreenState, YellowState.

    • Trong RedState::handle(), in ra "Đèn đỏ: Dừng lại!".

    • RedState::getNextState() sẽ trả về một instance của GreenState.

  3. Lớp chính: TrafficLight giữ con trỏ đến trạng thái hiện tại.

  4. Thực thi: Tạo một vòng lặp trong main, mỗi lần lặp gọi light.change() để thấy đèn tự chuyển đổi hành vi một cách mượt mà mà không dùng một câu lệnh if nào.


Câu hỏi

  1. Strategy vs State: Cấu trúc lớp (UML) của hai mẫu này gần như giống hệt nhau. Vậy sự khác biệt cốt lõi nằm ở ý định (intent) của lập trình viên là gì? (Gợi ý: Ai là người quyết định việc thay đổi đối tượng bên trong?).

  2. Observer: Làm thế nào để Subject truyền dữ liệu cho Observer hiệu quả nhất? (Mô hình Push - đẩy dữ liệu đi, hay mô hình Pull - chỉ báo có thay đổi rồi Observer tự vào lấy?).

Ngày 22: Resource Management (RAII)

Triết lý RAII - Resource Acquisition Is Initialization

  • RAII là gì?

    • Tài nguyên (Bộ nhớ, File handle, Mutex, Socket) phải được gắn chặt với vòng đời của một đối tượng trên Stack.

    • Khởi tạo đối tượng = Chiếm hữu tài nguyên.

    • Hủy đối tượng (Destructor) = Giải phóng tài nguyên.

  • Tại sao RAII quan trọng?

    • Đảm bảo tài nguyên luôn được giải phóng ngay cả khi có Exception xảy ra (Exception Safety).

    • Giảm bớt gánh nặng tâm lý cho lập trình viên (không cần nhớ phải close() hay delete).

  • Ví dụ thực tế: Cách dùng std::lock_guard để quản lý Mutex thay vì gọi lock()unlock() thủ công.

Smart Pointers

C++11 giới thiệu các con trỏ thông minh để thay thế hoàn toàn con trỏ thô (raw pointers) trong việc quản lý quyền sở hữu.

  • std::unique_ptr (Quyền sở hữu độc quyền):

    • Mỗi tài nguyên chỉ có duy nhất một chủ sở hữu.

    • Không thể sao chép (No Copy), chỉ có thể di chuyển (Move).

    • Hiệu năng: Tương đương con trỏ thô (Zero overhead).

  • std::shared_ptr (Quyền sở hữu chung):

    • Sử dụng cơ chế Reference Counting (Đếm tham chiếu). Tài nguyên chỉ bị xóa khi số lượng người sở hữu bằng 0.

    • Khi nào nên dùng: Khi một đối tượng được chia sẻ bởi nhiều thành phần khác nhau trong hệ thống.

  • std::weak_ptr (Tham chiếu yếu):

    • Giải quyết vấn đề Circular Dependency (Vòng lặp tham chiếu) khiến shared_ptr không bao giờ giải phóng được bộ nhớ.
  • Các hàm Helper: Tại sao nên dùng std::make_uniquestd::make_shared thay vì dùng new.

Quản lý tài nguyên & Tối ưu

  • Quy tắc Rule of Five (C++11 trở lên):

    • Nếu bạn tự viết class quản lý tài nguyên, bạn cần định nghĩa: Destructor, Copy Constructor, Copy Assignment, Move Constructor, Move Assignment.
  • Smart Pointers làm tham số hàm:

    • Khi nào truyền unique_ptr, khi nào truyền shared_ptr, và khi nào vẫn nên dùng T* hoặc T& để tối ưu hiệu năng.
  • Sách tham khảo: Đọc chương 14 (Exception Handling) trong sách OOP-C++.pdf (Joyce Farrell) và phần Resource Management trong Clean Code.

  • Lab 22: "The Resource Manager". Xây dựng một lớp quản lý File và Database Connection sử dụng RAII và Smart Pointers để đảm bảo không bao giờ bị rò rỉ dù chương trình bị crash.


BÀI TẬP

Nhiệm vụ 1: Exception Safety test

Viết một hàm cấp phát mảng bằng new, sau đó ném một std::runtime_error trước khi gọi delete. Sử dụng công cụ chẩn đoán (như Task Manager hoặc Valgrind) để thấy bộ nhớ bị rò rỉ. Sau đó, sửa lại bằng std::unique_ptr và quan sát kết quả.

Nhiệm vụ 2: Fix Circular Dependency

Tạo 2 lớp: Parent và Child.

  1. Cho mỗi lớp giữ một shared_ptr trỏ tới lớp kia.

  2. Kiểm tra xem Destructor của chúng có được gọi không (thường là không).

  3. Sửa lại bằng cách dùng weak_ptr ở một đầu và kiểm tra lại.

Nhiệm vụ 3: Đọc chương 7 sách Clean Code (Error Handling)

Liên hệ: Cách RAII giúp code dọn dẹp lỗi (Cleanup) trở nên cực kỳ gọn gàng so với việc dùng khối finally cồng kềnh.


LAB 22: AUTO-CLEAN DATABASE CONNECTION (KẾT NỐI DB TỰ DỌN DẸP)

Bạn cần xây dựng một hệ thống quản lý kết nối giả lập:

  1. Lớp DatabaseConnection: Có hàm open(), query(), close(). Trong Destructor phải tự động gọi close() nếu kết nối còn mở.

  2. Lớp QueryRunner:

    • Chứa một std::unique_ptr<DatabaseConnection>.

    • Đảm bảo rằng khi QueryRunner ra khỏi phạm vi (scope), kết nối DB sẽ được đóng ngay lập tức.

  3. Hàm process():

    • Nhận một std::shared_ptr<DatabaseConnection>.

    • Mô phỏng việc nhiều luồng cùng làm việc trên một kết nối và chỉ đóng kết nối khi luồng cuối cùng hoàn tất.


Câu hỏi

  1. Chi phí bộ nhớ: Một std::shared_ptr tốn dung lượng gấp đôi con trỏ thô (do có Control Block). Trong trường hợp nào chi phí này là không đáng kể so với lợi ích an toàn?

  2. Atomic operation: Tại sao việc tăng/giảm biến đếm tham chiếu trong shared_ptr lại là thao tác nguyên tử (atomic)? Điều này ảnh hưởng gì đến hiệu năng trong ứng dụng đa luồng?

Ngày 23: Thực hành Design Patterns

Thiết kế kiến trúc Mini-Framework

Chúng ta sẽ xây dựng một Hệ thống Quản lý Đơn hàng & Thông báo (Order & Notification System). Framework này cần áp dụng ít nhất 3 patterns:

  • Strategy: Để tính toán phí vận chuyển khác nhau (Fast, Saver).

  • Observer: Để thông báo cho các bộ phận (Kho, Vận chuyển, Khách hàng) khi đơn hàng đổi trạng thái.

  • Singleton/Factory: Để quản lý cấu hình hệ thống hoặc tạo các đối tượng Service.

Thách thức thiết kế:

  • Lớp Order cần biết về Customer để gửi thông báo.

  • Lớp Customer cần giữ danh sách các Order để quản lý lịch sử.

  • => Đây chính là Circular Dependency. Nếu dùng shared_ptr cho cả hai, bộ nhớ sẽ không bao giờ được giải phóng.

Thực thi Code & Xử lý lỗi bộ nhớ

Bạn sẽ bắt tay vào viết code cho Framework. Đây là giai đoạn bạn phải đối mặt với các file Header lồng nhau và lỗi "Memory Leak ngầm".

  • Kỹ thuật Forward Declaration: Học cách khai báo class Customer; trong file Order.h thay vì #include "Customer.h" để phá vỡ vòng lặp include.

  • Sử dụng std::weak_ptr: * Order sở hữu Customer (dùng shared_ptr).

    • Customer chỉ tham chiếu đến Order (dùng weak_ptr).

    • Phân tích tại sao cách này giúp xóa sạch đối tượng khi chương trình kết thúc.

  • Triển khai Patterns:

    • Viết Interface IShippingStrategy và các lớp thực thi.

    • Viết Interface IObserver cho các dịch vụ thông báo.

Kiểm thử & Refactoring

  • Unit Test: Viết test để đảm bảo khi một Order được tạo, phí vận chuyển được tính đúng theo Strategy đã chọn.

  • Memory Leak Test: Sử dụng công cụ (như mã giả kiểm tra số lượng count của shared_ptr) để đảm bảo các đối tượng OrderCustomer thực sự bị hủy khi ra khỏi scope.

  • Refactoring: Xem lại code theo chuẩn Ngày 1-6. Tên hàm đã sạch chưa? Có hàm nào làm quá nhiều việc không?


BÀI TẬP: MINI-FRAMEWORK "SMART-STORE"

Yêu cầu chức năng
  1. Tính toán phí (Strategy):

    • AirShipping: 10$ / kg.

    • GroundShipping: 2$ / kg.

  2. Thông báo (Observer):

    • Khi Order.status thay đổi thành "SHIPPED", tự động gửi tin nhắn cho CustomerInventorySystem.
  3. Quan hệ vòng lặp (Circular Dependency):

    • Class Order có thuộc tính shared_ptr<Customer>.

    • Class Customervector<weak_ptr<Order>> orders.

    • Viết hàm Customer::printHistory() để duyệt qua các weak_ptr, kiểm tra xem đơn hàng còn tồn tại không (lock()) trước khi in.


LAB 23: PHẪU THUẬT CIRCULAR DEPENDENCY

Hãy thực hiện đoạn mã sau và sửa lỗi để nó không bị rò rỉ bộ nhớ:

#include <iostream>
#include <memory>
#include <vector>

class Order; // Forward declaration

class Customer {
public:
    std::string name;
    // LỖI: Nếu dùng shared_ptr ở đây sẽ gây leak
    std::vector<std::shared_ptr<Order>> orderHistory; 

    Customer(std::string n) : name(n) {}
    ~Customer() { std::cout << "Customer destroyed\\n"; }
};

class Order {
public:
    int id;
    std::shared_ptr<Customer> customer;

    Order(int i, std::shared_ptr<Customer> c) : id(i), customer(c) {}
    ~Order() { std::cout << "Order destroyed\\n"; }
};

// NHIỆM VỤ: 
// 1. Sửa orderHistory thành weak_ptr.
// 2. Triển khai logic Strategy để tính giá trong Order.
// 3. Đảm bảo khi main kết thúc, cả Customer và Order đều in ra dòng "destroyed".

Câu hỏi

  1. Tại sao Forward Declaration chỉ hoạt động với con trỏ () hoặc tham chiếu (&) mà không hoạt động nếu ta khai báo một đối tượng thực thể bên trong lớp khác?

  2. Nếu dùng weak_ptr, làm sao để đảm bảo dữ liệu không bị xóa mất khi ta đang thực hiện dở dang một tác vụ trong Customer::printHistory()? (Gợi ý: Hàm lock()).

GIAI ĐOẠN 4: TỐI ƯU HÓA

Ngày 24: Memory Management & Cache Locality

Phân cấp bộ nhớ & Cơ chế Cache

  • Memory Hierarchy (Phân cấp bộ nhớ):

    • Tại sao CPU không đọc trực tiếp từ RAM? Sự chênh lệch tốc độ khủng khiếp giữa Register, L1, L2, L3 Cache và RAM.
  • Cơ chế Cache Line:

    • CPU không bao giờ đọc 1 byte lẻ loi. Khi bạn yêu cầu 1 biến, CPU sẽ bốc nguyên một khối (thường là 64 bytes) xung quanh biến đó vào Cache.
  • Spatial Locality (Tính cục bộ không gian):

    • Nếu bạn truy cập một địa chỉ bộ nhớ, khả năng cao bạn sẽ truy cập các địa chỉ ngay sát nó trong tương lai gần.
  • Temporal Locality (Tính cục bộ thời gian):

    • Một dữ liệu vừa được dùng có khả năng sẽ được dùng lại sớm.

Array vs Linked List

  • Mảng (Contiguous Memory):

    • Các phần tử nằm kề nhau. CPU nạp một Cache Line là có sẵn 4-16 phần tử tiếp theo. Đây là hiện tượng Cache Hit.
  • Danh sách liên kết (Fragmented Memory):

    • Các Node nằm rải rác khắp nơi trên Heap. Mỗi lần nhảy sang Node tiếp theo là một lần CPU phải đợi RAM phản hồi. Đây là hiện tượng Cache Miss.
  • CPU Pipelining & Branch Prediction:

    • Cách CPU "đoán" dữ liệu tiếp theo. Mảng giúp việc dự đoán này trở nên cực kỳ chính xác.
  • Thực nghiệm: So sánh thời gian duyệt 1 triệu phần tử giữa std::vector<int>std::list<int>.

Data-Oriented Design (DOD) sơ lược

  • Tư duy hướng dữ liệu:

    • Thay vì gom mọi thứ vào một Object (Array of Structures - AoS), hãy cân nhắc tách dữ liệu ra (Structure of Arrays - SoA) để tối ưu hóa Cache.
  • Padding & Alignment:

    • Tại sao kích thước của một struct đôi khi lại lớn hơn tổng các thành phần cộng lại? Cách sắp xếp các biến trong class để tiết kiệm bộ nhớ.
  • Sách tham khảo: Tìm đọc các bài viết về "Cache Friendly Code" của Scott Meyers hoặc Herb Sutter.

  • Lab 24: "The Speed Demon". Viết chương trình đo lường hiệu năng xử lý ma trận và tối ưu hóa nó bằng cách thay đổi thứ tự các vòng lặp để tận dụng Cache.


BÀI TẬP

Nhiệm vụ 1: Kiểm chứng Cache Miss

Viết chương trình duyệt một mảng 2 chiều 10000 \times 10000:

  1. Cách 1: Duyệt theo hàng (array[i][j]).

  2. Cách 2: Duyệt theo cột (array[j][i]).

    Đo thời gian thực thi của cả hai và giải thích tại sao duyệt theo hàng nhanh hơn rất nhiều dựa trên kiến thức về Cache Line.

Nhiệm vụ 2: Tối ưu hóa Struct Padding

Cho 2 struct sau:

struct Bad {
    char a;    // 1 byte
    double b;  // 8 bytes
    char c;    // 1 byte
}; // sizeof = ?

struct Good {
    double b;  // 8 bytes
    char a;    // 1 byte
    char c;    // 1 byte
}; // sizeof = ?

Hãy dùng sizeof để in ra kích thước của chúng. Tại sao Good lại nhỏ hơn Bad mặc dù chứa cùng dữ liệu?


LAB 24: CACHE-CONSCIOUS SEARCH (TÌM KIẾM THÂN THIỆN CACHE)

Bạn cần thực hiện một bài toán tìm kiếm trên tập dữ liệu lớn:

  1. Khởi tạo 1 triệu số nguyên.

  2. Lưu vào std::vector<int> và sắp xếp nó.

  3. Lưu vào std::set<int> (Dưới dạng cây nhị phân - các node rời rạc).

  4. Thực hiện tìm kiếm 10.000 giá trị ngẫu nhiên trên cả hai cấu trúc.

  5. Kết quả: Bạn sẽ thấy dù cả hai đều có độ phức tạp \(O(\log n)\), nhưng tìm kiếm trên vector (dùng std::lower_bound) thường nhanh hơn đáng kể. Hãy giải thích tại sao dựa trên Cache Locality.


Câu hỏi

  1. Nếu mảng luôn nhanh hơn, tại sao chúng ta vẫn cần Danh sách liên kết (Linked List)? Trong trường hợp đặc biệt nào thì tính cục bộ của bộ nhớ không còn là ưu thế tuyệt đối?

  2. False Sharing: Trong lập trình đa luồng, chuyện gì xảy ra nếu hai luồng cùng ghi vào hai biến khác nhau nhưng lại nằm chung trên một Cache Line?

Ngày 25: Move Semantics & Rvalue References

Lvalue và Rvalue

Để hiểu Move Semantics, bạn phải phân biệt được đối tượng nào "có tên" và đối tượng nào "tạm thời".

  • Lvalue (Left value): Những đối tượng có định danh, có địa chỉ bộ nhớ cố định (ví dụ: biến x, obj). Bạn có thể lấy địa chỉ của chúng bằng toán tử &.

  • Rvalue (Right value): Những giá trị tạm thời, thường nằm bên phải dấu bằng (ví dụ: hằng số 42, kết quả của phép tính a + b, hoặc đối tượng tạm thời trả về từ một hàm).

  • Rvalue Reference (&&): Cú pháp mới để "bắt" các giá trị tạm thời này.

    • Tại sao chúng ta cần nó? Để "đánh dấu" rằng: "Đối tượng này sắp chết, tôi có thể lấy trộm tài nguyên của nó".

Move Constructor & Move Assignment

Đây là phần thực hành quan trọng nhất để tối ưu hóa Class của bạn.

  • Move Constructor: Thay vì cấp phát bộ nhớ mới và copy dữ liệu (như Copy Constructor), ta chỉ cần "trỏ" con trỏ của đối tượng mới vào vùng nhớ của đối tượng cũ, sau đó gán con trỏ cũ về nullptr.

  • Move Assignment Operator (operator=): Tương tự như Move Constructor nhưng dành cho phép gán.

  • Hàm std::move(): Nó không thực sự di chuyển cái gì cả! Nó chỉ là một phép ép kiểu (cast) biến từ Lvalue thành Rvalue để kích hoạt Move Constructor.

  • Quy tắc "Rule of Five" (Hoàn thiện): Bây giờ bạn đã đủ bộ 5 hàm đặc biệt: Destructor, Copy Constructor, Copy Assignment, Move Constructor, Move Assignment.

[Image: Deep Copy vs. Move Semantics visualization - copying vs. stealing the pointer]

Perfect Forwarding & Tối ưu thực chiến

  • Perfect Forwarding (std::forward): Cách truyền tham số trong Template sao cho giữ nguyên được tính chất Lvalue/Rvalue của nó.

  • Xác nhận hiệu năng: * Khi nào trình biên dịch tự động thực hiện RVO (Return Value Optimization).

    • Tại sao không nên dùng std::move() khi trả về một biến cục bộ từ hàm.
  • Sách tham khảo: Đọc mục "Move Semantics" trong sách Effective Modern C++ (Scott Meyers).

  • Lab 25: "The Giant Buffer". Xây dựng một lớp quản lý mảng dữ liệu cực lớn và đo sự chênh lệch thời gian giữa việc Copy và Move khi truyền đối tượng vào Vector.


BÀI TẬP

Nhiệm vụ 1: Phân biệt Lvalue/Rvalue

Trong các dòng code sau, cái nào là Lvalue, cái nào là Rvalue?

int a = 10;
int b = a + 5;
string s1 = "Hello";
string s2 = s1 + " World";

Nhiệm vụ 2: Cài đặt Move Semantics cho SimpleString

Lấy lại lớp SimpleString ở Ngày 8 và thêm vào:

  1. Move Constructor.

  2. Move Assignment Operator.

    Trong mỗi hàm, hãy in ra dòng chữ "Move called" để kiểm chứng.

Nhiệm vụ 3: Đo lường tốc độ

  1. Tạo một std::vector<SimpleString>.

  2. Dùng push_back() với một đối tượng có tên (Lvalue).

  3. Dùng push_back() với std::move() của đối tượng đó.

  4. Quan sát số lần cấp phát bộ nhớ bị cắt giảm.


LAB 25: BIG DATA TRANSFER (CHUYỂN GIAO DỮ LIỆU LỚN)

Bạn cần xây dựng một lớp BigData mô phỏng việc tải file:

  1. Lớp BigData: Chứa một mảng động int* data kích thước 10 triệu phần tử.

  2. Cài đặt Copy Constructor: Thực hiện vòng lặp 10 triệu lần để copy (rất chậm).

  3. Cài đặt Move Constructor: Chỉ hoán đổi con trỏ (cực nhanh).

  4. Hàm BigData createBigData(): Tạo một đối tượng và trả về.

  5. Thực thi:

    • Trong main, gọi BigData b1 = createBigData();.

    • Đo thời gian thực hiện. Bạn sẽ thấy nhờ Move Semantics, 10 triệu phần tử được chuyển sang b1 gần như tức thời (0ms).


Câu hỏi

  1. Tại sao sau khi gọi std::move(obj), chúng ta không nên sử dụng lại biến obj đó nữa? Trạng thái của obj lúc đó như thế nào?

  2. Nếu một lớp có thành phần là con trỏ thông minh std::unique_ptr, trình biên dịch có tự động tạo Move Constructor cho bạn không? (Gợi ý: Tìm hiểu về default keyword).

Ngày 26: Exception Safety & Performance

Chi phí thực sự của Exception

  • Cơ chế Stack Unwinding:

    • Chuyện gì xảy ra khi một throw được kích hoạt? CPU phải làm gì để tìm kiếm khối catch phù hợp và gọi Destructor cho các đối tượng trên Stack?
  • Chi phí "Happy Path" (Khi không có lỗi):

    • Tại sao code có try-catch vẫn chạy nhanh gần như code thường nhờ cơ chế "Zero-cost exceptions" (Table-based approach).
  • Chi phí khi xảy ra lỗi:

    • Tại sao throw lại rất đắt đỏ (đắt hơn hàng trăm lần so với if-else hoặc trả về mã lỗi).
  • Từ khóa noexcept (C++11):

    • Tầm quan trọng của việc đánh dấu hàm không ném ngoại lệ để trình biên dịch thực hiện tối ưu hóa cực hạn.

3 Cấp độ An toàn ngoại lệ

Đây là thước đo trình độ của một Senior C++ Developer. Bạn cần đảm bảo code không để lại "bãi chiến trường" sau khi lỗi xảy ra.

  • 1. Basic Guarantee (Cam kết cơ bản):

    • Nếu ngoại lệ xảy ra, không có tài nguyên nào bị rò rỉ (Memory leak) và các đối tượng vẫn ở trạng thái hợp lệ (dù có thể dữ liệu không như ý).
  • 2. Strong Guarantee (Cam kết mạnh - Rollback):

    • Quy tắc "All or Nothing": Nếu hàm thất bại, hệ thống quay về trạng thái y hệt như trước khi gọi hàm. Kỹ thuật thường dùng: Copy-and-Swap.
  • 3. No-fail Guarantee (Cam kết không lỗi):

    • Hàm hứa sẽ không bao giờ ném ngoại lệ (thường dùng cho Destructor, Move Constructor và các hàm giải phóng tài nguyên).

Kỹ thuật viết code An toàn & Hiệu năng

  • RAII là chìa khóa: Tại sao không thể có Exception Safety nếu không dùng RAII (Ngày 22).

  • Tránh "Ném" trong Destructor: Tại sao ném ngoại lệ khi đang Unstacking lại khiến chương trình gọi std::terminate ngay lập tức.

  • Modern Error Handling: Khi nào dùng std::optional hoặc std::expected (C++23) để thay thế cho Exception trong các đoạn code nhạy cảm về hiệu năng.

  • Lab 26: "The Bulletproof Vector". Viết một hàm push_back tùy chỉnh cho một mảng động sao cho nó đạt mức Strong Guarantee (Dùng kỹ thuật Copy-and-Swap).


BÀI TẬP

Nhiệm vụ 1: noexcept

Viết một lớp có Move Constructor không có noexcept. Thêm nó vào một std::vector. Sau đó thêm noexcept và chạy lại.

Quan sát: Bạn sẽ thấy std::vector chỉ dám dùng "Move" nếu bạn cam kết noexcept, nếu không nó sẽ chọn "Copy" để đảm bảo an toàn, khiến hiệu năng giảm mạnh.

Nhiệm vụ 2: Implement Copy-and-Swap

Viết toán tử gán operator= cho lớp MyBuffer sao cho nó đạt Strong Exception Safety.

Gợi ý: 1. Tạo một bản sao tạm thời (temp copy).

  1. Swap nội dung bản sao với this (dùng hàm swap không ném lỗi).

  2. Nếu bước 1 lỗi, dữ liệu cũ vẫn nguyên vẹn.

Nhiệm vụ 3: Đọc chương 7 sách Clean Code (Error Handling)

Tập trung vào phần: "Define Exception Classes in Terms of a Caller's Needs".


LAB 26: TRANSACTION MANAGER (QUẢN LÝ GIAO DỊCH)

Bạn cần xây dựng một hệ thống chuyển tiền giữa 2 tài khoản ngân hàng:

  1. Hàm transfer(Account& from, Account& to, double amount).

  2. Yêu cầu Strong Guarantee:

    • Nếu đang trừ tiền ở from mà bị lỗi (ví dụ: mất kết nối hoặc hạn mức), tiền không được mất.

    • Nếu đang cộng tiền ở to mà bị lỗi, tiền phải được trả lại cho from.

  3. Thực thi: Sử dụng các đối tượng RAII để tự động hoàn tác (undo) nếu quá trình giao dịch không hoàn tất.


Câu hỏi

  1. Tại sao trong các dự án Google hoặc mã nguồn của Linux Kernel, người ta thường cấm sử dụng C++ Exceptions? Điều đó có mâu thuẫn với việc Exception làm code sạch hơn không?

  2. Trong C++, nếu một Exception thoát ra khỏi một noexcept function, chuyện gì sẽ xảy ra? Nó có khác gì so với một hàm bình thường không?

Ngày 27: Concurrency & Multithreading (Cơ bản)

Luồng (Threads) & Vòng đời của Thread

  • Khái niệm Multithreading:

    • Phân biệt Process (Tiến trình) và Thread (Luồng).

    • Tại sao dùng Multithreading? (Tăng hiệu suất, giữ UI không bị treo).

  • std::thread trong C++11:

    • Cách khởi tạo luồng với: Hàm thường, Lambda, và Hàm thành viên của lớp (Member functions - điểm quan trọng trong OOP).

    • join() vs detach(): Khi nào cần đợi luồng kết thúc, khi nào để nó chạy ngầm.

  • Truyền tham số cho Thread:

    • Vấn đề an toàn khi truyền tham chiếu (std::ref) vào luồng.

Race Condition & Cơ chế khóa Mutex

  • Race Condition (Tranh chấp dữ liệu):

    • Hiện tượng xảy ra khi hai luồng cùng đọc/ghi vào một biến cùng lúc, dẫn đến kết quả sai lệch không thể dự đoán.
  • Sử dụng std::mutex:

    • Cơ chế lock()unlock().
  • RAII cho Mutex (Cực kỳ quan trọng):

    • Tại sao không nên dùng mutex.lock() thủ công?

    • Sử dụng std::lock_guardstd::unique_lock để đảm bảo Mutex luôn được giải phóng ngay cả khi có Exception.

  • Deadlock (Bế tắc):

    • Khái niệm "Triết gia ăn tối" và cách tránh Deadlock bằng std::lock (để khóa nhiều mutex cùng lúc).

Thread-Safe Class Design

  • Thiết kế Lớp an toàn đa luồng:

    • Cách tích hợp std::mutex vào trong Class để bảo vệ các thuộc tính private.

    • Khái niệm Granularity (Độ mịn của khóa): Khóa toàn bộ object hay chỉ khóa một phần dữ liệu? (Đánh đổi giữa an toàn và hiệu năng).

  • std::atomic:

    • Khi nào dùng biến nguyên tử thay vì dùng Mutex để đạt hiệu suất cao hơn cho các biến đơn giản.
  • Lab 27: "The Thread-Safe Bank". Xây dựng một lớp BankAccount hỗ trợ nạp/rút tiền từ nhiều luồng đồng thời mà không làm sai lệch số dư.


BÀI TẬP THỰC CHIẾN NGÀY 27

Nhiệm vụ 1: Chứng minh Race Condition

  1. Viết một lớp Counter có biến int value = 0.

  2. Tạo 2 luồng, mỗi luồng tăng value lên 100.000 lần.

  3. In kết quả cuối cùng (Bạn sẽ thấy nó nhỏ hơn 200.000).

  4. Sửa lại dùng std::mutex và quan sát kết quả đúng.

Nhiệm vụ 2: Thread & OOP Member Function

Viết một lớp Downloader. Tạo một luồng để chạy hàm download() của một đối tượng cụ thể.

Lưu ý: Cú pháp std::thread t(&Downloader::download, &myObj);.

Nhiệm vụ 3: Đọc chương 13 sách Clean Code (Concurrency)

Tập trung vào các nguyên tắc: "Keep your concurrency-related code separate from other code" và "Limit the scope of data".


LAB 27: MULTI-THREADED LOGGER (HỆ THỐNG GHI LOG ĐA LUỒNG)

Bạn cần xây dựng một hệ thống ghi Log mà nhiều thành phần trong ứng dụng có thể gọi cùng lúc:

  1. Lớp Logger (Singleton): Chỉ có một instance duy nhất.

  2. Hàm log(string message):

    • Mở một file log.txt.

    • Ghi message kèm timestamp.

    • Đóng file.

  3. Thử thách:

    • Dùng std::lock_guard để đảm bảo rằng các dòng log không bị ghi đè hoặc trộn lẫn vào nhau khi 10 luồng cùng gọi hàm log().

    • Sử dụng std::chrono để lấy thời gian chính xác đến mili giây.


Câu hỏi

  1. Nếu một hàm chỉ đọc dữ liệu (không ghi), chúng ta có cần dùng Mutex không? (Gợi ý: Tìm hiểu về std::shared_mutex trong C++17).

  2. Tại sao việc lạm dụng Mutex lại có thể làm cho chương trình đa luồng chạy chậm hơn cả chương trình đơn luồng?

Ngày 28: STL Optimization

Tối ưu hóa Vector & Chuỗi

std::vector là container mặc định và quan trọng nhất, nhưng nó thường bị dùng sai cách dẫn đến lãng phí bộ nhớ.

  • Cơ chế Reallocation: Tại sao push_back lại có thể làm chậm chương trình gấp 10 lần? (Hiện tượng copy toàn bộ mảng khi vượt quá capacity).

  • Chiến thuật reserve() vs resize(): Cách cấp phát bộ nhớ trước để tránh việc cấp phát lại liên tục.

  • emplace_back() vs push_back(): Hiểu về In-place construction để tránh các bản sao tạm thời thừa thãi.

  • Small String Optimization (SSO): Cách C++ quản lý chuỗi ngắn trên Stack thay vì Heap và tác động của nó đến hiệu năng.

  • Kỹ thuật dọn dẹp: shrink_to_fit() và thành ngữ swap để giải phóng bộ nhớ thực sự.

Map vs Unordered_Map

Đây là câu hỏi phỏng vấn kinh điển và là mấu chốt để tối ưu hóa tìm kiếm.

  • std::map (Self-balancing Binary Search Tree):

    • Cấu trúc: Cây nhị phân đỏ-đen (Red-Black Tree).

    • Đặc điểm: Luôn sắp xếp thứ tự, truy cập \(O(\log n)\).

    • Điểm yếu: Tốn bộ nhớ cho các node, không thân thiện với Cache (Ngày 24).

  • std::unordered_map (Hash Table):

    • Cấu trúc: Bảng băm.

    • Đặc điểm: Truy cập trung bình $O(1)$.

    • Điểm yếu: Có thể bị Hash Collision (Xung đột mã băm) dẫn đến $O(n)$ trong trường hợp tệ nhất.

  • Khi nào dùng cái nào? So sánh dựa trên số lượng dữ liệu, yêu cầu về thứ tự và tính ổn định của khóa.

Set & Các kỹ thuật duyệt dữ liệu tối ưu

  • std::set vs std::unordered_set: Tương tự như Map, ứng dụng cho việc quản lý danh sách duy nhất.

  • Tại sao nên dùng std::vector đã sắp xếp + std::binary_search thay vì std::set? (Phân tích dựa trên Cache Locality).

  • Modern STL (C++17/20):

    • std::string_view: Tránh copy chuỗi khi chỉ đọc.

    • std::span: Cách truyền mảng/vector vào hàm mà không phụ thuộc vào container.

  • Lab 28: "The High-Frequency Dictionary". Xây dựng một từ điển chứa 1 triệu từ và so sánh tốc độ tìm kiếm, chèn giữa các loại container khác nhau.


BÀI TẬP THỰC CHIẾN NGÀY 28

Nhiệm vụ 1: Đo lường Reallocation

  1. Viết vòng lặp push_back 1 triệu phần tử vào vector.

  2. Đo thời gian khi không dùng reserve().

  3. Đo thời gian khi có dùng reserve().

    In ra số lần địa chỉ của vector.data() bị thay đổi.

Nhiệm vụ 2: Map vs Unordered_Map Benchmark

  1. Chèn 100.000 cặp Key-Value ngẫu nhiên vào cả hai loại map.

  2. Thực hiện tìm kiếm 10.000 khóa.

  3. So sánh thời gian thực thi. Giải thích tại sao unordered_map nhanh hơn trong trường hợp này.

Nhiệm vụ 3: Đọc chương 14 sách Clean Code

Liên hệ cách cấu trúc dữ liệu sạch giúp giảm độ phức tạp của thuật toán.


LAB 28: CUSTOMER DATA ANALYZER (BỘ PHÂN TÍCH DỮ LIỆU KHÁCH HÀNG)

Bạn cần quản lý danh sách 500.000 khách hàng (ID là số nguyên, Name là chuỗi):

  1. Yêu cầu 1: Tìm kiếm khách hàng theo ID cực nhanh (Ưu tiên std::unordered_map).

  2. Yêu cầu 2: In ra danh sách khách hàng có ID từ 1000 đến 2000 theo thứ tự tăng dần (Ưu tiên std::map).

  3. Yêu cầu 3: Tối ưu bộ nhớ bằng cách sử dụng std::vector kết hợp std::lower_bound cho các dữ liệu tĩnh không thay đổi.

  4. Thử thách: Viết một hàm nhận vào std::string_view thay vì const std::string& để tìm kiếm khách hàng theo tên và đo sự chênh lệch hiệu năng.


Câu hỏi

  1. Tại sao việc sử dụng operator[] trên std::map lại nguy hiểm hơn std::map::at()? (Gợi ý: Chuyện gì xảy ra nếu Key không tồn tại?).

  2. Nếu bạn có một tập dữ liệu nhỏ (dưới 100 phần tử), tại sao một mảng phẳng (std::vector) thường nhanh hơn một bảng băm (std::unordered_map)?

Ngày 29: Profiling & Debugging

Debug với GDB

  • GDB (GNU Debugger) cơ bản:

    • Cách biên dịch code với cờ g để giữ lại thông tin debug.

    • Các lệnh sống còn: run, break (điểm dừng), step (vào hàm), next (qua dòng), print (xem giá trị biến).

  • Debug nâng cao:

    • backtrace: Truy vết ngược lại các hàm đã gọi để biết chính xác lỗi bắt đầu từ đâu khi chương trình crash.

    • watch: Theo dõi một biến, chương trình sẽ dừng lại ngay khi giá trị biến đó bị thay đổi.

    • Debug chương trình đa luồng (multi-threaded debugging).

  • Sử dụng IDE Debugger: Cách dùng giao diện trực quan của VS Code hoặc CLion để thay thế dòng lệnh GDB.

Valgrind

  • Memcheck: Công cụ số 1 để tìm kiếm Memory Leaks.

    • Phân biệt giữa "Definitely lost" (chắc chắn rò rỉ) và "Still reachable" (vẫn còn con trỏ quản lý).
  • Tìm lỗi truy cập bộ nhớ: * Phát hiện lỗi "Invalid read/write" (truy cập mảng ngoài phạm vi).

    • Phát hiện việc sử dụng biến chưa khởi tạo.
  • Helgrind: Tìm kiếm lỗi tranh chấp dữ liệu (Race Conditions) trong lập trình đa luồng mà bạn đã học ở Ngày 27.

Profiling

  • Gprof & Perf:

    • Phân tích hàm nào tiêu tốn nhiều CPU nhất.

    • Hiểu về "Hot spots" trong mã nguồn.

  • Flame Graphs: Cách đọc biểu đồ nhiệt để tối ưu hóa những đoạn code chạy chậm.

  • Phân tích Cache Misses: Sử dụng Cachegrind để thực chứng những gì bạn đã học ở Ngày 24 (tác động của Cache Locality).

  • Lab 29: "The Broken Engine". Tôi sẽ đưa bạn một đoạn code đầy lỗi bộ nhớ và chạy cực chậm. Nhiệm vụ của bạn là dùng Valgrind để sửa lỗi và dùng Gprof để tăng tốc nó lên gấp 2 lần.


BÀI TẬP

Nhiệm vụ 1: Sửa lỗi Segmentation Fault

Viết một đoạn code cố tình truy cập vào con trỏ NULL hoặc mảng quá giới hạn. Sử dụng GDB lệnh backtrace để xác định chính xác dòng code gây lỗi.

Nhiệm vụ 2: Truy tìm rò rỉ bộ nhớ

Sử dụng lớp SimpleString (không có Destructor) hoặc một vector các con trỏ new mà không delete. Chạy Valgrind và thực hiện sửa lỗi cho đến khi nhận được thông báo: "All heap blocks were freed -- no leaks are possible".

Nhiệm vụ 3: Đọc chương 15 sách Clean Code (JUnit Internals)

Liên hệ: Tại sao việc viết Unit Test tốt lại giúp quá trình Debugging trở nên nhàn nhã hơn rất nhiều?


LAB 29: MEMORY & PERFORMANCE DOCTOR

Bạn có một ứng dụng xử lý danh sách 1 triệu sinh viên, nhưng nó đang gặp 2 vấn đề:

  1. Lỗi bộ nhớ: Càng chạy lâu, chương trình càng tốn RAM và thỉnh thoảng tự đóng.

  2. Tốc độ: Hàm sắp xếp và tìm kiếm mất tới 10 giây để phản hồi.

Yêu cầu:

  • Sử dụng Valgrind Memcheck để tìm các chỗ quên delete và các lỗi Invalid read khi duyệt mảng.

  • Sử dụng Gprof để phát hiện ra rằng bạn đang dùng thuật toán Bubble Sort \(O(n^2)\) và thay thế bằng std::sort \(O(n \log n)\).

  • Sử dụng Cachegrind để kiểm tra xem việc đổi từ std::list sang std::vector đã giảm bao nhiêu % Cache Miss.


Câu hỏi

  1. Tại sao không nên bật cờ tối ưu hóa O3 khi đang Debug với GDB? (Gợi ý: Trình biên dịch có thể thay đổi thứ tự dòng code hoặc xóa bỏ biến).

  2. Sự khác biệt giữa Static Analysis (như dùng Cppcheck) và Dynamic Analysis (như Valgrind) là gì? Tại sao cần cả hai?

Ngày 30: Capstone Project

Phân tích & Thiết kế kiến trúc

Bạn sẽ chọn một trong hai chủ đề:

  • Chủ đề A (Hệ thống quản lý): Smart Library Management (Quản lý thư viện thông minh với chức năng tìm kiếm, mượn trả, phân loại sách đa tầng).

  • Chủ đề B (Hệ thống mô phỏng): Mini Game Engine (Hệ thống quản lý các thực thể game, xử lý va chạm và hệ thống Rendering trừu tượng).

Nhiệm vụ thiết kế:

  • Sơ đồ lớp (Class Diagram): Vẽ cấu trúc phân cấp, xác định các Interface (ví dụ: IRepository, IEntity, IObserver).

  • Áp dụng SOLID: Xác định nơi nào dùng Strategy (cho việc tìm kiếm/sắp xếp), nơi nào dùng Factory (khởi tạo đối tượng), nơi nào dùng Observer (thông báo sự kiện).

Thực thi mã nguồn "Sạch"

Đây là lúc bạn áp dụng tất cả "vũ khí" đã học:

  • Memory Management: Sử dụng tuyệt đối std::unique_ptrstd::shared_ptr. Không dùng new/delete thủ công.

  • Templates: Viết các Container hoặc hàm tìm kiếm tổng quát.

  • Move Semantics: Tối ưu hóa việc chuyển giao dữ liệu giữa các module.

  • Exception Safety: Đảm bảo hệ thống không "sập" khi người dùng nhập dữ liệu sai hoặc file bị hỏng.

  • Thread-safety: (Nếu làm hệ thống lớn) Đảm bảo việc truy xuất dữ liệu đồng thời không gây Race Condition.

Tối ưu hóa, Debug & Đóng gói

  • Profiling: Chạy Valgrind để đảm bảo 0% Memory Leak.

  • Performance: Sử dụng kiến thức về Cache LocalitySTL Optimization để tối ưu hóa những vòng lặp xử lý dữ liệu nặng.

  • Clean Code Review: Đọc lại code một lần cuối. Đặt câu hỏi: "Nếu một người khác đọc code này, họ có hiểu ngay trong 30 giây không?".

  • Documentation: Viết file README.md hướng dẫn cách biên dịch và kiến trúc của hệ thống.


YÊU CẦU KỸ THUẬT BẮT BUỘC (CHECKLIST)

Để dự án được coi là "Tốt nghiệp", bạn phải tích hợp được các thành phần sau:

  1. Tính đóng gói (Encapsulation): Không có biến thành viên nào để public.

  2. Tính đa hình (Polymorphism): Có ít nhất 1 Interface hoặc Abstract Class.

  3. SOLID:

    • LSP: Lớp con phải thay thế được lớp cha mà không gây lỗi.

    • DIP: Lớp cấp cao không phụ thuộc trực tiếp vào lớp cấp thấp (Dùng Dependency Injection).

  4. Design Patterns: Áp dụng ít nhất 2 mẫu (ví dụ: Singleton cho Logger và Factory cho Book/Entity creation).

  5. Modern C++: Sử dụng auto, lambda, nullptr, và std::string_view.


LAB 30: THE FINAL CHALLENGE - "SMART LIBRARY ENGINE"

Nếu bạn chọn hệ thống quản lý thư viện, hãy thực hiện tính năng nâng cao sau để chứng minh trình độ:

  1. Dữ liệu lớn: Xử lý 100,000 cuốn sách lưu trong std::vector nhưng được Index bởi một std::unordered_map để tìm kiếm O(1).

  2. Tính năng tìm kiếm (Strategy Pattern): Cho phép tìm theo Tên, Tác giả, hoặc ISBN bằng cách thay đổi thuật toán tìm kiếm tại Runtime.

  3. Hệ thống Log (Observer Pattern): Mỗi khi có sách được mượn, các module EmailServiceInventoryService sẽ nhận được thông báo để cập nhật.

  4. An toàn: Sử dụng std::unique_ptr để quản lý danh sách sách, đảm bảo khi chương trình tắt, toàn bộ dữ liệu được giải phóng sạch sẽ.


Tổng kết hành trình 30 ngày

  • Ngày 1-10: Bạn đã học cách viết mã nguồn sạch và quản lý bộ nhớ cơ bản.

  • Ngày 11-20: Bạn làm chủ tư duy OOP và các nguyên lý thiết kế hệ thống bền vững (SOLID).

  • Ngày 21-30: Bạn chạm đến đỉnh cao của hiệu năng (Cache, Move Semantics, Multithreading) và các công cụ chuyên nghiệp.

More from this blog

Learn DevOps

289 posts