Syllabus 110 ngày C++
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,#ifdefhoạ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ặcclang++(dùng các flagE,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
.cppvà.h. Tự tạo một thư viện tĩnh (.ahoặ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
structcó 1charvà 1intlạ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
=và{}(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
constmọi nơi có thể. Tại saoconstgiú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
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.
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).Phân biệt được ngay lập tức một biểu thức là L-value hay R-value.
Luôn khởi tạo biến bằng dấu
{}và dùngconstmộ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
&&và||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
charvới mộtint? 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ớii++(hậu tố) về mặt hiệu năng. Tại sao nên dùng++icho 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
switchnhanh hơnif? 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]]và[[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 elision và return 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
inlinevà 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
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
O2hoặcO3.Đ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.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*và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
constvà con trỏ. Tại sao dùngconstgiú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ósizeofkhá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
alignofvàoffsetofđể kiểm tra vị trí các biến trong bộ nhớ.Bài tập: Thiết kế một
structchứ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
newvàdelete. 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
SlowCopyvsFastCopy(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ơnstd::list(dùng con trỏ nhảy cóc) về mặt hiệu năng thực tế.
Chiến lược
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ỏ.
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.Đọ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[]và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
newthà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 sSize và capacity.
Amortized complexity: Tại sao
vectorlạ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
vectorlại "vô đối" về tốc độ duyệt so vớilist.
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().
- Sử dụng
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
deletekhi 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_ptrkhô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
MyUniquePtrvừ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
Datetừ 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.
- Thiết kế lại lớp
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 objects và thread-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_Vectorcủa bạn ở ngày 11 để hỗ trợ deep copy hoàn chỉnh.
- Nâng cấp lớp
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
vector100MB qua hàm bằng copy vs move.
- Viết code so sánh thời gian chạy khi truyền một
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
constcho 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ùngstd::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
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).
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.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
protectedvà 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
Shapecơ bản (Circle, Triangle) từ sách PPP. Vẽ sơ đồ bộ nhớ cho từng đối tượng.
- Xây dựng hệ thống lớp
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
overridevàfinaloverride: É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
finalgiú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.
- Thực hiện các ví dụ gây lỗi nếu thiếu
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.orgxem 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_castlạ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
Itemvớ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
deletethủ 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
FileHandlertự động đóng file và một lớpLockGuardđơ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_ptrkhô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ùngfclosethay vìdelete).
Move-only
Cách truyền
unique_ptrvà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ơnnew.
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.
- Chuyển đổi toàn bộ project "Vật phẩm" ngày 25 từ con trỏ thô sang
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_ptrtố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_ptrtrỏ 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ớiunique_ptr.
- Đo chi phí hiệu năng khi copy
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_castvàstd::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).
Áp dụng unique_ptr làm mặc định, chỉ dùng shared_ptr khi thực sự cần chia sẻ.
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.
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
Dùng
unique_ptrtheo mặc định: Trong C++ chuyên nghiệp, 90% trường hợp bạn chỉ cầnunique_ptr. Đừng lạm dụngshared_ptrvì nó có chi phí hiệu năng (atomic increment/decrement).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.
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ểinlinehoà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
swapvà lớpStack<T>thủ công. Sử dụnggodbolt.orgđể xem cách trình biên dịch sinh ra 2 hàm khác nhau hoàn toàn chointvàdouble.
- Viết lại hàm
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ớivector<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
copytổng quát. Sử dụng specialization để nếu dữ liệu là kiểu "plain old data" (nhưint), hàm sẽ dùngmemcpy(tốc độ cao) thay vì vòng lặpfor.
- Thiết kế một hàm
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
printfan 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.
- Viết một hàm
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ơnstd::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.
- Xây dựng một lớp
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
typenamevsclass, dependent namesHiể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
.hvà.cppnhư 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
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.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.
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::listthường chậm hơnstd::vectorngay 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
vectorvslist. Bạn sẽ thấy sức mạnh của sự liên tục bộ nhớ.
- Viết code đo thời gian duyệt (traversal) 1 triệu phần tử của
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::multimapvàstd::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ớivector<pair>.
- Phân tích chi phí bộ nhớ của
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_mapchậm hơnmap? (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
mapvàunordered_mapvới dữ liệu 500,000 từ.
- 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
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::vectortận dụng tối đa mỗi lần CPU load dữ liệu từ RAM.Pointer chasing: Tại sao
std::listvàstd::mapkhiế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::maptruyền thống.
- Đọ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ì
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).
Sắp xếp IP theo số lượng (Dùng vector + std::sort).
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_findchạy được trên cảstd::listvàstd::vector. Phân tích sự khác biệt về hiệu năng khi dịch chuyển Iterator.
- Viết một thuật toán
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
autotrong 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::functionvs template lambda).
- So sánh mã Assembly giữa việc dùng lambda và dùng hàm thường (
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::transformvàstd::find_if.
- Chuyển đổi một đoạn code cũ đầy rẫy vòng lặp lồng nhau sang sử dụng
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::sortvsstd::stable_sortvsstd::partial_sort.std::partitionvàstd::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)) vsstd::binary_search(O(log n)) trên mảng đã sort.
- Benchmark: Tìm kiếm trên 10 triệu phần tử bằng
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.
Sử dụng std::partition để tách các điểm ảnh "nhiễu" ra khỏi ảnh chính.
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
constexprBiến
constexprvs Biếnconst: 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::arrayvà 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ụngstatic_assertđể chứng minh kết quả đã có sẵn trước khi chương trình chạy.
- Viết hàm tính giai thừa (factorial) và sin/cos bằng
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ưifcho kiểu dữ liệu).Ứng dụng: Tạo một lớp
Buffertự động chọninthoặclong longdựa trên kích thước yêu cầu.
Thực hành
- Dùng
static_assertkế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.
- Dùng
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
requiresclause.Định nghĩa một concept (ví dụ:
template<typename T> concept Hashable = ...).
Refactoring với concepts
Thay thế toàn bộ
std::enable_ifphứ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.
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.
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::threadVòng đời của luồng:
join()vsdetach(). 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
whilekhi 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::asynclại tiện hơnstd::threadtrong nhiều trường hợp.Các chính sách thực thi:
std::launch::asyncvsstd::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.
- 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
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ơnint+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
ThreadPoolquả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.
Các luồng con (worker threads) tự động lấy task ra để xử lý.
Sử dụng std::atomic<bool> để quản lý trạng thái dừng của Pool.
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
alignasvàalignofđể ép kiểu sắp xếp bộ nhớ.Viết một class
Matrixvà đ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 + Dlại tạo ra 3 đối tượng tạm thời và 3 vòng lặpfordư 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
forduy 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.
- Xây dựng một khung (skeleton) đơn giản cho
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
perfhoặ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 Newhoặc một mảng tĩnh để tránhnew/deleteliê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 nhanhOrderID.
- Sử dụng
RAII & smart pointers: Quản lý vòng đời của từng
Orderbằngstd::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::atomicvàstd::mutextối ưu).
HƯỚNG DẪN THỰC HIỆN CHI TIẾT
Bước 1: Thiết kế object Order và OrderBook
Định nghĩa
struct Ordervớ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ụ:Sidedùngenum charđặt cạnhQuantity).Viết lớp
OrderBooksử dụngunique_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_boundtrên các container để tìm mức giá khớp nhanh nhất.Tối ưu hóa: Sử dụng
constexprcho 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::mutexvới scope nhỏ nhất) để đưa lệnh vàoOrderBook.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::listsang mộtBufferliên tục để tận dụng cache line.
CHECKLIST
Move semantics: Bạn có dùng
std::movekhi chuyển lệnh vàoOrderBookkhông?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?
Smart pointers: Có con trỏ thô () nào đang quản lý
newkhông? (Nếu có, chuyển ngay sangunique_ptr).Cache-friendly: Dữ liệu có nằm liên tiếp trong bộ nhớ không?
Exception safety: Nếu hệ thống hết bộ nhớ,
OrderBookcó 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ụngunique_ptrthay 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.
- 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
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ỏnextvà 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ỏ
headlà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::vectornhanh hơn 10-50 lần so vớistd::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:
std::vector<int>có 1 triệu phần tử.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
insertHeadvàdeleteHeadcủa SLL.
- Thực chất là thao tác
Ứ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
insertTailvàdeleteHead.Ứ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ỏ
frontvàrear.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 * +).
- Cách dùng Stack để chuyển từ biểu thức người đọc (
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
newmà khôngdeletetrong 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:
Nhập một ký tự (Add).
Nhấn 'U' để Undo.
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
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.
Cố tình bỏ hàm Destructor (không giải phóng memory).
Chạy lệnh:
valgrind --leak-check=full ./programQuan 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::sorttrong 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
mergeSortsử dụngstd::vectorphụ.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::sortcủa C++.Quan sát sự chênh lệch và giải thích tại sao
std::sortvẫn là nhanh nhất.
Câu hỏi
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)?
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
PriorityQueuesử dụngstd::vectorlà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
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).
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
vectorcá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
countingSorthỗ trợ sắp xếp theo chữ số thứexp.Triển khai
radixSortgọicountingSortlặ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
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).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).
- Viết hàm
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
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).
Tại sao
std::sorttrong 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
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ỳ?
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 > 1hoặcBF < -1.
Cập nhật cấu trúc Node:
Thêm biến
heightvà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
heightlại tối ưu hơn việc tínhheightbằ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ácinsertvàremove.
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ùngchar heighthoặc kỹ thuật bit để tiết kiệm bộ nhớ nếu cây có hàng triệu nút.
- Thay vì lưu
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)vàleftRotate(Node* x).Yêu cầu: Cập nhật biến
heightngay 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
insertcủa BST ngày hôm qua.Thử thách: Viết hàm xóa
removecho 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
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ì?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::listhoặcstd::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
alphavượ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_cusẽ khác hoàn toànkey % 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()trongstd::unordered_mapđể tránh Rehash nhiều lần nếu biết trước số lượng phần tử.
- Sử dụng
BÀI TẬP
Nhiệm vụ 1: Cài đặt Hash Table bằng Chaining
Tự viết một class
MyHashTabledùng mảng cácstd::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)và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:
std::map(Cây đỏ đen - O(log n)).std::unordered_map(Bảng băm - O(1)).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
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?
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::mapvàstd::setdù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::mapcó thời gian tìm kiếm O(log n) nhưng thực tế lại chậm hơnstd::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ảnglower_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::map và std::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
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?
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:
Nạp dữ liệu: Đọc file văn bản chứa từ vựng và nghĩa.
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).
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.
- Dùng
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_mapnhanh hơn trong tìm kiếm đơn lẻ.- Giải thích tại sao
maplại vượt trội khi cần in ra danh sách từ vựng theo thứ tự A-Z.
- Giải thích tại sao
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.txttrê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::stringhoặcstd::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_mapsẽ 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
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?
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:
Cài đặt đồ thị bằng
std::vector<std::vector<int>>.Viết hàm
BFS(int start_node).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
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ề?
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:
Sắp xếp tất cả các cạnh theo thứ tự trọng số tăng dần.
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 đó.
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 Compression và Union 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:
Cài đặt DSU tối ưu.
Triển khai Kruskal bằng
std::sortvà DSU.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
DisjointSetvới hai hàmfind()và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
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?
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?
Đ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ứivới trọng lượngw).
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]và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 đồ:
Vàng: 60$ (10kg)
Bạc: 100$ (20kg)
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ếna, b, cđể đạt độ phức tạp không gian $O(1)$.
Câu hỏi
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).
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ụ
gprofhoặcValgrind --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::listthànhstd::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>>> adjcho đồ thị.
Nhiệm vụ 2: Benchmark & Optimize
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.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.Thử thay đổi
unordered_mapthà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:
Dữ liệu của tôi có thứ tự không?
Tôi cần ưu tiên tốc độ tìm kiếm hay tốc độ ghi?
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 daysTốt:
int elapsedTimeInDays;,int daysSinceCreation;
Quy tắc 2: Tránh gây nhiễu (Avoid Disinformation).
Đừng đặt tên
accountListnếu nó không phải là mộtList(trong C++ làstd::list). Hãy dùngaccountshoặcaccountGroup.Tránh các ký tự gần giống nhau:
Ovà0,lvà1.
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, ktrong 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ùngretrieve, lúc dùnggetcho 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.
- Đừng lúc thì dùng
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 structAddressđể tạo ngữ cảnh rõ ràng.
- Nếu bạn có các biến
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
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?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,whilenê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
structhoặcclass.
Tham số logic (Flag Arguments):
- Tại sao truyền
boolvào hàm (ví dụ:render(true)) là "tội ác"? Hãy tách thành 2 hàm:renderForAdmin()vàrenderForGuest().
- Tại sao truyền
Nói không với Tác dụng phụ (Side Effects):
- Hàm mang tên
checkPasswordthì không được phép kiêm luôn việcsession.initialize(). Tác dụng phụ ngầm là nguồn cơn của các bug khó tìm nhất.
- Hàm mang tên
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
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).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ơntotal=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
if1 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ở:
Xóa tất cả các đoạn code bị comment (
//).Đị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
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?
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
trynhư một "giao dịch" (transaction).Cách viết các khối
catchcụ 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
throwsnhư 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.
- Trong C++, mặc dù không có từ khóa
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ề
nullptrlà 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
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).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):
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).
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).
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
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).
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ề Mocking và Dependency 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).
- 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ư
Tính đóng gói (Encapsulation)
Bản chất của Đóng gói:
Không chỉ là đặt
privatecho 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ùngclass.
- Khi nào dùng
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
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?
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
newvàdelete.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().
- Tại sao dùng
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[].
- Cách cấp phát mảng trên Heap và tầm quan trọng của
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.
- Bản chất thực sự của
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 Bnế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
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).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/deletethủ công và thay bằngstd::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ế
protectedvì 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/setcho mọi biếnprivatethự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 traa > 0.
Encapsulation & Refactoring
Hàm truy cập Hằng (Const Member Functions):
Tầm quan trọng của từ khóa
constsau 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:
Biến
balance(số dư) phải làprivate.Không có
setBalance: Số dư chỉ được thay đổi thông qua các hành độngdeposit(amount)vàwithdraw(amount).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.
Hàm
getBalance(): Phải là hàmconst.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àmprintSummary().
Câu hỏi
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?Trong C++, từ khóa
friendcho phép một lớp khác truy cập vào vùngprivate. Vậyfriendlà "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ụ:
Caris-aVehicle,Dogis-aAnimal.
- Chỉ kế thừa khi lớp con thực sự là một biến thể của lớp cha. Ví dụ:
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ụ:
Carhas-aEngine,Househas-aRoom.
- Sử dụng khi một đối tượng chứa hoặc sở hữu đối tượng khác. Ví dụ:
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
Enginemà không ảnh hưởng đến cấu trúc của lớpCar.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
Stackkế thừa từ lớpArrayList.(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
CombatRobotkế thừaRobot.Lớp
WorkerRobotkế thừaRobot.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
Robotchứa mộtvectorcácModule. 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
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?
Làm thế nào để quyết định khi nào dùng
protectedthay vìprivatecho 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
overridevàfinal(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ẽ:Lấy
vptrtừ đối tượng.Truy cập vào
V-Table.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
virtualdestructor: 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ọidraw()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
Viết một lớp
BasevàDerived. TrongDerivedcó cấp phát mảng động.Dùng con trỏ
Base* b = new Derived().Xóa
delete bkhiBaseKHÔNG có virtual destructor. Dùng công cụ check leak hoặc in ra màn hình để thấy destructor củaDerivedkhông được gọi.Thêm
virtualvà 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:
Lớp cơ sở
Employeecó hàm ảocalculateSalary().Các lớp con:
FullTimeEmployee(Lương cứng),Contractor(Lương theo giờ).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-elsehayswitch-caseđể kiểm tra kiểu nhân viên. Hãy để V-Table tự quyết định.
Câu hỏi
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ể
inlinehàm).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ếuAnimallà 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
interfacenhư Java hay C#, chúng ta dùng lớp thuần ảo.
- C++ không có từ khóa
Đ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ụ:
SmartPhonevừ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ừ
SqlDatabasesangMongoDatabasemà 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ớpMp3Player,WavPlayer,FlacPlayer. Hệ thống chính chỉ gọiIMediaPlayer->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
Định nghĩa Interface
IShapevới hàmgetArea()vàgetPerimeter().Định nghĩa Interface
IDrawablevới hàmdraw().Tạo lớp
Circlekế 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:
Định nghĩa Interface
IMessageServicevới hàm ảo thuần túysendMessage(string msg).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]".
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ọiservice->sendMessage(text).
Thực thi trong
main:Thử truyền các loại Service khác nhau vào
UserAlertvà quan sát kết quả.Bạn sẽ thấy
UserAlerthoà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
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").
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 + complex2lại tốt hơncomplex1.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àostreamhoặcistream, không phải lớp của bạn).
- Tại sao các toán tử này bắt buộc phải là
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ốintgiả.
- Cách phân biệt giữa tiền tố (Prefix:
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:
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).Toán tử
==,!=: So sánh hai phân số.Toán tử
<<và>>: Cho phép viếtcout << frac1;vàcin >> frac2;.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
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?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ệnhmyObj[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
privatevàprotectedcủa lớp đó.Tại sao dùng
friendthay vì tạoGetter/Settercô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).
ostreamcần truy cập dữ liệu để in nhưng không thể là thành viên của lớp.
- Đây là ứng dụng phổ biến nhất (đã nhắc ở Ngày 13).
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
friendcó 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.
- 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
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
Matrixvà lớpVector. 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.
- Ví dụ: Lớp
Bảo mật & Refactoring
Nguy cơ phá vỡ Encapsulation:
- Làm sao để dùng
friendmà vẫn giữ code "Sạch"? (Nguyên tắc: Chỉ dùngfriendkhi 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).
- Làm sao để dùng
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ềnfriendmớ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ử.
Viết hàm in mảng bằng cách dùng
Gettergọi 1 triệu lần.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:
Lớp
Vector: Chứa mảng độngdouble* values.Lớp
Matrix: Chứa mảng 2 chiềudouble** data.Hàm
friend Vector multiply(const Matrix& m, const Vector& v):Hàm này phải được khai báo là
friendtrong cả 2 lớp.Nó sẽ truy cập trực tiếp vào
m.datavàv.valuesđể tính toán tích ma trận.
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
Tại sao nói
friendlà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).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ặctemplate <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>.
- Cách viết hàm nhận nhiều kiểu khác nhau:
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 { ... };.
- Truyền giá trị hằng số vào Template:
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).
- 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ụ:
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ể
inlinecode 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.
- Nhìn lại
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:
Đị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ử.
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.
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
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)?
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
Employeechứ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-elsehoặcswitch-caseđể xử lý các loại khách hàng, hãy tạo một InterfaceDiscountStrategyvà 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ũ.
- Thay vì dùng
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().
Hãy chỉ ra các Actor liên quan đến từng phương thức.
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.
Tại sao hàm cũ vi phạm OCP?
Hãy refactor lại bằng cách dùng Interface
Shapeđể hàmcalculateAreakhô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:
Thiết kế sai (Vi phạm OCP): Viết lớp
TaxCalculatorvới hàmcalculate(double amount, string country)sử dụngif (country == "VN") ... else if (country == "US") ....Thiết kế đúng (SOLID):
Tạo Interface
ITaxStrategycó hàmcalculateTax(double amount).Cài đặt
VietnamTax,USTax,EuropeTax.Lớp
TaxServicesẽ nhận vào mộtITaxStrategy*(Dependency Injection) và thực hiện tính toán.
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 trongTaxServicekhông.
Câu hỏi
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"?
SRP nói một lớp chỉ làm một việc. Vậy một lớp
Customervừa cóName, vừa cóAddress, vừa cóEmailcó 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
Squarekế thừa từRectanglelại vi phạm LSP? (Vì hành visetWidthcủa Hình vuông làm thay đổi cả Height, phá vỡ logic của Hình chữ nhật).
- Tại sao
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ồ.
- Một lớp có thể
Thực hành Refactoring
Refactoring Code vi phạm:
- Phân tích một hệ thống Robot: Interface
IRobotcówalk(),fly(),swim(). Một Robot công nghiệp không thể bay nhưng vẫn phải cài đặtfly(). Hãy dùng ISP để sửa lại.
- Phân tích một hệ thống Robot: Interface
LSP trong C++:
- Tại sao từ khóa
virtualvàoverridelà công cụ đắc lực để duy trì LSP?
- Tại sao từ khóa
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:
Thiết kế sai (Vi phạm ISP): Tạo Interface
IWorkervới các hàmwork(),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ủ.
- Chuyện gì xảy ra nếu bạn có một lớp
Thiết kế đúng (SOLID):
Tách thành
IWorkable,IEatable,ISleepable.Lớp
HumanWorkerkế thừa cả 3.Lớp
RobotWorkerchỉ kế thừaIWorkable.
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.
- Viết một hàm
Câu hỏi
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?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:
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).
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:
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).
Setter Injection: Truyền qua các hàm
set.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_ptrhoặcstd::shared_ptrđể truyền Interface vào lớp cấp cao.
- Cách dùng
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:
Tạo Interface
IDatabase.Cho
MySQLDatabasekế thừaIDatabase.Sửa
PasswordAnalyzerđể nhậnIDatabase*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:
Thiết kế vi phạm DIP: Lớp
Switchchứa trực tiếp đối tượngLightBulb. Khi muốn dùngSwitchchoFan(Quạt), bạn phải sửa code của lớpSwitch.Thiết kế theo DIP & DI:
Tạo Interface
ISwitchablecó hàmturnOn()vàturnOff().Cho
LightBulb,Fan,AirConditionerkế thừaISwitchable.Lớp
Switchnhận mộtISwitchable&qua Constructor.
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ớpSwitchmà không cần thiết bị thật.
Câu hỏi
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).
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ùngstd::mutexhoặ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).
WindowsFactorytạo raWindowsButtonvàWindowsCheckbox.MacFactorytạo raMacButtonvàMacCheckbox.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:
Không thể copy đối tượng (Sử dụng
= deletecho Copy Constructor).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.
Tạo Interface
Enemyvới hàmattack().Tạo lớp
Levelvới hàm ảospawnEnemy().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:
Interface Sản phẩm:
IButton,ITextBox.Sản phẩm cụ thể:
DarkButton,DarkTextBox(Dùng cho Dark Mode).LightButton,LightTextBox(Dùng cho Light Mode).
Interface Factory:
IThemeFactorycó hàmcreateButton()vàcreateTextBox().Cài đặt Factory:
DarkThemeFactoryvàLightThemeFactory.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
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?
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 raCoffee + Milk + Sugardễ 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
Facadechứ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
HomeTheaterFacadecung cấp hàmwatchMovie().
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ê:
Interface
ICoffeecó hàmcost()vàdescription().Lớp
SimpleCoffeegiá 10$.Các Decorator
MilkDecorator(+2\(),SugarDecorator(+1\)).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).
Nhiệm vụ: Viết lớp
ComputerFacade.Hàm chính:
startComputer().Logic bên trong: Facade sẽ gọi
CPU.freeze(),Memory.load(),HardDrive.read(),CPU.execute().Kết quả: Ở
main, bạn chỉ cần một dòngcomputer.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
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).
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-elsekhổ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
Subjectgiữ một danh sáchstd::vector<IObserver*>.Hàm
notify()duyệt danh sách và gọiupdate()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).
Interface
IPaymentStrategycó hàmpay(int amount).Cài đặt
CreditCardStrategyvàPaypalStrategy.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
Lớp
NewsAgency(Subject) có các thuộc tính:headline.Interface
IChannel(Observer) có hàmupdate(string news).Cài đặt
TVChannelvàMobileApp.Khi
NewsAgencycậ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):
Interface:
ITrafficLightStatecó hàmhandle()vàgetNextState().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ủaGreenState.
Lớp chính:
TrafficLightgiữ con trỏ đến trạng thái hiện tại.Thực thi: Tạo một vòng lặp trong
main, mỗi lần lặp gọilight.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ệnhifnào.
Câu hỏi
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?).
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()haydelete).
Ví dụ thực tế: Cách dùng
std::lock_guardđể quản lý Mutex thay vì gọilock()và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_ptrkhông bao giờ giải phóng được bộ nhớ.
- Giải quyết vấn đề Circular Dependency (Vòng lặp tham chiếu) khiến
Các hàm Helper: Tại sao nên dùng
std::make_uniquevàstd::make_sharedthay vì dùngnew.
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ềnshared_ptr, và khi nào vẫn nên dùngT*hoặcT&để tối ưu hiệu năng.
- Khi nào truyền
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.
Cho mỗi lớp giữ một
shared_ptrtrỏ tới lớp kia.Kiểm tra xem Destructor của chúng có được gọi không (thường là không).
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:
Lớp
DatabaseConnection: Có hàmopen(),query(),close(). Trong Destructor phải tự động gọiclose()nếu kết nối còn mở.Lớp
QueryRunner:Chứa một
std::unique_ptr<DatabaseConnection>.Đảm bảo rằng khi
QueryRunnerra khỏi phạm vi (scope), kết nối DB sẽ được đóng ngay lập tức.
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
Chi phí bộ nhớ: Một
std::shared_ptrtố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?Atomic operation: Tại sao việc tăng/giảm biến đếm tham chiếu trong
shared_ptrlạ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
Ordercần biết vềCustomerđể gửi thông báo.Lớp
Customercần giữ danh sách cácOrderđể quản lý lịch sử.=> Đây chính là Circular Dependency. Nếu dùng
shared_ptrcho 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 fileOrder.hthay vì#include "Customer.h"để phá vỡ vòng lặp include.Sử dụng
std::weak_ptr: *Ordersở hữuCustomer(dùngshared_ptr).Customerchỉ tham chiếu đếnOrder(dùngweak_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
IShippingStrategyvà các lớp thực thi.Viết Interface
IObservercho 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ượngOrdervàCustomerthự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
Tính toán phí (Strategy):
AirShipping: 10$ / kg.GroundShipping: 2$ / kg.
Thông báo (Observer):
- Khi
Order.statusthay đổi thành "SHIPPED", tự động gửi tin nhắn choCustomervàInventorySystem.
- Khi
Quan hệ vòng lặp (Circular Dependency):
Class
Ordercó thuộc tínhshared_ptr<Customer>.Class
Customercóvector<weak_ptr<Order>> orders.Viết hàm
Customer::printHistory()để duyệt qua cácweak_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
Tại sao
Forward Declarationchỉ 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?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ụ trongCustomer::printHistory()? (Gợi ý: Hàmlock()).
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>và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ớ.
- Tại sao kích thước của một
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:
Cách 1: Duyệt theo hàng (
array[i][j]).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:
Khởi tạo 1 triệu số nguyên.
Lưu vào
std::vector<int>và sắp xếp nó.Lưu vào
std::set<int>(Dưới dạng cây nhị phân - các node rời rạc).Thực hiện tìm kiếm 10.000 giá trị ngẫu nhiên trên cả hai cấu trúc.
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ùngstd::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
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?
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ínha + 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.
- Tại sao không nên dùng
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:
Move Constructor.
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 độ
Tạo một
std::vector<SimpleString>.Dùng
push_back()với một đối tượng có tên (Lvalue).Dùng
push_back()vớistd::move()của đối tượng đó.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:
Lớp
BigData: Chứa một mảng độngint* datakích thước 10 triệu phần tử.Cài đặt Copy Constructor: Thực hiện vòng lặp 10 triệu lần để copy (rất chậm).
Cài đặt Move Constructor: Chỉ hoán đổi con trỏ (cực nhanh).
Hàm
BigData createBigData(): Tạo một đối tượng và trả về.Thực thi:
Trong
main, gọiBigData 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
b1gần như tức thời (0ms).
Câu hỏi
Tại sao sau khi gọi
std::move(obj), chúng ta không nên sử dụng lại biếnobjđó nữa? Trạng thái củaobjlúc đó như thế nào?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ềdefaultkeyword).
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ốicatchphù hợp và gọi Destructor cho các đối tượng trên Stack?
- Chuyện gì xảy ra khi một
Chi phí "Happy Path" (Khi không có lỗi):
- Tại sao code có
try-catchvẫn chạy nhanh gần như code thường nhờ cơ chế "Zero-cost exceptions" (Table-based approach).
- Tại sao code có
Chi phí khi xảy ra lỗi:
- Tại sao
throwlại rất đắt đỏ (đắt hơn hàng trăm lần so vớiif-elsehoặc trả về mã lỗi).
- Tại sao
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::terminatengay lập tức.Modern Error Handling: Khi nào dùng
std::optionalhoặcstd::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_backtù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).
Swap nội dung bản sao với this (dùng hàm swap không ném lỗi).
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:
Hàm
transfer(Account& from, Account& to, double amount).Yêu cầu Strong Guarantee:
Nếu đang trừ tiền ở
frommà 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 ở
tomà bị lỗi, tiền phải được trả lại chofrom.
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
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?
Trong C++, nếu một Exception thoát ra khỏi một
noexceptfunction, 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::threadtrong 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()vsdetach(): 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.
- Vấn đề an toàn khi truyền tham chiếu (
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()vàunlock().
- Cơ chế
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_guardvàstd::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).
- Khái niệm "Triết gia ăn tối" và cách tránh Deadlock bằng
Thread-Safe Class Design
Thiết kế Lớp an toàn đa luồng:
Cách tích hợp
std::mutexvào trong Class để bảo vệ các thuộc tínhprivate.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
BankAccounthỗ 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
Viết một lớp
Countercó biếnint value = 0.Tạo 2 luồng, mỗi luồng tăng
valuelên 100.000 lần.In kết quả cuối cùng (Bạn sẽ thấy nó nhỏ hơn 200.000).
Sửa lại dùng
std::mutexvà 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:
Lớp
Logger(Singleton): Chỉ có một instance duy nhất.Hàm
log(string message):Mở một file
log.txt.Ghi message kèm timestamp.
Đóng file.
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àmlog().Sử dụng
std::chronođể lấy thời gian chính xác đến mili giây.
Câu hỏi
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_mutextrong C++17).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_backlạ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()vsresize(): 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()vspush_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::setvsstd::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_searchthay 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
Viết vòng lặp
push_back1 triệu phần tử vàovector.Đo thời gian khi không dùng
reserve().Đ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
Chèn 100.000 cặp Key-Value ngẫu nhiên vào cả hai loại map.
Thực hiện tìm kiếm 10.000 khóa.
So sánh thời gian thực thi. Giải thích tại sao
unordered_mapnhanh 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):
Yêu cầu 1: Tìm kiếm khách hàng theo ID cực nhanh (Ưu tiên
std::unordered_map).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).Yêu cầu 3: Tối ưu bộ nhớ bằng cách sử dụng
std::vectorkết hợpstd::lower_boundcho các dữ liệu tĩnh không thay đổi.Thử thách: Viết một hàm nhận vào
std::string_viewthay 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
Tại sao việc sử dụng
operator[]trênstd::maplại nguy hiểm hơnstd::map::at()? (Gợi ý: Chuyện gì xảy ra nếu Key không tồn tại?).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 đề:
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.
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
deletevà các lỗiInvalid readkhi 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::listsangstd::vectorđã giảm bao nhiêu % Cache Miss.
Câu hỏi
Tại sao không nên bật cờ tối ưu hóa
O3khi đ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).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_ptrvàstd::shared_ptr. Không dùngnew/deletethủ 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 Locality và STL 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.mdhướ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:
Tính đóng gói (Encapsulation): Không có biến thành viên nào để
public.Tính đa hình (Polymorphism): Có ít nhất 1 Interface hoặc Abstract Class.
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).
Design Patterns: Áp dụng ít nhất 2 mẫu (ví dụ: Singleton cho Logger và Factory cho Book/Entity creation).
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 độ:
Dữ liệu lớn: Xử lý 100,000 cuốn sách lưu trong
std::vectornhưng được Index bởi mộtstd::unordered_mapđể tìm kiếm O(1).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.
Hệ thống Log (Observer Pattern): Mỗi khi có sách được mượn, các module
EmailServicevàInventoryServicesẽ nhận được thông báo để cập nhật.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.