Problem overview
The method minCostConnectPoints receives an array of points, each an [x, y] pair. Connecting two points costs their Manhattan distance, |xi - xj| + |yi - yj|: the distance along the grid lines. The method returns the minimum total cost of connections that make all the points reachable from one another. The points are distinct, and there are at most 1000 of them.
In the first example, [[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]], four connections join the five points: [0, 0] to [2, 2] costs 4, [2, 2] to [5, 2] costs 3, [5, 2] to [7, 0] costs 4, and [2, 2] to [3, 10] costs 9. The total is 20.
Approach: Kruskal's algorithm
Think of every pair of points as an edge of a graph, with the Manhattan distance as its cost. That makes n * (n - 1) / 2 edges, about half a million for 1000 points. The cheapest way to connect all the points is a minimum spanning tree: n - 1 edges that connect every point with the least total cost.
Kruskal's algorithm builds it greedily. Sort the edges from cheapest up, and take each edge whose two ends are still in different groups, which joins the two groups into one. An edge whose ends are already in the same group would only close a cycle and add cost, so it is skipped. Taking the cheapest edge between two groups is always safe: the finished tree has to join those groups somehow, and swapping in the cheaper edge would give a tree that costs no more.
To tell quickly whether two points are in the same group, the method uses union-find. Each group is a tree, and each point links to a parent. The root, the one point that is its own parent, names the group. Two points are in the same group exactly when they lead to the same root.
After n - 1 edges are taken, all points are in one group and the method can stop. Sorting the edges dominates the work: O(n^2 log n) time, and keeping them takes O(n^2) space. Prim's algorithm, which grows one tree from a starting point, is the other classic approach. This lesson uses Kruskal's.
For the third example, [[0, 0], [1, 0], [0, 1], [10, 10]], the sorted edges, each shown as (cost, i, j), are:
(1, 0, 1)
(1, 0, 2)
(2, 1, 2)
(19, 1, 3)
(19, 2, 3)
(20, 0, 3)
The first two edges are taken. The edge (2, 1, 2) joins two points that are already connected through point 0, so it is skipped. Then (19, 1, 3) joins the last point, and with three edges taken the method returns 1 + 1 + 19 = 21.
Step by step
- Store the number of points,
points.length, in avalnamedn. - Create
edgesas an emptymutable.ArrayBuffer[Array[Int]](). AnArrayBufferis an array that can grow. Each edge will beArray(cost, i, j), with the cost first because that is what the edges are sorted by. - Loop over
i <- 0 until n, and inside it loop overj <- i + 1 until n. Starting ati + 1lists each pair once and never pairs a point with itself, since connectingitojcosts the same asjtoi. - Compute the cost as
math.absof the x difference plusmath.absof the y difference. The absolute value makes each difference a distance whichever point is further left or lower. - Add the edge with
edges += Array(cost, i, j). - Sort the edges by cost into
sortedwithedges.sortBy(_(0)). It returns a sorted copy and leavesedgesalone. - Set up union-find.
parentisArray.tabulate(n)(i => i), so every point starts as its own root.sizeisArray.fill(n)(1), wheresize(r)counts the points in the group with rootr. - Keep two counters as
vars:total, the cost so far, andused, the number of edges taken. A tree overnpoints has exactlyn - 1edges. - Loop over the sorted edges with
edge <- sorted.edge(0)is the cost, andedge(1)andedge(2)are the two points. - Find both roots with the helper
find, intovarsrootAandrootB. They arevars because the next step may swap them. - Skip edges inside one group. If
rootA != rootB, the edge joins two groups, and the network takes it. Equal roots mean a cycle. - Union by size. If
size(rootA) < size(rootB), swap the two roots using a temporaryval, so thatrootAis the root of the larger group. The smaller tree always goes under the larger one, so no path to a root grows longer than about log2(n) steps. - Merge: set
parent(rootB)torootA, and addsize(rootB)tosize(rootA). - Take the edge: add
edge(0)tototaland 1 toused. This is the key line: the edge is the cheapest one left that joins two groups. - Stop early. Once
used == n - 1, every point is in one group, so returntotalright away. - End with
total. The loop only reaches this line for a single point: it has no edges, and connecting it costs nothing. - Write the helper
findbelow the main method, inside the class. It takes theparentarray and anodeand returns the root. Copynodeinto avar x, since parameters cannot be reassigned. - Climb to the root: while
parent(x) != x, setparent(x)toparent(parent(x))(its grandparent), then setxtoparent(x). This is path halving: each node passed skips one level, so later walks from there are shorter. Then returnx.
The solution in Scala
class Solution {
def minCostConnectPoints(points: Array[Array[Int]]): Int = {
val n = points.length
val edges = mutable.ArrayBuffer[Array[Int]]()
for (i <- 0 until n) {
for (j <- i + 1 until n) {
val cost = math.abs(points(i)(0) - points(j)(0)) + math.abs(points(i)(1) - points(j)(1))
edges += Array(cost, i, j)
}
}
val sorted = edges.sortBy(_(0))
val parent = Array.tabulate(n)(i => i)
val size = Array.fill(n)(1)
var total = 0
var used = 0
for (edge <- sorted) {
var rootA = find(parent, edge(1))
var rootB = find(parent, edge(2))
if (rootA != rootB) {
if (size(rootA) < size(rootB)) {
val temp = rootA
rootA = rootB
rootB = temp
}
parent(rootB) = rootA
size(rootA) += size(rootB)
total += edge(0)
used += 1
if (used == n - 1) {
return total
}
}
}
total
}
def find(parent: Array[Int], node: Int): Int = {
var x = node
while (parent(x) != x) {
parent(x) = parent(parent(x))
x = parent(x)
}
x
}
}
The Scala details worth a closer look:
- Imports. The code uses
mutable.ArrayBuffer, which the lesson already makes available. In a file of your own, addimport scala.collection.mutable. sortBy(_(0))._(0)is short foredge => edge(0).sortByreturns a new sorted collection, so the originaledgeskeeps its order. The sort is stable: edges of equal cost stay in the order they were added, which is why(19, 1, 3)comes before(19, 2, 3)above.Array.tabulateandArray.fill.Array.tabulate(n)(f)builds an array whose elementiisf(i), soArray.tabulate(4)(i => i)is0,1,2,3.Array.fill(n)(1)givesncopies of 1.returninside a loop. Aforloop over a collection is compiled to a call with a function body, so thereturn totalinside it is a "non-local return". In Scala 3.5 the compiler accepts it, prints the warningNon local returns are no longer supported; use `boundary` and `boundary.break` in `scala.util` instead, and the method still returns correctly. The warning does not stop the build.- Parameters are values.
findcopiesnodeintovar xbecause Scala does not allow assigning to a parameter. - Arrays are shared.
findreceives the sameparentarray, not a copy, so its path halving changes what the main method sees. That is the point of the compression. - The helper is a method, not a nested function. It is declared after its use in the class body. A class's methods can call one another in any order.
Complexity
Let n be the number of points.
Time: O(n^2 log n). Building the edges is O(n^2), and sorting about n^2 / 2 of them is O(n^2 log n). The union-find calls cost almost nothing each: with union by size and path halving, a find takes nearly constant time on average.
Space: O(n^2). The edge list holds n * (n - 1) / 2 arrays of three integers, and the sorted copy holds the same again. The two union-find arrays take O(n).
Edge cases and common mistakes
- A single point.
[[5, 5]]has no edges,used == n - 1is never checked, and the method returnstotal, 0. - Two points.
[[0, 0], [3, 4]]gives 7, the Manhattan distance: 3 + 4. It is not 5, the straight-line distance. - Negative coordinates.
[[3, 12], [-2, 5], [-4, 1]]gives 18.math.abstakes care of the signs. Far-apart points are fine too:[[-1000000, -1000000], [1000000, 1000000]]gives 4000000. - Points on a diagonal.
[[0, 0], [1, 1], [2, 2]]gives 4: two connections of cost 2 each. The direct connection from the first to the last would cost 4 by itself. - Taking every cheap edge. Skipping the root check adds edges that close cycles, such as (2, 1, 2) in the third example, and the total comes out too high.
- Not hanging one root under the other. If the merge step is left out, the groups never combine, and every edge looks like it joins separate groups.
- Using
j <- 0 until n. That lists each pair twice and also pairs each point with itself, at cost 0. Starting ati + 1avoids both.
Testing
[[0, 0], [2, 2], [3, 10], [5, 2], [7, 0]]gives20.[[3, 12], [-2, 5], [-4, 1]]gives18.[[0, 0], [1, 0], [0, 1], [10, 10]]gives21.[[5, 5]]gives0.[[0, 0], [3, 4]]gives7.[[0, 0], [1, 1], [2, 2]]gives4.
