Nhập môn

Vector trong C++ và List trong Python: cơ chế mảng động, độ phức tạp và lỗi TLE hay gặp

std::vector trong C++ và list trong Python đều được cài đặt bên dưới bằng cấu trúc mảng động (dynamic array): một vùng lưu trữ có thể tăng dung lượng khi đầy. Trong CPython, vùng liên tiếp chứa con trỏ tới các đối tượng; không phải toàn bộ các đối tượng số nằm sát nhau. Hiểu rõ sự khác biệt giữa size và capacity, vì sao lệnh push_back / append đạt độ phức tạp trung bình khấu hao O(1), cũng như thói quen truyền tham chiếu const vector<int>& trong C++ sẽ giúp bạn tránh được những lỗi quá thời gian (TLE) đáng tiếc nhất.

Ý chính
  • Cả std::vector (C++) và list (Python) đều là mảng động (Dynamic Array) lưu dữ liệu trên các ô nhớ liên tiếp, cho phép truy cập phần tử bất kỳ a[i] trong O(1).
  • Nhờ cơ chế tăng dung lượng theo cấp số nhân khi cần, thao tác thêm vào cuối push_back() / append() có độ phức tạp trung bình khấu hao (amortized) là O(1).
  • Ngược lại, việc chèn (insert) hoặc xóa (erase / pop(i)) ở đầu hay giữa mảng phải dồn toàn bộ các phần tử phía sau nên tốn O(n).
  • Trong C++, khi truyền vector vào hàm, nên truyền theo tham chiếu nếu không cần bản sao (const vector<int>& a hoặc vector<int>& a); nếu viết vector<int> a sẽ bị chép lại toàn bộ mảng mất O(n) ở mỗi lần gọi hàm!

Bản chất Mảng động (Dynamic Array): Phân biệt size và capacity

Trong ngôn ngữ C truyền thống, khi khai báo một mảng tĩnh như int a[100];, kích thước của mảng bị khóa cứng ngay từ lúc biên dịch. Nếu dữ liệu thực tế có 100.000 phần tử thì mảng bị tràn, còn nếu chỉ có 5 phần tử thì 95 ô nhớ còn lại bị bỏ phí.

Để giải quyết bài toán đó, std::vector trong C++ (thuộc thư viện <vector>) và list trong Python sử dụng cấu trúc mảng động (Dynamic Array). Bên trong mỗi đối tượng vector luôn quản lý 3 thông tin cốt lõi:

  1. Con trỏ data: Trỏ tới địa chỉ ô nhớ đầu tiên của vùng nhớ liên tiếp trên bộ nhớ Heap.
  2. Kích thước thực tế (size): Số lượng phần tử đang thực sự có mặt trong mảng (lấy bằng a.size() trong C++ hoặc len(a) trong Python).
  3. Dung lượng đã cấp phát (capacity): Tổng số ô nhớ mà vector đã xin hệ điều hành đặt chỗ trước (lấy bằng a.capacity() trong C++). Ta luôn có bất đẳng thức: size <= capacity (capacity >= size).

Chừng nào size < capacity (capacity > size), vùng nhớ đặt trước vẫn còn các ô trống ở cuối. Lúc này, khi bạn gọi a.push_back(x) (C++) hoặc a.append(x) (Python), chương trình chỉ việc ghi thẳng giá trị x vào ô trống ngay tại chỉ số a[size] rồi tăng size lên 1 — thao tác này tốn đúng O(1)!

Vì sao push_back có lúc phải chép lại mảng O(n) nhưng trung bình vẫn là O(1)?

Câu hỏi thú vị nhất trong các buổi phỏng vấn là: "Điều gì xảy ra khi mảng đã đầy khít (size == capacity) mà ta vẫn gọi tiếp push_back(x)?"

Vì các ô nhớ của mảng động bắt buộc phải nằm liền kề nhau mà vùng nhớ ngay phía sau có thể đã bị biến khác chiếm chỗ, vector không thể chỉ xin thêm 1 ô lẻ tẻ. Thay vào đó, nó thực hiện quy trình tăng dung lượng; dưới đây chọn mô hình nhân đôi để dễ chứng minh:

  1. Trong mô hình minh họa, xin vùng mới với new_capacity = max(1, capacity * 2). Chuẩn C++ không ấn định hệ số tăng của vector; CPython cũng có chính sách cấp dư riêng. Các mốc 1, 2, 4, 8 ở hình và code không phải cam kết của mọi thư viện.
  2. Sao chép toàn bộ size phần tử từ vùng nhớ cũ sang vùng nhớ mới, rồi thêm phần tử mới x vào tiếp theo.
  3. Giải phóng vùng nhớ cũ.
1. push(10) size=1, cap=1 10 2. push(20) — nhân đôi size=2, cap=2 (chép 1 ô) 10 20 3. push(30, 40) size=4, cap=4 (chép 2 ô) 10 20 30 40 4. push(50) — cap = 8 size=5, còn 3 ô trống O(1) 10 20 30 40 50 — — —
1. push(10): size=1, cap=1 [10] 2. push(20): nhân đôi cap=2 [10, 20] 3. push(30, 40): cap=4 [10, 20, 30, 40] 4. push(50): cap=8 (5 ô dùng, 3 ô trống)
Hình 1. Quá trình nhân đôi capacity từ 1 lên 2, 4, 8 khi thêm lần lượt 5 phần tử 10, 20, 30, 40, 50 vào vector.

Nghe qua, bước sao chép toàn bộ mảng cũ tốn tới O(n) thao tác! Vậy tại sao người ta vẫn khẳng định push_back() có độ phức tạp thuật toán là O(1)?

Hãy cộng tổng số lần sao chép khi ta thêm liên tiếp n phần tử từ đầu (giả sử capacity khởi đầu bằng 1 và nhân đôi ở các mốc lũy thừa của 2 là 1, 2, 4, 8, ..., 2ᵏ < n): \[\begin{aligned} & \text{Tổng số lần chép phần tử cũ} \\ &= 1 + 2 + 4 + 8 + \dots + 2^k \\ &= 2^{k + 1} - 1 < 2n \end{aligned}\]

Như vậy, trong suốt quá trình thực hiện n lệnh push_back, tổng công sức (bao gồm n lần ghi phần tử mới cộng với chưa tới 2n lần chép phần tử cũ khi mở rộng mảng) luôn nhỏ hơn 3n thao tác! Chia đều tổng chi phí < 3n đó cho n lần gọi hàm (phương pháp này gọi là phân tích khấu hao — Amortized Analysis), mỗi lệnh push_back() trung bình chỉ tốn chưa tới 3 thao tác, tức là O(1) khấu hao (Amortized O(1))!

Hãy chạy đoạn code mô phỏng quá trình nhân đôi capacity và đếm số lần sao chép dưới đây:

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

// Mô phỏng cơ chế nhân đôi capacity của mảng động khi thêm n phần tử
void moPhongNhanDoi(int n) {
    int sz = 0, cap = 1, tongSaoChep = 0;
    for (int i = 1; i <= n; i++) {
        if (sz == cap) {
            cap *= 2;           // cấp phát vùng nhớ mới gấp đôi
            tongSaoChep += sz;  // chép toàn bộ sz phần tử cũ sang vùng nhớ mới
        }
        sz++;
        cout << "push(" << i << ")->" << sz << "/" << cap << " ";
    }
    cout << "| tong_chep=" << tongSaoChep << "\n";
}

int main() {
    moPhongNhanDoi(5);
    // push(1)->1/1 push(2)->2/2 push(3)->3/4 push(4)->4/4 push(5)->5/8 | tong_chep=7
    return 0;
}

Bảng độ phức tạp các thao tác trên vector (C++) và list (Python)

Vì các phần tử nằm sát cạnh nhau trong bộ nhớ, mọi thao tác ở cuối mảng hoặc truy cập theo chỉ số đều cực nhanh (O(1)), nhưng mọi thao tác chèn hoặc xóa ở đầu / giữa mảng đều chậm (O(n)) vì phải dịch chuyển các phần tử đứng sau:

Thao tác Cú pháp C++ (vector<T> a) Cú pháp Python (a = []) Độ phức tạp Lý do kỹ thuật
Truy cập phần tử thứ i a[i] hoặc a.at(i) a[i] O(1) Tính thẳng địa chỉ ô nhớ data + i
Lấy số phần tử hiện có a.size() len(a) O(1) Đọc trực tiếp biến thành viên size
Thêm 1 phần tử vào cuối a.push_back(x) a.append(x) O(1) khấu hao Cấp phát dư và chỉ mở rộng khi cần; hệ số tùy cài đặt
Xóa 1 phần tử ở cuối a.pop_back() a.pop() O(1) trong C++; O(1) khấu hao với CPython Python có thể thu hẹp vùng chứa; giả định chi phí hủy phần tử là hằng số
Chèn vào vị trí i bất kỳ a.insert(a.begin() + i, x) a.insert(i, x) O(n) Phải đẩy n - i phần tử phía sau sang phải 1 ô
Xóa phần tử tại vị trí i a.erase(a.begin() + i) a.pop(i) hoặc del a[i] O(n) Phải kéo n - i - 1 phần tử phía sau sang trái 1 ô
Tìm kiếm tuần tự giá trị x find(a.begin(), a.end(), x) x in a hoặc a.index(x) O(n) Phải quét qua từng phần tử từ đầu mảng

Mẹo xóa phần tử ở giữa mảng trong O(1): Nếu bài toán chỉ coi vector là một túi chứa các phần tử mà không quan trọng thứ tự đứng trước – sau, khi muốn xóa phần tử a[i], bạn đừng gọi a.erase(a.begin() + i) mất O(n). Hãy ghi đè phần tử cuối cùng lên a[i] rồi xóa phần tử cuối: a[i] = a.back(); a.pop_back(); — tốn đúng O(1)! Còn nếu mảng đã được sắp xếp, bạn có thể kết hợp với tìm kiếm nhị phân để tra cứu trong O(log n).

Cảnh báo lỗi TLE kinh điển nhất trong C++: Truyền vector theo giá trị

Khi chuyển từ mảng tĩnh hoặc từ Python sang C++ (xem thêm bài nên học Python hay C++), có một cái bẫy khiến hàng nghìn thí sinh bị Quá thời gian (Time Limit Exceeded — TLE) dù thuật toán hoàn toàn đúng:

  • Trong Python: Khi bạn viết def xu_ly(a):, Python luôn truyền tham chiếu tới đối tượng danh sách (chi phí truyền là O(1)), không hề tạo ra bản sao của danh sách.
  • Trong C++: Mặc định mọi tham số hàm đều được truyền theo giá trị (pass-by-value)! Nếu bạn viết: Ví dụ int kiemTra(vector<int> a, int i) { return a[i] > 0; } nhận bản sao khi gọi với một vector lvalue; ví dụ chạy được để so sánh cách truyền nằm trong khối code bên dưới. Mỗi một lần hàm kiemTra(a, i) được gọi, C++ sẽ xin cấp phát vùng nhớ mới và chép lại toàn bộ n phần tử của a sang một bản sao mới tinh rồi mới chạy hàm! Nếu hàm này nằm trong một vòng lặp chạy n lần hoặc trong hàm đệ quy tìm kiếm nhị phân, n lần gọi với một vector dài n có thể tốn O(n²) cho sao chép; O(log n) lần gọi với cùng vector dài n có thể tốn O(n log n). Truyền theo giá trị vẫn hợp lý khi thực sự cần sở hữu bản sao hoặc dùng ngữ nghĩa di chuyển.

Quy tắc bắt buộc khi viết hàm nhận vector trong C++:

  1. Nếu hàm chỉ đọc dữ liệu mà không sửa mảng: luôn viết const vector<int>& a (truyền tham chiếu hằng — tốn O(1) và an toàn tuyệt đối).
  2. Nếu hàm cần sửa trực tiếp các phần tử của mảng gốc: luôn viết vector<int>& a (truyền tham chiếu).
  3. Chỉ viết vector<int> a (không có dấu &) khi bạn thực sự cố tình muốn tạo một bản sao độc lập để chỉnh sửa nháp mà không làm hỏng mảng gốc.

Ngoài ra, nếu biết trước sẽ thêm khoảng n phần tử vào vector, hãy gọi a.reserve(n); trước vòng lặp để đặt trước vùng nhớ một lần duy nhất, tránh hoàn toàn chi phí nhân đôi và chép lại mảng trung gian!

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

// ĐÚNG: Truyền tham chiếu hằng (const&) để chỉ đọc, không tốn O(n) sao chép mảng
long long tinhTong(const vector<int>& a) {
    long long s = 0;
    for (int x : a) s += x;
    return s;
}

