Moises Gamio
Moises Gamio Software Engineer. Comprehensive experience in all phases of the software development lifecycle for several economic sectors.

From O(n²) to O(n): Replace Nested Loops with a HashMap in Java

From O(n²) to O(n): Replace Nested Loops with a HashMap in Java

One endpoint in a B2B order system I worked on returned 1,000 orders, and it was slow. The database wasn’t the problem. Two innocent-looking nested loops were. Here’s how one data structure, the HashMap, turned 2,000,000 comparisons into about 3,000.

A List data structure is an ordered collection of items, where each item has a specific position or index within the list.

A HashMap is a data structure that stores key-value pairs, enabling efficient retrieval of values based on their keys. It uses a hash function to determine the storage location (or “bucket”) of each key-value pair within an underlying array, allowing for fast lookups, insertions, and deletions.

Use Case

We have a RESTful API endpoint to retrieve a list of orders placed by a given buyer.

Swagger UI showing the GET orders-by-buyer endpoint

The response includes different kinds of addresses per order, as you can see in the following schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[
  {
    "orderId": 123456,
    "buyerId": 1234,
    "customerNumberId": 12,
    "supplierId": 1,
    "orderDate": "2025-05-15",
    "orderDeliveryAddress": {
      "company": "string",
      "contact": "string",
      "countryCode": "string"
    },
    "orderInvoiceAddress": {
      "company": "string",
      "contact": "string",
      "countryCode": "string"
    },
    "desiredDeliveryDate": "2025-05-16",
    "orderPositions": [
      {
        "orderPositionNumber": 1,
        "orderPositionArticle": {
          "articleId": 4531
        },
        "orderPositionQuantity": 10
      }
    ]
  }
]

As you know, REST Controllers normally delegate the data retrieval to backend services.

To build the body of the list of orders, the backend service calls different database functions to retrieve data. One of these calls is to recover all historical addresses per orderId.

1
2
3
4
5
6
7
8
9
10
11
12
public List<Order> listOrders(Integer buyerId) {

  List<Order> listOfOrders = getOrdersByBuyerId(buyerId);
  
  //code omitted for brevity
  
  //retrieve headers and enrich listOfOrders
  //retrieve addresses and enrich listOfOrders
  //retrieve positions and enrich listOfOrders
  
  return listOfOrders;
}

If the user requests the first 1000 orders, the backend makes only one database function call to retrieve 2000 records (2 different addresses per orderId).

1
2
3
4
{
  //retrieve addresses
  Address[] listOfAddresses = getAddresses(order1, order2, ... order1000);
}

Here is the Object model to represent an Address:

1
2
3
4
5
6
7
8
public class Address   {
  private Integer orderId;
  private Integer addressId;
  private Integer type;
  private String company;
  private String contact;
  private String countryCode;
}

The problem comes when we need to include two loops nested in the code to assign the addresses to every order.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//retrieve addresses and enrich listOfOrders
for (Order order : listOfOrders) {
  for (Address address : listOfAddresses) {
    if (order.getOrderId().equals(address.getOrderId())) {
      switch(TYPEOFADDRESS.valueOf(address.getType())) {
        case DELIVERY:
        {
          //assign address to order
          break;
        }
        case INVOICE:
        {
          //assign address to order
          break;
        }
        default:
        {
          //assign null to order
          break;
        }
      } 
    }
  }
}

That means that the performance according to Big O Notation is N x N = O(N2).

This is exactly the kind of design decision that separates code that works from code that scales. Real-world cases like this one, with diagrams and explanations, fill Software Design Principles: A Practical Guide:

codersite


HashMap to the rescue

We need a fast lookup to iterate exactly the two addresses per order in sequential mode.

A HashMap allows us to associate values (the two addresses) with a unique key (orderId). So here is our new data structure:

1
HashMap<Integer, List<Address>> hashMapOfAddressesByOrderId = new HashMap<>();

We implement an algorithm to transform a List into a HashMap.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private HashMap<Integer, List<Address>> transformListToHashMap(Address[] listOfAddresses) {

  HashMap<Integer, List<Address>> hashMapOfAddressesByOrderId = new HashMap<>();
		
  for (Address address : listOfAddresses) {
    List<Address> listOfAddressesInHashMap = hashMapOfAddressesByOrderId.get(address.getOrderId());
    if (listOfAddressesInHashMap == null) {
        listOfAddressesInHashMap = new ArrayList<>();
        listOfAddressesInHashMap.add(address);
    } else {
        listOfAddressesInHashMap.add(address);
    }
    hashMapOfAddressesByOrderId.put(address.getOrderId(), listOfAddressesInHashMap);
  }

  return hashMapOfAddressesByOrderId;
}

Here is our new iteration through the new data structures.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//retrieve addresses and enrich listOfOrders

HashMap<Integer, List<Address>> hashMapOfAddressesByOrderId = transformListToHashMap(listOfAddresses);

for (Order order : listOfOrders) {

  List<Address> listOfAddressesInHashMap =
      hashMapOfAddressesByOrderId.getOrDefault(order.getOrderId(), List.of());
  
  for (Address address : listOfAddressesInHashMap) {

    switch(TYPEOFADDRESS.valueOf(address.getType())) {
      case DELIVERY:
      {
        //assign address to order
        break;
      }
      case INVOICE:
      {
        //assign address to order
        break;
      }
      default:
      {
        //assign null to order
        break;
      }
    } 
  }
}

getOrDefault returns an empty list for an order without addresses, so the loop simply doesn’t run instead of throwing a NullPointerException.

The Java 8 One-Liner

1
2
3
Map<Integer, List<Address>> addressesByOrderId =
    Arrays.stream(listOfAddresses)
          .collect(Collectors.groupingBy(Address::getOrderId));

Same result, same O(N) cost. The loop above shows what’s happening under the hood, and groupingBy is what you’d write in production.

Performance Comparison

Approach Operations for 1,000 orders and 2,000 addresses Big O
Nested loops 1,000 × 2,000 = 2,000,000 comparisons O(N × M)
HashMap 2,000 inserts + 1,000 lookups ≈ 3,000 steps O(N + M)


The new performance according to Big O Notation is linear: O(N).

The main idea in Analysis of Algorithms is always to improve the algorithm performance, by reducing the number of steps and comparisons. – codersite.dev

When to use each

Use a List when:

  • You care about the order of elements.

  • You need to store duplicates.

  • You iterate over elements in a sequential manner.

Use a HashMap when:

  • You need to associate values with unique keys.

  • You need fast lookup, insertion, or deletion by key.

  • The data is best represented in a key-value format.

Replacing a nested loop with a HashMap is one of the most common optimizations interviewers look for. If you have interviews coming up, practice it on real questions:

java interview

Please support me as a writer. Every contribution helps, and your donation can help add more articles to this website, no matter how small. Thank you!

☕ Buy me a coffee