Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Xóa 1 dòng trong mảng 2 chiều cấp phát động

Xóa 1 dòng trong mảng 2 chiều cấp phát động
Nguồn: Hỏi đáp học viên Độ khó: Dễ Con trỏ Cấp phát động Mảng 2 chiều

Đề bài

Cho mảng 2 chiều các số nguyên được cấp phát động gồm dong dòng, cot cột. Hãy viết hàm xóa 1 dòng có chỉ số viTriXoa ra khỏi mảng (các dòng phía sau dồn lên), rồi in ra mảng sau khi xóa.

Dữ liệu vào và kết quả (mình bổ sung để các bạn chạy thử): đề gốc chỉ là một bài tập về con trỏ nên không có định dạng nhập/xuất. Để tiện kiểm tra, trong bài này mình quy ước như sau:

  • Dòng đầu gồm 2 số nguyên dong, cot $(2 \le dong \le 1000,\ 1 \le cot \le 1000)$.
  • dong dòng tiếp theo, mỗi dòng gồm cot số nguyên (giá trị tuyệt đối không quá $10^9$) là các phần tử của mảng.
  • Dòng cuối là số nguyên viTriXoa $(0 \le viTriXoa \lt dong)$: chỉ số dòng cần xóa (đánh số từ 0).
  • Kết quả: in ra mảng sau khi xóa gồm dong − 1 dòng, mỗi dòng là các phần tử cách nhau một khoảng trắng.
Ví dụ 1 – input
3 3
1 2 3
4 5 6
7 8 9
0
Ví dụ 1 – output
4 5 6
7 8 9
Ví dụ 2 – input
5 3
1 1 1
2 2 2
3 3 3
4 4 4
5 5 5
1
Ví dụ 2 – output
1 1 1
3 3 3
4 4 4
5 5 5

Lời mở đầu

Bài này là một đoạn hỏi đáp giữa mình với một em học viên đang học tới phần con trỏ. Em ấy viết hàm xóa dòng thì "lúc chạy kết quả ra đúng, nhưng khi tắt chương trình đi thì lại hiện lỗi" 😥. Mình thấy đây là ví dụ cực kỳ hay để thấy được sức mạnh của con trỏ 💪, đồng thời cũng là cái bẫy mà rất nhiều bạn mới học con trỏ hay dính, nên viết lại thành bài để nhiều bạn cùng tham khảo. Hy vọng giúp ích được cho các bạn 😇.

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:

Link nộp bài

Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.

Nộp bài trên CLUE OJ

Trước hết, mảng 2 chiều động là gì? Bản chất mảng 2 chiều động chính là mảng 1 chiều mà mỗi phần tử trong mảng 1 chiều đó là 1 mảng 1 chiều. Ví dụ mảng 2 chiều a có 5 dòng, 10 cột:

  • Đầu tiên là khai báo ra con trỏ cấp 2 a, sẽ new ra 5 (số dòng) con trỏ cấp 1: int **a = new int*[5];
  • Sau đó với mỗi con trỏ cấp 1, sẽ đi new ra 10 (cột) ô nhớ: for(int i = 0; i < 5; i++) a[i] = new int[10];

Bên C thì thay new bằng malloc như code trong bài này. Tức là mỗi a[i] là 1 con trỏ, đại diện cho cả 1 dòng.

Xóa 1 dòng thì làm sao? Nó cũng giống quy tắc xóa 1 phần tử trong mảng 1 chiều vậy đó: toàn bộ các ô nhớ từ sau vị trí cần xóa sẽ nhích lên phía trước hết, sau đó ta giải phóng phần dư ra ở cuối. Ví dụ mảng có 5 dòng, xóa dòng 1 nghĩa là dòng 2 sẽ phải nhích lên đứng ở dòng 1, dòng 3 nhích lên đứng ở dòng 2, dòng 4 nhích lên đứng ở dòng 3. Câu hỏi là: nhích bằng cách nào?

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Cách 1Copy từng ô nhớ của dòng sau lên dòng trước – O(dong · cot)

Cách đầu tiên ai cũng nghĩ tới: đi copy từng ô nhớ của dòng 2 lên từng ô nhớ tương ứng của dòng 1, rồi dòng 3 lên dòng 2... Copy xong thì dòng cuối cùng thành dư, ta free nó đi và giảm dong xuống 1.

Với mảng 1 chiều cấp phát động thì đây là cách duy nhất: cái cần xóa là ô nhớ sau cùng thì phải dùng cơ chế realloc để nó giảm bớt ô nhớ, chứ không có chuyện delete a[n-1]; được, vì lúc này a[n-1] không phải là con trỏ mà delete được, a[n-1] chỉ đang là 1 ô nhớ thuộc dãy ô nhớ quản lý bởi duy nhất 1 con trỏ a.

#include <iostream>
#include <cstdlib>
using namespace std;

void xoaDong(int**& a, int& dong, int cot, int viTriXoa)
{
    // dời từng ô của các dòng phía sau lên trên 1 dòng
    for (int i = viTriXoa; i < dong - 1; i++)
        for (int j = 0; j < cot; j++)
            a[i][j] = a[i + 1][j];
    free(a[dong - 1]); // dòng cuối giờ dư ra, giải phóng nó
    dong--;
}

int main()
{
    int dong, cot;
    cin >> dong >> cot;
    int** a = (int**)malloc(dong * sizeof(int*));
    for (int i = 0; i < dong; i++)
    {
        a[i] = (int*)malloc(cot * sizeof(int));
        for (int j = 0; j < cot; j++)
            cin >> a[i][j];
    }
    int viTriXoa;
    cin >> viTriXoa;
    xoaDong(a, dong, cot, viTriXoa);
    for (int i = 0; i < dong; i++)
    {
        for (int j = 0; j < cot; j++)
            cout << a[i][j] << " ";
        cout << "\n";
    }
    for (int i = 0; i < dong; i++)
        free(a[i]);
    free(a);
    return 0;
}
import sys


def xoa_dong(a, dong, cot, vi_tri_xoa):
    # dời từng ô của các dòng phía sau lên trên 1 dòng
    for i in range(vi_tri_xoa, dong - 1):
        for j in range(cot):
            a[i][j] = a[i + 1][j]
    a.pop()  # dòng cuối giờ dư ra, bỏ nó đi
    return dong - 1


data = sys.stdin.buffer.read().split()
dong, cot = int(data[0]), int(data[1])
pos = 2
a = []
for i in range(dong):
    a.append([int(x) for x in data[pos:pos + cot]])
    pos += cot
vi_tri_xoa = int(data[pos])
dong = xoa_dong(a, dong, cot, vi_tri_xoa)
out = []
for i in range(dong):
    out.append(' '.join(map(str, a[i])) + ' ')
sys.stdout.write('\n'.join(out) + '\n')

Đánh giá độ phức tạp của cách làm này:

  • Không gian: không dùng thêm bộ nhớ: $O(1)$.
  • Thời gian: mỗi dòng phía sau phải copy đủ cot ô, tổng cộng $(dong - 1 - viTriXoa) \cdot cot$ phép gán, xấu nhất cỡ $O(dong \cdot cot)$. Ví dụ 1000 dòng, 1000 cột mà xóa dòng 0 thì phải gán gần $10^6$ lần.
Không gian$O(1)$
Thời gian$O(dong \cdot cot)$

Chạy thì đúng, nhưng như mình nói với em học viên: đừng làm thế 😄, làm thế là trình độ con trỏ chưa tốt. Bản chất từng dòng là 1 con trỏ, và con trỏ thì có thể trỏ đi được!

Cách 2Chỉ nhảy con trỏ a[i] = a[i + 1], có temp giữ nhà – O(dong)

Vì thế nên ta cho a[1] là con trỏ quản lý dòng 1, sẽ nhảy đến a[2] là con trỏ quản lý dòng 2. Như thế thì khi gọi duyệt a[1] là nó sẽ duyệt các ô nhớ thuộc dòng 2, nói cách khác cũng như việc toàn bộ dòng 2 copy lên dòng 1. Nhưng thay vì phải làm từng ô nhớ (giả sử có 1000 ô tức là có 1000 cột thì phải chạy vòng lặp làm 1000 lần) thì giờ chỉ làm 1 lần cho con trỏ a[1] = a[2]; thế là xong ^^. Đây là sức mạnh của con trỏ nhé 😃:

a[1] = a[2];
a[2] = a[3];
a[3] = a[4];
Bẫy: free nhầm con trỏ cuối

Nhích xong rồi, nếu mình free(a[4]) (giải phóng "dòng cuối dư ra" như cách 1) thì lúc này đâu được! Vì lần cuối cùng: a[3] = a[4], tụi nó đang ở cùng địa chỉ của nhau. Nếu giải phóng con trỏ a[4] nghĩa là giải phóng vùng nhớ mà nó quản lý, thì hiện tại a[3] cũng đang ở đó mà 🙂. Thấy vấn đề không?

Chưa hết: dòng mình cần xóa là dòng index 1, và nó đang được quản lý bởi con trỏ a[1]. Nếu cho a[1] = a[2] tức là a[1] nhảy tới địa chỉ con trỏ a[2], thì như thế mình đã mất kết nối với địa chỉ a[1] ban đầu rồi. Nó có nhà của nó, mà giờ nó bỏ qua nhà khác, vậy nhà của nó bị vô chủ rồi ⇒ rò rỉ bộ nhớ.

