← All Problems · Patterns & Complexity Cheat Sheet

Clustering Algorithms in Python

Four ways to group items into clusters, each implemented from scratch in plain Python — no numpy, no scikit-learn, no external dependencies, matching every other page in this set. Three of them (K-Means, DBSCAN, Hierarchical) discover groups from a distance metric computed on the fly; the fourth (Union-Find) groups items from explicit "these two belong together" facts you already have, with no distance calculation at all. Every number in every trace table below was actually produced by running the code, not worked out by hand — see the scripts for yourself in the code blocks.

These are general-purpose clustering techniques — nothing on this page is a claim about what Fin's own systems actually use.

K-Means

Pick k starting centroids, assign every point to its nearest centroid, then recompute each centroid as the mean position of the points now assigned to it. Repeat assign-then-recompute until no point changes clusters (or, equivalently, no centroid moves) — that's the whole algorithm. It always converges to a local optimum, but which one depends heavily on where the centroids started.

k has to be chosen up front — K-Means has no built-in notion of "how many clusters are actually here," it will happily force any k you give it, even a bad one.

Worked Example

Seven 2D points, split visually into a tight group near the origin and a looser group up and to the right: P1(1,1), P2(1.5,2), P3(3,4), P4(5,7), P5(3.5,5), P6(4.5,5), P7(3.5,4.5). k=2, with deterministic (non-random) initial centroids at P1 and P2 so the trace below is exactly reproducible: C0=(1,1), C1=(1.5,2).

IterCentroids beforeAssignmentsCentroids after
1 C0=(1,1), C1=(1.5,2) P1→C0, P2→C1, P3→C1, P4→C1, P5→C1, P6→C1, P7→C1 C0=(1.0,1.0), C1=(3.5,4.5833)
2 C0=(1.0,1.0), C1=(3.5,4.5833) P1→C0, P2→C0, P3→C1, P4→C1, P5→C1, P6→C1, P7→C1 C0=(1.25,1.5), C1=(3.9,5.1)
3 C0=(1.25,1.5), C1=(3.9,5.1) P1→C0, P2→C0, P3→C1, P4→C1, P5→C1, P6→C1, P7→C1 C0=(1.25,1.5), C1=(3.9,5.1) — unchanged, converged

Iteration 1 moves C1 a long way (it starts equal to P2, then jumps to the mean of six points once everything but P1 lands in its cluster). By iteration 3 the assignments and centroids both stop changing, so the loop exits. Final clusters: {P1, P2} and {P3, P4, P5, P6, P7}.

import math

def euclidean_distance(a, b):
    return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(len(a))))

def kmeans(points, k, initial_centroids, max_iters=100):
    centroids = list(initial_centroids)
    for _ in range(max_iters):
        # assign each point to its nearest centroid
        assignments = []
        for p in points:
            distances = [euclidean_distance(p, c) for c in centroids]
            assignments.append(distances.index(min(distances)))

        # recompute each centroid as the mean of its assigned points
        new_centroids = []
        for cluster_id in range(k):
            members = [points[i] for i in range(len(points)) if assignments[i] == cluster_id]
            if not members:
                # empty cluster -- keep the old centroid rather than dividing by zero
                new_centroids.append(centroids[cluster_id])
                continue
            mean_x = sum(m[0] for m in members) / len(members)
            mean_y = sum(m[1] for m in members) / len(members)
            new_centroids.append((mean_x, mean_y))

        # converged: no centroid moved, stop early
        if new_centroids == centroids:
            centroids = new_centroids
            break
        centroids = new_centroids

    return centroids, assignments

points = [(1, 1), (1.5, 2), (3, 4), (5, 7), (3.5, 5), (4.5, 5), (3.5, 4.5)]
centroids, assignments = kmeans(points, k=2, initial_centroids=[(1, 1), (1.5, 2)])
print(centroids)
print(assignments)

Complexity

O(n · k · i · d) time, where n is the number of points, k the number of clusters, i the number of iterations until convergence, and d the number of dimensions — every point is compared against every centroid on every iteration. O(n + k) space for the assignments and centroids.

When to Use It / Tradeoffs

  • Needs k specified upfront — no way to ask "how many clusters" for you.
  • Sensitive to initial centroid placement — a bad random start can converge to a visibly worse local optimum (see the Pitfalls section for the standard fix).
  • Assumes roughly spherical, similar-sized clusters — struggles badly on elongated or nested shapes.
  • No native concept of noise — every point is forced into some cluster, even an obvious outlier.
  • Not deterministic in general (random initialization) — though the run above is, since the initial centroids were fixed rather than randomized.
  • Fast and simple, which is why it's usually the first thing tried on numeric, roughly round clusters at moderate scale.

