355. 设计推特

104 阅读6分钟

题目介绍

力扣355题:leetcode-cn.com/problems/de…

image.png

image.png

分析

这个场景在我们的现实生活中非常常见。拿朋友圈举例,比如我刚加到女神的微信,然后我去刷新一下我的朋友圈动态,那么女神的动态就会出现在我的动态列表,而且会和其他动态按时间排好序。只不过 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 { 
    
    }
    
    /**
     * 初始化方法
     */
    public Twitter() {

    }

    /**
     * 发送推特
     */
    public void postTweet(int userId, int tweetId) {

    }

    /**
     * 获取指定用户发送的推特以及自己关注的用户的推特
     */
    public List<Integer> getNewsFeed(int userId) {
        return null;
    }
    
    /**
     * 用户followerId关注用户followeeId
     */
    public void follow(int followerId, int followeeId) {

    }

    /**
     * 用户followerId取消关注用户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;
    }
}

image.png

2、User 类的实现

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

image.png

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

class User {
    private int id;
    public Set<Integer> followed;
    //用户发表的推文链表头节点
    public Tweet head;
    
    pulic 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 Map<Integer,User> userMap = new HashMap<>();
    
    /**
     * 初始化方法
     */
    public Twitter() {

    }

    /**
     * 发送推特
     */
    public void postTweet(int userId, int tweetId) {
        //若userId不存在,则新建
        if(!userMap.containsKey(userId) {
            userMap.put(userId,new User(userId));
        }
        //找到对应的User对象
        User u = userMap.get(userId);
        u.post(tweetId);
    }

    
    
    /**
     * 用户followerId关注用户followeeId
     */
    public void follow(int followerId, int followeeId) {
        //若followerId不存在,则新建
        if(!userMap.containsKey(followerId) {
            User u = new User(followerId);
            userMap.put(followerId,u);
        }
        
        //若followeeId不存在,则新建
        if(!userMap.containsKey(followeeId) {
            User u = new User(followeeId);
            userMap.put(followeeId,u);
        }
        userMap.get(followerId).follow(followeeId);
    }

    /**
     * 用户followerId取消关注用户followeeId
     */
    public void unfollow(int followerId, int followeeId) {
        if(userMap.containsKey(followerId) {
            User flwer = userMap.get(followerId);
            flwer.unfollow(followeeId);
        }
    }
    
    /**
     * 获取指定用户发送的推特以及自己关注的用户的推特
     */
    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;
}

完整代码如下:

/**
 * @Auther: huangshuai
 * @Date: 2021/10/22 22:04
 * @Description:
 * @Version:
 */
class Twitter {

    //记录发送推特的时间
    private static int timeStamp = 0;
    //我们需要一个映射userId和User对象对应起来
    private Map<Integer, User> userMap = new HashMap<>();

    /**
     * 发送推特
     */
    public void postTweet(int userId, int tweetId) {
        //若userId不存在,则新建
        if (!userMap.containsKey(userId)) {
            userMap.put(userId, new User(userId));
        }
        //找到对应的User对象
        User u = userMap.get(userId);
        u.post(tweetId);
    }


    /**
     * 用户followerId关注用户followeeId
     */
    public void follow(int followerId, int followeeId) {
        //若followerId不存在,则新建
        if (!userMap.containsKey(followerId)) {
            User u = new User(followerId);
            userMap.put(followerId, u);
        }

        //若followeeId不存在,则新建
        if (!userMap.containsKey(followeeId)) {
            User u = new User(followeeId);
            userMap.put(followeeId, u);
        }
        userMap.get(followerId).follow(followeeId);
    }

    /**
     * 用户followerId取消关注用户followeeId
     */
    public void unfollow(int followerId, int followeeId) {
        if (userMap.containsKey(followerId)) {
            User flwer = userMap.get(followerId);
            flwer.unfollow(followeeId);
        }
    }

    /**
     * 获取指定用户发送的推特以及自己关注的用户的推特
     */
    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;
    }

    /**
     * 推特类
     */
    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;
        }
    }

    /**
     * 用户类
     */
    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;
        }
    }
}

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

640.gif

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

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

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