-
-
Notifications
You must be signed in to change notification settings - Fork 5.6k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 { | ||
/** @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). | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This probably implies a time complexity of |
||
|
||
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) | ||
} | ||
} |
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', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
}) | ||
}) | ||
}) |
There was a problem hiding this comment.
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.