Bỏ qua để đến nội dung

Biểu thức chính quy (Regex)

Sau bài này, bạn sẽ:

  • Hiểu biểu thức chính quy (regular expression, regex) là một cách mô tả một tập chuỗi, và đọc được các thành phần chính: lớp ký tự, lượng từ, neo, nhóm, tham lam và lười, lookaround.
  • Viết được mẫu trích số điện thoại di động Việt Nam, email và ngày tháng từ một đoạn văn tiếng Việt, rồi che hoặc đổi dạng chúng bằng re.sub.
  • Dùng đúng mô-đun re của Python: search, findall, sub, compile, cờ, chuỗi thô, và biết vì sao \w hợp với chữ có dấu còn [a-zA-Z] thì không.
  • Nhận ra khi nào regex không phải công cụ đúng: cấu trúc lồng nhau như HTML, và mẫu có thể chạy lâu theo hàm mũ.

Bạn có vài nghìn tin rao vặt như tin dưới đây, và cần lấy ra số điện thoại, email, ngày tháng. Hoặc ngược lại: cần che số điện thoại trước khi đưa văn bản vào huấn luyện một mô hình, vì đó là dữ liệu cá nhân (bài Chuẩn bị dữ liệu pre-train có nói vì sao).

Bán xe Wave 2019, giá 12 triệu. Gọi anh Nam 0912 345 678 hoặc chị Lan +84987654321. Email: nam.nguyen@gmail.com. Xem xe từ 05/11/2026 đến 15/11/2026. STK 1903456789123 không phải số điện thoại.

(Số, tên và email trong bài đều tự đặt.) Bằng mắt, bạn nhận ra số điện thoại ngay. Nhưng bạn nhận ra nhờ hình dạng, không nhờ con số cụ thể: “bắt đầu bằng 0 hoặc +84, rồi khoảng 9 chữ số, có thể cách nhau”. Không thể liệt kê mọi số điện thoại có thể có. Bạn chỉ mô tả được hình dạng của chúng.

Với các hàm chuỗi quen thuộc, bạn có thể tách theo khoảng trắng rồi giữ những mảnh toàn chữ số. Cách này hỏng ngay ở “0912 345 678”, vì số bị cắt làm ba mảnh “0912”, “345”, “678”. Nó lấy nhầm “12” (giá tiền) và “1903456789123” (số tài khoản). Và nó bỏ qua “+84987654321”, vì dấu + không phải chữ số. Mỗi trường hợp mới lại cần thêm một đoạn if, và code nhanh chóng thành một mớ luật khó đọc.

Một ngôn ngữ nhỏ để mô tả hình dạng

Phần tiêu đề “Một ngôn ngữ nhỏ để mô tả hình dạng”

Regex là một ngôn ngữ nhỏ, chuyên để viết đúng những câu như “0 hoặc +84, rồi một chữ số trong 3, 5, 7, 8, 9, rồi 8 chữ số nữa, có thể cách nhau bằng dấu cách”. Viết một lần, chạy được trên mọi văn bản, và hầu hết ngôn ngữ lập trình, trình soạn thảo, lệnh grep đều hiểu.

Regex xuất hiện trong xử lý ngôn ngữ từ rất sớm. Chatbot ELIZA (Weizenbaum, 1966) gần như chỉ là một chuỗi phép thay thế theo mẫu: thấy “I need X” thì đáp “What would it mean to you if you got X?”. Ngày nay, nhiều bộ tách từ (tokenizer), ví dụ của GPT-2, vẫn dùng regex để cắt văn bản thành từng mảnh trước khi chạy thuật toán chính (bài Tách từ).

Bài toán của bài này: mô tả hình dạng của số điện thoại, email, ngày tháng bằng một mẫu, rồi dùng mẫu đó để tìm, trích, che và đổi dạng chúng trong văn bản tiếng Việt, đồng thời biết mẫu sai ở đâu.

Một regex là một mẫu (pattern). Bộ máy regex quét văn bản từ trái sang phải, tìm những đoạn khớp (match) với mẫu. Trong Python:

import re
re.search(r"Wave", "Bán xe Wave 2019") # khớp "Wave" ở vị trí 7

Mẫu đơn giản nhất là chữ thường: Wave khớp đúng chuỗi “Wave”. Sức mạnh nằm ở các ký tự đặc biệt (metacharacter): . ^ $ * + ? { } [ ] \ | ( ). Muốn khớp chính các ký tự này thì đặt \ trước: \. khớp một dấu chấm, \+ khớp một dấu cộng. Đó là lý do mẫu số điện thoại viết \+84.

Lớp ký tự: một vị trí, nhiều lựa chọn

Phần tiêu đề “Lớp ký tự: một vị trí, nhiều lựa chọn”

Lớp ký tự (character class) [...] khớp đúng một ký tự nằm trong ngoặc:

MẫuKhớp một ký tựVí dụ khớp
[35789]một trong năm chữ số 3, 5, 7, 8, 99 trong “0912”
[0-9]một chữ số từ 0 tới 9 (dấu - chỉ khoảng)2
[^>]mọi ký tự trừ > (dấu ^ đầu ngoặc là phủ định)b trong <b>
[ .]một dấu cách hoặc một dấu chấm (trong ngoặc, . là dấu chấm thật)dấu cách trong “0912 345”

Vài lớp hay dùng có tên viết tắt. Chữ hoa là phủ định của chữ thường:

Viết tắtTrong Python 3 (chuỗi str)Phủ định
\dmột chữ số Unicode, gồm 0-9\D
\wmột ký tự chữ (word character): mọi chữ cái và chữ số Unicode, cộng dấu _\W
\smột khoảng trắng: dấu cách, tab, xuống dòng\S
.mọi ký tự trừ xuống dòng (ký tự đại diện, wildcard)

Dòng \w là chỗ quan trọng với tiếng Việt. Tài liệu Python ghi rõ: với chuỗi str, \w khớp mọi ký tự mà str.isalnum() coi là chữ hoặc số, cộng dấu _. Vậy “ở”, “à”, “ộ”, “đ” đều là \w. Ngược lại, [a-zA-Z] chỉ có 52 chữ cái không dấu: nó cắt “Phở” thành “Ph”. Nhiều tài liệu tiếng Anh, kể cả sách của Jurafsky và Martin, viết \w như [a-zA-Z0-9_]. Điều đó chỉ đúng khi bật cờ re.ASCII.

Lượng từ (quantifier) đặt ngay sau một ký tự, một lớp hay một nhóm, và nói nó được lặp bao nhiêu lần:

Lượng từSố lầnVí dụ
*0 lần trở lên[0-9]* khớp cả chuỗi rỗng
+1 lần trở lên[0-9]+ khớp “2019”
?0 hoặc 1 lần[ .]? cho phép có hoặc không có dấu cách
{3}đúng 3 lần\d{3} khớp “345”
{1,2}từ 1 tới 2 lần\d{1,2} khớp “5” và “05”

Lượng từ chỉ áp lên thứ đứng ngay trước nó. ha+ khớp trọn “haaa”, còn trong “hahaha” nó chỉ khớp từng “ha” một. Muốn khớp cả “hahaha” thì phải nhóm lại: (ha)+.

Neo (anchor) không khớp ký tự nào. Nó chỉ kiểm tra vị trí:

  • ^ khớp ở đầu chuỗi, $ khớp ở cuối chuỗi. Với cờ re.MULTILINE, chúng khớp cả ở đầu và cuối mỗi dòng.
  • \b là ranh giới từ (word boundary): chỗ giữa một ký tự \w và một ký tự không phải \w, hoặc giữa một ký tự \w và đầu hay cuối chuỗi. \bxe\b khớp “xe” trong “Bán xe Wave”, không khớp trong “xem”. Vì \w của Python 3 hiểu chữ có dấu, \b cũng hiểu: \bHà\b khớp “Hà” trong “Hà Nội”.

