Nhập môn Tư duy Lập trình/Lặp lồng nhau và lỗi lệch một đơn vị
13/31
Bài 13 / 31~12 phútRẽ nhánh & lặpMiễn phí lượt xem

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ánTrong code
Đi qua từng bànVò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ónMột lần chạy thân trong
Sang bàn mới thì bắt đầu lại từ món 1Vò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ượt3 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:

Lưới 3 hàng 4 cột, mười hai ô; nhãn bên trái ghi b bằng 1, b bằng 2, b bằng 3 cho ba hàng; mỗi ô ghi cặp giá trị b và m tương ứng, ô cuối cùng ở góc dưới bên phải b bằng 3 m bằng 4 được tô nhấn; chú thích trên ghi vòng ngoài có 3 giá trị của b và vòng trong có 4 giá trị của m, chú thích dưới ghi 3 nhân 4 bằng 12 ô nên thân trong chạy đúng 12 lần chứ không phải 3 cộng 4 bằng 7

Đế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.

💡 Cách nhớ

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]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.

Cách tự kiểm rẻ nhất

Đừ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)
🖊️ Thử đoán

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.

Kết quả

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.

🐛 Tìm bug trong code này
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ế

📚 Đào sâu (không bắt buộc)

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

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 for thay 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

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Vì 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?
  2. Q2
    Vò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?
  3. Q3
    Trong đoạn ở mục 5, vì sao kết quả là 16 chứ không phải 12? Dòng o = o + 1 thứ hai chạy mấy lần?
  4. Q4
    Đổi while dem < 3 thành while dem <= 3 làm thay đổi gì? Vì sao lỗi này khó phát hiện hơn lỗi cú pháp?
  5. Q5
    Kỹ 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?
  6. 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

Đặt 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

Bài tiếp theo

Mini challenge — trace một vòng lặp lồng