Suốt 11 bài trước, mỗi bài đều có 1 sandbox tương tác riêng — cây AVL, đồ thị Dijkstra, mảng sắp xếp, cây Huffman... Bài cuối này lùi lại một bước và đặt câu hỏi khác: những sandbox đó có điểm chung gì? Câu trả lời là có — rất nhiều. Và khi tách phần chung ra thành 1 framework tối giản, việc dựng visualizer cho một thuật toán hoàn toàn mới chỉ còn tốn vài chục dòng code, thay vì viết lại từ đầu.

1. Vấn Đề: Mỗi Sandbox Đều Tự Viết Lại Cùng Một Thứ

Nhìn lại 11 sandbox đã xây (từ Bài 1 tới Bài 11), chúng đều có chung 3 thành phần, dù thuật toán bên trong khác nhau hoàn toàn:

  • Điều khiển bước: chạy thuật toán từng bước một, có nút play/pause, tốc độ tuỳ chỉnh — không "nhảy" thẳng tới kết quả cuối.
  • Vẽ trạng thái: mỗi bước cần vẽ lại node/cạnh (cây, đồ thị) hoặc ô/thanh (mảng, bảng băm) lên canvas.
  • Log giải thích: một dòng mô tả tiếng Việt đi kèm mỗi bước, luôn đồng bộ với hình vẽ đang hiển thị.

Nếu tách 3 phần này thành 1 engine dùng chung, phần còn lại riêng cho từng thuật toán chỉ là: sinh ra danh sách các bướcvẽ 1 bước cụ thể trông như thế nào.

2. Framework Điều Khiển Bước: Biến Thuật Toán Thành Danh Sách "Step"

Công cụ tự nhiên nhất trong JavaScript để "tạm dừng" một thuật toán giữa chừng và lấy ra từng trạng thái là generator function (function* / yield). Mỗi lần yield, thuật toán "đóng băng" và trả về đúng 1 khung trạng thái — giống hệt 1 khung hình của video:

step-engine.js
// Bất kỳ thuật toán nào cũng có thể viết thành 1 generator sinh step
function* bubbleSortSteps(input) {
  const arr = input.slice();
  for (let i = 0; i < arr.length - 1; i++) {
    for (let j = 0; j < arr.length - 1 - i; j++) {
      yield { array: arr.slice(), compare: [j, j + 1], desc: `So sánh a[${j}] và a[${j + 1}]` };
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        yield { array: arr.slice(), swap: [j, j + 1], desc: `Hoán đổi vì a[${j}] > a[${j + 1}]` };
      }
    }
  }
}

// Engine dùng chung: chạy generator tới hết, LƯU LẠI toàn bộ step vào mảng
function collectSteps(generator) {
  return [...generator]; // materialize toàn bộ — không lazy nữa
}
💡 Mẹo: Vì sao vật chất hoá (materialize) hết step thay vì lazy?
Nếu chỉ giữ generator và gọi .next() mỗi lần tiến 1 bước, muốn lùi lại 1 bước sẽ phải chạy lại thuật toán từ đầu tới bước đó — tốn CPU và phức tạp. Vì hầu hết bài toán minh hoạ trong series có kích thước nhỏ (vài chục node/phần tử), vật chất hoá toàn bộ mảng step vào bộ nhớ ngay từ đầu ([...generator]) cho phép tua tới/lùi bất kỳ bước nào tức thì chỉ bằng cách đổi currentIndex — đánh đổi một chút bộ nhớ để lấy trải nghiệm điều khiển mượt hơn nhiều.

3. Lớp Vẽ Node/Cạnh Tái Sử Dụng

Phần vẽ cũng tách thành các hàm nguyên tử nhỏ, dùng chung cho mọi thuật toán loại "đồ thị/cây" (node + cạnh) hoặc loại "mảng/bảng" (ô/thanh) — thuật toán mới chỉ cần gọi lại đúng những hàm này với dữ liệu khác:

draw-primitives.js
// Dùng lại cho MỌI thuật toán dạng đồ thị/cây: AVL, Dijkstra, Trie, B-Tree, Huffman...
function drawNode(ctx, x, y, label, color) {
  ctx.beginPath();
  ctx.arc(x, y, 16, 0, Math.PI * 2);
  ctx.fillStyle = color;
  ctx.fill();
  ctx.fillStyle = '#fff';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(label, x, y);
}
function drawEdge(ctx, x1, y1, x2, y2) {
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.lineTo(x2, y2);
  ctx.stroke();
}

// Dùng lại cho MỌI thuật toán dạng mảng: Sorting, DP, Memory Allocator, Hash Table...
function drawBar(ctx, x, y, w, h, label, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = '#fff';
  ctx.textAlign = 'center';
  ctx.fillText(label, x + w / 2, y + h / 2);
}

4. Đồng Bộ Log Giải Thích Với Từng Bước

Vì mỗi step đã mang sẵn 1 chuỗi desc, việc "đồng bộ" chỉ đơn giản là: 1 hàm renderStep(index) duy nhất vừa vẽ lại canvas theo steps[index], vừa cập nhật log hiển thị đúng mô tả của step đó — không có 2 luồng cập nhật riêng biệt nên không bao giờ bị lệch pha:

