Skip to main content

Python Deque and Collections Practice Problems & Exercises

Practice: Deque and Collections

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

#1Deque Front-Back BuilderEasy
dequeappendleftappend

Build a deque by processing a sequence of directional append operations. Each operation is a tuple of ('left', value) or ('right', value).

Constraints:

  • 0 to 1000 operations
  • Values can be any integer
Solution
from collections import deque

def build_deque(operations):
dq = deque()
for direction, value in operations:
if direction == 'left':
dq.appendleft(value)
else:
dq.append(value)
return list(dq)

# Tests
print(build_deque([('right', 1), ('left', 2), ('right', 3), ('left', 4)]))
print(build_deque([('right', 20), ('left', 10), ('right', 30)]))
print(build_deque([]))

Time: O(n) -- each append is O(1). Space: O(n) for the deque.

from collections import deque

def build_deque(operations):
  """
  Given a list of (direction, value) tuples where direction is
  'left' or 'right', build a deque by appending each value to
  the specified end. Return the deque as a list.

  Example:
    operations = [('right', 1), ('left', 2), ('right', 3), ('left', 4)]
    Result: [4, 2, 1, 3]
  """
  # TODO: implement
  pass
Expected Output
[4, 2, 1, 3]
[10, 30, 20]
[]
Hints

Hint 1: Create an empty deque, then loop through the operations list.

Hint 2: Use dq.append(val) for 'right' and dq.appendleft(val) for 'left'.

Hint 3: Return list(dq) at the end.


#2Bounded Recent LoggerEasy
dequemaxlenrotating buffer

Simulate a log buffer that retains only the most recent k messages. Use deque(maxlen=k) so old messages are automatically discarded.

Constraints:

  • 0 to 100,000 messages
  • 1 <= k <= len(messages)
Solution
from collections import deque

def recent_messages(messages, k):
buffer = deque(maxlen=k)
for msg in messages:
buffer.append(msg)
return list(buffer)

# Tests
print(recent_messages(["boot", "init", "connect", "auth", "query", "close"], 3))
print(recent_messages(["a", "b", "c", "d", "e"], 1))
print(recent_messages(["a", "b", "c", "d", "e"], 5))

Time: O(n) where n is the number of messages. Space: O(k) -- the deque never holds more than k elements.

from collections import deque

def recent_messages(messages, k):
  """
  Given a list of log messages and an integer k, return the
  last k messages. Use deque(maxlen=k) so memory never exceeds
  k entries, even if millions of messages stream in.

  Example:
    messages = ["boot", "init", "connect", "auth", "query", "close"]
    k = 3
    Result: ["query", "close"] -- wait, that's only 2... nope:
    Result: ["auth", "query", "close"]
  """
  # TODO: implement
  pass
Expected Output
['auth', 'query', 'close']
['e']
['a', 'b', 'c', 'd', 'e']
Hints

Hint 1: Create a deque with maxlen=k.

Hint 2: Append each message -- the deque auto-evicts the oldest when full.

Hint 3: Return list(dq) at the end.


#3Palindrome Checker with DequeEasy
dequepopleftpop

Use a deque to check whether a string is a palindrome. Load characters into a deque, then compare from both ends simultaneously using popleft() and pop().

Constraints:

  • Ignore spaces and case
  • Empty string and single character are palindromes
Solution
from collections import deque

def is_palindrome(s):
cleaned = s.lower().replace(" ", "")
dq = deque(cleaned)
while len(dq) > 1:
if dq.popleft() != dq.pop():
return False
return True

# Tests
print(is_palindrome("racecar"))
print(is_palindrome("Race Car"))
print(is_palindrome("hello"))
print(is_palindrome("A"))
print(is_palindrome(""))

Time: O(n) -- each character is popped exactly once. Space: O(n) for the deque.

from collections import deque

def is_palindrome(s):
  """
  Check if a string is a palindrome using a deque.
  Compare characters from both ends, moving inward.
  Ignore spaces and case.

  Example:
    is_palindrome("racecar")     -> True
    is_palindrome("Race Car")    -> True
    is_palindrome("hello")       -> False
  """
  # TODO: implement
  pass
Expected Output
True
True
False
True
True
Hints

Hint 1: Convert to lowercase and remove spaces before loading into the deque.

Hint 2: While the deque has more than 1 element, popleft and pop -- compare them.

Hint 3: If any pair differs, return False. If the loop finishes, return True.


#4Rotate Array with DequeEasy
dequerotate

Use deque.rotate() to rotate a list by k positions. Positive k rotates right, negative k rotates left.

Constraints:

  • k can be positive, negative, or zero
  • The list can have 0 to 100,000 elements
Solution
from collections import deque

def rotate_list(nums, k):
dq = deque(nums)
dq.rotate(k)
return list(dq)

# Tests
print(rotate_list([1, 2, 3, 4, 5], 2))
print(rotate_list([1, 2, 3, 4, 5], -1))
print(rotate_list([1, 2, 3], 0))
print(rotate_list([1], 100))

Time: O(k) for the rotation + O(n) for deque construction. Space: O(n) for the deque.

Note: deque.rotate(k) is O(k), not O(n). It performs k individual popleft/append (or pop/appendleft) operations internally, each O(1).

from collections import deque

def rotate_list(nums, k):
  """
  Rotate a list to the right by k positions using deque.rotate().
  Return the rotated list.

  Example:
    rotate_list([1, 2, 3, 4, 5], 2) -> [4, 5, 1, 2, 3]
    rotate_list([1, 2, 3, 4, 5], -1) -> [2, 3, 4, 5, 1]
  """
  # TODO: implement
  pass
Expected Output
[4, 5, 1, 2, 3]
[2, 3, 4, 5, 1]
[1, 2, 3]
[1]
Hints

Hint 1: Load the list into a deque.

Hint 2: Call dq.rotate(k) -- positive rotates right, negative rotates left.

Hint 3: Return list(dq).


#5Sliding Window AverageMedium
dequemaxlensliding window

Implement a MovingAverage class that computes the average of the last k values in a stream. Each call to next(val) adds a value and returns the current average. Use deque(maxlen=k) to maintain the window efficiently.

Constraints:

  • 1 <= k <= 10,000
  • Values can be any integer or float
Solution
from collections import deque

class MovingAverage:
def __init__(self, k):
self.window = deque(maxlen=k)
self.window_sum = 0

def next(self, val):
if len(self.window) == self.window.maxlen:
self.window_sum -= self.window[0]
self.window.append(val)
self.window_sum += val
return self.window_sum / len(self.window)

# Tests
ma = MovingAverage(3)
print(ma.next(1))
print(ma.next(10))
print(ma.next(3))
print(ma.next(5))

Time: O(1) per next() call. Space: O(k) for the deque window.

Key insight: Maintaining a running sum avoids recalculating the sum every time. When the deque is full and a new element arrives, the leftmost element is about to be evicted -- subtract it from the sum before the append.

from collections import deque

class MovingAverage:
  """
  Calculate the moving average of a data stream over
  the last k values.

  Example:
    ma = MovingAverage(3)
    ma.next(1)  -> 1.0       (only [1])
    ma.next(10) -> 5.5       (average of [1, 10])
    ma.next(3)  -> 4.667     (average of [1, 10, 3])
    ma.next(5)  -> 6.0       (average of [10, 3, 5], 1 evicted)
  """
  def __init__(self, k):
      # TODO: initialize
      pass

  def next(self, val):
      # TODO: implement — return the current moving average
      pass
Expected Output
1.0
5.5
4.666666666666667
6.0
Hints

Hint 1: Use a deque(maxlen=k) to hold the window and a running sum variable.

Hint 2: Before appending, if the window is full, subtract the element about to be evicted (window[0]).

Hint 3: Append the new value, add it to the sum, then return sum / len(window).


#6Sliding Window MaximumHard
dequemonotonic dequesliding window

Implement the classic sliding window maximum algorithm using a monotonic deque. The deque stores indices in decreasing order of their corresponding values. This achieves O(n) total time because each index is pushed and popped at most once.

Constraints:

  • 1 <= k <= len(nums)
  • 1 <= len(nums) <= 100,000
Solution
from collections import deque

def max_sliding_window(nums, k):
dq = deque() # stores indices, decreasing by value
result = []

for i, val in enumerate(nums):
# Remove indices outside the current window
while dq and dq[0] <= i - k:
dq.popleft()

# Remove indices whose values are smaller than current
while dq and nums[dq[-1]] < val:
dq.pop()

dq.append(i)

# Record the max once the first window is complete
if i >= k - 1:
result.append(nums[dq[0]])

return result

# Tests
print(max_sliding_window([1, 3, -1, -3, 5, 3, 6, 7], 3))
print(max_sliding_window([1], 1))
print(max_sliding_window([1, 3, 1, 2, 0, 5, 3, 6], 4))

Time: O(n) -- each element enters and leaves the deque at most once. Space: O(k) for the deque.

Why this works: The deque maintains a decreasing order of values. When a new value is larger than the back of the deque, those smaller elements can never be the window maximum while the new element is in the window, so they are discarded. The front always holds the index of the current maximum.

from collections import deque

def max_sliding_window(nums, k):
  """
  Given a list of integers and a window size k, return a list
  of the maximum value in each sliding window of size k.

  Use a monotonic deque to achieve O(n) time.

  Example:
    nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
    Result: [3, 3, 5, 5, 6, 7]
  """
  # TODO: implement
  pass
Expected Output
[3, 3, 5, 5, 6, 7]
[1]
[3, 3, 3, 5, 5, 6]
Hints

Hint 1: Store indices (not values) in the deque. The front always holds the index of the current window max.

Hint 2: For each new index i: remove from front any index outside the window (index <= i - k).

Hint 3: Remove from back any index whose value is less than nums[i] -- they can never be the max.

Hint 4: Append i. Once i >= k - 1, the window is full -- record nums[dq[0]] as the max.


#7LRU Cache with OrderedDictHard
OrderedDictmove_to_endLRU cache

Implement a fully functional LRU cache using collections.OrderedDict. Both get and put must run in O(1) time. This is a classic interview problem (LeetCode 146).

Constraints:

  • 1 <= capacity <= 1000
  • Keys and values are integers
Solution
from collections import OrderedDict

class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.cache = OrderedDict()

def get(self, key):
if key not in self.cache:
return -1
self.cache.move_to_end(key)
return self.cache[key]

def put(self, key, value):
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
self.cache.popitem(last=False)

# Tests
cache = LRUCache(2)
cache.put(1, 10)
cache.put(2, 20)
print(cache.get(1)) # 10 -- key 1 moved to most recent
cache.put(3, 30) # evicts key 2
print(cache.get(2)) # -1 -- evicted
print(cache.get(3)) # 30
print(cache.get(1)) # 10

Time: O(1) for both get and put. Space: O(capacity).

Why OrderedDict: move_to_end(key) is O(1) because OrderedDict maintains a doubly-linked list of keys alongside the hash table. Plain dict preserves insertion order but has no way to move an existing key to the end in O(1).

from collections import OrderedDict

class LRUCache:
  """
  Implement a Least Recently Used (LRU) cache with O(1) get and put.

  - get(key): return the value if it exists, otherwise -1.
    Marks the key as most recently used.
  - put(key, value): insert or update. If the cache exceeds
    capacity, evict the least recently used key.

  Example:
    cache = LRUCache(2)
    cache.put(1, 10)
    cache.put(2, 20)
    cache.get(1)        -> 10  (key 1 is now most recent)
    cache.put(3, 30)    -> evicts key 2 (least recently used)
    cache.get(2)        -> -1  (evicted)
  """
  def __init__(self, capacity):
      # TODO: initialize
      pass

  def get(self, key):
      # TODO: implement
      pass

  def put(self, key, value):
      # TODO: implement
      pass
Expected Output
10
-1
30
10
Hints

Hint 1: Use an OrderedDict where the rightmost entry is the most recently used.

Hint 2: On get: if the key exists, call self.cache.move_to_end(key) to mark it as recent.

Hint 3: On put: if the key exists, move it to end. Set the value. If over capacity, call self.cache.popitem(last=False) to evict the leftmost (least recent) entry.


#8Layered Config with ChainMapMedium
ChainMapconfigurationnew_child

Build a three-layer configuration system using ChainMap. Command-line arguments override environment variables, which override defaults. Return a lookup function.

Constraints:

  • All three input dicts can be empty
  • Keys are strings, values can be any type
Solution
from collections import ChainMap

def build_config(defaults, env, cli):
chain = ChainMap(cli, env, defaults)
return lambda key: chain.get(key)

# Tests
defaults = {'debug': False, 'timeout': 30, 'retries': 3}
env = {'timeout': 60}
cli = {'debug': True}

get = build_config(defaults, env, cli)
print(get('debug'))
print(get('timeout'))
print(get('retries'))
print(get('missing'))

Time: O(m) per lookup where m is the number of maps in the chain (here 3 -- effectively O(1)). Space: O(1) additional -- ChainMap is a view, not a copy.

Key insight: ChainMap does not merge the dicts. It walks them in order at lookup time. This means changes to the original dicts are reflected immediately through the ChainMap -- useful when environment variables or config files can reload at runtime.

from collections import ChainMap

def build_config(defaults, env, cli):
  """
  Build a layered configuration using ChainMap.
  Priority: cli > env > defaults.

  Return a function get_config(key) that looks up the key
  through the chain, returning None if not found anywhere.

  Example:
    defaults = {'debug': False, 'timeout': 30, 'retries': 3}
    env = {'timeout': 60}
    cli = {'debug': True}

    get = build_config(defaults, env, cli)
    get('debug')    -> True     (from cli)
    get('timeout')  -> 60       (from env)
    get('retries')  -> 3        (from defaults)
    get('missing')  -> None
  """
  # TODO: implement
  pass
Expected Output
True
60
3
None
Hints

Hint 1: Create a ChainMap with cli as the first map, then env, then defaults.

Hint 2: Return a function (or lambda) that calls chain.get(key) -- .get() returns None for missing keys.


#9Scope Chain SimulatorMedium
ChainMapnew_childscoping

Build a scope chain simulator using ChainMap.new_child() and .parents. This is how Python itself resolves variable names through nested scopes.

Constraints:

  • pop_scope() on a single-scope chain should raise an error or be a no-op
  • Variable names are strings
Solution
from collections import ChainMap

class ScopeChain:
def __init__(self):
self.chain = ChainMap()

def push_scope(self):
self.chain = self.chain.new_child()

def pop_scope(self):
if self.chain.parents.maps:
self.chain = self.chain.parents
else:
raise RuntimeError("Cannot pop the global scope")

def set(self, name, value):
self.chain[name] = value # writes to the first (innermost) map

def get(self, name):
return self.chain[name] # walks the chain; raises KeyError if not found

# Tests
sc = ScopeChain()
sc.set('x', 10)
sc.push_scope()
sc.set('y', 20)
print(sc.get('x')) # 10 -- found in outer scope
print(sc.get('y')) # 20 -- found in inner scope
sc.pop_scope()
print(sc.get('x')) # 10
try:
sc.get('y')
except KeyError:
print("Caught KeyError for y")

Time: O(d) per lookup where d is the scope depth. Space: O(n) total across all scopes for n variables.

Why ChainMap here: new_child() creates a new scope layer in O(1). .parents removes it in O(1). Variable lookup automatically walks outward through all scopes. This is exactly how Python resolves names through local, enclosing, global, and builtin scopes.

from collections import ChainMap

class ScopeChain:
  """
  Simulate nested variable scopes (like Python's LEGB rule).

  - push_scope(): create a new inner scope
  - pop_scope(): remove the innermost scope
  - set(name, value): set variable in the current (innermost) scope
  - get(name): look up variable, walking outward through scopes
    Raise KeyError if not found in any scope.

  Example:
    sc = ScopeChain()
    sc.set('x', 10)          # global scope
    sc.push_scope()
    sc.set('y', 20)          # inner scope
    sc.get('x')  -> 10       # found in outer scope
    sc.get('y')  -> 20       # found in inner scope
    sc.pop_scope()
    sc.get('x')  -> 10
    sc.get('y')  -> KeyError
  """
  def __init__(self):
      # TODO: initialize
      pass

  def push_scope(self):
      # TODO: implement
      pass

  def pop_scope(self):
      # TODO: implement
      pass

  def set(self, name, value):
      # TODO: implement
      pass

  def get(self, name):
      # TODO: implement
      pass
Expected Output
10
20
10
Caught KeyError for y
Hints

Hint 1: Store the chain as a ChainMap. Start with one empty dict as the global scope.

Hint 2: push_scope: self.chain = self.chain.new_child() -- adds a new empty dict at the front.

Hint 3: pop_scope: self.chain = self.chain.parents -- removes the front dict.

Hint 4: set writes to self.chain.maps[0] (the innermost scope). get uses self.chain[name].


#10Student Records with NamedTupleMedium
namedtupletyping.NamedTuple_asdict_replace

Use typing.NamedTuple to define a Student record. Filter and sort students by GPA, then convert to dicts for JSON serialization using _asdict().

Constraints:

  • 0 to 10,000 students
  • GPA is a float between 0.0 and 4.0
Solution
from typing import NamedTuple, List

class Student(NamedTuple):
name: str
grade: int
gpa: float

def top_students(students: List[Student], min_gpa: float) -> List[dict]:
filtered = [s for s in students if s.gpa >= min_gpa]
filtered.sort(key=lambda s: s.gpa, reverse=True)
return [dict(s._asdict()) for s in filtered]

# Tests
students = [
Student("Alice", 12, 3.9),
Student("Bob", 11, 3.2),
Student("Carol", 12, 3.95),
Student("Dave", 10, 2.8),
]
print(top_students(students, 3.5))
print(top_students(students, 4.0))

Time: O(n log n) for sorting. Space: O(n) for the filtered list.

Why NamedTuple over dict: NamedTuples are immutable (no accidental mutation), use less memory than dicts, are hashable (can be used in sets), and provide attribute access for readable code. The _asdict() method gives you a dict when you need one for serialization.

from typing import NamedTuple, List

class Student(NamedTuple):
  name: str
  grade: int
  gpa: float

def top_students(students: List[Student], min_gpa: float) -> List[dict]:
  """
  Filter students with GPA >= min_gpa, sorted by GPA descending.
  Return a list of dicts (using _asdict()) for JSON serialization.

  Example:
    students = [
        Student("Alice", 12, 3.9),
        Student("Bob", 11, 3.2),
        Student("Carol", 12, 3.95),
        Student("Dave", 10, 2.8),
    ]
    top_students(students, 3.5)
    -> [{'name': 'Carol', 'grade': 12, 'gpa': 3.95},
        {'name': 'Alice', 'grade': 12, 'gpa': 3.9}]
  """
  # TODO: implement
  pass
Expected Output
[{'name': 'Carol', 'grade': 12, 'gpa': 3.95}, {'name': 'Alice', 'grade': 12, 'gpa': 3.9}]
[]
Hints

Hint 1: Filter with a list comprehension: [s for s in students if s.gpa >= min_gpa].

Hint 2: Sort the filtered list by gpa descending: sorted(..., key=lambda s: s.gpa, reverse=True).

Hint 3: Convert each Student to a dict using s._asdict(). In Python 3.8+, _asdict returns a regular dict.


#11Time-Limited Key-Value StoreHard
dequeOrderedDictexpirationdesign

Design a key-value store where entries automatically expire after a fixed time-to-live (TTL). This combines dict-based O(1) lookup with time-based eviction -- a pattern used in DNS caches, session stores, and rate limiters.

Constraints:

  • Timestamps are monotonically increasing integers
  • TTL is a positive integer
  • Keys are strings, values are integers
Solution
from collections import deque

class ExpiringCache:
def __init__(self, ttl):
self.ttl = ttl
self.store = {} # key -> (value, expiry_time)
self.expiry_queue = deque() # (expiry_time, key) for lazy cleanup

def _cleanup(self, current_time):
"""Remove expired entries from the front of the queue."""
while self.expiry_queue and self.expiry_queue[0][0] <= current_time:
exp_time, key = self.expiry_queue.popleft()
# Only delete if the stored expiry matches (key may have been overwritten)
if key in self.store and self.store[key][1] == exp_time:
del self.store[key]

def put(self, key, value, timestamp):
self._cleanup(timestamp)
expiry = timestamp + self.ttl
self.store[key] = (value, expiry)
self.expiry_queue.append((expiry, key))

def get(self, key, timestamp):
self._cleanup(timestamp)
if key not in self.store:
return -1
value, expiry = self.store[key]
if timestamp >= expiry:
del self.store[key]
return -1
return value

# Tests
cache = ExpiringCache(ttl=5)
cache.put("a", 100, timestamp=1)
print(cache.get("a", timestamp=3)) # 100 (expires at 6, 3 < 6)
print(cache.get("a", timestamp=5)) # 100 (expires at 6, 5 < 6)
print(cache.get("a", timestamp=7)) # -1 (expired: 7 >= 6)
cache.put("b", 200, timestamp=10)
print(cache.get("b", timestamp=14)) # 200 (expires at 15, 14 < 15)
print(cache.get("b", timestamp=15)) # -1 (expired: 15 >= 15)

Time: O(1) amortized for both get and put. Cleanup is amortized O(1) because each entry enters and leaves the deque exactly once. Space: O(n) where n is the number of non-expired entries.

Design insight: The deque serves as a lazy expiration queue. Since timestamps are monotonic, expired entries are always at the front. We clean them up on each operation, spreading the cost across calls. This is the same pattern used by Redis for key expiration.

from collections import deque, OrderedDict

class ExpiringCache:
  """
  A key-value store where each entry expires after a given time.

  - put(key, value, timestamp): store a key-value pair at the given time
  - get(key, timestamp): return the value if the key exists AND has not
    expired. Return -1 if missing or expired.
  - Each entry lives for self.ttl time units from its insertion timestamp.

  Example (ttl=5):
    cache.put("a", 100, timestamp=1)
    cache.get("a", timestamp=3)  -> 100  (1+5=6 > 3, not expired)
    cache.get("a", timestamp=7)  -> -1   (1+5=6 < 7, expired)
  """
  def __init__(self, ttl):
      # TODO: initialize with a time-to-live value
      pass

  def put(self, key, value, timestamp):
      # TODO: implement
      pass

  def get(self, key, timestamp):
      # TODO: implement
      pass
Expected Output
100
100
-1
200
-1
Hints

Hint 1: Store entries as (value, expiry_time) in a dict. expiry_time = timestamp + ttl.

Hint 2: On get, check if the key exists and if timestamp < expiry_time.

Hint 3: Bonus: use a deque to track insertion order and lazily clean up expired entries for memory efficiency.

© 2026 EngineersOfAI. All rights reserved.