Dấu | nghĩa là “hoặc”, và nó có độ ưu tiên thấp nhất: \+84|0 là “\+84 hoặc 0”. Dấu ngoặc tròn có hai việc:

  1. Gom nhóm, để lượng từ hay dấu | áp lên cả cụm: (?:\+84|0) giới hạn phép “hoặc” trong ngoặc.
  2. Bắt (capture) đoạn khớp để dùng lại. (\d{1,2}) là một nhóm bắt (capturing group). Các nhóm được đánh số từ 1, theo thứ tự dấu mở ngoặc từ trái sang phải.

Viết (?:...) khi chỉ cần gom mà không cần bắt: đó là nhóm không bắt (non-capturing group). Muốn đặt tên cho nhóm thì viết (?P<ngay>...).

Nhóm bắt dùng được trong phép thay thế qua tham chiếu ngược (backreference) \1, \2, \3. Với mẫu ngày tháng \b(\d{1,2})/(\d{1,2})/(\d{4})\b, phần thay \3-\2-\1 đổi “05/11/2026” thành “2026-11-05”: nhóm 3 (năm), gạch ngang, nhóm 2 (tháng), gạch ngang, nhóm 1 (ngày).

Lượng từ mặc định là tham lam (greedy): khớp càng nhiều càng tốt, rồi chỉ nhả bớt khi phần sau của mẫu cần. Thử với đoạn HTML <b>Phở</b> và <i>bún chả</i> và mẫu <.*>:

  • < khớp dấu < đầu tiên.
  • .* nuốt hết phần còn lại của chuỗi.
  • > không còn gì để khớp, nên bộ máy lùi từng ký tự. Lần lùi đầu tiên đã trả lại dấu > cuối chuỗi. Khớp xong.

Kết quả là cả chuỗi, không phải thẻ <b>. Thêm ? sau lượng từ để nó thành lười (lazy, non-greedy): *?, +?, ?? khớp càng ít càng tốt. <.*?> thử khớp 0 ký tự, rồi 1, rồi 2…, và dừng ở dấu > gần nhất, nên lấy được từng thẻ. Cách thứ ba thường rõ ràng hơn: <[^>]+> nói thẳng “các ký tự không phải >”, nên không bao giờ vượt qua dấu >.

Đôi khi bạn cần điều kiện về hàng xóm của đoạn khớp mà không muốn đưa hàng xóm vào kết quả. Đó là lookaround. Giống neo, nó khớp một vị trí, độ dài 0:

Cú phápTênĐúng khi
(?=...)nhìn trước (lookahead)ngay sau là ...
(?!...)nhìn trước phủ địnhngay sau không là ...
(?<=...)nhìn sau (lookbehind)ngay trước là ...
(?<!...)nhìn sau phủ địnhngay trước không là ...

Trong tin rao vặt, số tài khoản 1903456789123 chứa đoạn “0345678912”, đúng hình dạng một số di động. Mẫu số điện thoại vì vậy mở bằng (?<!\d) và kết bằng (?!\d): trước và sau đoạn khớp không được là chữ số. Sao không dùng \b? Vì \b đòi một bên là \w, mà trước “+84” thường là dấu cách: giữa dấu cách và + không có ranh giới từ nào, nên \b\+84 không khớp ở đó. Trong Python, phần bên trong lookbehind phải có độ dài cố định: (?<!\d) được, (?<!\d+) thì báo lỗi.

HàmLàm gìTrả về
re.search(p, s)tìm chỗ khớp đầu tiên, ở bất kỳ đâuđối tượng Match hoặc None
re.match(p, s)chỉ thử khớp ở đầu chuỗiMatch hoặc None
re.fullmatch(p, s)đòi cả chuỗi khớpMatch hoặc None
re.findall(p, s)mọi chỗ khớp, không chồng lên nhaudanh sách chuỗi; nếu mẫu có nhóm bắt thì danh sách nhóm
re.finditer(p, s)như findalldãy Match, có cả vị trí m.start(), m.end()
re.sub(p, r, s)thay mọi chỗ khớp bằng r (r có thể chứa \1 hoặc là một hàm)chuỗi mới
re.split(p, s)tách chuỗi tại các chỗ khớpdanh sách chuỗi
re.compile(p, flags)biên dịch mẫu một lần, dùng lại nhiều lầnđối tượng Pattern có các hàm trên

