「design Twitter」是 LeetCode 上第 335 道题目,不仅题目本身很有意思,而且把合并多个有序链表的算法和面向对象设计(OO design)结合起来了,很有实际意义,本文就带大家来看看这道题。

至于 Twitter 的什么功能跟算法有关系,等我们描述一下题目要求就知道了。

一、题目及应用场景简介

Twitter 和微博功能差不多,我们主要要实现这样几个 API:

class Twitter {

    /** user 发表一条 tweet 动态 */
public void postTweet(int userId, int tweetId) {} /** 返回该 user 关注的人(包括他自己)最近的动态 id,
最多 10 条,而且这些动态必须按从新到旧的时间线顺序排列。*/
public List<Integer> getNewsFeed(int userId) {} /** follower 关注 followee,如果 Id 不存在则新建 */
public void follow(int followerId, int followeeId) {} /** follower 取关 followee,如果 Id 不存在则什么都不做 */
public void unfollow(int followerId, int followeeId) {}
}

举个具体的例子,方便大家理解 API 的具体用法:

Twitter twitter = new Twitter();

twitter.postTweet(1, 5);
// 用户 1 发送了一条新推文 5 twitter.getNewsFeed(1);
// return [5],因为自己是关注自己的 twitter.follow(1, 2);
// 用户 1 关注了用户 2 twitter.postTweet(2, 6);
// 用户2发送了一个新推文 (id = 6) twitter.getNewsFeed(1);
// return [6, 5]
// 解释:用户 1 关注了自己和用户 2,所以返回他们的最近推文
// 而且 6 必须在 5 之前,因为 6 是最近发送的 twitter.unfollow(1, 2);
// 用户 1 取消关注了用户 2 twitter.getNewsFeed(1);
// return [5]

这个场景在我们的现实生活中非常常见。拿朋友圈举例,比如我刚加到女神的微信,然后我去刷新一下我的朋友圈动态,那么女神的动态就会出现在我的动态列表,而且会和其他动态按时间排好序。只不过 Twitter 是单向关注,微信好友相当于双向关注。除非,被屏蔽...

这几个 API 中大部分都很好实现,最核心的功能难点应该是 getNewsFeed,因为返回的结果必须在时间上有序,但问题是用户的关注是动态变化的,怎么办?

这里就涉及到算法了:如果我们把每个用户各自的推文存储在链表里,每个链表节点存储文章 id 和一个时间戳 time(记录发帖时间以便比较),而且这个链表是按 time 有序的,那么如果某个用户关注了 k 个用户,我们就可以用合并 k 个有序链表的算法合并出有序的推文列表,正确地 getNewsFeed 了!

具体的算法等会讲解。不过,就算我们掌握了算法,应该如何编程表示用户 user 和推文动态 tweet 才能把算法流畅地用出来呢?这就涉及简单的面向对象设计了,下面我们来由浅入深,一步一步进行设计。

二、面向对象设计

根据刚才的分析,我们需要一个 User 类,储存 user 信息,还需要一个 Tweet 类,储存推文信息,并且要作为链表的节点。所以我们先搭建一下整体的框架:

class Twitter {
private static int timestamp = 0;
private static class Tweet {}
private static class User {} /* 还有那几个 API 方法 */
public void postTweet(int userId, int tweetId) {}
public List<Integer> getNewsFeed(int userId) {}
public void follow(int followerId, int followeeId) {}
public void unfollow(int followerId, int followeeId) {}
}

之所以要把 Tweet 和 User 类放到 Twitter 类里面,是因为 Tweet 类必须要用到一个全局时间戳 timestamp,而 User 类又需要用到 Tweet 类记录用户发送的推文,所以它们都作为内部类。不过为了清晰和简洁,下文会把每个内部类和 API 方法单独拿出来实现。

1、Tweet 类的实现

根据前面的分析,Tweet 类很容易实现:每个 Tweet 实例需要记录自己的 tweetId 和发表时间 time,而且作为链表节点,要有一个指向下一个节点的 next 指针。

