Single source shortest path is one of most popular the topics taught in a introductory algorithm course. Given a graph G=<V,E>G = \left< V, E \right> with nn vertices and mm edges, the famous Dijkstra's algorithm has runtime O(mlog⁡n)O(m \log n) by using a binary heap. When more advanced heap such such Fibonacci heap is deployed, the runtime can be improved to O(m+nlog⁡n)O(m + n \log n). Most introductory courses are satisfied with the above solutions without going further.

Here we explore the topic by considering a special case where all edge weights are non-negative integers within the set {0,1,2,...,C}\{0, 1, 2, ..., C\}. We develop new heaps incrementally and we will finally see that Dijkstra algorithm can do substantially better than O(m+nlog⁡n)O(m + n \log n).

Time: O(m+nC)O(m + nC), Space: O(nC)O(nC).

As a warm up, we demonstrate an algorithm with runtime O(m+nC)O(m + nC) and space overhead O(nC)O(nC). Here we exclude the space O(m+n)O(m + n) required for storing the graph to simplify the discussion and comparison between the algorithms.

The idea is very simple. We know that the possible distance from any vertex to the source vertex (denoted as ss) is no less than nCnC. It suffices to keep an array A1A_1 with length nCnC, such that A1[i]A_1[i] maintains the set of vertices with temporary distance ii. In this context, we call A1[i]A_1[i] a bucket and the array A1A_1 itself the buckets.

Initially all A1[i]A_1[i]'s are empty except that A1[0]A_1[0] contains the source vertex. The algorithm iterates over A1A_1 to find an unmarked vertex with the minimum distance, marked it, update its neighbors' distances and move them to the corresponding buckets.

Each vertex is inserted into A1A_1 once. There are at most mm updates of vertices' distances. As the minimum distance of the unmarked vertices is monotonically increasing, we scan the array A1A_1 only once. Summing over the cost we get the O(m+nC)O(m + nC) time complexity bound.

Time: O(m+nC)O(m + nC), Space: O(C)O(C).

The space usage of the above algorithm can be improve to O(C)O(C). Relabel the vertices as s=v0,v1,v2,...,vns = v_0, v_1, v_2, ..., v_n according to the order they are marked.

Suppose that Dijkstra's algorithm uses an array dd to record the vertices' distances to ss. Initially, d(s)=0d(s) = 0 and d(v)=∞d(v) = \infty for ∀v≠s\forall v \neq s. Now, consider the moment that kk (0≤k<n0 \le k < n) vertices have been marked. For i>ki > k, the value of d(vi)d(v_i) is either

  1. d(vi)=∞d(v_i) = \infty, if viv_i is not adjacent to any vertex vjv_j for j≤kj \le k. In this case viv_i is not in the array A1A_1.

  2. d(vi)=d(vj)+wi,jd(v_i) = d(v_j) + w_{i,j} for some j≤kj \le k, where wi,jw_{i,j} is the edge weight between viv_i and vjv_j.

By the property of Dijkstra's algorithm, at this moment it also holds that

0=d(s)=d(v0)≤d(v1)≤d(v2)...≤d(vk) 0 = d(s) = d_(v_0) \le d(v_1) \le d(v_2) ... \le d(v_k)

Therefore, for i>ki > k, either viv_i is not in A1A_1 or

d(vi)≤d(vj)+wi,j≤d(vk)+C d(v_i) \le d(v_j) + w_{i,j} \le d(v_k) + C

All un-marked vertices that are in A1A_1 must be in the range of

A1[d(vk)...d(vk)+C] A_1[d(v_k) ... d(v_k) + C]

In general, let μ\mu be the distance of the least marked vertex. It suffices to maintain a window of A1[μ...μ+C]A_1[\mu...\mu+C]. We can implement this by initializing A1A_1 with size C+1C+1 and use it in a wrap-around manner.

Time: O(m+nC)O(m + n \sqrt C), Space: O(C)O(C).

In previous section, we successfully reduce the size of A1A_1 to 1+C1 + C. But the runtime remains O(m+nC)O(m + nC). To motivate the algorithm discussed in this section, we first provide an alternative view of this complexity:

  1. We update the vertices' distance and move the vertices between buckets, which has cost O(m)O(m);

  2. At each iteration of Dijkstra's algorithm, in the worst case, we need to scan the entire array of A1A_1 to find an unmarked vertex with minimum distance, which takes O(C)O(C) times. This is repeated nn times.

Where could we have wasted our time? Do we really have spend O(C)O(C) time to find the unmarked vertex with minimum distance?

We can improve this to O(C)O(\sqrt C) by using two-level buckets. We break the buckets in A1A_1 into C+1\sqrt { C + 1} blocks, each of size C+1\sqrt { C + 1} (assume here C+1\sqrt { C + 1} is an integer for simplicity). For each block, we use one flag bit to indicate whether this block is empty. This results in C+1\sqrt { C + 1} flags bits, which are stored in bit array A2A_2. From now on,

Now, to find an unmarked vertex with minimum distance, we scan A2A_2 to find the first non-zero bit then the corresponding bucket in A1A_1, which takes O(C)O(\sqrt C) time. Totaling the runtime, we obtain a bound of O(m+nC)O(m + n \sqrt C).

Question to ponder: calculate the size of C\sqrt C for some values of CC which you consider reasonable in real applications.

Time: O(mlog⁡mnC)O( m \log_{ \frac{m}{n} } C), Space: O(C)O(C).

It is natural to extend the idea to 33-level buckets. Suppose the block size if Δ\Delta. Then the third level contains a single bit block of size Δ\Delta, the second level contains Δ\Delta bit blocks (with total size Δ3\Delta^3), and the first level contains the buckets A1A_1 of size Δ3=C+1\Delta^3 = C + 1. It follows that Δ=(C+1)13\Delta = (C + 1)^\frac{1}{3} and the time complexity is O(m+nC13)O(m + n C^\frac{1}{3} ).

We may use four level buckets, in which Δ=(C+1)14\Delta = (C + 1)^\frac{1}{4} and the time complexity is O(m+nC14)O(m + n C^\frac{1}{4}).

Question to ponder: calculate the size of C13C^\frac{1}{3} and C14C^\frac{1}{4} for some values of CC which you consider reasonable in real applications.

Does the analysis carry for kk-level buckets for general value of kk? No. We can no longer consider the kk as a constant and have to take into consider the overhead of insertion, decrease-key and deletion explicitly.

For a kk-level bucket structure with bucket size Δ=(1+C)1k≥2\Delta = (1 + C)^\frac{1}{k} \ge 2,

  1. insertion takes O(k)O(k) time,

  2. decrease-key takes O(k)O(k) time,

as we need to maintain the indicators bits in 2...k2...k levels. Finally,

  1. delete-min takes O(kΔ)O(k \Delta) time,

as we need to scan kk buckets, each of size Δ\Delta, to find the min element and delete it.

The time complexity now becomes

O(mk+nkΔ)=O(mk+nkC1k) O(mk + n k \Delta) = O( mk + nkC^\frac{1}{k})

Balancing the two terms, we get 1k=log⁡mnlog⁡C\frac{1}{k} = \frac{ \log \frac{m}{n} }{ \log C}, i.e., k=log⁡mnCk = \log_{\frac{m}{n} } C. To summarize, the time is

O(mlog⁡mnC) O(m \log_{\frac{m}{n} } C)

and the space overhead is

O(∑i=0k−1C/Δi)=O(C) O\left( \sum_{i = 0}^{k - 1} C / \Delta^i \right) = O(C)

Question to ponder: calculate the size of log⁡mnC\log_{\frac{m}{n} } C for some values of CC which you consider reasonable in real applications.

Time: O(m+nlog⁡Clog⁡log⁡C)O(m + n \frac{\log C}{\log \log C}), Space: O((log⁡Clog⁡log⁡C)2)O( (\frac{\log C}{\log \log C} )^2 ).

The core idea underlying the k-level bucket structure discussed in the previous section is that every integer ww in [0,C][0, C] can be written as a base-Δ\Delta number with length at most kk:

w=∑i=0k−1wi⋅Δi w = \sum_{i = 0}^{k - 1} w_i \cdot \Delta^i

where wi∈[0,Δ)w_i \in [0, \Delta). For convenience, we write it as

w=(wk−1,wk−2,...,w0)Δ w = (w_{k - 1}, w_{k - 2}, ..., w_0)_\Delta

Now the meaning of the kk-level buckets is clear. The kthk^{th} level partitions the ww's according to the value of wk−1w_{k - 1}. The (k−1)th(k - 1)^{th} level further partitions the ww's according to the value of wk−2w_{k - 2}, and so on. The path from the top level to bottom level is uniquely determined by the sequence (wk−1,wk−2,...,w0)Δ(w_{k - 1}, w_{k - 2}, ..., w_0)_\Delta. Indeed, this is a special case of trie, which has alphabet size Δ\Delta and contains only sequence of length kk.

But we may still waste some work. Remember that our goal for designing the data structure is to find the min element. Therefore, it suffices to distinguish the min element from the rest. We should not waste our effort in determining the relative order between the non-min elements.

In the following example, the min elements v1,v2v_1, v_2 fall into the subtree of the first bucket on the kthk^{th} level while vn−1,vnv_{n - 1}, v_n fall into the last one. As vn−1,vnv_{n - 1}, v_n are not min elements, it is unnecessary to maintain a subtree to separate them. Worse still, if later decrease-key is applied to either vn−1v_{n -1} or vnv_n, and if the new key has to be moved to a new bucket in the kthk^{th} level, the effort we have spent in constructing the sub-tree is completely useless.

Instead, we can create an auxiliary set to the last bucket on level kk, to pus vn−1,vnv_{n - 1}, v_n into the set directly. Similar idea apply to v3v_3 in the example.

The new structure we get is as follows:

In general, we may not even has a kk-level trie. In the example below, we can already determine the min-element v1v_1 at some level i>1i > 1. Why should we bother to expand the buckets further? Note that this idea is similar to that of trie compression used in Patricia trie.

We can be even more lazy. We expand a bucket only when it is necessary. See the example below as an example. In this example, C=15C = 15 and Δ=4\Delta = 4.

Now, to delete-min, we need to determine the order of v1,v2v_1, v_2.

Inserting a new element follows the current structure, until it finds a bucket to fall in.

Delete-min begin the search from the lowest level non-empty bucket block.

After delete-min, we may need to delete the empty bucket blocks recursively. For each bucket block, we maintain counter for the number of non-empty buckets. This allows us to delete empty bucket blocks in O(k)O(k) time.

We decrease the key of v4v_4 to 9. Note that we do not need to compare v4v_4 and v5v_5 for the moment. Bucket block counter is maintained when v4v_4 is moved to another bucket.

Delete-min is invoked again. We need to expand the bucket containing v5v_5 and v4v_4 to distinguish them.

We continue to insert a new element v6=12v_6 = 12.

We decrease the key of v6v_6 to 1010. It is pushed down by one level.

We are now prepared to analyze the amortized cost of these operations:

  1. Insert: we pay an O(k)O(k) amortized cost of insertion. Any subsequent pushed down operation on this element is charged to its insertion. Note that an element can be push-down by at most kk levels.

  2. Decrease-key: we pay an O(1)O(1) amortized cost of decrease-key operation. The push-down operation is charged to its insertion.

  3. Delete-min: to find the min-element, we scan the bucket block in the lowest level to find the first non-empty bucket. This has time Δ\Delta. If the bucket found contains multiple elements, we perform an expansion and push the elements down, the cost of which is charged to insertion of these elements. Finally, we may need to delete empty bucket blocks recursively, which has cost O(k)O(k).

Therefore, the Dijkstra's algorithm with this structure has runtime O(m+n(k+Δ))O(m + n(k + \Delta)), which is minimized when

k=Δ=(C+1)1k k = \Delta = (C + 1)^\frac{1}{k}

Solving the equation gives k=O(log⁡Clog⁡log⁡C)k = O(\frac{\log C}{\log \log C} ).

Time: O(m+nlog⁡C)O( m + n \sqrt{\log C} ), Space: O(log⁡C⋅2log⁡C)O(\sqrt{\log C} \cdot 2^{\sqrt{\log C} }).

Beginning from the naïve approach, we have improved a lot on both time and space overhead. Amazingly, this is not the end of the story. To motivate what is possibly the inefficient part of O(m+nlog⁡Clog⁡log⁡C)O(m + n \frac{\log C}{\log \log C}) approach, look at the following example.

Suppose that CC is a large number. Initially the structure is empty. We insert an element v1v_1 followed by v2v_2 and they fall into the same bucket in the kthk^{th} level. Next, we perform a delete-min operation. To distinguish, we expand the buckets all the way down to 1st1^{st} level.

But since there are two elements, why don't we compare them directly and return the minimum one? In such case we avoid the unnecessary expansions.

We can extend the idea further. If the number of elements in a bucket is too few, we refuse to expand it. Instead we make of copy of the elements and construct a comparison based priority queue (e.g., Fibonacci heap) on them. Further, note that we are only interested in the possible min-element. Therefore the heap is constructed for only one bucket that contains the min-element.

We use a parameter tt (to be determined) to control this number. In particular, if the number is at most tt, we construct a comparison based priority queue. We perform expansion only if the number exceeds tt.

  1. Insert: insert an element as before. If the element falls into the bucket that is associated with a priority, then also insert this element into the heap.

  2. Decrease-key: if the element is in the bucket that is associated with a priority queue, perform decrease-key in the queue. Otherwise, perform decrease-key as before. If the element falls into the bucket that is associated with a priority queue, then also insert this element into the heap.

  3. Delete-min:

  1. Find the first non-empty bucket in the lowest level
  2. IF the bucket has more than tt elements THEN
  3. \qquad Expand the bucket
  4. \qquad GO TO STEP 1
  5. ELSE
  6. \qquad IF ∄\nexists queue THEN
  7. \qquad\qquad Construct a queue
  8. \qquad Perform delete-min in the queue

Let I(t),D(t),X(t)I(t), D(t), X(t) be the time needed to perform insert, decrease-key and delete-min operation in a comparison based priority queue respectively. Then it is obvious that the the structure we propose, the amortized time we need for insert and decrease-key is

  1. Insert: O(k+I(t))O(k + I(t)).
  2. Decrease-key: O(D(t)+I(t))O(D(t) + I(t)).

To analyze the time needed for delete-min is more complicated. It relies on the implementation of the following:

Find the first non-empty bucket in the lowest level.

We record the previous deleted min element μ\mu (initially set to 0). Denote the Δ\Delta-base representation of μ\mu as

μ=(μk−1,μk−2,...,μ0)Δ \mu = (\mu_{k - 1}, \mu_{k - 2}, ..., \mu_0)_\Delta

Observe that the non-empty bucket block in the lowest level must contain μ\mu. Denote this level as ii. To find the first non-empty bucket, we start scan from μi−1th\mu_{i - 1}^{th} bucket in the ithi^{th} level. Therefore, each bucket block is only scanned once. Further, the buck block in the ithi^{th} level is created from the (i+1)th(i + 1)^{th} level because it contains more than tt elements. We can charge the scanned cost to these tt elements, each with Δt\frac{\Delta}{t}. As there are kk level, a element could be charge at most kk times. It follows that the amortized cost of delete-min is given by

  1. Delete-min: O(X(t)+kΔt)O(X(t) + \frac{k \Delta}{t})

For Fibonacci heap, I(t)=D(t)=1I(t) = D(t) = 1 and X(t)=log⁡tX(t) = \log t. Therefore, we have runtime

O(m+n[k+log⁡t+kΔt]) O \left( m + n \left[k + \log t + \frac{k \Delta}{t} \right] \right)

To minimize it, we need to set k=log⁡t=kΔtk = \log t = \frac{k \Delta}{t}. It holds that t=2kt = 2^k and t=Δ=C1kt = \Delta = C^\frac{1}{k}, which implies that 2k=2log⁡Ck2^{k} = 2^{ \frac{\log C}{k} } and k=log⁡Ck = \sqrt {\log C}. The time is

O(m+nlog⁡C) O(m + n \sqrt{\log C})

and the space overhead is O(kΔ)=O(log⁡C⋅2log⁡C)O(k \Delta) = O(\sqrt{\log C} \cdot 2^{\sqrt{\log C} }).

Reference

[1]. David R. Karger, MIT Advanced Algorithm, 2013.
[2]. B. V. Cherkassky, A. V. Goldberg, and C. Silverstein, “Buckets, Heaps, Lists, and Monotone Priority Queues,” SIAM J. Comput., vol. 28, no. 4, pp. 1326–1346, Jan. 1999