Lặp lồng nhau và lỗi lệch một đơn vị
Một vòng lặp nằm trong một vòng lặp khác: thân trong chạy n nhân m lần, không phải n cộng m. Và chỉ cần lệch một đơn vị ở điều kiện dừng là thừa hoặc thiếu đúng một vòng.
TL;DR: Đặt một vòng lặp vào trong thân một vòng lặp khác thì thân trong chạy n nhân m lần, không phải n cộng m — vì mỗi vòng ngoài kéo theo trọn một lượt chạy hết vòng trong. Nhân chứ không cộng là chỗ trực giác đánh lừa mạnh nhất: ba nhân bốn ra mười hai, nghe thì rõ, nhưng đọc code thì phần lớn người mới đoán bảy. Cái bẫy thứ hai là lệch một đơn vị: đổi < thành <= ở điều kiện dừng làm vòng lặp chạy thừa đúng một vòng, và với vòng lồng thì một vòng thừa ở tầng ngoài kéo theo cả một lượt vòng trong.
Bạn cần in bảng cửu chương. Cần so từng học sinh với từng học sinh còn lại để tìm cặp trùng tên. Cần duyệt từng ô của một bảng có hàng và cột.
Tất cả đều là một việc: với mỗi thứ ở nhóm này, làm hết mọi thứ ở nhóm kia. Đó là vòng lặp lồng nhau, và bài này lo hai câu hỏi mà người mới hay trả lời sai: nó chạy bao nhiêu lần, và tại sao lại thừa thiếu đúng một vòng.
1. Analogy — mỗi bàn, mọi món
Bạn phục vụ trong quán ăn có 3 bàn, mỗi bàn gọi 4 món. Bạn không bưng 7 lần. Bạn đi tới bàn 1 và bưng đủ 4 món; xong xuôi mới sang bàn 2 và lại bưng đủ 4 món; rồi bàn 3. Tổng cộng 12 lượt bưng.
Điểm mấu chốt: khi sang bàn 2, bạn bắt đầu lại từ món thứ nhất, không phải bưng tiếp món thứ 5. Danh sách món được duyệt lại từ đầu ở mỗi bàn.
| Trong quán | Trong code |
|---|---|
| Đi qua từng bàn | Vòng lặp ngoài |
| Bưng từng món của bàn đó | Vòng lặp trong |
| Một lượt bưng một món | Một lần chạy thân trong |
| Sang bàn mới thì bắt đầu lại từ món 1 | Vòng trong chạy lại từ đầu mỗi vòng ngoài |
| 3 bàn, mỗi bàn 4 món, 12 lượt | 3 nhân 4 bằng 12 lần chạy thân trong |
2. Vì sao thân trong chạy n nhân m lần, không phải n cộng m?
bang = [1, 2, 3]
mon = [1, 2, 3, 4]
for b in bang:
for m in mon:
print(b, m)
Mười hai dòng output, không phải bảy. Mỗi ô trong lưới dưới đây là một lần thân trong chạy:

