Nhập môn

Kỹ thuật hai con trỏ (Two Pointers): giải thích từng bước, code C++ và Python

Kỹ thuật hai con trỏ (two pointers) là phương pháp dùng hai biến chỉ số cùng duyệt trên mảng theo quy tắc đơn điệu (đi ngược chiều nhau hoặc cùng tiến về một phía) mà không bao giờ quay lùi. Nhờ mỗi con trỏ chỉ dịch chuyển tối đa n bước, các bài toán tìm cặp phần tử vốn mất O(n²) bằng hai vòng lặp lồng nhau được rút gọn xuống chỉ còn O(n).

Ý chính
  • "Con trỏ" trong kỹ thuật này thực chất là hai biến chỉ số nguyên (l và r, hoặc i và j), không phải kiểu con trỏ địa chỉ bộ nhớ *p củ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 n lầ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ới a[r] vẫn lớn hơn X, nên a[r] cộng với bất kỳ số nào khác trong đoạn cũng đều lớn hơn X. Ta loại ngay a[r] bằng cách giảm r đ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ới a[l] vẫn chưa đủ X, nên a[l] không thể ghép với số nào để đạt X. Ta loại ngay a[l] bằng cách tăng l lê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:

0 1 2 3 4 5 6 Bước 1 1 (L) 2 4 6 8 10 15 (R) 1 + 15 = 16 > 14 (tổng quá lớn) → dịch R sang trái: R = 5 Bước 2 1 (L) 2 4 6 8 10 (R) 15 1 + 10 = 11 < 14 (tổng quá nhỏ) → dịch L sang phải: L = 1 Bước 3 1 2 (L) 4 6 8 10 (R) 15 2 + 10 = 12 < 14 (vẫn nhỏ hơn 14) → dịch L sang phải: L = 2 Bước 4 1 2 4 (L) 6 8 10 (R) 15 4 + 10 = 14: Tìm thấy cặp ở vị trí L = 2 và R = 5!
Bước 1: L = 0 (1), R = 6 (15) 1 + 15 = 16 > 14 → giảm R xuống 5 Bước 2: L = 0 (1), R = 5 (10) 1 + 10 = 11 < 14 → tăng L lên 1 Bước 3: L = 1 (2), R = 5 (10) 2 + 10 = 12 < 14 → tăng L lên 2 Bước 4: L = 2 (4), R = 5 (10) 4 + 10 = 14 → Tìm thấy tại (2, 5)!
Hình 1. Bốn bước tìm cặp có tổng bằng 14 trên mảng tăng dần. Mỗi bước loại bỏ hẳn một phần tử ở đầu hoặc cuối đoạn.
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;
}

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 l tăng thêm 1, hoặc r giảm đi 1. Không có lệnh nào làm l lùi lại hay làm r tăng lên.
  • Do đó, khoảng cách r - l giảm đi đúng 1 sau mỗi bước, nên vòng lặp chạy tối đa n - 1 lầ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 l và r).
Học cùng giảng viên

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ần X nhấ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 khi l >= r.
  • Mẹo đếm số cặp có tổng ≤ X trê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ên a[l] ghép với bất kỳ phần tử nào từ vị trí l + 1 đến r cũng đều có tổng ≤ X. Có đúng r - l phần tử như vậy! Ta cộng ngay ans += (r - l) rồi tăng l++ mà không cần duyệt từng phần tử ở giữa. Ngược lại nếu a[l] + a[r] > X, ta chỉ cần giảm r--.

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 A và một phần tử thuộc B có 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;
}

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ố 0 về cuối mảng mà vẫn giữ nguyên thứ tự các số khác 0, 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 đến n - 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ăng write++ và chép a[write] = a[read]. Kết thúc vòng lặp, đoạn 0 .. write chứ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

Cẩn thận
  • Á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 l không đảm bảo tổng sẽ tăng lên, và việc giảm r cũ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ết l <= r, khi l == r chươ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ùng while (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ới 10^9, tổng a[l] + a[r] có thể đạt 2 × 10^9 hoặ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òng while vét nốt phần đuôi của mảng a và mảng b.

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).

Muốn học bài bản cùng giảng viên?

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í.

Xem Khóa Blue 2.0

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483