New: 6 free SQL practice datasets with 300+ questions — try the SQL Compiler →
DSA

Two Pointers Technique in Python

Two pointers technique in Python with real code: two-sum on a sorted list, palindromes, removing duplicates and merging, with step counts and output.

Upskly AI Team September 26, 2026 11 min read
Two Pointers Technique in Python

The two pointers technique solves array and string problems by keeping two positions (indexes) in the data and moving them according to simple rules, instead of checking every pair. It often turns an O(n²) solution into O(n) and needs almost no extra memory. There are three common shapes: pointers that start at opposite ends and move toward each other, pointers that move in the same direction at different speeds or roles, and one pointer per sequence when you walk two lists together.

Below is each shape with working Python code, real output and the reasoning that makes it correct.

In this guide

The short version

  • Use two indexes (lo and hi, or read and write) instead of a nested loop.
  • Opposite ends: works when the data is sorted (or symmetric), so each comparison lets you discard one side.
  • Same direction: read scans, write builds the answer in place.
  • Typical cost: O(n) time, O(1) extra space.

The idea

Take a classic: in a sorted list, find two numbers that add up to a target. The brute-force way tries every pair with two nested loops. With two pointers, you start at the two ends. If the sum is too small, the only way to increase it is to move the left pointer to a bigger number. If it is too big, move the right pointer to a smaller one. Every step rules out a whole group of pairs, so you never need to look at them. Here are both, counting steps for a list of 1,000 sorted numbers where the answer is the very last pair:

def two_sum_brute(nums, target):
    steps = 0
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            steps += 1
            if nums[i] + nums[j] == target:
                return (i, j), steps
    return None, steps

def two_sum_pointers(nums, target):
    lo, hi, steps = 0, len(nums) - 1, 0
    while lo < hi:
        steps += 1
        s = nums[lo] + nums[hi]
        if s == target:
            return (lo, hi), steps
        if s < target:
            lo += 1
        else:
            hi -= 1
    return None, steps

Output

((998, 999), 499500)
((998, 999), 999)

The pair was found after 499,500 steps by brute force and 999 with two pointers. On a million items the difference would be about 500 billion against a million. (If steps and Big O are new, read Big O Notation Explained With Python Examples.)

Pattern 1: pointers from both ends

Two sum on a sorted list

Watch the pointers move on a small example. We want two numbers that add to 10:

nums = [1, 3, 4, 6, 8, 11]
target = 10
lo, hi = 0, len(nums) - 1
while lo < hi:
    s = nums[lo] + nums[hi]
    print(f"lo={lo} hi={hi} sum={s}")
    if s == target:
        print("found", nums[lo], nums[hi])
        break
    elif s < target:
        lo += 1
    else:
        hi -= 1

Output

lo=0 hi=5 sum=12
lo=0 hi=4 sum=9
lo=1 hi=4 sum=11
lo=1 hi=3 sum=9
lo=2 hi=3 sum=10
found 4 6

At each step the sum was either too large (move hi left) or too small (move lo right), until 4 + 6 hit the target.

Why it is correct: when the sum is too big, the number at hi cannot be part of any valid pair with any number at or after lo, because those are all at least as large. So hi can safely move. The same argument works for lo. This only works because the list is sorted.

Is it a palindrome?

A palindrome reads the same backwards. Compare the ends, then move inwards. Skip anything that is not a letter or digit, and ignore case:

def is_palindrome(s):
    cleaned = [c.lower() for c in s if c.isalnum()]
    lo, hi = 0, len(cleaned) - 1
    while lo < hi:
        if cleaned[lo] != cleaned[hi]:
            return False
        lo += 1
        hi -= 1
    return True

print(is_palindrome("A man, a plan, a canal: Panama"))
print(is_palindrome("hello"))
print(is_palindrome(""))

Output

True
False
True

Reverse in place

Swap the two ends and move inward. It uses no second list:

def reverse_in_place(items):
    lo, hi = 0, len(items) - 1
    while lo < hi:
        items[lo], items[hi] = items[hi], items[lo]
        lo += 1
        hi -= 1

data = [1, 2, 3, 4, 5]
reverse_in_place(data)
print(data)

Output

[5, 4, 3, 2, 1]

Container with the most water

Given the heights of vertical lines, find the two that, with the x-axis, hold the most water. The area is width × the shorter height. Start with the widest container, then move the shorter side inward, because moving the taller one can never help (the width shrinks and the height is still capped by the shorter line):

def max_area(heights):
    lo, hi, best = 0, len(heights) - 1, 0
    while lo < hi:
        width = hi - lo
        best = max(best, width * min(heights[lo], heights[hi]))
        if heights[lo] < heights[hi]:
            lo += 1
        else:
            hi -= 1
    return best

print(max_area([1, 8, 6, 2, 5, 4, 8, 3, 7]))

Output

49

Pattern 2: pointers moving the same way

Here a read pointer scans every item, while a write pointer marks where the next kept item should go. You modify the list in place without extra memory.

Remove duplicates from a sorted list

def remove_duplicates(nums):
    if not nums:
        return 0
    write = 1
    for read in range(1, len(nums)):
        if nums[read] != nums[write - 1]:
            nums[write] = nums[read]
            write += 1
    return write

data = [1, 1, 2, 2, 2, 3, 4, 4]
k = remove_duplicates(data)
print(k, data[:k])

Output

4 [1, 2, 3, 4]

write always points at the slot after the last unique item. When read finds a new value, it is copied to write. Only the first k items matter afterwards, which is why we print data[:k].

Move all zeros to the end

def move_zeros(nums):
    write = 0
    for read in range(len(nums)):
        if nums[read] != 0:
            nums[write], nums[read] = nums[read], nums[write]
            write += 1

data = [0, 1, 0, 3, 12]
move_zeros(data)
print(data)

Output

[1, 3, 12, 0, 0]

Every non-zero item is swapped into the next write slot, so the order of the non-zero items is kept and the zeros drift to the end.

Fast and slow pointers

A variation: one pointer moves one step at a time and the other two. When the fast one reaches the end, the slow one is at the middle. On a plain list you would just use len(items) // 2, but the same idea is essential for linked lists, where you cannot jump to a position, and for detecting cycles (see Linked List in Python):

def middle(items):
    slow = fast = 0
    while fast + 1 < len(items):
        slow += 1
        fast += 2
    return items[slow]

print(middle([1, 2, 3, 4, 5]))
print(middle([1, 2, 3, 4, 5, 6]))

Output

3
4

Pattern 3: one pointer per sequence

When you have two sorted lists, keep a pointer into each and always advance the one with the smaller item. This is the merge step of merge sort:

def merge(a, b):
    i = j = 0
    out = []
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            out.append(a[i])
            i += 1
        else:
            out.append(b[j])
            j += 1
    out.extend(a[i:])
    out.extend(b[j:])
    return out

print(merge([1, 4, 7], [2, 3, 9, 10]))

Output

[1, 2, 3, 4, 7, 9, 10]

The same walk finds the items two sorted lists have in common. When the items are equal, take it and advance both. Otherwise advance the smaller side:

def intersect(a, b):
    i = j = 0
    out = []
    while i < len(a) and j < len(b):
        if a[i] == b[j]:
            out.append(a[i])
            i += 1
            j += 1
        elif a[i] < b[j]:
            i += 1
        else:
            j += 1
    return out

print(intersect([1, 2, 2, 3, 5], [2, 2, 3, 4]))

Output

[2, 2, 3]

How to recognise a two-pointer problem

Matching problems to patterns
Clue in the problemLikely pattern
Sorted array, find a pair or triple with a sumOpposite ends
Palindromes, reversing, symmetric checksOpposite ends
Maximise something using two boundariesOpposite ends, move the weaker side
Remove or partition items in placeRead and write pointers
Middle of a linked list, cycle detectionFast and slow
Merge, compare or intersect two sorted listsOne pointer per sequence
A contiguous range with a property (sum, distinct items)Usually a sliding window, see the next guide

The last row is a close relative: Sliding Window Technique in Python.

Common mistakes

Mistake 1: using opposite-ends pointers on unsorted data

The reasoning behind moving a pointer depends on the order. On unsorted data the same code silently gives the wrong answer. Here there is a pair (3 and 4), but the pointers never find it. After sorting, it works:

def two_sum_brute(nums, target):
    steps = 0
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            steps += 1
            if nums[i] + nums[j] == target:
                return (i, j), steps
    return None, steps

