Nhập môn

Xâu đối xứng (Palindrome), số đối xứng và bài toán đảo chữ (Anagram): C++, Python

Xâu đối xứng (palindrome) là xâu ký tự khi đọc từ trái sang phải hay từ phải sang trái đều hoàn toàn giống nhau, chẳng hạn như "racecar", "madam" hay "abba". Thay vì tốn O(n) bộ nhớ phụ để tạo một bản sao đảo ngược rồi so sánh, ta dùng kỹ thuật hai con trỏ xuất phát từ hai đầu xâu tiến dần vào tâm để kiểm tra ngay tại chỗ chỉ trong O(n) thời gian và O(1) bộ nhớ.

Ý chính
  • Kiểm tra xâu đối xứng (Palindrome) tối ưu: Đặt hai con trỏ l = 0 và r = n - 1, lặp khi l < r: nếu s[l] != s[r] thì trả về false, ngược lại tăng l, giảm r. Đạt O(n) thời gian và O(1) bộ nhớ phụ.
  • Kiểm tra câu đối xứng thực tế: Bỏ qua các ký tự không phải chữ/số bằng isalnum và quy về chữ thường bằng tolower ngay trong lúc dịch hai con trỏ.
  • Kiểm tra số nguyên đối xứng không đổi sang xâu: Dùng vòng lặp lấy chữ số cuối n % 10 ghép vào số đảo ngược dao = dao * 10 + n % 10, rồi so sánh dao với số ban đầu.
  • Kiểm tra hai xâu đảo chữ (Anagram): Dùng mảng đếm tần suất dem[26] (tăng ở xâu s, giảm ở xâu t) trong O(n) thay vì sắp xếp mất O(n log n).

Xâu đối xứng (Palindrome) là gì và cách kiểm tra bằng hai con trỏ

Các bản C++ và Python dưới đây được đối chiếu trên đầu vào ASCII. std::string UTF-8 đếm byte, còn Python str đếm mã Unicode; kiểm tra đối xứng cho tiếng Việt cần xác định cách chuẩn hóa và đơn vị ký tự trước.

Một xâu ký tự s độ dài n được gọi là xâu đối xứng (palindrome) nếu ký tự đầu bằng ký tự cuối, ký tự thứ hai bằng ký tự kế cuối, và cứ thế đối xứng qua tâm: s[i] == s[n - 1 - i] với mọi 0 ≤ i < n.

Cách viết ngắn nhất mà nhiều bạn thường nghĩ tới là tạo một xâu mới đảo ngược rev (ví dụ s[::-1] trong Python) rồi kiểm tra s == rev. Cách này chạy đúng nhưng có hai nhược điểm:

  1. Phải cấp phát thêm O(n) bộ nhớ phụ để lưu bản sao đảo ngược.
  2. Luôn phải chép toàn bộ n ký tự ngay cả khi cặp ký tự đầu–cuối s[0] và s[n - 1] đã khác nhau ngay từ bước đầu tiên!

Giải pháp chuẩn mực trong lập trình thuật toán và phỏng vấn là dùng kỹ thuật hai con trỏ chạy ngược chiều nhau ngay trên xâu gốc:

  • Đặt con trỏ trái ở đầu xâu l = 0 và con trỏ phải ở cuối xâu r = n - 1.
  • Trong khi l nhỏ hơn r:
    • So sánh s[l] với s[r]. Nếu khác nhau, kết luận ngay xâu không đối xứng (false) và dừng hàm.
    • Nếu bằng nhau, nhích hai con trỏ tiến vào tâm: ++l và --r.
  • Khi vòng lặp kết thúc (l ≥ r), mọi cặp đối xứng đã khớp hết nên trả về true.
'r' l = 0 'a' l = 1 'c' l = 2 'e' Tâm (3) 'c' r = 4 'a' r = 5 'r' r = 6 Bước 1: (l=0, r=6) s[0]='r' == s[6]='r' Khớp → tăng l, giảm r Bước 2: (l=1, r=5) s[1]='a' == s[5]='a' Khớp → tăng l, giảm r Bước 3: (l=2, r=4) s[2]='c' == s[4]='c' Gặp nhau ở tâm l=r=3 → Đúng Chỉ cần 3 phép so sánh tại chỗ: O(n) thời gian và O(1) bộ nhớ phụ
Kiểm tra "racecar" bằng hai con trỏ Bước 1 (l=0, r=6): s[0]='r' == s[6]='r' Khớp → dịch vào trong (l=1, r=5) Bước 2 (l=1, r=5): s[1]='a' == s[5]='a' Khớp → dịch vào trong (l=2, r=4) Bước 3 (l=2, r=4): s[2]='c' == s[4]='c' Khớp → dịch vào tâm (l=3, r=3) Dừng vì l == r (tâm 'e') → Palindrome! Thời gian O(n), bộ nhớ phụ O(1)
Hình 1. Hai con trỏ l và r xuất phát từ hai đầu xâu "racecar", đối chiếu từng cặp ký tự rồi tiến vào tâm chỉ với 3 phép so sánh.

Bảng so sánh hai cách tiếp cận trên xâu độ dài n (xem thêm cách đánh giá tại bài độ phức tạp thuật toán Big-O):

Tiêu chí Tạo bản sao đảo ngược (s == s[::-1]) Hai con trỏ tại chỗ (l = 0, r = n - 1)
Thời gian xấu nhất O(n) (cấp phát + đảo + so sánh) O(n) (tối đa n / 2 lần so sánh)
Thời gian khi lệch ngay ở hai đầu Vẫn mất O(n) để tạo xong xâu đảo Dừng ngay ở bước đầu tiên O(1)
Bộ nhớ phụ (Extra Space) O(n) (tốn thêm bộ nhớ lưu xâu mới) O(1) (chỉ dùng 2 biến nguyên l, r)

Biến thể thực tế: Kiểm tra câu đối xứng bỏ qua dấu câu và chữ hoa/thường

Trong các bài phỏng vấn thực tế (như bài Valid Palindrome trên LeetCode), đầu vào không chỉ gồm chữ cái thường liền mạch mà là cả một câu có khoảng trắng, dấu phẩy, dấu hai chấm và chữ in hoa, ví dụ: "A man, a plan, a canal: Panama"

Đề bài yêu cầu chỉ xét các chữ cái và chữ số (alphanumeric) và không phân biệt chữ hoa/thường. Nếu lọc hết các ký tự hợp lệ ra một xâu tạm mới rồi mới kiểm tra, bạn lại tốn O(n) bộ nhớ phụ.

Thay vào đó, ta giữ nguyên bộ khung hai con trỏ l, r và chỉ thêm hai vòng lặp while nhỏ bên trong để nhảy qua các ký tự dấu câu/khoảng trắng:

  1. Trong khi l < r và s[l] không phải chữ/số (!isalnum(s[l])), ta tăng ++l để bỏ qua.
  2. Trong khi l < r và s[r] không phải chữ/số (!isalnum(s[r])), ta giảm --r để bỏ qua.
  3. Khi cả s[l] và s[r] đều đã đứng ở ký tự hợp lệ, ta so sánh tolower(s[l]) với tolower(s[r]).
#include <cctype>
#include <iostream>
#include <string>

using namespace std;

// 1. Kiểm tra xâu đối xứng cơ bản bằng hai con trỏ: O(n) thời gian, O(1) bộ nhớ
bool la_xau_doi_xung(const string& s) {
    int l = 0;
    int r = static_cast<int>(s.size()) - 1;
    while (l < r) {
        if (s[l] != s[r]) {
            return false;
        }
        ++l;
        --r;
    }
    return true;
}

// 2. Kiểm tra câu đối xứng khi bỏ qua dấu câu/khoảng trắng và không phân biệt hoa/thường
bool la_cau_doi_xung(const string& s) {
    int l = 0;
    int r = static_cast<int>(s.size()) - 1;
    while (l < r) {
        while (l < r && !isalnum(static_cast<unsigned char>(s[l]))) ++l;
        while (l < r && !isalnum(static_cast<unsigned char>(s[r]))) --r;
        if (tolower(static_cast<unsigned char>(s[l])) != tolower(static_cast<unsigned char>(s[r]))) {
            return false;
        }
        ++l;
        --r;
    }
    return true;
}

int main() {
    cout << la_xau_doi_xung("racecar") << " " << la_xau_doi_xung("abca") << " ";
    cout << la_cau_doi_xung("A man, a plan, a canal: Panama") << " " << la_cau_doi_xung("race a car") << "\n";
    return 0;
}

Chương trình in ra 1 0 1 0 tương ứng với:

  • "racecar" → 1 (đối xứng)
  • "abca" → 0 (tại l = 1, r = 2 có 'b' != 'c' nên dừng ngay)
  • "A man, a plan, a canal: Panama" → 1 (sau khi bỏ dấu câu và đổi chữ thường là "amanaplanacanalpanama" đối xứng hoàn hảo)
  • "race a car" → 0 (sau khi bỏ khoảng trắng là "raceacar" không đối xứng).
Học cùng giảng viên

Chủ đề này nằm trong Lecture 07: String (Chuỗi ký tự) của Khóa Green. Xem đề cương

Kiểm tra số nguyên đối xứng không cần đổi sang xâu

Phần đảo toàn bộ số trong code giả định 0 ≤ n ≤ 2³¹ - 1, dùng long long cho số đảo để không tràn. Nếu nhận toàn miền long long, cần kiểm tra trước phép nhân hoặc chỉ đảo một nửa số. Một số nguyên được gọi là số đối xứng (palindrome number) nếu các chữ số của nó đọc xuôi hay ngược đều như nhau, ví dụ 12321, 7, 4004. Lưu ý:

  • Mọi số âm (như -121) đều không phải số đối xứng vì đọc ngược sẽ thành 121-.
  • Các số tận cùng bằng 0 (như 12310, khác số 0) không bao giờ đối xứng vì chữ số đầu tiên không thể là 0.

Để kiểm tra số nguyên n ≥ 0 có đối xứng không mà không cần đổi sang xâu ký tự, ta dùng phép chia lấy dư % 10 và chia nguyên / 10 để xây dựng số đảo ngược dao:

  • Ở mỗi bước, chữ số hàng đơn vị của n là d = n % 10.
  • Ta đẩy d vào cuối biến dao bằng công thức: dao = dao * 10 + d, rồi bỏ chữ số cuối của n bằng n /= 10.

Hãy xem bảng chạy từng bước với n = 12321 (lưu ban_dau = 12321, khởi tạo dao = 0):

Bước lặp Giá trị n đầu bước Chữ số cuối d = n % 10 Cập nhật dao = dao * 10 + d Bỏ chữ số cuối n = n // 10
1 12321 1 0 × 10 + 1 = 1 1232
2 1232 2 1 × 10 + 2 = 12 123
3 123 3 12 × 10 + 3 = 123 12
4 12 2 123 × 10 + 2 = 1232 1
5 1 1 1232 × 10 + 1 = 12321 0 (Dừng)

Kết thúc vòng lặp, dao == ban_dau == 12321 nên 12321 là số đối xứng!

Mở rộng: Bài toán kiểm tra hai xâu đảo chữ (Anagram) trong O(n)

Một bài toán chị em luôn đi cặp với Palindrome trong các bộ đề xử lý xâu ký tự là kiểm tra hai xâu đảo chữ (Valid Anagram):

  • Hai xâu s và t được gọi là đảo chữ (anagram) của nhau nếu ta có thể tráo đổi vị trí các ký tự của s để tạo thành t. Ví dụ: "listen" và "silent" là anagram; "rat" và "car" không phải là anagram.
  • Điều kiện cần và đủ để s và t là anagram của nhau là: chúng có cùng độ dài và tần suất xuất hiện của từng chữ cái trong hai xâu hoàn toàn bằng nhau.

Nếu bạn dùng hàm sắp xếp sort(s) và sort(t) rồi so sánh s == t, độ phức tạp sẽ là O(n log n). Ta có thể làm tốt hơn hẳn trong thời gian tuyến tính O(n) và bộ nhớ O(1) chỉ bằng một mảng đếm dem[26], với điều kiện cả hai xâu chỉ có các chữ ASCII thường a..z:

  1. Nếu s.size() != t.size(), trả về false ngay lập tức.
  2. Tạo mảng dem[26] khởi tạo bằng 0. Duyệt i từ 0 đến n - 1:
    • Với ký tự s[i], ta cộng 1 vào ô tương ứng: ++dem[s[i] - 'a'].
    • Với ký tự t[i], ta trừ 1 ở ô tương ứng: --dem[t[i] - 'a'].
  3. Sau vòng lặp, nếu mọi chữ cái ở hai xâu xuất hiện với số lần bằng nhau thì tất cả 26 ô của dem phải triệt tiêu về đúng bằng 0!
#include <iostream>
#include <limits>
#include <string>
#include <vector>

using namespace std;

// 1. Kiểm tra số nguyên đối xứng bằng cách đảo chữ số toán học (không đổi sang xâu)
bool la_so_doi_xung(long long n) {
    if (n < 0) return false;
    long long ban_dau = n;
    long long dao = 0;
    while (n > 0) {
        long long chu_so = n % 10;
        if (dao > (numeric_limits<long long>::max() - chu_so) / 10) return false;
        dao = dao * 10 + chu_so;
        n /= 10;
    }
    return dao == ban_dau;
}

// 2. Kiểm tra hai xâu đảo chữ (Anagram) bằng mảng tần suất 26 chữ cái trong O(n)
bool la_anagram(const string& s, const string& t) {
    if (s.size() != t.size()) return false;
    vector<int> dem(26, 0);
    for (size_t i = 0; i < s.size(); ++i) {
        ++dem[s[i] - 'a'];
        --dem[t[i] - 'a'];
    }
    for (int c : dem) {
        if (c != 0) return false;
    }
    return true;
}

int main() {
    cout << la_so_doi_xung(12321) << " " << la_so_doi_xung(12310) << " " << la_so_doi_xung(-121) << " ";
    cout << la_anagram("listen", "silent") << " " << la_anagram("rat", "car") << "\n";
    return 0;
}

Chương trình in ra 1 0 0 1 0 tương ứng với: 12321 (1), 12310 (0), -121 (0), cặp "listen" / "silent" (1) và cặp "rat" / "car" (0).

Lỗi hay gặp

Cẩn thận
  • Quên kiểm tra l < r trong vòng lặp while bỏ qua dấu câu: Trong câu toàn dấu câu như ",.!?", nếu vòng lặp trong chỉ viết while (!isalnum(s[l])) ++l; mà thiếu điều kiện l < r, chỉ số l sẽ chạy vượt qua cuối xâu và gây lỗi truy cập ngoài vùng nhớ!
  • Quên truyền kiểu không dấu vào isalnum và tolower trong C++: Theo chuẩn C/C++, nếu kiểu char mang giá trị âm (ký tự mở rộng ngoài ASCII chuẩn) được truyền thẳng vào std::isalnum(ch), hành vi sẽ không xác định (Undefined Behavior). Ép kiểu sang unsigned char trước khi gọi hàm là thói quen an toàn tuyệt đối.
  • Tràn số int 32-bit khi đảo chữ số của số nguyên lớn: Ví dụ số n = 1999999999 nằm vừa trong kiểu int 32-bit, nhưng số đảo ngược của nó là 9999999991 lại vượt quá 2³¹ - 1. Vì vậy hãy khai báo biến dao kiểu long long trong C++.

Luyện ngay trên Big-O Coding

Thực hành ngay kỹ thuật kiểm tra xâu đối xứng, số đối xứng và mảng đếm tần suất trên hệ thống Big-O Coding:

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

Xâu rỗng "" và xâu 1 ký tự "a" có phải là xâu đối xứng không?

Có. Theo định nghĩa, cả xâu rỗng (độ dài 0) và xâu 1 ký tự (độ dài 1) khi đọc xuôi hay ngược đều như nhau nên đều là xâu đối xứng.

Nếu được phép xóa tối đa 1 ký tự để xâu trở thành đối xứng thì làm thế nào?

Bạn vẫn chạy hai con trỏ l và r từ hai đầu vào. Tại vị trí đầu tiên gặp s[l] != s[r], bạn thử 2 khả năng: xóa s[l] (kiểm tra đoạn s[l + 1 .. r]) hoặc xóa s[r] (kiểm tra đoạn s[l .. r - 1]). Thuật toán vẫn chạy trong O(n) thời gian và O(1) bộ nhớ.

Làm sao tìm xâu con đối xứng dài nhất (Longest Palindromic Substring) trong một xâu?

Cách trực quan nhất là mở rộng từ tâm (Expand Around Center): coi mỗi vị trí i là tâm lẻ (l = i, r = i) và mỗi khe i, i + 1 là tâm chẵn (l = i, r = i + 1), rồi bung hai con trỏ sang hai phía chừng nào s[l] == s[r] trong O(n²) thời gian và O(1) bộ nhớ.

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