深蓝黑背景封面。左侧是一串 8 颗珠子围成的环形手串示意:两颗相邻的青色珠子排在环顶,右侧依次是紫色、白色、灰色、紫色、白色、灰色珠子;环顶一段紫色圆弧括号框住连续 3 颗珠子作为窗口,旁边标注「窗口 m = 3:同色出现 2 次 → 该颜色违规」,环中心写题目「任意连续 m 颗里同色超过一次即违规,问:多少种颜色违规」。环下一行小注「难点在首尾相接处:直线扫描会漏掉跨端点的窗口」。右侧大标题两行「环形手串:连续 m 颗内」「同色超一次怎么数」,副标题「断环为链 + 滑动窗口,一次遍历数出违规颜色」,小注「约束检查题:不求最值,只问违规」。左上角标注「面试经典 · 算法」,右上角圆角胶囊标注「Day 06 · 12-03」。 图 1:本文封面,环形手串与滑动窗口示意。

先说明属性:这是一篇笔试真题复盘,题目出自本地历史笔试资料整理,与任何公司的当前招聘无关。题意:一条 n 颗珠子串成的环形手串,每颗珠子可以无色,也可以涂若干种颜色,颜色共 c 种。要求是:任意一种颜色,在任意连续的 m 颗珠子里至多出现一次。问有多少种颜色不满足要求——也就是存在某个连续 m 颗的窗口,让该颜色出现至少两次。数据范围:n ≤ 10⁴,m ≤ 10³,c ≤ 50。

一句话思路:断环为链——把前 m−1 颗复制接到尾部,再用长度 m 的滑动窗口扫一遍,窗口内某颜色计数到 2 就把它标记为违规,答案是去重后的违规颜色种数。

为什么难在「环形」。 如果手串是直线,一个普通滑动窗口就解决了;环形的麻烦在首尾相接:最后一颗和第一颗相邻,跨端点的窗口(比如最后 2 颗加第 1 颗)在直线扫描里根本没有位置。处理环形结构有个通用套路——断环为链:把前 m−1 颗复制一份接到链尾,所有跨端点的窗口就都有处可放。窗口从链头滑到原链尾,恰好 n 个位置,不重不漏。

窗口里维护什么。 一个长度 c+1 的计数数组:珠子进入窗口时,它身上的每种颜色计数加一,离开窗口时减一;某种颜色计数加到 2,就进违规集合。注意题目问的是「多少种颜色违规」而不是「违规多少次」,所以用集合去重,同一颜色被抓到多次只算一次。每颗珠子进出窗口各一次,时间 O(n × 每颗平均颜色数),额外空间 O(c),对照数据范围非常宽裕。

用代码实现并跑四组用例(Node.js 22.15.0,node bracelet.js):

function countBadColors(n, m, c, beads) {
  const k = Math.min(m, n);                        // 窗口长度不超过环长
  const ext = beads.concat(beads.slice(0, k - 1)); // 尾部拼接前 k-1 颗
  const cnt = new Array(c + 1).fill(0);
  const bad = new Set();
  const add = (i) => {
    for (const color of ext[i]) {
      cnt[color]++;
      if (cnt[color] >= 2) bad.add(color);         // 窗口内同色第二次出现
    }
  };
  const remove = (i) => { for (const color of ext[i]) cnt[color]--; };
  for (let i = 0; i < k; i++) add(i);              // 初始窗口 [0, k)
  for (let i = 1; i < n; i++) { remove(i - 1); add(i + k - 1); }
  return bad.size;
}

实测输出:

样例一(官方样例 n=5 m=2)违规颜色数 = 2 (期望 2)
样例二(八珠手串 m=3)违规颜色数 = 1 (期望 1)
样例三(无违规)违规颜色数 = 0 (期望 0)
样例四(跨端点违规)违规颜色数 = 1 (期望 1)

样例一的违规颜色是 2 号和 3 号:2 号出现在第 1、3、4 颗,第 3 与第 4 颗相邻落进同一个窗口;3 号出现在第 1、3、5 颗,第 5 颗的下一颗就是环上的第 1 颗——这个违规只有跨端点窗口才抓得住,正是断环为链的意义。样例四同理:颜色只出现在首尾两颗,直线扫描必然漏报。

深蓝黑背景概念图,顶部小字「概念图 · 环形滑动窗口」,主标题「断环为链,把环形约束拉成直线」。上部左侧是一串 8 颗珠子的圆环小图(颜色序列:青、青、紫、白、灰、紫、白、灰),经箭头指向右侧拉直的链:8 颗原珠之后用虚线框额外拼接了前 2 颗的拷贝,标注「复制前 m−1 = 2 颗接到尾部,跨端点的窗口就有处可放」。链下方画三个窗口括号:窗口一框住开头三颗(青、青、紫)标「青色 ×2 → 违规」;窗口二框住中间三颗(紫、白、灰)标「各 1 次 → 合法」;窗口三框住末尾跨界三颗(灰、青、青)标「跨端点窗口,青色 ×2 → 违规,不拼接就会漏」。下部四步流程卡:① 断环为链,尾部拼接前 m−1 颗;② 长度 m 的窗口从链头滑到原链尾;③ 用计数数组统计窗口内各颜色次数,超过 1 即标记该颜色;④ 答案 = 被标记颜色的种数(去重),本例为 1 种。底部注「每颗珠子进出窗口各一次,时间 O(n),额外空间 O(c)」。 图 2:断环为链与滑动窗口计数的四步流程。

等价视角:按颜色看间距。 滑动窗口之外还有一种理解方式:把每种颜色的出现位置按环序排好,相邻两次出现(含首尾环绕)的珠子距离如果小于 m,这两颗就能被某个 m 窗口同时装下,该颜色违规。两种写法判据等价,窗口版不用排序、一趟扫完,更适合在笔试时限里写对。

边界与失分点。 三个容易栽的地方。第一,漏掉跨端点窗口——只扫直线窗口,答案偏小;第二,把「多少种颜色」做成「多少次违规」,忘了去重;第三,off-by-one:长度 m 的窗口要覆盖跨端点情形,需要复制的恰好是前 m−1 颗,多复制会重复计数、少复制会漏。另一个值得主动交代的边界是 m ≥ n:窗口不短于整圈,意味着任何出现两次的颜色都违规,实现里把窗口长度收到 min(m, n) 即可。

面试现场的答题结构。 先复述题意并确认「问的是违规颜色种数」,再指出环形是唯一难点、给出断环为链,然后写出窗口加计数数组的主流程并主动报复杂度,最后把 m=1、m ≥ n、同色去重这几个边界点出来。你在笔试里遇到过环形结构的题吗?当时是怎么处理的?欢迎分享。