Bài 30.2: Bài tập thực hành chuẩn hóa chuỗi trong C++

Trong thế giới lập trình, việc làm việc với dữ liệu chuỗi là vô cùng phổ biến. Tuy nhiên, dữ liệu chuỗi chúng ta nhận được thường không "sạch sẽ": có thể có khoảng trắng thừa, ký tự đặc biệt không mong muốn, hoặc sự khác biệt về chữ hoa/chữ thường. Đây chính là lúc chuẩn hóa chuỗi (string normalization) trở nên cần thiết.

Chuẩn hóa chuỗi là quá trình biến đổi một chuỗi về một định dạng nhất quán, loại bỏ sự "nhiễu" để dữ liệu dễ dàng được xử lý, so sánh và lưu trữ hơn. Hãy tưởng tượng bạn cần so sánh tên sản phẩm hoặc địa chỉ nhập từ nhiều nguồn khác nhau. Nếu không chuẩn hóa, " Apple ", "apple", "APPLE.", và "apple" sẽ bị coi là khác nhau, dẫn đến kết quả sai lệch trong tìm kiếm hoặc phân tích.

Bài viết này không chỉ giới thiệu khái niệm, mà sẽ đưa bạn đi sâu vào thực hành các kỹ thuật chuẩn hóa chuỗi phổ biến nhất trong C++ thông qua các bài tập cụ thể. Hãy cùng nhau "làm sạch" dữ liệu chuỗi của chúng ta!

Các Tác Vụ Chuẩn Hóa Chuỗi Phổ Biến

Trước khi bắt tay vào code, hãy điểm qua một số tác vụ chuẩn hóa chuỗi mà chúng ta sẽ thực hành:

  1. Xóa khoảng trắng thừa ở đầu và cuối chuỗi: Khoảng trắng (spaces, tabs, newlines) không có ý nghĩa ở hai đầu chuỗi.
  2. Xóa khoảng trắng thừa giữa các từ: Biến đổi nhiều khoảng trắng liên tiếp thành chỉ một khoảng trắng duy nhất.
  3. Chuyển đổi kiểu chữ: Đưa toàn bộ chuỗi về chữ hoa hoặc chữ thường để việc so sánh không phân biệt chữ hoa/thường.
  4. Xóa ký tự không mong muốn: Có thể là dấu câu, ký tự đặc biệt, v.v.

Giờ thì, hãy chuẩn bị môi trường C++ của bạn và cùng giải quyết từng bài tập!

Bài Tập 1: Xóa khoảng trắng thừa ở đầu và cuối chuỗi

Đây là một tác vụ cơ bản nhưng rất quan trọng. Khoảng trắng ở đầu (leading) và cuối (trailing) thường xuất hiện do người dùng nhập liệu hoặc từ các nguồn dữ liệu không được định dạng tốt.

Chúng ta có thể sử dụng các hàm tìm kiếm vị trí ký tự và cắt chuỗi có sẵn trong C++.

Ví dụ:

#include <iostream>
#include <string>
#include <algorithm>

string xoaKt(const string& s) {
    size_t dau = s.find_first_not_of(" \t\n\r");
    if (string::npos == dau) return s;
    size_t cuoi = s.find_last_not_of(" \t\n\r");
    return s.substr(dau, (cuoi - dau + 1));
}

int main() {
    string s = "   \n  Đây là một chuỗi lộn xộn có khoảng trắng thừa ở đầu và cuối.   \t ";
    string sSach = xoaKt(s);

    cout << "Chuỗi gốc: '" << s << "'\n";
    cout << "Chuỗi sau khi trim: '" << sSach << "'\n";

    return 0;
}

Output:

Chuỗi gốc: '   
  Đây là một chuỗi lộn xộn có khoảng trắng thừa ở đầu và cuối.       '
Chuỗi sau khi trim: 'Đây là một chuỗi lộn xộn có khoảng trắng thừa ở đầu và cuối.'

