IOI18F Meetings Post 1
This is the first of a series of blog posts about the task `meetings' from IOI18. It is also available in a contest that will take place in conjunction with these posts.
We chose this problem as an experiment to see whether we can present a single problem in a way that it covers a lot of useful knowledge for a contest related topic. In this aspect, meetings is a great task in that it starts with dynamic programming, then moves into range queries and tree constructions, and finally, finishes it off with geometric data structures.
Furthermore, the setting of meetings is as classical as a data structure problem can get: you get a static array, and you query about some statistic on its subintervals. This is a setting that has been viewed as "completely understood" by most competitors who aim for top 40 or higher at the IOI since around 2005. However, this problem was given, and had 0 solvers. I've also heard several past contestants who placed in the Top-3 at IOIs refer to this problem as "Godly", so that's another reason to discuss it in more details.
In short, the ideas underlying meetings are extremely novel, especially given how well understood the topic is. Also, if mechanically you can solve meetings in 2 hours reliably, you shouldn't have trouble with most data structural topics.
Problem Statement
Meetings is a problem with a backstory about mountains that lie in a row. These mountains are numbered from through in order, and the only way to travel from mountain to mountain , assuming , is to visit mountains through . Mountain has height . One person lives at the top of each mountain.
We say that the cost of traveling from mountain to mountain is equal to the maximum height of all the mountains from mountain to mountain , inclusive.
On each of days, a meeting will be held. On day , the people in the mountains from to , inclusive, want to meet at the top of a specific mountain between mountain and mountain . They wish to select a mountain that minimizes the sum of the costs that each individual incurs. The total sum of the costs that each individual incurs is the cost of the meeting.
Problem Formalization
The problem here is to compute, for each of the days, the minimum possible cost of the meeting on that day.
We start by formalizing the problem. Imagine that we are given an array of integers. Define for to be the maximum of , and define if . For pairs of integers , compute .
Problem Examples
We'll use the examples in the provided problem statement to help make this more clear. Let , let , and let .
We first analyze .
If we set , then we're computing . . . , for a total sum of .
If we set , then we're computing . . . , for a total sum of .
If we set , then we're computing . . . , for a total sum of .
Therefore, the optimal choice for is , giving a sum of .
We now analyze .
If we set , then we're computing . . . , for a total sum of .
If we set , then we're computing . . . , for a total sum of .
If we set , then we're computing . . . , for a total sum of .
Therefore, the optimal choice for is , giving a sum of .
Rephrasing the Problem
In the original problem, we were given pairs of integers for which we were asked to compute . Imagine that we had to report the answers for all pairs of integers. How quickly can we generate the answers for all of these pairs? Because there are pairs of integers, any such approach would be , so the best that we can aim for is a algorithm.
Initial Thoughts
If we wanted to maximize the given sum for a given interval , then we would wish to pick such that was the maximum value in the subarray from to - this is because every subarray contains , and the sum we get is , which is the maximum attainable sum. In our problem though, we're trying to minimize the given sum. A natural thing to try would be to pick the minimum value in the subarray. If we look closely at the examples, we see that the optimal choice of was the minimum value in the subarray. Does this value always give us the minimum sum?
It turns out that the answer is no. Consider the following example: , with . If we picked because is the smallest value in that subarray, the cost would be . However, if we picked , then our cost would be , which turns out to be optimal in this scenario.
Starting with Brute Force
Without a clear way to tell, for a given pair of integers , which value of minimizes the desired sum, we can start just by trying all of them. The most naive brute force approach might look something like this:
def m(A, i, j):
if j < i:
return m(A, j, i)
maximum_value = A[i]
for k in i+1 to j, inclusive:
maximum_value = max(maximum_value, A[k])
return maximum_value
# assume A is the given array of integers, and the desired interval is [l, r]
def f(A, l, r):
best_cost = infinity
for k in l to r, inclusive:
cost_with_meeting_at_k = 0
for i in l to r, inclusive:
cost_with_meeting_at_k += m(A, i, k)
best_cost = min(best_cost, cost_with_meeting_at_k)
return best_cost
What is the runtime of this algorithm? We can annotate the above code.
def m(A, i, j):
# runs in O(N) time
if j < i:
return m(A, j, i)
maximum_value = A[i]
for k in i+1 to j, inclusive: # O(N)
maximum_value = max(maximum_value, A[k])
return maximum_value
# assume A is the given array of integers, and the desired interval is [l, r]
def f(A, l, r):
# runs in O(N^3) time
best_cost = infinity
for k in l to r, inclusive: # O(N)
cost_with_meeting_at_k = 0
for i in l to r, inclusive: # O(N)
cost_with_meeting_at_k += m(A, i, k) # O(N)
best_cost = min(best_cost, cost_with_meeting_at_k)
return best_cost
If we passed in all possible inputs to this function, we would solve the problem in time, which is very far from the best-case performance of .
One way we can start optimizing this is by precomputing all of the possible outputs that m
can give. If we precompute all of these results, then the computation in the innermost for loop is instead of . This means that f
runs in time, and as long as we can precompute all of the possible outputs for m
in , we can solve the problem in time.
Computing range maxima quickly
The simplest approach to precomputing all possible outputs for m
is as follows:
def m(A, i, j):
# runs in O(N) time
if j < i:
return m(A, j, i)
maximum_value = A[i]
for k in i+1 to j, inclusive: # O(N)
maximum_value = max(maximum_value, A[k])
return maximum_value
def precompute_m(A):
n = len(A)
m_values = {}
for i in 0 to n-1, inclusive:
for j in i to n-1, inclusive:
m_values[i][j] = m(A, i, j)
return m_values
This approach runs in time, which is a sufficient improvement initially. However, if we want to solve the original problem in time, then we also need to precompute all of these values in time.
Imagine the following hypothetical situation: . We note the following:
m(A, 0, 0) = max([1]) = 1
m(A, 0, 1) = max([1, 3]) = 3
m(A, 0, 2) = max([1, 3, 2]) = 3
m(A, 0, 3) = max([1, 3, 2, 4]) = 4
If we look carefully at the lists that we're computing maxima for, if we hold constant and increment , we're merely adding new integers to the list. Adding a new integer cannot decrease the maximum of the list - in fact, the only way that it increases the maximum of the list is if the given integer is larger than any of the previous integers. This leads us to the following conclusion when : . This means that, for a fixed value of , we can compute for in time. Since there are distinct possibilities for , we can compute all possible values of m
in time. Sample pseudocode for this is as follows:
def precompute_m(A):
n = len(A)
m_values = {}
for i in 0 to n-1, inclusive:
m_values[i][i] = A[i]
for j in i+1 to n-1, inclusive:
m_values[i][j] = max(m_values[i][j-1], A[j])
return m_values
If we also needed to maintain where to find these values, we can add that information as well, as follows:
def precompute_m(A):
n = len(A)
m_values = {}
m_index = {}
for i in 0 to n-1, inclusive:
m_values[i][i] = A[i]
m_index[i][i] = i
for j in i+1 to n-1, inclusive:
if A[j] > m_values[i][j-1]:
m_values[i][j] = m_values[i][j-1]
m_index[i][j] = m_index[i][j-1]
else:
m_values[i][j] = A[j]
m_index[i][j] = j
return m_values, m_index
Further Observations
We'll return to the mountain analogy for ease of explanation. We noticed earlier that if everyone met on the highest mountain, this would maximize the cost of the meeting. Outside of the case where there's only one mountain, this means that we should consider a mountain at a lower elevation, either to the left or to the right of the mountain with the highest elevation. For notational convenience, let the meeting comprise the individuals from mountains to , and let mountain be the mountain with the highest elevation (choosing one arbitrarily if multiple mountains share the highest elevation).
Assume without loss of generality that the meeting is held to the right of mountain . Everyone on mountains through will contribute cost equal to the height of mountain since they must travel through it to meet with the other individuals and it is the highest mountain. Since their costs are now fixed, we can ignore them and consider only the individuals on mountains to . Note though that this is a strictly smaller instance of the original problem, and a solution in the situation of considering the individuals from to generalizes to the original problem instance of considering the individuals on mountains through . By symmetric logic, if the mountain is to be held to the left of mountain , then all the individuals on mountains through contribute cost equal to the height of mountain , and we are solving a smaller instance of organizing a meeting for the individuals on mountains through .
Example
Consider , and imagine that we are computing . is the maximum value in the array. If the optimal choice is to the right, then three individuals incur a cost of , and we solve the problem instance , and the choices are index , giving , or index , giving . The final cost in this situation is . If the optimal choice is to the left, then three individuals incur a cost of , and we solve the problem instance , and the choices are index , giving , or index , giving . The final cost in this situation is . The minimum cost of the meeting is claimed to be .
If we compute manually for all possible choices of location, we get that location has a cost of , location has a cost of , location has a cost of , location has a cost of , and location has a cost of . This demonstrates that the minimum cost is .
Pseudocode and Performance
We outline the following pseudocode for this approach:
def precompute_m(A):
n = len(A)
m_values = {}
m_index = {}
for i in 0 to n-1, inclusive:
m_values[i][i] = A[i]
m_index[i][i] = i
for j in i+1 to n-1, inclusive:
if A[j] > m_values[i][j-1]:
m_values[i][j] = m_values[i][j-1]
m_index[i][j] = m_index[i][j-1]
else:
m_values[i][j] = A[j]
m_index[i][j] = j
return m_values, m_index
def f(A, l, r):
precompute m_index with precompute_m, if it hasn't already been done
if l > r:
# base case, the array is empty
return 0
if we've computed f(A, l, r), return it directly
k = m_index[i][j]
# if the optimal choice is in [l, k-1]
left_cost = f(A, l, k-1) + A[k] * (r-k+1)
# if the optimal choice is in [k+1, r]
right_cost = f(A, k+1, r) + A[k] * (k-l+1)
optimal_cost = min(left_cost, right_cost)
memoize f(A, l, r) = optimal_cost
return optimal_cost
What's the complexity of this approach? This isn't immediately obvious - note for example that calling might end up calling , which calls , going all the way down to , so a single call might take linear time to run. If we call it again though, the run returns immediately due to the memoization. Is it possible that answering the queries in a particular order could result in runtime?
It turns out that we can obviate the need to think about the order in which we answer the queries if we order them in such a way that the performance is guaranteed to be . Imagine if we answer the queries in nondecreasing order of - we would start by computing for all valid , then for all valid , going all the way to . Note that the intervals and are strictly smaller than the interval . Therefore, if we process the queries in increasing size order, then because we memoize all the results, a given invocation of f
will hit memoized values, or zero, immediately, and each query is guaranteed to be answered in constant time.
Runtime Analysis
When considering if an algorithm will be fast enough to solve a problem, keep in mind that modern-day machines are capable of executing on the order of 100 million operations a second. As a result, one quick way of evaluating if an algorithm is good enough to solve a problem is to plug in the values directly into the bound and compare it to 100 million.
For example, when is 5000, gives 25 million whereas gives 125 billion, so an algorithm is too slow but an algorithm will be fast enough.
Comments