4

I have an array with a large number of elements, and I need to find the k largest elements.

For an idea of scale, let us assume an integer array of length 10,000,000, and k is 1,000.

I see three potential solutions:

  1. This answers to this question on Stack Overflow suggest the selection algorithm to find the kth largest integer, Then perform a partition to find all elements larger than that value.

  2. A Min Heap with a max size of 1000 could also be used. If the heap is full when you attempt to insert you remove the min and add your new element. Doing this for one element would be average O of 1 for the insert and O of log(n) for the remove (if necessary). So I guess worst case would be something like O(n log k). What is the average case?

  3. I could use a selection sort algorithm to find the max k times and put it on the right of the array. This solution seems like it would have a worst case of O(n*k).

Which of these solutions would you expect to perform best on my data set?

3
  • 1
    Are you looking for an algorithm? Information on a min heap? While I understand what you are trying to do, I am not quite sure which aspect of the task you need help with.
    – user22815
    Commented Dec 22, 2016 at 21:59
  • @snowman I'm trying to figure out which one of the three algorithms I mentioned is best and why? Commented Dec 22, 2016 at 22:49
  • 2
    The first has O(n^2), second O(n log 1000), third O(n*1000). So what is the min of those with a given n?
    – user188153
    Commented Dec 22, 2016 at 23:54

3 Answers 3

6

There are a few questions to ask when dealing with large quantities of data (I leave "large" intentionally vague).

  • Can all of the data fit into memory at once?

  • Is the amount of data known up front? Or could more come in while the algorithm is processing?

  • Is it feasible to sort the data before analyzing it?

Most college-level exercises tend to deal with small amounts of data, so these questions do not matter. However, consider Google: they have massive amounts of data, and loading the entire search index into memory and sorting it is nowhere near feasible.

Even when the amount of data could fit into memory, sometimes the best solutions are ones that work in the larger case. For example, you may not need to sort this array to find the largest elements. From a theoretical perspective Big-O notation is great: however, in the real world, sometimes the fact that O(n + n log n) simplifies to O(n log n) is not a big reassurance. It could translate into minutes or hours of extra run time.


Big-O analysis:

  1. While the link you posted claims it to be O(n), as does Wikipedia, I disagree. Big-O is about worst-case complexity, which is actually O(n2). While the average-case is n (there is no letter for that), the worst-case is a huge time killer.

  2. The array is iterated once, which is O(n). Modifying the binary heap is O(log2 k), but is performed (worst case) n times. This makes the whole thing O(n + n log2k) = O(n log2k). Also keep in mind that k is small compared to n, and log2k is even smaller.

  3. Partial selection sort in this instance is O(kn).

The first option may in theory perform well most of the time, with certain data sets performing poorly.

The second option will perform very well all of the time. In the worst case, it is much better than the first algorithm. The first algorithm may outperform this one some of the time, however.

The third option is pretty much constant for a given n: the contents of the array do not matter, only its size.

I would lean toward the binary heap myself, but it might be worth analyzing whether the data would give the Quickselect algorithm a difficult time and perhaps implement both and measure their real-world speed.

1
  • 1
    A huge advantage of the min-heap approach is that you only need a single pass through the data. If the data is being read from a file, database, or network stream, rather than stored in memory, this could potentially be a huge factor in performance. Even if it is in memory, the cache performance of a single pass with a small-ish heap could be significant. Commented Dec 24, 2016 at 21:11
0

None of the ones you mentioned.

Use Quicksort, but only sort partitions recursively that contain one of the k largest elements. With your numbers, you split 10,000,000 elements into roughly two times 5 million, then the second 5 million into two times 2.5 million, and so on, until 1000 elements are left.

Takes about 2n + k log k operations if you want the k largest items in sorted order, and 2n in unsorted order. That is O(n) as long as k = O (n / log n).

To make it substantially faster (by a large constant factor): Pick a value m in a clever way, then pick m random array elements, and use the largest as the pivot. With the numbers here you might use m = 1000. Now the first partitioning will only move about one in thousand items to the other side of the pivot, doing a lot less work, having better branch prediction, optimising the code for the most common case that an item is not moved. So the first partitioning will be much faster, and then we are left with a partition of size 1000.

Of course we might be unlucky and the largest of 1000 values is within the largest 1000. So we determine the average time depending on m, and use the best m. A smaller m will make the partitioning slower and less effective but reduces the probability that the work done is in vain.

0

None of the ones you mentioned.

Use modified min-heap, of size k.

Sketch:

  • Any correct algorithm will necessarily examine every element, with O(n) cost.
  • k ≪ n
  • Typically the heap will be full, containing k entries.
  • Inserts are cheap, O(1) time, but they're paired with removes that have O(log k) cost.
  • During most of the scan, we can almost always avoid those costs.

