Arrays & Strings · intermediate · ~15 min
- Recognise the three two-pointer patterns — **converging**, **slow/fast**, and **sliding window** — and pick the right one for a problem. - Rewrite an O(n^2) nested-loop scan as a single O(n) pass using two coordinated indices. - Check whether a string is a palindrome in place, with no extra buffer. - Detect a cycle in a singly-linked list with Floyd's tortoise-and-hare, guarding every dereference. - Grow and shrink a sliding window while maintaining an invariant (e.g. "no repeated character"). - Reason precisely about the *meet point* where the two pointers touch, where off-by-one bugs live.
Imagine you have a row of boxes and you want to answer a question about them — is this word a palindrome? which pair adds up to a target? how long is the longest stretch with no repeats? The slow, obvious way is to compare every box with every other box: two nested loops, O(n^2) work. The two-pointer technique replaces that with two markers that walk the same row in a coordinated way, so the whole answer falls out in a single left-to-right (or inward) sweep — O(n).
The "pointers" can be literal C pointers (char *p) or plain array indices (size_t i). Both count as two-pointer thinking; what matters is that two positions move together under a rule you control.
This lesson builds directly on two things you already know. From Arrays, you know that array elements sit next to each other in memory and that a[i] is really *(a + i). From Pointer basics, you know how to hold an address, dereference it, and step it forward. Two-pointer is what you get when you keep two of those positions at once and give each one a job.
There are three common flavours, and almost every two-pointer problem is one of them:
A huge number of "scan a sequence" problems have an obvious slow solution and a two-pointer fast solution, and the gap between them is the difference between code that finishes instantly and code that stalls on real input.
| Problem | Naive approach | Two-pointer approach |
|---|---|---|
| Is this string a palindrome? | Build a reversed copy, compare — O(n) time and O(n) extra memory | Converge from both ends — O(n) time, O(1) memory |
| Find a pair summing to a target (sorted array) | Check every pair — O(n^2) | Converge and move the pointer that fixes the sum — O(n) |
| Longest substring with no repeats | Re-check every start/end pair — O(n^2) or worse | Sliding window — O(n) |
| Does a linked list loop forever? | Store every visited node in a set — O(n) memory | Floyd's slow/fast — O(1) memory |
Beyond speed, two-pointer solutions are often the ones that use constant extra memory. On embedded devices, in kernels, and in tight inner loops, "O(1) extra space" is not a nicety — it is the whole reason the code is acceptable. That is why in-place reversal, in-place compaction, and cycle detection are all classic two-pointer wins.
There is no new syntax here — the skill is in the coordination rule each pattern uses. Study each pattern as its own tool.
Definition. Place one index i at the start and one index j at the end. At each step you compare or combine the two ends, then move i right, j left, or both. Stop when they meet or cross.
Plain language. You are pinching the sequence from both sides at once. Every step throws away one element from each end, so you finish in about n/2 steps.
How it works internally. For a palindrome, s[i] must equal s[j] for every mirrored pair. If any mirrored pair differs, it is not a palindrome and you can stop early. The loop condition i < j is what guarantees you never compare an element with itself or run past the middle.
String: "radar" indices 0..4
r a d a r
^ ^
i=0 j=4 s[0]=='r' == s[4]=='r' -> advance
r a d a r
^ ^
i=1 j=3 s[1]=='a' == s[3]=='a' -> advance
r a d a r
^ i=2, j=2 -> i < j is false, STOP: palindrome
When to use it. Sorted arrays (2-sum, closest pair), symmetry checks (palindromes), and "squeeze from both ends" problems like container-with-most-water. When NOT to: unsorted data where the order carries no meaning for your comparison, or where you must look at all pairs regardless.
Common pitfall. Using i <= j instead of i < j. With <=, when i == j you compare the middle element with itself (harmless for palindromes but wasteful) — and worse, on some formulations you step past the boundary and read out of bounds.
Knowledge check. For the string
"abba"(length 4), list the exact(i, j)pairs that get compared before the loop stops. How many comparisons is that?
Definition. Both pointers start at the same place and move in the same direction, but the fast one advances two steps for every one step of the slow one.
Plain language. Think of two runners on a track. If the track is a straight line, the fast runner reaches the end and it's over. If the track secretly loops, the fast runner eventually laps the slow one and they collide — that collision is your signal that a loop exists. This is Floyd's tortoise and hare.
How it works internally. Each iteration, slow moves +1 and fast moves +2, so the gap between them changes by exactly 1 per step. If there is a cycle, once both pointers are inside the loop the gap shrinks by 1 each step until it hits 0 — they must meet. A second use is finding the middle: when fast reaches the end, slow is at the halfway point.
List with a cycle (node 4 points back to node 1):
0 -> 1 -> 2 -> 3 -> 4
^______________|
slow moves 1, fast moves 2 each step:
step 1: slow@1 fast@2
step 2: slow@2 fast@4
step 3: slow@3 fast@2 (fast wrapped through the loop)
step 4: slow@4 fast@4 <- MEET: cycle detected
When to use it. Linked-list cycle detection, finding the middle node in one pass, finding the start of a loop. When NOT to: random-access arrays where indices give you O(1) jumps anyway — slow/fast shines when you can only move forward one link at a time.
Common pitfall. Dereferencing without guarding. Before fast = fast->next->next, you must know both fast and fast->next are non-NULL, or you crash on the last node.
Knowledge check (find the bug). A learner writes
while (fast->next)as the loop condition. On a list with an odd number of nodes and no cycle, why can this dereference NULL?
Definition. Two indices left and right both move forward. The window is the range [left, right]. You extend right to include new elements; when a rule (the invariant) breaks, you advance left to shrink the window until the rule holds again.
Plain language. Picture a stretchy frame sliding along the sequence. You grow it greedily; the moment it becomes "illegal," you pull the trailing edge forward until it's legal again. Because each pointer only ever moves forward, the total work is O(n) even though the window itself moves back and forth in size.
How it works internally. For "longest substring without repeating characters," the invariant is "every character in the window is distinct." You typically keep a small lookup (e.g. a 256-entry seen[] table for ASCII) recording where each character last appeared, so you can jump left past the previous duplicate in O(1).
s = "abcabcbb"
[a]bcabcbb window "a" len 1
[ab]cabcbb window "ab" len 2
[abc]abcbb window "abc" len 3 <- best so far
a[bca]bcbb 'a' repeats -> shrink left past old 'a'
ab[cab]cbb 'b' repeats -> shrink
...
best length = 3
When to use it. Longest/shortest contiguous run satisfying a property, running sums, throttling and rate windows in logs. When NOT to: problems where the answer is not a contiguous range, or where shrinking from the left can never restore the invariant.
Common pitfall. Moving left by only 1 when a duplicate is found deep in the window, instead of jumping it past the duplicate's last position — that can silently keep an illegal character inside the window.
Knowledge check (explain in your own words). Why is a sliding window O(n) even though
leftandrighttogether seem to "revisit" elements? (Hint: count how many times each pointer can move.)
| If the problem is about... | Reach for |
|---|---|
| Symmetry, or a sorted array + target sum | Converging |
| A linked list you can only walk forward | Slow/fast |
| The longest/shortest contiguous run with a property | Sliding window |
There is no special language feature — you declare two positions and a loop. What varies is the movement rule.
/* Converging: start at both ends, walk inward */
size_t i = 0, j = len; /* j is one PAST the last valid index */
while (i < j) { /* strict < : stop when they meet */
/* compare s[i] with s[j-1], then: */
i++;
j--;
}
/* Slow/fast on a linked list */
node_t *slow = head, *fast = head;
while (fast != NULL && fast->next != NULL) { /* guard BOTH before ->next->next */
slow = slow->next; /* +1 */
fast = fast->next->next; /* +2 */
}
/* Sliding window over a string */
size_t left = 0;
for (size_t right = 0; s[right] != '\0'; right++) {
/* extend window to include s[right]; if invariant broke, advance left */
}
Use size_t for indices into arrays and strings — it is unsigned and matches what strlen returns, avoiding signed/unsigned comparison warnings.
Two-pointer is a family of algorithms. In all of them, two indices walk a structure at coordinated speeds.
Examples:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Converging two-pointer: is the whole string a palindrome? O(n), O(1) space. */
int is_palindrome(const char *s) {
size_t i = 0;
size_t j = strlen(s); /* j = length; treat it as one-past-the-end */
while (i < j) {
j--; /* step j to a valid index before reading */
if (s[i] != s[j]) return 0; /* mismatched mirror pair -> not a palindrome */
i++;
}
return 1; /* all mirror pairs matched (empty string counts) */
}
/* Sliding-window two-pointer: length of the longest substring with no repeats.
Assumes ASCII input. O(n) time, O(1) extra space (fixed 256-entry table). */
int longest_unique(const char *s) {
int last_seen[256]; /* last_seen[c] = index+1 of last occurrence, 0 = never */
for (int c = 0; c < 256; c++) last_seen[c] = 0;
int best = 0;
size_t left = 0; /* window start */
for (size_t right = 0; s[right] != '\0'; right++) {
unsigned char c = (unsigned char)s[right];
if (last_seen[c] > (int)left) /* c already inside the current window? */
left = (size_t)last_seen[c]; /* jump left past the duplicate */
last_seen[c] = (int)right + 1; /* record 1-based position of c */
int window_len = (int)(right - left + 1);
if (window_len > best) best = window_len;
}
return best;
}
int main(void) {
const char *tests[] = { "radar", "hello", "abba", "", "x" };
size_t n = sizeof(tests) / sizeof(tests[0]);
for (size_t k = 0; k < n; k++)
printf("is_palindrome(\"%s\") = %d\n", tests[k], is_palindrome(tests[k]));
const char *words[] = { "abcabcbb", "bbbbb", "pwwkew", "abcdef" };
size_t m = sizeof(words) / sizeof(words[0]);
for (size_t k = 0; k < m; k++)
printf("longest_unique(\"%s\") = %d\n", words[k], longest_unique(words[k]));
return 0;
}
What it does. The program exercises two of the three patterns. is_palindrome converges from both ends; longest_unique slides a window and uses a 256-entry table to jump past duplicates.
Expected output:
is_palindrome("radar") = 1
is_palindrome("hello") = 0
is_palindrome("abba") = 1
is_palindrome("") = 1
is_palindrome("x") = 1
longest_unique("abcabcbb") = 3
longest_unique("bbbbb") = 1
longest_unique("pwwkew") = 3
longest_unique("abcdef") = 6
Edge cases. The empty string is a palindrome (the loop body never runs). A single character is trivially a palindrome and has longest-unique length 1. Casting to unsigned char before indexing last_seen matters: on platforms where char is signed, a byte like 0xC3 would otherwise become a negative index and read out of bounds.
Tracing is_palindrome("abba") (length 4):
| Step | i | j (before j--) | j (after j--) | s[i] | s[j] | Action |
|---|---|---|---|---|---|---|
| enter | 0 | 4 | 3 | 'a' | 'a' | equal, i++ |
| loop | 1 | 3 | 2 | 'b' | 'b' | equal, i++ |
| check | 2 | — | — | — | — | i < j is 2 < 2 -> false, exit |
Return 1. Notice j starts as the length (4, one past the last index) and is decremented to a valid index inside the loop before any read — that is the guard against reading s[4].
Tracing longest_unique("pwwkew"):
| right | c | last_seen[c] before | inside window? | left after | last_seen[c] set to | window_len | best |
|---|---|---|---|---|---|---|---|
| 0 | p | 0 | no | 0 | 1 | 1 | 1 |
| 1 | w | 0 | no | 0 | 2 | 2 | 2 |
| 2 | w | 2 | yes (2 > 0) | 2 | 3 | 1 | 2 |
| 3 | k | 0 | no | 2 | 4 | 2 | 2 |
| 4 | e | 0 | no | 2 | 5 | 3 | 3 |
| 5 | w | 3 | no (3 > 2? yes) | 3 | 6 | 3 | 3 |
At right=2 the second w is inside the window, so left jumps to 2 (past the first w). The window "wke" at right=4 gives length 3, the answer. The > left test (not >= 0) is what stops stale positions outside the window from wrongly shrinking it.
The slow/fast walkthrough from the original lesson, guarded correctly:
node_t *slow = head, *fast = head;
while (fast && fast->next) { /* both non-NULL, so ->next->next is safe */
slow = slow->next; /* tortoise: +1 */
fast = fast->next->next; /* hare: +2 */
if (slow == fast) return 1;/* they collided -> a cycle exists */
}
return 0; /* fast ran off the end -> no cycle */
Each iteration the tortoise gains one link and the hare gains two, so inside a loop the hare closes the gap by exactly one per step and a meeting is guaranteed. If the list is straight, fast or fast->next becomes NULL and the loop exits cleanly.
Mistake 1 — Reading before you validate the index.
/* WRONG: reads s[strlen(s)], one past the terminator */
size_t i = 0, j = strlen(s);
while (i < j) {
if (s[i] != s[j]) return 0; /* first iteration reads s[len] */
i++; j--;
}
On the first pass j equals the length, so s[j] reads the byte after the string. Fix: decrement j before the read (j--; if (s[i] != s[j]) ...) or initialise j = strlen(s) - 1 and use while (i < j) — but beware strlen of "" is 0, so 0 - 1 underflows size_t to a huge number. The j = len then j-- inside the loop form avoids that trap.
Mistake 2 — Unguarded fast pointer.
/* WRONG: crashes at the end of an odd-length list */
while (fast->next) {
fast = fast->next->next; /* if fast->next is the last node, ->next is NULL, ->next->next crashes */
slow = slow->next;
}
Fix: check fast && fast->next before the double step. Recognise it: a segfault that only happens on some list lengths (odd vs even) is the fingerprint of a missing NULL guard.
Mistake 3 — Shrinking the window by one instead of jumping.
/* WRONG: only moves left by 1, may leave the duplicate inside */
if (seen_in_window(c)) left++;
If the duplicate is several positions inside the window, a single left++ still leaves it there and the invariant stays broken. Fix: jump left to just past the duplicate's last index. Prevent it: always ask "after this move, is the invariant guaranteed restored, not just nudged toward restoration?"
Mistake 4 — i <= j on a converging loop. With <=, at i == j you compare an element with itself and, in strlen-1 formulations, can step j below 0. Use strict <.
Compiler warnings to fix first. Turn on -Wall -Wextra. The classic here is "comparison of integer expressions of different signedness" — it means you mixed a signed int index with the unsigned size_t from strlen. Make both size_t, or cast deliberately.
Runtime crashes. A segfault in linked-list code almost always means an unguarded ->next. Run under a sanitizer:
cc -fsanitize=address,undefined -g two_pointer.c && ./a.out
AddressSanitizer pinpoints the exact out-of-bounds read (e.g. s[len]) and UBSan flags the signed/unsigned underflow.
Logic errors. When the answer is wrong but nothing crashes, print both pointers every iteration:
printf("i=%zu j=%zu s[i]=%c s[j]=%c\n", i, j, s[i], s[j]);
Questions to ask when it doesn't work:
fast && fast->next before both ->next steps?left, is the invariant truly restored, or only partly?Two-pointer code lives or dies on staying in bounds and guarding lifetimes, because pointer arithmetic here is deliberately walking to the edges of a structure.
0 .. n-1. A converging loop must never read a[n]. The safe idiom is j = len; then j-- before each read, so j is always a legal index at the moment of dereference.size_t underflow. size_t is unsigned, so 0 - 1 wraps to SIZE_MAX, not -1. Never write j = strlen(s) - 1 without first proving the string is non-empty. This is a real undefined-behaviour trap that turns into a wild out-of-bounds read.fast->next->next, both fast and fast->next must be non-NULL. The guard while (fast && fast->next) is not optional — it is the memory-safety contract of the pattern.char indexing. When a byte indexes a lookup table (last_seen[s[right]]), cast through unsigned char. A signed char holding 0x80..0xFF becomes negative and indexes before the array — a silent out-of-bounds read.last_seen[] entry is undefined behaviour and produces garbage window boundaries.const char * when you only read, so the compiler stops you from accidentally writing through it, and never free a buffer you did not allocate.Concrete uses. In-place memmove-style compaction in libraries; strrev/palindrome checks in text tools; two-pointer merge in merge-sort and in database join engines over sorted runs; Floyd's cycle detection in garbage collectors and in linked-structure validators; sliding windows in rate limiters, TCP flow-control windows, and log-analysis tools that ask "most events of one type within any 60-second span." Defensive scanners (log filters, simple pattern matchers) use the same coordinated-index skeleton.
Professional habits — beginner.
left/right, slow/fast, not p1/p2.< vs <=) deliberately and write a one-line comment saying why it stops there.const on read-only inputs; use size_t for indices.Professional habits — advanced.
1. (Beginner) Reverse an array in place. Implement void reverse_array(int *a, size_t n) that reverses the first n elements using converging indices — no second array. Requirements: swap a[i] and a[j], move inward, stop at the middle. Example: {1,2,3,4,5} becomes {5,4,3,2,1}. Constraint: O(1) extra space. Hint: loop while i < j. Concept: converging.
2. (Beginner) Palindrome check. Implement int is_palindrome(const char *s) returning 1/0 for the whole string. Requirements: handle "" (return 1) and single characters. Example: "level" -> 1, "open" -> 0. Hint: start j = strlen(s) and decrement before reading. Concept: converging.
3. (Intermediate) Find the middle node. Implement node_t *list_middle(node_t *head) returning the middle node in a single pass; for even length return the second of the two middle nodes. Requirements: one traversal, O(1) space, no length counting. Hint: when fast reaches the end, slow is at the middle. Concept: slow/fast.
4. (Intermediate) Longest substring without repeats. Implement int longest_unique(const char *s) for ASCII input. Example: "abcabcbb" -> 3, "bbbbb" -> 1. Requirements: single pass, O(1) extra space via a fixed table; cast bytes through unsigned char. Hint: jump left past a duplicate, do not step by 1. Concept: sliding window.
5. (Challenge) Detect a cycle and return its length. Implement int cycle_length(node_t *head) that returns 0 if the list has no cycle, otherwise the number of nodes in the cycle. Requirements: use Floyd's slow/fast to detect a meeting point, then keep one pointer fixed and advance the other around the loop to count nodes. Constraint: O(1) extra space, and never dereference NULL. Hint: after the pointers meet inside the loop, one more lap measures its length. Concepts: slow/fast, then a counting walk.
size_t) or two pointers and a loop; the movement rule lives in the loop body, and the stop condition (i < j, or fast && fast->next) is load-bearing.fast->next->next, size_t underflow from strlen("") - 1, signed-char table indexing, and nudging the window by 1 instead of jumping past a duplicate.-Wall -Wextra -fsanitize=address,undefined to catch bounds and signedness bugs early.