A quack is a data structure combining properties of both stacks and queues. It can be viewed as a list of elements written left to right such that three operations are possible:
push(x): add a new item x to the left end of the list
pop(): remove and return the item on the left end of the list
pull(): remove the item on the right end of the list.
Implement a quack using three stacks and O(1) additional memory, so that the amortized time for any push, pop, or pull operation is O(1).
The key insight is to use three stacks strategically: a left stack for recent pushes, a right stack for elements that will be pulled, and a middle stack as a buffer for transfers. We maintain the invariant that elements are distributed across stacks such that left operations (push/pop) primarily use the left stack, while right operations (pull) use the right stack. When stacks become unbalanced, we redistribute elements to maintain amortized O(1) performance.
leftStack: Contains recently pushed elements (top = leftmost in quack)
rightStack: Contains elements ready for pulling (top = rightmost in quack)
middleStack: Temporary stack for element transfers and rebalancing
Push Operation: Always push to leftStack
Pop Operation:
If leftStack not empty, pop from it
Otherwise, redistribute elements and then pop
Pull Operation:
If rightStack not empty, pop from it
Otherwise, redistribute elements and then pop
Rebalancing Strategy: When a stack is empty and we need an element from it, transfer roughly half the elements from the other stack through the middle stack
classSolution {
stack<int> leftStack, rightStack, middleStack;
public:void push(int x) {
leftStack.push(x);
}
intpop() {
if (leftStack.empty()) {
rebalanceForPop();
}
int result = leftStack.top();
leftStack.pop();
return result;
}
intpull() {
if (rightStack.empty()) {
rebalanceForPull();
}
int result = rightStack.top();
rightStack.pop();
return result;
}
private:void rebalanceForPop() {
if (rightStack.empty()) return;
// Move half of rightStack to leftStack via middleStack
int size = rightStack.size();
int toMove = size /2;
// First, move elements to middle stack (reverses order)
for (int i =0; i < toMove; i++) {
middleStack.push(rightStack.top());
rightStack.pop();
}
// Then move from middle to left (reverses back to correct order)
while (!middleStack.empty()) {
leftStack.push(middleStack.top());
middleStack.pop();
}
}
voidrebalanceForPull() {
if (leftStack.empty()) return;
// Move half of leftStack to rightStack via middleStack
int size = leftStack.size();
int toMove = size /2;
// Move elements to middle stack
for (int i =0; i < toMove; i++) {
middleStack.push(leftStack.top());
leftStack.pop();
}
// Move from middle to right stack
while (!middleStack.empty()) {
rightStack.push(middleStack.top());
middleStack.pop();
}
}
boolempty() {
return leftStack.empty() && rightStack.empty();
}
};
⏰ Time complexity: O(1) amortized for all operations. While individual rebalancing operations can take O(n), each element is moved at most a constant number of times across its lifetime, making the amortized cost O(1).
🧺 Space complexity: O(n) where n is the number of elements in the quack, using three stacks. Additional space usage is O(1) as required.
Method 2 - Optimized Two-Stack Approach with Lazy Migration#
We can optimize the approach by using primarily two stacks (left and right) and only using the middle stack during transfers. This reduces memory overhead and simplifies the logic while maintaining the same amortized bounds. The key is to perform lazy migration - only move elements when absolutely necessary.
⏰ Time complexity: O(1) amortized. Each element is moved at most twice during its lifetime in the data structure, giving constant amortized cost per operation.
🧺 Space complexity: O(n) for storing n elements plus O(1) additional memory as specified in the problem requirements.