Pull to refresh

Time Limit Exceeded это не только про сложность алгоритма

Level of difficultyMedium
Reading time6 min
Views4K

Приветствую всех пользователей Хабра!

Disclaimer: Я не какой-то Senior разработчик на C/C++ и не выигрывал ICPC. Я просто пишу код на Golang и иногда решаю алгоритмические задачки. И не претендую ни на что!

Предыстория

Вчера я как обычно зашел на Leetcode, чтобы решить ежедневную задачку. Условие:

An attendance record for a student can be represented as a string 
where each character signifies whether the student was absent, late, or present on that day. 
The record only contains the following three characters:

'A': Absent.

'L': Late.

'P': Present.

Any student is eligible for an attendance award if they meet both of the following criteria:

The student was absent ('A') for strictly fewer than 2 days total.

The student was never late ('L') for 3 or more consecutive days.

Given an integer n, return the number of possible attendance records of length n that make a student eligible for an attendance award. The answer may be very large, so return it modulo 109 + 7

Я написал код, проверил на тестовых значениях, все ок. Отправляю решение и получаю Time Limit Exceeded. Посидев и подумав еще немного, я не нашел решения лучше. Пошел смотреть решения. Наткнулся на следующее решение. Здесь человек пишет, что такое решение подходит, но оно не будет проходить по времени из-за большого количества вызовов функций. Он преобразовал это решение под итеративный подход.

Получилось два решения:

Мое (TLE):

class Solution {
public:
    int mod = 1e9+7;

    int checkRecord(int n) {
        map<tuple<int,int,int>, int> memo;
        return helper(n, 0, 0, memo);
    }

    int helper(int n, int absence, int consLate, map<tuple<int,int,int>,int>& memo) {
        if (n == 0) return 1;
        if (memo.find({n, absence, consLate}) != memo.end()) return memo[{n,absence, consLate}];

        int result = 0;
        result = (result + helper(n-1, absence, 0, memo)) % mod;
        if (absence < 1) {
            result = (result + helper(n-1, absence+1, 0, memo)) % mod;
        }
        if (consLate < 2) {
            result = (result + helper(n-1, absence, consLate+1, memo)) % mod;
        }

        memo[{n,absence, consLate}] = result;
        return result;
    }
}

Пользователя:

class Solution {
private:
    static const int MOD = 1000000000 + 7;

public:
    int checkRecord(int n) {
        vector<vector<int>> prevDP(2, vector<int>(3, 1));

        for (int i = 1; i <= n; i++) {
            vector<vector<int>> newDP(2, vector<int>(3, 0));
            for (int a = 0; a < 2; a++) {
                for (int l = 0; l < 3; l++) {
                    // Pick P
                    newDP[a][l] += prevDP[a][2];
                    newDP[a][l] %= MOD;
                    if (a > 0) {
                        // Pick A
                        newDP[a][l] += prevDP[a - 1][2];
                        newDP[a][l] %= MOD;
                    }
                    if (l > 0) {
                        // Pick L
                        newDP[a][l] += prevDP[a][l - 1];
                        newDP[a][l] %= MOD;
                    }
                }
            }
            prevDP = newDP;
        }

        return prevDP[1][2];
    }
};

Если так взглянуть на эти решения, то сложность по выполнению у них одинаковая O(n), то есть линейная. Тут я и решил поинтересоваться насколько же мое решение оказывается медленее.

Профилирование

Я написал самый простой бенчмарк и запускал функции 100 раз с максимальными по объему входными данными.

Профилирование я делал на Windows (каюсь) при помощи gprof. Профиль моего кода выдал следующий результат без учета внутренних функций самого gprof:

  2.03     27.47     0.66 505560900     0.00     0.00  std::__tuple_compare<std::tuple<int, int, int>, std::tuple<int, int, int>, 0ull, 3ull>::__less(std::tuple<int, int, int> const&, std::tuple<int, int, int> const&)
  1.57     27.98     0.51 1338627000     0.00     0.00  std::tuple_element<0ull, std::tuple<int, int, int> >::type const& std::get<0ull, int, int, int>(std::tuple<int, int, int> const&)
  1.26     28.39     0.41 1338627000     0.00     0.00  std::_Head_base<0ull, int, false>::_M_head(std::_Head_base<0ull, int, false> const&)
  1.08     28.74     0.35 1338627000     0.00     0.00  int const& std::__get_helper<0ull, int, int, int>(std::_Tuple_impl<0ull, int, int, int> const&)

Сам бенчмарк выполнялся что-то около 32 секунд, в топе функций видим не результат глубоких рекурсий, а сравнение tuple, который я использовал в качестве ключа к map, и получение значений из этого же tuple.

Профиль решения пользователя выдал что-то следующее:

  1.63      1.10     0.02  1000100     0.00     0.00  std::vector<int, std::allocator<int> >* std::__do_uninit_fill_n<std::vector<int, std::allocator<int> >*, unsigned long long, std::vector<int, std::allocator<int> > >(std::vector<int, std::allocator<int> >*, unsigned long long, std::vector<int, std::allocator<int> > const&)
  0.81      1.11     0.01 12000600     0.00     0.00  __gnu_cxx::__normal_iterator<int const*, std::vector<int, std::allocator<int> > >::base() const
  0.81      1.12     0.01  9000000     0.00     0.00  __gnu_cxx::__normal_iterator<int*, std::vector<int, std::allocator<int> > >::__normal_iterator(int* const&)