render-step.js
let steps = [];
let currentIndex = 0;

function renderStep(index) {
  currentIndex = Math.max(0, Math.min(index, steps.length - 1));
  const step = steps[currentIndex];
  draw(step); // hàm vẽ RIÊNG cho từng thuật toán (dùng drawNode/drawBar ở trên)
  logEl.textContent = step.desc; // luôn khớp 100% với những gì vừa vẽ
  progressEl.textContent = `Bước ${currentIndex + 1} / ${steps.length}`;
}
🕳️ Cạm bẫy thường gặp: Vẽ và log lệch pha khi dùng setTimeout tự do
Một lỗi phổ biến khi tự viết animation: gọi draw() và cập nhật log ở 2 hàm callback khác nhau, mỗi hàm có setTimeout riêng. Nếu tốc độ người dùng chọn thay đổi giữa chừng, 2 timer có thể trôi lệch nhau — hình vẽ đang ở bước 5 nhưng log lại hiện mô tả bước 4. Gộp cả vẽ và log vào cùng 1 hàm nhận currentIndex làm nguồn sự thật duy nhất (single source of truth) loại bỏ hoàn toàn khả năng lệch pha này.

5. Chạy Thử: Cùng 1 Engine, 2 Thuật Toán Khác Hẳn Nhau

Sân chơi bên dưới dùng đúng 1 engine (play/pause/lùi/tiến/tốc độ/log) cho cả Bubble Sort (dữ liệu dạng mảng, vẽ bằng drawBar) lẫn BFS trên đồ thị (dữ liệu dạng node/cạnh, vẽ bằng drawNode/drawEdge) — chỉ khác nhau ở hàm sinh step và hàm vẽ, phần điều khiển hoàn toàn giống hệt nhau.

🎮 Sân chơi tương tác: Algorithm Playground

Chọn thuật toán

Bước 0 / 0

Nhật ký (đồng bộ với hình vẽ)

Đổi thuật toán để thấy CÙNG 1 bộ điều khiển chạy 2 loại dữ liệu khác nhau.

playground-live.js

6. Tổng Kết Series: Nhìn Lại 11 Bài Đã Đi Qua

Mỗi bài trong series đều có thể mô tả lại vừa đúng bằng framework 3 phần ở trên — chỉ khác nhau ở cách sinh step và cách vẽ:

Bài Chủ đề Ý chính
01 AVL & Red-Black Tree Xoay cây để giữ cân bằng chiều cao O(log n)
02 Dijkstra & A* Lan vùng theo trọng số, heuristic định hướng tìm kiếm
03 Sorting Visualizer So sánh trực quan độ phức tạp các thuật toán sắp xếp
04 Trie Cây tiền tố cho tra cứu/gợi ý từ theo từng ký tự
05 Union-Find Path compression & union by rank gần như O(1)
06 Segment Tree / Fenwick Tree Truy vấn khoảng & cập nhật điểm trong O(log n)
07 Quy Hoạch Động Memoization/tabulation cho bài toán con chồng lấp
08 B-Tree Database Index Cây bậc cao tối ưu số lần đọc/ghi ổ đĩa
09 Memory Allocator Free list, First/Best/Worst-Fit, coalescing
10 Hash Table & Va Chạm Chaining vs Open Addressing, tombstone, rehashing
11 Huffman Data Compression Mã tiền tố tối ưu theo tần suất bằng thuật toán tham lam
🔬 Đào sâu: Framework này áp dụng được cho cả 11 bài trên không?
Về mặt cấu trúc — có. AVL cần drawNode/drawEdge cho cây, Sorting cần drawBar cho mảng, Hash Table cần cả hai (bucket dạng cột + chain dạng node nối tiếp). Khác biệt thực tế giữa các sandbox trong series chủ yếu nằm ở tương tác trực tiếp (click để xoá 1 khối nhớ, click để chèn 1 đỉnh đồ thị...) — phần này khó tổng quát hoá tuyệt đối vì mỗi thao tác người dùng mang ý nghĩa khác nhau tuỳ thuật toán. Framework "điều khiển bước" phù hợp nhất khi minh hoạ một lần chạy trọn vẹn của thuật toán (như 2 demo phía trên); các sandbox có tương tác click-để-thao-tác tuỳ ý (Bài 1, 8, 9, 10) vẫn cần thêm phần xử lý sự kiện chuột riêng bên ngoài engine step.

Trắc nghiệm ôn tập

Câu 1: Vì sao generator function (function*/yield) phù hợp tự nhiên để biến 1 thuật toán thành danh sách các "step"?

Trắc nghiệm ôn tập

Câu 2: Vì sao vật chất hoá (materialize) toàn bộ step vào 1 mảng lại giúp "lùi bước" (step back) gần như miễn phí?

Trắc nghiệm ôn tập

Câu 3: Lợi ích chính của việc gộp vẽ canvas và cập nhật log vào chung 1 hàm renderStep(index) là gì?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 11: Huffman Data Compression 🎉 Hoàn thành Series — Quay lại Lộ trình DSA Series JS: Iterators, Generators & Bất Đồng Bộ