Hai điểm hay gây bất ngờ. Thứ nhất, findall đổi dạng kết quả theo số nhóm bắt: không nhóm thì trả cả đoạn khớp, một nhóm thì trả nhóm đó, nhiều nhóm thì trả các bộ (tuple). Thứ hai, re.match(r"\d+", "Giá 12 triệu") trả None, vì chuỗi không bắt đầu bằng chữ số; re.search mới tìm ra “12”.

Các cờ (flag) hay dùng: re.IGNORECASE (không phân biệt hoa thường), re.MULTILINE (^ và $ theo từng dòng), re.DOTALL (. khớp cả xuống dòng), re.ASCII (\w, \d, \b, \s chỉ hiểu ASCII), re.VERBOSE (cho phép viết mẫu nhiều dòng, có chú thích).

Nhìn theo kiểu toán: một regex là một tập chuỗi

Phần tiêu đề “Nhìn theo kiểu toán: một regex là một tập chuỗi”

Phần này giải thích chữ “chính quy” trong tên gọi, và chuẩn bị cho mục Giới hạn. Gọi Σ\Sigma là bảng chữ cái, tức tập mọi ký tự được dùng, và Σ∗\Sigma^* là tập mọi chuỗi hữu hạn gồm các ký tự đó, kể cả chuỗi rỗng ε\varepsilon. Mỗi regex rr mô tả một tập chuỗi L(r)⊆Σ∗L(r) \subseteq \Sigma^*: những chuỗi khớp trọn vẹn với rr.

Kleene (1951, in lại năm 1956) định nghĩa regex như sau: các viên gạch cơ sở là từng chữ cái đơn lẻ (khớp đúng chữ cái đó), chuỗi rỗng ε\varepsilon và tập rỗng ∅\varnothing (không khớp gì); mọi regex khác được ghép từ chúng bằng ba phép:

L(r1r2)={uv:u∈L(r1), v∈L(r2)}L(r1∣r2)=L(r1)∪L(r2)L(r∗)={ε}∪L(r)∪L(rr)∪L(rrr)∪⋯\begin{aligned} L(r_1 r_2) &= \lbrace uv : u \in L(r_1),\ v \in L(r_2) \rbrace \\ L(r_1 \mid r_2) &= L(r_1) \cup L(r_2) \\ L(r^*) &= \lbrace \varepsilon \rbrace \cup L(r) \cup L(rr) \cup L(rrr) \cup \cdots \end{aligned}
  • Nối (concatenation): dòng đầu nói chuỗi khớp r1r2r_1 r_2 là một chuỗi khớp r1r_1 đặt liền trước một chuỗi khớp r2r_2.
  • Hợp (union): dòng hai là dấu |, chuỗi khớp một trong hai mẫu.
  • Sao Kleene (Kleene star): dòng ba là dấu *, nối rr với chính nó 0, 1, 2… lần.

Các ký hiệu còn lại chỉ là cách viết tắt. r+r^+ là rr∗r r^* (ít nhất một lần). r?r? là r∣εr \mid \varepsilon. [357] là 3∣5∣73 \mid 5 \mid 7. \d{3} là \d\d\d. Một tập chuỗi viết được từ các viên gạch cơ sở và ba phép trên gọi là ngôn ngữ chính quy (regular language). Kleene cũng chứng minh rằng đó đúng là các tập mà một ôtômat hữu hạn (finite automaton) nhận ra được: một cỗ máy chỉ có hữu hạn trạng thái, đọc từng ký tự một và chuyển trạng thái.

Về lịch sử: Thompson (1968) biên dịch regex thành ôtômat cho trình soạn thảo QED, và cách mô phỏng ôtômat của ông chạy trong thời gian tỉ lệ với độ dài văn bản. Trình soạn thảo ed về sau có lệnh g/re/p (tìm theo regex rồi in dòng), từ đó có lệnh grep. Nhưng theo Cox (2007), ed và grep lại tìm khớp bằng quay lui, và nhiều bộ máy regex ngày nay, kể cả re của Python, cũng vậy. Mục Giới hạn cho thấy cái giá của lựa chọn đó.