DBSCAN

DBSCAN (Density-Based Spatial Clustering of Applications with Noise) classifies every point as core (at least min_samples points, including itself, within distance eps), border (not core itself, but within eps of a core point), or noise (neither). Clusters are grown by chaining density-reachable core points together: start at an unvisited core point, claim every point in its eps-neighborhood into the same cluster, and for any of those that are themselves core, recursively pull in their neighborhoods too. Points that end up reachable only as border points get claimed but don't expand the cluster further.

Unlike K-Means, DBSCAN never needs a target number of clusters — the density structure of the data determines how many clusters exist — and it explicitly labels outliers as noise instead of forcing them into a cluster.

Worked Example

Nine 2D points: a tight group of four near (1,1), one extra point A5(2.3,1.2) sitting just off that group, a tight group of three near (5,5), and one far-away outlier at (10,10). eps=1.0, min_samples=3.

PointNeighbors within eps (incl. self)CountCore?Final label
A1(1,1)A1, A2, A3, A44Yescluster 0 (core)
A2(1.2,1.5)A1, A2, A3, A44Yescluster 0 (core)
A3(0.8,1.3)A1, A2, A3, A44Yescluster 0 (core)
A4(1.5,1)A1, A2, A3, A4, A55Yescluster 0 (core)
A5(2.3,1.2)A4, A52Nocluster 0 (border)
B1(5,5)B1, B2, B33Yescluster 1 (core)
B2(5.2,5.3)B1, B2, B33Yescluster 1 (core)
B3(4.8,4.7)B1, B2, B33Yescluster 1 (core)
N1(10,10)N11Nonoise

A5 is the interesting one: it only has 2 neighbors within eps (itself and A4), so it fails the core-point test on its own — but since it falls inside core point A4's neighborhood, it gets claimed as a border point of cluster 0 without ever expanding the cluster further. N1 has no neighbors at all within eps and is never claimed by any core point's neighborhood, so it stays noise.

import math

def euclidean_distance(a, b):
    return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(len(a))))

def region_query(points, idx, eps):
    return [j for j in range(len(points)) if euclidean_distance(points[idx], points[j]) <= eps]

def dbscan(points, eps, min_samples):
    n = len(points)
    # None = not yet visited, -1 = noise, 0..k = cluster id
    labels = [None] * n
    neighbors = [region_query(points, i, eps) for i in range(n)]
    # a point is a core point if enough points (including itself) fall within eps
    is_core = [len(neighbors[i]) >= min_samples for i in range(n)]

    next_cluster_id = -1
    for i in range(n):
        if labels[i] is not None:
            continue
        if not is_core[i]:
            # provisionally noise -- may still be claimed as a border point later
            labels[i] = -1
            continue

        next_cluster_id += 1
        labels[i] = next_cluster_id
        # grow the cluster outward from this core point
        seeds = list(neighbors[i])
        pos = 0
        while pos < len(seeds):
            j = seeds[pos]
            pos += 1
            if labels[j] == -1:
                # was noise -- claim it as a border point of this cluster
                labels[j] = next_cluster_id
            if labels[j] is not None:
                continue
            labels[j] = next_cluster_id
            if is_core[j]:
                # j is also core -- its neighborhood extends the cluster further
                for k in neighbors[j]:
                    if k not in seeds:
                        seeds.append(k)

    return labels, is_core

points = [
    (1.0, 1.0), (1.2, 1.5), (0.8, 1.3), (1.5, 1.0), (2.3, 1.2),
    (5.0, 5.0), (5.2, 5.3), (4.8, 4.7),
    (10.0, 10.0),
]
labels, is_core = dbscan(points, eps=1.0, min_samples=3)
print(labels)
print(is_core)

Complexity

O(n²) time as implemented above — region_query does a linear scan over all n points for each of the n points, since it's a plain distance check with no spatial index. With a spatial index (a k-d tree or grid, not implemented here since that would pull in extra machinery), neighbor queries drop to roughly O(log n) each, for O(n log n) overall. O(n) space for labels and cached neighbor lists.