// ĐÚNG: Truyền tham chiếu (&) khi cần sửa trực tiếp trên vector gốc
void xoaViTriKhongCanThuTu(vector<int>& a, size_t idx) {
    a[idx] = a.back();  // ghi đè bằng phần tử cuối trong O(1)
    a.pop_back();       // xóa phần tử cuối trong O(1)
}

int main() {
    vector<int> a;
    a.reserve(5);  // đặt trước vùng nhớ cho 5 phần tử để tránh cấp phát lại
    for (int x : {10, 20, 30, 40, 50}) {
        a.push_back(x);
    }
    cout << tinhTong(a) << "\n";  // 150

    // Xóa phần tử ở chỉ số 1 (giá trị 20) trong O(1) bằng mẹo đổi với phần tử cuối
    xoaViTriKhongCanThuTu(a, 1);
    for (size_t i = 0; i < a.size(); i++) {
        cout << a[i] << (i + 1 == a.size() ? "\n" : " ");
    }  // 10 50 30 40
    return 0;
}

Lỗi hay gặp

Cẩn thận
  • Nhầm lẫn giữa a.reserve(n) và a.resize(n) trong C++. a.reserve(n) chỉ đặt chỗ trước dung lượng capacity >= n nhưng a.size() vẫn bằng 0 (truy cập a[0] lúc này là lỗi truy cập vùng nhớ chưa khởi tạo!). Ngược lại, a.resize(n) vừa cấp phát bộ nhớ vừa tạo sẵn n phần tử có giá trị 0 (a.size() == n), nếu sau đó bạn lại gọi tiếp a.push_back(x) thì phần tử x sẽ nằm ở vị trí n (thành n + 1 phần tử)!
  • Dùng a.pop(0) liên tục trong Python để làm hàng đợi (Queue). Vì list là mảng động, mỗi lệnh a.pop(0) phải dồn toàn bộ n - 1 phần tử còn lại sang trái một ô mất O(n). Lặp n lần a.pop(0) sẽ tốn O(n²)! Khi cần lấy phần tử ở đầu danh sách trong O(1), hãy dùng collections.deque trong Python hoặc std::deque / std::queue trong C++.
  • Lưu con trỏ hoặc iterator trỏ vào phần tử của vector rồi gọi push_back. Khi push_back làm size vượt quá capacity, vector sẽ chuyển nhà sang vùng nhớ mới và giải phóng vùng nhớ cũ (iterator invalidation), làm mọi con trỏ hay iterator lấy trước đó bị treo (dangling pointer).

Luyện ngay trên Big-O Coding

Thực hành thao tác mảng động với vector (C++) và list (Python) qua các bài tập trên hệ thống Big-O Coding:

Câu hỏi thường gặp

Sau khi gọi a.clear() trong C++, bộ nhớ capacity của vector có được trả lại cho hệ điều hành không?

Không! Lệnh a.clear() chỉ đưa a.size() về 0 nhưng vẫn giữ nguyên a.capacity() để tránh phải cấp phát lại khi thêm phần tử mới. a.shrink_to_fit() chỉ là yêu cầu không ràng buộc để giảm capacity; thư viện có thể không thực hiện. Giải phóng vùng cấp phát cũng không bảo đảm hệ điều hành nhận lại RAM ngay.

Sự khác nhau cơ bản giữa vector<int> của C++ và list của Python trong bộ nhớ là gì?

std::vector<int> lưu trực tiếp các giá trị int (thường 32-bit trên môi trường phổ biến) nằm liền kề nhau trong một khối nhớ duy nhất, rất tiết kiệm RAM và thân thiện với bộ nhớ đệm CPU. Ngược lại, list của Python lưu mảng các con trỏ trỏ tới các đối tượng PyObject trên Heap nên linh hoạt hơn nhưng tốn bộ nhớ hơn.

Khi nào nên dùng a[i] và khi nào nên dùng a.at(i) trong C++?

Toán tử a[i] không kiểm tra tràn chỉ số nên chạy nhanh nhất; còn a.at(i) kiểm tra điều kiện i < a.size() (a.size() > i) và ném ngoại lệ std::out_of_range nếu vượt biên, rất hữu ích khi gỡ lỗi.

Muốn học bài bản cùng giảng viên?

Khóa Green cho người mới, sinh viên năm 1–2: xây nền tảng lập trình và thuật toán vững chắc, biết dùng AI hỗ trợ. 16 buổi online, học thử buổi 1 miễn phí.

Xem Khóa Green

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483