Bài 3.3: Bài tập thực hành kiểu dữ liệu phức tạp trong C++

Chào mừng các bạn đã quay trở lại với series blog C++ của FullhouseDev! Sau khi làm quen với các kiểu dữ liệu cơ bản như số nguyên, số thực, ký tự,... chúng ta nhanh chóng nhận ra rằng chúng không đủ để biểu diễn các thông tin phức tạp trong thế giới thực. Làm sao để lưu trữ danh sách điểm của học sinh? Làm sao để mô tả một đối tượng bao gồm nhiều loại thông tin khác nhau (ví dụ: một cuốn sách có tên, tác giả, năm xuất bản)?

Đó chính là lúc các kiểu dữ liệu phức tạp (complex data types) hoặc còn gọi là kiểu dữ liệu có cấu trúc (structured data types) phát huy sức mạnh. Trong bài thực hành này, chúng ta sẽ cùng nhau khám phá và làm quen với một số kiểu dữ liệu phức tạp thông dụng nhất trong C++, bao gồm: Mảng (Arrays), Cấu trúc (Structs), và hai người bạn cực kỳ hữu ích từ Thư viện chuẩn C++ (Standard Library) là vectorstring.

Nắm vững các kiểu dữ liệu này sẽ là nền tảng vững chắc để bạn xây dựng các chương trình phức tạp và mạnh mẽ hơn rất nhiều!


1. Mảng (Arrays): Ngôi nhà chung của các phần tử cùng loại

Hãy tưởng tượng bạn cần lưu trữ điểm số của 5 môn học. Việc tạo 5 biến riêng biệt (diemMon1, diemMon2, ...) có vẻ không hiệu quả. Đây là lúc mảng tỏa sáng. Mảng là một tập hợp các phần tử cùng kiểu dữ liệu được lưu trữ liên tiếp trong bộ nhớ. Chúng ta có thể truy cập từng phần tử thông qua chỉ số (index).

Đặc điểm chính của mảng trong C++ (mảng C-style truyền thống):

  • Kích thước cố định khi khai báo.
  • Các phần tử được đánh số từ 0 đến kích thước - 1.
  • Truy cập phần tử bằng toán tử [].

Ví dụ 1: Khai báo và sử dụng mảng cơ bản

#include <iostream>

int main() {
    // Khai báo một mảng số nguyên có kích thước 5
    int diemSo[5]; 

    // Gán giá trị cho các phần tử
    diemSo[0] = 85;
    diemSo[1] = 92;
    diemSo[2] = 78;
    diemSo[3] = 95;
    diemSo[4] = 88;

    // In giá trị của một phần tử cụ thể
    cout << "Diem mon thu 3 (chi so 2): " << diemSo[2] << endl; 

    // Khai báo và khởi tạo ngay lập tức
    double nhietDo[] = {25.5, 27.0, 26.3, 28.1}; // Kich thuoc se tu dong la 4

    // In kich thuoc (khong phai la cach tot nhat, chi mang tinh minh hoa voi mang C-style)
    cout << "So luong ngay trong mang nhiet do: " << sizeof(nhietDo) / sizeof(nhietDo[0]) << endl;

    return 0;
}

Giải thích:

  • int diemSo[5]; khai báo một mảng tên là diemSo có thể chứa 5 số nguyên.
  • diemSo[index] = value; gán giá trị value cho phần tử tại vị trí index. Nhớ là chỉ số bắt đầu từ 0!
  • cout << diemSo[2]; in ra giá trị của phần tử thứ 3 (tại chỉ số 2).
  • double nhietDo[] = { ... }; là cách khai báo và khởi tạo mảng ngay lập tức. Trình biên dịch sẽ tự động tính kích thước dựa trên số lượng phần tử bạn cung cấp.
  • sizeof(nhietDo) / sizeof(nhietDo[0]) là một kỹ thuật (hơi "thủ công") để tính số lượng phần tử trong mảng C-style khi nó được khai báo trong cùng một scope.

Ví dụ 2: Duyệt qua mảng bằng vòng lặp

Để làm việc với tất cả (hoặc nhiều) phần tử trong mảng, chúng ta thường dùng vòng lặp.

#include <iostream>

int main() {
    int soLuongSp[4] = {100, 150, 75, 200}; // So luong san pham theo quy

    // Duyet mang bang vong lap for truyen thong
    cout << "So luong san pham theo tung quy:" << endl;
    for (int i = 0; i < 4; ++i) {
        cout << "- Quy " << (i + 1) << ": " << soLuongSp[i] << endl;
    }

    // Duyet mang bang range-based for loop (tu C++11) - don gian hon!
    cout << "\nSo luong san pham (dung range-based for):" << endl;
    for (int soLuong : soLuongSp) {
        cout << soLuong << " ";
    }
    cout << endl;

    return 0;
}

Giải thích:

  • Vòng lặp for (int i = 0; i < 4; ++i) là cách phổ biến để duyệt mảng dựa trên chỉ số. i đi từ 0 đến 3, tương ứng với các chỉ số của mảng soLuongSp.
  • Vòng lặp for (int soLuong : soLuongSp) (range-based for loop) là cách hiện đại và ngắn gọn hơn. Trong mỗi lần lặp, biến soLuong sẽ tự động nhận giá trị của từng phần tử trong mảng soLuongSp theo thứ tự.

Ví dụ 3: Bài tập nhỏ với mảng - Tính tổng và trung bình

Hãy tính tổng điểm và điểm trung bình từ mảng diemSo ở Ví dụ 1.

#include <iostream>
#include <numeric> // Can cho accumulate (trong cac bai sau co the dung)

int main() {
    int diemSo[5] = {85, 92, 78, 95, 88};
    int tongDiem = 0;

    // Tinh tong diem
    for (int i = 0; i < 5; ++i) {
        tongDiem += diemSo[i];
    }

    // double trungBinh = static_cast<double>(tongDiem) / 5; // Can ep kieu de chia lay so thuc

    // Hoac dung range-based for
    int tongDiem2 = 0;
    for (int diem : diemSo) {
        tongDiem2 += diem;
    }
    double trungBinh = static_cast<double>(tongDiem2) / sizeof(diemSo)/sizeof(diemSo[0]); // Linh hoat hon mot chut

    cout << "Tong diem: " << tongDiem2 << endl;
    cout << "Diem trung binh: " << trungBinh << endl;

    return 0;
}

Giải thích:

  • Chúng ta dùng vòng lặp để cộng dồn giá trị của từng phần tử vào biến tongDiem.
  • Để tính trung bình, chúng ta chia tongDiem cho số lượng phần tử (là 5). Việc sử dụng static_cast<double> là quan trọng để đảm bảo phép chia là phép chia số thực, tránh kết quả bị cắt cụt (ví dụ: 440 / 5 = 88, nhưng nếu không ép kiểu và một trong hai là số nguyên, kết quả sẽ là số nguyên). Cách dùng sizeof(diemSo)/sizeof(diemSo[0]) giúp code linh hoạt hơn nếu bạn thay đổi kích thước mảng.

2. Cấu trúc (Structs): Gói gọn dữ liệu khác loại

Mảng rất tốt cho dữ liệu cùng loại, nhưng làm sao để biểu diễn một thứ gì đó có nhiều thuộc tính với các kiểu dữ liệu khác nhau? Ví dụ, thông tin về một người có thể bao gồm Tên (chuỗi), Tuổi (số nguyên), Chiều cao (số thực), Tình trạng hôn nhân (boolean),...

Cấu trúc (struct) trong C++ cho phép chúng ta nhóm các biến thành viên (member variables) lại với nhau dưới một tên duy nhất, tạo thành một kiểu dữ liệu "mới" do người dùng định nghĩa.

Đặc điểm chính của Struct:

  • Tập hợp các thành viên với kiểu dữ liệu bất kỳ.
  • Truy cập các thành viên bằng toán tử . (dot operator).
  • Mặc định, các thành viên trong struct là public (có thể truy cập từ bên ngoài).

Ví dụ 4: Định nghĩa và sử dụng một Struct đơn giản

Hãy tạo một cấu trúc để biểu diễn một điểm trong mặt phẳng 2D.

#include <iostream>

// Dinh nghia cau truc Diem (Point)
struct Point {
    int x; // Thanh vien x (hoanh do)
    int y; // Thanh vien y (tung do)
}; // Ket thuc dinh nghia bang dau cham phay

int main() {
    // Tao mot bien (doi tuong) thuoc kieu Point
    Point p1; 

    // Truy cap va gan gia tri cho cac thanh vien
    p1.x = 10;
    p1.y = 20;

    // Truy cap va in gia tri cac thanh vien
    cout << "Toa do cua diem p1: (" << p1.x << ", " << p1.y << ")" << endl;

    // Tao va khoi tao ngay lap tuc
    Point p2 = {30, 40}; 
    cout << "Toa do cua diem p2: (" << p2.x << ", " << p2.y << ")" << endl;

    return 0;
}

Giải thích:

  • struct Point { ... }; định nghĩa một kiểu dữ liệu mới tên là Point.
  • Bên trong {} là các biến thành viên: int x;int y;.
  • Point p1; tạo một biến (một instance) có kiểu Point.
  • p1.xp1.y dùng toán tử . để truy cập các thành viên xy của biến p1.
  • Point p2 = {30, 40}; là cách khởi tạo nhanh các thành viên khi tạo biến struct.

Ví dụ 5: Struct phức tạp hơn

Hãy tạo một struct để lưu thông tin cơ bản về một sinh viên.

#include <iostream>
#include <string> // Can cho string

// Dinh nghia cau truc SinhVien
struct SinhVien {
    string maSinhVien; // Ma sinh vien (chuoi)
    string hoTen;      // Ho va ten (chuoi)
    int tuoi;               // Tuoi (so nguyen)
    double diemTrungBinh;   // Diem trung binh (so thuc)
};

int main() {
    // Tao mot bien SinhVien
    SinhVien sv1;

    // Gan gia tri cho cac thanh vien
    sv1.maSinhVien = "SV001";
    sv1.hoTen = "Nguyen Van A";
    sv1.tuoi = 20;
    sv1.diemTrungBinh = 8.5;

    // In thong tin sinh vien
    cout << "Thong tin sinh vien:" << endl;
    cout << "  Ma SV: " << sv1.maSinhVien << endl;
    cout << "  Ho Ten: " << sv1.hoTen << endl;
    cout << "  Tuoi: " << sv1.tuoi << endl;
    cout << "  Diem TB: " << sv1.diemTrungBinh << endl;

    return 0;
}

Giải thích:

  • Struct SinhVien nhóm 4 thông tin khác loại: string, int, double.
  • Chúng ta tạo biến sv1 kiểu SinhVien và gán giá trị cho từng thành viên bằng toán tử ..

Ví dụ 6: Mảng của Structs

Chúng ta hoàn toàn có thể kết hợp các kiểu dữ liệu phức tạp với nhau. Một trường hợp phổ biến là tạo một mảng chứa các biến struct.

#include <iostream>
#include <string>

struct SanPham {
    string ten;
    double gia;
    int soLuongTonKho;
};

int main() {
    // Tao mot mang chua 3 san pham
    SanPham danhSachSP[3]; 

    // Gan du lieu cho cac san pham trong mang
    danhSachSP[0] = {"Laptop", 1200.0, 15}; // Khoi tao nhanh phan tu dau tien

    danhSachSP[1].ten = "Chuot";
    danhSachSP[1].gia = 25.5;
    danhSachSP[1].soLuongTonKho = 50;

    danhSachSP[2] = {"Ban Phim", 75.0, 30};

    // Duyet qua mang san pham va in thong tin
    cout << "Danh sach san pham:" << endl;
    for (int i = 0; i < 3; ++i) {
        cout << "- Ten: " << danhSachSP[i].ten 
                  << ", Gia: " << danhSachSP[i].gia 
                  << ", Ton kho: " << danhSachSP[i].soLuongTonKho << endl;
    }

    return 0;
}

Giải thích:

  • SanPham danhSachSP[3]; khai báo một mảng có 3 phần tử, mỗi phần tử là một biến kiểu SanPham.
  • Để truy cập thành viên của một struct nằm trong mảng, chúng ta sử dụng kết hợp toán tử [] (truy cập phần tử mảng) và toán tử . (truy cập thành viên struct): danhSachSP[i].ten.

3. vector: Mảng động linh hoạt

Nhược điểm lớn nhất của mảng C-style là kích thước cố định. Bạn cần biết trước mình sẽ lưu bao nhiêu phần tử. Trong nhiều trường hợp, số lượng dữ liệu có thể thay đổi trong quá trình chạy chương trình. Đây là lúc vector từ Thư viện chuẩn C++ trở thành giải pháp lý tưởng.

vector là một container (bộ chứa) kiểu mảng động. Nó có thể tự động thay đổi kích thước khi bạn thêm hoặc bớt phần tử.

Đặc điểm chính của vector:

  • Kích thước động, có thể thay đổi lúc runtime.
  • Cung cấp nhiều hàm tiện ích (thêm, xóa, lấy kích thước,...).
  • Các phần tử cũng được lưu trữ liền kề trong bộ nhớ (giống mảng).
  • Yêu cầu #include <vector>.

Ví dụ 7: Tạo và thêm phần tử vào vector

#include <iostream>
#include <vector> // Can cho vector

int main() {
    // Khai bao mot vector chua so nguyen rong
    vector<int> soNguyen; 

    // Them phan tu vao cuoi vector
    soNguyen.push_back(10);
    soNguyen.push_back(25);
    soNguyen.push_back(5);

    // In kich thuoc hien tai cua vector
    cout << "Kich thuoc vector sau khi them: " << soNguyen.size() << endl; 

    // Them mot phan tu nua
    soNguyen.push_back(42);
    cout << "Kich thuoc vector sau khi them nua: " << soNguyen.size() << endl;

    return 0;
}

Giải thích:

  • vector<int> soNguyen; khai báo một vector có thể chứa các số nguyên. Ban đầu nó rỗng.
  • .push_back(value) là hàm để thêm value vào cuối vector. Vector sẽ tự động "lớn lên" để chứa phần tử mới.
  • .size() là hàm trả về số lượng phần tử hiện có trong vector.

Ví dụ 8: Truy cập và duyệt vector

Bạn có thể truy cập phần tử của vector giống như mảng truyền thống bằng toán tử [], hoặc dùng hàm .at() (an toàn hơn vì nó kiểm tra giới hạn).

#include <iostream>
#include <vector>

int main() {
    vector<double> nhietDo = {22.1, 23.5, 21.9, 24.0, 25.2};

    // Truy cap phan tu bang chi so (giong mang)
    cout << "Nhiet do ngay thu 2 (chi so 1): " << nhietDo[1] << endl; 

    // Truy cap phan tu bang .at() (an toan hon)
    cout << "Nhiet do ngay thu 4 (chi so 3): " << nhietDo.at(3) << endl; 

    // Duyet vector bang vong lap for truyen thong
    cout << "\nDanh sach nhiet do (for):" << endl;
    for (int i = 0; i < nhietDo.size(); ++i) { // Dung .size() de lay kich thuoc dong
        cout << nhietDo[i] << " ";
    }
    cout << endl;

    // Duyet vector bang range-based for loop
    cout << "Danh sach nhiet do (range-based for):" << endl;
    for (double temp : nhietDo) {
        cout << temp << " ";
    }
    cout << endl;

    return 0;
}

Giải thích:

  • nhietDo[1]nhietDo.at(3) đều truy cập phần tử. .at(index) sẽ ném ra một ngoại lệ nếu index nằm ngoài phạm vi hợp lệ, giúp phát hiện lỗi sớm hơn là chỉ truy cập bằng [] (có thể gây ra lỗi runtime không mong muốn).
  • Vòng lặp for truyền thống dùng nhietDo.size() để biết khi nào dừng.
  • Range-based for loop hoạt động rất tốt với vector.

4. string: Làm việc với văn bản dễ dàng

Làm việc với chuỗi ký tự là một tác vụ rất phổ biến trong lập trình. C-style strings (mảng ký tự kết thúc bằng '\0') có thể hơi phức tạp và dễ gây lỗi (ví dụ: tràn bộ đệm). string từ Thư viện chuẩn C++ cung cấp một cách an toàn và tiện lợi để xử lý văn bản.

Đặc điểm chính của string:

  • Quản lý bộ nhớ tự động.
  • Cung cấp nhiều hàm tiện ích (nối chuỗi, tìm kiếm, lấy độ dài, so sánh,...).
  • Có thể thay đổi kích thước.
  • Yêu cầu #include <string>.

Ví dụ 9: Khai báo và thao tác với string

#include <iostream>
#include <string> // Can cho string

int main() {
    // Khai bao va khoi tao string
    string ten = "FullhouseDev";
    string loiChao = "Xin chao, ";

    // Noi chuoi (concatenation)
    string thongDiep = loiChao + ten + "!"; 

    // In chuoi
    cout << thongDiep << endl;

    // Lay do dai chuoi
    cout << "Do dai cua chuoi thongDiep: " << thongDiep.length() << endl; // hoac .size()

    // Truy cap ky tu tai vi tri cu the (giong mang)
    cout << "Ky tu dau tien: " << thongDiep[0] << endl;
    cout << "Ky tu cuoi cung: " << thongDiep[thongDiep.length() - 1] << endl;

    return 0;
}

Giải thích:

  • string ten = "FullhouseDev"; tạo và khởi tạo một chuỗi.
  • Toán tử + được nạp chồng (overload) cho string để nối chuỗi.
  • .length() (hoặc .size()) trả về số ký tự trong chuỗi.
  • Bạn có thể truy cập từng ký tự bằng toán tử [], giống như mảng ký tự.

Ví dụ 10: Đọc chuỗi từ nhập liệu

Khi đọc chuỗi từ bàn phím, cần lưu ý cách cin >> hoạt động và khi nào nên dùng getline.

#include <iostream>
#include <string>
#include <limits> // Can cho numeric_limits

int main() {
    string tenRieng;
    string tenDayDu;

    cout << "Nhap ten rieng (chi mot tu): ";
    cin >> tenRieng; // Doc den khoang trang hoac Enter
    cout << "Ten rieng ban vua nhap: " << tenRieng << endl;

    // Can phai "xoa" ky tu Enter con sot lai trong bo dem nhap
    cin.ignore(numeric_limits<streamsize>::max(), '\n'); 

    cout << "Nhap ho va ten day du: ";
    getline(cin, tenDayDu); // Doc toan bo dong den khi gap Enter
    cout << "Ho ten day du ban vua nhap: " << tenDayDu << endl;

    return 0;
}

Giải thích:

  • cin >> tenRieng; chỉ đọc một từ duy nhất, dừng lại khi gặp khoảng trắng, tab hoặc Enter.
  • getline(cin, tenDayDu); đọc toàn bộ dòng văn bản cho đến khi gặp ký tự Enter.
  • Sau khi dùng cin >>, ký tự Enter vẫn còn lại trong bộ đệm nhập. Nếu không "xóa" nó (bằng cin.ignore(...)), lệnh getline tiếp theo sẽ đọc ngay ký tự Enter đó và kết thúc ngay lập tức mà không chờ bạn nhập gì cả. Lệnh cin.ignore(...) dùng để loại bỏ phần còn lại của dòng trước đó khỏi bộ đệm.

5. Kết hợp các kiểu dữ liệu phức tạp

Sức mạnh thực sự đến khi chúng ta kết hợp các kiểu dữ liệu này. Ví dụ, bạn có thể tạo một vector chứa các đối tượng SinhVien (đã dùng struct ở trên), mỗi đối tượng SinhVien lại chứa string cho tên và mã số.

Ví dụ 11: vector chứa Struct

#include <iostream>
#include <vector>
#include <string>

// Dinh nghia lai struct SinhVien
struct SinhVien {
    string maSinhVien;
    string hoTen;
    int tuoi;
    double diemTrungBinh;
};

int main() {
    // Tao mot vector chua cac doi tuong SinhVien
    vector<SinhVien> danhSachSinhVien;

    // Tao va them sinh vien vao vector
    SinhVien sv1 = {"SV001", "Nguyen Van A", 20, 8.5};
    danhSachSinhVien.push_back(sv1);

    danhSachSinhVien.push_back({"SV002", "Tran Thi B", 19, 9.0}); // Them truc tiep doi tuong khoi tao nhanh

    SinhVien sv3;
    sv3.maSinhVien = "SV003";
    sv3.hoTen = "Le Van C";
    sv3.tuoi = 21;
    sv3.diemTrungBinh = 7.8;
    danhSachSinhVien.push_back(sv3);

    // Duyet qua vector va in thong tin tung sinh vien
    cout << "Danh sach sinh vien trong vector:" << endl;
    for (const SinhVien& sv : danhSachSinhVien) { // Nen dung tham chieu const de hieu qua hon
        cout << "  Ma SV: " << sv.maSinhVien 
                  << ", Ho Ten: " << sv.hoTen 
                  << ", Tuoi: " << sv.tuoi 
                  << ", Diem TB: " << sv.diemTrungBinh << endl;
    }

    return 0;
}

Giải thích:

  • vector<SinhVien> danhSachSinhVien; khai báo một vector mà mỗi phần tử của nó là một struct SinhVien.
  • Chúng ta có thể tạo một biến SinhVien riêng rồi dùng push_back để thêm vào vector, hoặc khởi tạo struct ngay trong lời gọi push_back.
  • Khi duyệt vector, mỗi biến sv trong vòng lặp for (const SinhVien& sv : danhSachSinhVien) là một tham chiếu đến một đối tượng SinhVien trong vector. Việc sử dụng const SinhVien& là một practice tốt: & (tham chiếu) giúp tránh việc copy toàn bộ struct lớn trong mỗi lần lặp (tiết kiệm hiệu năng), và const đảm bảo chúng ta không vô tình thay đổi dữ liệu của sinh viên khi duyệt.

Bài tập thực hành này đã giới thiệu cho bạn những kiểu dữ liệu phức tạp quan trọng nhất trong C++: Mảng (cố định, cùng loại), Cấu trúc (Struct) (gom nhóm khác loại), vector (mảng động, linh hoạt) và string (chuỗi ký tự tiện lợi).

Comments

There are no comments at the moment.