Regex của Python mạnh hơn lý thuyết một chút: tham chiếu ngược như (\w+) \1 (một từ lặp lại hai lần) không chính quy theo nghĩa toán học. Nhưng ý chính vẫn đúng, và nó giải thích giới hạn đầu tiên dưới đây.

  • Cấu trúc lồng nhau tùy ý. Để biết thẻ </div> nào đóng thẻ <div> nào, phải đếm đang mở bao nhiêu thẻ. Độ sâu có thể lớn tùy ý, còn ôtômat hữu hạn chỉ có hữu hạn trạng thái, nên không đếm được. Tập các chuỗi ngoặc cân bằng như (), (()), ((())) không phải ngôn ngữ chính quy. Với HTML, JSON hay mã nguồn, hãy dùng bộ phân tích thật, ví dụ html.parser hay json của Python. Regex chỉ hợp với việc nhỏ trên văn bản phẳng, như bỏ thẻ trong ô code thứ hai.
  • Quay lui thảm họa (catastrophic backtracking). Mô-đun re tìm khớp bằng cách thử và quay lui (backtracking): một nhánh hỏng thì lùi lại, thử cách chia khác. Với mẫu (a+)+$ và chuỗi nn chữ a theo sau là một dấu !, mẫu chắc chắn thất bại ở dấu !. Nhưng trước khi chịu thua, bộ máy thử mọi cách chia nn chữ a thành các lượt của a+ bên trong, và có 2n−12^{n-1} cách như vậy (giữa hai chữ a liền nhau, hoặc cắt, hoặc không). Trên máy phát triển, với Python 3.14, 24 chữ a mất khoảng 1.7 giây, và mỗi chữ a thêm vào làm thời gian gấp khoảng đôi. Bài này không cho chạy mẫu đó trên trang, vì nó có thể treo trình duyệt. Một mẫu như vậy trên server, gặp đầu vào do người dùng gửi, là một lỗ hổng từ chối dịch vụ có tên ReDoS. Cách chữa: tránh lượng từ lồng nhau trên cùng một thứ ((a+)+ viết lại thành a+), và giới hạn độ dài đầu vào.
  • Regex không hiểu nghĩa. Mẫu ngày tháng của bài khớp cả “31/02/2026” và “99/99/2026”. Mẫu chỉ kiểm hình dạng, nên luôn có hai loại lỗi: dương tính giả (false positive), khớp thứ không nên khớp, và âm tính giả (false negative), bỏ sót thứ nên khớp. Siết mẫu để bớt loại này thường làm tăng loại kia. Hộp cuối ở mục Lỗi hay gặp có thêm ví dụ.
  • Tách từ tiếng Việt cần hơn regex. \w+ cắt “Hà Nội” thành hai âm tiết “Hà” và “Nội”, trong khi đó là một từ. Bài Tách từ đi tiếp từ chỗ này.

Bảng dưới tách mẫu số điện thoại trong ô code thứ nhất thành từng phần, mỗi dòng một phần, theo đúng thứ tự bộ máy regex đi qua. Hai cột bên phải là đoạn mà từng phần khớp được trong hai số của tin rao vặt. Hãy nhìn các ô “rỗng”: lookaround và lượng từ ? có thể khớp mà không lấy ký tự nào.

Phần của mẫuÝ nghĩaTrong 0912 345 678Trong +84987654321
(?<!\d)ngay trước không phải chữ sốrỗng (chỉ kiểm tra)rỗng (chỉ kiểm tra)
(?:\+84|0)+84 hoặc 0, nhóm không bắt0+84
[35789]một chữ số đầu của đầu số di động99
\d{2}đúng hai chữ số1287
[ .]?một dấu cách hoặc dấu chấm, có thể không códấu cáchrỗng
\d{3}đúng ba chữ số345654
[ .]?như trêndấu cáchrỗng
\d{3}đúng ba chữ số678321
(?!\d)ngay sau không phải chữ sốrỗng (chỉ kiểm tra)rỗng (chỉ kiểm tra)

Với số tài khoản 1903456789123, đoạn “0345678912” qua được mọi dòng giữa bảng. Nhưng dòng đầu thất bại: ngay trước số 0 là chữ số 9. Đó là việc của lookaround.

Mẫu ngày tháng ngắn hơn, nhưng có ba nhóm bắt. Bảng dưới cho thấy nhóm nào giữ phần nào của “05/11/2026”, và phép thay \3-\2-\1 ghép chúng lại ra sao.

Phần của mẫuÝ nghĩaKhớpNhóm
\branh giới từrỗng
(\d{1,2})một hoặc hai chữ số: ngày05\1
/dấu gạch chéo/
(\d{1,2})một hoặc hai chữ số: tháng11\2
/dấu gạch chéo/
(\d{4})bốn chữ số: năm2026\3
\branh giới từrỗng

Phép thay \3-\2-\1 cho ra 2026 + - + 11 + - + 05, tức “2026-11-05”.

Code trong bài chạy thử lần cuối tháng 11/2026 với Python 3.14 (bản có trong Pyodide). Cả hai ô chỉ dùng mô-đun có sẵn của Python (re, unicodedata), chạy ngay trên trang và chạy độc lập với nhau.

Ô dưới biên dịch ba mẫu của bài, trích số điện thoại, email, ngày tháng từ tin rao vặt, thử bỏ lookaround để thấy nó dùng vào việc gì, rồi che số điện thoại và đổi ngày sang dạng năm-tháng-ngày. Mẫu số điện thoại được viết thành nhiều chuỗi thô liền nhau: Python tự nối chúng thành một chuỗi, và mỗi dòng có chỗ cho một chú thích, giống bảng ở mục Minh họa.

Trích số điện thoại, email, ngày tháng và che số

# Trích số điện thoại, email, ngày tháng từ một tin rao vặt, rồi che số
import re
text = (
"Bán xe Wave 2019, giá 12 triệu. Gọi anh Nam 0912 345 678 "
"hoặc chị Lan +84987654321. Email: nam.nguyen@gmail.com. "
"Xem xe từ 05/11/2026 đến 15/11/2026. "
"STK 1903456789123 không phải số điện thoại."
)
# Mỗi dòng một phần của mẫu, nối lại thành một chuỗi
PHONE = re.compile(
r"(?<!\d)" # ngay trước không phải chữ số
r"(?:\+84|0)" # mở đầu bằng +84 hoặc 0
r"[35789]\d{2}" # ba số đầu: đầu số di động
r"[ .]?\d{3}[ .]?\d{3}" # sáu số còn lại, có thể cách nhau
r"(?!\d)" # ngay sau không phải chữ số
)
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
DATE = re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b")
print("phones:", PHONE.findall(text))
print("emails:", EMAIL.findall(text))
print("dates: ", DATE.findall(text)) # có nhóm: ra bộ (ngày, tháng, năm)
# Bỏ hai lookaround thì số tài khoản cũng bị coi là số điện thoại
loose = re.compile(r"(?:\+84|0)[35789]\d{2}[ .]?\d{3}[ .]?\d{3}")
print("no guard:", loose.findall(text))
# Che số điện thoại, đổi ngày sang dạng yyyy-mm-dd bằng nhóm \3-\2-\1
masked = PHONE.sub("<PHONE>", text)
masked = DATE.sub(r"\3-\2-\1", masked)
print(masked)

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • phones có đúng hai số, '0912 345 678' và '+84987654321'. Năm 2019 và số tài khoản không lọt vào.
  • emails là ['nam.nguyen@gmail.com']. Dấu chấm cuối câu ngay sau “.com” không bị lấy theo, vì (?:\.[\w-]+)+ đòi sau mỗi dấu chấm phải có ít nhất một ký tự chữ hoặc dấu -.
  • dates là danh sách bộ ('05', '11', '2026') và ('15', '11', '2026'), không phải chuỗi ngày đầy đủ, vì mẫu DATE có ba nhóm bắt.
  • no guard: bỏ hai lookaround, mẫu lấy thêm '0345678912' từ giữa số tài khoản. Một dương tính giả điển hình.
  • Dòng cuối là tin rao vặt sau hai lần sub: hai số điện thoại thành <PHONE>, hai ngày thành “2026-11-05” và “2026-11-15”. Email vẫn còn. Muốn che nốt thì thêm một lần EMAIL.sub(...).

