Tổng hợp lời giải và phân tích chi tiết cho 75 câu hỏi CS Fundamentals
#1·Complexity
Cơ bản
Tra cứu một khoá trong hash table có độ phức tạp trung bình và xấu nhất là bao nhiêu?
A
Trung bình O(log n), xấu nhất O(n)
B
Trung bình O(n), xấu nhất O(n log n)
C
Trung bình O(1), xấu nhất O(1)
D
Trung bình O(1), xấu nhất O(n)
Đáp án đúng
Giải thích chi tiết & Đáp án đúng: D
Trung bình O(1), xấu nhất O(n). Hàm băm tốt phân bố khoá đều nên mỗi bucket chỉ giữ vài phần tử. Khi nhiều khoá va chạm về cùng bucket, thao tác suy biến thành duyệt tuyến tính danh sách trong bucket đó.
Hash table đổi bộ nhớ lấy tốc độ: hàm băm ánh xạ khoá thành chỉ số bucket, nên không cần so sánh khoá theo thứ tự như cây tìm kiếm. Chi phí trung bình O(1) chỉ đúng với giả định hàm băm phân bố đều và load factor được giữ thấp — hầu hết hiện thực rehash khi số phần tử vượt ngưỡng (ví dụ 0.75 lần số bucket).
Trường hợp xấu nhất O(n) xảy ra khi mọi khoá rơi vào cùng bucket. Đây không chỉ là tình huống lý thuyết: hash flooding là dạng tấn công cố ý gửi các khoá va chạm để đẩy server vào O(n) mỗi request. Nhiều runtime phản ứng bằng seed ngẫu nhiên cho hàm băm mỗi lần khởi động, hoặc chuyển bucket quá tải sang cây cân bằng — ví dụ Java HashMap dùng tree bin cho bucket lớn, giúp lookup thường gần O(log n) khi key có thể được phân thứ tự, nhưng cận tổng quát của hash table vẫn là O(n).
Khi phỏng vấn, điểm cần nói rõ là O(1) mang nghĩa "trung bình, có điều kiện" chứ không phải bảo đảm. Nếu bài toán cần cận trên chắc chắn hoặc cần duyệt khoá theo thứ tự, cấu trúc dựa trên cây với O(log n) ổn định thường là lựa chọn đúng hơn.
Khác biệt cơ bản về chi phí giữa mảng (array) và danh sách liên kết (linked list) là gì?
A
Mảng truy cập chỉ số O(1), chèn giữa O(n); linked list truy cập O(n), chèn O(1) nếu đã có vị trí
Đáp án đúng
B
Mảng dùng ít bộ nhớ hơn vì chỉ lưu con trỏ chứ không lưu trực tiếp dữ liệu
C
Cả hai đều O(1) cho truy cập, chỉ khác ở cách cấp phát bộ nhớ
D
Linked list nhanh hơn mảng ở mọi thao tác vì không phải cấp phát lại bộ nhớ
Giải thích chi tiết & Đáp án đúng: A
Mảng lưu liền kề nên truy cập theo chỉ số là O(1), nhưng chèn/xoá ở giữa phải dịch phần tử nên O(n). Linked list chèn/xoá chỉ cần nối lại con trỏ — O(1) khi đã cầm con trỏ tới vị trí — nhưng truy cập phần tử thứ k phải duyệt từ đầu nên O(n).
Khác biệt bắt nguồn từ cách bố trí bộ nhớ. Mảng chiếm một khối liền kề, nên địa chỉ phần tử thứ i tính trực tiếp bằng base + i × sizeof(T) — một phép nhân cộng, không phụ thuộc n. Linked list rải các nút khắp heap và nối bằng con trỏ, nên muốn tới phần tử thứ k phải đi qua k liên kết.
Điểm mà bảng độ phức tạp che mất là cache. CPU đọc bộ nhớ theo cache line 64 byte, nên duyệt mảng nạp sẵn nhiều phần tử kế tiếp trong một lần đọc; duyệt linked list thì mỗi nút là một lần nhảy tới địa chỉ khó đoán, gần như luôn cache miss. Trong thực tế, duyệt tuần tự mảng thường nhanh hơn linked list hàng chục lần dù cả hai đều là O(n). Vì lý do này, std::vector là mặc định được khuyến nghị trong C++ ngay cả cho khối lượng chèn/xoá vừa phải.
Linked list vẫn hợp lý ở vài chỗ cụ thể: khi cần chèn/xoá liên tục ở vị trí đã cầm sẵn con trỏ (danh sách LRU, free list của bộ cấp phát), khi phần tử lớn và việc copy khi dịch mảng tốn kém, hoặc khi cần giữ con trỏ/tham chiếu ổn định vì mảng có thể cấp phát lại và làm hỏng con trỏ cũ.
Stack dành cho dữ liệu chỉ đọc, heap dành cho dữ liệu ghi được
B
Stack nằm trong RAM còn heap được cấp phát trên ổ đĩa
C
Stack lưu biến toàn cục còn heap lưu biến cục bộ của hàm
D
Stack tự thu hồi khi hàm kết thúc; heap thì không
Đáp án đúng
Giải thích chi tiết & Đáp án đúng: D
Stack cấp phát theo khung lời gọi hàm: vào hàm thì đẩy khung, ra hàm thì thu hồi tự động, chi phí chỉ là dịch con trỏ. Heap cấp phát các khối rời rạc, vòng đời do lập trình viên hoặc garbage collector quyết định, chậm hơn và có thể phân mảnh. Biến cục bộ thường nằm ở stack, dữ liệu sống lâu hơn lời gọi hàm nằm ở heap.
Stack là cấu trúc LIFO gắn với luồng: mỗi lời gọi hàm đẩy một khung chứa tham số, biến cục bộ và địa chỉ trở về; hàm kết thúc thì con trỏ stack lùi lại và toàn bộ khung biến mất. Vì thế cấp phát chỉ tốn một phép trừ con trỏ, và dữ liệu luôn nóng trong cache. Đổi lại, kích thước stack cố định (thường 1–8 MB mỗi luồng) và vòng đời bị khoá cứng theo lời gọi — trả về con trỏ tới biến cục bộ là lỗi kinh điển.
Heap phục vụ dữ liệu có kích thước chưa biết lúc biên dịch hoặc phải sống lâu hơn hàm tạo ra nó. Bộ cấp phát phải tìm khối trống phù hợp, cập nhật sổ sách, và theo thời gian bộ nhớ bị phân mảnh. Ngôn ngữ có GC dời gánh nặng giải phóng sang runtime nhưng không xoá được chi phí: vẫn phải quét và (với GC nén) di chuyển đối tượng.
Ranh giới này không phải lúc nào cũng do lập trình viên quyết định. Compiler Go chạy escape analysis: biến cục bộ không "thoát" khỏi hàm sẽ được đặt trên stack dù có dùng new. JVM có scalar replacement với hiệu ứng tương tự. Trong JavaScript và Python, mọi object đều ở heap, chỉ giá trị primitive và tham chiếu nằm trên stack — chi tiết này giải thích vì sao gán một object cho biến khác là chia sẻ tham chiếu chứ không phải sao chép.
Process chạy song song thật còn thread chỉ luân phiên trên một lõi
B
Process có không gian địa chỉ riêng; thread cùng process dùng chung
Đáp án đúng
C
Thread do hệ điều hành quản lý, process do ngôn ngữ lập trình quản lý
D
Process nhẹ hơn thread nên tạo mới nhanh hơn nhiều
Giải thích chi tiết & Đáp án đúng: B
Process sở hữu không gian địa chỉ riêng, được hệ điều hành cô lập với nhau. Thread là đơn vị lập lịch bên trong một process: các thread dùng chung heap, mã và file descriptor, chỉ có stack và thanh ghi riêng. Hệ quả: thread trao đổi dữ liệu trực tiếp nhưng cần đồng bộ, process phải qua IPC nhưng lỗi ở process này không làm hỏng process kia.
Không gian địa chỉ là ranh giới sinh ra mọi khác biệt còn lại. Vì thread chia sẻ heap, truyền dữ liệu giữa chúng chỉ là truyền con trỏ — nhanh, nhưng mở ra race condition và buộc phải dùng lock hoặc cấu trúc bất biến. Process bị hệ điều hành cách ly nên phải dùng pipe, socket hay shared memory; chậm hơn vì tốn copy và chuyển ngữ cảnh vào kernel.
Chi phí cũng lệch rõ. Tạo process cần dựng bảng trang mới (Linux dùng copy-on-write để hoãn phần lớn công việc); tạo thread chỉ cần cấp một stack, thường 1–8 MB địa chỉ ảo. Chuyển ngữ cảnh giữa hai thread cùng process rẻ hơn giữa hai process vì không phải xả TLB.
Đánh đổi quyết định kiến trúc: Chrome chạy mỗi tab một process để một tab treo không kéo theo cả trình duyệt, chấp nhận tốn RAM. PostgreSQL chọn mô hình process cho mỗi kết nối vì độ bền, còn MySQL chọn thread cho nhẹ. Ở tầng ứng dụng, runtime hiện đại thường thêm một mức nữa — goroutine của Go hay virtual thread của Java 21 — là các luồng do runtime lập lịch, rẻ hơn thread hệ điều hành hàng trăm lần, và đó thường là câu hỏi tiếp theo trong buổi phỏng vấn.
Khi kết nối đi qua nhiều firewall vì UDP luôn được cho qua
B
Khi độ trễ quan trọng hơn tính toàn vẹn, ví dụ gọi thoại hay game thời gian thực
Đáp án đúng
C
Khi cần truyền file lớn vì UDP có thông lượng cao hơn
D
Khi cần mã hoá vì UDP hỗ trợ TLS còn TCP thì không
Giải thích chi tiết & Đáp án đúng: B
Khi độ trễ quan trọng hơn tính toàn vẹn tuyệt đối. TCP bảo đảm tin cậy và đúng thứ tự bằng cách truyền lại gói mất, nhưng việc đó khiến một gói mất chặn cả luồng phía sau. Với thoại, video trực tiếp hay game, dữ liệu tới trễ đã hết giá trị nên bỏ qua khung hình mất tốt hơn chờ nó.
TCP cung cấp luồng byte tin cậy, đúng thứ tự, có kiểm soát tắc nghẽn và kiểm soát luồng. Cái giá là bắt tay ba bước trước khi gửi được byte đầu tiên, và hiện tượng head-of-line blocking: gói số 5 mất thì gói 6, 7, 8 dù đã tới nơi vẫn phải nằm chờ trong buffer cho tới khi gói 5 được truyền lại.
UDP chỉ thêm cổng nguồn/đích và checksum lên trên IP — không bắt tay, không truyền lại, không bảo đảm thứ tự. Ứng dụng nhận đúng ranh giới datagram và tự quyết định phải làm gì với mất mát. Trong hội thoại thoại thời gian thực, mất 20 ms âm thanh là một tiếng lụp bụp gần như không nhận ra, trong khi chờ truyền lại tạo độ trễ nghe rõ.
QUIC là ví dụ đáng nêu trong phỏng vấn: nó chạy trên UDP nhưng tự hiện thực lại độ tin cậy ở tầng ứng dụng, theo từng stream độc lập, nên mất gói ở một stream không chặn các stream khác — đúng vấn đề head-of-line blocking mà HTTP/2 trên TCP không giải quyết được. HTTP/3 chính là HTTP trên QUIC. Bài học chung: chọn UDP không có nghĩa là bỏ độ tin cậy, mà là giành quyền tự quyết định độ tin cậy nào cần và ở mức nào.
Vì sao mật khẩu phải được băm (hash) chứ không phải mã hoá (encrypt) khi lưu vào database?
A
Vì băm nhanh hơn mã hoá nên quá trình đăng nhập phản hồi nhanh hơn
B
Vì luật bảo vệ dữ liệu cấm mã hoá thông tin cá nhân
C
Vì mã hoá đảo ngược được, lấy được khoá là khôi phục hết mật khẩu
Đáp án đúng
D
Vì băm cho kết quả độ dài cố định nên tiết kiệm dung lượng lưu trữ
Giải thích chi tiết & Đáp án đúng: C
Vì mã hoá có thể đảo ngược. Ai lấy được khoá — kẻ tấn công hay nhân viên nội bộ — sẽ khôi phục toàn bộ mật khẩu ở dạng rõ. Băm là một chiều: hệ thống chỉ cần băm lại mật khẩu người dùng nhập và so sánh, không bao giờ cần biết giá trị gốc. Phải dùng hàm băm chuyên cho mật khẩu (bcrypt, scrypt, Argon2) kèm salt.
Nguyên tắc là chỉ lưu thứ tối thiểu cần cho việc xác thực. Xác thực chỉ đòi hỏi trả lời "chuỗi này có khớp không", không đòi hỏi đọc lại mật khẩu. Lưu ở dạng khôi phục được tạo ra rủi ro không cần thiết: rò rỉ khoá biến toàn bộ database thành danh sách mật khẩu dùng được, và vì người dùng hay dùng lại mật khẩu, thiệt hại lan sang cả các dịch vụ khác.
Băm bằng SHA-256 vẫn chưa đủ. SHA được thiết kế để nhanh, mà GPU hiện nay tính hàng tỉ hash mỗi giây nên tấn công từ điển rất hiệu quả. Hàm băm mật khẩu đúng chuẩn cố tình tốn kém: bcrypt có tham số cost điều chỉnh số vòng, Argon2 còn thêm tham số bộ nhớ để vô hiệu hoá lợi thế song song của GPU và ASIC. OWASP hiện khuyến nghị Argon2id là lựa chọn đầu tiên, bcrypt là phương án thay thế chấp nhận được.
Salt là thành phần bắt buộc: một chuỗi ngẫu nhiên duy nhất cho mỗi người dùng, lưu kèm hash. Không có salt, hai người dùng cùng mật khẩu sẽ có cùng hash, và bảng tra sẵn (rainbow table) phá được hàng loạt trong một lượt. bcrypt và Argon2 sinh salt tự động và nhúng vào chuỗi kết quả nên không cần cột riêng. Cuối cùng, so sánh hash phải dùng hàm so sánh thời gian hằng số để tránh rò rỉ qua timing attack.
Điều gì xảy ra đầu tiên khi trình duyệt cần mở một tên miền chưa từng truy cập?
A
Gửi request HTTP GET tới server để lấy tài liệu HTML
B
Phân giải DNS để lấy địa chỉ IP tương ứng với tên miền
Đáp án đúng
C
Bắt tay TLS để thiết lập kênh mã hoá với server đích
D
Kiểm tra chứng chỉ của server trong kho tin cậy của hệ điều hành
Giải thích chi tiết & Đáp án đúng: B
Phân giải DNS. Mạng định tuyến theo địa chỉ IP chứ không theo tên miền, nên trình duyệt phải hỏi resolver để đổi tên miền thành IP trước. Sau đó mới lần lượt tới bắt tay TCP, bắt tay TLS, rồi gửi request HTTP.
Chuỗi đầy đủ khi mở một URL: tra cache DNS của trình duyệt và hệ điều hành, nếu không có thì hỏi resolver (thường của ISP hoặc 1.1.1.1/8.8.8.8), resolver đi từ root tới TLD rồi tới name server có thẩm quyền của tên miền; có IP rồi mới bắt tay TCP ba bước; với HTTPS thì bắt tay TLS; cuối cùng mới gửi request HTTP.
Mỗi bước là một vòng khứ hồi mạng, nên đây là nơi tối ưu độ trễ có hiệu quả rõ. dns-prefetch cho trình duyệt phân giải sớm tên miền của tài nguyên bên thứ ba; preconnect đi xa hơn, làm luôn TCP và TLS. TTL trong bản ghi DNS quyết định cache giữ bao lâu — đặt TTL thấp trước khi đổi hạ tầng để chuyển đổi nhanh, đặt cao lúc bình thường để giảm số lần tra.
Điểm hay bị hỏi tiếp: vì sao đổi bản ghi DNS mà một số người dùng vẫn vào server cũ. Nguyên nhân là cache nhiều tầng — trình duyệt, hệ điều hành, resolver của ISP — và không phải resolver nào cũng tôn trọng TTL. Đây cũng là lý do việc chuyển đổi hạ tầng nghiêm túc thường dùng load balancer đứng trước thay vì trông cậy vào việc đổi bản ghi DNS.
Đánh index cho một cột trong database đánh đổi điều gì?
A
Giảm dung lượng lưu trữ nhờ nén dữ liệu của cột được đánh index
B
Ghi nhanh hơn, nhưng truy vấn phức tạp trở nên chậm hơn đáng kể
C
Đọc nhanh hơn mà không đánh đổi gì, nên nên đánh index cho mọi cột
D
Đọc nhanh hơn, nhưng ghi chậm hơn và tốn thêm dung lượng lưu trữ
Đáp án đúng
Giải thích chi tiết & Đáp án đúng: D
Đọc nhanh, ghi chậm và tốn dung lượng. Index là một cấu trúc đã sắp xếp sẵn (thường là B-tree) giúp truy vấn tìm hàng mà không quét toàn bảng. Đổi lại, mỗi insert, update hay delete phải cập nhật cả bảng lẫn mọi index liên quan, và index chiếm thêm ổ đĩa lẫn cache.
Không có index, tìm một hàng phải quét tuần tự cả bảng — chi phí O(n) theo số hàng. Với B-tree index, chi phí xuống O(log n), nên trên bảng vài triệu hàng khác biệt là vài trăm mili giây so với vài mili giây. Đó là lý do mọi khoá ngoại và cột hay dùng trong WHERE, JOIN, ORDER BY đều nên được cân nhắc đánh index.
Chi phí phía ghi thường bị đánh giá thấp. Bảng có năm index nghĩa là mỗi INSERT thực chất là sáu lần ghi. Với hệ thống ghi nhiều (log, sự kiện, hàng đợi), đây là nguyên nhân thắt cổ chai rất hay gặp. Index không dùng tới còn tệ hơn vì chỉ có chi phí mà không có lợi ích — nên rà pg_stat_user_indexes để tìm index chưa từng được quét.
Ba chi tiết đáng nêu khi phỏng vấn. Thứ tự cột trong index tổ hợp có ý nghĩa: index (a, b) phục vụ được truy vấn lọc theo a hoặc theo cả a và b, nhưng không phục vụ truy vấn chỉ lọc theo b. Bọc cột trong hàm khiến index bị bỏ qua — WHERE lower(email) = ... cần index trên biểu thức lower(email). Và index chọn lọc kém, ví dụ cột chỉ có hai giá trị, thường không được planner dùng vì quét tuần tự còn rẻ hơn.
Nguyên tắc trách nhiệm đơn lẻ (Single Responsibility) phát biểu chính xác là gì?
A
Một class chỉ nên có một lý do để phải thay đổi
Đáp án đúng
B
Một class chỉ nên có đúng một phương thức public duy nhất
C
Một class không nên phụ thuộc vào quá một class khác trong hệ thống
D
Một hàm không nên dài quá hai mươi dòng mã lệnh
Giải thích chi tiết & Đáp án đúng: A
Một class chỉ nên có một lý do để thay đổi. Robert C. Martin sau đó diễn đạt rõ hơn: một module chỉ nên chịu trách nhiệm trước một nhóm người dùng hoặc một bên liên quan. Đây không phải quy tắc "class chỉ làm một việc" theo nghĩa đếm số phương thức.
Cách hiểu phổ biến nhưng sai là "mỗi class làm đúng một việc", dẫn tới việc tách class quá nhỏ và tạo ra hàng chục lớp một-phương-thức khó lần theo. Phát biểu đúng gắn với lý do thay đổi: nếu bộ phận kế toán yêu cầu đổi cách tính lương còn bộ phận nhân sự yêu cầu đổi cách xuất báo cáo, mà cả hai đều buộc phải sửa cùng một class, thì class đó đang gánh hai trách nhiệm.
Ví dụ kinh điển là class Employee vừa tính lương, vừa xuất báo cáo, vừa lưu vào database. Ba chức năng thay đổi vì ba lý do khác nhau và do ba bên khác nhau yêu cầu; nhập chúng vào một chỗ khiến mỗi lần sửa đều có nguy cơ phá hai chức năng còn lại. Tách theo lý do thay đổi cho ra ba thành phần với vòng đời độc lập.
Mặt trái cần cảnh giác: áp dụng quá đà tạo ra sự phân mảnh, nơi để hiểu một luồng nghiệp vụ phải mở bảy file. Chỉ tách khi đã quan sát được hai lý do thay đổi khác nhau trong thực tế, đừng tách dựa trên dự đoán. Đây cũng là điểm gặp nhau giữa nguyên tắc này và YAGNI — cả hai đều chống lại việc thiết kế cho tương lai tưởng tượng.
Stub dùng cho unit test còn mock chỉ dùng được trong integration test
B
Stub do thư viện sinh tự động còn mock phải viết tay hoàn toàn
C
Stub thay thế database còn mock thay thế các lời gọi qua mạng
D
Stub trả về dữ liệu định sẵn; mock còn kiểm tra xem nó có được gọi đúng cách không
Đáp án đúng
Giải thích chi tiết & Đáp án đúng: D
Stub cung cấp câu trả lời định sẵn để bài test chạy được — nó phục vụ đầu vào. Mock ngoài việc trả lời còn ghi nhận cách nó bị gọi và bài test sẽ khẳng định trên các tương tác đó. Nói gọn: stub dùng để kiểm tra trạng thái, mock dùng để kiểm tra hành vi.
Phân loại đầy đủ của Gerard Meszaros gồm năm loại test double. Dummy chỉ để lấp tham số và không bao giờ được dùng. Stub trả về giá trị cố định. Spy là stub có ghi lại lời gọi để kiểm tra sau. Mock được lập trình sẵn kỳ vọng và tự thất bại khi kỳ vọng không thoả. Fake là hiện thực đơn giản nhưng chạy thật, ví dụ repository lưu trong bộ nhớ.
Chọn loại nào quyết định bài test giòn hay bền. Khẳng định trên trạng thái — gọi hàm, kiểm tra kết quả — cho phép tái cấu trúc bên trong thoải mái. Khẳng định trên tương tác — kiểm tra rằng đã gọi repository.save đúng một lần với tham số này — trói bài test vào cách hiện thực, nên đổi cách làm dù kết quả vẫn đúng cũng khiến test đỏ.
Quy tắc thực dụng: mặc định dùng stub hoặc fake và khẳng định trên kết quả. Chỉ dùng mock khi bản thân tương tác mới là điều cần bảo đảm — ví dụ phải chắc rằng mail chỉ được gửi đúng một lần, hoặc bản ghi kiểm toán phải được tạo. Với repository, fake trong bộ nhớ thường cho bài test vừa nhanh vừa ít giòn hơn hẳn so với mock từng phương thức.
Trong REST, endpoint nào dưới đây đặt tên đúng quy ước?
A
GET /users/42/delete
B
POST /deleteUser?id=42
C
DELETE /users/42
Đáp án đúng
D
DELETE /getUserAndRemove/42
Giải thích chi tiết & Đáp án đúng: C
DELETE /users/42. Nguyên tắc cốt lõi của REST là đường dẫn mô tả tài nguyên bằng danh từ (thường ở số nhiều), còn hành động do HTTP method diễn đạt. Đặt động từ vào đường dẫn biến API thành RPC đội lốt REST và làm mất các bảo đảm ngữ nghĩa của method.
Bộ quy ước cơ bản: GET /users lấy danh sách, POST /users tạo mới, GET /users/42 lấy một, PUT /users/42 thay thế toàn bộ, PATCH /users/42 cập nhật một phần, DELETE /users/42 xoá. Quan hệ lồng nhau đi theo cùng nguyên tắc: GET /users/42/orders. Danh từ số nhiều được ưu tiên vì nó nhất quán cho cả tập lẫn phần tử.
Sai lầm đáng chú ý nhất là dùng GET cho thao tác thay đổi dữ liệu. GET được chuẩn quy định là an toàn, nên trình duyệt prefetch, proxy cache và crawler đều tự do gọi vào. Đã từng có hệ thống mất dữ liệu vì một crawler lần theo mọi liên kết dạng /delete. Mất tính an toàn cũng đồng nghĩa mất khả năng cache và khả năng thử lại.
Cần thẳng thắn về giới hạn của REST: một số thao tác không ánh xạ tự nhiên thành tài nguyên. Kích hoạt tài khoản, gửi lại email xác nhận, chạy một tác vụ nền — ép thành danh từ sẽ gượng ép. Thực tế được chấp nhận rộng rãi là mô hình hoá hành động thành tài nguyên khi hợp lý (POST /users/42/activation), và khi không hợp lý thì dùng thẳng một endpoint dạng hành động rồi ghi rõ trong tài liệu. Nhất quán trong toàn API quan trọng hơn việc tuân thủ tuyệt đối.
Nén dữ liệu để giảm kích thước trước khi gửi qua mạng
B
Tạo hash cố định để kiểm tra tính toàn vẹn của tập tin
C
Biểu diễn dữ liệu nhị phân bằng các ký tự văn bản an toàn để truyền
Đáp án đúng
D
Mã hoá dữ liệu để bên thứ ba không đọc được nội dung bên trong
Giải thích chi tiết & Đáp án đúng: C
Để biểu diễn dữ liệu nhị phân bằng ký tự văn bản, phục vụ các kênh chỉ truyền được văn bản như email, JSON hay URL. Base64 không phải mã hoá: không có khoá, ai cũng giải được, và nó còn làm dữ liệu phình thêm khoảng 33%.
Cách hoạt động: gom mỗi 3 byte (24 bit) rồi chia thành 4 nhóm 6 bit, mỗi nhóm ánh xạ thành một trong 64 ký tự A–Z, a–z, 0–9, cộng + và /. Vì 3 byte thành 4 ký tự nên kích thước tăng đúng 4/3, tức khoảng 33%. Ký tự = ở cuối chỉ là phần đệm khi số byte không chia hết cho 3.
Hiểu nhầm nguy hiểm nhất là coi Base64 như một lớp bảo vệ. Chuỗi dXNlcjpwYXNz trông như dữ liệu đã mã hoá nhưng giải ra là user:pass bằng một lệnh base64 -d. Đây chính là cơ chế của HTTP Basic Authentication, và cũng là lý do Basic Auth bắt buộc phải đi kèm HTTPS. Tương tự, phần payload của JWT là Base64url chứ không được mã hoá — đừng đặt dữ liệu nhạy cảm vào đó.
Biến thể Base64url thay + và / bằng - và _ để dùng an toàn trong URL và tên tập tin, đồng thời thường bỏ phần đệm. Về chi phí thực tế: nhúng ảnh dưới dạng data URI trong CSS hay HTML giúp bớt một request nhưng làm tăng 33% kích thước và mất khả năng cache riêng cho ảnh — chỉ nên dùng cho ảnh rất nhỏ như icon.
Hai vòng lặp lồng nhau, mỗi vòng chạy n lần, cho độ phức tạp thời gian là bao nhiêu?
A
O(n²)
Đáp án đúng
B
O(2n)
C
O(n)
D
O(n log n)
Giải thích chi tiết & Đáp án đúng: A
O(n²). Vòng ngoài chạy n lần, mỗi lần lại thực hiện trọn vẹn n bước của vòng trong, nên tổng số thao tác là n × n. Nếu hai vòng đặt nối tiếp thay vì lồng nhau thì chi phí chỉ là n + n, tức O(n).
Quy tắc nhận biết rất gọn: lồng nhau thì nhân, nối tiếp thì cộng. Nhân cho ra O(n²), cộng thì rút gọn về O(n) vì hằng số bị bỏ qua. Cùng logic đó, ba vòng lồng nhau cho O(n³).
Cần cẩn thận với vòng lặp lồng mà số vòng khác nhau. Vòng ngoài n lần, vòng trong m lần thì là O(n × m), không phải O(n²) — hai giá trị này khác hẳn nhau khi m nhỏ và cố định. Ngược lại, vòng trong chạy từ i tới n cho tổng n(n−1)/2 thao tác, vẫn là O(n²) vì hằng số 1/2 bị bỏ qua.
Chỗ hay bị bỏ sót là chi phí ẩn bên trong vòng lặp. Một vòng lặp đơn nhưng bên trong gọi arr.includes(x) thực chất là hai vòng lồng nhau, tức O(n²) dù nhìn chỉ thấy một for. Tương tự với việc nối chuỗi hay cắt mảng trong vòng lặp. Khi đọc mã để ước lượng chi phí, phải tính cả chi phí của những lời gọi trông như một thao tác đơn.
Stack chỉ chứa số nguyên còn queue chứa được mọi kiểu dữ liệu
B
Stack lưu trên vùng nhớ stack còn queue lưu trên vùng nhớ heap
C
Stack lấy ra phần tử vào sau cùng; queue lấy ra phần tử vào trước nhất
Đáp án đúng
D
Stack có kích thước cố định còn queue tự mở rộng khi cần thêm chỗ
Giải thích chi tiết & Đáp án đúng: C
Stack là LIFO — phần tử vào sau cùng ra trước, thao tác ở cùng một đầu. Queue là FIFO — phần tử vào trước ra trước, thêm ở một đầu và lấy ở đầu kia. Cả hai đều có thêm và lấy ở mức O(1); khác biệt nằm ở thứ tự lấy ra chứ không ở chi phí.
Cách chọn dựa trên bản chất bài toán. Stack hợp với những việc cần quay lui: lịch sử nút Back của trình duyệt, chức năng undo, kiểm tra dấu ngoặc cân bằng, duyệt cây theo chiều sâu, và chính call stack của chương trình. Queue hợp với những việc phải tôn trọng thứ tự tới: hàng đợi tác vụ, buffer request, duyệt cây theo chiều rộng, mô phỏng xếp hàng.
Một quan sát đáng nêu khi phỏng vấn: duyệt đồ thị theo chiều sâu và theo chiều rộng dùng cùng một thuật toán, chỉ khác ở việc dùng stack hay queue để giữ các đỉnh chờ xử lý. Đổi cấu trúc là đổi hoàn toàn thứ tự duyệt, còn phần mã còn lại giữ nguyên.
Về hiện thực, cả hai thường được dựng trên mảng động hoặc danh sách hai chiều. Với queue trên mảng, hiện thực ngây thơ dùng shift() để lấy phần tử đầu là O(n) vì phải dịch toàn bộ mảng; cách đúng là dùng mảng vòng hoặc deque. Trong JavaScript, mảng thường vẫn dùng làm stack tốt (push/pop đều O(1)) nhưng làm queue thì nên tránh shift() khi dữ liệu lớn.
Gọi một hàm bất đồng bộ (asynchronous) khác gọi hàm đồng bộ ở điểm nào?
A
Hàm bất đồng bộ trả về ngay một promise, kết quả tới sau qua callback
Đáp án đúng
B
Hàm bất đồng bộ không ném được lỗi nên phải kiểm tra giá trị trả về
C
Hàm bất đồng bộ chạy nhanh hơn vì được ưu tiên lập lịch cao hơn
D
Hàm bất đồng bộ luôn chạy trên một luồng riêng do runtime cấp phát
Giải thích chi tiết & Đáp án đúng: A
Hàm đồng bộ chặn luồng gọi tới khi có kết quả. Hàm bất đồng bộ trả về ngay một đối tượng đại diện cho kết quả tương lai (promise, future, task), luồng gọi chạy tiếp, và giá trị thật được giao sau khi tác vụ xong. Bất đồng bộ không làm tác vụ nhanh hơn, chỉ tránh lãng phí thời gian chờ.
Động lực của bất đồng bộ là chênh lệch tốc độ. Một lời gọi mạng mất khoảng 50 mili giây, trong khi CPU thực hiện được hàng trăm triệu lệnh trong khoảng thời gian đó. Chặn luồng để chờ là lãng phí đúng khoảng trống ấy. Bất đồng bộ cho phép luồng nhận việc khác và quay lại khi dữ liệu sẵn sàng.
Cần tách bạch bất đồng bộ với đa luồng — đây là chỗ hay lẫn. Bất đồng bộ nói về thời điểm nhận kết quả; đa luồng nói về việc có nhiều dòng thực thi cùng lúc. JavaScript bất đồng bộ nhưng chỉ một luồng. Ngược lại, một chương trình đa luồng hoàn toàn có thể chỉ toàn lời gọi đồng bộ chặn.
Sai lầm thường gặp khi mới dùng là await tuần tự những tác vụ vốn độc lập với nhau. Ba lời gọi API không phụ thuộc nhau mà await lần lượt thì tổng thời gian là tổng ba lời gọi; gom bằng Promise.all thì chỉ tốn bằng lời gọi chậm nhất. Sai lầm thứ hai là dùng bất đồng bộ cho công việc nặng CPU — nó không giúp gì, vì không có khoảng chờ nào để tận dụng; việc đó cần luồng hoặc tiến trình riêng.