The anchor-runner pointer strategy is a widely-used approach for solving problems that involve manipulating arrays in place. In this assignment, we will explore the anchor-runner pointer strategy, understand how it works, and apply it to solve an example problem.
To effectively use the anchor-runner pointer strategy, just like with the start-end pointer strategy, we need to answer several key questions:
anchor
pointer start?
runner
pointer start?
anchor
pointer?
runner
pointer?
anchor
pointer do something besides moving?
runner
pointer do something besides moving?
By answering these questions, we can establish a clear algorithmic approach that utilizes anchor-runner pointers. This strategy often allows us to mutate the array in place, removing the need for nested loops and improving the efficiency of our solution.
Let's consider a specific problem scenario to illustrate the anchor-runner pointer strategy.
// Given an array of positive integers, our task is
// to move all ones to the end of the array while preserving
// the relative order of non-one elements. The goal is to solve
// this problem in linear time complexity.
// If no ones are present in the array, no changes are needed.
// Example:
// Input: nums = [1, 2, 1, 4, 8]
// Output: [2, 4, 8, 1, 1]
Before applying the anchor-runner pointer strategy, let's consider a naive approach to solving the problem scenario:
splice
method.
push
method.
The time complexity of the naive solution is O(N^2)
since in each iteration through the array, we perform a deletion operation, which has a time complexity of O(N)
. This deletion operation is repeated for each element in the array, resulting in a quadratic overall time complexity.
Here is a coded solution for the naive approach. Note that we must use the counter
variable to avoid the problem of an infinite loop. After we move a one, we don't want to increment our index, because a new number will now be at the current index. This means that we must either move a number or move our index for each element in our array, but the index can't be relied upon to break out of our loop.
function moveOnes(nums) {
let idx = 0;
let counter = 0;
while (counter < nums.length) {
if (nums[idx] === 1) {
nums.splice(idx, 1);
nums.push(1);
} else {
idx++;
}
counter++;
}
return nums;
}
Now, let's discuss how we can use the anchor-runner (start-end pointer) strategy to solve this problem more efficiently. We can start by thinking high-level, and then we'll address the key questions mentioned earlier.
The idea of the solution we're going to work on is to maintain two pointers that move through the array, swapping elements with each other based on some rules. Our anchor
will be in charge of pointing to ones that we know need to move to the end. Our runner
will be in charge of moving ahead to find the non-ones that we can swap our ones with, moving in such a way that maintains the order of our non-one elements. This isn't very detailed, but it's a good mental model to keep in mind as we look at our more precise rules.
Where does my anchor
pointer start? - The anchor
pointer is initially set to the beginning of the array (index 0
).
Where does my runner
pointer start? - The runner
pointer is initially set to the beginning of the array (index 0
) as well.
Under which condition do I move the anchor
pointer? - We move the anchor
pointer after we make a swap, where anchor
is replaced with a non-one.
Under which condition do I move the runner
pointer? - We move the runner
pointer on each iteration, regardless of the element. When a swap is made, or not, runner
will be incremented.
Does the anchor
pointer do something besides moving? - It ensures that non-one elements are moved towards the beginning of the array by swapping the element at its current index with the element at the index pointed to by the runner
pointer.
Does the runner
pointer do something besides moving? - When the runner
pointer encounters a non-one element, it swaps the value at its current index with the value at the index pointed to by the anchor
pointer.
Under which condition do we stop the iteration? - When the runner
pointer makes it to the end of our array.
You might be wondering why we decided to start the runner pointer at index 0
in this problem and why it couldn't start at the end. Let's consider some examples to understand the reasons behind this choice.
First, let's take the array [2, 1, 1, 3]
. If we started the runner pointer at the end, it would be pointing at element 3
. Since we perform swaps when the runner encounters a non-one element, this would lead to swapping the values at indices 0
and 3
, resulting in the array [3, 1, 1, 2]
. As you can see, the relative order of the non-one elements is disturbed, and the solution becomes incorrect.
On the other hand, you might think that starting the runner at index 1 could be a valid strategy. For example, in the array [1, 2, 1, 3]
, if the runner starts at index 1
, we would obtain the correct solution. However, consider a scenario where the first element is a non-one element, such as [2, 3, 1, 4]
. In this case, starting the runner at index 1 would result in swapping the values at indices 0
and 1
, leading to the array [3, 2, 1, 4]
. Once again, the relative order of the non-one elements is disturbed, and the solution becomes incorrect.
To avoid these issues and ensure the preservation of the relative order of non-one elements, it is crucial to start the runner pointer at index 0
. This ensures that the swapping process occurs correctly, leading to the desired outcome. This can be a bit confusing to think about, so let's see our strategy in action.
Let's walk through the solution step-by-step using an example with the array [1, 2, 1, 4, 8]
:
Step 1: In the first step, we are initializing two pointers anchor
(a
) and runner
(r
) at index 0.
Step 2: We check the element at the runner
pointer. We make swaps when runner
is non-one. Since here it's one, we'll just increment the runner
by 1.
Step 3: The element at runner
is now 2
. It is a non-one element so we swap it with the element at anchor
pointer. Then, we move both pointers by 1.
Step 4: We check the element at the runner
pointer. It is one, so we increment the runner
by 1.
Step 5: The element at runner
is now 4. It's not one, so we swap it with the element at the anchor
pointer. We then move both pointers by 1.
Step 6: In the final step, the element at runner
is 8. It's not one, so we swap it with the element at the anchor
pointer. We then move both pointers by 1. The runner
pointer has reached the end of the array. The iteration is complete.
After the iteration, the array [1, 2, 1, 4, 8]
is modified to [2, 4, 8, 1, 1]
, with all ones moved to the end while preserving the relative order of non-one elements.
Here's a coded solution in JavaScript:
function moveOnes(arr) {
let anchor = 0;
let runner = 0;
while (runner < arr.length) {
if (arr[runner] !== 1) {
// Swap the elements at `anchor` and `runner`
[arr[anchor], arr[runner]] = [arr[runner], arr[anchor]];
// Increment `anchor` only when a swap is made
anchor++;
}
// Increment `runner` every iteration
runner++;
}
return arr;
}
const nums1 = [1, 2, 1, 4, 8];
const transformedNums1 = moveOnes(nums1);
console.log(nums1 === transformedNums1); // true
console.log(transformedNums1); // [2, 4, 8, 1, 1]
const nums2 = [3, 1, 5, 1, 1, 4, 8, 1];
const transformedNums2 = moveOnes(nums2);
console.log(nums2 === transformedNums2); // true
console.log(transformedNums2); // [3, 5, 4, 8, 1, 1, 1, 1]
This approach moves through the array in a very similar way to our anchor-runner pointer approach. The difference in this algorithm is that instead of swapping elements, we'll 'write' over ones with non-ones. As our reader
comes across non-ones, our writer
will overwrite its current element with this non-one given to us by reader
. Once reader
has read the whole array, we'll need to overwrite the end of the array with the number of ones that we overwrote. Let's look at our questions to get a better understanding.
Now let's explore the reader-writer variant on the same problem. We will address the key questions mentioned earlier. You might notice that the answers to the first 4 questions are the same, however, the final two questions have different answers.
Where does my anchor
pointer start? - The anchor
pointer (writer
) is initially set to the beginning of the array (index 0
).
Where does my runner
pointer start? - The runner
pointer (reader
) is initially set to the beginning of the array (index 0
) as well.
Under which condition do I move the anchor
pointer? - We move the anchor
pointer (writer
) after we make a modification to our array, where the value at writer
is overridden with a non-one.
Under which condition do I move the runner
pointer? - We move the runner
pointer (reader
) on each iteration, regardless of the element. When a modification is made, or not, reader
will be incremented.
Does the anchor
pointer do something besides moving? - The anchor pointer (writer
) indicates the position where the next non-one element should be written.
Does the runner
pointer do something besides moving? - The runner pointer (reader
) iterates through the array, reading each element, and its goal is to find non-one elements that will be written.
Under which condition do we stop the iteration? - After reader
has read our whole array, and we've filled in the rest of our array with the ones that we wrote over.
Here's the step-by-step explanation of the solution using the reader-writer variant:
Step 1: In the first step, we are initializing two pointers writer
(w
) and reader
(r
) at index 0
.
Step 2: We check the element at the reader
pointer. It's one, so we increment the reader
by 1.
Step 3: The element at the reader
pointer is now 2. Since it's not one we write it at the writer
pointer position. We then move both pointers by 1.
Step 4: We check the element at the reader
pointer. It is one, so we increment the reader
by 1.
Step 5: The element at the reader
pointer is now 4. It's not one so we write it at the writer
pointer position. We then move both pointers by 1.
Step 6: In the final step, the element at reader
is 8. It's not one, so we write it at the writer
pointer position. We then move both pointers by 1.
Step 7: The reader pointer has reached the end of the array. All non-one elements have been written, and all that is left to do is to fill the remaining positions from the writer position onwards with ones.
Step 7.1: We start by writing one at the writer
position, and then we move the writer pointer by 1.
Step 7.2: Once again, we write one at the writer
position, and then we move the writer pointer by 1. The writer has reached the end of the array after this iteration.
After the iteration, the array [1, 2, 1, 4, 8]
is modified to [2, 4, 8, 1, 1]
, with all ones moved to the end while preserving the relative order of non-one elements.
Now, let's write the coded solution for this reader-writer variant in JavaScript:
function moveOnes(nums) {
let writer = 0;
let reader = 0;
while (reader < nums.length) {
if (nums[reader] !== 1) {
// Overwrite the index at `writer` with the value at `reader`
nums[writer] = nums[reader];
// Increment `writer` when a modification is made
writer++;
}
// Increment `reader` every iteration
reader++;
}
while (writer < nums.length) {
nums[writer] = 1;
writer++;
}
return nums;
}
const nums1 = [1, 2, 1, 4, 8];
const transformedNums1 = moveOnes(nums1);
console.log(nums1 === transformedNums1); // true
console.log(transformedNums1); // [2, 4, 8, 1, 1]
const nums2 = [3, 1, 5, 1, 1, 4, 8, 1];
const transformedNums2 = moveOnes(nums2);
console.log(nums2 === transformedNums2); // true
console.log(transformedNums2); // [3, 5, 4, 8, 1, 1, 1, 1]
Both of these variants allow us to solve the problem in a linear O(N)
time complexity. The space complexity is O(1)
since we modify the array in-place without using any extra space.
You might be wondering when and why we would use the reader-writer variant instead of the anchor-runner one with swapping. The fact is that when dealing with arrays, in the majority of cases, this is just a matter of stylistic preference, and wherever you can use the reader-writer variant, you can also use the anchor-runner. The situation becomes different when dealing with linked lists (which we will cover in a future lesson), as the option to swap elements of the list is no longer available to us, so, to perform mutation of node values in place, we would have to use the reader-writer variant.
In conclusion, the anchor-runner pointer strategy provides an effective and efficient method for manipulating arrays in place. By utilizing two pointers, the anchor and runner, we can traverse the array and perform specific operations based on certain conditions. This strategy allows us to solve problems with improved time and space efficiency, often avoiding the need for additional data structures.
It's important to note that the specific implementation of the anchor-runner approach may vary depending on the problem requirements. Whether it involves swapping elements, writing values, or any other operation, the underlying principle remains the same: efficiently process the array by iteratively manipulating elements using the anchor
and runner
pointers.