Открытые вакансии на должность разработчика в Яндексе есть всегда. Компания развивается, и хороших программистов не хватает постоянно. И претендентов на эти должности тоже хоть отбавляй. Главная сложность – отобрать действительно подходящих кандидатов. И в этом плане Яндекс мало чем отличается от большинства крупных IT-компаний. Так что базовые принципы, описываемые в этой статье, могут быть применимы не только к Яндексу.
Однако стоит оговориться, что статья все же про подбор разработчиков. Т.е. собственно тех восьмидесяти процентов сотрудников, на которых держится массовая разработка. Часто мы нанимаем людей на специальные вакансии: например, разработчиков систем компьютерного зрения, лингвистов, экспертов по машинному обучению. В этом случае формат собеседования может заметно отличаться.
Чего ждать на собеседовании
Резюме и опыт работы позволяет составить первое впечатление о кандидате, однако есть некоторая проблема: умение хорошо писать резюме и умение программировать не сильно коррелируют. Так что есть огромное количество людей, которые при отличном резюме и «опыте» не могут написать работающий код, и есть некоторое количество кандидатов, резюме у которых – навевает тоску, а сами люди при этом стоящие профессионалы. Поэтому, чтобы действительно хорошо оценить возможности разработчика, без прямого общения не обойтись.
Иногда, чтобы понять, что кандидат не подходит, достаточно десяти минут: если человека ставят в тупик базовые вопросы о синтаксисе языка, на котором он по его утверждению писал несколько лет, дальнейший разговор смысла не имеет. Именно поэтому чаще всего первый этап серии собеседований в Яндексе обычно проводится через Skype. Все-таки отказать человеку, который добирался до офиса час по пробкам на пятой минуте собеседования – нехорошо с точки зрения вежливости, а еще 2 часа его мучать, зная что, скорее всего, не возьмешь – с точки зрения этики. Соответственно, удаленное интервью позволяет сэкономить время и нервы обеим сторонам.
С вопросами о синтаксисе главное – не перестараться, намеренно пытаясь подловить на каком-нибудь малоизвестном факте. Есть языки программирования с очень длинной и непростой историей, у которых примерно половина их возможностей – это какие-то исторически сложившиеся сложные и ненужные костыли. К таким, например, относится и наш любимый C++. Если вы не разработчик компилятора C++, почти всегда можно найти что-то, чего вы в языке не знаете. Просто непонятно, зачем это могло бы вам понадобиться.
Мы обычно используем заранее подготовленные тесты по языку, в которые входит 10-15 вопросов на знание синтаксиса, возможностей языка, принципов управления памятью и т.д. Чаще всего для успешного прохождения ответов на все вопросы не требуется, достаточно 70-80 процентов. Да и вообще сам тест – это скорее не тест, а набор тем, на которые нужно поговорить, нас интересует скорее не сам ответ (мы его и так знаем), а почему кандидат его выбрал.
Важный момент – знание алгоритмов. Здесь тоже нужно не перестараться, наверняка очень мало из людей, которые сами проводят собеседования, наизусть помнят как вращать красно-черное дерево, но знать как устроены контейнеры вашего любимого языка для того чтобы знать их ограничения и уметь в выбирать правильные – необходимо. На самом деле читать Кнута для того, чтобы изучить эту область не нужно, по сути, к этому этапу можно подготовиться, вдумчиво изучив примерно 30 страниц в Википедии.
- en.wikipedia.org/wiki/Dynamic_array
- en.wikipedia.org/wiki/Hash_table
- en.wikipedia.org/wiki/B-tree
- en.wikipedia.org/wiki/Rb-tree
- en.wikipedia.org/wiki/Self-balancing_binary_search_tree
- en.wikipedia.org/wiki/Heap_(data_structure)
- en.wikipedia.org/wiki/Trie
- en.wikipedia.org/wiki/Skip_list
- en.wikipedia.org/wiki/Graph_(abstract_data_type)
en.wikipedia.org/wiki/Sorting_algorithm
Помимо прочтения статей из списка, необходимо пройтись по всем релевантным ссылкам в них, так же вдумчиво их изучить, а при необходимости пойти еще глубже.
Пишем код
Но главное для разработчика – это, конечно, умение писать хороший код. И принимать программиста на работу только на основе его теоретических знаний, мягко говоря, странно. Тут можно вспомнить отрывок из книги Тома де Марко и Тимоти Листера «Человеческий фактор»:
Менеджер: «Как давно вы жонглируете?»
Кандидат: «Ну, примерно шесть лет».
Менеджер: «Тремя, четырьмя, пятью шарами умеете жонглировать?»
Кандидат: «Да, да и да».
Менеджер: «Работаете с горящими предметами?»
Кандидат: «Конечно».
Менеджер: «…ножами, топорами, открытыми коробками с сигарами, мягкими широкополыми шляпами?»
Кандидат: «Мне всё равно, чем жонглировать».
Менеджер: «А какую-нибудь весёлую скороговорку знаете?»
Кандидат: «Онабесподобна».
Менеджер: «Что ж, мне нравится. Думаю, мы вас возьмём».
Кандидат: «Аааа… Не хотите посмотреть, как я жонглирую?»
Менеджер: «Хм, мне как-то это не пришло в голову».
Поэтому, на последнем этапе кандидату предлагается выполнить практическое задание.
Сейчас мы вам приведем разбор задачи подобной тем, что могут попасться вам на собеседовании. Мы придумали ее специально для демонстрации среднего уровня сложности.
По условию задачи у вас есть формула с цифрами, операциями +-*/ и скобками. Нужно написать программу, которая ее вычисляет. Формула может быть большой. Хочется, чтобы программу можно было легко дорабатывать, вводя функции, параметры и т.д.
Перед тем как оставить кандидата один на один с задачей, мы обычно спрашиваем, как он собирается ее решать. Мало приятного через 2 часа обнаружить, что человек понятия не имеет с какой стороны к задаче приступить. В момент обсуждения можно немного направить и подсказать алгоритм.
Для проверки уровня сложности задачи мы дали ее двум нашим сотрудникам: программисту и менеджеру, который раньше был программистом, но не практикует уже несколько лет. Обоих мы попросили не освежать теорию и писать по памяти. Первый справился с задачей за два часа, а у второго на решение ушло четыре часа. Задача получилась несколько сложнее, чем стандартные, на решение которых обычно уходит от получаса до двух. Для примера разберем решение максимально наглядно. Опытный разработчик осознает все это, не задумываясь.
Итак, мы парсим формулу. В результате хотим получить синтаксическое дерево, где в узлах будут операции, а в листьях – константы. Если бы скобок не было, дерево было бы очень простым. У нас есть два приоритета операций, соответственно, дерево получается двухуровневым: сверху стоит + и -, снизу * и /.
Но так как скобки присутствуют, дерево может иметь неограниченную вложенность. Наивное решение задачи выглядит следующим образом: находим скобки и полностью выкусываем их из получившейся строки и заменяем, например, на имена переменных a1, a2, a3, a4… После разбора получаем двухуровневое дерево. Затем в каждом узле дерева, где осталась переменная, проводим разбор того, что было в скобках, и вставляем результаты вместо соответствующих кусков поддерева.
Однако у нас получился алгоритм с квадратичной временной сложностью в худшем случае, т.к. если у нас будет, например, 100 миллионов вложенных скобок, то на каждом шаге придется искать закрывающую скобку, а потом еще раз парсить эту строку, пройдя ее целиком без правого и левого символов. По большому счету, это уже можно посчитать за верное решение, работать такая программа будет.
Но большинство программистов отметет этот подход как слишком лобовой и неэффективный, сразу перейдя к поиску линейного алгоритма. Для этого, очевидно, начиная рекурсию со скобками не нужно сразу искать закрывающую. Оба наших тестовых кандидата пошли именно по этому пути. В результате у обоих получилось нечто похожее на recursive descent parser (подвид LL-парсера).
#include <string>
#include <cassert>
#include <memory>
#include <stdexcept>
#include <vector>
#include <iostream>
#include <cstdio>
using namespace std;
struct Token {
enum Type { value, operation, opening_bracket, closing_bracket};
Type type;
string text;
};
struct Tokenizer {
//I am too lazy to write real tokenizer, it is very simple. I hope formula generator for fake tokenizer will be ok.
public:
Tokenizer() { content=generate(); pos=content.begin(); } ;
const Token* peek() { return pos!=content.end() ?&*pos:0; } ;
const Token* get() { if (pos!=content.end()) { cout << pos->text << " "; } return pos!=content.end()?&*pos++:0; } ;
private:
vector<Token> generate(int level=0);
private:
vector<Token>::iterator pos;
vector<Token> content;
};
//To be honest using classes for expression parsing is a bit overkill, old style could save some code.
class Expression;
typedef struct auto_ptr<Expression> ExpressionPtr;
//Represents abstract expression
class Expression {
public:
Expression() {}
virtual ~Expression() {}
//actually this static parse functions should be constructor in most classes. I.e. this is violation of principle 'Resource Acquisition Is Initialization'.
//but some functions return different classes depending on context, i.e. this is some kind of 'virtual constructor' (see Operation::parse for example)
//so I made decision to make static construction function in all classes, just for uniformity
static ExpressionPtr parse(Tokenizer& tokens);
virtual float calc()=0;
virtual void debug(string prefix)=0;
};
//Represents single value: for example 3.1415
class Value: public Expression {
public:
Value() {}
virtual ~Value() {}
static bool isItYou(Tokenizer& tokens);
static ExpressionPtr parse(Tokenizer& tokens);
virtual float calc() { return _value; }
virtual void debug(string prefix) { cout << prefix << "Value(" << calc() <<"): " << _value << endl; }
protected:
float _value;
};
//Represents operation: + or -
class Operation: public Expression {
public:
Operation() {};
virtual ~Operation() {}
static ExpressionPtr parse(Tokenizer& tokens, ExpressionPtr& l);
virtual float calc();
virtual void debug(string prefix) { cout << prefix << "Operation(" << calc() <<"): " << _operation << endl; if ( _left.get() ) _left->debug(prefix + "t"); if ( _right.get() ) _right->debug(prefix + "t"); }
protected:
std::auto_ptr<Expression> _left;
std::auto_ptr<Expression> _right;
string _operation;
};
//Represents operation: * or /
class PriorityOperation: public Operation {
public:
PriorityOperation() {};
virtual ~PriorityOperation() {}
static ExpressionPtr parse(Tokenizer& tokens, ExpressionPtr& left);
//virtual float calc(); inherit it
virtual void debug(string prefix) { cout << prefix << "PriorityOperation(" << calc() <<"): " << _operation << endl; if ( _left.get() ) _left->debug(prefix + "t"); if ( _right.get() ) _right->debug(prefix + "t"); }
};
//Represents bracketed expression: (expr)
class BracketExpression: public Expression {
public:
static bool isItYou(Tokenizer& tokens);
static ExpressionPtr parse(Tokenizer& tokens);
virtual float calc() { return _internal->calc(); } ;
virtual void debug(string prefix) { cout << prefix << "Brackets(" << calc() <<"): " << endl; _internal->debug(prefix + "t"); }
protected:
std::auto_ptr<Expression> _internal;
};
ExpressionPtr Expression::parse(Tokenizer& tokens)
{
//cout << "Expression::parse" << endl;
if (!tokens.peek()) return ExpressionPtr();
if ( BracketExpression::isItYou(tokens) )
{
return BracketExpression::parse(tokens);
}
else
if ( Value::isItYou(tokens) )
{
return Value::parse(tokens);
}
else
{
throw logic_error("(Expression) Wtf is that: " + tokens.peek()->text );
}
}
bool Value::isItYou(Tokenizer& tokens)
{
const Token* t = tokens.peek();
if ( !t || t->type != Token::value ) return false;
char* endptr;
strtod( t->text.c_str(), &endptr);
return *endptr == 0;
}
ExpressionPtr Value::parse(Tokenizer& tokens)
{
//cout << "Value::parse" << endl;
std::auto_ptr<Value> foo( new Value );
const Token* t=tokens.get();
assert( t && t->type == Token::value );
char* endptr;
foo->_value = strtod( t->text.c_str(), &endptr);
return ExpressionPtr(foo.release()); //lack of heterosexual auto_ptr conversions is killing me
}
bool BracketExpression::isItYou(Tokenizer& tokens)
{
return tokens.peek() && tokens.peek()->type == Token::opening_bracket;
}
ExpressionPtr BracketExpression::parse(Tokenizer& tokens)
{
//cout << "BracketExpression::parse" << endl;
const Token* t=tokens.get();
assert ( t->type == Token::opening_bracket );
auto_ptr<BracketExpression> result ( new BracketExpression );
ExpressionPtr null;
result->_internal = Operation::parse(tokens, null);
t = tokens.get();
if ( t ==0 || t->type != Token::closing_bracket )
{
throw logic_error("(BracketExpression) mismatched brackets ");
}
return ExpressionPtr(result.release());
}
ExpressionPtr Operation::parse(Tokenizer& tokens, ExpressionPtr& l)
{
//cout << "Operation::parse:" << l.get() << endl;
ExpressionPtr left;
if (l.get())
{
left=l;
// left is assigned for us.
}
else
{
left=Expression::parse(tokens);
}
const Token *t=tokens.peek();
if (!t || t->type == Token::closing_bracket ) return left; //just return Value, sorry no operation guys
if (t->type == Token::operation && (t->text=="*" || t->text=="/") )
{
ExpressionPtr result = PriorityOperation::parse(tokens, left);
//we got exit after priority operations will end, parse position will be on + or - or at end
left = result;
t=tokens.peek();
if (!t || t->type == Token::closing_bracket ) return left; //just return Value, sorry no operation guys
}
//cout << "Operation::parse again" << endl;
if (t->type == Token::operation && (t->text=="+" || t->text=="-") )
{
tokens.get(); //just commit previous peek
auto_ptr<Operation> result ( new Operation );
result->_operation = t->text;
result->_left=left; //smart ptr giveup ownership
//cout << "Operation::parse before token" << endl;
ExpressionPtr foo=Expression::parse(tokens);
//cout << "Operation::parse after expression" << endl;
const Token *t=tokens.peek();
if (t != 0 && (t->text=="*" || t->text=="/"))
{
//cout << "Operation::parse to priority" << endl;
foo = PriorityOperation::parse(tokens,foo);
}
result->_right=foo;
ExpressionPtr bar(result.release());
return Operation::parse(tokens, bar);
}
else
{
throw logic_error("(Operation) Wtf is that: " + tokens.peek()->text);
}
}
ExpressionPtr PriorityOperation::parse(Tokenizer& tokens, ExpressionPtr& left)
{
//cout << "PriorityOperation::parse" << endl;
// left is already assigned for priority operation
const Token *t=tokens.peek();
if (!t || t->type == Token::closing_bracket ) return left; //just return Value, sorry no operation guys
if (t->type == Token::operation && (t->text=="*" || t->text=="/") )
{
tokens.get(); //commit previuos peek
auto_ptr<PriorityOperation> result ( new PriorityOperation );
result->_operation = t->text;
result->_left=left;
result->_right=Expression::parse(tokens);
ExpressionPtr rs(result.release());
return PriorityOperation::parse(tokens, rs);
}
else if (t->type == Token::operation && (t->text=="+" || t->text=="-") )
{
return left;
}
else
{
throw logic_error("(PriorityOperation) Wtf is that: " + tokens.peek()->text );
}
}
float Operation::calc()
{
if (_operation == "+")
{
float l=_left.get()?_left->calc():0.0f;
float r=_right.get()?_right->calc():0.0f;
return l+r;
}
else
if (_operation == "-")
{
float l=_left.get()?_left->calc():0.0f;
float r=_right.get()?_right->calc():0.0f;
return l-r;
}
else
if (_operation == "*")
{
float l=_left.get()?_left->calc():1.0f;
float r=_right.get()?_right->calc():1.0f;
return l*r;
}
else
if (_operation == "/")
{
float l = _left.get()?_left->calc():1.0f;
float r = _right.get()?_right->calc():1.0f;
return l/r;
}
else
{
throw logic_error("Wft: operation udefined");
}
}
//returning vector by value, will be slow( O(n*n) actually ), but it is just testing code.
vector<Token> Tokenizer::generate(int level)
{
//be careful with this value - formula size is approx 2^level
if (level > 6)
{
Token t;
char f[100];
snprintf(f,100,"%d",int(rand() % 100));
t.text=f;
t.type=Token::value;
return vector<Token>(&t,&t+1);
}
if (rand() % 10 == 0)
{
vector<Token> result;
Token t1,t2;
t1.type=Token::opening_bracket;
t1.text="(";
t2.type=Token::closing_bracket;
t2.text=")";
result.push_back(t1);
vector<Token> r=generate(level+1);
result.insert(result.end(),r.begin(),r.end());
result.push_back(t2);
return result;
}
char op = "+-*/"[rand()%4];
Token t;
t.type=Token::operation;
t.text=op;
vector<Token> result=generate(level+1);
result.push_back(t);
vector<Token> r2=generate(level+1);
result.insert(result.end(),r2.begin(),r2.end());
return result;
}
int main()
{
try
{
//create fake tokenizer
Tokenizer tk;
//parse it
ExpressionPtr null;
ExpressionPtr foo = Operation::parse(tk,null);
cout << endl;
foo->debug("");
float result = foo->calc();
cout << "result = " << result << endl;
}
catch(exception& e)
{
cout << e.what() << endl;
return 1;
}
return 0;
}
#include <stdlib.h>
#include <string.h>
#include <iostream>
#include <string>
#include <vector>
#include <stdexcept>
struct token {
enum { E_UNDEF, E_NUMBER, E_OPERATOR, E_LEVEL } type;
union {
double d_val;
int i_val;
char c_val;
} data;
token() {
type = E_UNDEF;
}
token(double val) : type(E_NUMBER) {
data.d_val = val;
}
token(int val) : type(E_LEVEL) {
data.i_val = val;
}
token(char val) : type(E_OPERATOR) {
data.c_val = val;
}
};
typedef std::vector<token> tokens;
void push_level(tokens &pr, int level) {
if (pr.empty() || pr.back().type != token::E_LEVEL) {
pr.push_back(token(level));
} else {
pr.back().data.i_val += level;
}
}
void push_operator(tokens &pr, char op) {
pr.push_back(token(op));
}
void push_number(tokens &pr, int num) {
if (pr.empty() || pr.back().type == token::E_LEVEL || (pr.back().type == token::E_OPERATOR && pr.size() > 1 && pr[pr.size() - 2].type == token::E_NUMBER) ) {
pr.push_back(token((double)num));
} else if (pr.back().type == token::E_OPERATOR && (pr.size() == 1 || pr[pr.size() - 2].type == token::E_LEVEL) ) {
if (pr.back().data.c_val == '*' || pr.back().data.c_val == '/') {
throw std::domain_error("unexpected operator");
}
if (pr.back().data.c_val == '-') {
num = -num;
}
pr.pop_back();
pr.push_back(token((double)num));
} else {
throw std::domain_error("unexpected number");
}
}
void pop_level(tokens &pr, int level) {
if (level <= 0) {
return;
}
if (pr.empty() || pr.back().type == token::E_LEVEL || pr.back().type == token::E_OPERATOR) {
throw std::domain_error("unexpected closing brace");
} else if (pr.size() > 1 && pr[pr.size() - 2].type == token::E_LEVEL) {
if (pr[pr.size() - 2].data.i_val > level) {
pr[pr.size() - 2].data.i_val -= level;
} else {
int delta = level - pr[pr.size() - 2].data.i_val;
token tmp = pr.back();
pr.pop_back(); pr.pop_back();
pr.push_back(tmp);
pop_level(pr, delta);
}
} else if (pr.size() > 3) {
token s2 = pr.back(); pr.pop_back();
token op = pr.back(); pr.pop_back();
token s1 = pr.back(); pr.pop_back();
if (s1.type != token::E_NUMBER || op.type != token::E_OPERATOR || s2.type != token::E_NUMBER) {
throw std::domain_error("unexpected closing brace");
}
switch (op.data.c_val) {
case '+':
s1.data.d_val += s2.data.d_val;
break;
case '-':
s1.data.d_val -= s2.data.d_val;
break;
case '*':
s1.data.d_val *= s2.data.d_val;
break;
case '/':
s1.data.d_val /= s2.data.d_val;
break;
default:
throw std::domain_error("internal error");
}
if (pr.back().type == token::E_LEVEL) {
if (pr.back().data.i_val > level) {
pr.back().data.i_val -= level;
pr.push_back(s1);
} else {
int delta = level - pr.back().data.i_val;
pr.pop_back();
pr.push_back(s1);
pop_level(pr, delta);
}
} else if (pr.back().type == token::E_OPERATOR) {
pr.push_back(s1);
pop_level(pr, level);
} else {
throw std::domain_error("unexpected closing brace");
}
} else {
throw std::domain_error("unexpected closing brace");
}
}
double process(const std::string &str) {
tokens program;
push_level(program, 3);
for (std::string::const_iterator cit = str.begin(); cit != str.end(); ++cit) {
switch (*cit) {
case '(':
push_level(program, 3);
break;
case ')':
pop_level(program, 3);
break;
case '*':
case '/':
pop_level(program, 1);
push_operator(program, *cit);
push_level(program, 1);
break;
case '+':
case '-':
if (cit == str.begin() || strchr("(+/-*", *(cit-1))) {
push_operator(program, *cit);
} else {
pop_level(program, 2);
push_operator(program, *cit);
push_level(program, 2);
}
break;
case ' ':
break;
case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9':
{
int curnum = 0;
while (cit != str.end()) {
curnum = 10*curnum + (*cit - '0');
if ((cit + 1) == str.end() || !isdigit(*(cit+1))) {
break;
}
++cit;
}
push_number(program, curnum);
}
break;
default:
throw std::domain_error("unexpected symbol");
}
}
pop_level(program, 3);
if (program.size() == 0 || program.size() > 1) {
throw std::domain_error("incomplete expression");
}
return program.back().data.d_val;
}
int main() {
std::string line;
while (!std::cin.eof()) {
std::getline(std::cin, line);
if (line.length() > 0) {
try {
std::cout << process(line) << std::endl;
} catch (std::exception &e) {
std::cout << "error: " << e.what() << std::endl;
}
}
}
return 0;
}
Если вы знаете о существовании каких-нибудь классических алгоритмов в этой области, то задача становится проще. Однако что-то работающее может написать практически любой.
Если говорить об алгоритмах разбора, то классическим считается shunting yard algorithm. Также помимо Recursive descent parser популярными является LR-парсер.
Кроме кода
Если же кандидат претендует на звание senior-разработчика, мы обязательно постараемся понять, имеет ли он представление о планировании архитектуры системы.
Часто мы говорим о подходе к разработке. Это особенно важно для людей, которые утверждают, что имеют опыт управления проектами, но также мы часто беседуем на эти темы и с обычными разработчиками, в этом случае это уже не тест, скорее мы просто хотим понять, чего человек ждет, и будет ли ему у нас комфортно работать.
Важная часть собеседования – обсуждение проектов, над которыми кандидату довелось поработать. Нужно понять, какую роль он играл в их исполнении. В резюме может быть указан десяток мегапроектов, но обычно достаточно пары вопросов по каждому из них, чтобы понять, действительно ли человек играл в них важную роль или был на подхвате.
Интересно также послушать, о каких аспектах проекта кандидат будет рассказывать подробнее всего. Это может раскрыть некоторые детали о его специализации. В свою очередь, мы рассказываем о том, какие проекты может предложить Яндекс. У всех есть свои интересы и предпочтения, а возможность выбора – это всегда приятно.
В конечном итоге, мы принимаем решение, подходит нам кандидат или нет. Однако окончательно ясно, приживется ли человек в Яндексе, становится после испытательного срока. Если бы о работнике все можно было понять исключительно по собеседованию, мир бы выглядел совсем иначе.
На тех, кто по каким-то причинам не прошел собеседование, мы за редкими исключениями крест не ставим. Как правило, мы готовы рассматривать кандидата снова через некоторое время. Многие рекрутирующие разработчики дают кандидатам советы и список литературы. Правда, мы не можем поручиться, что так делают абсолютно все, но часто для разработчика отказ кандидату – не меньший стресс, чем для самого кандидата.
- Вирт, «Алгоритмы + структуры данных = программы»
- Кормен, Ривест, и другие «Алгоритмы: построение и анализ»
- Липпман «Основы программирования на С++»
- Scott Meyers. Effective C++. More Effective C++. Effective STL
- Herb Sutter «Exceptional C++» «More Exceptional C++»
За всеми обновлениями списка вакансий можно следить здесь.
Автор: anatolix