Ô thứ hai gom các chỗ dễ sai: [a-zA-Z] so với \w trên chữ có dấu, cờ re.ASCII, chữ ở dạng tách dấu, tham lam và lười trên HTML, và một bộ tách từ đơn giản.

Chữ có dấu, tham lam và lười, bộ tách từ nhỏ

# \w với chữ có dấu, tham lam và lười, và một bộ tách từ nhỏ bằng regex
import re
import unicodedata
s = "Phở Hà Nội ngon quá!"
print(re.findall(r"[a-zA-Z]+", s)) # chỉ chữ không dấu
print(re.findall(r"\w+", s)) # \w của Python 3 là Unicode
print(re.findall(r"\w+", s, flags=re.ASCII)) # cờ ASCII: quay về a-z
# Cùng chữ "hòa" nhưng dấu huyền tách thành ký tự riêng (dạng NFD)
nfd = unicodedata.normalize("NFD", "hòa")
print(len("hòa"), len(nfd), len(re.findall(r"\w+", nfd)))
# Tham lam và lười trên một đoạn HTML ngắn
html = "<b>Phở</b> và <i>bún chả</i>"
print(re.findall(r"<.*>", html)) # .* nuốt tới dấu > cuối cùng
print(re.findall(r"<.*?>", html)) # .*? dừng ở dấu > gần nhất
print(re.sub(r"<[^>]+>", "", html)) # bỏ thẻ: [^>] không vượt qua >
# Bộ tách từ đơn giản: một dãy chữ, hoặc một dấu câu
line = "Giá 12.000đ, rẻ quá!"
print(re.findall(r"\w+|[^\w\s]", line))
# Thêm nhánh số đứng trước để giữ nguyên "12.000"
NUM = r"\d+(?:[.,]\d+)*"
print(re.findall(NUM + r"|\w+|[^\w\s]", line))

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả, theo thứ tự các dòng in ra:

  • Ba dòng đầu. [a-zA-Z]+ cắt “Phở Hà Nội ngon quá!” thành ['Ph', 'H', 'N', 'i', 'ngon', 'qu']. \w+ cho đúng năm âm tiết ['Phở', 'Hà', 'Nội', 'ngon', 'quá']. Bật cờ re.ASCII thì \w quay về nghĩa ASCII, và kết quả lại giống dòng đầu.
  • 3 4 2. Chữ “hòa” gõ thông thường có 3 ký tự. Ở dạng chuẩn hóa NFD, dấu huyền tách thành một ký tự tổ hợp (combining character) riêng, nên có 4 ký tự. Ký tự tổ hợp không phải \w, nên \w+ cắt chữ làm 2 mảnh. Văn bản lấy từ nhiều nguồn có thể ở dạng này mà nhìn không khác gì. Bài Chuẩn hóa văn bản chỉ cách đưa về một dạng trước khi chạy regex.
  • Ba dòng HTML. <.*> tham lam trả về một mảnh là cả chuỗi. <.*?> lười trả về bốn thẻ ['<b>', '</b>', '<i>', '</i>']. re.sub(r"<[^>]+>", "", html) bỏ thẻ, còn lại “Phở và bún chả”.
  • Hai dòng cuối. Mẫu \w+|[^\w\s] là một bộ tách từ nhỏ: một dãy ký tự chữ, hoặc một ký tự không phải chữ cũng không phải khoảng trắng (dấu câu). Trên “Giá 12.000đ, rẻ quá!”, nó cắt giá tiền thành '12', '.', '000đ'. Thêm nhánh số \d+(?:[.,]\d+)* đứng trước thì ra '12.000' và 'đ'. Thứ tự các nhánh quan trọng: tại mỗi vị trí, bộ máy thử nhánh trái trước, nhánh nào khớp trước thì được chọn, kể cả khi nhánh sau khớp được dài hơn.