class Tweet {
private int id;
private int time;
private Tweet next; // 需要传入推文内容(id)和发文时间
public Tweet(int id, int time) {
this.id = id;
this.time = time;
this.next = null;
}
}

2、User 类的实现

我们根据实际场景想一想,一个用户需要存储的信息有 userId,关注列表,以及该用户发过的推文列表。其中关注列表应该用集合(Hash Set)这种数据结构来存,因为不能重复,而且需要快速查找;推文列表应该由链表这种数据结构储存,以便于进行有序合并的操作。画个图理解一下:

除此之外,根据面向对象的设计原则,「关注」「取关」和「发文」应该是 User 的行为,况且关注列表和推文列表也存储在 User 类中,所以我们也应该给 User 添加 follow,unfollow 和 post 这几个方法:

// static int timestamp = 0
class User {
private int id;
public Set<Integer> followed;
// 用户发表的推文链表头结点
public Tweet head; public User(int userId) {
followed = new HashSet<>();
this.id = userId;
this.head = null;
// 关注一下自己
follow(id);
} public void follow(int userId) {
followed.add(userId);
} public void unfollow(int userId) {
// 不可以取关自己
if (userId != this.id)
followed.remove(userId);
} public void post(int tweetId) {
Tweet twt = new Tweet(tweetId, timestamp);
timestamp++;
// 将新建的推文插入链表头
// 越靠前的推文 time 值越大
twt.next = head;
head = twt;
}
}

3、几个 API 方法的实现

class Twitter {
private static int timestamp = 0;
private static class Tweet {...}
private static class User {...} // 我们需要一个映射将 userId 和 User 对象对应起来
private HashMap<Integer, User> userMap = new HashMap<>(); /** user 发表一条 tweet 动态 */
public void postTweet(int userId, int tweetId) {
// 若 userId 不存在,则新建
if (!userMap.containsKey(userId))
userMap.put(userId, new User(userId));
User u = userMap.get(userId);
u.post(tweetId);
} /** follower 关注 followee */
public void follow(int followerId, int followeeId) {
// 若 follower 不存在,则新建
if(!userMap.containsKey(followerId)){
User u = new User(followerId);
userMap.put(followerId, u);
}
// 若 followee 不存在,则新建
if(!userMap.containsKey(followeeId)){
User u = new User(followeeId);
userMap.put(followeeId, u);
}
userMap.get(followerId).follow(followeeId);
} /** follower 取关 followee,如果 Id 不存在则什么都不做 */
public void unfollow(int followerId, int followeeId) {
if (userMap.containsKey(followerId)) {
User flwer = userMap.get(followerId);
flwer.unfollow(followeeId);
}
} /** 返回该 user 关注的人(包括他自己)最近的动态 id,
最多 10 条,而且这些动态必须按从新到旧的时间线顺序排列。*/
public List<Integer> getNewsFeed(int userId) {
// 需要理解算法,见下文
}
}

三、算法设计

实现合并 k 个有序链表的算法需要用到优先级队列(Priority Queue),这种数据结构是「二叉堆」最重要的应用,你可以理解为它可以对插入的元素自动排序。乱序的元素插入其中就被放到了正确的位置,可以按照从小到大(或从大到小)有序地取出元素。

PriorityQueue pq
# 乱序插入
for i in {2,4,1,9,6}:
pq.add(i)
while pq not empty:
# 每次取出第一个(最小)元素
print(pq.pop()) # 输出有序:1,2,4,6,9

借助这种牛逼的数据结构支持,我们就很容易实现这个核心功能了。注意我们把优先级队列设为按 time 属性从大到小降序排列,因为 time 越大意味着时间越近,应该排在前面:

public List<Integer> getNewsFeed(int userId) {
List<Integer> res = new ArrayList<>();
if (!userMap.containsKey(userId)) return res;
// 关注列表的用户 Id
Set<Integer> users = userMap.get(userId).followed;
// 自动通过 time 属性从大到小排序,容量为 users 的大小
PriorityQueue<Tweet> pq =
new PriorityQueue<>(users.size(), (a, b)->(b.time - a.time)); // 先将所有链表头节点插入优先级队列
for (int id : users) {
Tweet twt = userMap.get(id).head;
if (twt == null) continue;
pq.add(twt);
} while (!pq.isEmpty()) {
// 最多返回 10 条就够了
if (res.size() == 10) break;
// 弹出 time 值最大的(最近发表的)
Tweet twt = pq.poll();
res.add(twt.id);
// 将下一篇 Tweet 插入进行排序
if (twt.next != null)
pq.add(twt.next);
}
return res;
}

