所以在 Airbnb 的案例里,用户浏览了他们的房源主页,Airbnb 试着去过滤房源来找出最适合的。我们怎样处理这个问题呢?(注意这个问题是后端的,所以我们不需要管前端或者网络流量或者 https over http 或者 Amazon EC2 over home cluster 等。首先,因为我们已经熟悉了程序员仓库中最强大的工具(在说假设而不是抽象),我们假设处理的是完全适配 RAM 的数据。然后你也可以假设我们的 RAM 是足够大的。足够大去支持,但这是多大呢?这是另一个非常好的问题。需要多大的内存来存储真正的数据呢?如果我们处理的是四百万单元的数据(还是假设),如果我们大概知道每一个单元的大小,之后我们可以简单地驱动需要的内存,就是 4M*sizeof(one_unit)。考虑下「房源」及其性质(properties),事实上,至少考虑一下处理这一问题必要的性质(一个「房源」是我们的单元)。我们需要用 C++结构伪代码来表示一些问题,你可以简单地将他们转化为一个 MongoDB 略图目标或者任何你想要的形式, 我们只讨论性质的名字和类别。(试着去想象这是在空间经济里用字位段或者位集合)
// feel free to reorganize this struct to avoid redundant space // usage because of aligning factor // Remark 1: some of the properties could be expressed as enums, // bitset is chosen for as multi-value enum holder. // Remark 2: for most of the count values the maximum is 16 // Remark 3: price value considered as integer, // int considered as 4 byte. // Remark 4: neighborhoods property omitted // Remark 5: to avoid spatial queries, we're // using only country code and city name, at this point won't consider // the actual coordinates (latitude and longitude) struct AirbnbHome { wstring name; // wide string uint price; uchar rating; uint rating_count; vector photos; // list of photo URLs string host_id; uchar adults_number; uchar children_number; // max is 5 uchar infants_number; // max is 5 bitset<3> home_type; uchar beds_number; uchar bedrooms_number; uchar bathrooms_number; bitset<21> accessibility; bool superhost; bitset<20> amenities; bitset<6> facilities; bitset<34> property_types; bitset<32> host_languages; bitset<3> house_rules; ushort country_code; string city; };
算法复杂度:我们在这里快速地简要介绍,在之后的文章「Algorithmic Complexity and Software Performance: the missing manual」我会进行详细的解释。在大部分情况里,找到一个算法的「大 O」复杂度是简单的。首先要注意到,我们总是考虑最差的情况,即:一个算法要最多算多少次才能产生一个合适的结果(用来解决问题)。
就像一个哈希表,我们通过房源的价格来匹配每一套房子。所有具有相同价格的房源都归入单独的二元搜索树。如果我们存储房源的 ID 而不是上面定义的完整对象(AirbnbHome 结构),也可以节省一些空间。最可能的情况是将所有房源的完整对象保存在哈希表,并将房源 ID 映射到房源的完整对象中,以及保存另一个哈希表(或更好的,一个数组),该哈希表将价格与房源 ID 进行映射。因此,当用户请求价格范围时,我们从价格表中获取房源 ID,将结果裁剪成固定大小(即分页,通常在一页上显示 10-30 个项目),然后使用每个房源 ID 获取完整的房源对象。请记得,要注意平衡。平衡对二元搜索树至关重要,因为它是在 O(logN)内完成树操作的唯一保证。当你按排序顺序插入元素时,二元搜索树不平衡的问题就会很明显,最终,树将变成连接列表,这显然会导致线性时间复杂度。现在假设我们所有的搜索树都是完美平衡的。再看看上面的图。每个数组元素代表一棵大树。如果我们改变这个树,变成图呢?
这是一个「更接近真实」的图。这个图展示了最隐蔽的数据结构和图,带领我们到了(下文)。
图表征:进阶
图论的缺点是缺乏单独定义,这就是为什么你无法在库中找到 std::graph。我们已经尝试过表示「特别的」图 BST。重点在于,树是图,但图未必是树。最后一张示例图表明在一个抽象图下面有很多树,「价格 vs 房源」和一些节点的类型不同,价格只有价格值的图节点,表示满足特定价格的房源 ID(房源节点)的树。这很像混合数据结构,不同于我们在教科书示例中见到的简单图形。图表示的重点:不存在固定、「权威」的图表示结构(这与 BST 不同,后者用左/右子指针代表基于节点的特定表示,尽管你可以用一个数组表示 BST)。你可以用最便捷的方式表示一个图,重点在于你把它「看作」是图。「看图」的意思是使用适用于图的算法。
N-ary 树更像是模拟一个图。
首先,将 N-ary 树节点表示为:
struct NTreeNode { T value; vector children; };
该结构仅表示树的一个节点,完整的树如下所示:
// almost pseudocode class NTree { public: void Insert(const T&); void Remove(const T&); // lines of omitted code private: NTreeNode* root_; };
// A representation of a graph with both vertex and edge tables // Vertex table is a hashtable of edges (mapped by label) // Edge table is a structure with 4 fields // VELO = Vertex Edge Label Only (e.g. no vertex payloads) class ConnectedVELOGraph { public: struct Edge { Edge(const std::string& f, const std::string& t) : from(f) , to(t) , used(false) , next(nullptr) {} std::string ToString() { return (from + " - " + to + " [used:" + (used ? "true" : "false") + "]"); } std::string from; std::string to; bool used; Edge* next; }; ConnectedVELOGraph() {} ~ConnectedVELOGraph() { vertices_.clear(); for (std::size_t ix = 0; ix < edges_.size(); ++ix) { delete edges_[ix]; } } public: void InsertEdge(const std::string& from, const std::string& to) { Edge* e = new Edge(from, to); InsertVertexEdge_(from, e); InsertVertexEdge_(to, e); edges_.push_back(e); } public: void Print() { for (auto elem : edges_) { std::cout << elem->ToString() << std::endl; } } std::vector Trace(const std::string& v) { std::vector path; Edge* e = vertices_[v]; while (e != nullptr) { if (e->used) { e = e->next; } else { e->used = true; path.push_back(e->from + ":-:" + e->to); e = vertices_[e->to]; } } return path; } private: void InsertVertexEdge_(const std::string& label, Edge* e) { if (vertices_.count(label) == 0) { vertices_[label] = e; } else { vertices_[label]->next = e; } } private: std::unordered_map vertices_; std::vector edges_; };
注意 bug,bug 到处都是。该代码包含大量假设,比如标签,我们可以通过节点理解字符串标签,确保你可以将其更新成任意事物。接下来,是命名。如注释中所述,VELOGraph 仅适用于 Vertex Edge Label Only Graph。重点在于,该图表示包括一个将节点标签映射至节点关联边的表、一个包含与边相连的两个节点的边列表,和一个仅用于 Trace() 函数的 flag。查看 Trace() 函数实现,它使用边的 flag 来标记已经遍历过的边(在任意 Trace() 调用之后应该重置 flag)。
示例:Twitter
另一种表示叫做相邻矩阵,它在有向图中有用,就像我们在 Twitter 关注图中所使用的那样。
有向图
推特案例中有 8 个节点,所以我们需要使用|V|x|V|的二维矩阵表征这个图(其中 |V|分别代表行数和列数)。如果从 v 到 u 有一条有向边,那么我们称矩阵的元素 [v][u] 为真,否则为假。
如你所见,这是一个十分稀疏的矩阵,其中的值代表是否有单向连接路径。如果我们需要了解 Patrick 是否关注了 Bob 的推特,那么我们只需要查看矩阵中 ["Patrick"]["Sponge Bob"] 的值是不是等于 1。而要查看 Ann 推特的关注者,我需要获得「Ann」的整个列;同样查看 Sponge Bob 正在关注的人只需要查看「Sponge Bob」的行就行。此外,邻接矩阵(Adjacency matrix)可以用来描述无向图,它不会在 v 到 u 的边选择值「0 或 1」来表示是否有连接,它会同时设置两个方向的值都为 1,即 adj_matrix[v][u] = 1 和 adj_matrix[u][v] = 1。因此,无向图的邻接矩阵为对称矩阵。
右图表示邻接列表(adjacency list),每个列表描述了图中的一组邻近节点。在图表中,我们突出了哈希表的用法,因为任何节点的访问复杂度都是 O(1)。而且对于邻近节点列表,我们并没有提到任何具体的数据结构,也不会从列表转化为向量。重点是,如果要确定 Patrick 是否关注 Liz,我们应该遍历哈希表中每一个元素(常数时间),而邻近矩阵需要查看每一个与 Liz 相关的元素(线性时间)。线性时间在这一点上并不是那么糟,因为我们经需要循环与 Patrick 相关的固定数目的节点。
如果我们用空间复杂度表示推特,这需要 3 亿个哈希表记录,每个记录指向一个向量(选择向量以避免链表的左/右指针所产生的内存开销)。此外,虽然没有统计数据,但平均推特关注的人数是 707 个。所以如果我们考虑每个哈希表记录指向一个有 707 个用户 ID 的数组,且每个 ID 有 8 个字节,那么现在我们可以计算出储存空间约为 12TB。
因此基于该示例,无论何时 Liz 发推特,Spone Bob 和 Ann 都必须在他们的时间线上找到特定的推文。一项普遍使用的解决该问题的技术是为每个用户的时间线保持独立的结构。假设推特有 3 亿的用户,我们可以假定至少存在 3 亿个时间线(每人一个)。简单的说,无论何时发推,我们应该找到他的关注者并且更新他们的时间轴,时间线可以被表征成连结串列或平衡树(以推文的日期作为节点关键字)。
// 'author' represents the User object, at this point we are interested only in author.id // // 'tw' is a Tweet object, at this point we are interested only in 'tw.id' void DeliverATweet(User* author, Tweet* tw) { // we assume that 'tw' object is already stored in a database // 1. Get the list of user's followers (author of the tweet) vector user_followers = GetUserFollowers(author->id); // 2. insert tweet into each timeline for (auto follower : user_followers) { InsertTweetIntoUserTimeline(follower->id, tw->id); } }
// Warning: a bunch of pseudocode ahead void RangeInsertIntoTimelines(vector user_ids, long tweet_id) { for (auto id : user_ids) { InsertIntoUserTimeline(id, tweet_id); } } void DeliverATweet(User* author, Tweet* tw) { // we assume that 'tw' object is already stored in a database // 1. Get the list of user's (tweet author's) followers's ids vector user_followers = GetUserFollowers(author->id); // 2. Insert tweet into each timeline in parallel const int CHUNK_SIZE = 4000; // saw this somewhere for (each CHUNK_SIZE elements in user_followers) { Thread t = ThreadPool.GetAvailableThread(); // somehow t.Run(RangeInsertIntoTimelines, current_chunk, tw->id); } }
// struct TreeNode { // T item; // TreeNode* left; // TreeNode* right; // } // you cann pass a callback function to do whatever you want to do with the node's value // in this particular example we are just printing its value. // node is the "starting point", basically the first call is done with the "root" node void PreOrderTraverse(TreeNode* node) { if (!node) return; // stop std::cout << node->item; PreOrderTraverse(node->left); // do the same for the left sub-tree PreOrderTraverse(node->right); // do the same for the right sub-tree } void InOrderTraverse(TreeNode* node) { if (!node) return; // stop InOrderTraverse(node->left); std::cout << node->item; InOrderTraverse(node->right); } void PostOrderTraverse(TreeNode* node) { if (!node) return; // stop PostOrderTraverse(node->left); PostOrderTraverse(node->right); std::cout << node->item; }
假设我们要将所有 Netflix 电影存储在二进制搜索树中,并将电影标题作为排序键。所以无论何时用户输入类似「Inter」的内容,我们都会返回一个以「Inter」开头的电影列表,举例,[「Interstellar」,「Interceptor」,「Interrogation of Walter White」]。如果我们将返回标题中包含「Inter」的所有电影(不仅仅是以「Inter」开头的电影)那就太好了,并且该列表将根据电影的评分或与该特定用户相关的内容进行排序(喜欢惊悚片比戏剧更多)。这个例子的重点在于对 BST 进行有效的范围查询,但像往常一样,我们不会深入探讨其余部分。基本上,我们需要通过搜索关键字进行快速查找,然后获得按关键字排序的结果列表,这很可能应该是电影评级和/或基于用户个性化数据的内部排名。我们会尽可能地坚持 KISK 原则(Keep It Simple,Karl)。
// Cached representation of an Item // Full Item object (with title, description, comments etc.) // could be fetched from the database struct Item { // ID_TYPE is the type of Item's unique id, might be an integer, or a string ID_TYPE id; int rating; };
// this is a pseudocode, that's why I didn't bother with "const&"'s and "std::"'s // though it could have look better, forgive me C++ fellows vector GetItemsByKeywordInSortedOrder(string keyword) { // assuming IndexTable is a big hashtable mapping keywords to Item BSTs BST items = IndexTable[keyword]; // suppose BST has a function InOrderProduceVector(), which creates a vector and // inserts into it items fetched via in-order traversing the tree vector sorted_result = items.InOrderProduceVector(); return sorted_result; }
这里是一种 InOrderProduceVector() 实现:
template class BST { public: // other code ... vector InOrderProduceVector() { vector result; result.reserve(1000); // magic number, reserving a space to avoid reallocation on inserts InOrderProduceVectorHelper_(root_, result); // passing vector by reference return result; } protected: // takes a reference to vector void InOrderProduceVectorHelper_(BSTNode* node, vector& destination) { if (!node) return; InOrderProduceVectorHelper_(node->left, destination); destination.push_back(node->item); InOrderProduceVectorHelper_(node->right, destination); } private: BSTNode* root_; };
// Reminder: this is pseudocode, no bother with "const&", "std::" or others // forgive me C++ fellows template class BST { public: // other code ... vector ReverseInOrderProduceVector(int offset, int limit) { vector result; result.reserve(limit); // passing result vector by reference // and passing offset and limit ReverseInOrderProduceVectorHelper_(root_, result, offset, limit); return result; } protected: // takes a reference to vector // skips 'offset' nodes and inserts up to 'limit' nodes void ReverseInOrderProduceVectorHelper_(BSTNode* node, vector& destination, int offset, int limit) { if (!node) return; if (limit == 0) return; --offset; // skipping current element ReverseInOrderProduceVectorHelper_(node->right, destination, offset, limit); if (offset <= 0) { // if skipped enough, insert destination.push_back(node->value); --limit; // keep the count of insertions } ReverseInOrderProduceVectorHelper_(node->left, destination, offset, limit); } private: BSTNode* root_; }; // ... other possibly useful code // this is a pseudocode, that's why I didn't bother with "const&"'s and "std::"'s // though it could have look better, forgive me C++ fellows vector GetItemsByKeywordInSortedOrder(string keyword, offset, limit) // pagination using offset and limit { // assuming IndexTable is a big hashtable mapping keywords to Item BSTs BST items = IndexTable[keyword]; // suppose BST has a function InOrderProduceVector(), which creates a vector and // inserts into it items fetched via reverse in-order traversing the tree // to get items in descending order (starting from the highest rated item) vector sorted_result = items.ReverseInOrderProduceVector(offset, limit); return sorted_result; }
// Assuming graph is connected // and a graph node is defined by this structure // struct GraphNode { // T item; // vector children; // } // WARNING: untested code void BreadthFirstSearch(GraphNode* node) // start node { if (!node) return; queue q; q.push(node); while (!q.empty()) { GraphNode* cur = q.front(); // doesn't pop q.pop(); for (auto child : cur->children) { q.push(child); } // do what you want with current node cout << cur->item; } }
3. 对于当前节点,考虑其所有未访问近邻,通过当前节点计算它们的实验距离。对比新计算的实验距离和当前分配的值,分配较小的值。例如,如果当前节点 A 的距离是 6,连接 A 与近邻 B 的边长度为 2,则经过 A 到 B 的距离是 6 + 2 = 8。如果 B 之前标记的距离大于 8,则将其更改为 8。反之,保留当前值。
4. 当我们考虑完当前节点的所有近邻之后,将当前节点标记为已访问,并从 unvisited set 中移除。已访问节点无需再检查。
5. 如果目标节点已经标记为已访问(当规划两个特定节点之间路线的时候),或 unvisited set 中节点之间的最小实验距离是无穷大(当规划完整遍历时,初始节点和其余未访问节点之间没有连接时),则停止,算法结束。
6. 反之,选择标记有最小实验距离的未访问节点,将其设置为新的「当前节点」,并返回第 3 步。
在我们的示例中,我们首先将节点 B(车辆)设置为初始节点。前两步:
我们的 unvisited set 包含了所有的节点,同时也要注意图左边里显示的表格。所有的节点都包括了到 B 和到之前节点的最短距离。例如,从 B 到 F 的最短距离是 20,之前节点是 B。
没有评论:
发表评论