6
\$\begingroup\$

Need some feedback on this implementation of LFU cache in python3.

Original problem : https://leetcode.com/problems/lfu-cache/

Would really appreciate some feedback on readability, understandability and areas of improvement.

""" ListNode class to create new nodes objects
@variables: key - Key from key,value pair stored by node
            value - Value from key,value pair stored by node
            next - Link to next ListNode object of doubly linked list
            prev - Link to previous ListNode object of doubly linked list
            frequency - Holds the number of times this node was accessed 
                        in LFUCache.get() and LFUCache.put() methods.
                        Default is 1 at node creation on LFUCache.put() call
"""
class ListNode:
     def __init__(self, key=0, val=0, next=None, prev=None):
        self.key = key
        self.val = val
        self.next = next
        self.prev = prev
        self.frequency = 1

""" Main class to create LFU cache.
The idea is to maintain cache linked list in order of frequency and LRU within same frequency
A hash map (self.hash_map) will hold node key as key and pointer to node as value. Another hash map
will hold distinct node frequencies as key and pointer to head node for the frequency.

                                          ______ ______ ______ ______ ______  
                                         |      |      |      |      |      |
hash_map                                 |  23  |  56  |  14  |  6   |  19  |
                                         |______|______|______|______|______|
                                            |      |      |      |      |      
                               _____________|  ____|      |      |_____ |_______________
                              /               /           |            \                \
           _______        ___V___        ____V__        __V____        _V_____        ___V___        _______ 
          |       | <--- |       | <--- |       | <--- |       | <--- |       | <--- |       | <--- |       |
LFUCache  | Head  |      | 23(5) |      | 56(3) |      | 14(2) |      |  6(2) |      | 19(1) |      | Tail  |
          |_______| ---> |_______| ---> |_______| ---> |_______| ---> |_______| ---> |_______| ---> |_______|
                             ^              ^              ^                             ^
                              \              \______       |                            /
                               \______________      \      |      _____________________/     
                                             _\___ __\__ __|__ __/__
                                            |     |     |     |     |
freq_map                                    |  5  |  3  |  2  |  1  |
                                            |_____|_____|_____|_____|
                                          
"""
class LFUCache:

    def __init__(self, capacity: int):
        self.capacity = capacity
        
        self.hash_map = {} # Hash map to hold cache keys as key and link to node as value
        self.freq_map = {} # Hash map to hold frequency as key and link to head node for that frequency
        
        # Dummy head and tail cache nodes to make opertions on edge cases(one node and two node cache) simple
        self.head = ListNode(0,0)
        self.tail = ListNode(0,0)
        
        # Initially set tail (dummy node) as head node for frequency 1
        # so that first cache node is added before tail node
        self.freq_map[1] = self.tail
        
        # Link head cache node and tail cache nodes together
        self.head.next,self.tail.prev = self.tail,self.head

    """ This method will get the value of key (input) if it exists
    in the LFU cache else returns -1. If key exists then this method
    will call self._move() to move the node to front of it's new 
    frequency queue
    """
    def get(self, key: int) -> int:
        if self.hash_map.get(key):
            # Key exists, get link to node from hash map
            node = self.hash_map.get(key)
            # Since frequency changed, move this node to
            # front of new frequency queue
            self._move(node)
            return node.val
        else:
            return -1

    """ This method will update the value of key (input) if it exists
    in the LFU cache else inserts new node into the appropriate position
    on LFU cache
    """
    def put(self, key: int, value: int) -> None:
        
        # Handle edge case when capacity is 0
        if self.capacity == 0:
            return
        
        if self.hash_map.get(key):
            # Key exists, get link to node from hash map
            self.hash_map.get(key).val = value
            # Move this node to front of new frequency queue
            self._move(self.hash_map.get(key))
        else:
            # Key does not exist, need to create a new node. Check if capcaity is full
            if len(self.hash_map) == self.capacity:
                # If node to be removed is the head node for it's frequency then remove it from frequency map
                # If frequency of node to be removed is 1 then we need to reset head node for frequency 1 to tail node
                if self.freq_map.get(self.tail.prev.frequency) == self.tail.prev and self.tail.prev.frequency == 1:
                    self.freq_map[1] = self.tail
                elif self.freq_map.get(self.tail.prev.frequency) == self.tail.prev:
                    self.freq_map.pop(self.tail.prev.frequency) 
                # Remove last node (tail.prev) from the cache list
                self._remove(self.tail.prev)
            
            # Create new node and 
            #     - Add it to cache list
            #     - Set it as head node for frequency 1
            node = ListNode(key,value)
            head = self.freq_map.get(1)
            self.freq_map[1] = node
            self._add(node, head)
    
    """ This method will add a new node(input) before the head(input) node
    on LFU cache
    """
    def _add(self, node :ListNode, head :ListNode) -> None:
        
        # When adding in the middle of cache we don't have dummy head node
        # so we set head.prev to play as dummy head node
        if head != self.head:
            head = head.prev
            
        # Add node operation
        node.next = head.next
        head.next.prev = node
        head.next = node
        node.prev = head
        self.hash_map[node.key] = node 

    """ This method will remove a node(input) from LFU cache and hash_map
    """                   
    def _remove(self, node :ListNode) -> None:
        node.prev.next = node.next
        node.next.prev = node.prev
        node.next = None
        node.prev = None
        self.hash_map.pop(node.key)
        
    def _move(self, node :ListNode) -> None:
        new_frequency = node.frequency + 1
        
        # We need to find head node, before which we need to place the input node 
        # If new frequency already exists in the cache list then get the head node for that frequency
        # else if new frequency does not exist then node will remain at same place in cache so set head as node.next
        # else get the head node for current frequency of input node    
        if self.freq_map.get(new_frequency):
            head = self.freq_map.get(new_frequency)
        elif self.freq_map.get(node.frequency) == node:
            head = node.next
        else:
            head = self.freq_map.get(node.frequency)

        # Set new head nodes for current frequency of input node    
        if self.freq_map.get(node.frequency) == node and node.frequency == node.next.frequency:
            self.freq_map[node.frequency] = node.next
        elif self.freq_map.get(node.frequency) == node:
            self.freq_map.pop(node.frequency)
            
        self._remove(node)
        self._add(node,head)
        node.frequency = new_frequency
        # Set this node as head node for new frequency (current frequency + 1)
        self.freq_map[new_frequency] = node
\$\endgroup\$

1 Answer 1

6
\$\begingroup\$

PEP8 compliance

Whitespace

Use spaces after commas in parameter lists on function calls: ListNode(0, 0) vs. ListNode(0,0)

Also colons in type hints should have a space after them, but not before: node: ListNode vs. node :ListNode.

But the real issue here is your inconsistency of placing whitespace, since you do it according to PEP8 in other places: self._add(node, head), key: int.

Docstrings

Docstrings belong right below the definition they are intended to document:

class Foo:
    """Docstring documenting class Foo."""

    ...
    
    def bar(self):
        """Docstring documenting method bar."""
        ...

vs.

"""Not a docstring."""
class Foo:
    ...

    """Not a docstring either."""
    def bar(self):
        ...

Use return-early and inverted-testing

Consider this:

def get(self, key: int) -> int:
    if not self.hash_map.get(key):
        return -1

    node = self.hash_map.get(key)
    self._move(node)
    return node.val

vs. this:

        
def get(self, key: int) -> int:
    if self.hash_map.get(key):
        # Key exists, get link to node from hash map
        node = self.hash_map.get(key)
        # Since frequency changed, move this node to
        # front of new frequency queue
        self._move(node)
        return node.val
    else:
        return -1

This may even be a good use case for the walrus operator:

def get(self, key: int) -> int:
    if not (node := self.hash_map.get(key)):
        return -1

    self._move(node)
    return node.val

Divide an conquer

Currently some of your class' methods are rather long. You should try to split them into smaller methods, each dealing with a particular part of a problem. An example would be put():

def put(self, key: int, value: int) -> None:
    # Handle edge case when capacity is 0
    if self.capacity == 0:
        return
    
    if not self.hash_map.get(key):
        return self._put_new(key, value)
        
    self.hash_map.get(key).val = value
    self._move(self.hash_map.get(key))

def _put_new(self, key: int, value: int) -> None:
    """Add a non-existant key to the map."""
    if len(self.hash_map) == self.capacity:
        # If node to be removed is the head node for it's frequency then remove it from frequency map
        # If frequency of node to be removed is 1 then we need to reset head node for frequency 1 to tail node
        if self.freq_map.get(self.tail.prev.frequency) == self.tail.prev and self.tail.prev.frequency == 1:
            self.freq_map[1] = self.tail
        elif self.freq_map.get(self.tail.prev.frequency) == self.tail.prev:
            self.freq_map.pop(self.tail.prev.frequency) 
        # Remove last node (tail.prev) from the cache list
        self._remove(self.tail.prev)
    
    # Create new node and 
    #     - Add it to cache list
    #     - Set it as head node for frequency 1
    node = ListNode(key,value)
    head = self.freq_map.get(1)
    self.freq_map[1] = node
    self._add(node, head)
\$\endgroup\$
1
  • \$\begingroup\$ Thanks a lot @richard-neumann . Really appreciate you taking time to review and share valuable feedback. This is exactly what i was looking for ! \$\endgroup\$ Commented Mar 7, 2022 at 17:15

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.