Đây chính là lý do code của em học viên chạy ra kết quả đúng nhưng tắt chương trình thì lỗi: đến cuối chương trình, vòng lặp giải phóng bộ nhớ sẽ free lại vùng nhớ đã bị free trước đó (double free).

Vậy nên mình phải cho 1 con trỏ temp giữ nhà giùm nó: int *temp = a[viTriXoa]; là cho con trỏ temp quản lý địa chỉ của a[1] đó (đang muốn xóa dòng index 1). Rồi thì thằng a[1] mới trỏ tới a[2], a[2] trỏ a[3], a[3] trỏ a[4], rồi thì ta free(temp).

Tưởng tượng thế này đi hen. Giả sử mảng a có 5 dòng, địa chỉ lần lượt của con trỏ đầu mỗi dòng sẽ là 0x001, 0x002, 0x003, 0x004, 0x005 (chỗ này là mình tượng trưng địa chỉ cho dễ hình dung chứ thực tế các địa chỉ đầu mỗi dòng không có liền kề sát nhau như vậy đâu nha, đừng anh em nào bắt bẻ mình chỗ này nhé hihi ^_^).

  • Giờ cần xóa dòng index 1: tức là địa chỉ 0x002, hiện tại nó đang được quản lý địa chỉ bởi con trỏ a[1].
  • Ta cho temp = a[1], tức là temp sẽ quản lý địa chỉ 0x002.
  • Sau đó a[1] sẽ nhảy tới a[2] tức là a[1] sẽ là 0x003; a[2] nhảy tới a[3] tức là 0x004; a[3] nhảy tới a[4] tức là 0x005.
  • Vậy giờ đây mảng 2 chiều đang quản lý các con trỏ cấp 1 từ a[0] đến a[3] sẽ là các địa chỉ 0x001, 0x003, 0x004, 0x005.
  • Còn lúc này đây, địa chỉ 0x002 đang là con trỏ temp quản lý, ta free(temp) giải phóng nó đi 🙂.
Ý chính
Xóa dòng $k$ = giữ địa chỉ dòng $k$ bằng temp → nhích các con trỏ a[i] = a[i + 1] với $i$ từ $k$ đến $dong - 2$ → free(temp) → dong--. Nhờ con trỏ mà chỉ nhảy phát là cả dòng đi theo, không phải đụng tới ô nhớ nào bên trong.

Cái này nói bằng lời thì hơi "hack não" 😅, nhưng cứ kẻ hình mảng 2 chiều ra giấy, đánh dấu các con trỏ đầu mỗi dòng, rồi nhìn vào sẽ rõ hơn. Hoặc dùng khung minh hoạ dưới đây: chọn cách làm rồi bấm Tiếp. Các bạn chọn thử "Quên temp, free(a[dong − 1])" để thấy tận mắt cái lỗi của em học viên nhé:

Ví dụ nhanh:

Code cuối cùng mình sửa lại cho em học viên chạy ngon lành (cả bản Python cũng làm y hệt ý tưởng, vì trong Python mỗi phần tử của list chính là một tham chiếu tới list con, giống con trỏ cấp 1):

#include <iostream>
#include <cstdlib>
using namespace std;

void xoaDong(int**& a, int& dong, int cot, int viTriXoa)
{
    int* temp = a[viTriXoa]; // giữ nhà giùm dòng cần xóa
    for (int i = viTriXoa; i < dong - 1; i++)
    {
        a[i] = a[i + 1]; // chỉ nhảy con trỏ, cả dòng đi theo
    }
    free(temp);
    dong--;
}

int main()
{
    int dong, cot;
    cin >> dong >> cot;
    int** a = (int**)malloc(dong * sizeof(int*));
    for (int i = 0; i < dong; i++)
    {
        a[i] = (int*)malloc(cot * sizeof(int));
        for (int j = 0; j < cot; j++)
            cin >> a[i][j];
    }
    int viTriXoa;
    cin >> viTriXoa;
    xoaDong(a, dong, cot, viTriXoa);
    for (int i = 0; i < dong; i++)
    {
        for (int j = 0; j < cot; j++)
            cout << a[i][j] << " ";
        cout << "\n";
    }
    for (int i = 0; i < dong; i++)
        free(a[i]);
    free(a);
    return 0;
}
import sys


def xoa_dong(a, dong, vi_tri_xoa):
    temp = a[vi_tri_xoa]  # giữ dòng cần xóa
    for i in range(vi_tri_xoa, dong - 1):
        a[i] = a[i + 1]  # chỉ đổi "con trỏ" (tham chiếu), cả dòng đi theo
    a.pop()  # bỏ ô tham chiếu cuối đang bị trùng
    del temp  # giải phóng dòng cũ
    return dong - 1


