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

fde401eda3656daa34f477ba24242866.jpg

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

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, int> memo;
        return helper(n, 0, 0, memo);
    }

    int helper(int n, int absence, int consLate, map,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> prevDP(2, vector(3, 1));

        for (int i = 1; i <= n; i++) {
            vector> newDP(2, vector(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, 0ull, 3ull>::__less(std::tuple const&, std::tuple const&)
  1.57     27.98     0.51 1338627000     0.00     0.00  std::tuple_element<0ull, std::tuple >::type const& std::get<0ull, int, int, int>(std::tuple 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 >* std::__do_uninit_fill_n >*, unsigned long long, std::vector > >(std::vector >*, unsigned long long, std::vector > const&)
  0.81      1.11     0.01 12000600     0.00     0.00  __gnu_cxx::__normal_iterator > >::base() const
  0.81      1.12     0.01  9000000     0.00     0.00  __gnu_cxx::__normal_iterator > >::__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::operator()(int const&, int const&) const
  2.32      9.98     0.26 25994400     0.00      0.00  std::_Rb_tree, std::_Select1st >, std::less, std::allocator > >::_M_lower_bound(std::_Rb_tree_node >*, std::_Rb_tree_node_base*, int const&)
  1.51     10.15     0.17 503228700     0.00     0.00  std::_Rb_tree, std::_Select1st >, std::less, std::allocator > >::_S_key(std::_Rb_tree_node > const*)
  1.42     10.31     0.16 503228700     0.00     0.00  std::_Rb_tree_node >::_M_valptr() const
  1.25     10.45     0.14 503228700     0.00     0.00  __gnu_cxx::__aligned_membuf >::_M_ptr() const

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

  2.17      2.47     0.07 25554600     0.00     0.00  std::__detail::_Hash_node, false>::_M_next() const
  1.55      2.52     0.05 24994400     0.00     0.00  std::_Hashtable, std::allocator >, std::__detail::_Select1st, std::equal_to, std::hash, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits >::_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, std::equal_to, std::allocator > >&)

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

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

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

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

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

Заключение

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

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

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

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

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

© Habrahabr.ru