When to Use It / Tradeoffs

  • Doesn't need k specified upfront — the number of clusters falls out of the density structure.
  • Explicitly labels outliers as noise instead of forcing them into a cluster.
  • Handles non-convex, oddly-shaped clusters that K-Means can't (density-reachability chains through curves and irregular shapes fine).
  • Deterministic given a fixed eps/min_samples and a fixed processing order (unlike K-Means's random-init non-determinism).
  • eps and min_samples are hard to pick well and results are sensitive to them (see Pitfalls).
  • Struggles when clusters have very different densities — a single global eps can't be simultaneously right for a dense cluster and a sparse one.

Hierarchical (Agglomerative) Clustering

Start with every point as its own singleton cluster. Repeatedly find the two closest clusters under some linkage rule and merge them into one, until only the target number of clusters remains (or, taken all the way, until every point has merged into one giant cluster). The full merge sequence from n singletons down to 1 cluster is a dendrogram — a tree of merges, ordered by the distance at which each merge happened — and you can "cut" it at any number of clusters after the fact, not just the one you originally asked for.

The linkage rule defines "distance between two clusters" (which isn't a single point-to-point distance once a cluster has more than one member): single linkage uses the distance between the two clusters' closest points, complete linkage the distance between their farthest points, average linkage the mean of every cross-cluster pairwise distance. The worked example and code below use single linkage, the simplest of the three to compute and trace by hand.

Worked Example

Six 2D points forming two visually obvious pairs-of-pairs plus a lone pair: P1(1,1), P2(1.5,1.5), P3(5,5), P4(5.5,5), P5(1,4), P6(1.2,4.5). Merging down to a single cluster, using single linkage:

StepMergeDistanceClusters remaining
1{P3} + {P4} → {P3,P4}0.55
2{P5} + {P6} → {P5,P6}0.5394
3{P1} + {P2} → {P1,P2}0.7073
4{P5,P6} + {P1,P2} → {P1,P2,P5,P6}2.552
5{P3,P4} + {P1,P2,P5,P6} → {all}3.8331

The three tightest pairs merge first (steps 1-3, all under distance 0.71), exactly matching the visual grouping. Cutting the dendrogram at "2 clusters remaining" (right after step 4) recovers {P3,P4} and {P1,P2,P5,P6} — note that P1,P2 (near the origin) and P5,P6 (upper-left) end up in the same 2-cluster split, because with only 2 clusters allowed, single linkage's "closest pair" rule merges whichever two clusters are nearest at each step regardless of the original three visual groupings, and by step 4 {P3,P4} is still the odd one out at distance 3.833 from everything else.

import math

def euclidean_distance(a, b):
    return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(len(a))))

def single_linkage_distance(points, cluster_a, cluster_b):
    # single linkage: distance between two clusters is the distance
    # between their single closest pair of points
    return min(
        euclidean_distance(points[i], points[j])
        for i in cluster_a
        for j in cluster_b
    )

def agglomerative_clustering(points, num_clusters):
    # every point starts as its own cluster
    clusters = [[i] for i in range(len(points))]
    merge_log = []

    while len(clusters) > num_clusters:
        # find the two closest clusters by linkage distance
        best_pair = None
        best_distance = None
        for a in range(len(clusters)):
            for b in range(a + 1, len(clusters)):
                d = single_linkage_distance(points, clusters[a], clusters[b])
                if best_distance is None or d < best_distance:
                    best_distance = d
                    best_pair = (a, b)

        a, b = best_pair
        merged = clusters[a] + clusters[b]
        merge_log.append((list(clusters[a]), list(clusters[b]), best_distance))

        # replace the two merged clusters with the single merged one
        remaining = [c for idx, c in enumerate(clusters) if idx not in (a, b)]
        remaining.append(merged)
        clusters = remaining

    return clusters, merge_log

points = [(1, 1), (1.5, 1.5), (5, 5), (5.5, 5), (1, 4), (1.2, 4.5)]
clusters, merge_log = agglomerative_clustering(points, num_clusters=1)
print(clusters)
for a, b, d in merge_log:
    print(a, b, round(d, 3))

Complexity

O(n³) time as implemented above: n-1 merges happen in total, and each merge step recomputes the linkage distance between every remaining pair of clusters from scratch — an O(n²) scan per merge, times O(n) merges. This is the naive version; production implementations avoid recomputing every pairwise distance after each merge by maintaining a priority queue of candidate merges and updating only the entries touched by the merge, which gets the whole process down to O(n² log n). O(n²) space for the full pairwise distance matrix.

When to Use It / Tradeoffs

  • Doesn't need k specified upfront — pick any cut of the dendrogram after the fact, or let the merge distances themselves suggest a natural cutoff.
  • Deterministic — no random initialization step, unlike K-Means.
  • Single linkage in particular can follow non-convex, chain-like shapes; complete/average linkage behave more like K-Means, biased toward compact clusters.
  • No explicit noise handling — like K-Means, every point ends up in some cluster.
  • O(n²) or worse complexity (even with the smarter bookkeeping) makes it impractical much past a few thousand points — see Pitfalls.

