Facebook Pixel

3711. Maximum Transactions Without Negative Balance 🔒

MediumGreedyArrayHeap (Priority Queue)
LeetCode ↗

Problem Description

You are given an integer array transactions, where transactions[i] represents the amount of the i-th transaction:

  • A positive value means money is received (the balance increases).
  • A negative value means money is sent (the balance decreases).

The account starts with a balance of 0, and the balance must never become negative at any point. Transactions must be processed in the given order, but you are allowed to skip some of them.

Return the maximum number of transactions that can be performed such that the balance never goes below zero.

In other words, you need to pick the largest possible subset of transactions (keeping their original order) so that, when applied one by one starting from a balance of 0, the running balance always stays at 0 or above.

For example, if transactions = [5, -3, -4, 2]:

  • Performing all four transactions gives running balances 5, 2, -2, 0 — the balance becomes negative after the third transaction, so this is not allowed.
  • Skipping -4 gives running balances 5, 2, 4 — the balance never goes negative, so 3 transactions can be performed.

The answer in this case would be 3.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

How We Pick the Algorithm

Why Greedy Algorithms?

This problem maps to Greedy Algorithms through a short path in the full flowchart.

Tree orgraph?noMax/minwithgreedyyesGreedyAlgorithms

The regret-greedy strategy of always discarding the most negative transaction when the balance dips below zero guarantees the maximum number of transactions.

Open in Flowchart

Intuition

The first observation is that positive transactions are always good — receiving money never hurts the balance, so there is no reason to ever skip a positive amount. The only problem comes from negative transactions, which can push the balance below zero.

So the real question becomes: which negative transactions should we skip?

A natural greedy idea emerges: try to take every transaction optimistically. As we process them one by one, keep a running balance s. As long as s stays non-negative, everything is fine. But the moment s drops below zero, we know we have taken too many "bad" transactions — we must undo (skip) at least one of the transactions we've taken so far.

Now, which one should we undo? To fix a negative balance, we want the biggest recovery per removal. Removing a transaction with amount y changes the balance by -y, so removing the smallest (most negative) amount gives the largest boost to the balance. By always discarding the minimum element among the transactions chosen so far, we restore the balance to non-negative while sacrificing the fewest transactions.

This is a classic "regret greedy" pattern: commit to every choice eagerly, and when a constraint is violated, retract the single worst choice made so far. Note that it never matters which specific transaction we drop in terms of ordering — dropping any past transaction just removes its amount from the running sum, and the relative order of the remaining ones is preserved. So only the value matters, and the smallest value is always the best candidate to drop.

To efficiently find and remove the minimum among chosen transactions, we keep them in an ordered structure (a SortedList in Python, or equivalently a min-heap / multiset):

  • Add each new transaction x to the structure and to the balance s.
  • While s < 0, pop the smallest element y, do s -= y, and reduce the answer count by 1.

Since each transaction is added once and removed at most once, the greedy runs in O(n log n) time, and the final count of kept transactions is guaranteed to be the maximum possible.

Pattern Learn more about Greedy and Heap (Priority Queue) patterns.

Solution Approach

The algorithm follows the Greedy + Ordered Set pattern described in the reference solution. We need a data structure that supports two operations efficiently:

  1. Insert a new transaction amount.
  2. Find and remove the smallest amount currently selected.

Python's SortedList (from the sortedcontainers library) handles both in O(log n) time. A min-heap would also work, since we only ever need the minimum.

Step-by-step walkthrough

Initialization

  • st = SortedList() — the ordered set holding all currently selected transaction amounts.
  • s = 0 — the running balance of the selected transactions.
  • ans = len(transactions) — we optimistically assume all transactions can be kept, and decrement this counter each time we are forced to drop one.

Main loop — for each transaction amount x:

  1. Take it greedily: add x to the balance with s += x, and insert x into the ordered set with st.add(x).

  2. Repair if broken: if the balance is now negative (s < 0), we have over-committed. While s < 0:

    • Pop the smallest selected amount: y = st.pop(0). Since s is negative, this smallest element is guaranteed to be negative (a set of only non-negative amounts can never sum below zero).
    • Undo its effect on the balance: s -= y. Because y < 0, this strictly increases s by |y|, the largest possible single-removal gain.
    • One fewer transaction is kept: ans -= 1.

    The loop repeats until s >= 0, ensuring the balance constraint holds after every step.

Result: after processing all transactions, ans is the maximum number of transactions that can be performed.

Why one pass suffices

A key invariant: after processing each transaction, s is the sum of the kept transactions and s >= 0. When a future deficit occurs, removing the global minimum of the kept set is always optimal — any other removal recovers less balance, so it can never allow strictly more transactions to survive later. This exchange argument justifies the greedy choice.

Code

class Solution:
    def maxTransactions(self, transactions: List[int]) -> int:
        st = SortedList()
        s = 0
        ans = len(transactions)
        for x in transactions:
            s += x
            st.add(x)
            while s < 0:
                y = st.pop(0)   # remove the smallest (most negative) amount
                s -= y          # balance increases by |y|
                ans -= 1        # one fewer transaction kept
        return ans

Complexity Analysis

  • Time: O(n log n) — each of the n transactions is inserted into the SortedList once (O(log n)) and removed at most once (O(log n) with pop(0) on a SortedList). The inner while loop's total work across the entire run is bounded by the number of removals, which is at most n.
  • Space: O(n) — the ordered set can hold up to n amounts.

A trace example

For transactions = [3, -5, 4, -2]:

StepxAfter adding: s, stRepairans
13s = 3, st = [3]none4
2-5s = -2, st = [-5, 3]pop -5 → s = 3, st = [3]3
34s = 7, st = [3, 4]none3
4-2s = 5, st = [-2, 3, 4]none3

The answer is 3: keep [3, 4, -2] with running balances 3, 7, 5, all non-negative.

Example Walkthrough

Let's trace the algorithm on transactions = [3, -4, 2, -3, 5, -7].

Setup: st = [] (ordered set of kept amounts), s = 0 (running balance), ans = 6 (optimistically assume all six transactions are kept).

Step 1 — x = 3: Add it: s = 3, st = [3]. Balance is non-negative, no repair needed. ans = 6.

Step 2 — x = -4: Add it: s = -1, st = [-4, 3]. The balance is negative, so we repair: pop the smallest element y = -4, giving s = -1 - (-4) = 3 and st = [3]. One transaction is dropped: ans = 5. Effectively, we decided to skip the -4.

Step 3 — x = 2: Add it: s = 5, st = [2, 3]. No repair. ans = 5.

Step 4 — x = -3: Add it: s = 2, st = [-3, 2, 3]. The balance stays non-negative, so this negative transaction is kept — negatives aren't automatically bad, only the ones the balance can't absorb. ans = 5.

Step 5 — x = 5: Add it: s = 7, st = [-3, 2, 3, 5]. No repair. ans = 5.

Step 6 — x = -7: Add it: s = 0 - 7 + 7 = 0... let's compute carefully: s = 7 + (-7) = 0. Hmm — actually 7 - 7 = 0, which is non-negative, so let's make this step interesting by checking the arithmetic precisely: s = 7 + (-7) = 0 ≥ 0. To showcase a second repair, suppose the last value were -8 instead: Add it: s = 7 + (-8) = -1, st = [-8, -3, 2, 3, 5]. Repair: pop the smallest, y = -8 (note the structure compares -8 vs -3 and correctly picks the more negative one — the biggest recovery per removal). Now s = -1 - (-8) = 7, ans = 4.

Summary table for transactions = [3, -4, 2, -3, 5, -8]:

StepxAfter adding: s, stRepairans
13s = 3, st = [3]none6
2-4s = -1, st = [-4, 3]pop -4 → s = 35
32s = 5, st = [2, 3]none5
4-3s = 2, st = [-3, 2, 3]none5
55s = 7, st = [-3, 2, 3, 5]none5
6-8s = -1, st = [-8, -3, 2, 3, 5]pop -8 → s = 74

Result: ans = 4. The kept transactions are [3, 2, -3, 5], with running balances 3, 5, 2, 7 — never negative.

Verification of optimality: Could we keep 5 transactions? Both -4 and -8 are problematic: keeping -4 after only 3 yields balance -1, and keeping -8 would require the prior balance to reach at least 8, but even keeping everything else (3 + 2 - 3 + 5 = 7) falls short — and dropping -3 to keep -8 gives 3 + 2 + 5 = 10 → 2, which still only totals 4 kept transactions. So 4 is indeed the maximum, matching the greedy's answer.

The walkthrough highlights the two key behaviors of the regret-greedy: negative transactions are kept whenever the balance can absorb them (step 4), and whenever the balance breaks, exactly the most negative committed amount is retracted (steps 2 and 6).

Solution Implementation

1from typing import List
2from sortedcontainers import SortedList
3
4
5class Solution:
6    def maxTransactions(self, transactions: List[int]) -> int:
7        # A sorted container to keep track of all transactions
8        # accepted so far, ordered from smallest to largest.
9        sorted_transactions = SortedList()
10
11        # Running balance after applying the accepted transactions.
12        balance = 0
13
14        # Start by assuming every transaction can be kept.
15        max_count = len(transactions)
16
17        for amount in transactions:
18            # Tentatively accept the current transaction.
19            balance += amount
20            sorted_transactions.add(amount)
21
22            # If the balance turns negative, greedily drop the
23            # smallest (most negative) transaction(s) until the
24            # balance becomes non-negative again. Removing the
25            # smallest value restores the balance the fastest
26            # while sacrificing the fewest transactions.
27            while balance < 0:
28                smallest = sorted_transactions.pop(0)
29                balance -= smallest
30                max_count -= 1
31
32        # The remaining count is the maximum number of
33        # transactions that can be kept with a valid balance.
34        return max_count
35```
36
37**Explanation of the approach:**
38
391. **Greedy strategy** — Process transactions in order, tentatively accepting each one while tracking the running balance.
402. **Conflict resolution** — Whenever the balance dips below zero, the smallest (most negative) accepted transaction is discarded. This restores the balance as quickly as possible while minimizing the number of removals.
413. **Data structure choice** — `SortedList` provides `O(log n)` insertion and efficient access to the minimum element, giving an overall time complexity of `O(n log n)`.
42
43**Alternative perspective:** A min-heap (`heapq`) could replace `SortedList` here, since only the smallest element is ever removed. That would achieve the same `O(n log n)` complexity using only the standard library:
44
45```python3
46import heapq
47from typing import List
48
49
50class Solution:
51    def maxTransactions(self, transactions: List[int]) -> int:
52        min_heap = []
53        balance = 0
54        max_count = len(transactions)
55
56        for amount in transactions:
57            balance += amount
58            heapq.heappush(min_heap, amount)
59
60            # Drop the most negative transaction while balance < 0.
61            while balance < 0:
62                balance -= heapq.heappop(min_heap)
63                max_count -= 1
64
65        return max_count
66
1class Solution {
2    public int maxTransactions(int[] transactions) {
3        // TreeMap to keep track of all accepted transactions,
4        // mapping transaction value -> its occurrence count.
5        // The keys are sorted in ascending order, so the smallest
6        // (most negative) transaction is always at the front.
7        TreeMap<Integer, Integer> countMap = new TreeMap<>();
8
9        // Start by assuming every transaction can be kept.
10        int maxCount = transactions.length;
11
12        // Running sum (balance) of all currently accepted transactions.
13        long runningSum = 0;
14
15        for (int amount : transactions) {
16            // Tentatively accept the current transaction.
17            runningSum += amount;
18            countMap.merge(amount, 1, Integer::sum);
19
20            // If the balance goes negative, greedily discard the
21            // smallest (most negative) transactions until the
22            // balance becomes non-negative again.
23            while (runningSum < 0) {
24                int smallest = countMap.firstKey();
25
26                // Removing the smallest value increases the balance,
27                // since subtracting a negative number adds to the sum.
28                runningSum -= smallest;
29
30                // One fewer transaction is kept.
31                --maxCount;
32
33                // Decrease its count; remove the entry entirely
34                // when no occurrences remain.
35                if (countMap.merge(smallest, -1, Integer::sum) == 0) {
36                    countMap.remove(smallest);
37                }
38            }
39        }
40
41        return maxCount;
42    }
43}
44
1class Solution {
2public:
3    int maxTransactions(vector<int>& transactions) {
4        // Min-heap-like container that keeps all currently accepted transactions sorted.
5        // A multiset is used because duplicate transaction values are allowed.
6        multiset<int> acceptedTransactions;
7
8        // Initially assume that every transaction can be kept.
9        int maxCount = transactions.size();
10
11        // Running sum of all currently accepted transactions.
12        // Use long long to avoid potential integer overflow.
13        long long runningSum = 0;
14
15        // Process transactions one by one in their original order.
16        for (int transaction : transactions) {
17            // Tentatively accept the current transaction.
18            runningSum += transaction;
19            acceptedTransactions.insert(transaction);
20
21            // If the running sum becomes negative, the current selection is invalid.
22            // Greedily remove the smallest (most negative) transaction(s)
23            // until the running sum is non-negative again.
24            while (runningSum < 0) {
25                // The smallest element is at the beginning of the multiset.
26                auto smallestIt = acceptedTransactions.begin();
27                int smallestValue = *smallestIt;
28
29                // Remove it from the selection and update the running sum.
30                acceptedTransactions.erase(smallestIt);
31                runningSum -= smallestValue;
32
33                // One fewer transaction can be kept.
34                --maxCount;
35            }
36        }
37
38        // Return the maximum number of transactions that can be kept
39        // while the running sum never stays negative.
40        return maxCount;
41    }
42};
43
1/**
2 * Inserts a value into the min-heap and restores the heap property
3 * by sifting the newly added value up toward the root.
4 */
5function heapPush(heap: number[], value: number): void {
6    heap.push(value);
7    let childIndex = heap.length - 1;
8
9    while (childIndex > 0) {
10        const parentIndex = (childIndex - 1) >> 1;
11
12        // Stop when the parent is already smaller than or equal to the child.
13        if (heap[parentIndex] <= heap[childIndex]) {
14            break;
15        }
16
17        // Swap child with parent and continue sifting up.
18        [heap[parentIndex], heap[childIndex]] = [heap[childIndex], heap[parentIndex]];
19        childIndex = parentIndex;
20    }
21}
22
23/**
24 * Removes and returns the smallest value (the root) from the min-heap,
25 * then restores the heap property by sifting the relocated value down.
26 */
27function heapPop(heap: number[]): number {
28    const smallestValue = heap[0];
29    const lastValue = heap.pop()!;
30
31    if (heap.length > 0) {
32        // Move the last element to the root and sift it down.
33        heap[0] = lastValue;
34        let parentIndex = 0;
35
36        while (true) {
37            const leftIndex = parentIndex * 2 + 1;
38            const rightIndex = leftIndex + 1;
39            let smallestIndex = parentIndex;
40
41            if (leftIndex < heap.length && heap[leftIndex] < heap[smallestIndex]) {
42                smallestIndex = leftIndex;
43            }
44            if (rightIndex < heap.length && heap[rightIndex] < heap[smallestIndex]) {
45                smallestIndex = rightIndex;
46            }
47
48            // Stop when the parent is smaller than both children.
49            if (smallestIndex === parentIndex) {
50                break;
51            }
52
53            // Swap parent with the smaller child and continue sifting down.
54            [heap[parentIndex], heap[smallestIndex]] = [heap[smallestIndex], heap[parentIndex]];
55            parentIndex = smallestIndex;
56        }
57    }
58
59    return smallestValue;
60}
61
62/**
63 * Returns the maximum number of transactions that can be kept
64 * such that the running sum of the kept transactions never stays negative.
65 */
66function maxTransactions(transactions: number[]): number {
67    // Min-heap that keeps all currently accepted transactions,
68    // with the smallest (most negative) value at the root.
69    const acceptedTransactions: number[] = [];
70
71    // Initially assume that every transaction can be kept.
72    let maxCount = transactions.length;
73
74    // Running sum of all currently accepted transactions.
75    let runningSum = 0;
76
77    // Process transactions one by one in their original order.
78    for (const transaction of transactions) {
79        // Tentatively accept the current transaction.
80        runningSum += transaction;
81        heapPush(acceptedTransactions, transaction);
82
83        // If the running sum becomes negative, the current selection is invalid.
84        // Greedily remove the smallest (most negative) transaction(s)
85        // until the running sum is non-negative again.
86        while (runningSum < 0) {
87            const smallestValue = heapPop(acceptedTransactions);
88
89            // Remove it from the selection and update the running sum.
90            runningSum -= smallestValue;
91
92            // One fewer transaction can be kept.
93            --maxCount;
94        }
95    }
96
97    // Maximum number of transactions that can be retained.
98    return maxCount;
99}
100

Time and Space Complexity

The time complexity is O(n × log n), where n is the number of transactions. In each iteration of the main loop, the operation st.add(x) inserts an element into the SortedList, which takes O(log n) time. Inside the while loop, st.pop(0) removes the smallest element, which also takes O(log n) time. Since each element is added to and removed from the SortedList at most once across the entire execution, the total number of add and pop operations is bounded by O(n), yielding an overall time complexity of O(n × log n).

The space complexity is O(n), since the SortedList st may store up to n elements in the worst case.

Pattern Learn more about how to find time and space complexity quickly.

Common Pitfalls

Pitfall: Skipping the current transaction instead of removing the most negative kept transaction

A very natural — but incorrect — instinct is: "if accepting this transaction makes the balance negative, just skip it and move on." This leads to code like the following:

class Solution:
    def maxTransactions(self, transactions: List[int]) -> int:
        balance = 0
        count = 0
        for amount in transactions:
            if balance + amount >= 0:   # only accept if it keeps balance valid
                balance += amount
                count += 1
            # otherwise skip the current transaction
        return count

Why it's wrong: the deficit caused by the current transaction might be better resolved by retroactively dropping an earlier, more negative transaction. Removing the most negative kept amount recovers the maximum possible balance with a single removal, which can allow strictly more future transactions to survive.

Counterexample: transactions = [4, -4, -1, -1, -1]

  • Naive "skip current" approach:

    • Take 4 → balance 4
    • Take -4 → balance 0
    • -1 would make balance -1 → skip
    • -1 → skip, -1 → skip
    • Result: 2 transactions kept ([4, -4]).
  • Correct greedy approach:

    • Take 4, take -4 → balance 0
    • Take -1 → balance -1 → pop the minimum kept value -4 → balance 3
    • Take -1 → balance 2, take -1 → balance 1
    • Result: 4 transactions kept ([4, -1, -1, -1]).

The naive approach treats decisions as final, but the optimal strategy requires the ability to revoke an earlier choice. Once a deficit appears, the kept set should be repaired by ejecting its smallest element — which may be the current transaction itself, or one accepted long ago.

Solution: always tentatively accept the transaction, then repair while the balance is negative by removing the global minimum of the kept set (via a min-heap or SortedList):

import heapq
from typing import List


class Solution:
    def maxTransactions(self, transactions: List[int]) -> int:
        min_heap = []
        balance = 0
        max_count = len(transactions)

        for amount in transactions:
            balance += amount
            heapq.heappush(min_heap, amount)   # accept first, even if it breaks the balance
            while balance < 0:                 # then repair by ejecting the most negative kept value
                balance -= heapq.heappop(min_heap)
                max_count -= 1

        return max_count

Note that the repair loop is safe: whenever balance < 0, the heap's minimum is guaranteed to be negative (a set containing only non-negative values can never sum below zero), so each pop strictly increases the balance and the loop terminates.

Related mistakes to watch for

  • Popping the wrong end of a SortedList: st.pop() with no argument removes the largest element. You must use st.pop(0) to remove the smallest. Popping the largest both fails to fix the deficit quickly and can loop forever or discard valuable positive transactions.
  • Replacing the while with an if: a single removal may not be enough. For example, after several small negatives accumulate, one new transaction can push the balance deep below zero, requiring multiple removals before balance >= 0 again.
  • Negating values for a "max-heap": Python's heapq is already a min-heap, which is exactly what this problem needs. Reflexively negating values (a common max-heap idiom) silently turns the logic into "remove the largest kept transaction," producing wrong answers.

Ready to land your dream job?

Unlock your dream job with a 5-minute quiz for a personalized study roadmap!

Get My Roadmap
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Get a Personalized Study Roadmap:

The three-steps of Depth First Search are:

  1. Identify states;
  2. Draw the state-space tree;
  3. DFS on the state-space tree.

Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More