这个过程是这样的,下面是我制作的一个 GIF 图描述合并链表的过程。假设有三个 Tweet 链表按 time 属性降序排列,我们把他们降序合并添加到 res 中。注意图中链表节点中的数字是 time 属性,不是 id 属性:

至此,这道一个极其简化的 Twitter 时间线功能就设计完毕了。

四、最后总结

本文运用简单的面向对象技巧和合并 k 个有序链表的算法设计了一套简化的时间线功能,这个功能其实广泛地运用在许多社交应用中。

我们先合理地设计出 User 和 Tweet 两个类,然后基于这个设计之上运用算法解决了最重要的一个功能。可见实际应用中的算法并不是孤立存在的,需要和其他知识混合运用,才能发挥实际价值。

当然,实际应用中的社交 App 数据量是巨大的,考虑到数据库的读写性能,我们的设计可能承受不住流量压力,还是有些太简化了。而且实际的应用都是一个极其庞大的工程,比如下图,是 Twitter 这样的社交网站大致的系统结构:

我们解决的问题应该只能算 Timeline Service 模块的一小部分,功能越多,系统的复杂性可能是指数级增长的。所以说合理的顶层设计十分重要,其作用是远超某一个算法的。

最后,Github 上有一个优秀的开源项目,专门收集了很多大型系统设计的案例和解析,而且有中文版本,上面这个图也出自该项目。对系统设计感兴趣的读者可以点击「阅读原文」查看。

PS:本文前两张图片和 GIF 是我第一次尝试用平板的绘图软件制作的,花了很多时间,尤其是 GIF 图,需要一帧一帧制作。如果本文内容对你有帮助,点个赞分个享,鼓励一下我呗!

我最近精心制作了一份电子书《labuladong的算法小抄》,分为【动态规划】【数据结构】【算法思维】【高频面试】四个章节,共 60 多篇原创文章,绝对精品!限时开放下载,在我的公众号 labuladong 后台回复关键词【pdf】即可免费下载!

欢迎关注我的公众号 labuladong,技术公众号的清流,坚持原创,致力于把问题讲清楚!

设计Twitter 时间线的更多相关文章

  1. 如何解决Angular网页内嵌推特时间线无法正常显示

    我最近解决了一个折磨了我好久但是解决方法却只是添加两三行代码的问题.我没有在网上找到合适的解决方案,最后是我根据官方网站和很多的帖子里的部分代码得到的启发,尝试了很久之后得到的解决方法.因为过程实在是 ...

  2. 《时间线》:WWT 数字宇宙,虚拟星空

    李北辰/文 王尔德说:我们生活在阴沟里,却仍有人仰望星空.如今,这句感叹依旧代表某种程度的正确,但这位19世纪的英国绅士有所不知:在一百多年后的东方"帝都",当困于阴沟中的年轻人们 ...

  3. WPF中的动画——(三)时间线(TimeLine)

    WPF中的动画——(三)时间线(TimeLine) 时间线(TimeLine)表示时间段. 它提供的属性可以让控制该时间段的长度.开始时间.重复次数.该时间段内时间进度的快慢等等.在WPF中内置了如下 ...

  4. postgreSQL 时间线

    “时间线”(Timeline)是PG一个很有特色的概念,在备份恢复方面的文档里面时有出现.但针对这个概念的详细解释却很少,也让人不太好理解,我们在此仔细解析一下. 时间线的引入 为了理解引入时间线的背 ...

  5. ANDROID_MARS学习笔记_S04_007_从服务器获取微博数据时间线

    一.代码 1.xml(1)activity_main.xml <?xml version="1.0" encoding="utf-8"?> < ...

  6. 设计APP时我们该怎么做

    不得不承认,手机APP已经渗透到我们的生活中,根据数据统计,人们每天平均有3.9个小时是花费在手机APP的使用上的,可以预见,手机APP正在改变我们的生活.手机APP受到人们的欢迎,很多商家也看到了其 ...

  7. SpriteBuilder中时间线播放音效的弊端

    当你美滋滋的在时间线中播放音效的时候,你要想到音效时间线并不适于播放同步于游戏事件的声音,比如碰撞和加速时. 它同样不能被用来播放背景循环的声音,这就本质上拒绝了通过timeline播放背景音乐.甚至 ...

  8. PowerBI功能发布时间线

    DAX/PowerBI系列 - PowerBI功能发布时间线 PowerBI从GA/上线以来,每月发布的功能收集起来做成了一个报表:(耐心等待PowerBI 出来,噔噔噔噔~~~) 上图显示: 1)D ...

  9. CSS之实现垂直时间线展示相关内容效果

    如下,最近在工作中遇到实现时间线效果的需求,用纯css即可实现,下面给出详细实现代码. html: <div class="time_line_list_wrap hide" ...

随机推荐

  1. JD-GUI反编译jar包为Java源代码

    程序员难免要借鉴其他java工程的代码.可有时只能拿到.calss文件,jar包或者war包,这个时候要求程序员能熟练的将这些类型文件反编译为Java代码并形成可编译运行的项目.本文介绍的反编译工具是 ...

  2. python 报错 wxPyDeprecationWarning: Using deprecated class PySimpleApp.

    如题:python 报错 提示为 : wxPyDeprecationWarning: Using deprecated class PySimpleApp. 解决:将 wx.PySimpleApp() ...

  3. C/C++ 条件编译

    条件编译就是指有条件的编译,即根据条件去编译代码,在编译阶段时就对代码做出取舍,有的编译,有的不编译,这样比写成一个个判断函数更有效率,比如工程代码大部分的地方都类似,只有个别语句因为使用的硬件版本不 ...

  4. TP5调用小程序微信支付,回调,在待支付中再次调用微信支付

    1,必须要有 $mch_id $key $appid这三个值,是需要去申请的,我是直接用公司的2,购买商品订单号用户openid统一下单名称商品价格(必须以分为单位,调起微信支付)服务器的ip地址(没 ...

  5. 实验三 HTML表格和表单的制作

    实验三 HTML表格和表单的制作 [实验目的] 1.掌握表格的创建.结构调整与美化方法: 2.熟悉表格与单元格的主要属性及其设置方法: 3.掌握通过表格来进行网页页面的布局方法. [实验环境] 连接互 ...

  6. 多测师讲解requests __上_高级讲师肖sir

    1.三种接口接口请求方式 # # 在python当中接口的请求方式有哪些:# import requests # 导入requests接口库# # # # 请求方式有三种:# # # # 第一种:# ...

  7. day10 Pyhton学习

    一.昨日内容回顾 函数: 定义:对功能或者动作的封装 def 函数名(形参): 函数体 函数名(实参) return:  返回,当程序运行到return的时候,终止函数的执行 一个函数一定拥有返回值 ...

  8. 【C语言入门学习笔记】如何把C语言程序变成可执行文件!

    环境 在ANSI的任何一种实现中,存在两种不同的环境. 翻译环境:在这个环境里,源代码被转换为可执行的机器指令. 执行环境:用于实际执行代码. 翻译环境 组成一个程序的每个源文件通过编译过程分别转成目 ...

  9. 【Windows编程】入门篇——win 32窗口的hello word!

    ✍  Windows编程基础 1.Win 32应用程序基本类型 1)  控制台程序 不需要完善的windows窗口,可以使用DOS窗口方式显示 2)  Win 32窗口程序 包含窗口的程序,可以通过窗 ...

  10. ffmpeg实现视频文件合并/截取预览视频/抽取音频/crop(裁剪)(ffmpeg4.2.2)

    一,ffmpeg的安装 请参见: https://www.cnblogs.com/architectforest/p/12807683.html 说明:刘宏缔的架构森林是一个专注架构的博客,地址:ht ...