Trong lập trình nói chung và lập trình thi đấu nói riêng, kĩ thuật hai con trỏ (two pointers) là một kĩ thuật rất hiệu quả để giải quyết các bài toán trên mảng hoặc chuỗi. Kĩ thuật này giúp tiết kiệm không gian lưu trữ, giảm thời gian chạy của giải thuật rất hiệu quả.
Vậy kĩ thuật hai con trỏ là gì? Đúng như tên gọi của nó, kĩ thuật này sử dụng hai con trỏ đồng thời để kiểm soát hai vị trí trên mảng hoặc chuỗi. Tuy nhiên khác với khái niệm con trỏ mà chúng ta thường biết (là một biến trỏ vào địa chỉ trong bộ nhớ máy tính của một đối tượng), con trỏ trong kĩ thuật này chỉ đơn giản là những biến kiểm soát vị trí trên mảng hoặc chuỗi, trong đó có một biến luôn luôn không vượt quá biến còn lại. Hình vẽ dưới đây là một minh họa:
Trong rất nhiều bài toán trên mảng hoặc chuỗi, chúng ta sẽ phải quét qua các cặp giá trị, hoặc những đoạn liên tiếp trên dãy số. Nếu như sử dụng những vòng lặp lồng nhau, hoặc kết hợp các cấu trúc dữ liệu để kiểm soát các đoạn giá trị thì thời gian chạy của giải thuật sẽ không thể đảm bảo. Vậy giải pháp là gì? Chính là kĩ thuật two pointers - một giải pháp vừa tiết kiệm không gian lưu trữ, vừa giảm thời gian thực thi của giải thuật (thông thường xuống ). Khi sử dụng two-pointers, thay vì chỉ truy cập được một phần tử của dãy, ta sẽ truy cập được đồng thời hai phần tử của dãy ở mỗi lần lặp, qua đó giảm số thao tác cần thực hiện đi rất nhiều.
Kĩ thuật này có thể chia làm ba dạng thường gặp:
Sau đây chúng ta sẽ cùng phân tích ý tưởng của cả ba dạng toán thường gặp của kĩ thuật này thông qua những bài tập điển hình của chúng.
Trong dạng bài tập này, ta sẽ luôn luôn có một con trỏ đi phía trước với tốc độ chậm, và một con trỏ đi phía sau với tốc độ nhanh hơn. Thông thường, ở mỗi lần lặp, con trỏ phía sau sẽ luôn tăng lên, còn con trỏ phía trước thì chỉ tăng lên khi thỏa mãn một tập điều kiện nào đó.
Giải thuật tổng quát có thể viết mã giả như sau:
Trong mã giả trên, các yếu tố có thể thay đổi tùy theo từng người lập trình và từng bài toán, nhưng ý tưởng thì vẫn không thay đổi. Ví dụ, vòng lặp for
có thể thay bằng vòng lặp while
, hay việc xử lý logic có thể đưa lên trước hoặc sau khi dịch chuyển con trò,…Ngoài ra, trong những bài toán phức tạp, có thể con trỏ phía sau cũng sẽ chỉ tăng lên khi đáp ứng được một tập điều kiện nào đó, vì vậy điều quan trọng ở đây là các bạn hiểu được ý tưởng thuật toán!
Cho trước mảng số nguyên đã sắp xếp tăng dần.
Yêu cầu: Hãy loại bỏ các phần tử trùng lặp trong dãy và đưa ra độ dài của dãy mới?
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Bởi dãy số đã cho đã được sắp xếp tăng dần, nên các phần tử giống nhau chắc chắn sẽ đứng cạnh nhau.
Duy trì hai con trỏ và để kiểm soát các phần tử trên dãy. Con trỏ sẽ duyệt qua tất cả các phần tử trên dãy, và con trỏ sẽ chạy phía sau và kiểm soát luôn kích thước của đoạn phần tử bằng với . Mỗi lần chạy qua, ta sẽ gán luôn để coi như "đặt cờ".
Kết quả cuối cùng chính là .
Độ phức tạp: .
Cho dãy số gồm phần tử nguyên. Một đoạn con liên tục của dãy là một hoặc nhiều phần tử liên tục lấy từ dãy . Đoạn con liên tục được gọi là hoàn hảo nếu như nó chứa không quá số nguyên phân biệt.
Yêu cầu: Hãy tìm đoạn con liên tục hoàn hảo dài nhất của dãy
Input:
Ràng buộc:
Subtasks:
Output:
Sample Input 1:
Sample Output 1:
Duyệt qua tất cả các đoạn con và áp dụng kĩ thuật đếm phân phối để tìm ra đoạn con hoàn hảo dài nhất.
Độ phức tạp: .
Áp dụng kĩ thuật Hai con trỏ. Đặt là vị trí xuất hiện cuối cùng của giá trị khi trong đoạn con đang xét. Ta sử dụng hai con trỏ và để duyệt dãy con như sau:
Độ phức tạp: .
Trong dạng bài này, ta sẽ sử dụng hai con trỏ, một con trỏ đi từ đầu dãy tiến lên, và một con trỏ đi từ cuối dãy lùi xuống (tất nhiên chỉ dịch chuyển khi thỏa mãn điều kiện nào đó). Chúng sẽ liên tục di chuyển cho tới khi gặp nhau ở giữa dãy số.
Dạng bài này có thể được biến đổi nâng cao thêm như sau:
Lược đồ giải thuật có thể mô tả như sau:
Cho dãy số gồm phần tử đã được sắp xếp theo thứ tự tăng dần.
Yêu cầu: Đếm số cặp phần tử thỏa mãn với là số nguyên cho trước? Lưu ý, hai cặp số là hoán vị của nhau thì chỉ tính một lần.
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Phương án hai vòng lặp cho bài toán này quá đơn giản nên tôi sẽ không nhắc đến nữa.
Một phương án khác mà các bạn học sinh thường nghĩ đến cho bài toán này sẽ là duyệt mọi và tìm kiếm nhị phân số lượng giá trị trong dãy số. Tuy nhiên, với giới hạn thì giải thuật vẫn sẽ bị quá thời gian chạy 1s. Ta cần giải thuật tốt hơn, đó chính là two - pointers.
Sử dụng con trỏ đặt ở đầu dãy số, và con trỏ đặt ở cuối dãy số. Do dãy số đã sắp xếp tăng dần, nên ở mỗi bước lặp, ta xử lý logic như sau:
Quá trình lặp sẽ kết thúc khi và chạm vào nhau. Lúc này ta có được giải thuật với độ phức tạp chỉ là .
Một hàng rào gồm thanh rào có độ cao lần lượt là khoảng cách giữa các thanh rào với nhau bằng chính xác một đơn vị. Người ta muốn cải tạo hàng rào bằng cách chọn ra hai thanh rào bất kỳ, rồi căng một bức tranh lên đó. Bức tranh có diện tích càng lớn thì hàng rào sẽ càng trở nên đẹp mắt. Biết rằng, nếu căng một bức tranh lên hai thanh rào thì chiều cao và chiều dài của bức tranh đó sẽ lần lượt là: và .
Yêu cầu: Tìm ra diện tích lớn nhất có thể của bức tranh căng lên?
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Ta sử dụng hai con trỏ để kiểm soát hai thanh rào ở mỗi bước lặp. Ban đầu coi và diện tích lớn nhất mặc định sẽ là giữa hai thanh rào thứ và thứ .
Ở mỗi lần lặp, ta sẽ lựa chọn xem chiều cao của bức tranh tạo ra sẽ là hay (bên nào thấp hơn sẽ được chọn). Sau đó, dịch chuyển con trỏ ở bên tương ứng cho tới khi thu được một thanh rào có chiều cao lớn hơn chiều cao đã chọn, để loại bỏ trước những phương án không tốt hơn kết quả hiện tại.
Kết thúc mỗi bước lặp ta cập nhật diện tích bức tranh lớn nhất vào một biến .
Độ phức tạp: .
Kĩ thuật Sliding Window là một kĩ thuật khá thú vị, nó có thể được áp dụng vào những bài toán điển hình. Ý tưởng của kĩ thuật này khá giống với kĩ thuật slow & fast, khi chúng ta sử dụng hai con trỏ liên tục tịnh tiến về phía sau, tuy nhiên điểm đáng lưu ý là kết quả của bài toán sẽ liên tục được cập nhật dựa vào đoạn con được kiểm soát bởi hai con trỏ đó.
Hãy tưởng tượng rằng ta có một chiếc xe bus với độ dài cố định, và lần lượt là điểm đầu và điểm cuối của xe. "Chiếc xe" này sẽ liên tục di chuyển trên dãy số cho tới khi hết dãy. Trong quá trình "trượt" trên dãy số, ta sẽ xử lý logic và cập nhật kết quả cần thiết của bài toán.
Mô hình thuật toán có thể mô tả như sau:
Các bước cần làm trong kĩ thuật Sliding Window có thể tóm tắt như sau:
Kĩ thuật Sliding Window rất hữu ích trong các bài toán liên quan tới kết quả lớn nhất/nhỏ nhất trên các đoạn cố định của dãy số hoặc chuỗi kí tự. Sau đây chúng ta sẽ cùng xem xét một số ví dụ tiêu biểu của kĩ thuật này.
Cho trước dãy số gồm phần tử và một số nguyên dương .
Yêu cầu: Hãy xác định đoạn con có kích thước bằng có tổng lớn nhất?
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Dễ thấy, do các đoạn con ta cần xét có kích thước cố định bằng nên kích thước của window sẽ chính bằng .
Nếu như xét mọi đoạn con độ dài bằng hai vòng lặp thì chắc chắn sẽ bị quá thời gian, do .
Ta xét liên tục các đoạn con độ dài bằng cách sử dụng sliding window như sau: Gọi là tổng của đoạn con đang xét. Nhận thấy, mỗi khi tịnh tiến window ra phía sau một đơn vị, giả sử nó kết thúc tại vị trí thì tổng đoạn con sẽ thay đổi một lượng là:
Vì thế, ta chỉ cần sử dụng một vòng lặp để liên tục cập nhật tổng đoạn con lớn nhất.
Độ phức tạp: .
Cho trước một dãy số gồm số nguyên là các bit và cùng với một số nguyên dương .
Yêu cầu: Hãy tìm một tập gồm nhiều nhất các đoạn con của dãy sao cho kích thước của các đoạn con đó đều đôi một khác nhau, và tổng của các đoạn con đều bằng
Input:
Ràng buộc:
Output:
Sample Input:
Sample Output:
Giải thích:
Tập các đoạn con tìm được là: .
Nhận thấy, tổng của một đoạn con chính là số lượng số trong đoạn con đó. Ta sử dụng kĩ thuật sliding window như sau:
set
để lưu trữ các giá trị khác nhau.set
chính là kết quả bài toán.Độ phức tạp: .