- "Con trỏ" trong kỹ thuật này thực chất là hai biến chỉ số nguyên (
lvàr, hoặcivàj), không phải kiểu con trỏ địa chỉ bộ nhớ*pcủa C++. - Điều kiện cốt lõi để dùng hai con trỏ là tính đơn điệu: khi một chỉ số dịch chuyển thì đại lượng đang xét tăng lên hoặc giảm đi theo một chiều xác định.
- Vì mỗi chỉ số chỉ tăng hoặc chỉ giảm tối đa
nlần trong suốt chương trình, độ phức tạp thời gian luôn là O(n) và bộ nhớ phụ là O(1).
Kỹ thuật hai con trỏ là gì?
Khi cần tìm hai phần tử trong mảng thỏa mãn một điều kiện nào đó (ví dụ: tìm hai số có tổng bằng X), cách nghĩ tự nhiên nhất là dùng hai vòng lặp lồng nhau: vòng ngoài cố định phần tử thứ nhất a[i], vòng trong thử mọi phần tử thứ hai a[j] đứng sau nó. Cách vét cạn này xét toàn bộ n(n - 1) / 2 cặp, tức có độ phức tạp là O(n²).
Tuy nhiên, nếu mảng đã được sắp xếp tăng dần, mỗi lần so sánh tổng a[l] + a[r] với X cho phép ta loại bỏ ngay cả một hàng dài các cặp không cần thử:
- Đặt chỉ số
l = 0ở đầu mảng (phần tử nhỏ nhất) vàr = n - 1ở cuối mảng (phần tử lớn nhất). - Nếu
a[l] + a[r] > X: vìa[l]đã là số nhỏ nhất đang còn lại mà cộng vớia[r]vẫn lớn hơnX, nêna[r]cộng với bất kỳ số nào khác trong đoạn cũng đều lớn hơnX. Ta loại ngaya[r]bằng cách giảmrđi 1 (r--). - Nếu
a[l] + a[r] < X: vìa[r]đã là số lớn nhất đang còn lại mà cộng vớia[l]vẫn chưa đủX, nêna[l]không thể ghép với số nào để đạtX. Ta loại ngaya[l]bằng cách tăngllên 1 (l++). - Nếu
a[l] + a[r] == X: ta đã tìm thấy cặp cần tìm!
Cách điều khiển hai chỉ số l và r phối hợp nhịp nhàng như vậy được gọi là kỹ thuật hai con trỏ (two pointers).
Ví dụ từng bước: Tìm cặp phần tử có tổng bằng 14
Cho mảng tăng dần 7 phần tử a = [1, 2, 4, 6, 8, 10, 15] và cần tìm hai vị trí có tổng bằng X = 14. Hãy theo dõi 4 bước dịch chuyển của L và R:
| Bước | Vị trí L | Vị trí R | Tổng a[L] + a[R] | So với 14 | Hành động tiếp theo |
|---|---|---|---|---|---|
| 1 | 0 (a[0] = 1) |
6 (a[6] = 15) |
1 + 15 = 16 |
16 > 14 (quá lớn) |
Giảm R xuống 5 |
| 2 | 0 (a[0] = 1) |
5 (a[5] = 10) |
1 + 10 = 11 |
11 < 14 (quá nhỏ) |
Tăng L lên 1 |
| 3 | 1 (a[1] = 2) |
5 (a[5] = 10) |
2 + 10 = 12 |
12 < 14 (quá nhỏ) |
Tăng L lên 2 |
| 4 | 2 (a[2] = 4) |
5 (a[5] = 10) |
4 + 10 = 14 |
Bằng 14 |
Tìm thấy tại (2, 5) |
Code tìm cặp có tổng bằng X bằng C++ và Python
#include <iostream>
#include <utility>
#include <vector>
using namespace std;
// Tìm hai vị trí (l, r) với l < r trong mảng tăng dần a sao cho a[l] + a[r] == x
pair<int, int> timCapTongX(const vector<int>& a, int x) {
int l = 0, r = (int)a.size() - 1;
while (l < r) {
long long tong = (long long)a[l] + a[r];
if (tong == x) return {l, r};
if (tong < x) l++; // cần tổng lớn hơn -> dịch con trỏ trái sang phải
else r--; // cần tổng nhỏ hơn -> dịch con trỏ phải sang trái
}
return {-1, -1};
}
int main() {
vector<int> a = {1, 2, 4, 6, 8, 10, 15};
auto [l1, r1] = timCapTongX(a, 14);
auto [l2, r2] = timCapTongX(a, 20);
cout << l1 << " " << r1 << "\n"; // 2 5
cout << l2 << " " << r2 << "\n"; // -1 -1
return 0;
}
def tim_cap_tong_x(a, x):
"""Tìm hai vị trí (l, r) trong danh sách tăng dần a sao cho a[l] + a[r] == x."""
l, r = 0, len(a) - 1
while l < r:
tong = a[l] + a[r]
if tong == x:
return l, r
if tong < x:
l += 1 # cần tổng lớn hơn -> tăng l
else:
r -= 1 # cần tổng nhỏ hơn -> giảm r
return -1, -1
a = [1, 2, 4, 6, 8, 10, 15]
print(*tim_cap_tong_x(a, 14)) # 2 5
print(*tim_cap_tong_x(a, 20)) # -1 -1
Nếu mảng đầu vào đề bài cho chưa được sắp xếp, bạn có thể sắp xếp mảng trước trong O(n log n) rồi chạy hai con trỏ trong O(n). Tổng độ phức tạp là O(n log n) — vẫn nhanh hơn rất nhiều so với O(n²) khi n = 100.000. (Nếu đề yêu cầu trả về chỉ số trên mảng gốc chưa sắp xếp, chỉ cần lưu từng phần tử kèm chỉ số ban đầu dưới dạng cặp (giá_trị, vị_trí_cũ) trước khi sắp xếp).
Vì sao hai con trỏ chỉ tốn O(n) bước?
Khi nhìn vào vòng lặp hoặc đặc biệt là các biến thể có vòng while lồng bên trong vòng for, người mới rất dễ tưởng nhầm độ phức tạp là O(n²). Để tính đúng, ta dùng phương pháp đếm tổng số bước dịch chuyển (amortized analysis):
- Ban đầu khoảng cách giữa hai con trỏ là
r - l = n - 1. - Ở mỗi vòng lặp, hoặc
ltăng thêm 1, hoặcrgiảm đi 1. Không có lệnh nào làmllùi lại hay làmrtăng lên. - Do đó, khoảng cách
r - lgiảm đi đúng 1 sau mỗi bước, nên vòng lặp chạy tối đan - 1lần rồi dừng.
Vậy:
- Thời gian: O(n) trên mảng đã sắp xếp.
- Bộ nhớ phụ: O(1) (chỉ dùng hai biến nguyên
lvàr).
Chủ đề này nằm trong Lecture 04 - Two Pointers & Sliding Window của Khóa Blue 2.0. Xem đề cương
Ba mẫu hai con trỏ phổ biến nhất bạn cần nhớ
Kỹ thuật hai con trỏ không chỉ có kiểu đi từ hai đầu vào giữa. Trong thực tế làm bài, bạn sẽ gặp 3 mẫu chính:
Mẫu 1: Hai con trỏ đi ngược chiều từ hai đầu mảng (l = 0, r = n - 1)
- Khi nào dùng: Tìm cặp có tổng bằng
X, tìm cặp có tổng gầnXnhất, đếm số cặp có tổng≤ X, kiểm tra xâu đối xứng (palindrome), đảo ngược mảng tại chỗ, hoặc bài toán chứa nhiều nước nhất (Container With Most Water). - Quy tắc: Một con trỏ ở đầu trái (
l = 0), một con trỏ ở đầu phải (r = n - 1), tiến lại gần nhau cho tới khil >= r. - Mẹo đếm số cặp có tổng
≤ Xtrên mảng tăng dần: Nếu tại bước đang xét màa[l] + a[r] <= X, thì vì mảng tăng dần nêna[l]ghép với bất kỳ phần tử nào từ vị tríl + 1đếnrcũng đều có tổng≤ X. Có đúngr - lphần tử như vậy! Ta cộng ngayans += (r - l)rồi tăngl++mà không cần duyệt từng phần tử ở giữa. Ngược lại nếua[l] + a[r] > X, ta chỉ cần giảmr--.
Mẫu 2: Hai con trỏ trên hai mảng khác nhau (i = 0 trên mảng A, j = 0 trên mảng B)
- Khi nào dùng: Gộp hai mảng đã sắp xếp thành một mảng tăng dần (bước Merge cốt lõi của thuật toán Merge Sort), tìm giao của hai mảng tăng dần, tìm cặp một phần tử thuộc
Avà một phần tử thuộcBcó chênh lệch|a[i] - b[j]|nhỏ nhất, hoặc kiểm tra xâu con không liên tiếp (subsequence). - Quy tắc: So sánh
a[i]vàb[j], bên nào nhỏ hơn thì đưa vào kết quả (hoặc dịch chuyển để thu hẹp khoảng cách) và nhích con trỏ của mảng đó lên 1.
Hãy xem bảng chạy từng bước khi gộp mảng A = [1, 3, 5, 8] và B = [2, 3, 6, 7]:
| Bước | Con trỏ i (Mảng A) |
Con trỏ j (Mảng B) |
So sánh A[i] và B[j] |
Phần tử đưa vào mảng kết quả | Mảng kết quả hiện tại |
|---|---|---|---|---|---|
| 1 | i = 0 (1) |
j = 0 (2) |
1 <= 2 → lấy A[0] |
1 (i tăng lên 1) |
[1] |
| 2 | i = 1 (3) |
j = 0 (2) |
3 > 2 → lấy B[0] |
2 (j tăng lên 1) |
[1, 2] |
| 3 | i = 1 (3) |
j = 1 (3) |
3 <= 3 → lấy A[1] |
3 (i tăng lên 2) |
[1, 2, 3] |
| 4 | i = 2 (5) |
j = 1 (3) |
5 > 3 → lấy B[1] |
3 (j tăng lên 2) |
[1, 2, 3, 3] |
| 5 | i = 2 (5) |
j = 2 (6) |
5 <= 6 → lấy A[2] |
5 (i tăng lên 3) |
[1, 2, 3, 3, 5] |
| 6 | i = 3 (8) |
j = 2 (6) |
8 > 6 → lấy B[2] |
6 (j tăng lên 3) |
[1, 2, 3, 3, 5, 6] |
| 7 | i = 3 (8) |
j = 3 (7) |
8 > 7 → lấy B[3] |
7 (j tăng lên 4 — hết B) |
[1, 2, 3, 3, 5, 6, 7] |
| Vét đuôi | i = 3 (8) |
j = 4 (hết) |
Mảng B đã hết → vét nốt A | 8 (i tăng lên 4 — hết A) |
[1, 2, 3, 3, 5, 6, 7, 8] |
#include <iostream>
#include <vector>
using namespace std;
// Gộp hai mảng tăng dần a và b thành một mảng tăng dần trong O(n + m)
vector<int> gopHaiMang(const vector<int>& a, const vector<int>& b) {
int i = 0, j = 0;
vector<int> c;
c.reserve(a.size() + b.size());
while (i < (int)a.size() && j < (int)b.size()) {
if (a[i] <= b[j]) c.push_back(a[i++]);
else c.push_back(b[j++]);
}
while (i < (int)a.size()) c.push_back(a[i++]);
while (j < (int)b.size()) c.push_back(b[j++]);
return c;
}
int main() {
vector<int> a = {1, 4, 7, 9};
vector<int> b = {2, 3, 7, 12};
vector<int> c = gopHaiMang(a, b);
for (int k = 0; k < (int)c.size(); ++k) {
cout << c[k] << (k + 1 == (int)c.size() ? "\n" : " "); // 1 2 3 4 7 7 9 12
}
return 0;
}
def gop_hai_mang(a, b):
"""Gộp hai danh sách tăng dần a và b thành danh sách tăng dần trong O(n + m)."""
i, j = 0, 0
c = []
while i < len(a) and j < len(b):
if a[i] <= b[j]:
c.append(a[i])
i += 1
else:
c.append(b[j])
j += 1
c.extend(a[i:])
c.extend(b[j:])
return c
a = [1, 4, 7, 9]
b = [2, 3, 7, 12]
print(*gop_hai_mang(a, b)) # 1 2 3 4 7 7 9 12
Mẫu 3: Hai con trỏ đi cùng chiều trên một mảng (write và read, hoặc left và right cùng xuất phát từ đầu mảng)
- Khi nào dùng: Xóa phần tử trùng nhau trên mảng đã sắp xếp, dồn tất cả các số
0về cuối mảng mà vẫn giữ nguyên thứ tự các số khác0, nén mảng tại chỗ (in-place) với bộ nhớO(1), hoặc duy trì một đoạn con[left..right]thỏa mãn điều kiện. - Cơ chế con trỏ đọc – ghi (
read/write): Con trỏreadđi lướt qua từng phần tử từ0đếnn - 1để kiểm tra. Mỗi khi gặp phần tử hợp lệ (ví dụ phần tử khác với phần tử vừa ghi trước đóa[read] != a[write]), ta tăngwrite++và chépa[write] = a[read]. Kết thúc vòng lặp, đoạn0 .. writechứa toàn bộ các phần tử phân biệt mà không tốn thêm bất kỳ mảng phụ nào. - Khi đoạn
[left..right]được dùng như một khung trượt dọc theo mảng để tính tổng hoặc đếm tần suất, mẫu này chính là Kỹ thuật cửa sổ trượt (Sliding Window).
So sánh Hai con trỏ với Vét cạn, Tìm kiếm nhị phân và Bảng băm
Cùng một bài toán tìm cặp phần tử có tổng bằng X, bạn có tới 4 cách tiếp cận. Bảng dưới đây giúp bạn chọn đúng công cụ theo ràng buộc của đề bài:
| Phương pháp | Độ phức tạp thời gian | Bộ nhớ phụ | Yêu cầu mảng đã sắp xếp? | Khi nào nên chọn? |
|---|---|---|---|---|
| Hai vòng lặp lồng nhau (Vét cạn) | O(n²) |
O(1) |
Không | Chỉ dùng khi n ≤ 3.000 |
Bảng băm (unordered_set / dict) |
Trung bình O(n) |
O(n) |
Không | Khi không muốn thay đổi thứ tự mảng gốc và bộ nhớ RAM thoải mái |
Duyệt i + Tìm kiếm nhị phân X - a[i] |
O(n log n) |
O(1) |
Có | Khi dễ viết nhanh hoặc điều kiện kiểm tra phức tạp hơn tổng đơn giản |
| Kỹ thuật hai con trỏ | O(n) (nếu đã sắp xếp) hoặc O(n log n) (tính cả bước sort) |
O(1) |
Có | Tối ưu nhất trên mảng đã sắp xếp, không tốn bộ nhớ O(n) như bảng băm và rất dễ mở rộng sang bài toán 3Sum hoặc đếm số cặp a[l] + a[r] ≤ X |
Lỗi hay gặp
- Áp dụng hai con trỏ tìm tổng trên mảng chưa sắp xếp. Nếu mảng chưa có thứ tự tăng dần, việc tăng
lkhông đảm bảo tổng sẽ tăng lên, và việc giảmrcũng không đảm bảo tổng sẽ giảm xuống. Thuật toán sẽ bỏ sót nghiệm ngay lập tức. - Dùng điều kiện
while (l <= r)khi đề yêu cầu hai phần tử ở hai vị trí khác nhau. Nếu viếtl <= r, khil == rchương trình sẽ lấy chính phần tửa[l]cộng với chính nó (a[l] + a[l] == X). Hãy dùngwhile (l < r). - Tràn số nguyên khi cộng
a[l] + a[r]trong C++. Nếu mỗi phần tử có thể lên tới10^9, tổnga[l] + a[r]có thể đạt2 × 10^9hoặc lớn hơn. Hãy ép kiểu(long long)a[l] + a[r]trước khi so sánh. - Quên xử lý phần còn dư khi gộp hai mảng. Khi vòng lặp
while (i < n && j < m)kết thúc, chắc chắn một trong hai mảng vẫn còn phần tử chưa lấy hết. Luôn nhớ hai vòngwhilevét nốt phần đuôi của mảngavà mảngb.
Luyện ngay trên Big-O Coding
Áp dụng ngay kỹ thuật hai con trỏ vào các bài tập dưới đây trên hệ thống Big-O Coding:
Câu hỏi thường gặp
Muốn tìm bộ ba số có tổng bằng X (bài toán 3Sum) thì dùng hai con trỏ thế nào?
Bạn sắp xếp mảng tăng dần, sau đó dùng một vòng lặp for cố định phần tử thứ nhất a[i], rồi dùng kỹ thuật hai con trỏ l = i + 1 và r = n - 1 trên đoạn phía sau để tìm cặp có tổng bằng X - a[i]. Độ phức tạp giảm từ O(n³) xuống O(n²).
Tìm cặp có tổng bằng X bằng hai con trỏ khác gì với dùng Tìm kiếm nhị phân?
Với mỗi a[i], bạn có thể dùng tìm kiếm nhị phân để tìm giá trị X - a[i] trong đoạn còn lại mất O(log n), tổng cộng n phần tử mất O(n log n). Còn hai con trỏ tận dụng kết quả của bước trước để không phải tìm lại từ đầu, nên chỉ mất O(n) trên mảng đã sắp xếp.
Khi nào bài toán tìm đoạn con có tổng bằng X KHÔNG dùng được hai con trỏ?
Khi mảng có cả số dương lẫn số âm. Lúc đó việc mở rộng đầu phải hay thu hẹp đầu trái không còn làm tổng đoạn tăng/giảm đơn điệu nữa. Với mảng có số âm, bạn cần kết hợp mảng cộng dồn với bảng băm (Hash Map).
Luyện phỏng vấn Google, Meta, Amazon theo Interview Patterns: 16 buổi online live, lớp 7–15 người, C++/Python/Java. Học thử buổi 1 miễn phí.