def two_sum_pointers(nums, target):
    lo, hi, steps = 0, len(nums) - 1, 0
    while lo < hi:
        steps += 1
        s = nums[lo] + nums[hi]
        if s == target:
            return (lo, hi), steps
        if s < target:
            lo += 1
        else:
            hi -= 1
    return None, steps

nums = [8, 1, 6, 3, 4]
print(two_sum_pointers(nums, 7))
print(two_sum_pointers(sorted(nums), 7))

Output

(None, 4)
((0, 3), 2)

Note that the indexes in the second answer refer to the sorted list. If you need original positions, sort (value, index) pairs, or use a dictionary approach (see Hash Tables in Python).

Mistake 2: off-by-one at the boundaries

Decide whether the loop condition is lo < hi or lo <= hi. For pair problems, the two pointers must not be the same item, so use <.

Mistake 3: forgetting to move a pointer

If some branch of the loop leaves both pointers unchanged, the loop never ends. Check that every path moves at least one pointer.

Mistake 4: mixing up in-place changes and returned lists

Functions like remove_duplicates modify the list and return a length. Read the original problem statement carefully to see what it expects you to return.

Try it yourself

Work out each answer first, then open the solution.

1. Use the two-pointer method on [2, 7, 11, 15] with target 9. What does it return, and how many steps does it take?

Show answer

Output

((0, 1), 3)

The pair at positions 0 and 1 (2 and 7), after 3 steps: it moved hi left twice, then found the sum.

2. Given a sorted list that may contain negatives, return the squares in sorted order in O(n).

Show solution
def sorted_squares(nums):
    lo, hi = 0, len(nums) - 1
    out = [0] * len(nums)
    for i in range(len(nums) - 1, -1, -1):
        if abs(nums[lo]) > abs(nums[hi]):
            out[i] = nums[lo] ** 2
            lo += 1
        else:
            out[i] = nums[hi] ** 2
            hi -= 1
    return out

print(sorted_squares([-4, -1, 0, 3, 10]))

Output

[0, 1, 9, 16, 100]

The biggest square is always at one of the two ends (a large negative or a large positive). Fill the answer from the back, taking whichever end is larger in absolute value.

3. Remove every occurrence of a value from a list in place, and return the new length.

Show solution
def remove_value(nums, val):
    write = 0
    for x in nums:
        if x != val:
            nums[write] = x
            write += 1
    return write

data = [3, 2, 2, 3, 4]
k = remove_value(data, 3)
print(k, data[:k])

Output

3 [2, 2, 4]

The same read and write pattern as removing duplicates, with a different test.

4. What are the time and space complexity of the palindrome check above, ignoring the cleaning step?

Show answer

Time O(n), because each pointer moves at most across half the string. Space O(1), because only two indexes are stored.

Run these in our free Python compiler.

Frequently asked questions

What is the two pointers technique?

It is a way of solving problems on arrays and strings using two indexes that move through the data by simple rules, so you avoid checking every pair and usually get O(n) time instead of O(n²).

Does the array have to be sorted for two pointers?

For the opposite-ends pattern on problems like two sum, yes: sorting is what makes it safe to discard one side. Other patterns, such as read and write pointers or palindrome checks, do not need sorting.

What is the difference between two pointers and a sliding window?

A sliding window is a special case of two pointers, where both pointers move forward together and the range between them is a contiguous chunk you are tracking, such as a running sum or a set of distinct items.

What is the time complexity of two pointers?

Usually O(n), because each pointer moves in one direction and never revisits an item. Sorting first, if needed, adds O(n log n).

What are fast and slow pointers used for?

Finding the middle of a linked list, detecting cycles, and finding the start of a cycle. The fast pointer moves two steps for each step of the slow one.

Run this code in your browser

The free Upskly compiler runs Python with nothing to install. Paste the example, change it and see what happens.

Open Python Compiler

Stuck on a traceback?

AI Assist works inside the Python notebook, so you can ask about an error or a concept without leaving the cell.

Try AI Assist

Test yourself

Timed questions on Arrays, Strings and Two Pointers, with an explanation for every answer.

Take the Quiz
Upskly AI Team
Learning made simple
Scroll to Top