O(N×M) 拍平成 O(N+M):反查表如何把楼层摄像头计数从慢路径救回来
「勾这个楼层到底覆盖几个摄像头?」——超管在配置页的盲勾体验必须解决。盲勾的体验很差:要么勾多了(放出不该放的楼层),要么勾少了(后面发现某楼层其实没摄像头,白勾)。
本文把”在 DTO 加一个字段这件事”拆开讲:为什么必须用反查表、为什么计数口径要和 getCameraTree 对齐、为什么保序用 LinkedHashMap、为什么 toMap 要带 merge 函数。前端删 Mock 零计算渲染,后端单测 6/6 全绿。
🎧 文章导读
🎵 背景音乐

图1:建反查表 → 按 spaceId 查楼层 → 按楼层 merge 计数,三步完成统计
一、需求背景:两层链路中的”展示性增量”
在拆本次增量之前,先说清整体链路的两层,否则单看本文会摸不着头脑。
1.1 第一层(已合入):给超管做公司默认可见楼层配置
上一提交 9c7e71ac6 给 super-admin 提供了公司默认可见楼层的配置能力:
- 新增
GET /cameraInfo/orgFloorConfig/{orgId}查某公司当前园区已勾选的可见楼层; - 新增
POST /cameraInfo/orgFloorConfig整体保存(空数组即清空,仅超管可操作); - 落库表
security_camera_org_floor加了唯一约束(park_id, org_id, floor_space_id); - 保存时先硬删再批插,逻辑干脆。
这套配置是 getCameraTree 可见性过滤的数据源:非超管只能看到所属公司配置楼层下的摄像头,或已审批通过的摄像头编号。
1.2 第二层(本次未提交的增量):配置页显示「勾这个楼层到底几个摄像头」
配置页面上,超管面对一串楼层勾选框,却不知道「勾下这一栋的 3 楼,到底能看到几个摄像头」。所以本次给配置查询的返回值加了一个 floorCameraCounts 字段,告诉前端每个被勾选楼层下有多少摄像头。
前端要做的事很简单:把三处 Mock 数据(园区、ABC 栋、随机数量)删掉,改为调真实的空间树接口拉当前园区层级,再调配置查询接口回显已勾选楼层与每楼层摄像头数、调保存接口提交。
增量虽小,决策不少。下面把”为什么这样做”的三个关键决策讲清楚。
二、关键决策 1:计数口径必须和 getCameraTree 对齐
2.1 这是整件事的立身之本
getCameraTree 判可见是按楼层放行整个楼层子树——某个楼层被勾选,它下面挂的所有节点(包括房间)里的摄像头全部可见。
那么配置页上「该楼层 N 个摄像头」的 N,就必须是这个楼层子树下的全部摄像头数,否则前端显示的数字和用户实际能看到的数量对不上,超管会被误导。

