PHP multidimensional K-D Tree implementation with k-nearest-neighbour search.
The tree is built in memory from a list of items (KDTree) and can be saved to a
compact binary file. The file-backed tree (FSKDTree) reads nodes lazily while
searching, so opening a large index costs almost nothing and searches touch only
the nodes they visit. This is much faster than unserializing a whole tree on every
request, and it is the recommended way to use the library for anything that does
not fit comfortably in memory or is rebuilt rarely and searched often.
Via Composer
$ composer require hexogen/kdtree- PHP 8.2 or newer; tested on PHP 8.2, 8.3, 8.4 and 8.5.
- A 64-bit PHP build for
FSKDTree/FSTreePersister: the binary format uses 64-bit integers, whichpack()/unpack()only support on 64-bit platforms. The in-memoryKDTreeworks on 32-bit builds too.
No extensions beyond the PHP core are needed.
All classes live in the Hexogen\KDTree namespace:
use Hexogen\KDTree\FSKDTree;
use Hexogen\KDTree\FSTreePersister;
use Hexogen\KDTree\Item;
use Hexogen\KDTree\ItemFactory;
use Hexogen\KDTree\ItemList;
use Hexogen\KDTree\KDTree;
use Hexogen\KDTree\NearestSearch;
use Hexogen\KDTree\Point;//Item container with 2 dimensional points
$itemList = new ItemList(2);
//Adding 2 - dimension items to the list
$itemList->addItem(new Item(1, [1.2, 4.3]));
$itemList->addItem(new Item(2, [1.3, 3.4]));
$itemList->addItem(new Item(3, [4.5, 1.2]));
$itemList->addItem(new Item(4, [5.2, 3.5]));
$itemList->addItem(new Item(5, [2.1, 3.6]));
//Building tree with given item list
$tree = new KDTree($itemList);Every item has an integer id and a list of finite numeric coordinates; the number of coordinates must match the dimension count of the list. Adding an item whose id is already in the list replaces the earlier item, so ids should be unique. The tree is built once from the whole list: there is no incremental insert or delete, rebuild the tree when the data changes. Building is O(n log n) on average whatever the order of the input (the median is chosen with a random pivot).
Ids are plain ints because the binary file stores them as signed 64-bit
integers. Keep other data (names, payloads) in your own storage keyed by id.
//Creating search engine with custom algorithm (currently Nearest Search)
$searcher = new NearestSearch($tree);
//Retrieving a result ItemInterface[] array with given size (currently 2)
$result = $searcher->search(new Point([1.25, 3.5]), 2);
echo $result[0]->getId(); // 2
echo $result[0]->getNthDimension(0); // 1.3
echo $result[0]->getNthDimension(1); // 3.4
echo $result[1]->getId(); // 1
echo $result[1]->getNthDimension(0); // 1.2
echo $result[1]->getNthDimension(1); // 4.3Results are ordered nearest first by Euclidean distance. The second argument is
the number of items to return (default 1); asking for more items than the tree
holds returns all of them, asking for 0 returns an empty array, and searching an
empty tree returns an empty array. The point must have the same number of
dimensions as the tree.
Results carry the items only, not their distances. When you need the distance, compute it from the coordinates:
$point = new Point([1.25, 3.5]);
foreach ($searcher->search($point, 2) as $item) {
$squared = 0.0;
for ($d = 0; $d < $point->getDimensionsCount(); $d++) {
$delta = $item->getNthDimension($d) - $point->getNthDimension($d);
$squared += $delta * $delta;
}
echo $item->getId(), ': ', sqrt($squared), PHP_EOL;
}A NearestSearch instance can be reused for any number of searches on the same
tree.
The build picks pivots from a private random generator, so mt_srand() and
mt_rand() in your code are not affected. If you need the same tree layout on
every run (for example in tests or to get byte-identical persisted files), pass a
seeded Random\Randomizer:
use Random\Engine\Xoshiro256StarStar;
use Random\Randomizer;
$tree = new KDTree($itemList, new Randomizer(new Xoshiro256StarStar(42)));The shape only affects build time and the on-disk layout; a search finds the same nearest items whatever the shape (items at exactly equal distances may come back in a different order).
//Init tree writer
$persister = new FSTreePersister('/path/to/dir');
//Save the tree to /path/to/dir/treeName.bin
$persister->convert($tree, 'treeName.bin');FSTreePersister writes to a temporary file in the same directory and renames it
over the target, so it is safe to rebuild a tree file while other processes are
reading it: readers never see a half-written file, and an FSKDTree that already
has the old file open keeps reading the old data until it is re-opened. On
failure the old file is left untouched and the temporary file is removed. Two
things to be aware of:
- On Windows the rename fails while another process has the target file open.
- The new file gets default permissions (your
umask), not the permissions of the file it replaces. Re-applychmod()after persisting if you rely on them.
//ItemInterface factory
$itemFactory = new ItemFactory();
//Then init new instance of file system version of the tree
$fsTree = new FSKDTree('/path/to/dir/treeName.bin', $itemFactory);
//Now use fs kdtree to search
$fsSearcher = new NearestSearch($fsTree);
//Retrieving a result ItemInterface[] array with given size (currently 2)
$result = $fsSearcher->search(new Point([1.25, 3.5]), 2);
echo $result[0]->getId(); // 2
echo $result[1]->getId(); // 1FSKDTree implements the same KDTreeInterface as KDTree, so everything that
works on an in-memory tree works on a file-backed one. The factory decides which
class the items read from the file become; implement
Hexogen\KDTree\Interfaces\ItemFactoryInterface to get your own item objects
back from a search.
Opening a file reads only the header and validates that the file size matches the item count it declares. The file is opened read-only, so any number of processes can search the same file at the same time. The file handle stays open as long as the tree, or any node obtained from it, is referenced.
Nodes read from the file are cached, so a long-lived FSKDTree gradually loads
the whole file into memory. To bound memory, keep only the top levels cached:
// cache the root and 12 levels below it (at most ~8k nodes), read the rest on demand
$fsTree = new FSKDTree('/path/to/dir/treeName.bin', $itemFactory, 12);With a cache depth of n at most 2^(n+1) - 1 nodes stay in memory; 0 keeps
only the root, null (the default) keeps everything ever read.
getMinBoundary() and getMaxBoundary() return the corners of the bounding box
that contains all items, as arrays indexed by dimension. On an empty tree they
are [INF, ...] and [-INF, ...] respectively, and getRoot() returns null.
FSTreePersister writes and FSKDTree reads format version 1. All integers and
floats are little-endian, floats are IEEE 754 doubles. The layout is described in
the FSKDTree docblock and summarised here (D is the number of dimensions,
N the number of items):
| Offset | Size | Content |
|---|---|---|
| 0 | 3 bytes | magic KDT |
| 3 | 1 byte | format version (1) |
| 4 | 4 bytes | D, unsigned 32-bit |
| 8 | 8 bytes | N, unsigned 64-bit |
| 16 | 8 × D bytes |
max boundary, D doubles |
| 16 + 8·D | 8 × D bytes |
min boundary, D doubles |
| 16 + 16·D | N nodes |
nodes, root first, pre-order (right subtree, then left) |
Each node is 24 + 8 × D bytes (FSKDTree::getNodeLength($dimensions)):
| Size | Content |
|---|---|
| 8 bytes | item id, signed 64-bit |
| 8 bytes | absolute file offset of the left child, 0 if none |
| 8 bytes | absolute file offset of the right child, 0 if none |
8 × D bytes |
coordinates, D doubles |
Files written by versions before 0.3.0 have no header and cannot be read anymore:
re-create them with FSTreePersister. The reader rejects files with a wrong
magic, an unsupported version, a dimension count of 0, or a size that does not
match N.
Invalid input and file problems are reported with exceptions, not PHP warnings.
Hexogen\KDTree\Exception\ValidationExceptionfor invalid arguments: an empty coordinate list, a coordinate that is not numeric or not finite (NAN,INF), an item or search point whose dimension count does not match the tree, anItemListwith0dimensions, or a negative result length.Hexogen\KDTree\Exception\FileException(aRuntimeException) fromFSKDTreeandFSTreePersisterwhen a file cannot be opened, written or renamed, is truncated, has an unsupported format, or when a node is read after the tree's file handle was closed.\OutOfRangeExceptionfromgetNthDimension()for a dimension index outside0 .. getDimensionsCount() - 1.\InvalidArgumentExceptionfrom theFSKDTreeconstructor for a negative cache depth.
Please see CHANGELOG for more information on what has changed recently.
$ composer test
$ composer check-styleThe test suite runs on PHP 8.2–8.5 in CI with 100% code coverage; contributions are expected to keep it there.
Please see CONTRIBUTING and CONDUCT for details.
If you discover any security related issues, please email volodymyrbas@gmail.com instead of using the issue tracker.
The MIT License (MIT). Please see License File for more information.