data = sys.stdin.buffer.read().split()
dong, cot = int(data[0]), int(data[1])
pos = 2
a = []
for i in range(dong):
    a.append([int(x) for x in data[pos:pos + cot]])
    pos += cot
vi_tri_xoa = int(data[pos])
dong = xoa_dong(a, dong, vi_tri_xoa)
out = []
for i in range(dong):
    out.append(' '.join(map(str, a[i])) + ' ')
sys.stdout.write('\n'.join(out) + '\n')
Bên C++ dùng new/delete thì sao?
Bên C thì giải phóng là free(temp), còn bên C++ nếu cấp phát bằng new thì em hoàn toàn có thể delete[] temp; nhé (phải có [] vì nó đang là con trỏ cấp 1 quản lý nhiều ô nhớ nên phải delete[]). Lúc này chả cần dùng realloc gì cả, vì mỗi a[i] là 1 con trỏ, đại diện cho cả 1 dòng.
Xóa 1 cột thì có chơi thế được không?
Cột thì không chơi thế được 😄, vì mỗi cột chỉ là tập hợp các ô nhớ thuộc từng dòng mà, không có con trỏ nào đại diện cho cả 1 cột cả. Lúc này phải làm như xóa phần tử trong mảng 1 chiều cho từng dòng: các ô phía sau trong dòng phải nhích lên, rồi dùng realloc cho các con trỏ cấp 1 ở từng dòng để bớt đi 1 ô.

Khung chạy tay dưới đây chạy đúng từng dòng của code C++ ở trên. Phần đầu là cấp phát và nhập mảng, các bạn có thể bấm Tiếp nhanh qua phần đó để tới hàm xoaDong (dòng 5 – 14):

C++▶ Chạy tay từng dòng codetemp giữ địa chỉ dòng cần xóa, nhích các con trỏ a[i] = a[i + 1], rồi free(temp).
Dữ liệu mẫu:
1#include <iostream>
2#include <cstdlib>
3using namespace std;
4
5void xoaDong(int**& a, int& dong, int cot, int viTriXoa)
6{
7 int* temp = a[viTriXoa]; // giữ nhà giùm dòng cần xóa
8 for (int i = viTriXoa; i < dong - 1; i++)
9 {
10 a[i] = a[i + 1]; // chỉ nhảy con trỏ, cả dòng đi theo
11 }
12 free(temp);
13 dong--;
14}
15
16int main()
17{
18 int dong, cot;
19 cin >> dong >> cot;
20 int** a = (int**)malloc(dong * sizeof(int*));
21 for (int i = 0; i < dong; i++)
22 {
23 a[i] = (int*)malloc(cot * sizeof(int));
24 for (int j = 0; j < cot; j++)
25 cin >> a[i][j];
26 }
27 int viTriXoa;
28 cin >> viTriXoa;
29 xoaDong(a, dong, cot, viTriXoa);
30 for (int i = 0; i < dong; i++)
31 {
32 for (int j = 0; j < cot; j++)
33 cout << a[i][j] << " ";
34 cout << "\n";
35 }
36 for (int i = 0; i < dong; i++)
37 free(a[i]);
38 free(a);
39 return 0;
40}

Đánh giá độ phức tạp của cách làm này:

  • Không gian: chỉ thêm 1 con trỏ temp: $O(1)$.
  • Thời gian: chỉ có $dong - 1 - viTriXoa$ phép gán con trỏ, không phụ thuộc số cột: $O(dong)$.
Không gian$O(1)$
Thời gian$O(dong)$

Thay vì phải đi vào từng ô cột tương ứng trong từng dòng mà hoán vị tụi nó với nhau, thì giờ nhờ con trỏ mà chỉ nhảy phát là cả dòng đi theo, ngon phết nhở 😁. Suy ngẫm đi nhé, kẻ ra giấy sẽ nhìn rõ :)).

Ghi chú của mình: code C++ (biên dịch kèm AddressSanitizer để bắt lỗi bộ nhớ) và Python của cả 2 cách đã được kiểm tra với 400 bộ test ngẫu nhiên (2 – 7 dòng, 1 – 6 cột, xóa dòng bất kỳ kể cả dòng đầu và dòng cuối), so với kết quả xóa dòng trực tiếp – khớp hết, không có lỗi bộ nhớ nào. Mình cũng chạy thử bản "quên temp, free(a[dong − 1])": AddressSanitizer báo ngay lỗi dùng vùng nhớ sau khi free (heap-use-after-free), còn bản biên dịch thường thì chương trình bị dừng với lỗi double free, đúng như phân tích. Với mảng 1000 × 1000, cả 2 cách đều chạy dưới 1 giây vì thời gian chủ yếu nằm ở phần nhập/xuất $10^6$ số; khác biệt nằm ở hàm xoaDong: cách 1 gán gần $10^6$ ô, cách 2 chỉ gán 999 con trỏ.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét