December 1, 2022
Yang Yan
A friend Sanjeev introduced the following problem over Thanksgiving weekend:
Given a list of unsorted integers , find in the maximum difference between consecutive integers of the sorted list.
To invalidate a naive bucket and radix sort, assume each is up to , such that a radix sort runs in and the bucket sort in . However, with some suspension of belief, assume that other basic arithmetic and comparisons are constant time.
Anyway, I detail my approach below.
I began the problem thinking through iterative solutions, and this came to mind:
For each prefix , track the max gap for integers of that prefix, alongside the . For , if it falls before or after , potentially update if either or are greater. Otherwise, the max gap cannot increase.
Of course, it’s easy to spot the error here: should , cannot increase—but it can decrease. Unfortunately, we don’t have the information to determine whether or not this happens.
Intuitively, we need to have information about all other gaps of the prefix, to determine if decreases with . Looking back, most iterative solutions are some form of DP, and for iterations, we need to keep at most information per state. Then, DP approaches are probably impossible.
This is a fairly common CP technique, most commonly in Mo’s algorithm-like scenarios. I’m not too confident about this approach, though, because the final runtime usually involves a factor.
The core intuition behind Mo’s algorithm is that each of the buckets cover a range of rather than , and whatever updates need to happen are linear to the range. So processing a bucket ends up being , with all bucket processing being , which usually eats the runtime of resetting buckets times.
The problem here is that processing a number is hardly dependent on the range of the number—it is dependent on the number of numbers already processed, in that bucket. I fail to see a solution to this.
I make the mistake of jumping back into DP at this time. We get the initial gap for free. We can then partition the range of the numbers into buckets of size . Numbers within the same bucket don’t need to be considered, and provided a reasonable distribution of , we expect around buckets. This means we can hash the numbers into buckets in time. The assumption on the distribution is a red herring we may be able to solve later with randomization.
I also make the mistake of jumping back into iterative procedures. I’m not sure where I was going with the following proposal—there’s a lot wrong with it.
Given the current max gap , and a current left pivot , assume that the max gap is either or greater, or occurs to the right . To begin, swap to the beginning of the array and set it as left pivot, with as the initial gap .
For each new number , if , hash to its bucket. Otherwise, is close to , and the max gap cannot occur between and by our assumptions. So, update to . Updates may need to be cascaded—after one update to , consult the series of buckets up to and including and update to the largest number in those buckets.
I think the update cascade procedure may be correct—we cascade through each at most times, and only move if we’re sure that numbers before the new cannot increase the max gap. However, among other things, we run into a problem after we’ve cascaded—it is now possible that occurs before and in fact invalidates our and decreases the max gap—which is the same core problem we ran into before.
Intuitively, this make a lot of sense, and it is my error for not noticing it earlier. Again, we attempt to keep a constant amount of state per iteration, and again we are brought down by the possibility that we need to access a history of max gaps in a certain iteration.
At this point I’m taking notice of the nice property of buckets, especially when we have of them and can hash into them for free. In considering the expected initial gap for the previous approach, I also notice that the max gap is very rarely equal to , and is usually much larger. We can determine if the max gap is equal to this by simply partitioning into buckets: if each bucket has one number, we are done, as we’ve essentially bucket sorted. otherwise, the max gap is greater than .
In fact, we cannot have the max gap occur within any single bucket. So we don’t need all the numbers within every bucket, only two numbers for the min/max of the bucket. As the max gap cannot occur within a bucket, we need only check the max of each bucket with the min of the next bucket. So, we are done:
Partition the range into buckets, and hash in each number into its bucket. Track the min/max of each bucket and simply take the max of all and as the max gap.
The final solution is surprisingly simple and I am ashamed it took me so long to get it. I took a long twenty-minute detour the second time I considered iterative solutions, thought it did bring me marginally closer to the bucket solution. In hindsight, it should be immediately obvious based on the state information argument that any naive iterative solution is bound to fail.
Square root decomposition should have gotten me closer than it did. Any bucketing approach—both my solution and Mo’s algorithm—relies on the fact that buckets reduce the range of the problem. Though range doesn’t factor into the runtime here, range limits which numbers are relevant, and immediately decreases the state space of each bucket to constant.
In fact, I think bucketing solutions are likely quite flexible (I see them around a lot, e.g. in Bloom filters and VeB trees, if I recall correctly). The core intuition is that either the runtime or information state of a subproblem is tied to its range—and buckets reduce that range, essentially for free most of the time.
This problem took me around 40 minutes of focused airplane time to solve, which is a bit longer than I’d hope, considering the simplicity of the solution. I’ve been reflecting on this a bit—it’s not quite pride, nor honor, nor interview preparation, nor an interest in research, that seems to push me back to math and algorithms every so often. A mentor Richard once said to me:
Suppose they put me in a room, and each time I solved the problem on screen, a green check mark would light up (this part is very important), and they would give me a bit of food. I would be pretty happy.
Well, Richard is a PhD (or postdoc (or professor?)) now. And I still can’t say I’ve found very many things that make me happier than those green check marks. I think, at some level, I’d much rather prefer working in the space of well-defined technical problems, than spaces which seem to smell like nepotism, privilege, appearance, and chance. But these are big words, and these spaces are big spaces whose boundaries are at least quite differentiable. I think I just want to feel in-control of my own destiny, and this problem made me feel that way.
I’ve been playing around with Github Copilot recently on recommendation of a friend Tony, and found it to be surprisingly compentent, but ultimately useless, while writing this post:
Close, Copilot.
Sanjeev also mentions a variant of this problem, but for the min gap, which may require a randomized solution. I’ll tackle this later.