图2:左侧 getCameraTree 按楼层放行子树,右侧 floorCameraCounts 必须统计相同子树
2.2 空间模型
摄像头可能直接挂在「楼层」节点上,也可能挂在楼层下的「房间」节点上;而房间节点带 floorId 指回所属楼层。
所以:
1 | 「楼层子树的摄像头」 |
这个口径一旦想清楚,算法的骨架就有了:把任意 spaceId 折算成所属楼层 id,再把摄像头按楼层归堆计数。
三、关键决策 2:用反查表而不是嵌套遍历
3.1 最朴素的写法
对每个摄像头,遍历空间树找它属于哪个被勾选的楼层:
1 | // O(N×M):N 个摄像头 × M 个被勾选楼层 |
空间数和摄像头数一旦都上百,就是 O(N×M) 的嵌套扫描,园区规模一大就慢。
3.2 折中方案:先建反查表
1 | // 反查表:spaceId → 所属楼层 id |
建表 O(空间数),归堆 O(摄像头数),**总复杂度 O(空间数+摄像头数)**,一次扫完。
反查表本质上就是把嵌套的查找拍平成一次 Map 预处理。这不是什么高深技巧,但在业务代码里最常被忽略。
四、关键决策 3:LinkedHashMap + merge 函数
4.1 为什么用 LinkedHashMap
前端展示楼层列表要按 floorSpaceIds(被勾选楼层)传入的顺序渲染,不能让 HashMap 的哈希序打乱。
1 | Map<String, Integer> counts = floorSpaceIds.stream() |
最后一个参数 LinkedHashMap::new 指定底层使用 LinkedHashMap,保证遍历顺序和 floorSpaceIds 一致。
4.2 为什么 toMap 要带 merge 函数
空间数据里如果出现重复 id(理论上不该、实际上可能),Collectors.toMap 默认会抛 merge 异常直接炸接口:
1 | java.lang.IllegalStateException: Duplicate key xxx |
取 (l, r) -> l(保留先出现的值),容错优先于严格:
1 | .collect(Collectors.toMap( |
两个小但关键的细节:LinkedHashMap 保序,merge 函数容错。少一个,线上就要炸。
五、核心算法 countCamerasByFloor
5.1 第一步:每个被勾选楼层先初始化为 0
1 | Map<String, Integer> counts = floorSpaceIds.stream() |
注意初始化用的是被勾选楼层列表(floorSpaceIds),而不是全园区所有楼层——没被勾选的楼层根本不会出现在结果里,这是「只统计被勾选楼层」的第一道关卡。
5.2 第二步:建立 spaceId 到所属楼层 id 的反查表
1 | Map<String, String> floorBySpace = spaces.stream().collect(Collectors.toMap( |
这段三元嵌套是算法的实质:
| 节点类型 | 折算结果 |
|---|---|
| 房间 | 取其 floorId 指向的楼层 |
| 楼层 | 自身 id |
| 其它层级(楼栋、园区等) | 空串忽略 |
StringUtils.defaultString 是防空:万一某个房间的 floorId 是 null,兜底成空串,后面 counts::containsKey 自然过滤掉。
5.3 第三步:每个摄像头按 spaceId 归到楼层,只统计被勾选的
1 | cameras.stream() |
filter(counts::containsKey) 是第二道关卡:园区里摄像头可能挂在非楼层/非房间(比如挂在楼栋层、或挂在未被该公司勾选的楼层),这些 floorBySpace 折算出来的 id 不在 counts 的 key 集合里,一律不计。
两道关卡叠加,结果就精确等于「被勾选楼层子树下的摄像头数」。
六、getOrgFloorConfig 的改动
6.1 改动点
原来 getOrgFloorConfig 拿到 floorSpaceIds 直接塞进 result 就返回,逻辑很轻。现在要在返回前多干两件事:
- 拉本园区全部空间(
spaceInfoClient.list); - 拉本园区全部摄像头(
listByCondition(cameraQuery),按 parkId 过滤); - 然后交给
countCamerasByFloor算计数填进floorCameraCounts。
改动只动了 CameraInfoServiceImpl.java 里这一个方法,以及 DTO 加字段、单测加用例,三处收敛在同一模块,不涉及跨服务。
6.2 为什么「全量拉」而不是「按楼层过滤拉」
[!tip] 性能 vs 可读性的权衡
全量拉空间和摄像头看起来粗放,但这是配置页低频接口、数据量也就一个园区的规模。按楼层过滤反而要为每个勾选楼层发一次查询,N 次 IO 远比一次全量拉慢。用反查表把后续计算压成 O(N+M),整体反而更快。配置接口不是性能热点,可读性优先。
七、前端接入:零计算
前端在另一个仓库(文件路径以实际仓库为准),本次改动两个文件:
- 摄像头楼层配置页面组件:删掉了「园区、ABC 栋、随机数量」三处 Mock 数据,改为调用真实的空间树接口拉当前园区空间层级,再调配置查询接口回显已勾选楼层与每楼层摄像头数、调保存接口提交。
- API 定义文件:新增
orgFloorConfig的 GET/POST 封装,对应后端两个接口。
前端的职责被刻意压到最轻——它只负责拉数据、渲染勾选框和「该楼层 N 个摄像头」的文案、提交勾选结果,零计算。所有计数逻辑都在后端算好,前端拿到
floorCameraCounts直接按 key 取值渲染。
这和”计数口径要和 getCameraTree 对齐”是配套的:计数是后端的职责,前端不该重复实现一套可能跑偏的口径。
7.1 前端验证
- ESLint:0 error;
- 生产构建成功,30 条 warning 均为项目既有问题,与本次改动无关。
八、DTO 改动:只加一个字段
CameraOrgFloorConfigDTO 只加了一个字段,key 是楼层 spaceId、value 是摄像头数:
1 | private Map<String, Integer> floorCameraCounts; |
字段为什么不复杂化、为什么不单独建一个 DTO?因为它就是「配置查询的附加信息」,和
floorSpaceIds(已勾选楼层列表)同生同灭,没必要拆。这是 YAGNI——单用途的附加字段,塞进现有 DTO 最省事。
九、测试与验证
后端单测 CameraInfoServiceImplTest.java 新增了 cameraCountsIncludeCamerasBoundToRooms 用例,专门验证「直接挂楼层的摄像头 + 挂楼层下房间的摄像头都计入该楼层」这个口径:
- floor-1 上有一个直接挂楼层的摄像头 + 一个挂 room-1 的摄像头(room-1 的
floorId指向 floor-1)→ floor-1 计 2; - floor-2 无摄像头 → 0。
加上原有用例,后端单测 6/6 全绿。
这个用例的价值在于它锁住了口径——如果以后有人改
countCamerasByFloor时忘了把房间下的摄像头归到楼层,这个用例会立刻红。
十、经验总结
10.1 两条可复用的经验
[!tip] 经验 1:附加统计字段要对齐主链路口径
本次floorCameraCounts必须和getCameraTree的可见性判断用同一个口径(楼层子树全计),否则前端显示数和实际可见数对不上。任何「给现有功能加展示性统计」的需求,第一步都是先确认主链路的统计口径,再让附加字段对齐它。
[!tip] 经验 2:嵌套查找先建反查表
O(N×M) 的嵌套遍历,几乎总能用一次 Map 预处理拍平成 O(N+M)。这不是什么高深技巧,但在业务代码里最常被忽略——看到双层循环找归属关系,第一反应就该是「能不能先建个 Map」。
10.2 为什么不过度设计
这次刻意没做这些事:
| 没做的事 | 原因 |
|---|---|
给 countCamerasByFloor 抽接口 |
只有一个实现、一个调用方,抽了就是死灵活性 |
给 floorCameraCounts 做分页或懒加载 |
配置页一次性渲染、数据量就是一栋楼的层级 |
在 DTO 上加 totalCameraCount 汇总字段 |
前端要汇总自己 reduce 一下就行,后端不该提供没人要求的字段 |
每一行改动都能直接追溯到「让超管看到每楼层摄像头数」这一个需求。
10.3 反查表适用的 4 个场景
- 空间归属:摄像头属于哪个楼层、设备属于哪个房间——本质都是 spaceId → 父级 id 的映射。
- 组织归属:用户属于哪个组织、订单属于哪个事业部——userId/orderId → orgId。
- 标签归属:商品属于哪些类目、文章属于哪些话题——商品/文章 id → 标签集合。
- 租户归属:资源属于哪个租户——resourceId → tenantId。
判断标准很简单:只要出现”两个集合,每个元素要找它在另一个集合里的归属”的双层循环,就该建反查表。
10.4 算法选型的”够用就好”原则
不要追求”理论最优”,要追求”业务场景下的最优”。本次的全量拉 + O(N+M) 计算,在园区规模下完全够用:
| 数据规模 | 性能 |
|---|---|
| 空间数 < 1000,摄像头数 < 1000 | 单次查询 < 50ms |
| 空间数 < 5000,摄像头数 < 5000 | 单次查询 < 200ms |
| 空间数 > 10000 | 考虑分园区拉或异步预热 |
配置接口不是性能热点,可读性优先于理论最优。
十一、结语
「让超管看到每楼层摄像头数」这个需求,看起来只是「在 DTO 加一个字段」,背后却是三个决策:计数口径和主链路对齐、O(N+M) 替代 O(N×M)、保序和容错两个细节。
下次再遇到”展示性统计字段”的需求,按这个顺序思考:
- 口径和谁对齐? 找到主链路的统计口径,让新字段对齐它。
- 有没有嵌套查找? 有就先建反查表。
- 需要保序吗? 需要就用
LinkedHashMap。 - 数据可能有脏吗? 有就给
toMap加 merge 函数。
算法优化的最高境界不是”我想到了牛逼的算法”,而是”我把嵌套循环换成了一次 Map 预处理”。