Đếm số ô, không đếm số nhãn. Ba nhãn hàng cộng bốn nhãn cột là bảy, nhưng bảy là số nhãn, còn thứ bạn cần là số ô.
Trực giác cộng đến từ chỗ ta quen đọc hai vòng lặp như hai việc nối tiếp nhau. Nhưng chúng không nối tiếp — cái này nằm trong cái kia, và đó là toàn bộ khác biệt:
# Hai vong NOI TIEP: 3 lan roi 4 lan, tong 7 lan
for b in bang:
print(b)
for m in mon:
print(m)
Đoạn trên thật sự chạy 7 lần. Chỉ cần thụt lề vòng thứ hai vào trong vòng thứ nhất là con số nhảy từ 7 lên 12, mà không dòng nào bị thêm hay bớt.
Thụt lề quyết định nhân hay cộng. Ngang hàng thì cộng, lồng vào trong thì nhân.
3. Vòng trong chạy lại từ đầu ở mỗi vòng ngoài
Biến lặp bị ghi đè mỗi vòng và mất sạch giá trị cũ. Với vòng lồng, biến lặp của vòng trong bị đặt lại từ phần tử đầu tiên ở mỗi lượt chạy của vòng ngoài — nó không nhớ mình đã đi tới đâu ở lượt trước.
Đây là chỗ dễ hiểu sai thứ hai. Với bang = [1, 2, 3] và mon = [1, 2], sáu cặp in ra theo đúng thứ tự này:
1 1
1 2
2 1
2 2
3 1
3 2
Cột phải quay về 1 ở mỗi lần cột trái đổi. Nếu vòng trong nhớ chỗ nó dừng lần trước thì sau 1 2 sẽ không còn gì để chạy nữa và toàn bộ phần sau biến mất.
Biến tích luỹ thì ngược lại — nó không bị đặt lại, miễn là bạn khởi tạo nó ở ngoài cả hai vòng:
bang = [1, 2, 3]
mon = [1, 2]
so_luot = 0
for b in bang:
for m in mon:
so_luot = so_luot + 1
print(so_luot)
Output thật:
6
Đặt so_luot = 0 vào giữa hai vòng for thì nó bị đặt lại ở mỗi vòng ngoài, và kết quả cuối chỉ còn 2 — số vòng của một lượt vòng trong. Cùng đúng cái bẫy vị trí dòng khởi tạo ở bài trước, chỉ thêm một tầng.
4. Lỗi lệch một đơn vị — thừa hoặc thiếu đúng một vòng
Vòng lặp luôn hỏi thêm một lần nữa sau vòng cuối cùng, và chính lần hỏi thất bại đó làm nó dừng. Lỗi lệch một đơn vị sống đúng ở ranh giới giữa lần hỏi cuối cùng thành công và lần hỏi đầu tiên thất bại.
Hai đoạn code dưới đây khác nhau đúng một ký tự:
dem = 1
while dem < 3:
print(dem)
dem = dem + 1
dem = 1
while dem <= 3:
print(dem)
dem = dem + 1
Đoạn đầu in 1 và 2. Đoạn sau in 1, 2 và 3. Một ký tự, một vòng chênh lệch.
Không có đoạn nào "đúng" hơn đoạn nào — đúng hay sai tuỳ việc bạn cần in tới 2 hay tới 3. Cái nguy hiểm là cả hai đều chạy êm, nên nếu bạn chọn nhầm thì không có dấu hiệu nào ngoài kết quả lệch.
Với vòng lồng, một vòng thừa ở tầng ngoài không tốn một lần chạy — nó tốn trọn một lượt vòng trong. Vòng ngoài 3 lên 4 với vòng trong 4 phần tử là 12 lần thành 16 lần, thừa bốn.
Đừng nhẩm với danh sách 100 phần tử. Thu bài toán về hai phần tử rồi đếm tay: [1, 2] lồng [1, 2] phải ra đúng bốn dòng. Lệch một đơn vị ở quy mô nhỏ lộ ra ngay lập tức, còn ở quy mô lớn thì bạn không bao giờ đếm nổi bằng mắt.
5. Thử đoán — đếm số lần chạy
hang = [1, 2, 3, 4]
cot = [1, 2, 3]
o = 0
for h in hang:
for c in cot:
o = o + 1
o = o + 1
print(o)
Chương trình in ra số mấy? Chú ý thật kỹ lề của hai dòng o = o + 1 — chúng không cùng một tầng. Viết ra giấy kèm một câu nói rõ dòng thứ hai chạy mấy lần.
Output thật:
16
Không phải 12. Hai dòng cộng nằm ở hai tầng khác nhau:
- Dòng đầu thụt sâu hơn, nằm trong thân vòng trong — chạy 4 nhân 3 bằng 12 lần.
- Dòng thứ hai thụt nông hơn, nằm trong thân vòng ngoài nhưng ngoài vòng trong — chạy đúng 4 lần, một lần cho mỗi vòng ngoài, sau khi vòng trong đã chạy hết.
Tổng: 12 cộng 4 bằng 16.
Đây là lý do lề trong Python không phải chuyện thẩm mỹ. Dịch dòng thứ hai vào sâu thêm bốn dấu cách là kết quả nhảy lên 24, và không có ký tự nào khác đổi.
6. Tới lượt bạn — tìm bug
Yêu cầu: đếm xem trong danh sách điểm có bao nhiêu cặp hai bạn khác nhau cùng điểm. Với diem = [7, 9, 7] thì có đúng một cặp: bạn thứ nhất và bạn thứ ba.
diem = [7, 9, 7]
cap_trung = 0
for a in diem:
for b in diem:
if a == b:
cap_trung = cap_trung + 1
print(cap_trung)7. Bẫy thường gặp
❌ Nhầm 1 — cộng thay vì nhân. Nhìn hai vòng lặp rồi nhẩm 3 cộng 4 bằng 7.
✅ Đếm ô, không đếm nhãn. Vẽ lưới nếu cần: số hàng nhân số cột.
❌ Nhầm 2 — đặt biến tích luỹ giữa hai vòng. Nó bị đặt lại ở mỗi vòng ngoài, và kết quả cuối chỉ phản ánh lượt vòng trong cuối cùng.
✅ Khởi tạo ngoài cả hai vòng nếu bạn muốn tổng của toàn bộ; đặt giữa hai vòng chỉ đúng khi bạn thật sự muốn một tổng riêng cho từng vòng ngoài.
❌ Nhầm 3 — chọn nhầm < hay <= rồi nhẩm bằng mắt. Ở vòng lồng, một vòng thừa tầng ngoài kéo theo trọn một lượt vòng trong.
✅ Thu về hai phần tử rồi đếm tay. Đây là kỹ thuật dùng được cho mọi vòng lặp, không riêng vòng lồng.
8. 📚 Đào sâu — vì sao lỗi lệch một đơn vị dai dẳng đến thế
Why numbering should start at zero (Edsger W. Dijkstra, EWD831, 11/08/1982) — một ghi chép viết tay hai trang, và là lời giải thích gọn nhất cho chuyện vì sao lỗi này không bao giờ hết.
Dijkstra xét bốn cách viết một dải số rồi lập luận rằng cách duy nhất không sinh phiền toái là chặn dưới lấy cả, chặn trên bỏ đi. Lý do ông đưa ra: nếu lấy cả chặn trên, thì khi dải co lại thành rỗng, con số ở chặn trên buộc phải là một số không tự nhiên. Kèm theo đó là hai tính chất khiến cách viết này dễ dùng — hiệu của hai chặn đúng bằng độ dài dải, và hai dải liền nhau thì chặn trên của dải này bằng chặn dưới của dải kia.
Đọc ghi chép này giải thích được điều bạn sẽ gặp suốt đời lập trình: gần như mọi ngôn ngữ đánh số phần tử từ 0 và dùng dải nửa mở, và cả hai lựa chọn đó đều đến từ đúng lập luận trên. Lỗi lệch một đơn vị dai dẳng vì nó nằm ở khớp giữa cách người đếm (từ 1, lấy cả hai đầu) và cách máy đếm.
9. Liên hệ các bài khác
- Bài 3 — Vòng lặp while — lần hỏi thất bại cuối cùng là nơi lỗi lệch một đơn vị sinh ra.
- Bài 4 — for và danh sách — bẫy vị trí dòng khởi tạo ở đó quay lại tại mục 3, chỉ thêm một tầng lề.
- Bài 2 — Điều kiện phức hợp — cùng họ "chạy êm nhưng sai": không lỗi, không cảnh báo, chỉ kết quả lệch.
- Bài 6 — Mini challenge — nơi bạn tự trace một vòng lồng, không có bảng điền sẵn.
10. Tóm tắt
- Ra 7 trong khi đáp án là 12? Soi lề trước tiên — lệch xa bất thường như vậy thường là dấu hiệu phép nhân bị đọc thành phép cộng.
- Thấy giá trị "nhảy về đầu" giữa hai lượt vòng ngoài? Không phải bug: vòng trong luôn chạy lại từ phần tử đầu, nó không nhớ lượt trước.
- Tổng chỉ bằng kết quả của một lượt vòng ngoài? Dòng khởi tạo đang nằm giữa hai
forthay vì ngoài cả hai. - Nghi số lần chạy lệch đúng một đơn vị? Kiểm
<hay<=ở điều kiện dừng trước tiên. - Không chắc thì đừng nhẩm với danh sách lớn: thu về hai phần tử, đếm tay, đối chiếu với con số bạn biết trước là đúng.
11. Tự kiểm tra
- Q1Vì sao hai vòng lặp lồng nhau cho ra n nhân m lần chạy, còn hai vòng đặt ngang hàng lại cho ra n cộng m? Khác biệt nằm ở đâu trong code?
- Q2Vòng trong có nhớ nó đã chạy tới đâu ở lượt trước không? Nếu có thì output của ví dụ ở mục 3 sẽ khác thế nào?
- Q3Trong đoạn ở mục 5, vì sao kết quả là 16 chứ không phải 12? Dòng
o = o + 1thứ hai chạy mấy lần? - Q4Đổi
while dem < 3thànhwhile dem <= 3làm thay đổi gì? Vì sao lỗi này khó phát hiện hơn lỗi cú pháp? - Q5Kỹ thuật thu bài toán về hai phần tử giúp bắt lỗi kiểu gì, và vì sao nó hiệu quả hơn đọc lại code?
- Q6Ở bài tìm bug mục 6, vì sao đoạn code đếm ra 5 thay vì 1? Nêu hai nguyên nhân tách bạch.
Bài tiếp theo: Mini challenge — trace một vòng lặp lồng
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên