Bài 6.1. Kỹ thuật xử lý mảng một chiều

Bài 6.1. Kỹ thuật xử lý mảng một chiều
Chào mừng bạn đến với chuỗi bài viết về Cấu trúc Dữ liệu và Giải thuật của FullhouseDev!
Trong thế giới lập trình, mảng là một trong những cấu trúc dữ liệu cơ bản và được sử dụng phổ biến nhất. Đặc biệt, mảng một chiều (1D Array) là viên gạch đầu tiên mà bất kỳ lập trình viên nào cũng cần nắm vững. Nó không chỉ đơn giản về cấu trúc mà còn là nền tảng cho nhiều cấu trúc dữ liệu phức tạp hơn. Bài viết này sẽ đi sâu vào các kỹ thuật xử lý mảng một chiều hiệu quả, giúp bạn làm chủ công cụ mạnh mẽ này trong C++.
Mảng Một Chiều: Hiểu Đúng Bản Chất
Trước khi đi vào các kỹ thuật, hãy cùng nhắc lại một chút về mảng một chiều. Về cơ bản, mảng một chiều là một tập hợp các phần tử có cùng kiểu dữ liệu, được lưu trữ trong các vùng nhớ liền kề nhau. Điều này có ý nghĩa cực kỳ quan trọng:
- Truy cập nhanh chóng: Nhờ tính liền kề, bạn có thể truy cập trực tiếp vào bất kỳ phần tử nào của mảng chỉ bằng chỉ số (index) của nó. Độ phức tạp cho thao tác này là O(1) - không đổi, bất kể mảng lớn hay nhỏ.
- Kích thước cố định (đối với mảng C-style truyền thống): Khi khai báo mảng C-style (
int arr[10];
), kích thước mảng thường được xác định tại thời điểm biên dịch và không thể thay đổi sau đó. Điều này có những hạn chế nhất định khi cần thêm hoặc bớt phần tử.
Trong C++, chúng ta có thể sử dụng mảng C-style hoặc std::array
(kích thước cố định, an toàn hơn) hoặc std::vector
(mảng động, kích thước thay đổi được). Bài viết này sẽ tập trung vào các kỹ thuật xử lý chung áp dụng cho mảng một chiều, và chủ yếu sử dụng C-style array hoặc std::vector
trong ví dụ code để minh họa sự linh hoạt.
Khởi Tạo và Truy Cập Phần Tử
Đây là thao tác cơ bản nhất. Bạn khai báo mảng và gán giá trị, sau đó truy cập các phần tử thông qua chỉ số (bắt đầu từ 0).
#include <iostream>
#include <vector> // Sử dụng vector cho sự linh hoạt
int main() {
// Khai báo và khởi tạo mảng C-style
int mangCoDinh[5] = {10, 20, 30, 40, 50};
// Truy cập phần tử
std::cout << "Phan tu tai chi so 0: " << mangCoDinh[0] << std::endl; // Output: 10
std::cout << "Phan tu tai chi so 3: " << mangCoDinh[3] << std::endl; // Output: 40
// Thay đổi giá trị phần tử
mangCoDinh[1] = 25;
std::cout << "Phan tu tai chi so 1 sau khi thay doi: " << mangCoDinh[1] << std::endl; // Output: 25
std::cout << "---" << std::endl;
// Khai bao va khoi tao std::vector (mang dong)
std::vector<double> mangDong = {1.1, 2.2, 3.3, 4.4};
// Truy cap phan tu
std::cout << "Phan tu dau tien cua vector: " << mangDong[0] << std::endl; // Output: 1.1
std::cout << "Phan tu cuoi cung cua vector: " << mangDong[mangDong.size() - 1] << std::endl; // Output: 4.4
// Them phan tu vao cuoi vector (khong the voi mang C-style co dinh)
mangDong.push_back(5.5);
std::cout << "Vector sau khi them phan tu cuoi: ";
for (double val : mangDong) {
std::cout << val << " ";
}
std::cout << std::endl; // Output: Vector sau khi them phan tu cuoi: 1.1 2.2 3.3 4.4 5.5
return 0;
}
Giải thích code:
- Chúng ta khai báo cả mảng C-style (
mangCoDinh
) vàstd::vector
(mangDong
) để minh họa. - Truy cập phần tử sử dụng cú pháp
ten_mang[chi_so]
. Chỉ số luôn bắt đầu từ 0 và kết thúc ở kích thước mảng trừ đi 1. - Việc gán giá trị mới vào một chỉ số cụ thể cũng tương tự:
ten_mang[chi_so] = gia_tri_moi;
. Thao tác này là O(1). - Với
std::vector
, chúng ta có thể sử dụng.push_back()
để thêm phần tử vào cuối, làm tăng kích thước của nó một cách tự động (không thể làm điều này dễ dàng với mảng C-style cố định).
Kỹ Thuật Duyệt Mảng (Traversal)
Duyệt mảng là quá trình truy cập từng phần tử của mảng, thường là từ đầu đến cuối, để thực hiện một thao tác nào đó (in ra, tính tổng, tìm kiếm, v.v.). Có nhiều cách để duyệt mảng trong C++.
Duyệt bằng vòng lặp for
truyền thống
Đây là cách phổ biến nhất, sử dụng một biến đếm làm chỉ số.
#include <iostream>
int main() {
int arr[] = {10, 20, 30, 40, 50};
int kichThuoc = sizeof(arr) / sizeof(arr[0]); // Tính kích thước mảng C-style
std::cout << "Duyet mang bang for truyen thong: ";
for (int i = 0; i < kichThuoc; ++i) {
std::cout << arr[i] << " "; // Truy cap phan tu bang chi so i
}
std::cout << std::endl;
return 0;
}
Giải thích code:
- Chúng ta sử dụng
sizeof(arr) / sizeof(arr[0])
để tính kích thước của mảng C-style. Lưu ý: Cách này chỉ hoạt động khiarr
là mảng thực sự, không phải con trỏ trỏ đến phần tử đầu tiên của mảng. - Vòng lặp
for
bắt đầu từ chỉ số 0 (i = 0
) và tiếp tục cho đến khii
nhỏ hơn kích thước mảng (i < kichThuoc
). - Trong mỗi lần lặp, chúng ta truy cập phần tử tại chỉ số
i
(arr[i]
).
Duyệt bằng vòng lặp range-based for
(C++11 trở lên)
Cách này gọn gàng hơn nhiều khi bạn chỉ cần truy cập giá trị của từng phần tử mà không cần quan tâm đến chỉ số.
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec = {100, 200, 300, 400};
std::cout << "Duyet vector bang range-based for: ";
for (int element : vec) { // 'element' se lan luot nhan gia tri cua cac phan tu trong 'vec'
std::cout << element << " ";
}
std::cout << std::endl;
int arr[] = {1, 2, 3, 4, 5};
std::cout << "Duyet mang C-style bang range-based for: ";
for (int element : arr) { // 'element' se lan luot nhan gia tri cua cac phan tu trong 'arr'
std::cout << element << " ";
}
std::cout << std::endl;
// Neu muon thay doi gia tri phan tu trong luc duyet bang range-based for
std::cout << "Thay doi gia tri trong luc duyet: ";
for (int& element : vec) { // Su dung tham chieu '&' de co the thay doi gia tri goc
element += 1; // Tang moi phan tu len 1
std::cout << element << " ";
}
std::cout << std::endl; // Output: 101 201 301 401
return 0;
}
Giải thích code:
- Cú pháp
for (kieu_du_lieu ten_bien : container)
cho phép lặp qua từng phần tử củacontainer
(mảng, vector, list, v.v.). - Biến
ten_bien
sẽ lần lượt nhận giá trị của các phần tử trongcontainer
. - Nếu bạn cần thay đổi giá trị của các phần tử trong quá trình duyệt bằng
range-based for
, bạn cần sử dụng tham chiếu (&
) cho biến lặp (int& element
).
Độ phức tạp của duyệt mảng luôn là O(n), với n là số lượng phần tử, vì bạn cần xem xét (ít nhất là truy cập) mỗi phần tử một lần.
Kỹ Thuật Tìm Kiếm (Searching)
Tìm kiếm là bài toán xác định xem một giá trị có tồn tại trong mảng hay không, và nếu có thì ở vị trí nào.
Tìm kiếm tuyến tính (Linear Search)
Đây là kỹ thuật đơn giản nhất: duyệt qua từng phần tử của mảng từ đầu đến cuối và so sánh nó với giá trị cần tìm.
#include <iostream>
#include <vector>
// Ham tim kiem tuyen tinh
int timKiemTuyenTinh(const std::vector<int>& arr, int giaTriCanTim) {
for (int i = 0; i < arr.size(); ++i) {
if (arr[i] == giaTriCanTim) {
return i; // Tim thay, tra ve chi so
}
}
return -1; // Khong tim thay, tra ve -1 (quy uoc)
}
int main() {
std::vector<int> numbers = {15, 22, 8, 45, 30, 8};
int giaTri1 = 45;
int giaTri2 = 100;
int giaTri3 = 8;
int viTri1 = timKiemTuyenTinh(numbers, giaTri1);
if (viTri1 != -1) {
std::cout << "Tim thay " << giaTri1 << " tai chi so: " << viTri1 << std::endl; // Output: Tim thay 45 tai chi so: 3
} else {
std::cout << "Khong tim thay " << giaTri1 << " trong mang." << std::endl;
}
int viTri2 = timKiemTuyenTinh(numbers, giaTri2);
if (viTri2 != -1) {
std::cout << "Tim thay " << giaTri2 << " tai chi so: " << viTri2 << std::endl;
} else {
std::cout << "Khong tim thay " << giaTri2 << " trong mang." << std::endl; // Output: Khong tim thay 100 trong mang.
}
int viTri3 = timKiemTuyenTinh(numbers, giaTri3);
if (viTri3 != -1) {
std::cout << "Tim thay " << giaTri3 << " tai chi so: " << viTri3 << std::endl; // Output: Tim thay 8 tai chi so: 2 (tim thay lan dau tien)
} else {
std::cout << "Khong tim thay " << giaTri3 << " trong mang." << std::endl;
}
return 0;
}
Giải thích code:
- Hàm
timKiemTuyenTinh
nhận vào một vector (hoặc mảng) và giá trị cần tìm. - Nó duyệt qua từng phần tử từ chỉ số 0 đến cuối.
- Nếu tìm thấy phần tử có giá trị bằng
giaTriCanTim
, hàm trả về ngay lập tức chỉ số của phần tử đó. - Nếu vòng lặp kết thúc mà không tìm thấy, hàm trả về -1 (một giá trị quy ước để chỉ không tìm thấy).
- Độ phức tạp của tìm kiếm tuyến tính là O(n) trong trường hợp xấu nhất (phần tử ở cuối hoặc không có trong mảng) và O(1) trong trường hợp tốt nhất (phần tử đầu tiên).
Tìm kiếm nhị phân (Binary Search)
Kỹ thuật này hiệu quả hơn đáng kể so với tìm kiếm tuyến tính, nhưng có một yêu cầu bắt buộc: mảng phải được sắp xếp.
Ý tưởng là liên tục chia đôi mảng tìm kiếm. So sánh giá trị cần tìm với phần tử ở giữa mảng.
- Nếu bằng, tìm kiếm thành công.
- Nếu nhỏ hơn, tiếp tục tìm kiếm ở nửa bên trái.
- Nếu lớn hơn, tiếp tục tìm kiếm ở nửa bên phải.
Quá trình này lặp lại cho đến khi tìm thấy hoặc phạm vi tìm kiếm trở nên rỗng.
#include <iostream>
#include <vector>
#include <algorithm> // Can std::sort de sap xep mang truoc khi tim kiem nhi phan
// Ham tim kiem nhi phan (ap dung cho mang DA SAP XEP)
int timKiemNhiPhan(const std::vector<int>& arr, int giaTriCanTim) {
int low = 0;
int high = arr.size() - 1;
while (low <= high) {
int mid = low + (high - low) / 2; // Tranh tran so voi (low + high) / 2
if (arr[mid] == giaTriCanTim) {
return mid; // Tim thay, tra ve chi so giua
} else if (arr[mid] < giaTriCanTim) {
low = mid + 1; // Gia tri can tim lon hon, tim o nua ben phai
} else { // arr[mid] > giaTriCanTim
high = mid - 1; // Gia tri can tim nho hon, tim o nua ben trai
}
}
return -1; // Khong tim thay
}
int main() {
std::vector<int> sortedNumbers = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100}; // Mang da sap xep
int giaTri1 = 50;
int giaTri2 = 35;
int viTri1 = timKiemNhiPhan(sortedNumbers, giaTri1);
if (viTri1 != -1) {
std::cout << "Tim thay " << giaTri1 << " tai chi so: " << viTri1 << " (tim kiem nhi phan)" << std::endl; // Output: Tim thay 50 tai chi so: 4 (tim kiem nhi phan)
} else {
std::cout << "Khong tim thay " << giaTri1 << " trong mang (tim kiem nhi phan)." << std::endl;
}
int viTri2 = timKiemNhiPhan(sortedNumbers, giaTri2);
if (viTri2 != -1) {
std::cout << "Tim thay " << giaTri2 << " tai chi so: " << viTri2 << " (tim kiem nhi phan)" << std::endl;
} else {
std::cout << "Khong tim thay " << giaTri2 << " trong mang (tim kiem nhi phan)." << std::endl; // Output: Khong tim thay 35 trong mang (tim kiem nhi phan).
}
// Minh hoa can phai sap xep truoc
std::vector<int> unsortedNumbers = {15, 8, 22, 45, 30};
std::cout << "Mang chua sap xep: ";
for(int val : unsortedNumbers) std::cout << val << " ";
std::cout << std::endl;
// Ket qua tim kiem nhi phan tren mang chua sap xep co the khong chinh xac!
// int viTriSai = timKiemNhiPhan(unsortedNumbers, 22); // Co the khong tim thay hoac tim thay sai vi tri
// std::cout << "Tim kiem 22 tren mang chua sap xep (co the sai): " << viTriSai << std::endl;
// Sap xep mang truoc khi tim kiem
std::sort(unsortedNumbers.begin(), unsortedNumbers.end());
std::cout << "Mang sau khi sap xep: ";
for(int val : unsortedNumbers) std::cout << val << " ";
std::cout << std::endl;
int viTriDung = timKiemNhiPhan(unsortedNumbers, 22);
if (viTriDung != -1) {
std::cout << "Tim thay " << 22 << " tai chi so: " << viTriDung << " (tren mang da sap xep)" << std::endl; // Output: Tim thay 22 tai chi so: 2 (tren mang da sap xep)
} else {
std::cout << "Khong tim thay " << 22 << " trong mang (tren mang da sap xep)." << std::endl;
}
return 0;
}
Giải thích code:
- Hàm
timKiemNhiPhan
sử dụng hai con trỏlow
vàhigh
để xác định phạm vi tìm kiếm hiện tại. Ban đầu,low
là chỉ số đầu tiên vàhigh
là chỉ số cuối cùng. - Vòng lặp
while (low <= high)
tiếp tục chừng nào phạm vi tìm kiếm còn hợp lệ. - Trong mỗi lần lặp, chúng ta tính chỉ số giữa
mid
. Công thứclow + (high - low) / 2
an toàn hơn(low + high) / 2
vì nó tránh nguy cơ tràn số nếulow
vàhigh
là các số nguyên rất lớn. - Chúng ta so sánh
arr[mid]
vớigiaTriCanTim
và điều chỉnhlow
hoặchigh
để thu hẹp phạm vi tìm kiếm vào nửa bên trái hoặc phải. - Độ phức tạp của tìm kiếm nhị phân là O(log n), nhanh hơn rất nhiều so với tìm kiếm tuyến tính cho mảng lớn. Tuy nhiên, chi phí sắp xếp ban đầu (nếu mảng chưa sắp xếp) có thể là O(n log n).
Lưu ý: Thư viện <algorithm>
trong C++ cung cấp hàm std::binary_search
(chỉ kiểm tra sự tồn tại) và std::lower_bound
, std::upper_bound
(tìm vị trí chèn hoặc vị trí của phần tử) hiệu quả hơn.
Kỹ Thuật Chèn (Insertion)
Chèn một phần tử mới vào mảng một chiều C-style cố định là một thao tác phức tạp và không hiệu quả nếu bạn cần chèn vào giữa hoặc đầu mảng, bởi vì nó đòi hỏi phải dịch chuyển các phần tử hiện có để tạo chỗ trống. Nếu mảng đã đầy, bạn thậm chí không thể chèn thêm trừ khi tạo một mảng mới lớn hơn.
Với std::vector
, thao tác chèn dễ dàng hơn nhiều nhờ khả năng tự động thay đổi kích thước, nhưng chi phí dịch chuyển vẫn tồn tại.
Hãy xem xét ví dụ chèn vào một vị trí cụ thể trong mảng C-style (giả định còn chỗ trống).
#include <iostream>
#include <vector>
// Ham chen phan tu vao mang C-style (can dam bao mang con dung luong)
// gia su mang co dung luong toi da la 100
void chenVaoMangCoDinh(int arr[], int& kichThuocThucTe, int dungLuongToiDa, int giaTriCanChen, int viTriCanChen) {
// Kiem tra vi tri chen co hop le khong
if (viTriCanChen < 0 || viTriCanChen > kichThuocThucTe) {
std::cerr << "Loi: Vi tri chen khong hop le!" << std::endl;
return;
}
// Kiem tra mang con cho trong khong
if (kichThuocThucTe >= dungLuongToiDa) {
std::cerr << "Loi: Mang da day, khong the chen!" << std::endl;
return;
}
// Dich chuyen cac phan tu tu vi tri chen den cuoi mang sang phai
for (int i = kichThuocThucTe; i > viTriCanChen; --i) {
arr[i] = arr[i - 1];
}
// Chen gia tri moi vao vi tri trong
arr[viTriCanChen] = giaTriCanChen;
// Tang kich thuoc thuc te cua mang len 1
kichThuocThucTe++;
}
int main() {
const int DUNG_LUONG = 10;
int mang[DUNG_LUONG] = {10, 20, 30, 40}; // Khoi tao 4 phan tu
int kichThuocHienTai = 4; // Kich thuoc thuc te dang su dung
std::cout << "Mang truoc khi chen: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 10 20 30 40
}
std::cout << std::endl;
// Chen gia tri 25 vao vi tri 2 (sau 20, truoc 30)
chenVaoMangCoDinh(mang, kichThuocHienTai, DUNG_LUONG, 25, 2);
std::cout << "Mang sau khi chen 25 vao vi tri 2: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 10 20 25 30 40
}
std::cout << std::endl;
// Chen gia tri 5 vao vi tri 0 (dau mang)
chenVaoMangCoDinh(mang, kichThuocHienTai, DUNG_LUONG, 5, 0);
std::cout << "Mang sau khi chen 5 vao vi tri 0: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 5 10 20 25 30 40
}
std::cout << std::endl;
// Chen gia tri 55 vao vi tri cuoi (bang kichThuocHienTai)
chenVaoMangCoDinh(mang, kichThuocHienTai, DUNG_LUONG, 55, kichThuocHienTai); // viTriCanChen = 6
std::cout << "Mang sau khi chen 55 vao vi tri cuoi: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 5 10 20 25 30 40 55
}
std::cout << std::endl;
// Thu chen khi mang day (gia su kichThuocHienTai da bang DUNG_LUONG)
// ... (can bo sung logic kiem tra mang day trong thuc te)
std::cout << "--- Chen voi std::vector ---" << std::endl;
std::vector<int> vec = {10, 20, 30, 40};
std::cout << "Vector truoc khi chen: ";
for(int val : vec) std::cout << val << " ";
std::cout << std::endl;
// Chen gia tri 25 vao vi tri 2 (index 2)
// std::vector::insert() nhan iterator lam vi tri chen
vec.insert(vec.begin() + 2, 25);
std::cout << "Vector sau khi chen 25 vao vi tri 2: ";
for(int val : vec) std::cout << val << " "; // Output: 10 20 25 30 40
std::cout << std::endl;
// Chen gia tri 5 vao vi tri 0
vec.insert(vec.begin(), 5);
std::cout << "Vector sau khi chen 5 vao vi tri 0: ";
for(int val : vec) std::cout << val << " "; // Output: 5 10 20 25 30 40
std::cout << std::endl;
return 0;
}
Giải thích code:
- Đối với mảng C-style, chúng ta phải quản lý thủ công kích thước thực tế (
kichThuocThucTe
) đang sử dụng, khác với kích thước khai báo ban đầu (DUNG_LUONG
). - Để chèn vào
viTriCanChen
, chúng ta bắt đầu từ phần tử cuối cùng hiện có (kichThuocThucTe - 1
) và dịch chuyển nó sang phải một vị trí (arr[i] = arr[i-1]
) cho đến khi đếnviTriCanChen
. - Sau khi dịch chuyển, vị trí
viTriCanChen
sẽ "trống", chúng ta gángiaTriCanChen
vào đó. - Cuối cùng, tăng
kichThuocThucTe
lên 1. - Thao tác dịch chuyển này có độ phức tạp O(n) trong trường hợp xấu nhất (chèn vào đầu mảng) vì bạn phải dịch chuyển gần như tất cả các phần tử.
- Đối với
std::vector
, hàminsert()
giúp đơn giản hóa việc này, nhưng về mặt hiệu suất, nó vẫn phải thực hiện thao tác dịch chuyển tương tự bên trong. Cú phápvec.insert(vec.begin() + viTri, giaTri)
chèngiaTri
vào vị trí trước phần tử tại chỉ sốviTri
.
Kỹ Thuật Xóa (Deletion)
Xóa một phần tử khỏi mảng C-style cố định cũng gặp vấn đề tương tự như chèn: nó đòi hỏi phải dịch chuyển các phần tử để lấp đầy khoảng trống do phần tử bị xóa để lại.
Hãy xem xét ví dụ xóa một phần tử tại một vị trí cụ thể trong mảng C-style.
#include <iostream>
#include <vector>
// Ham xoa phan tu khoi mang C-style tai mot vi tri
void xoaKhoiMangCoDinh(int arr[], int& kichThuocThucTe, int viTriCanXoa) {
// Kiem tra vi tri xoa co hop le khong
if (viTriCanXoa < 0 || viTriCanXoa >= kichThuocThucTe) {
std::cerr << "Loi: Vi tri xoa khong hop le!" << std::endl;
return;
}
// Dich chuyen cac phan tu tu vi tri can xoa + 1 den cuoi mang sang trai
for (int i = viTriCanXoa; i < kichThuocThucTe - 1; ++i) {
arr[i] = arr[i + 1];
}
// Giam kich thuoc thuc te cua mang di 1
kichThuocThucTe--;
// Luu y: Phan tu cuoi cung (tai chi so kichThuocThucTe truoc khi giam) van ton tai trong bo nho
// nhung chung ta coi nhu no khong con thuoc ve mang nua.
}
int main() {
const int DUNG_LUONG = 10;
int mang[DUNG_LUONG] = {5, 10, 20, 25, 30, 40, 55};
int kichThuocHienTai = 7;
std::cout << "Mang truoc khi xoa: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 5 10 20 25 30 40 55
}
std::cout << std::endl;
// Xoa phan tu tai vi tri 2 (gia tri 20)
xoaKhoiMangCoDinh(mang, kichThuocHienTai, 2);
std::cout << "Mang sau khi xoa phan tu tai vi tri 2: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 5 10 25 30 40 55
}
std::cout << std::endl;
// Xoa phan tu tai vi tri 0 (gia tri 5)
xoaKhoiMangCoDinh(mang, kichThuocHienTai, 0);
std::cout << "Mang sau khi xoa phan tu tai vi tri 0: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 10 25 30 40 55
}
std::cout << std::endl;
// Xoa phan tu cuoi cung (gia tri 55)
xoaKhoiMangCoDinh(mang, kichThuocHienTai, kichThuocHienTai - 1);
std::cout << "Mang sau khi xoa phan tu cuoi: ";
for (int i = 0; i < kichThuocHienTai; ++i) {
std::cout << mang[i] << " "; // Output: 10 25 30 40
}
std::cout << std::endl;
std::cout << "--- Xoa voi std::vector ---" << std::endl;
std::vector<int> vec = {5, 10, 20, 25, 30, 40, 55};
std::cout << "Vector truoc khi xoa: ";
for(int val : vec) std::cout << val << " ";
std::cout << std::endl;
// Xoa phan tu tai vi tri 2 (index 2)
// std::vector::erase() nhan iterator lam vi tri xoa
vec.erase(vec.begin() + 2);
std::cout << "Vector sau khi xoa phan tu tai vi tri 2: ";
for(int val : vec) std::cout << val << " "; // Output: 5 10 25 30 40 55
std::cout << std::endl;
// Xoa phan tu tai vi tri 0
vec.erase(vec.begin());
std::cout << "Vector sau khi xoa phan tu tai vi tri 0: ";
for(int val : vec) std::cout << val << " "; // Output: 10 25 30 40 55
std::cout << std::endl;
// Xoa phan tu cuoi cung
vec.pop_back(); // Cach don gian hon xoa phan tu cuoi
// Hoac vec.erase(vec.end() - 1);
std::cout << "Vector sau khi xoa phan tu cuoi: ";
for(int val : vec) std::cout << val << " "; // Output: 10 25 30 40
std::cout << std::endl;
return 0;
}
Giải thích code:
- Tương tự như chèn, chúng ta quản lý
kichThuocThucTe
. - Để xóa phần tử tại
viTriCanXoa
, chúng ta bắt đầu từviTriCanXoa
và dịch chuyển tất cả các phần tử sau nó sang trái một vị trí (arr[i] = arr[i+1]
) cho đến gần cuối mảng (kichThuocThucTe - 1
). - Sau khi dịch chuyển, giảm
kichThuocThucTe
đi 1. Phần tử cuối cùng ban đầu vẫn còn đó nhưng không còn nằm trong phạm vi "thực tế" của mảng. - Thao tác dịch chuyển này cũng có độ phức tạp O(n) trong trường hợp xấu nhất (xóa phần tử đầu tiên).
std::vector::erase()
vàstd::vector::pop_back()
(xóa cuối) cung cấp các cách tiện lợi để xóa phần tử, nhưng chi phí dịch chuyển vẫn có thể xảy ra bên dưới (đặc biệt khi xóa ở đầu hoặc giữa).
Lưu ý: Do chi phí O(n) cho chèn và xóa ở vị trí bất kỳ, mảng một chiều C-style không phải là lựa chọn tốt nhất cho các ứng dụng cần chèn/xóa thường xuyên ở giữa mảng. Các cấu trúc dữ liệu khác như Danh sách liên kết (std::list
trong C++) sẽ hiệu quả hơn cho các thao tác này (O(1)), nhưng lại mất đi khả năng truy cập O(1) bằng chỉ số. std::vector
là sự cân bằng, truy cập O(1), nhưng chèn/xóa ở giữa vẫn O(n).
Kỹ Thuật Sắp Xếp (Sorting)
Sắp xếp là quá trình sắp xếp các phần tử trong mảng theo một thứ tự nhất định (tăng dần hoặc giảm dần). Có rất nhiều thuật toán sắp xếp khác nhau với độ phức tạp và ưu nhược điểm riêng. Dưới đây là một thuật toán sắp xếp đơn giản nhất (nhưng kém hiệu quả) để minh họa: Sắp xếp nổi bọt (Bubble Sort), và sau đó là cách sử dụng thư viện chuẩn C++.
Sắp xếp nổi bọt (Bubble Sort)
Thuật toán này lặp đi lặp lại việc duyệt qua mảng, so sánh các cặp phần tử liền kề và hoán đổi chúng nếu chúng không đúng thứ tự, cho đến khi mảng được sắp xếp.
#include <iostream>
#include <vector>
// Ham sap xep noi bot (Bubble Sort)
void bubbleSort(std::vector<int>& arr) {
int n = arr.size();
// Duyet qua tat ca cac phan tu trong mang
for (int i = 0; i < n - 1; ++i) {
// vong lap thu nhat co nhiem vu day phan tu lon nhat chua duoc sap xep ve cuoi
// Duyet cac cap phan tu lien ke
for (int j = 0; j < n - 1 - i; ++j) { // -i vi cac phan tu cuoi cung da dung cho
// Neu phan tu hien tai lon hon phan tu ke tiep, hoan doi chung
if (arr[j] > arr[j + 1]) {
// Hoan doi arr[j] va arr[j+1]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
int main() {
std::vector<int> numbers = {64, 34, 25, 12, 22, 11, 90};
std::cout << "Mang truoc khi sap xep: ";
for(int val : numbers) std::cout << val << " "; // Output: 64 34 25 12 22 11 90
std::cout << std::endl;
bubbleSort(numbers);
std::cout << "Mang sau khi sap xep (Bubble Sort): ";
for(int val : numbers) std::cout << val << " "; // Output: 11 12 22 25 34 64 90
std::cout << std::endl;
return 0;
}
Giải thích code:
- Thuật toán sử dụng hai vòng lặp lồng nhau. Vòng lặp ngoài chạy
n-1
lần, đảm bảo rằng sau mỗi lần lặp ngoài, phần tử lớn nhất còn lại sẽ nổi lên cuối mảng (đúng vị trí). - Vòng lặp trong duyệt qua các cặp phần tử liền kề chưa được sắp xếp.
- Nếu
arr[j]
lớn hơnarr[j+1]
, chúng được hoán đổi vị trí. - Độ phức tạp của Bubble Sort là O(n^2) trong trường hợp xấu nhất và trung bình, làm cho nó không hiệu quả cho các mảng lớn.
Sắp xếp bằng thư viện chuẩn std::sort
Cách thực tế và hiệu quả nhất để sắp xếp mảng hoặc vector trong C++ là sử dụng hàm std::sort
từ thư viện <algorithm>
. Hàm này thường sử dụng thuật toán Introsort (kết hợp QuickSort, HeapSort và InsertionSort) có hiệu suất trung bình là O(n log n).
#include <iostream>
#include <vector>
#include <algorithm> // Can thu vien nay
int main() {
std::vector<int> numbers = {64, 34, 25, 12, 22, 11, 90};
std::cout << "Mang truoc khi sap xep: ";
for(int val : numbers) std::cout << val << " "; // Output: 64 34 25 12 22 11 90
std::cout << std::endl;
// Sap xep tang dan
std::sort(numbers.begin(), numbers.end());
std::cout << "Mang sau khi sap xep tang dan (std::sort): ";
for(int val : numbers) std::cout << val << " "; // Output: 11 12 22 25 34 64 90
std::cout << std::endl;
std::vector<int> anotherNumbers = {5, 2, 8, 1, 9, 4};
std::cout << "Mang truoc khi sap xep: ";
for(int val : anotherNumbers) std::cout << val << " "; // Output: 5 2 8 1 9 4
std::cout << std::endl;
// Sap xep giam dan
std::sort(anotherNumbers.begin(), anotherNumbers.end(), std::greater<int>());
std::cout << "Mang sau khi sap xep giam dan (std::sort): ";
for(int val : anotherNumbers) std::cout << val << " "; // Output: 9 8 5 4 2 1
std::cout << std::endl;
return 0;
}
Giải thích code:
- Hàm
std::sort
nhận hai iterator: iterator đến phần tử đầu tiên và iterator đến phần tử sau phần tử cuối cùng của dãy cần sắp xếp. Vớistd::vector
, chúng ta sử dụngvec.begin()
vàvec.end()
. Với mảng C-style, bạn có thể sử dụng con trỏarr
vàarr + kich_thuoc
. - Theo mặc định,
std::sort
sắp xếp theo thứ tự tăng dần. - Để sắp xếp giảm dần, bạn có thể truyền thêm một tham số thứ ba là hàm so sánh, ví dụ
std::greater<int>()
. - Độ phức tạp là O(n log n), hiệu quả hơn nhiều so với Bubble Sort cho mảng lớn.
Các Kỹ Thuật Xử Lý Khác
Ngoài các thao tác cơ bản, mảng một chiều còn được sử dụng trong nhiều bài toán khác.
Tìm phần tử lớn nhất/nhỏ nhất
Duyệt qua mảng và theo dõi phần tử lớn nhất/nhỏ nhất tìm được cho đến thời điểm đó.
#include <iostream>
#include <vector>
#include <limits> // Can std::numeric_limits
int main() {
std::vector<int> numbers = {15, 8, 22, 45, 5, 30};
if (numbers.empty()) {
std::cout << "Mang rong, khong co phan tu lon nhat/nho nhat." << std::endl;
return 0;
}
int maxVal = std::numeric_limits<int>::min(); // Khoi tao gia tri max rat nho
int minVal = std::numeric_limits<int>::max(); // Khoi tao gia tri min rat lon
for (int val : numbers) {
if (val > maxVal) {
maxVal = val; // Cap nhat max neu tim thay gia tri lon hon
}
if (val < minVal) {
minVal = val; // Cap nhat min neu tim thay gia tri nho hon
}
}
std::cout << "Phan tu lon nhat: " << maxVal << std::endl; // Output: 45
std::cout << "Phan tu nho nhat: " << minVal << std::endl; // Output: 5
// Co the dung thu vien chuan:
// auto max_it = std::max_element(numbers.begin(), numbers.end());
// auto min_it = std::min_element(numbers.begin(), numbers.end());
// std::cout << "Max (std::): " << *max_it << std::endl;
// std::cout << "Min (std::): " << *min_it << std::endl;
return 0;
}
Giải thích code:
- Chúng ta khởi tạo
maxVal
với giá trị nhỏ nhất có thể của kiểuint
vàminVal
với giá trị lớn nhất có thể. Điều này đảm bảo rằng phần tử đầu tiên của mảng (hoặc bất kỳ phần tử nào) sẽ cập nhật đúngmaxVal
vàminVal
. - Duyệt qua từng phần tử, so sánh nó với
maxVal
vàminVal
hiện tại và cập nhật nếu cần. - Độ phức tạp là O(n) vì chúng ta duyệt qua mảng một lần. Thư viện chuẩn
std::max_element
vàstd::min_element
cũng có độ phức tạp O(n).
Tính tổng hoặc trung bình cộng
Duyệt qua mảng và cộng dồn giá trị của các phần tử.
#include <iostream>
#include <vector>
#include <numeric> // Can thu vien nay cho std::accumulate
int main() {
std::vector<double> numbers = {10.5, 20.0, 15.5, 5.0};
if (numbers.empty()) {
std::cout << "Mang rong, khong the tinh tong hoac trung binh." << std::endl;
return 0;
}
double tong = 0.0;
for (double val : numbers) {
tong += val; // Cong don gia tri
}
double trungBinh = tong / numbers.size();
std::cout << "Tong cac phan tu: " << tong << std::endl; // Output: 51
std::cout << "Trung binh cong: " << trungBinh << std::endl; // Output: 12.75
// Co the dung thu vien chuan:
// double tong_std = std::accumulate(numbers.begin(), numbers.end(), 0.0);
// std::cout << "Tong (std::accumulate): " << tong_std << std::endl;
return 0;
}
Giải thích code:
- Khởi tạo biến
tong
bằng 0. - Duyệt qua mảng và cộng giá trị của từng phần tử vào
tong
. - Tính trung bình bằng cách chia tổng cho số lượng phần tử.
- Độ phức tạp là O(n). Thư viện chuẩn
std::accumulate
cũng có độ phức tạp O(n).
Đảo ngược mảng
Đổi chỗ các phần tử ở hai đầu mảng cho nhau, tiến dần vào giữa.
#include <iostream>
#include <vector>
#include <algorithm> // Can thu vien nay cho std::reverse
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
std::cout << "Mang truoc khi dao nguoc: ";
for(int val : numbers) std::cout << val << " "; // Output: 1 2 3 4 5
std::cout << std::endl;
int left = 0;
int right = numbers.size() - 1;
while (left < right) {
// Hoan doi phan tu tai left va right
int temp = numbers[left];
numbers[left] = numbers[right];
numbers[right] = temp;
// Dich chuyen con tro vao trong
left++;
right--;
}
std::cout << "Mang sau khi dao nguoc (thu cong): ";
for(int val : numbers) std::cout << val << " "; // Output: 5 4 3 2 1
std::cout << std::endl;
std::vector<int> anotherNumbers = {10, 20, 30, 40};
std::cout << "Mang truoc khi dao nguoc: ";
for(int val : anotherNumbers) std::cout << val << " "; // Output: 10 20 30 40
std::cout << std::endl;
// Dung thu vien chuan
std::reverse(anotherNumbers.begin(), anotherNumbers.end());
std::cout << "Mang sau khi dao nguoc (std::reverse): ";
for(int val : anotherNumbers) std::cout << val << " "; // Output: 40 30 20 10
std::cout << std::endl;
return 0;
}
Giải thích code:
- Sử dụng hai con trỏ (hoặc chỉ số)
left
bắt đầu từ đầu vàright
bắt đầu từ cuối. - Trong khi
left
còn nhỏ hơnright
, hoán đổi phần tử tại hai vị trí này. - Sau khi hoán đổi, dịch chuyển
left
tiến lên vàright
lùi lại. - Quá trình dừng lại khi
left
không còn nhỏ hơnright
(chúng gặp nhau hoặc vượt qua nhau). - Độ phức tạp là O(n) vì mỗi phần tử chỉ được truy cập và hoán đổi tối đa một lần. Thư viện chuẩn
std::reverse
cũng có độ phức tạp O(n).
Khi nào sử dụng Mảng Một Chiều?
Mảng một chiều (bao gồm cả std::vector
) là lựa chọn tốt khi:
- Bạn cần lưu trữ một tập hợp các phần tử cùng kiểu dữ liệu.
- Bạn cần truy cập các phần tử dựa trên chỉ số một cách nhanh chóng (O(1)).
- Bạn thường xuyên duyệt qua toàn bộ mảng.
- Với mảng C-style cố định hoặc
std::array
: khi bạn biết trước chính xác hoặc giới hạn trên của số lượng phần tử và kích thước không thay đổi nhiều. - Với
std::vector
: khi số lượng phần tử có thể thay đổi linh hoạt, và các thao tác chèn/xóa chủ yếu diễn ra ở cuối (vìpush_back
,pop_back
thường là O(1) trung bình - amortized O(1)).
Mảng một chiều C-style không phù hợp khi:
- Bạn cần chèn hoặc xóa các phần tử thường xuyên ở đầu hoặc giữa mảng, do chi phí O(n) của việc dịch chuyển.
- Kích thước của tập dữ liệu thay đổi rất lớn và không thể dự đoán trước.
Bài tập ví dụ:
Gửi thư.
Tất cả các thành phố của Lineland đều nằm trên trục tọa độ Ox. Do đó, mỗi thành phố được liên kết với vị trí xi - tọa độ trên trục Ox. Không có hai thành phố được đặt tại một điểm. Cư dân Lineland thích gửi thư cho nhau. Một người chỉ có thể gửi thư nếu người nhận sống ở một thành phố khác. Chi phí gửi thư chính xác bằng khoảng cách giữa thành phố của người gửi và thành phố của người nhận. Đối với mỗi thành phố, hãy tính hai giá trị mini và maxi, trong đó mini là chi phí tối thiểu để gửi thư từ thành phố thứ i đến một thành phố khác và maxi là chi phí tối đa để gửi thư từ thành phố thứ i đến một số thành phố khác.
Input Format
Dòng đầu tiên là số nguyên dương n(2 ≤ n ≤ 10^6) Dòng thứ hai chứa chuỗi n số nguyên khác nhau x1, x2, ..., xn (-10^10<= xi <=10^10), trong đó xi là tọa độ x của thành phố thứ i. Tất cả các xi là khác biệt và theo thứ tự tăng dần.
Constraints
.
Output Format
Đối với mỗi thành phố in ra 2 giá trị mini và maxi trên 1 dòng.
Ví dụ:
Dữ liệu vào
4
-5 -2 2 7
Dữ liệu ra
3 12
3 9
4 7
5 12
Chào bạn, bài toán "Gửi thư" này yêu cầu tính chi phí tối thiểu (mini) và tối đa (maxi) để gửi thư từ mỗi thành phố đến bất kỳ thành phố khác. Các thành phố được đặt trên trục Ox với tọa độ đã cho và được sắp xếp theo thứ tự tăng dần.
Hướng giải bài này như sau:
- Đọc dữ liệu: Đọc số lượng thành phố
n
và sau đó đọcn
tọa độx_i
vào một mảng hoặc vector. Vì tọa độ có thể rất lớn, hãy sử dụng kiểu dữ liệulong long
để lưu trữ chúng. - Xử lý cho từng thành phố: Duyệt qua từng thành phố từ
i = 0
đếnn-1
. Đối với mỗi thành phối
:- Tính mini: Chi phí tối thiểu để gửi thư từ thành phố
i
đến thành phố khác chính là khoảng cách nhỏ nhất giữa thành phối
và thành phố gần nhất với nó. Do các tọa độ đã được sắp xếp tăng dần, thành phố gần nhất với thành phối
(nếu không phải thành phố đầu tiên hoặc cuối cùng) chính là hai thành phố kề nó trong mảng, tức là thành phối-1
và thành phối+1
.- Nếu
i
là thành phố đầu tiên (index 0): Thành phố gần nhất duy nhất là thành phối+1
(index 1). mini =x[1] - x[0]
. - Nếu
i
là thành phố cuối cùng (indexn-1
): Thành phố gần nhất duy nhất là thành phối-1
(indexn-2
). mini =x[n-1] - x[n-2]
. - Nếu
i
là thành phố ở giữa (0 < i < n-1): Thành phố gần nhất là thành phối-1
hoặci+1
. mini =min(x[i] - x[i-1], x[i+1] - x[i])
.
- Nếu
- Tính maxi: Chi phí tối đa để gửi thư từ thành phố
i
đến thành phố khác chính là khoảng cách lớn nhất giữa thành phối
và thành phố xa nhất với nó. Do các tọa độ đã được sắp xếp tăng dần, thành phố xa nhất với thành phối
luôn là một trong hai thành phố ở hai đầu dãy: thành phố đầu tiên (index 0) hoặc thành phố cuối cùng (indexn-1
).- maxi =
max(x[i] - x[0], x[n-1] - x[i])
. Công thức này đúng cho mọii
, kể cả trường hợp đầu và cuối.
- maxi =
- Tính mini: Chi phí tối thiểu để gửi thư từ thành phố
- In kết quả: Đối với mỗi thành phố
i
, in ra giá trịmini
vàmaxi
đã tính được trên cùng một dòng, cách nhau bởi dấu cách, sau đó xuống dòng để in kết quả cho thành phố tiếp theo.
Cách tiếp cận này có độ phức tạp O(n) vì ta chỉ duyệt qua mảng tọa độ một lần để tính mini và maxi cho mỗi thành phố. Điều này hiệu quả với n
lên đến 10^6.
Lưu ý sử dụng long long
cho tọa độ và các phép tính khoảng cách để tránh tràn số.
Comments