Procedure:

  1. At all times let threshold be the minimum heap value; we read it without removing it.
  2. Read k elements to populate the heap. Now it is full.
  3. Scan over each remaining element e.
  • 3.1. If e <= threshold then loop back to (3.) to keep scanning.
  • 3.2. Do heap.remove_min().
  • 3.3. Do heap.insert(e)
  • 3.4. Loop back to (3.) to continue scanning.

The threshold will ratchet up, up, ever more slowly but always upward.

At the end the heap contains the k largest values, in sorted order.

Worst case is O(n + n log k), meaning O(n log k), when input is already sorted. In which case prefer to scan in reverse direction, for cost of O(n). Making an initial sample of k random locations is also a valid approach for filling the heap, which is robust against adversarial input patterns.

For a random permutation of distinct values the cost is O(n + m log k), where m relates to the distribution of input values (slow ratcheting behavior) and we expect that m ≪ n.

The key here is our threshold comparison has constant cost, unrelated to magnitude of k. And for each given element it nearly always says that we needn't bother with heap operations for it.


Here is a graphical depiction of performance. Number of heap push() operations looks logarithmic in n, to me.

push operations needed for a problem of size (n, k)

Here is x="ratio", on a log-log scale. Notice that for each value of k we run through four decades of n, 1e4 up to 1e7.

push operations, by ratio of n to k

from array import array
from collections.abc import Generator
from dataclasses import asdict, dataclass
from heapq import heapify, heappop, heappushpop
from pathlib import Path
from random import shuffle
from time import time
from typing import Any

from matplotlib import pyplot as plt
from seaborn.palettes import SEABORN_PALETTES as palette
from tqdm import tqdm
import pandas as pd
import seaborn as sns


def ratchet(ranks: array[int]) -> Generator[dict[str, int], None, None]:
    """Demonstrates the speed at which we slowly ratchet up a min bound."""
    bound = ranks[0]
    prev = bound
    for i, rank in enumerate(ranks):
        if bound < rank:
            bound = rank
            yield {"idx": i, "bound": bound, "delta": bound - prev}
            prev = bound


def _get_xs(n: int) -> array[int]:
    xs = array("L", range(n))
    shuffle(xs)
    return xs


@dataclass
class Result:
    pushes: int
    elapsed: float


def experiment(n: int, k: int) -> Result:
    xs = list(_get_xs(n))
    t0 = time()
    h, xs = xs[:k], xs[k:]
    heapify(h)
    i = 0
    for x in xs:
        if x > h[0]:
            heappushpop(h, x)
            i += 1

    elapsed = round(time() - t0, 6)

    for x in range(n - k, n):
        assert x == heappop(h)

    return Result(i, elapsed)


def run_experiments() -> Generator[dict[str, float], None, None]:
    """Sweeps through a few decades of problem sizes."""
    for trial in tqdm(range(12)):
        n = 10_000
        while n <= 10_000_000:
            for k in [1, 10, 100, 1_000]:
                param = {"n": n, "k": k}
                result = experiment(**param)
                yield {**param, **asdict(result)}
            n *= 10


def main(csv: Path = Path("/tmp/heap.csv")) -> None:
    ratchet_df = pd.DataFrame(ratchet(_get_xs(int(1e7))))
    no_scientific_notation: dict[str, Any] = {"disable_numparse": True}
    print(ratchet_df.to_markdown(index=False, **no_scientific_notation))

    if not csv.exists():
        df = pd.DataFrame(run_experiments())
        df.to_csv(csv, index=False)
    df = pd.read_csv(csv)
    df["ratio"] = df.n / df.k
    print(df)
    sns.catplot(data=df, x="n", y="pushes", hue="k", palette=palette["bright"])
    # plt.gca().set(xscale="log", yscale="log")
    plt.show()


if __name__ == "__main__":
    main()
3
  • Just make sure k is not too large. Since the values in the minheap are initially not very large, I think you will have k log (n/k) insertions into the heap, taking O(log k) time, for a total of O(k log k log (n/k)) operations, and you want k small enough so this remains O(n). With n = 10,000,000 and k = 1,000 it’s no problem.
    – gnasher729
    Commented Jul 14 at 13:51
  • @gnasher729, I invite you to play around with it. I agree that size (1e7, 1e3) is no problem. // For any random distribution (Gaussian, exponential, uniform, ...), it's always the same problem as long as we have distinct values. So it's enough to talk about the rank number of each observation, which the code above does. And then if a few values turn out to be non-unique, no big deal. If there's many collisions (e.g. boolean or enum values) then we're better off with counters and computing a histogram).
    – J_H
    Commented Jul 14 at 20:01
  • 1
    K = 100,000: If your minheap contains the 100,000 largest item so far, and you examine item m, its position in the array is anywhere from 1 to m. The largest 100,000 items belong into the min_heap, the probability that item #m belongs there is k / m. The number of insertions into the minheap is about the integral of k/m for m from k to n, that’s k * log (n/k) insertions. The code is absolutely correct, but at some point if k is large enough it doesn’t run in O(n) anymore.
    – gnasher729
    Commented Jul 15 at 20:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.