305
社区成员
发帖
与我相关
我的任务
分享Unit3 三次作业的背景都围绕一个在线视频社区展开:用户之间存在关注关系,用户可以上传视频,粉丝可以收到视频;后续迭代又加入点赞、投币、转发、评论、勋章、推荐、画像等功能。和前两个单元相比,这一单元的简单得多,而是“规格已经写清楚了,我能不能准确、完整、可维护地实现它”。
我的整体架构比较稳定,三次迭代都以 Network 作为系统门面,维护全局用户表和视频表;User 维护用户自身状态,如关注、粉丝、收件箱、观看历史、点赞记录、贡献记录、类型兴趣等;Video 维护视频状态,如上传者、分区、播放量、点赞数、转发数、投币数和评论区,感觉分工比较明确。
第一轮中,Network 主要负责用户、视频和关注图;第二轮在原结构上扩展互动经济系统;第三轮继续加入推荐相关状态。这样的好处是增量开发比较顺:大多数新增需求都可以用现有的框架,文物两很少。
这一单元让我对 JML 的理解从“这是一段很长的注释”变成了“这是一份可以直接驱动实现和测试的合同”。一段 JML 至少同时给出了几类信息:
规格驱动将一个任务拆成了几件事:输入是否合法、异常先后顺序是否正确、正常分支是否改变了所有该改变的状态、是否误改了不该改变的对象、查询方法是否保持纯性。以前写代码时我更容易关注主流程,而 JML 会强迫我补上边界和状态保持条件。
比如 cleanSpamComments 并不只是“删掉含关键词的评论”,它还要求返回数组长度为 2,要求第一个值是删除数量,第二个值是被删除评论中关键词出现次数的最大值,还要求未删除评论的 id-content 对应关系保持不变。类似地,recommendNthUp 不只是排序问题,还涉及候选集合的定义、排名的严格条件、同分按较小 id 优先、异常优先级,以及查询不能改变任何状态。
我觉得 JML 的优点是边界清晰,尤其适合多人协作和评测;缺点是表达冗长,读起来不如自然语言直观。比较有效的方法不是逐字“翻译”JML,而是先提取出状态模型、异常分支、可修改集合和关键量词,再把它们转成实现清单和测试清单。
本单元的 JUnit 测试不适合只测样例,因为样例覆盖不到 JML 中最容易错的部分。我主要从三个角度设计测试。
第一是根据规格写边界测试。比如年龄范围、重复用户、重复视频、关注自己、重复关注、视频不存在、未观看就点赞/投币、无视频推荐等,都能直接从 signals 中得到。
第二是测试查询方法的纯性。像 queryMutualFollowingSum、queryMostPopularVideo、recommendNthUp 这类方法,很多 bug 不是返回值第一次错,而是查询过程中不小心修改了集合,导致后续操作出错。因此我在测试里会保存用户、视频、关注关系、收件箱、热度等快照,然后在查询后比较状态是否完全一致。
从公测、强测和互测的角度看,本单元最苦难的部分是算法的实现,有很多变量的维护需要算法,而O(n^2)的复杂度基本都会被卡时间,强测的数据强度也强大了很多,基本上强测数据点比普通互测的数据点还要强,只要把各种算法成功实现并且控制时间复杂度,应该就能过强测。
第一次作业的主要任务是维护用户、视频、关注关系和视频收件箱。此时系统还比较轻,但已经包含了性能问题。
全局用户和视频我使用 HashMap<Integer, UserInterface> 与 HashMap<Integer, VideoInterface> 存储,这样 containsUser、getUser、containsVideo、getVideo 都能接近 O(1) 完成。关注关系和粉丝关系使用 HashSet<UserInterface>。
视频收件箱使用 ArrayList<Integer>,新视频通过 add(0, videoId) 插入到开头,使 queryReceivedUnwatchedVideos 可以按“最新优先”取前五个。这个操作在粉丝量很大时会比较重,但它只发生在上传或转发传播时,相比每次查询都排序或扫描全局视频,仍然更符合需求。
性能上最需要注意的是两个方法:queryMutualFollowingSum 和 queryShortestPath。如果每次查询互关的人数时都要遍历关注列表,那一定会超时,而是应该维护一个MutualFollowingSum,在用户之间关注以及取关时就改变mutualfollowingSum,查询时直接返回。最短路查询则是有向关注图上的 BFS,复杂度是 O(V+E),这里很难完全缓存,因为关注图会变化,所以重点是写清楚访问标记,避免重复入队。
最大的bug是查询方法的副作用。JML 中 queryMutualFollowingSum 是 pure,不能为了方便统计就临时改动集合,哪怕最后看起来返回值对了也不行。这个问题在研读 JML 之后才真正意识到:规格不仅约束输出,也约束过程中对象表示是否被非法改变。
以及Junit测试时记得使用深clone;
第二次作业的需求明显变复杂,新增分区、播放、点赞、投币、转发、评论、清理评论、最热视频、贡献者、勋章和最长下降年龄链等功能。。
在 User 中,我新增了硬币数、观看集合、点赞集合、勋章表和贡献表。观看和点赞使用集合,是为了快速判断某用户是否已经观看或点赞;贡献关系使用 HashMap<Integer, Integer>,方便累计某个贡献者的投币总量,并维护 bestContributor 缓存。
在 Video 中,我新增了分区、播放量、点赞数、转发数、投币数和评论区。评论区使用 HashMap<Integer, Comment>,便于按评论 id 判断重复;返回评论数组时再按 id 排序,避免内部存储被接口暴露。
为了优化 queryMostPopularVideo(type),我维护了 HashMap<String, TreeSet<VideoInterface>> rankedVideos,每个分区一个按热度降序、id 升序排序的集合。这样查询最热视频可以直接取 first(),而热度变化时只需要在对应分区里先删除旧对象、修改热度、再插回去。
这一轮的性能瓶颈有两个:评论清理和最长下降年龄链,评论清理需要扫描目标视频的评论内容,本质上与评论总长度相关;最长下降年龄链可以把用户按年龄升序排序后做 DP,避免暴力枚举所有路径。
第二次迭代最大的 bug 风险是 TreeSet 中可变对象的排序问题。TreeSet 的顺序依赖 Video.getHeat(),但 Video 的热度会在观看、点赞、投币、转发时改变。如果直接修改对象而不先从 TreeSet 中删除,集合内部的红黑树结构不会自动重排,queryMostPopularVideo 可能返回旧的第一名。这个问题表面上是容器使用问题,本质上是“排序键可变”带来的表示不一致。
第三次作业在前两轮基础上加入推荐系统:全局最佳贡献者、视频推荐、UP 主推荐、最有影响力 UP。
为了支持推荐,我在 User 中新增了 typeCounts,记录用户观看过各分区视频的次数;还新增了用户上传过的视频集合,用于计算某个 UP 在某分区的影响力。视频类型用 VideoType 统一管理,避免字符串散落在多处。
recommendVideo 的核心是计算 video.getHeat() * user.getInterest(video.getType(), videos.size()),因此直接遍历所有视频即可。recommendNthUp 则要先选出候选 UP:排除用户自己和该用户已经关注的人,然后按 computeUpScore 降序排序,同分时 id 小者优先。
第三次迭代最容易错的是候选集合的边界。recommendNthUp 排除的是“自己”和“自己已经关注的人”,但关注自己的粉丝仍然是候选人。这个点如果只凭直觉,很容易把 followers 也排除掉。我的测试里专门加入了“别人关注我,但我没关注他”的场景,应当确保候选集合符合 JML。
第二个问题是异常优先级。以 recommendNthUp 为例,用户不存在优先于非法 rank,非法 rank 优先于无视频,无视频优先于候选人数不足。这个顺序不是随便写的,而是从 JML 的 exceptional behavior 中读出来的。测试时我会刻意构造多个异常条件同时满足的输入,检查抛出的是否是规格指定的那个异常。
Unit3 第二次研讨课的“JML 击鼓传花”给我很大触动。我们组讨论了自然语言和 JML 在多人传递中的信息损失问题,最明显的感受是:复杂需求不一定更容易传错,简单但边界含糊的需求反而更危险。
当时我们组有两个题目。一个是找出某个用户播放量、点赞量、转发量最高的三个视频;另一个是统计某个人的关注链中有多少个循环可以回到自己。直觉上第一个题目简单,第二个题目抽象。但传递结果恰好相反:第二个题目因为一开始就把方法、返回值和约束、方法签名说得比较清楚,后面的人虽然要理解循环,但整体没有跑偏;第一个题目因为自然语言描述过于松散,没有说清楚参数、返回类型和排序规则,传到后面我甚至不知道返回值应该是 Video[] 还是 boolean。
这说明自然语言的最大弊端不是“不严谨”这三个字本身,而在于它默认了太多上下文逻辑,出题者觉得“最高的三个视频”很自然,但是下一个人不知道如何理解这里的最高以及以怎样的形式返回视频对象、视频 id,还是布尔判断?没有这些清楚的定义,后续写出来的 JML 也是百花齐放。
我在研讨课中发现的共性问题主要有三类。第一是接口边界缺失,包括方法名、参数、返回类型、异常条件没有先定下来;第二是词义错会,比如某个英文词在业务语境中有特定含义,但传递者和接收者理解不同;第三是 JML 冗余导致阅读者抓不到重点,量词写得很多,却没有把核心业务规则凸显出来。
如果以后多人组队写规格,我认为至少要做几件事。首先先固定接口表:方法名、参数类型、返回类型、异常类型必须在自然语言阶段就明确。其次给出术语表,像“关注者”“粉丝”“候选 UP”“热度”“贡献者”都不能靠个人直觉理解。再次给出边界样例,包括空集合、并列、重复、非法输入和异常优先级。最后做一次自然语言到 JML、再从 JML 回到自然语言的双向复述,比较两次自然语言是否还表达同一个需求。这个过程看起来慢,但比最后每个人实现了不同任务要快得多。
不管是自然语言还是JML,我其实觉得要判断好不好,最重要的是让别人get到你的想表达的点,这样就成功了。
这一单元我也尝试了大模型
它真正有用的地方有三个。第一是帮助把 JML 拆成实现清单,例如这个方法涉及哪些字段、哪些异常、哪些状态不能改。第二是生成测试思路,尤其是边界测试和状态快照测试。第三是搭本地评测机的搭建,可以很好识别 CPU 时间寻找潜在 TLE。
当然,大模型也可能忽略效率问题。它经常会先写出最直观的遍历实现,如果不主动要求分析复杂度,就不会意识到强测中的瓶颈。因此我觉得使用大模型时要把问题问得更工程化:不仅问“怎么实现”,还要问“这个实现在哪些输入规模下会炸”“哪些字段需要增量维护”“哪些查询必须保持纯性”。
感觉大模型对于单元模块可以做得很好,但是对于多方位联动,比如jml的方法测试,以及到底哪些状态不可以改变时,就会有一定的破绽。
Unit3 和前两个单元的体验很不一样。这一单元像是在和一份严格合同共事:合同写了什么就实现什么,没写的不能擅自添加,写了的边界不能靠直觉省略,所以虽然自己思考的地方很少,但是细节方面更多。
我最大的收获是开始习惯从规格反推代码。以前看到一个方法,我会先想实现;现在会先问它修改哪些状态、保持哪些状态、异常怎么分支、查询是否纯、哪些操作需要缓存。这个顺序虽然一开始慢,但越到后面越能减少信息传递导致的代码修改。
总的来说,这一单元比前两单元轻松,但是学习到原来还有这种开发方法,原来协同开发传递信息是一个这样的复杂过程:代码不是只对样例负责,而是对规格负责。