Clean Markup

Finding the Kth Smallest Element in Linear Time with Quickselect in JavaScript

Finding the Kth Smallest Element in Linear Time with Quickselect in JavaScript

When analyzing client-side performance metrics, interactive dashboard distributions, or user scoreboards, developers frequently need order statistics such as the 50th percentile median or the top ten values. A common pattern is to invoke array.slice().sort((a, b) => a - b)[k]. While simple, full array sorting executes in $O(n \log n)$ time, introducing unnecessary computational overhead when only a single boundary value is needed.

The Quickselect algorithm provides an optimal alternative. By adapting the partitioning routine from Quicksort, Quickselect isolates the $k$-th smallest element by only recursing or looping into the partition containing the target index. Discarding the irrelevant partition on each iteration yields an expected linear time complexity of $O(n)$, drastically reducing CPU work on large datasets.

Example

import { quickselect } from './quickselect.js';

// Network latency measurements in milliseconds
const latencies = [142, 38, 895, 210, 56, 74, 312, 18, 490];

// Find the 0-indexed median (5th smallest element, index 4)
const medianLatency = quickselect([...latencies], 4);
console.log(`Median latency: ${medianLatency}ms`); // 142ms

// Find the minimum value (k = 0) without a full sort
const fastestResponse = quickselect([...latencies], 0);
console.log(`Fastest response: ${fastestResponse}ms`); // 18ms

Implementation

/**
 * Swaps two elements in an array in place.
 * @param {Array<number>} arr
 * @param {number} i
 * @param {number} j
 */
function swap(arr, i, j) {
  const temp = arr[i];
  arr[i] = arr[j];
  arr[j] = temp;
}

/**
 * Partitions a subarray around a pivot chosen using median-of-three.
 * All elements smaller than the pivot are moved to its left, and larger to its right.
 * @param {Array<number>} arr
 * @param {number} left
 * @param {number} right
 * @returns {number} The final index position of the pivot.
 */
function partition(arr, left, right) {
  // Choose pivot using median-of-three to mitigate worst-case sorted scenarios
  const mid = (left + right) >> 1;
  if (arr[mid] < arr[left]) swap(arr, left, mid);
  if (arr[right] < arr[left]) swap(arr, left, right);
  if (arr[right] < arr[mid]) swap(arr, mid, right);

  const pivotValue = arr[mid];
  // Place pivot at right - 1 boundary
  swap(arr, mid, right - 1);
  let i = left;
  let j = right - 1;

  while (true) {
    while (arr[++i] < pivotValue);
    while (arr[--j] > pivotValue);
    if (i >= j) break;
    swap(arr, i, j);
  }

  // Restore pivot to its partitioned position
  swap(arr, i, right - 1);
  return i;
}

/**
 * Finds the kth smallest element (0-indexed) in an array in expected O(n) time.
 * Mutates the input array slice during partitioning.
 *
 * @param {Array<number>} arr - Array of numerical values.
 * @param {number} k - Zero-based target rank (0 <= k < arr.length).
 * @returns {number} The kth smallest value.
 */
export function quickselect(arr, k) {
  if (k < 0 || k >= arr.length) {
    throw new RangeError('Target rank k must fall within array bounds');
  }

  let left = 0;
  let right = arr.length - 1;

  while (left < right) {
    // For small partitions, insertion sort is faster than recursive pivots
    if (right - left < 10) {
      for (let i = left + 1; i <= right; i++) {
        const val = arr[i];
        let j = i - 1;
        while (j >= left && arr[j] > val) {
          arr[j + 1] = arr[j];
          j--;
        }
        arr[j + 1] = val;
      }
      return arr[k];
    }

    const pivotIndex = partition(arr, left, right);

    if (k === pivotIndex) {
      return arr[k];
    } else if (k < pivotIndex) {
      right = pivotIndex - 1;
    } else {
      left = pivotIndex + 1;
    }
  }

  return arr[left];
}

Main Point: Use Quickselect instead of full array sorting to compute medians and order statistics in expected linear time without allocating auxiliary memory.

Credits

Photo by Federico Di Dio on Unsplash

← Home