Two Pointers Pattern: Đảo mảng, kiểm tra chuỗi đối xứng và tìm cặp số
Hướng dẫn Two Pointers Pattern bằng TypeScript: opposite pointers, read/write pointers, đảo mảng, chuỗi đối xứng, tìm cặp số, 3Sum và lỗi thường gặp.
ShanverseUpdated 11 min read— viewsLoading views
Data Structures
Algorithms
Two Pointers là gì?
Thay vì cố định một phần tử rồi duyệt lại toàn bộ phần còn lại, ta đặt hai con trỏ trên cùng cấu trúc dữ liệu:
Mỗi bước dùng thông tin hiện tại để quyết định:
Tăng left.
Giảm right.
Di chuyển cả hai.
Giữ một con trỏ đọc và một con trỏ ghi.
Hiệu quả không đến từ tên biến
Comments
left/right
, mà từ việc mỗi bước
loại bỏ chắc chắn một phần không gian tìm kiếm
.
Các biến thể chính
Sliding Window là một họ con của Two Pointers nhưng còn duy trì trạng thái của toàn bộ cửa sổ.
Khi nào nên nghĩ đến Two Pointers?
Dấu hiệu phổ biến:
Mảng hoặc chuỗi có thứ tự/tính đối xứng.
Cần tìm pair/triplet thỏa điều kiện.
Cần xử lý in-place với bộ nhớ phụ nhỏ.
Kết quả phụ thuộc vào hai đầu của một đoạn.
Có thể loại bỏ một phía dựa trên so sánh hiện tại.
Một con trỏ có thể đọc, con trỏ còn lại ghi kết quả đã compact.
Two Pointers không tự động đúng cho mọi mảng. Pair sum trên mảng chưa sort, điều kiện không monotonic hoặc cần thử tổ hợp bất kỳ có thể cần hash map, sorting, binary search hay thuật toán khác.
Mẫu 1: Hai con trỏ từ hai đầu
Câu hỏi quan trọng ở mỗi nhánh: Vì sao di chuyển con trỏ này không thể bỏ lỡ đáp án?
Ví dụ 1: Đảo mảng in-place
Invariant
Trước mỗi vòng lặp:
Mọi phần tử trước left đã ở đúng vị trí đảo ngược.
Mọi phần tử sau right đã ở đúng vị trí đảo ngược.
Chỉ đoạn [left, right] còn chưa xử lý.
Mỗi vòng xử lý hai vị trí, nên:
Time: O(N).
Auxiliary space: O(1).
Hàm mutate input.
Nếu không muốn mutate:
Bản không mutate cần O(N) bộ nhớ vì tạo bản sao.
Ví dụ 2: Kiểm tra chuỗi đối xứng
Với constraint ASCII và bỏ qua ký tự không phải chữ/số:
Không cần tạo chuỗi đã lọc nên bộ nhớ phụ O(1) trong constraint ASCII.
Unicode không đơn giản
JavaScript index string theo UTF-16 code unit. Emoji, dấu kết hợp và grapheme cluster có thể bị tách. toLowerCase() cũng không giải quyết mọi quy tắc locale hay canonical equivalence.
Với dữ liệu quốc tế:
Xác định “ký tự” là code point hay grapheme người dùng nhìn thấy.
Normalize bằng String.prototype.normalize() theo policy.
Dùng Unicode property escapes hoặc Intl.Segmenter.
Xác định rõ case folding/locale.
Một bản tạo array grapheme có thể đúng hơn về ngôn ngữ nhưng dùng O(N) bộ nhớ; đó là trade-off hợp lý khi correctness quan trọng hơn auxiliary space.
Ví dụ 3: Tìm cặp số trong mảng đã sort
Cho mảng tăng dần, tìm hai phần tử có tổng bằng target:
Vì sao di chuyển đúng?
Nếu sum < target, giữ numbers[left] rồi chọn một phần tử nhỏ hơn numbers[right] chỉ làm tổng nhỏ hơn hoặc bằng hiện tại. Vì vậy left không thể thuộc đáp án còn lại và có thể tăng.
Nếu sum > target, giữ numbers[right] rồi chọn phần tử lớn hơn numbers[left] chỉ làm tổng lớn hơn hoặc bằng. Vì vậy có thể giảm right.
Time: O(N).
Space: O(1).
Precondition: mảng đã sort tăng dần.
Mảng chưa sort
Không được áp dụng logic trên trực tiếp. Có ba lựa chọn:
Hash map: O(N) time, O(N) space, giữ index gốc.
Sort bản sao: O(N log N) time; nếu chỉ sort value sẽ mất index gốc.
Sort các cặp { value, originalIndex }, rồi Two Pointers.
Trả về mọi pair duy nhất
Với mảng sorted, sau khi tìm một pair phải bỏ qua duplicates:
Nếu yêu cầu đếm mọi cặp index thay vì pair value duy nhất, logic duplicate phải khác: có thể cần đếm tần suất ở hai đầu.
Mẫu 2: Read/Write Pointers
Hai con trỏ cùng đi sang phải:
read khám phá input.
write đánh dấu nơi ghi phần tử hợp lệ tiếp theo.
Xóa duplicate khỏi sorted array
Sau khi chạy, sorted.slice(0, newLength) là vùng hợp lệ.
Invariant
Trước mỗi bước read, đoạn [0, write) chứa đúng các giá trị unique đã xử lý. write cũng chính là độ dài kết quả.
Di chuyển số 0 về cuối
Thứ tự tương đối của các số khác 0 được giữ nguyên. Time O(N), space O(1).
Có thể giảm số lần self-swap:
Mẫu 3: Fast/Slow Pointers
Trên linked list, fast đi hai bước và slow đi một bước:
Đây vẫn là Two Pointers nhưng không phải opposite-ends hay Sliding Window.
Mở rộng thành 3Sum
Fix một phần tử, rồi dùng Two Pointers cho phần còn lại:
Sorting tốn O(N log N), vòng ngoài kết hợp scan O(N²), space phụ thuộc implementation sort và output. Two Pointers giảm phần pair search từ O(N²) xuống O(N) cho mỗi phần tử cố định.
Two Pointers và Sliding Window khác nhau thế nào?
Ví dụ:
Pair sum sorted: không cần aggregate của toàn bộ [left, right].
Longest substring không lặp: cần frequency map của cửa sổ.
Reverse array: cả hai con trỏ tiến vào giữa.
Sliding Window: right thường mở rộng, left thu hẹp khi invariant bị vi phạm.
Chứng minh độ phức tạp
Đối với opposite pointers:
left chỉ tăng từ 0 đến N-1.
right chỉ giảm từ N-1 về 0.
Tổng số lần di chuyển không vượt quá 2N.
Đối với read/write:
read duyệt mỗi phần tử một lần.
write tăng tối đa N lần.
Do đó time thường là O(N), miễn là mỗi vòng không thực hiện thao tác O(N) như shift(), splice() giữa mảng, copy cả đoạn hoặc scan lại.
Lỗi thường gặp
1. Quên precondition sorted
Logic sum < target → left++ chỉ đúng khi array đã sort theo chiều phù hợp.
2. Sort làm mất index hoặc mutate input
Mutate array. Nếu cần giữ input hoặc index gốc, sort bản sao/cặp có index.
3. Infinite loop
Mọi nhánh trong while (left < right) phải di chuyển ít nhất một pointer hoặc return.
4. Dùng left <= right sai chỗ
Pair cần hai index khác nhau nên thường là left < right. <= có thể dùng cùng một phần tử hai lần.
5. Xử lý duplicate không đúng yêu cầu
“Unique value pairs”, “all index pairs” và “first pair” là ba output contract khác nhau.
6. So sánh số thực bằng ===
Với floating-point, cân nhắc epsilon:
Epsilon phải phù hợp domain; Number.EPSILON đơn lẻ không phải tolerance vạn năng.
7. Unicode/case normalization mơ hồ
Định nghĩa rõ ASCII, code point, grapheme, punctuation, accents và locale trước khi viết palindrome.
8. Tuyên bố O(1) nhưng đã tạo copy
[...items], Array.from(text), filter() hay normalized string đều dùng O(N) memory.
Edge cases cần test
Array/string rỗng.
Một phần tử.
Hai phần tử bằng nhau.
Tất cả phần tử trùng nhau.
Không có pair hợp lệ.
Nhiều pair trùng value.
Số âm, 0, số rất lớn.
NaN, Infinity nếu domain cho phép.
Palindrome chỉ có punctuation.
Case khác nhau, dấu tiếng Việt và Unicode normalization.
Emoji/grapheme cluster.
Input sorted tăng, giảm hoặc chưa sort.
Xác nhận hàm có mutate input hay không.
Quy trình giải bài Two Pointers
Xác định output contract: value, index, một kết quả hay tất cả kết quả.
Kiểm tra input có sorted không; có được phép sort không.
Chọn chuyển động: opposite, read/write, fast/slow hay window.
Viết invariant trước khi code.
Với mỗi nhánh, chứng minh pointer bị bỏ không thể thuộc đáp án.
Đảm bảo mọi vòng đều tiến triển.
Phân tích mutation và auxiliary space thật sự.
Kiểm tra duplicates, off-by-one, Unicode và number precision.
Test input nhỏ bằng brute force để đối chiếu.
Benchmark trên constraint thực tế.
Kết luận
Two Pointers là kỹ thuật “loại bỏ có chứng minh”:
Đảo mảng: đổi hai đầu rồi tiến vào giữa.
Palindrome: bỏ ký tự không liên quan rồi so sánh hai đầu.
Pair sum sorted: tổng nhỏ thì tăng trái, tổng lớn thì giảm phải.
Compact array: read đọc input, write xây vùng kết quả.
Khi precondition và invariant đúng, Two Pointers thường đạt O(N) time và O(1) auxiliary space. Khi input chưa sort, cần index gốc, Unicode phức tạp hoặc điều kiện không monotonic, hãy chọn hash map, sort có metadata hoặc thuật toán phù hợp thay vì ép Two Pointers.
Table of contents(32)
Codeplain text
1left → [ ... vùng chưa xử lý ... ] ← right
Codeplain text
1Two Pointers: dùng quan hệ giữa hai vị trí để loại bỏ lựa chọn.2Sliding Window: hai vị trí là biên của một đoạn liên tục có state.
Codeplain text
1Quan sát hai vị trí → loại một phía hoặc xác nhận kết quả → di chuyển pointer
Codetypescript
1let left =0;2let right = items.length-1;34while (left < right) {5// Inspect items[left] and items[right].67if (shouldMoveLeft()) left++;8elseif (shouldMoveRight()) right--;9else {10 left++;11 right--;12 }13}
Codetypescript
1functionreverseInPlace<T>(items:T[]):T[] {2let left =0;3let right = items.length-1;45while (left < right) {6 [items[left], items[right]] = [items[right], items[left]];7 left++;8 right--;9 }1011return items;12}
Hướng dẫn Fast & Slow Pointers và Floyd’s Cycle Detection bằng TypeScript: phát hiện cycle, tìm entry, cycle length, functional graph và các giới hạn thực tế.
Hướng dẫn Trie (Prefix Tree) để xây dựng Search Suggestion và Autocomplete: TypeScript, top-K ranking, Unicode normalization, radix tree và production architecture.