И код выполнялся около одной с четвертью секунды.

Раз такое дело, я решил убрать tuple из своего кода. Я представил этот tuple в виде int, считая, что tuple это просто три индекса. Таким образом, tuple(i, j, k) = i*6 + j*3 + k. Значения 6 и 3 выходят из условия задачи.

Операции с получением значений из tuple и сравнения должны уйти. Делаю профиль, получаю:

  2.40      9.72     0.27 505560900     0.00     0.00  std::less<int>::operator()(int const&, int const&) const
  2.32      9.98     0.26 25994400     0.00      0.00  std::_Rb_tree<int, std::pair<int const, int>, std::_Select1st<std::pair<int const, int> >, std::less<int>, std::allocator<std::pair<int const, int> > >::_M_lower_bound(std::_Rb_tree_node<std::pair<int const, int> >*, std::_Rb_tree_node_base*, int const&)
  1.51     10.15     0.17 503228700     0.00     0.00  std::_Rb_tree<int, std::pair<int const, int>, std::_Select1st<std::pair<int const, int> >, std::less<int>, std::allocator<std::pair<int const, int> > >::_S_key(std::_Rb_tree_node<std::pair<int const, int> > const*)
  1.42     10.31     0.16 503228700     0.00     0.00  std::_Rb_tree_node<std::pair<int const, int> >::_M_valptr() const
  1.25     10.45     0.14 503228700     0.00     0.00  __gnu_cxx::__aligned_membuf<std::pair<int const, int> >::_M_ptr() const

Здесь я увидел деревья. Погуглив, я узнал, что map в C++ - это дерево, а мне нужно было unordered_map (правда сделал я это после написания дальнейшего решения). Получается сложность моего первого алгоритма все-таки была O(n*logn), а не O(n).

Хорошо, заменил map на unordered_map, сложность стала O(n). Т.к. эта структура данных использует std::hash для вычисления хэша, вернуть tuple не получилось, оставляю int. Делаю профиль:

  2.17      2.47     0.07 25554600     0.00     0.00  std::__detail::_Hash_node<std::pair<int const, int>, false>::_M_next() const
  1.55      2.52     0.05 24994400     0.00     0.00  std::_Hashtable<int, std::pair<int const, int>, std::allocator<std::pair<int const, int> >, std::__detail::_Select1st, std::equal_to<int>, std::hash<int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::_M_find_before_node(unsigned long long, int const&, unsigned long long) const
  1.55      2.57     0.05      100     0.50     7.74  helper(int, int, int, std::unordered_map<int, int, std::hash<int>, std::equal_to<int>, std::allocator<std::pair<int const, int> > >&)

Получаю большое количество вызовов std::hash, но время все-таки уменьшилось. Нужно думать дальше.

Зная максимальное значение входных данных, можно посчитать максимальное количество возможных комбинаций и вместо мапы использовать вектор. Так как запись и чтение из вектора это O(1) и по факту просто чтение из памяти со сдвигом, то это должно ускорить этот код.

Заменил мапу на вектор, выделив ему (1e6+1)*6 интов. Делаю профиль, получаю:

100.00      0.01     0.01      100   100.00   100.00  __gnu_cxx::__enable_if<std::__is_scalar<int>::__value, void>::__type std::__fill_a1<int*, int>(int*, int*, int const&)
  0.00      0.01     0.00      300     0.00     0.00  std::__new_allocator<int>::~__new_allocator()
  0.00      0.01     0.00      200     0.00     0.00  std::__new_allocator<int>::_M_max_size() const
  0.00      0.01     0.00      200     0.00     0.00  std::allocator<int>::~allocator()

Вуаля, это решение работает еще быстрее, чем то, что предложил пользователь.

Заключение

Что я хотел сказать этой статьей. Я хотел поделиться со всеми вами тем, что даже знание правильного подхода не гарантирует, что ваше решение примут, как произошло со мной на Leetcode. Ведь по сути разные только подходы, а сложность самих решений одинакова.

Можно ли как-то проверять по-другому время выполнения решения? Наверное, можно, но не нужно. Всегда можно придумать какой-нибудь велосипед, который не понятно как работал бы. С точки зрения автоматической проверки проще всегда проверить, что код выполняется за определенное время, чем магическими способами высчитывать сложность алгоритма.

И в защиту себя от негативных комментариев хочу сказать, что я не хотел высказать какую-то свою обиду или нажаловаться :)

Просто делюсь своим таким вот опытом.

P.S. Этой второй раз, когда решение не принимается, потому что я не доглядел (передал вектор по значению, а не по ссылке) или просто что-то не знал.

Tags:
Hubs:
Total votes 11: ↑9 and ↓2+9
Comments36

Articles