- Kiểm tra xâu đối xứng (Palindrome) tối ưu: Đặt hai con trỏ
l = 0vàr = n - 1, lặp khil < r: nếus[l] != s[r]thì trả vềfalse, ngược lại tăngl, giảmr. Đạ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
isalnumvà quy về chữ thường bằngtolowerngay 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 % 10ghép vào số đảo ngượcdao = dao * 10 + n % 10, rồi so sánhdaovớ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âus, giảm ở xâut) trong O(n) thay vì sắp xếp mấtO(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:
- Phải cấp phát thêm O(n) bộ nhớ phụ để lưu bản sao đảo ngược.
- Luôn phải chép toàn bộ
nký tự ngay cả khi cặp ký tự đầu–cuốis[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 = 0và con trỏ phải ở cuối xâur = n - 1. - Trong khi
lnhỏ hơnr:- So sánh
s[l]vớis[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:
++lvà--r.
- So sánh
- 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.
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:
- Trong khi
l < rvàs[l]không phải chữ/số (!isalnum(s[l])), ta tăng++lđể bỏ qua. - Trong khi
l < rvàs[r]không phải chữ/số (!isalnum(s[r])), ta giảm--rđể bỏ qua. - Khi cả
s[l]vàs[r]đều đã đứng ở ký tự hợp lệ, ta so sánhtolower(s[l])vớitolower(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;
}
def la_xau_doi_xung(s: str) -> bool:
"""Kiểm tra xâu đối xứng bằng hai con trỏ trong O(n) thời gian, O(1) bộ nhớ phụ."""
l, r = 0, len(s) - 1
while l < r:
if s[l] != s[r]:
return False
l += 1
r -= 1
return True
def la_cau_doi_xung(s: str) -> bool:
"""Kiểm tra câu đối xứng bỏ qua dấu câu/khoảng trắng và không phân biệt hoa/thường."""
l, r = 0, len(s) - 1
while l < r:
while l < r and not s[l].isalnum():
l += 1
while l < r and not s[r].isalnum():
r -= 1
if s[l].lower() != s[r].lower():
return False
l += 1
r -= 1
return True
if __name__ == "__main__":
kq = [
1 if la_xau_doi_xung("racecar") else 0,
1 if la_xau_doi_xung("abca") else 0,
1 if la_cau_doi_xung("A man, a plan, a canal: Panama") else 0,
1 if la_cau_doi_xung("race a car") else 0,
]
print(*kq)
Chương trình in ra 1 0 1 0 tương ứng với:
"racecar"→1(đối xứng)"abca"→0(tạil = 1, r = 2có'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).
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ành121-. - 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
nlàd = n % 10. - Ta đẩy
dvào cuối biếndaobằng công thức:dao = dao * 10 + d, rồi bỏ chữ số cuối củanbằngn /= 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
svà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ủasđể tạo thànht. Ví dụ:"listen"và"silent"là anagram;"rat"và"car"không phải là anagram. - Điều kiện cần và đủ để
svàtlà 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:
- Nếu
s.size() != t.size(), trả vềfalsengay lập tức. - Tạo mảng
dem[26]khởi tạo bằng0. Duyệtitừ0đếnn - 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'].
- Với ký tự
- 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
demphải triệt tiêu về đúng bằng0!
#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;
}
def la_so_doi_xung(n: int) -> bool:
"""Kiểm tra số nguyên đối xứng bằng phép chia lấy dư, không đổi sang xâu."""
if n < 0:
return False
ban_dau = n
dao = 0
while n > 0:
dao = dao * 10 + (n % 10)
n //= 10
return dao == ban_dau
def la_anagram(s: str, t: str) -> bool:
"""Kiểm tra hai xâu đảo chữ (Anagram) bằng mảng đếm 26 chữ cái trong O(n)."""
if len(s) != len(t):
return False
dem = [0] * 26
base = ord("a")
for ch_s, ch_t in zip(s, t):
dem[ord(ch_s) - base] += 1
dem[ord(ch_t) - base] -= 1
return all(c == 0 for c in dem)
if __name__ == "__main__":
kq = [
1 if la_so_doi_xung(12321) else 0,
1 if la_so_doi_xung(12310) else 0,
1 if la_so_doi_xung(-121) else 0,
1 if la_anagram("listen", "silent") else 0,
1 if la_anagram("rat", "car") else 0,
]
print(*kq)
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
- Quên kiểm tra
l < rtrong vòng lặpwhilebỏ qua dấu câu: Trong câu toàn dấu câu như",.!?", nếu vòng lặp trong chỉ viếtwhile (!isalnum(s[l])) ++l;mà thiếu điều kiệnl < r, chỉ sốlsẽ 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
isalnumvàtolowertrong C++: Theo chuẩn C/C++, nếu kiểucharmang giá trị âm (ký tự mở rộng ngoài ASCII chuẩn) được truyền thẳng vàostd::isalnum(ch), hành vi sẽ không xác định (Undefined Behavior). Ép kiểu sangunsigned chartrước khi gọi hàm là thói quen an toàn tuyệt đối. - Tràn số
int32-bit khi đảo chữ số của số nguyên lớn: Ví dụ sốn = 1999999999nằm vừa trong kiểuint32-bit, nhưng số đảo ngược của nó là9999999991lại vượt quá2³¹ - 1. Vì vậy hãy khai báo biếndaokiểulong longtrong 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ớ.
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í.


