Taro Logo

Design an ATM Machine

Medium
Asked by:
Profile picture
0 views
Topics:
ArraysGreedy Algorithms

There is an ATM machine that stores banknotes of 5 denominations: 20, 50, 100, 200, and 500 dollars. Initially the ATM is empty. The user can use the machine to deposit or withdraw any amount of money.

When withdrawing, the machine prioritizes using banknotes of larger values.

  • For example, if you want to withdraw $300 and there are 2 $50 banknotes, 1 $100 banknote, and 1 $200 banknote, then the machine will use the $100 and $200 banknotes.
  • However, if you try to withdraw $600 and there are 3 $200 banknotes and 1 $500 banknote, then the withdraw request will be rejected because the machine will first try to use the $500 banknote and then be unable to use banknotes to complete the remaining $100. Note that the machine is not allowed to use the $200 banknotes instead of the $500 banknote.

Implement the ATM class:

  • ATM() Initializes the ATM object.
  • void deposit(int[] banknotesCount) Deposits new banknotes in the order $20, $50, $100, $200, and $500.
  • int[] withdraw(int amount) Returns an array of length 5 of the number of banknotes that will be handed to the user in the order $20, $50, $100, $200, and $500, and update the number of banknotes in the ATM after withdrawing. Returns [-1] if it is not possible (do not withdraw any banknotes in this case).

Example 1:

Input
["ATM", "deposit", "withdraw", "deposit", "withdraw", "withdraw"]
[[], [[0,0,1,2,1]], [600], [[0,1,0,1,1]], [600], [550]]
Output
[null, null, [0,0,1,0,1], null, [-1], [0,1,0,0,1]]

Explanation
ATM atm = new ATM();
atm.deposit([0,0,1,2,1]); // Deposits 1 $100 banknote, 2 $200 banknotes,
                          // and 1 $500 banknote.
atm.withdraw(600);        // Returns [0,0,1,0,1]. The machine uses 1 $100 banknote
                          // and 1 $500 banknote. The banknotes left over in the
                          // machine are [0,0,0,2,0].
atm.deposit([0,1,0,1,1]); // Deposits 1 $50, $200, and $500 banknote.
                          // The banknotes in the machine are now [0,1,0,3,1].
atm.withdraw(600);        // Returns [-1]. The machine will try to use a $500 banknote
                          // and then be unable to complete the remaining $100,
                          // so the withdraw request will be rejected.
                          // Since the request is rejected, the number of banknotes
                          // in the machine is not modified.
atm.withdraw(550);        // Returns [0,1,0,0,1]. The machine uses 1 $50 banknote
                          // and 1 $500 banknote.

Constraints:

  • banknotesCount.length == 5
  • 0 <= banknotesCount[i] <= 109
  • 1 <= amount <= 109
  • At most 5000 calls in total will be made to withdraw and deposit.
  • At least one call will be made to each function withdraw and deposit.
  • Sum of banknotesCount[i] in all deposits doesn't exceed 109

Solution


Clarifying Questions

When you get asked this question in a real-life environment, it will often be ambiguous (especially at FAANG). Make sure to ask these questions in that case:

  1. What are the initial values for `banknotesCounts`? Can I assume it's initialized to all zeros, or will it have some pre-existing values?
  2. Can the input `amount` for the withdraw function be zero or negative? What should I return in those cases?
  3. If there are multiple ways to withdraw the exact amount using the fewest number of banknotes, is any valid combination acceptable?
  4. Can I assume that the input array `banknotesCounts` will always contain exactly 5 denominations in the order specified (20, 50, 100, 200, 500)?
  5. Is there a limit to the size of the `amount` being deposited or withdrawn? I.e. should I worry about integer overflow during the calculation?

Brute Force Solution

Approach

For designing an ATM, a brute force approach means we try all possible combinations of bills to dispense until we find one that matches the requested amount. It's like rummaging through all your cash until you find the right combination. This approach focuses on direct computation without any smart optimizations.

Here's how the algorithm would work step-by-step:

  1. First, receive the withdrawal amount from the user.
  2. Start trying combinations of the largest bills first (e.g., 100s, then 50s, then 20s, etc.).
  3. Check if that combination is exactly equal to the withdrawal amount.
  4. If it's too much, reduce the number of largest bills and try different smaller bill combinations.
  5. If it's too little, add more of the largest bills or include other smaller bill combinations and recalculate.
  6. Keep exploring all combinations of bills (100s, 50s, 20s, 10s, 5s, and 1s) until a matching combination is found.
  7. If you find a combination, check to see if we have enough of each bill in the ATM.
  8. If the ATM has enough of each bill, dispense the combination to the user.
  9. If all combinations are exhausted and no exact match is found, then return an error indicating that the amount cannot be dispensed.

Code Implementation

class ATM:
    def __init__(self, initial_balance=0, available_denominations=[20, 50, 100]):
        self.account_balance = initial_balance
        self.cash_available = {
            20: 100, 
            50: 50,  
            100: 20   
        }
        self.available_denominations = available_denominations
        self.pin = "1234"

    def check_balance(self, entered_pin):
        if entered_pin == self.pin:
            return self.account_balance
        else:
            return "Incorrect PIN"

    def withdraw(self, amount, entered_pin):
        if entered_pin != self.pin:
            return "Incorrect PIN"

        if amount <= 0:
            return "Invalid amount"

        if amount > self.account_balance:
            return "Insufficient funds in account"

        if amount > self.calculate_total_cash_available():
            return "Insufficient funds in ATM"

        if amount % min(self.available_denominations) != 0:
            return "Amount must be a multiple of available denominations"

        dispensed_cash = self.dispense_cash(amount)

        if dispensed_cash is None:
            return "Cannot dispense exact amount with available denominations"

        self.account_balance -= amount
        return dispensed_cash

    def deposit(self, amount, entered_pin):
        if entered_pin != self.pin:
            return "Incorrect PIN"

        if amount <= 0:
            return "Invalid deposit amount"

        self.account_balance += amount
        return "Deposit successful"

    def change_pin(self, old_pin, new_pin):
        if old_pin != self.pin:
            return "Incorrect PIN"
        if len(new_pin) != 4 or not new_pin.isdigit():
            return "Invalid new PIN. Must be 4 digits."

        self.pin = new_pin
        return "PIN changed successfully"

    def dispense_cash(self, amount):
        cash_to_dispense = {}
        remaining_amount = amount

        # Iterate through denominations from largest to smallest
        for denomination in sorted(self.available_denominations, reverse=True):
            while remaining_amount >= denomination and self.cash_available[denomination] > 0:
                if denomination not in cash_to_dispense:
                    cash_to_dispense[denomination] = 0
                cash_to_dispense[denomination] += 1
                remaining_amount -= denomination
                self.cash_available[denomination] -= 1

        if remaining_amount == 0:
            return cash_to_dispense
        else:
            self.replenish_cash(cash_to_dispense)
            return None

    def calculate_total_cash_available(self):
        total = 0
        for denomination, count in self.cash_available.items():
            total += denomination * count
        return total

    def replenish_cash(self, dispensed_cash):
        # If dispensing fails, restore the cash
        for denomination, count in dispensed_cash.items():
            self.cash_available[denomination] += count

def brute_force_atm_testing():
    atm = ATM(initial_balance=500)

    # Test valid balance check
    print(f"Initial balance: {atm.check_balance('1234')}
")

    # Test invalid PIN during balance check
    print(f"Balance check with wrong pin: {atm.check_balance('0000')}
")

    # Test valid withdrawal
    withdrawal_amount = 100
    withdrawal_result = atm.withdraw(withdrawal_amount, "1234")
    print(f"Withdrawal of ${withdrawal_amount}: {withdrawal_result}
")

    # Test insufficient funds withdrawal
    withdrawal_amount = 1000
    withdrawal_result = atm.withdraw(withdrawal_amount, "1234")
    print(f"Withdrawal of ${withdrawal_amount} (insufficient funds): {withdrawal_result}
")

    # Test changing the pin successfully
    print(f"Changing pin: {atm.change_pin('1234', '5678')}
")

    # Test deposit
    deposit_amount = 200
    deposit_result = atm.deposit(deposit_amount, "5678")
    print(f"Deposit of ${deposit_amount}: {deposit_result}
")

    # Test invalid pin for deposit
    deposit_amount = 200
    deposit_result = atm.deposit(deposit_amount, "1234")
    print(f"Deposit of ${deposit_amount} with invalid pin: {deposit_result}
")

brute_force_atm_testing()

Big(O) Analysis

Time Complexity
O(amount^6) – The brute force approach explores all possible combinations of denominations to reach the target amount. With a fixed set of denominations (100s, 50s, 20s, 10s, 5s, and 1s), the algorithm essentially has six nested loops, each iterating through the possible counts for each denomination up to the withdrawal amount. In the worst-case scenario, each loop could iterate up to the withdrawal amount because we try all possible combinations for each denomination. Therefore, the time complexity is proportional to amount * amount * amount * amount * amount * amount, which is O(amount^6).
Space Complexity
O(1) – The described brute force approach for an ATM machine primarily uses a few integer variables to keep track of the number of each type of bill being considered in a combination and the remaining amount to dispense. It does not involve creating any auxiliary data structures like lists or hash maps that scale with the withdrawal amount (N). Therefore, the space complexity remains constant, independent of the withdrawal amount, and is O(1).

Optimal Solution

Approach

Designing an ATM involves breaking down the complex process into smaller, manageable actions. We need to think about the ATM's functionalities like dispensing cash, checking balances, and handling transactions, and how they interact with the user and the bank's system.

Here's how the algorithm would work step-by-step:

  1. First, identify the main actions a user can perform: checking balance, withdrawing cash, depositing funds, and potentially transferring money or changing PIN.
  2. Next, for each action, figure out the sequence of steps involved. For example, withdrawing cash needs the user to enter their PIN, select an amount, and then receive the money.
  3. Consider how the ATM interacts with the user. This involves designing the user interface and ensuring it's easy to understand and use.
  4. Think about how the ATM validates user input. For instance, checking if the PIN is correct, or if the withdrawal amount is available and within limits.
  5. Account for error handling. What happens if the PIN is incorrect, or the machine is out of cash?
  6. Design how the ATM communicates with the bank's central system to check balances, update accounts, and record transactions. This includes handling network issues.
  7. Ensure security measures are in place to protect against fraud and unauthorized access, like encrypting data and limiting access to sensitive functions.

Code Implementation

class ATMMachine:
    def __init__(self, initial_balance=0):
        self.account_balance = initial_balance
        self.transaction_log = []

    def check_balance(self, pin_number):
        if self._verify_pin(pin_number):
            return self.account_balance
        else:
            return "Incorrect PIN."

    def deposit(self, amount, pin_number):
        if self._verify_pin(pin_number):
            if amount > 0:
                self.account_balance += amount
                self.transaction_log.append(f"Deposit: +${amount}")
                return f"Deposited ${amount}. New balance: ${self.account_balance}"
            else:
                return "Invalid deposit amount."
        else:
            return "Incorrect PIN."

    def withdraw(self, amount, pin_number):
        if self._verify_pin(pin_number):
            if amount > 0 and amount <= self.account_balance:
                self.account_balance -= amount
                self.transaction_log.append(f"Withdrawal: -${amount}")

                # Must update transaction log before returning new balance
                return f"Withdrew ${amount}. New balance: ${self.account_balance}"

            elif amount <= 0:
                return "Invalid withdrawal amount."
            else:

                # Insufficient Funds
                return "Insufficient funds."
        else:
            return "Incorrect PIN."

    def _verify_pin(self, entered_pin):
        correct_pin = "1234"
        return entered_pin == correct_pin

    def get_transaction_log(self):
        return self.transaction_log

# Example Usage
atm = ATMMachine(100)
print(atm.check_balance("1234"))
print(atm.deposit(50, "1234"))

# Must implement pin verification
print(atm.withdraw(20, "1234"))

print(atm.check_balance("1234"))
print(atm.get_transaction_log())

Big(O) Analysis

Time Complexity
O(1) – The described ATM operations (checking balance, withdrawal, deposit, PIN change) typically involve a fixed number of steps, regardless of the total number of bank accounts or transactions in the system. Input validation, communication with the bank (assuming efficient database lookups), and dispensing cash are bounded by constant time operations. Therefore, the time complexity of individual ATM transactions is O(1). The ATM's interaction with the bank's system may involve searching through account records; however, assuming the bank uses indexed databases, it will retrieve account records in constant time.
Space Complexity
O(1) – The space complexity of designing an ATM machine, based on the provided description, is primarily constant. The system needs to store a fixed number of variables, such as the user's PIN, the withdrawal amount, and potentially a few temporary variables for validating input and communicating with the bank's system. No dynamic data structures that scale with the number of users or transactions are explicitly mentioned, and recursion is not indicated. Therefore, the auxiliary space remains constant irrespective of the number of users or transactions processed, resulting in O(1) space complexity.

Edge Cases

CaseHow to Handle
Initial banknotesCounts is null or emptyInitialize the ATM with zero counts for all denominations.
Withdraw amount is zeroReturn an array of zeros representing no banknotes withdrawn.
Withdraw amount is negativeReturn [-1] indicating an invalid withdrawal request.
Withdraw amount is larger than the total amount in the ATMReturn [-1] indicating insufficient funds.
Withdraw amount cannot be formed by any combination of banknotesReturn [-1] indicating no valid withdrawal combination.
Large withdraw amount requiring a large number of calculationsThe greedy algorithm minimizes banknotes used but could become slow; consider optimizations if performance is critical.
Integer overflow when calculating total amount in the ATM or intermediate values during withdrawUse appropriate data types (e.g., long) to prevent integer overflow.
Deposit of a single, very large number of banknotesThe deposit method should correctly handle adding a large number of banknotes to the existing counts.