Bài 30.3: Bài tập thực hành tần suất ký tự trong C++

Bài 30.3: Bài tập thực hành tần suất ký tự trong C++
Chào mừng các bạn quay trở lại với chuỗi bài học lập trình C++ cùng FullhouseDev! Hôm nay, chúng ta sẽ cùng nhau giải quyết một bài toán thực hành kinh điển nhưng cực kỳ hữu ích: đếm tần suất xuất hiện của từng ký tự trong một chuỗi văn bản cho trước.
Tại sao bài toán này lại quan trọng? Việc phân tích tần suất ký tự có ứng dụng rộng rãi trong nhiều lĩnh vực:
- Xử lý văn bản: Hiểu cấu trúc ngôn ngữ, phát hiện lỗi chính tả cơ bản.
- Mật mã học: Phân tích tần suất là kỹ thuật cơ bản trong giải mã các mật mã cổ điển (như mật mã Caesar, Vigenère).
- Nén dữ liệu: Các ký tự xuất hiện nhiều có thể được gán mã ngắn hơn (ví dụ: nén Huffman).
- Phân tích dữ liệu: Thống kê đơn giản về dữ liệu dạng văn bản.
Bài tập này không chỉ giúp bạn rèn luyện kỹ năng xử lý chuỗi mà còn là cơ hội tuyệt vời để thực hành sử dụng các cấu trúc dữ liệu quan trọng trong C++ như map
, unordered_map
hoặc thậm chí là mảng (array).
Bài Toán: Đếm Tần Suất Ký Tự
Yêu cầu của chúng ta rất đơn giản: cho một chuỗi văn bản bất kỳ, hãy viết chương trình C++ để liệt kê từng ký tự duy nhất xuất hiện trong chuỗi đó cùng với số lần nó xuất hiện.
Ví dụ:
- Chuỗi "hello world"
- Kết quả mong muốn (thứ tự có thể khác nhau tùy cách triển khai):
- 'h': 1
- 'e': 1
- 'l': 3
- 'o': 2
- ' ': 1
- 'w': 1
- 'r': 1
- 'd': 1
Chúng ta sẽ cùng khám phá một số cách tiếp cận khác nhau để giải quyết bài toán này, mỗi cách sử dụng một cấu trúc dữ liệu khác nhau.
Cách 1: Sử Dụng map
map
trong C++ Standard Library là một cấu trúc dữ liệu lưu trữ các cặp khóa-giá trị (key-value pairs) theo thứ tự của khóa. Nó rất phù hợp cho bài toán này vì mỗi ký tự duy nhất có thể là khóa (char
) và tần suất của nó là giá trị (int
).
Ưu điểm của map
:
- Tự động quản lý các ký tự duy nhất.
- Dữ liệu được lưu trữ theo thứ tự của ký tự (theo mã ASCII), giúp việc in ra kết quả có thứ tự.
- Dễ sử dụng.
Nhược điểm:
- Tốc độ truy cập và chèn có thể chậm hơn so với
unordered_map
hoặc mảng trong trường hợp tốt nhất (average case).
Hãy xem code ví dụ:
#include <iostream>
#include <string>
#include <map> // Thư viện cần thiết cho map
#include <limits> // Để xử lý cin.ignore
int main() {
string input_string;
cout << "Nhap mot chuoi bat ky: ";
//cin >> input_string; // Chỉ đọc một từ
// Dùng getline để đọc cả dòng, bao gồm cả khoảng trắng
getline(cin >> ws, input_string); // ws bỏ qua khoảng trắng đầu dòng
// Khai báo một map để lưu tần suất: key là char, value là int
map<char, int> frequency;
// Duyệt qua từng ký tự trong chuỗi
for (char c : input_string) {
// Đối với mỗi ký tự c, tăng giá trị đếm trong map
// Nếu c chưa có trong map, nó sẽ được thêm vào với giá trị ban đầu là 0, sau đó tăng lên 1.
// Nếu c đã có, giá trị hiện tại sẽ được tăng lên 1.
frequency[c]++;
}
// In kết quả tần suất
cout << "\nTan suat ky tu:" << endl;
// Duyệt qua từng cặp key-value trong map
for (const auto& pair : frequency) {
// pair.first là ký tự, pair.second là số lần xuất hiện
cout << "'" << pair.first << "': " << pair.second << endl;
}
return 0;
}
Giải thích Code:
- Chúng ta bao gồm các thư viện cần thiết:
<iostream>
cho nhập/xuất,<string>
cho làm việc với chuỗi, và<map>
chomap
.<limits>
được thêm vào để sử dụngws
giúp đọc dòng input sạch hơn sau các thao tác nhập liệu khác nếu có. string input_string;
khai báo biến để lưu chuỗi nhập từ người dùng.getline(cin >> ws, input_string);
đọc toàn bộ một dòng văn bản từ bàn phím vàoinput_string
.ws
được dùng để loại bỏ các ký tự khoảng trắng (whitespace) còn lại trong bộ đệm nhập liệu trước khi đọc dòng mới, đảm bảogetline
hoạt động đúng.map<char, int> frequency;
tạo một map rỗng. Kiểu khóa làchar
(ký tự), kiểu giá trị làint
(số lần đếm).- Vòng lặp
for (char c : input_string)
là cấu trúc lặp dựa trên phạm vi (range-based for loop), duyệt qua từng ký tực
tronginput_string
. frequency[c]++;
là phần cốt lõi. Đây là cách truy cập hoặc thêm một phần tử vàomap
.- Nếu ký tự
c
chưa tồn tại làm khóa trongmap
,map
sẽ tự động tạo một mục nhập mới với khóa làc
và khởi tạo giá trị (value) tương ứng là0
(giá trị mặc định choint
). Sau đó, toán tử++
sẽ tăng giá trị này lên1
. - Nếu ký tự
c
đã tồn tại,map
sẽ truy cập đến giá trị hiện tại của khóac
và tăng giá trị đó lên1
.
- Nếu ký tự
- Vòng lặp
for (const auto& pair : frequency)
dùng để duyệt qua tất cả các cặp khóa-giá trị trongmap
sau khi đã đếm xong.pair
ở đây là mộtpair
màpair.first
là khóa (char
) vàpair.second
là giá trị (int
). cout << "'" << pair.first << "': " << pair.second << endl;
in ra kết quả theo định dạng 'ký tự': số lần.
Kết quả khi chạy chương trình với input "hello world":
Nhap mot chuoi bat ky: hello world
Tan suat ky tu:
' ': 1
'd': 1
'e': 1
'h': 1
'l': 3
'o': 2
'r': 1
'w': 1
Bạn có thể thấy các ký tự được in ra theo thứ tự tăng dần của mã ASCII, đó là đặc điểm của map
.
Biến Thể: Đếm Tần Suất Chỉ Chữ Cái (Không Phân Biệt Hoa Thường)
Đôi khi, chúng ta chỉ muốn đếm tần suất của các ký tự chữ cái và không quan tâm đến việc chúng là chữ hoa hay chữ thường, cũng như bỏ qua các ký tự khác (số, dấu câu, khoảng trắng...). Chúng ta có thể dễ dàng chỉnh sửa code trên.
Chúng ta cần thêm thư viện <cctype>
để sử dụng các hàm kiểm tra và chuyển đổi ký tự.
#include <iostream>
#include <string>
#include <map>
#include <cctype> // Thư viện cho isalpha và tolower
#include <limits>
int main() {
string input_string;
cout << "Nhap mot chuoi bat ky: ";
getline(cin >> ws, input_string);
map<char, int> frequency;
for (char c : input_string) {
// Kiểm tra xem ký tự có phải là chữ cái không
if (isalpha(c)) {
// Chuyển ký tự về dạng chữ thường trước khi đếm
char lower_c = tolower(c);
frequency[lower_c]++;
}
}
cout << "\nTan suat chu cai (khong phan biet hoa thuong):" << endl;
for (const auto& pair : frequency) {
cout << "'" << pair.first << "': " << pair.second << endl;
}
return 0;
}
Giải thích Sự Thay Đổi:
#include <cctype>
được thêm vào.- Trong vòng lặp,
if (isalpha(c))
kiểm tra xem ký tực
có phải là một ký tự chữ cái (a-z, A-Z) hay không. Chỉ những ký tự này mới được xử lý. - Nếu là chữ cái,
char lower_c = tolower(c);
chuyển ký tự đó thành chữ thường.tolower
trả về chữ thường tương ứng nếu đó là chữ hoa, hoặc trả về chính ký tự đó nếu nó đã là chữ thường hoặc không phải chữ cái. frequency[lower_c]++;
tăng bộ đếm cho ký tự chữ thường tương ứng.
Với input "Hello World! 123", output sẽ là:
Nhap mot chuoi bat ky: Hello World! 123
Tan suat chu cai (khong phan biet hoa thuong):
'd': 1
'e': 1
'h': 1
'l': 3
'o': 2
'r': 1
'w': 1
Các ký tự không phải chữ cái như khoảng trắng, dấu '!', số '1', '2', '3' đã bị bỏ qua.
Cách 2: Sử Dụng unordered_map
unordered_map
cũng là một cấu trúc dữ liệu lưu trữ cặp khóa-giá trị, tương tự như map
. Tuy nhiên, nó sử dụng kỹ thuật băm (hashing) để lưu trữ dữ liệu thay vì dựa trên thứ tự.
Ưu điểm của unordered_map
:
- Trung bình (average case), tốc độ truy cập, chèn và xóa phần tử nhanh hơn đáng kể so với
map
(thường là O(1)).
Nhược điểm:
- Các phần tử không được lưu trữ theo thứ tự của khóa. Thứ tự khi duyệt qua
unordered_map
là không xác định và có thể thay đổi. - Trường hợp xấu nhất (worst case) cho truy cập/chèn có thể chậm hơn (O(n)) nếu có nhiều xung đột băm (hash collisions), mặc dù hiếm gặp với các kiểu dữ liệu chuẩn như
char
.
Code sử dụng unordered_map
rất giống với map
:
#include <iostream>
#include <string>
#include <unordered_map> // Thư viện cần thiết cho unordered_map
#include <limits>
int main() {
string input_string;
cout << "Nhap mot chuoi bat ky: ";
getline(cin >> ws, input_string);
// Khai bao mot unordered_map
unordered_map<char, int> frequency;
for (char c : input_string) {
frequency[c]++; // Logic đếm giống hệt map
}
cout << "\nTan suat ky tu (su dung unordered_map):" << endl;
// Duyệt qua unordered_map, thứ tự KHONG duoc dam bao
for (const auto& pair : frequency) {
cout << "'" << pair.first << "': " << pair.second << endl;
}
return 0;
}
Giải thích Code:
Code này gần như giống hệt code sử dụng map
, chỉ khác ở việc bao gồm thư viện <unordered_map>
và khai báo unordered_map<char, int> frequency;
. Logic đếm frequency[c]++;
hoạt động tương tự.
Sự khác biệt chính sẽ nằm ở thứ tự xuất hiện của các ký tự khi in ra. Với cùng input "hello world", output có thể khác so với map
:
Nhap mot chuoi bat ky: hello world
Tan suat ky tu (su dung unordered_map):
'l': 3
'o': 2
' ': 1
'w': 1
'r': 1
'd': 1
'h': 1
'e': 1
Lưu ý: Thứ tự này chỉ là một khả năng và có thể khác mỗi lần chạy hoặc trên các hệ thống khác nhau.
Khi nào nên dùng map
và khi nào nên dùng unordered_map
? Nếu bạn cần các phần tử được sắp xếp theo khóa hoặc cần đảm bảo hiệu suất trường hợp xấu nhất, hãy dùng map
. Nếu bạn chỉ cần tra cứu nhanh và thứ tự không quan trọng, unordered_map
thường là lựa chọn hiệu quả hơn về tốc độ trung bình.
Cách 3: Sử Dụng Mảng (Array)
Đối với bài toán đếm tần suất ký tự, đặc biệt nếu chúng ta chỉ làm việc với các ký tự trong bộ mã ASCII (có khoảng 256 ký tự khác nhau), chúng ta có thể sử dụng một mảng đơn giản. Mỗi chỉ số của mảng sẽ tương ứng với mã ASCII của một ký tự, và giá trị tại chỉ số đó sẽ là số lần xuất hiện của ký tự đó.
Bộ mã ASCII tiêu chuẩn sử dụng các giá trị từ 0 đến 127. Bộ mã ASCII mở rộng (extended ASCII) có thể lên đến 255. Một mảng kích thước 256 là đủ để bao phủ tất cả các ký tự có thể được biểu diễn bằng 1 byte.
Ưu điểm của mảng:
- Rất hiệu quả về tốc độ truy cập (luôn là O(1)) vì chỉ cần tính toán địa chỉ dựa trên chỉ số.
- Đơn giản cho các bộ ký tự có phạm vi nhỏ và cố định như ASCII.
Nhược điểm:
- Kích thước mảng cố định, có thể lãng phí bộ nhớ nếu bộ ký tự rất lớn hoặc rất thưa thớt.
- Không linh hoạt cho các bộ ký tự không có thứ tự liên tục hoặc quá lớn như Unicode (trừ khi dùng các kỹ thuật mapping phức tạp hơn).
- Cần cẩn thận với kiểu dữ liệu
char
(có thể là signed hoặc unsigned tùy hệ thống) khi dùng làm chỉ số mảng.
Code sử dụng mảng:
#include <iostream>
#include <string>
#include <vector> // Sử dụng vector thay vì mảng C-style để dễ quản lý
#include <limits>
int main() {
string input_string;
cout << "Nhap mot chuoi bat ky: ";
getline(cin >> ws, input_string);
// Sử dụng vector kích thước 256, khởi tạo tất cả giá trị bằng 0
// Mỗi chỉ số từ 0-255 tương ứng với một mã ASCII/byte value.
// Kích thước 256 bao phủ tất cả giá trị có thể của 1 byte (char)
vector<int> frequency(256, 0);
for (char c : input_string) {
// Chuyển ký tự sang kiểu unsigned char để đảm bảo chỉ số mảng là dương
// và nằm trong phạm vi 0-255.
unsigned char index = static_cast<unsigned char>(c);
frequency[index]++;
}
cout << "\nTan suat ky tu (su dung mang/vector):" << endl;
// Duyệt qua mảng/vector để in kết quả
for (int i = 0; i < 256; ++i) {
// Chỉ in ra các ký tự có tần suất lớn hơn 0
if (frequency[i] > 0) {
// Chuyển chỉ số (int) trở lại thành char để in
char character = static_cast<char>(i);
// Kiểm tra thêm để không in các ký tự điều khiển không hiển thị rõ
if (isprint(character) || character == ' ') { // isprint cần cctype
cout << "'" << character << "': " << frequency[i] << endl;
} else {
// In mã ASCII cho các ký tự không in được
cout << "[ASCII " << i << "]: " << frequency[i] << endl;
}
}
}
return 0;
}
Lưu ý: Cần thêm #include <cctype>
cho isprint
nếu bạn muốn sử dụng điều kiện kiểm tra đó. Nếu không, bỏ qua dòng if (isprint...)
.
Giải thích Code:
#include <vector>
được sử dụng để làm việc vớivector
, một dạng mảng động tiện lợi hơn mảng C-style truyền thống.vector<int> frequency(256, 0);
tạo một vector có kích thước 256 và khởi tạo tất cả các phần tử bằng 0. Kích thước 256 là để chứa tần suất của tất cả các giá trị có thể có của kiểuunsigned char
(từ 0 đến 255).- Trong vòng lặp,
unsigned char index = static_cast<unsigned char>(c);
là bước quan trọng. Kiểuchar
trong C++ có thể làsigned
hoặcunsigned
tùy thuộc vào trình biên dịch. Nếuchar
làsigned
và chứa một ký tự có mã ASCII > 127 (ví dụ các ký tự mở rộng), giá trị của nó có thể âm. Sử dụng giá trị âm làm chỉ số mảng sẽ gây lỗi. Ép kiểu sangunsigned char
đảm bảo chúng ta có một giá trị từ 0 đến 255, an toàn để dùng làm chỉ số cho vector có kích thước 256. frequency[index]++;
tăng bộ đếm tại chỉ số tương ứng với mã của ký tự. Đây là thao tác rất nhanh.- Vòng lặp cuối cùng
for (int i = 0; i < 256; ++i)
duyệt qua toàn bộ vector. if (frequency[i] > 0)
kiểm tra xem chỉ sối
(tức là mã ASCIIi
) có xuất hiện trong chuỗi hay không.char character = static_cast<char>(i);
chuyển chỉ sối
trở lại thành kiểuchar
để hiển thị ký tự tương ứng.- Phần kiểm tra
isprint
và in mã ASCII là để xử lý các ký tự đặc biệt như xuống dòng, tab, chuông... mà việc in trực tiếp có thể không hiển thị đúng hoặc gây ra hành vi không mong muốn.
Với input "hello world", output (không dùng isprint để đơn giản) sẽ giống như map
, nhưng thứ tự chắc chắn theo mã ASCII từ 0 đến 255 (chỉ in những ký tự có tần suất > 0).
Nhap mot chuoi bat ky: hello world
Tan suat ky tu (su dung mang/vector):
' ': 1
'd': 1
'e': 1
'h': 1
'l': 3
'o': 2
'r': 1
'w': 1
Comments