Giải thích code:

  • Hàm xoaKt nhận một chuỗi làm đối số.
  • s.find_first_not_of(" \t\n\r"): Tìm vị trí của ký tự đầu tiên không phải là khoảng trắng (`), tab (\t), xuống dòng (\n), hoặc ký tự carriage return (\r).string::npos` là một giá trị đặc biệt chỉ ra không tìm thấy.
  • s.find_last_not_of(" \t\n\r"): Tương tự, tìm vị trí của ký tự cuối cùng không phải là các loại khoảng trắng.
  • s.substr(dau, (cuoi - dau + 1)): Trả về một chuỗi con (substring) bắt đầu từ vị trí dau và có độ dài là cuoi - dau + 1.

Đây là một cách tiếp cận đơn giản và hiệu quả. Bạn cũng có thể sử dụng các iterator và algorithm với isspace để làm điều này, nhưng cách dùng substr thường dễ đọc hơn cho tác vụ trim cơ bản.

Bài Tập 2: Xóa khoảng trắng thừa giữa các từ

Sau khi đã xử lý khoảng trắng ở hai đầu, giờ là lúc làm gọn các khoảng trắng bên trong. Chuỗi "Hello world" nên trở thành "Hello world".

Một kỹ thuật hiệu quả là sử dụng stringstream. Nó có khả năng đọc từng "từ" (các chuỗi được phân tách bởi khoảng trắng) từ một chuỗi, giúp chúng ta dễ dàng xây dựng lại chuỗi mà chỉ giữa các từ có một khoảng trắng duy nhất.

Ví dụ:

#include <iostream>
#include <string>
#include <sstream>

string xoaKtNoiBo(const string& s) {
    stringstream ss(s);
    string tu;
    string kq = "";
    while (ss >> tu) {
        if (!kq.empty()) kq += " ";
        kq += tu;
    }
    return kq;
}

int main() {
    string s = "Đây   là    một   chuỗi  với   nhiều    khoảng   trắng    thừa.";
    string sSach = xoaKtNoiBo(s);

    cout << "Chuỗi gốc: '" << s << "'\n";
    cout << "Chuỗi sau khi xóa khoảng trắng nội bộ: '" << sSach << "'\n";

    return 0;
}

Output:

Chuỗi gốc: 'Đây   là    một   chuỗi  với   nhiều    khoảng   trắng    thừa.'
Chuỗi sau khi xóa khoảng trắng nội bộ: 'Đây là một chuỗi với nhiều khoảng trắng thừa.'

Giải thích code:

  • stringstream ss(s);: Khởi tạo một stringstream với chuỗi đầu vào. Stringstream cho phép bạn coi chuỗi như một luồng (stream), giống như cin hoặc cout.
  • while (ss >> tu): Vòng lặp này đọc từng "từ" từ stringstream vào biến tu. Toán tử >> cho stringstream tự động bỏ qua các khoảng trắng (bao gồm space, tab, newline) và trích xuất chuỗi ký tự tiếp theo không phải khoảng trắng.
  • if (!kq.empty()) { kq += " "; }: Điều kiện này đảm bảo rằng một khoảng trắng chỉ được thêm vào kq trước mỗi từ, ngoại trừ từ đầu tiên.
  • kq += tu;: Thêm từ đã đọc vào chuỗi kết quả.

Kết quả là chuỗi kq sẽ chứa các từ gốc được phân tách bởi đúng một khoảng trắng.

Bài Tập 3: Chuyển đổi kiểu chữ

Đôi khi, sự khác biệt giữa "APPLE" và "apple" là không quan trọng trong ngữ cảnh dữ liệu. Chuyển đổi toàn bộ chuỗi về cùng một kiểu chữ (thường là chữ thường) giúp việc so sánh trở nên dễ dàng hơn.

Chúng ta có thể sử dụng thuật toán transform kết hợp với hàm ::tolower (hoặc ::toupper) để làm điều này một cách hiệu quả.

Ví dụ:

#include <iostream>
#include <string>
#include <algorithm>
#include <cctype>

string chuThg(string s) {
    transform(s.begin(), s.end(), s.begin(),
                   [](unsigned char c){ return tolower(c); });
    return s;
}

string chuHoa(string s) {
    transform(s.begin(), s.end(), s.begin(),
                   [](unsigned char c){ return toupper(c); });
    return s;
}

int main() {
    string sGoc = "Đây Là MộT ChUỖi ViẾt LỘn XộN KiỂu ChỮ.";
    string sThg = chuThg(sGoc);
    string sHoa = chuHoa(sGoc);

    cout << "Chuỗi gốc: '" << sGoc << "'\n";
    cout << "Chuỗi chữ thường: '" << sThg << "'\n";
    cout << "Chuỗi chữ hoa: '" << sHoa << "'\n";

    return 0;
}

Output:

Chuỗi gốc: 'Đây Là MộT ChUỖi ViẾt LỘn XộN KiỂu ChỮ.'
Chuỗi chữ thường: 'đây là một chuỗi viết lộn xộn kiểu chữ.'
Chuỗi chữ hoa: 'ĐÂY LÀ MỘT CHUỖI VIẾT LỘN XỘN KIỂU CHỮ.'

Giải thích code:

  • transform(s.begin(), s.end(), s.begin(), ...): Thuật toán này áp dụng một phép biến đổi cho từng phần tử trong một phạm vi (s.begin() đến s.end()) và lưu kết quả vào một phạm vi đích (s.begin()). Ở đây, phạm vi đích trùng với phạm vi nguồn, tức là chuỗi s sẽ được thay đổi trực tiếp.
  • [](unsigned char c){ return tolower(c); }: Đây là một lambda function. Nó nhận một ký tự c (được ép kiểu sang unsigned char để tương thích tốt hơn với tolower/toupper qua các locale) và trả về ký tự đó sau khi đã chuyển sang chữ thường bằng tolower.
  • Hàm chuHoa hoạt động tương tự nhưng sử dụng toupper.

Lưu ý: Việc chuyển đổi kiểu chữ có thể phức tạp hơn với các ký tự không thuộc bảng mã ASCII cơ bản (ví dụ: ký tự có dấu trong tiếng Việt, hoặc các ngôn ngữ khác). tolowertoupper hoạt động dựa trên locale hiện tại của chương trình. Đối với các ứng dụng quốc tế hóa, bạn có thể cần sử dụng các thư viện hoặc kỹ thuật xử lý Unicode phức tạp hơn. Tuy nhiên, với các chuỗi tiếng Anh hoặc ASCII cơ bản, cách này là đủ.

Bài Tập 4: Xóa ký tự không mong muốn (ví dụ: dấu câu)

Trong nhiều trường hợp, các dấu câu hoặc ký tự đặc biệt khác không cần thiết cho việc xử lý dữ liệu chính. Chúng ta có thể loại bỏ chúng.

Thuật toán remove_if kết hợp với hàm kiểm tra ký tự như ::ispunct là lựa chọn tuyệt vời cho việc này. remove_if sẽ di chuyển các phần tử không thỏa mãn điều kiện về phía đầu phạm vi, và trả về một iterator trỏ đến vị trí cuối cùng hợp lệ mới. Sau đó, chúng ta dùng string::erase để xóa phần tử còn lại.

Ví dụ:

#include <iostream>
#include <string>
#include <algorithm>
#include <cctype>

string xoaDauCau(string s) {
    s.erase(remove_if(s.begin(), s.end(), ::ispunct), s.end());
    return s;
}

int main() {
    string s = "Xin chào thế giới!!!, Đây là một câu có dấu câu.";
    string sSach = xoaDauCau(s);

    cout << "Chuỗi gốc: '" << s << "'\n";
    cout << "Chuỗi sau khi xóa dấu câu: '" << sSach << "'\n";

    return 0;
}

Output:

Chuỗi gốc: 'Xin chào thế giới!!!, Đây là một câu có dấu câu.'
Chuỗi sau khi xóa dấu câu: 'Xin chào thế giới Đây là một câu có dấu câu'

Giải thích code:

  • remove_if(s.begin(), s.end(), ::ispunct): Thuật toán này lặp qua chuỗi. Với mỗi ký tự, nó gọi hàm ::ispunct để kiểm tra xem đó có phải là dấu câu không. Nếu ::ispunct trả về false (ký tự không phải dấu câu), ký tự đó được giữ lại và di chuyển về phía đầu chuỗi (nếu cần). Nếu trả về true (ký tự dấu câu), ký tự đó bị "đánh dấu" để xóa (bằng cách di chuyển các ký tự sau nó lên đè lên). Thuật toán trả về một iterator trỏ đến vị trí đầu tiên của các ký tự "rác" cần xóa ở cuối chuỗi.
  • s.erase(..., s.end()): Hàm erase của string nhận hai iterator: vị trí bắt đầu và vị trí kết thúc của phạm vi cần xóa. Chúng ta truyền iterator trả về từ remove_ifs.end() để xóa tất cả các ký tự từ vị trí đó đến cuối chuỗi.

Tương tự như tolower/toupper, hàm ::ispunct cũng phụ thuộc vào locale và có thể không nhận diện hết các loại dấu câu trong mọi ngôn ngữ.

Bài Tập 5: Kết hợp các bước chuẩn hóa

Trong thực tế, việc chuẩn hóa thường bao gồm nhiều bước. Thứ tự các bước có thể quan trọng. Ví dụ, bạn có thể muốn xóa dấu câu trước khi xóa khoảng trắng nội bộ để tránh trường hợp ". " trở thành "." thay vì "".

Hãy kết hợp các hàm đã tạo để thực hiện một quy trình chuẩn hóa hoàn chỉnh.

Ví dụ:

#include <iostream>
#include <string>
#include <algorithm>
#include <cctype>
#include <sstream>

string xoaKt(const string& s) {
    size_t dau = s.find_first_not_of(" \t\n\r");
    if (string::npos == dau) return s;
    size_t cuoi = s.find_last_not_of(" \t\n\r");
    return s.substr(dau, (cuoi - dau + 1));
}

string xoaKtNoiBo(const string& s) {
    stringstream ss(s);
    string tu;
    string kq = "";
    while (ss >> tu) {
        if (!kq.empty()) kq += " ";
        kq += tu;
    }
    return kq;
}

string chuThg(string s) {
    transform(s.begin(), s.end(), s.begin(),
                   [](unsigned char c){ return tolower(c); });
    return s;
}

string xoaDauCau(string s) {
    s.erase(remove_if(s.begin(), s.end(), ::ispunct), s.end());
    return s;
}

string chuanHoaChuoi(string s) {
    s = xoaDauCau(s);
    s = chuThg(s);
    s = xoaKtNoiBo(s);
    s = xoaKt(s);
    return s;
}

int main() {
    string s = "  !!!  Đây Là . . .    MộT   ChUỖi   RấT Lộn XộN !!!.  ";
    string sSach = chuanHoaChuoi(s);

    cout << "Chuỗi gốc: '" << s << "'\n";
    cout << "Chuỗi sau khi chuẩn hóa: '" << sSach << "'\n";

    return 0;
}

Output:

Chuỗi gốc: '  !!!  Đây Là . . .    MộT   ChUỖi   RấT Lộn XộN !!!.  '
Chuỗi sau khi chuẩn hóa: 'đây là một chuỗi rất lộn xộn'

Giải thích code:

  • Chúng ta gom các hàm chuẩn hóa từ các bài tập trước lại.
  • Hàm chuanHoaChuoi gọi các hàm con theo một trình tự logic. Thứ tự này có thể tùy chỉnh tùy theo yêu cầu cụ thể của việc chuẩn hóa (ví dụ: có cần xóa dấu câu không, có cần chuyển kiểu chữ không, v.v.). Trình tự trong ví dụ này là một cách tiếp cận hợp lý cho nhiều trường hợp sử dụng chung.
  • Mỗi bước xử lý trả về một chuỗi đã được làm sạch, và chuỗi này được dùng làm đầu vào cho bước tiếp theo.

Thực hành kết hợp các bước này giúp bạn xây dựng các quy trình chuẩn hóa phức tạp và tùy chỉnh cho nhu cầu dữ liệu của mình.

Comments

There are no comments at the moment.