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

Chuyển vị

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

  • Cài chuyển vị ma trận chữ nhật
  • Cài chuyển vị tại chỗ cho ma trận vuông
  • Giải thích vì sao chỉ duyệt nửa trên đường chéo
  • Dùng chuyển vị để tăng tốc phép nhân ma trận

Chuyển vị đổi vai trò hàng và cột. Phép toán này đơn giản tới mức chỉ cần một dòng lệnh trong vòng lặp, nhưng nó chứa một chi tiết mà rất nhiều người viết sai: nếu duyệt cả ma trận thì mỗi cặp bị đổi chỗ hai lần và kết quả trở về như cũ.

#Chuyển vị là gì

Ma trận chuyển vị
Ma trận thu được bằng cách đổi hàng thành cột. Phần tử ở hàng i cột j của ma trận gốc trở thành phần tử ở hàng j cột i của kết quả.
At[j][i] = A[i][j]

A (2 x 3)          At (3 x 2)
| 1  2  3 |        | 1  4 |
| 4  5  6 |        | 2  5 |
                   | 3  6 |

#Chuyển vị ra mảng mới

chuyen-vi.c
/* A là m x n, At là n x m. Hai vùng không được trùng nhau. */
void chuyen_vi(const int *A, int *At, size_t m, size_t n)
{
    for (size_t i = 0; i < m; ++i)
        for (size_t j = 0; j < n; ++j)
            At[j * m + i] = A[i * n + j];
}

Chú ý hai công thức chỉ số khác nhau. Ma trận gốc có n cột nên chỉ số là i * n + j. Ma trận kết quả có m cột nên chỉ số là j * m + i. Nhầm hai con số này là lỗi phổ biến nhất của bài này, và với ma trận vuông thì lỗi đó không lộ ra.

terminal
./chuyen-vi
A (2 x 3):
     1     2     3
     4     5     6

At (3 x 2):
     1     4
     2     5
     3     6

Thứ tự duyệt: đọc liền mạch hay ghi liền mạch

Chuyển vị không thể làm cả hai mảng cùng liền mạch. Nếu bạn duyệt theo hàng của A thì việc ghi vào At nhảy quãng, và ngược lại. Với ma trận lớn, cả hai cách đều chậm.

/* Chuyển vị theo khối: chia thành các ô vuông vừa bộ nhớ đệm.
   Nhanh hơn bản ngây thơ vài lần với ma trận lớn. */
enum { KHOI = 32 };

void chuyen_vi_khoi(const int *A, int *At, size_t m, size_t n)
{
    for (size_t i0 = 0; i0 < m; i0 += KHOI)
        for (size_t j0 = 0; j0 < n; j0 += KHOI) {
            size_t i_het = i0 + KHOI < m ? i0 + KHOI : m;
            size_t j_het = j0 + KHOI < n ? j0 + KHOI : n;

            for (size_t i = i0; i < i_het; ++i)
                for (size_t j = j0; j < j_het; ++j)
                    At[j * m + i] = A[i * n + j];
        }
}
terminal
# Ma trận 8192 nhân 8192 kiểu int, tức 256 MB
./do-chuyen-vi
ngay tho : 1.842 s
theo khoi: 0.393 s
ti le    : 4.7 lan

#Chuyển vị tại chỗ

Chỉ ma trận vuông mới chuyển vị tại chỗ được, vì kích thước phải giữ nguyên. Ý tưởng là đổi chỗ từng cặp đối xứng qua đường chéo chính.

Duyệt cả ma trận
void chuyen_vi_tai_cho(int *A, size_t n)
{
    for (size_t i = 0; i < n; ++i)
        for (size_t j = 0; j < n; ++j) {
            int t = A[i * n + j];

            A[i * n + j] = A[j * n + i];
            A[j * n + i] = t;
        }
}

/* Mỗi cặp bị đổi chỗ HAI lần, nên ma trận trở về đúng như ban đầu. */
Chỉ duyệt nửa trên đường chéo
void chuyen_vi_tai_cho(int *A, size_t n)
{
    for (size_t i = 0; i < n; ++i)
        for (size_t j = i + 1; j < n; ++j) {
            int t = A[i * n + j];

            A[i * n + j] = A[j * n + i];
            A[j * n + i] = t;
        }
}

/* j bắt đầu từ i + 1 nên mỗi cặp chỉ gặp đúng một lần. */
terminal
./tai-cho
Ban dau:
     1     2     3
     4     5     6
     7     8     9

Sau khi duyet ca ma tran:
     1     2     3
     4     5     6
     7     8     9

Sau khi duyet nua tren:
     1     4     7
     2     5     8
     3     6     9

#Vì sao chỉ duyệt nửa trên

Hãy đếm. Với ma trận ba nhân ba, phiên bản duyệt cả ma trận gặp cặp (0,1) khi i bằng 0 và j bằng 1, rồi gặp lại cặp đó khi i bằng 1 và j bằng 0. Hai lần đổi chỗ liên tiếp của cùng một cặp trả mọi thứ về vị trí ban đầu.

ijCặp bị đổiGhi chú
00(0,0) với (0,0)Đổi chỗ với chính nó, vô ích
01(0,1) với (1,0)Lần thứ nhất
02(0,2) với (2,0)Lần thứ nhất
10(1,0) với (0,1)Lần thứ hai, hoàn tác
11(1,1) với (1,1)Vô ích
...

Số phép đổi chỗ

nSố cặp cần đổiCông thức
333 * 2 / 2
464 * 3 / 2
104510 * 9 / 2
nn(n-1)/2Số cặp không tính đường chéo

Đúng một nửa số ô nằm ngoài đường chéo, và số ô trên đường chéo là n. Chuyển vị vẫn có độ phức tạp , nhưng hằng số nhỏ hơn một nửa so với cách duyệt sai.

#Ứng dụng và tính chất

Tăng tốc phép nhân ma trận

/* Chuyển vị B trước rồi nhân: cả hai mảng đều đọc liền mạch. */
chuyen_vi(B, Bt, n, n);

for (size_t i = 0; i < n; ++i)
    for (size_t j = 0; j < n; ++j) {
        double s = 0.0;

        for (size_t k = 0; k < n; ++k)
            s += A[i * n + k] * Bt[j * n + k];

        C[i * n + j] = s;
    }

Chi phí chuyển vị là , nhỏ hơn hẳn của phép nhân, nên đây gần như là bữa trưa miễn phí. Bài 10.4 đã nói về chuyện này.

Kiểm tra ma trận đối xứng

/* Ma trận đối xứng là ma trận bằng chính chuyển vị của nó. */
int la_doi_xung(const int *A, size_t n)
{
    for (size_t i = 0; i < n; ++i)
        for (size_t j = i + 1; j < n; ++j)      /* lại là nửa trên */
            if (A[i * n + j] != A[j * n + i])
                return 0;

    return 1;
}

Không cần tạo ma trận chuyển vị rồi so sánh. Chỉ cần kiểm tra từng cặp đối xứng, và cũng chỉ cần duyệt nửa trên vì cặp (i,j) (j,i) là cùng một phép kiểm tra.

Tính chấtCông thức
Chuyển vị hai lần trả về chính nó(At)t = A
Chuyển vị của tổng(A + B)t = At + Bt
Chuyển vị của tích, chú ý đảo thứ tự(A * B)t = Bt * At
Ma trận đối xứngAt = A
Ma trận phản đối xứngAt = -A

Xoay ma trận chín mươi độ

xoay.c
/* Xoay 90 độ theo chiều kim đồng hồ = chuyển vị rồi đảo từng hàng. */
void xoay_phai(int *A, size_t n)
{
    chuyen_vi_tai_cho(A, n);

    for (size_t i = 0; i < n; ++i)
        for (size_t j = 0; j < n / 2; ++j) {
            int t = A[i * n + j];

            A[i * n + j] = A[i * n + (n - 1 - j)];
            A[i * n + (n - 1 - j)] = t;
        }
}

/* Xoay 90 độ ngược chiều kim đồng hồ = chuyển vị rồi đảo từng cột. */
terminal
./xoay
Ban dau:
     1     2     3
     4     5     6
     7     8     9

Xoay phai 90 do:
     7     4     1
     8     5     2
     9     6     3

Đây là câu hỏi phỏng vấn kinh điển, và mẹo của nó là nhận ra rằng phép xoay chính là hai phép đơn giản ghép lại. Cả hai đều làm tại chỗ nên không cần bộ nhớ phụ.

Tự làm thử

  1. Cài chuyển vị ra mảng mới cho ma trận chữ nhật, kiểm tra bằng ma trận hai nhân ba trong bài.
  2. Cài chuyển vị tại chỗ theo cả hai cách trong phần so sánh, chạy cả hai và giải thích vì sao một cái không đổi gì.
  3. Đo thời gian chuyển vị ngây thơ và chuyển vị theo khối với ma trận 4096 nhân 4096, thử vài giá trị kích thước khối.
  4. Viết hàm kiểm tra ma trận đối xứng, thử với ma trận đối xứng và ma trận chỉ khác một ô.
  5. Xác nhận bằng thực nghiệm rằng (A * B)t bằng Bt * At với hai ma trận chữ nhật.
  6. Cài xoay ma trận chín mươi độ theo cả hai chiều, kiểm tra bằng cách xoay bốn lần và so với ma trận gốc.

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

  • Chuyển vị đổi A[i][j] thành At[j][i], và ma trận m nhân n thành n nhân m.
  • Hai công thức chỉ số khác nhau vì hai ma trận có số cột khác nhau. Với ma trận vuông thì lỗi này không lộ ra.
  • Chuyển vị tại chỗ chỉ làm được với ma trận vuông, và vòng lặp trong phải bắt đầu từ i + 1.
  • Duyệt cả ma trận làm mỗi cặp bị đổi chỗ hai lần, nên kết quả trở về đúng như ban đầu.
  • Chia khối giúp chuyển vị ma trận lớn nhanh hơn vài lần, và là kỹ thuật dùng lại ở nhiều bài toán khác.