Algorithm: Choose an efficient way for Dynamic Programming(LeetCode.5899)

Algorithm: Choose an efficient way for Dynamic Programming(LeetCode.5899)

I met a problem during the weekly competition of LeetCode. I think I write the code which is correct and near the time complexity the problem supposed. However, I got a TLE when I submited it. This blog is a summary for that.

The problem

Two Best Non-Overlapping Events

LeetCode5899.png

My original solution

  1. Sort the input by the end time of each event.
  2. Iterate the input, for each event, we construct a state transition equation
    f[0][i] = max(f[0][i-1], len[i])
    f[1][i] = max(f[0][k] + len[i], f[0][i])
    
    in which f[0][i] indicates the maximum value of the single event we can get before the end time of event i. f[1][i] indicates that maximum value of double events. k indicates the last event whose end time is earlier than the end time of event i. Finally, len[i] indicates the length of the event i. Besides, we save the sequence when we iterate it and find k with bisection method.
  3. f[1][n] is the result we want.

The code of my implemention is shown below and it has a lot of redundancy. Let's take a quick look and analyze the details in next section.

class Solution
{
public:
    static const int N = 100001;
    unordered_map<int, int> f0;
    map<int, int> f1;
    vector<int> f;

    static bool cmp(const vector<int> a, const vector<int> b)
    {
        return a.at(1) < b.at(1);
    }

    int find(int num)
    {
        int l = 0;
        int r = f.size() - 1;
        while (l < r)
        {
            int mid = (l + r + 1) / 2;
            if (f[mid] > num)
            {
                r = mid - 1;
            }
            else
            {
                l = mid;
            }
        }
        return f.at(r);
    }

    int maxTwoEvents(vector<vector<int>> &events)
    {
        sort(events.begin(), events.end(), cmp);
        f.push_back(0);
        f.push_back(events.at(0).at(1));
        f0.insert({0, 0});
        f1.insert({0, 0});
        f0.insert({events.at(0).at(1), events.at(0).at(2)});
        f1.insert({events.at(0).at(1), events.at(0).at(2)});
        int n = events.size();

        for (int i = 1; i < n; i++)
        {
            int start = events.at(i).at(0);
            int end = events.at(i).at(1);
            int len = events.at(i).at(2);
            int idx = find(start - 1);
            int top = max(len, f0[events.at(i - 1).at(1)]);
            if (!f0.count(end))
            {
                f0[end] = top;
                f.push_back(end);
            }
            else
            {
                f0[end] = max(f0[end], top);
            }
            if (!f1.count(end))
            {
                f1[end] = f0[idx] + len;
            }
            else
            {
                f1[end] = max(f1[end], f0[idx] + len);
            }
        }

        int r = 0;
        for (auto item : f1)
        {
            r = max(r, item.second);
        }
        return r;
    }
};

The shortcoming of my algorithm and implemention

The code above is completely a dreadful mess, isn't it?

Firstly, there is some problem with the state transition equation. Yes, it's right and it can works. But do we really need to save the pre-value of f[1]? The second part of the equation is f[1][i] = max(f[0][k] + len[i], f[0][i]). It never uses the value saved in f[1]! Since it's not neccessary for us to save the value of f[1], we could optimize the state transition equation.

f[i] = max(f[i-1], len[i])
result = max(result, f[i], f[k] + len[i])

In fact, it saves the value of single event of some end-time and then enumerate these events and get the maximum value. Thus we reduce not only the space complexity but also the difficulty of implemention.

Simple is the best!

Secondly, I used sort(events.begin(), events.end(), cmp); in my code, which sorts a vector of type vector<vector<int>>. That means, we need to move the elements of type vector<int> when we sort it , which costs a lot of time and may feedback us a TLE.

However, the step of sorting is neccessary because we need to know the maximum value we can get before the end-time of each event. The key is that we can avoid sorting the events directly. Instead, we can sort the index of the events and get the events by it. The code may seems like this.

vector<int> p(n);
for(int i = 0; i < n; i++) p[i] = i;
sort(p.begin(), p.end(), [&](const int a, const int b){
    return events[a][1] < events[b][1];
})

Finally, when I got the idea that we can use a sorted array of the indices of events, I suddenly understand what a complicated we I chose to save f[i]. I used map<int, int> to save f[i] because the range of the end-time is to large, which is 10^9, making it impossible to use an array of equal size of the range to save the end-times. However, when we have the array of indices, the code may seem like this.

vector<int> f(n);
f[0] = events[p[0]][2];
for (int i = 1; i < n; i++)
{
    f[i] = max(f[i - 1], events[p[i]][2]);
}

Thus we find a simplier way to express f[i].

After the revise, the code is much more clean:

class Solution
{
public:
    int n;

    int maxTwoEvents(vector<vector<int>> &events)
    {
        n = events.size();
        vector<int> p(n);
        for (int i = 0; i < n; i++)
        {
            p[i] = i;
        }
        sort(p.begin(), p.end(), [&](const int a, const int b)
             { return events[a][1] < events[b][1]; });

        // Init all the f[i]
        vector<int> f(n);
        f[0] = events[p[0]][2];
        for (int i = 1; i < n; i++)
        {
            f[i] = max(f[i - 1], events[p[i]][2]);
        }

        // find an event whose end-time is earlier than num with bisection method
        auto find = [&](int num)
        {
            int l = 0;
            int r = n - 1;
            while (l < r)
            {
                int mid = (l + r + 1) / 2;
                if (events[p[mid]][1] >= num)
                {
                    r = mid - 1;
                }
                else
                {
                    l = mid;
                }
            }
            return r;
        };

        // Get the result
        int r = 0;
        for (auto e : events)
        {
            r = max(r, f[find(e[1] + 1)]);
            int pre_idx = find(e[0]);
            if (events[p[pre_idx]][1] < e[0])
            {
                r = max(r, f[pre_idx] + e[2]);
            }
        }
        return r;
    }
};

At this time , the code makes us more comfortable, right?

However, there is a last preblem in the code.

// Get the result
int r = 0;
for (auto e : events)
{
    r = max(r, f[find(e[1] + 1)]);  // Here
    int pre_idx = find(e[0]);
    if (events[p[pre_idx]][1] < e[0])
    {
        r = max(r, f[pre_idx] + e[2]);
    }
}

In this block, we use r = max(r, f[find(e[1] + 1)]) to find the position in the f of current event to include the condition that we only choose one event. However, let's think about a question "is the order of comparison important?". Yeah, in each loop, we compare the r = max(r, f[find(e[1] + 1)]) with r, but whether we compare f[i] or f[j] has no effect. As long as we don't miss any elements in f, the result will be correct. Then, we can revise this block to make it cost less time.

// Get the result
int r = 0;
for (auto e : events)
{
    r = max(r, e[2]);  // Here
    int pre_idx = find(e[0]);
    if (events[p[pre_idx]][1] < e[0])
    {
        r = max(r, f[pre_idx] + e[2]);
    }
}

Conclusion

The reason why I write a "bad" code at the beginning is that I was too inflexible to use Dynamic Programming. I used to think DP means we construct state and equation, solve the problem in a loop and get a certain element in array as result. However, in this problem, it will confuse you if you stick to using state transition equation to get the final result.

Besides, the skill of using STL is important, sometimes what you need is a better implemention to help make your code more efficient.

I'm Rinne and I like programming! Welcome to talk about computer technology and anything about programming with me!