Câu 1/5 Bạn chạy re.findall(r"\d{2}/\d{2}/\d{4}", "Hạn 5/11/2026 và 15/11/2026"). Kết quả là gì?
Câu 2/5 Với chuỗi Anh nói "chào" rồi "tạm biệt", mẫu ".*" (dấu nháy kép, dấu chấm, dấu sao, dấu nháy kép) khớp được đoạn nào?
Câu 3/5 Trong Python 3, re.findall(r"[a-zA-Z]+", "Đà Lạt") trả về gì?
Câu 4/5 Vì sao không thể viết một regex (chính quy theo nghĩa toán học) khớp đúng mọi đoạn HTML có thẻ <div> lồng nhau sâu tùy ý?
Câu 5/5 Mẫu (a+)+$ chạy trên chuỗi gồm nn chữ a rồi một dấu !. Giả sử mỗi chữ a thêm vào làm thời gian gấp đôi, và 24 chữ a mất khoảng 1.7 giây. Với 30 chữ a, khoảng bao lâu?

Ý chính:

  • Regex mô tả hình dạng của một tập chuỗi. Lớp ký tự [...], \d, \w, \s, . chọn một ký tự; lượng từ *, +, ?, {m,n} nói bao nhiêu lần; neo ^, $, \b và lookaround (?=...), (?<!...) kiểm tra vị trí mà không lấy ký tự; nhóm (...) vừa gom vừa bắt, dùng lại bằng \1.
  • Lượng từ mặc định tham lam, khớp càng nhiều càng tốt; thêm ? thì thành lười. Lớp phủ định như [^>]+ thường rõ ràng hơn cả hai.
  • Trong Python, viết mẫu bằng chuỗi thô r"...", biên dịch bằng re.compile khi dùng lại, và chọn đúng hàm: search, match, fullmatch, findall, sub. \w của Python 3 hiểu chữ có dấu, [a-zA-Z] thì không; văn bản ở dạng tách dấu cần chuẩn hóa trước.
  • Regex không đếm được độ sâu lồng nhau (HTML, ngoặc), có thể chạy lâu theo hàm mũ khi lượng từ lồng nhau như (a+)+, và không hiểu nghĩa. Luôn thử mẫu trên cả dương tính giả lẫn âm tính giả.

Đọc thêm:

  • Jurafsky, D., Martin, J. H. (2026). Speech and Language Processing, bản nháp lần 3 (ngày 19/8/2026), chương 2 “Words and Tokens”, mục 2.6 (PDF của chương có link từ trang này). Giới thiệu regex cho xử lý ngôn ngữ, có dương tính giả và âm tính giả, ELIZA, và regex tiền xử lý của bộ tách từ GPT-2. Sách viết \w theo nghĩa ASCII [a-zA-Z0-9_], khác với Python 3 mặc định.
  • Python Software Foundation. re — Regular expression operations, tài liệu Python 3.14. Bảng đầy đủ cú pháp, các cờ, và các hàm của mô-đun re. Phần mô tả \w, \d cho chuỗi Unicode là nguồn của bài này.
  • Kuchling, A. M. Regular Expression HOWTO, tài liệu Python 3.14. Hướng dẫn dạng bài học, đi từ ký tự đơn tới nhóm và lookahead, có mục về chuỗi thô và lỗi hay gặp.
  • Cox, R. (2007). Regular Expression Matching Can Be Simple And Fast. Giải thích vì sao bộ máy quay lui có thể chạy theo hàm mũ, và cách mô phỏng ôtômat của Thompson tránh được điều đó.
  • Thompson, K. (1968). Regular expression search algorithm. Communications of the ACM 11(6): 419–422. Bài gốc biên dịch regex thành ôtômat để tìm kiếm văn bản. Lý thuyết nền là Kleene, S. C. (1951), Representation of Events in Nerve Nets and Finite Automata, RAND RM-704, in lại năm 1956 trong Automata Studies.

Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu