Start learning free

LESSON 278 OF 414 · BINARY SEARCH

Two Sum in Rust: O(n log n) with Sorting and Binary Search

Practice O(n log n) Read the full explanation

Given an array of integers, return indices of the two numbers such that they add up to a specific target.

You may assume that each input will have at most one solution, and you may not use the same index twice.

In case no solution exists, return [-1, -1]

Example:

Input: nums = [2, 7, 11, 15], target = 9
Output: [0, 1]
Explanation: nums[0] + nums[1] = 2 + 7 = 9

Note:

Your algorithm should run in O(n log n) time and use O(n) extra space.


STEP 1 OF 20
    Ctrl + Enter to check

    RUST · BINARY SEARCH

    Two Sum in Rust, explained

    Problem overview

    The function two_sum receives a vector of integers, nums, and a target. It must return the indices of two different positions whose values add up to target, or [-1, -1] when no such pair exists. There is at most one valid pair, and the same index can't be used twice, although two equal values at different indices are fine.

    In the example, nums = [2, 7, 11, 15] and target = 9. The values at indices 0 and 1 are 2 and 7, which sum to 9, so the answer is [0, 1].

    The statement also sets a budget: O(n log n) time and O(n) extra space. That rules out checking every pair and points toward sorting.

    Approach: sort the pairs, then search for each complement

    The brute-force idea tries every pair of indices, which is O(n²). The faster idea rests on one observation: if a value x is part of the answer, its partner must be exactly target - x, the complement. Looking up a specific value in a sorted array takes O(log n) with binary search, so one search per element gives O(n log n) overall.

    Sorting creates a problem, though: it moves values away from their original positions, and the answer has to use the original indices. The fix is to sort (value, original_index) pairs instead of bare values. Each value carries its index with it, wherever the sort puts it.

    The search for the partner of position i only looks at positions i + 1 and beyond. Every pair of positions has a left member, and the pair is found when the loop reaches it, so looking left would only repeat earlier work. Starting at i + 1 also stops an element from being paired with itself.

    Here is the idea on nums = [3, 9, -2, 5] with target = 3. The sorted pairs are [(-2, 2), (3, 0), (5, 3), (9, 1)]. At position 0 the value is -2, so the search looks for 5 among positions 1 to 3. The midpoint, position 2, holds 5, so the function returns the original indices of positions 0 and 2: [2, 3].

    Two Sum can be solved in other ways too. A hash map of the values seen so far reaches O(n) time, and a two-pointer scan over the same sorted pairs runs in O(n log n) because of the sort. This lesson practises binary search, so it searches for each complement directly.

    Step by step

    1. Pair each value with its index. The value goes first in the tuple so that sorting orders the pairs by value.
    2. Sort the pairs. After this, the values are in ascending order, which is what binary search needs.
    3. Record the length as n, a usize, for the loop and the search bounds.
    4. Loop over the positions and stop as soon as there is no position to the right of i. The last element has no partner left to search, so the loop ends there instead of searching an empty range.
    5. Compute the complement in i64 and skip this position if it falls outside the i32 range. No element can hold such a value, so there is nothing to search for.
    6. Search positions i + 1 to n - 1 with the helper, which returns a position in the sorted pairs or -1.
    7. On a hit, return the original indices stored in the second field of both pairs, not the sorted positions.
    8. Fall back to [-1, -1] when the loop finishes without a match.
    9. Write the helper's signature. It borrows the pairs as a slice and takes the inclusive range to search and the value to find.
    10. Convert both bounds to i64. The upper bound can drop below the lower one, and in general below zero, which a usize can't represent.
    11. Loop while the range is non-empty, that is, while lo <= hi.
    12. Take the midpoint as lo + (hi - lo) / 2 and convert it to usize for indexing.
    13. Compare and halve the range. Return the midpoint on a match; otherwise discard the half that can't contain the value.
    14. Return -1 once the range is empty.

    The solution in Rust

    impl Solution {
        fn binary_search_idx(sorted_nums: &[(i32, i32)], first: usize, last: usize, value: i32) -> i32 {
            let mut lo = first as i64;
            let mut hi = last as i64;
            while lo <= hi {
                let mid = lo + (hi - lo) / 2;
                let mid_u = mid as usize;
                if sorted_nums[mid_u].0 == value {
                    return mid as i32;
                } else if sorted_nums[mid_u].0 < value {
                    lo = mid + 1;
                } else {
                    hi = mid - 1;
                }
            }
            -1
        }
    
        fn two_sum(nums: Vec<i32>, target: i32) -> Vec<i32> {
            let mut sorted_nums: Vec<(i32, i32)> = nums
                .iter()
                .enumerate()
                .map(|(i, &v)| (v, i as i32))
                .collect();
            sorted_nums.sort_unstable();
            let n = sorted_nums.len();
            for i in 0..n {
                if i + 1 >= n {
                    break;
                }
                let need = (target as i64) - (sorted_nums[i].0 as i64);
                if need < i32::MIN as i64 || need > i32::MAX as i64 {
                    continue;
                }
                let j = Self::binary_search_idx(&sorted_nums, i + 1, n - 1, need as i32);
                if j != -1 {
                    return vec![sorted_nums[i].1, sorted_nums[j as usize].1];
                }
            }
            vec![-1, -1]
        }
    }
    

    The Rust details worth a closer look:

    • The iterator chain. nums.iter() yields &i32 references and enumerate() wraps each in a (usize, &i32) tuple. The closure pattern |(i, &v)| destructures that tuple and copies the value out of the reference. The type annotation on sorted_nums tells collect() to build a Vec<(i32, i32)>.
    • Sorting tuples. Tuples compare field by field, so the value decides the order and the index breaks ties. Since every index is unique, no two pairs are equal, and an unstable sort produces the same result a stable one would. sort_unstable also sorts in place without allocating.
    • as never panics. Casting between integer types silently truncates: 3_000_000_000_i64 as i32 is -1294967296. That is why the range check comes before need as i32.
    • usize underflow does panic. In a debug build, subtracting 1 from a usize that holds 0 stops the program with an overflow panic. The helper therefore works with i64 bounds, and two_sum uses the break guard instead of a loop over 0..n - 1, which would underflow for an empty input.
    • Slices and associated functions. The helper takes no self, so it is called as Self::binary_search_idx. Passing &sorted_nums, a &Vec<(i32, i32)>, works because Rust coerces it to the slice type &[(i32, i32)].
    • Final expressions. -1 and vec![-1, -1] have no semicolon, so they are the values the functions return when no earlier return fires.

    Complexity

    Time: O(n log n). Building the pairs is O(n) and sorting them is O(n log n). The loop runs at most n - 1 binary searches, each halving its range until it is empty, so each costs O(log n). The sort and the searches both stay within O(n log n).

    Space: O(n). The vector of pairs holds one tuple per element. The sort works in place, and the helper uses a few integers.

    Edge cases and common mistakes

    • Fewer than two elements. For an empty vector the loop never runs, and for a single element it breaks immediately. Both return [-1, -1] without panicking.
    • Equal values. [3, 3] with target 6 returns [0, 1]: the two 3s are different pairs, and the search for the second one starts at position 1.
    • Pairing an element with itself. [3, 4] with target 6 must return [-1, -1]. Searching from position 0 instead of i + 1 would find the same 3 and answer [0, 0].
    • Overflow. With target = -2_000_000_000 and an element equal to 2_000_000_000, subtracting in i32 overflows, which panics in a debug build. In i64 the complement, -4,000,000,000, is computed exactly and skipped.
    • Returning sorted positions. i and j are positions in the sorted vector. The answer comes from the .1 fields.
    • Using lo < hi. When the search range is a single element, lo equals hi. With a strict comparison that element is never checked, so [2, 7] with target 9 would wrongly return [-1, -1].
    • Index order. The index of the smaller value always comes first. For [7, 2] and target 9 the function returns [1, 0].

    Testing

    • nums = [2, 7, 11, 15], target = 9 gives [0, 1].
    • nums = [3, 9, -2, 5], target = 3 gives [2, 3].
    • nums = [-3, 4, 3, 90], target = 0 gives [0, 2].
    • nums = [1, 2, 3], target = 100 gives [-1, -1].
    • nums = [], target = 5 gives [-1, -1].
    Keep going with RustAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free