Union-Find: Clustering by Explicit Links

Union-Find is a fundamentally different approach to "clustering" than the three above: it never computes a distance or similarity between anything. Instead, you're given (or you discover via some separate, upstream check) explicit pairwise facts — "these two items belong together" — and Union-Find's job is purely to chain those pairwise facts into connected groups, transitively, in near-O(1) amortized time per operation. Every item starts in its own singleton group; union(a, b) merges two items' groups together; find(x) answers "which group is x in" by walking to that group's root.

The concrete example in this problem set is #9, Cluster Near-Duplicate Queries: an upstream embedding-similarity check has already flagged certain pairs of incoming support queries as similar enough to be near-duplicates, and the problem is to group all queries into clusters of mutually-connected duplicates (transitively — if q1~q2 and q2~q3 are both flagged, q1, q2, and q3 all land in one cluster even though q1~q3 was never directly flagged) so only one representative per cluster needs a fresh answer. See that page for the full worked trace over 8 nodes; it isn't repeated here.

Two optimizations make the near-O(1) bound possible: path compression (every node visited while walking up to the root during a find gets re-pointed straight at the root) and union by rank (always attach the shorter tree under the taller one, so trees never get needlessly deep).

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        # path compression: point every visited node straight at the root
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, a, b):
        root_a, root_b = self.find(a), self.find(b)
        if root_a == root_b:
            return False
        # union by rank: attach the shorter tree under the taller one
        if self.rank[root_a] < self.rank[root_b]:
            root_a, root_b = root_b, root_a
        self.parent[root_b] = root_a
        if self.rank[root_a] == self.rank[root_b]:
            self.rank[root_a] += 1
        return True

Complexity: amortized O(α(n)) per find/union call with both optimizations — effectively O(1); O(n) space for the parent and rank arrays.

Comparison

Algorithm Needs k upfront? Handles noise/outliers? Handles non-convex shapes? Deterministic? Typical complexity When to reach for it
K-Means Yes No — every point forced into a cluster No — assumes roughly spherical, similar-sized clusters No (random init), unless centroids are fixed O(n·k·i·d) Fast, simple, roughly round clusters, k is known or easy to estimate
DBSCAN No Yes — explicit noise label Yes — density-reachability chains through irregular shapes Yes (given fixed eps/min_samples/order) O(n²) naive / O(n log n) with a spatial index Unknown cluster count, outliers expected, clusters have roughly uniform density
Hierarchical No — cut the dendrogram at any k after the fact No — every point ends up in some cluster Depends on linkage — single linkage yes, complete/average more like K-Means Yes — no random initialization O(n³) naive / O(n² log n) with better bookkeeping Small-to-medium n, want the full merge hierarchy, unsure what k should be
Union-Find No — cluster count falls out of the given pairs N/A — items with no flagged pair stay singleton "clusters" N/A — no distance metric involved at all Yes Amortized O(α(n)) ≈ O(1) per op You already have (or cheaply derive) explicit pairwise "these belong together" facts

Pitfalls

  • K-Means is sensitive to initial centroid placement — a bad random start can converge to a visibly worse local optimum than a better start would have found. The standard fix is k-means++ initialization, which picks initial centroids probabilistically weighted toward points far from centroids already chosen, rather than picking them uniformly at random — not implemented above, but worth naming if asked "how would you improve this" in an interview.
  • K-Means assumes clusters are roughly spherical and similar-sized — it fails visibly on elongated, nested, or very differently-sized clusters, since it's minimizing distance to a single centroid per cluster, which is only a good fit for round, evenly-sized blobs.
  • DBSCAN's eps and min_samples are hard to tune and results are sensitive to them — too small an eps fragments one real cluster into many, too large merges distinct clusters into one.
  • DBSCAN struggles with clusters of very different densities — a single global eps/min_samples pair can't simultaneously be right for a tightly packed cluster and a sparse one; the sparse one either gets shredded into noise or the dense one over-merges with its surroundings.
  • Hierarchical clustering's O(n²) or worse complexity (O(n³) naive, O(n² log n) with better bookkeeping) makes it impractical at real scale — it's a small-to-medium-n tool, not something to reach for on millions of points.
  • Euclidean distance on unnormalized features silently favors whichever feature has the largest raw magnitude — this applies to K-Means, DBSCAN, and hierarchical clustering alike, since all three lean on a distance calculation. A feature measured in the thousands will dominate one measured in single digits unless every feature is scaled to a comparable range first (e.g. min-max or z-score normalization) before computing any distance.