Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 6.417 phút đọc

Vòng lặp lồng nhau

Sau bài này bạn sẽ làm được

  • Xác định số lần chạy của thân vòng lặp lồng nhau
  • Vẽ được các hình tam giác sao bằng vòng lặp lồng
  • Giải thích vì sao lồng hai tầng cho độ phức tạp bình phương
  • Giải thích vì sao duyệt theo hàng nhanh hơn duyệt theo cột

Đặt một vòng lặp bên trong vòng lặp khác là cách xử lý dữ liệu hai chiều: bảng, ma trận, hình vẽ. Nhưng số lần chạy nhân lên chứ không cộng lại, và với dữ liệu lớn thì cả thứ tự duyệt cũng ảnh hưởng tới tốc độ tới mức bất ngờ.

#Cơ chế

for (int i = 0; i < 3; ++i) {        /* vòng ngoài: HÀNG */
    for (int j = 0; j < 4; ++j) {    /* vòng trong: CỘT  */
        printf("%d,%d ", i, j);
    }

    printf("\n");
}
terminal
./lonh
0,0 0,1 0,2 0,3
1,0 1,1 1,2 1,3
2,0 2,1 2,2 2,3

Điểm mấu chốt: mỗi lần vòng ngoài chạy một lượt thì vòng trong chạy trọn vẹn từ đầu tới cuối. Biến j được khởi tạo lại về 0 ở mỗi lượt của i.

Ba lượt của vòng ngoài nhân với bốn lượt của vòng trong cho ra mười hai lần chạy thân.

#Vẽ hình bằng vòng lặp

Bài tập vẽ hình bằng dấu sao có vẻ nhàm chán, nhưng nó rèn đúng kỹ năng cần thiết: xác định quan hệ giữa số hàng và số cột.

Tam giác vuông tăng dần

for (int i = 1; i <= 5; ++i) {
    for (int j = 1; j <= i; ++j) {   /* số cột PHỤ THUỘC hàng */
        putchar('*');
    }

    putchar('\n');
}
*
**
***
****
*****

Tam giác cân

int n = 5;

for (int i = 1; i <= n; ++i) {
    for (int k = 0; k < n - i; ++k) {      /* khoảng trắng canh giữa */
        putchar(' ');
    }

    for (int j = 0; j < 2 * i - 1; ++j) {  /* số sao lẻ: 1, 3, 5, 7, 9 */
        putchar('*');
    }

    putchar('\n');
}
    *
   ***
  *****
 *******
*********

#Độ phức tạp nhân lên

Đây là chỗ vòng lặp lồng nhau trở nên nguy hiểm. Thêm một tầng lồng là nhân số phép tính lên, không phải cộng thêm.

Số tầngĐộ phức tạpn = 1 000n = 100 000
1O(n)10³ phép10⁵ phép
2O(n²)10⁶ phép10¹⁰ phép
3O(n³)10⁹ phép10¹⁵ phép

Máy tính hiện đại làm khoảng 10⁸ tới 10⁹ phép tính mỗi giây. Nhìn bảng trên: với n bằng 100 000, hai tầng lồng nhau cần khoảng 10¹⁰ phép, tức là vài chục giây tới vài phút. Ba tầng thì phải tính bằng ngày.

Vòng trong ngắn dần vẫn là bậc hai

for (int i = 0; i < n; ++i) {
    for (int j = i; j < n; ++j) {    /* vòng trong ngắn dần */
        lam_gi_do();
    }
}

/* Tổng số lượt: n + (n-1) + (n-2) + ... + 1 = n(n+1)/2
   Vẫn là O(n²), chỉ nhanh hơn đúng hai lần. */

Bỏ hằng số là quy tắc của Big-O, nên nhanh hơn hai lần không đổi được bậc. Với n lớn thì hai lần không cứu được gì.

#Thứ tự duyệt quyết định tốc độ

Đây là phần thú vị nhất của bài. Hai đoạn mã dưới đây làm đúng cùng một số phép tính, nhưng chênh nhau nhiều lần về thời gian chạy.

Duyệt theo cột: chậm
for (int j = 0; j < N; ++j) {
    for (int i = 0; i < N; ++i) {
        tong += a[i][j];
    }
}
Duyệt theo hàng: nhanh
for (int i = 0; i < N; ++i) {
    for (int j = 0; j < N; ++j) {
        tong += a[i][j];
    }
}
Mảng hai chiều nằm liên tiếp theo hàng. Duyệt theo hàng đọc liền mạch, duyệt theo cột nhảy quãng.

Tự đo trên máy của bạn

cache.c
#include <stdio.h>
#include <time.h>

#define N 4096

static int a[N][N];

int main(void)
{
    long long s = 0;
    clock_t   t0;

    t0 = clock();

    for (int i = 0; i < N; ++i) {
        for (int j = 0; j < N; ++j) {
            s += a[i][j];
        }
    }

    double theo_hang = (double)(clock() - t0) / CLOCKS_PER_SEC;

    t0 = clock();

    for (int j = 0; j < N; ++j) {
        for (int i = 0; i < N; ++i) {
            s += a[i][j];
        }
    }

    double theo_cot = (double)(clock() - t0) / CLOCKS_PER_SEC;

    printf("theo hàng: %.3f giây\n", theo_hang);
    printf("theo cột : %.3f giây\n", theo_cot);
    printf("chậm hơn %.1f lần (s = %lld)\n", theo_cot / theo_hang, s);

    return 0;
}
terminal
# Dùng -O1 để trình biên dịch không tối ưu bay mất vòng lặp
gcc -std=c17 -O1 cache.c -o cache && ./cache
theo hàng: 0.042 giây
theo cột : 0.310 giây
chậm hơn 7.4 lần (s = 0)

Con số cụ thể phụ thuộc máy, nhưng chênh lệch năm tới mười lần là bình thường. Hãy nhớ: cùng số phép tính, cùng thuật toán, chỉ khác thứ tự hai dòng for.

Tự làm thử

  1. In bảng cửu chương đầy đủ từ 1 tới 9 dạng lưới 9 hàng 9 cột, căn thẳng cột.
  2. Vẽ đủ sáu dạng tam giác sao: vuông tăng, vuông giảm, vuông lệch phải, tam giác cân, tam giác ngược, và hình thoi.
  3. In tam giác Pascal 10 hàng, canh giữa.
  4. Đếm số cặp phần tử trong mảng có tổng bằng một giá trị cho trước, dùng hai vòng lồng nhau. Đo thời gian với n bằng 1 000, 5 000 và 10 000, kiểm chứng thời gian tăng theo bình phương.
  5. Chạy chương trình cache.c ở trên và ghi lại tỉ lệ chênh lệch trên máy bạn.
  6. Viết chương trình tìm mọi bộ ba số nguyên dương nhỏ hơn 100 thỏa định lý Pythagoras. Ước lượng số lượt lặp trước khi chạy.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Mỗi lượt của vòng ngoài kéo theo vòng trong chạy trọn vẹn từ đầu tới cuối.
  • Tổng số lần chạy thân là tích số lượt của các tầng, không phải tổng.
  • Lồng hai tầng cho O(n²), ba tầng cho O(n³). Với n lớn thì chênh lệch là hàng giờ.
  • Vòng trong ngắn dần vẫn là O(n²), chỉ nhanh hơn đúng hai lần.
  • Mảng hai chiều trong C lưu liên tiếp theo hàng, nên duyệt theo hàng nhanh hơn nhiều.
  • Quy tắc thực hành: với a[i][j] thì j phải là vòng lặp trong cùng.