Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add closest pair of points algorithm #1706

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions Geometry/ClosestPairOfPoints.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* This class implements the Closest Pair of Points algorithm using a divide-and-conquer approach.
* @see {@link https://en.wikipedia.org/wiki/Closest_pair_of_points_problem}
* @class
*/
export default class ClosestPair {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be a class. Instead, it should be simply an exported function which takes an array of points and returns the closest pair. All methods here should be just functions or closures inside that function.

The tests then simplify to testing that single function.

/** @private */
#points

/**
* Creates a Closest Pair instance.
* @constructor
* @param {Array<{x: number, y: number}>} points - An array of points represented as objects with x and y coordinates.
* @throws {Error} Will throw an error if the points array is empty or invalid.
*/
constructor(points) {
this.#validatePoints(points)
this.#points = points
}

/**
* Validates that the input is a non-empty array of points.
* @private
* @param {Array} points - The array of points to validate.
* @throws {Error} Will throw an error if the input is not a valid array of points.
*/
#validatePoints(points) {
if (
!Array.isArray(points) ||
points.length === 0 ||
points.some((p) => isNaN(p.x) || isNaN(p.y)) ||
!points.every((p) => typeof p.x === 'number' && typeof p.y === 'number')
) {
throw new Error(
'points must be a non-empty array of objects with x and y properties.'
)
}
}

/**
* Calculates the distance between two points.
* @private
* @param {{x: number, y: number}} p1 - The first point.
* @param {{x: number, y: number}} p2 - The second point.
* @returns {number} The distance between the two points.
*/
#distance(p1, p2) {
return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)
}

/**
* Finds the closest pair of points in a small set (3 or fewer).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "3 or fewer" is just how this is used. This method supports arbitrarily many points (but of course has quadratic time complexity). I would document this as "to be used with small sets"; there is no strict requirement here.

This "brute force" function could also be reused for testing with random points.

* @private
* @param {Array<{x: number, y: number}>} points - An array of points with size <= 3.
* @returns {{pair: Array<{x: number, y: number}>, distance: number}} The closest pair and their distance.
*/
#bruteForceClosestPair(points) {
let minDist = Infinity
let pair = []

for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const dist = this.#distance(points[i], points[j])
if (dist < minDist) {
minDist = dist
pair = [points[i], points[j]]
}
}
}
return { pair, distance: minDist }
}

/**
* Finds the closest pair of points using divide-and-conquer.
* @private
* @param {Array<{x: number, y: number}>} points - An array of points sorted by x-coordinate.
* @returns {{pair: Array<{x: number, y: number}>, distance: number}} The closest pair and their distance.
*/
#closestPair(points) {
const n = points.length

if (n <= 3) {
return this.#bruteForceClosestPair(points)
}

const mid = Math.floor(n / 2)
const midPoint = points[mid]

// Recursive calls for left and right halves
const leftResult = this.#closestPair(points.slice(0, mid))
const rightResult = this.#closestPair(points.slice(mid))

// Find the overall closest pair
let minResult =
leftResult.distance < rightResult.distance ? leftResult : rightResult

// Create an array for strip points within min distance from midPoint
const strip = this.#getStripPoints(points, midPoint, minResult.distance)

// Check strip for closer pairs
const stripResult = this.#stripClosestPair(strip, minResult.distance)

return stripResult.distance < minResult.distance ? stripResult : minResult
}

/**
* Gets the strip of points within a certain distance from a midpoint.
* @private
* @param {Array<{x: number, y: number}>} points - An array of sorted points.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorted by X coordinate

* @param {{x: number, y: number}} midPoint - The midpoint used for filtering.
* @param {number} minDistance - The minimum distance to filter by.
* @returns {Array<{x: number, y: number}>} The filtered strip of points.
*/
#getStripPoints(points, midPoint, minDistance) {
return points.filter(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't matter for the asymptotic worst case and might make the code a bit harder to follow, but this can be optimized by leveraging the fact that the points are sorted by X and thus doing a binary search to find the bounds.

(point) => Math.abs(point.x - midPoint.x) < minDistance
)
}

/**
* Finds the closest pair in a strip of points sorted by y-coordinate.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is wrong. Since the function sorts by Y coordinate, it is not a requirement that the strip be sorted by Y coordinate. In fact it won't be, because it's sorted by X coordinate.

* @private
* @param {Array<{x: number, y: number}>} strip - An array of strip points sorted by y-coordinate.
* @param {number} minDistance - The minimum distance to check against.
* @returns {{pair: Array<{x: number, y: number}>, distance: number}} The closest pair and their distance from the strip.
*/
#stripClosestPair(strip, minDistance) {
let minDist = minDistance
let pair = []

// Sort by y-coordinate
strip.sort((a, b) => a.y - b.y)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably implies a time complexity of $O(n(\log{n})^2)$ overall, correct?


for (let i = 0; i < strip.length; i++) {
for (
let j = i + 1;
j < strip.length && strip[j].y - strip[i].y < minDist;
j++
) {
const dist = this.#distance(strip[i], strip[j])
if (dist < minDist) {
minDist = dist
pair = [strip[i], strip[j]]
}
}
}

return { pair, distance: minDist }
}

/**
* Finds the closest pair of points in the provided set.
* @public
* @returns {{pair: Array<{x: number, y: number}>, distance: number}} The closest pair and their distance.
*/
findClosestPair() {
const sortedPoints = this.#points.slice().sort((a, b) => a.x - b.x)
return this.#closestPair(sortedPoints)
}
}
120 changes: 120 additions & 0 deletions Geometry/Test/ClosestPairOfPoints.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import ClosestPair from '../ClosestPairOfPoints'

describe('ClosestPair', () => {
describe('Constructor', () => {
test('creates an instance with valid points', () => {
const points = [
{ x: 1, y: 1 },
{ x: 2, y: 2 }
]
const closestPair = new ClosestPair(points)
expect(closestPair).toBeInstanceOf(ClosestPair)
})

test('throws an error if points array is invalid', () => {
expect(() => new ClosestPair([])).toThrow(
'points must be a non-empty array of objects with x and y properties.'
)
expect(() => new ClosestPair([{ x: 0 }])).toThrow(
'points must be a non-empty array of objects with x and y properties.'
)
expect(() => new ClosestPair([{ x: NaN, y: NaN }])).toThrow(
'points must be a non-empty array of objects with x and y properties.'
)
})
})

describe('Finding Closest Pair', () => {
test('finds closest pair correctly', () => {
const points = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 },
{ x: -1, y: -1 },
{ x: -3, y: -4 }
]
const closestPair = new ClosestPair(points)
const result = closestPair.findClosestPair()

// Check that both points are part of the expected closest pair
const expectedPoints = [
{ x: 0, y: 0 },
{ x: 1, y: 1 }
]

expect(result.distance).toBeCloseTo(Math.sqrt(2)) // Distance between (0,0) and (1,1)
expect(expectedPoints).toContainEqual(result.pair[0])
expect(expectedPoints).toContainEqual(result.pair[1])
})

test('handles two points correctly', () => {
const points = [
{ x: 3, y: 4 },
{ x: 5, y: 6 }
]
const closestPair = new ClosestPair(points)
const result = closestPair.findClosestPair()

// Check that both points are part of the expected closest pair
const expectedPoints = [
{ x: 3, y: 4 },
{ x: 5, y: 6 }
]

expect(result.distance).toBeCloseTo(Math.sqrt(8)) // Distance between (3,4) and (5,6)
expect(expectedPoints).toContainEqual(result.pair[0])
expect(expectedPoints).toContainEqual(result.pair[1])
})

test('returns correct result with negative coordinates', () => {
const points = [
{ x: -1, y: -1 },
{ x: -2, y: -2 },
{ x: -3, y: -3 },
{ x: -4, y: -4 }
]
const closestPair = new ClosestPair(points)
const result = closestPair.findClosestPair()

// Check that both points are part of the expected closest pair
const expectedPoints = [
{ x: -1, y: -1 },
{ x: -2, y: -2 }
]

expect(result.distance).toBeCloseTo(Math.sqrt(2)) // Distance between (-1,-1) and (-2,-2)
expect(expectedPoints).toContainEqual(result.pair[0])
expect(expectedPoints).toContainEqual(result.pair[1])
})

test('returns correct result with identical coordinates', () => {
const points = [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0.5, y: 0.5 }
]
const closestPair = new ClosestPair(points)
const result = closestPair.findClosestPair()

// Check that both points are identical
expect(result.pair[0]).toEqual({ x: 0, y: 0 })
expect(result.pair[1]).toEqual({ x: 0, y: 0 })
expect(result.distance).toBe(0) // Distance between identical points is zero
})

test('handles large number of points correctly', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually tested.

As said, I would recommend testing against the brute force search.

const points = []

// Generate random points
for (let i = 0; i < 100; i++) {
points.push({ x: Math.random() * 100, y: Math.random() * 100 })
}

const closestPair = new ClosestPair(points)
const result = closestPair.findClosestPair()

// Check that the distance is a positive number
expect(result.distance).toBeGreaterThan(0)
})
})
})