三角洲行动 · 自动整理算法模拟器

仓库模式:多个 9 格宽、高度 8–16 的仓库 · 物品可 90° 旋转 · 自动分配并求解最优摆放方案

求解精度
优化目标
求解中…
算法说明 / 交互提示(含核心代码)

0 · 一句话总览

页面在做的是一件明确的事:把一堆可 90° 旋转的矩形物品,分配进若干个体积固定、尺寸各不相同的容器里,让尽量多(或尽量大)的物品放下,并输出一份人能照着摆的操作手册。它不是"最少用几个箱子"的经典装箱问题——容器数量、每个容器的宽高都是你给定的输入,所以这是一个固定异构容器的二维多背包问题,装不下时必须做取舍。

为什么不能求"最优":判定"能否全部塞进去"本身就属 NP-hard。把每件物品压成 1 格高、宽度不等的长条,容器 9 格宽,那么每一行就是一个容量 9 的箱子——问题立刻退化成最经典的一维装箱。所以除穷举外无人能保证最优,本工具走的是高质量启发式 + 大规模随机重启 + 局部搜索 + 重力压缩,在时间预算内给出近优解,并用确定性规则保证一个质量下限。

1 · 输入、变量与目标

符号含义取值
I物品集合,每件物品 i 有尺寸 (wᵢ, hᵢ)、类型 tᵢ 与身份 id、名称1 ≤ wᵢ, hᵢ ≤ 20;tᵢ ∈ {藏品, 装备, 武器, 药品, 弹药}
B容器集合,第 k 个容器有尺寸 (Wₖ, Hₖ)、可选的 尺寸白名单 allow 与 类型白名单 allowTypes,主仓库另有 main 标记仓库 9×8~16(主仓库固定 9×51);背包 1~5 × 1~9
p一个摆放:物品 id、类型、所在容器、左上角 (x, y)、朝向 rotx, y 为整数格坐标

硬约束(任何时刻都不能违反):

  1. 不越界:0 ≤ x 且 x + w ≤ Wₖ,0 ≤ y 且 y + h ≤ Hₖ;
  2. 不重叠:任意两个已放置物品的格子集合互不相交;
  3. 朝向只有两种:原样 (w, h) 或旋转 90° 后的 (h, w);
  4. 物品不可堆叠、不可裁剪、坐标必须是整数格;
  5. 尺寸/类型白名单(可选,仅仓库模式):容器可声明"只接收哪些尺寸、哪些类型",不在名单里的一律不能进入该容器。尺寸按物品原始尺寸匹配、旋转 90° 等价(勾了 2×5,则 2×5 与 5×2 都算);类型取物品的类型标签(藏品/装备/武器/药品/弹药,缺省视为藏品)。两张名单同时生效,物品必须同时满足。
  6. 容量上限:每种尺寸最多 999 件(录入时即被夹取),避免误输入把浏览器拖死。

软目标(按字典序比较,越靠前的优先级越高):

件数优先: ( 放入件数 → 占用总面积 → 总占用行数↓ → 按装填优先级加权的占用行数↓ )
面积优先: ( 占用总面积 → 放入件数 → 总占用行数↓ → 按装填优先级加权的占用行数↓ )

用"字典序 + 多级目标"而不是加权求和,是为了避免给不同量纲调权重:件数永远压过面积,面积永远压过行数,只在同分时才看下一级。最后一项是唯一带"偏好"色彩的目标,见第 8 节的装填优先级。

容器不是"越靠前越先装满",而是分三档:设了白名单的受限容器排第一档(它挑货,必须先用掉);没设任何白名单的万能容器排第二档(什么都能放,留到后面以保住余量);主仓库排最后一档(天然万能,永远最后考虑)。这条规则同时作用于装填顺序、同分裁决和评分加权,见第 8 节。

2 · 数据结构:位图 + 自由矩形表

每个容器内部维护两套互相印证的结构,它们决定了单次试排能有多快。

2.1 占用位图(O(1) 单格查询,O(面积) 区域判定)

把二维格子拉平成一维字节数组,1 表示已占用。所有"会不会压到别人"的判断都落在它上面。

const cells = new Uint8Array(W * H);        // 0 = 空,1 = 已占用

function markCells(cells, W, p, v) {        // 落子/擦除:把一件物品覆盖的格子整片写值
  for (let j = 0; j < p.h; j++)
    for (let i = 0; i < p.w; i++)
      cells[(p.y + j) * W + p.x + i] = v;
}

function regionFree(cells, W, H, x, y, w, h) {   // 一块矩形是否完全空着且不越界
  if (x < 0 || y < 0 || x + w > W || y + h > H) return false;   // 越界
  for (let j = 0; j < h; j++)
    for (let i = 0; i < w; i++)
      if (cells[(y + j) * W + x + i]) return false;              // 压到别人
  return true;
}

2.2 自由矩形表(MaxRects)

位图回答"这里能不能放",自由矩形表回答"下一个候选落点在哪"。它维护一张仍未被占用的矩形清单,初始只有整块容器;每放下一件,与它相交的自由矩形被切成最多 4 块(上/下/左/右),并删除被其它自由矩形完全包含的冗余块。

let free = [{ x: 0, y: 0, w: W, h: H }];     // 一开始整个容器都是空的

function splitFree(free, u) {                 // u = 刚放下那件物品所占的矩形
  const out = [];
  for (const f of free) {
    const apart = u.x >= f.x + f.w || u.x + u.w <= f.x ||
                  u.y >= f.y + f.h || u.y + u.h <= f.y;
    if (apart) { out.push(f); continue; }     // 不相交:原样保留
    if (u.y > f.y)             out.push({ x: f.x,   y: f.y,       w: f.w,               h: u.y - f.y });                 // 上
    if (u.y + u.h < f.y + f.h) out.push({ x: f.x,   y: u.y + u.h, w: f.w,               h: f.y + f.h - (u.y + u.h) }); // 下
    if (u.x > f.x)             out.push({ x: f.x,   y: f.y,       w: u.x - f.x,         h: f.h });                 // 左
    if (u.x + u.w < f.x + f.w) out.push({ x: u.x + u.w, y: f.y,   w: f.x + f.w - (u.x + u.w), h: f.h });           // 右
  }
  return pruneFree(out);                      // 去掉被其它自由矩形完全包住的块
}

为什么要 prune:不做剪枝,自由矩形会随放置次数线性膨胀,比较候选点的时间随之爆炸;剪掉被包含的块后,自由矩形数量在实战实例里通常维持在物品数的同一量级,单次试排才可能是微秒级。

3 · 一次完整构造:packMulti

给定一个物品顺序和一种落点口味,从头到尾放一遍,绝不回头、绝不回溯。这就是求解器最基本的一步,也是所有搜索的基础。实际代码拆成两层:makeStates() 建初始状态(可带"沿用上次摆放"的固定物品),packInto() 在上面跑装箱;packMulti() 只是把两者串起来的一个入口。

function packMulti(order, bins, heur, allowRotate, ordered, pinned) {
  return packInto(makeStates(bins, pinned), order, heur, allowRotate, ordered);
}

function packInto(states, order, heur, allowRotate, ordered) {
  const ranks = binRanks(states);              // 装填优先级名次:受限 → 万能 → 主仓库
  const unplaced = [];

  for (const it of order) {                    // 按顺序取物品
    let chosen = null;

    if (ordered) {                             // 策略 A:顺序装填:按优先级名次依次试容器
      for (const bi of binPriority(states)) {
        const st = states[bi];
        if (!sizeAllowed(st.allow, it)) continue;       // 尺寸白名单不收 → 跳过整个容器
        if (!typeAllowed(st.allowTypes, it)) continue;  // 类型白名单不收 → 同样跳过
        let best = null;
        for (const f of st.free)               //   遍历该容器所有自由矩形
          for (const o of rotsFor(it, allowRotate)) {       // 两种朝向
            if (o.w > f.w || o.h > f.h) continue;          // 塞不进这块,跳过
            const sc = fitScore(f, o.w, o.h, heur, st.cells, st.width, st.height);
            if (!best || cmpArr(sc, best.sc) < 0) best = { f, o, sc };   // 只留更好的
          }
        if (best) { chosen = { st, f: best.f, o: best.o }; break; }  // 这个容器放得下就不再往后看
      }
    } else {                                   // 策略 B:全局最优(所有容器一起比)
      let best = null;
      for (let bi = 0; bi < states.length; bi++) {
        const st = states[bi];
        if (!sizeAllowed(st.allow, it)) continue;
        if (!typeAllowed(st.allowTypes, it)) continue;
        for (const f of st.free)
          for (const o of rotsFor(it, allowRotate)) {
            if (o.w > f.w || o.h > f.h) continue;
            const sc = fitScore(f, o.w, o.h, heur, st.cells, st.width, st.height);
            sc.push(ranks[bi]);                        // 同分时优先放进优先级更高的容器
            if (!best || cmpArr(sc, best.sc) < 0) best = { st, f, o, sc };
          }
      }
      chosen = best;                           // 位置最优者胜
    }

    if (!chosen) { unplaced.push(it); continue; }   // 哪个容器都放不下 → 记入"未放置",不阻塞后续物品

    const p = { id: it.id, name: it.name, type: it.type || '藏品', w: chosen.o.w, h: chosen.o.h,
                rot: chosen.o.rot, x: chosen.f.x, y: chosen.f.y, binId: chosen.st.id };
    chosen.st.placed.push(p);
    markCells(chosen.st.cells, chosen.st.width, p, 1);    // 位图记账
    chosen.st.free = splitFree(chosen.st.free, p);        // 自由矩形表记账
  }
  return { states, unplaced };
}

两种策略的差别:策略 A 一旦找到能放下的容器就停止,因此"先装的容器会被塞满";策略 B 横跨所有容器比较落点质量,因此密度更高、但可能把某个容器塞得很整齐却让别的容器空着。勾选「顺序装填」只用 A,取消勾选则两种都跑一遍、取评分更优者(所以"全局最优"的搜索空间严格包含"顺序装填",结果不会更差)。

关键性质:整个过程的复杂度是 O(N · B · F · 2 · w̄h̄),其中 N 是物品数、B 是容器数、F 是平均自由矩形数、w̄h̄ 是平均物品面积(用于 markCells)。没有递归、没有回溯、没有穷举,所以"放一遍"在几十件物品时只要几百微秒——这是 0.01 秒档能够成立的根本原因。

4 · 五种落点口味:fitScore

同一块物品放进同一个自由矩形,靠左还是靠上、贴边还是留缝,会显著影响后续能不能塞得下。评分函数返回一个数组,按字典序比较,越小越优;数组前两项是"废料度量",后两项用坐标做同分并列时的收敛方向(保证确定性、可复现)。

function fitScore(f, w, h, heur, cells, W, H) {
  const rw = f.w - w, rh = f.h - h;            // 放下之后这块空位剩多宽、剩多高
  switch (heur) {
    case 'bssf': return [Math.min(rw, rh), Math.max(rw, rh), f.y, f.x];  // 最贴合短边
    case 'blsf': return [Math.max(rw, rh), Math.min(rw, rh), f.y, f.x];  // 最贴合长边
    case 'baf':  return [f.w * f.h - w * h, Math.min(rw, rh), f.y, f.x]; // 最小剩余面积
    case 'bl':   return [f.y + h, f.x + w, Math.min(rw, rh)];            // 靠左上收敛
    case 'tp':   return [-contactLen(cells, W, H, f.x, f.y, w, h), f.y, f.x]; // 最大贴边对齐
  }
}
口味通俗理解适合的场景
bssf 最贴合短边尽量把窄缝填死,不浪费细长空档长短条混放(1×6 与 2×2)
blsf 最贴合长边尽量让剩余空间还是个方正的大块后续还有大件要放
baf 最小剩余面积谁被填得最满就放谁追求利用率
bl 靠左上收敛像真人一样从左上角一格格码规则好照做、直观
tp 最大贴边对齐让新物品尽量贴着已有物品和箱壁摆出来最整齐、不留飘空

contactLen 统计候选位置四条边中有多少格紧贴已占用格子或容器边框,贴得越多值越大,取负号后成为"越小越优"的第一判据。

白名单判定(容器级筛选)

白名单在候选枚举之前生效:只要容器不收这个尺寸或类型,就把整个容器从候选里划掉,连"找落点"都不做。这样既保证结果合规,也顺带减少了搜索量。

function sizeAllowed(allow, it) {                            // 尺寸名单
  if (!allow || !allow.length) return true;                  // 空名单 = 不限
  return allow.indexOf(it.w + 'x' + it.h) >= 0 ||
         allow.indexOf(it.h + 'x' + it.w) >= 0;              // 旋转 90° 等价
}
function typeAllowed(allowTypes, it) {                       // 类型名单
  if (!allowTypes || !allowTypes.length) return true;
  return allowTypes.indexOf(it.type || '藏品') >= 0;        // 缺省类型按"藏品"
}
// 两张名单同时生效
if (!sizeAllowed(st.allow, it)) continue;
if (!typeAllowed(st.allowTypes, it)) continue;

5 · 七种排队顺序:buildOrders

人理包的习惯是"先放大件",但"大"有很多种定义,程序干脆全部试一遍。

function buildOrders(items) {
  const by = f => items.slice().sort(f);
  return [
    { label: '面积从大到小',     order: by((a, b) => b.w * b.h - a.w * a.h) },
    { label: '最长边从大到小',   order: by((a, b) => Math.max(b.w, b.h) - Math.max(a.w, a.h)) },
    { label: '最短边从大到小',   order: by((a, b) => Math.min(b.w, b.h) - Math.min(a.w, a.h)) },
    { label: '周长从大到小',     order: by((a, b) => (b.w + b.h) - (a.w + a.h)) },
    { label: '高度从大到小',     order: by((a, b) => b.h - a.h || b.w - a.w) },
    { label: '宽度从大到小',     order: by((a, b) => b.w - a.w || b.h - a.h) },
    { label: '占地面积小者优先', order: by((a, b) => a.w * a.h - b.w * b.h) }
  ];
}

6 · 重力压缩:把解规范化成人能照做的样子

数学上,"整体平移过的两个方案"是同一个解;但游戏里你不能让物品飘在半空。所以每次构造完成后,都做一轮先向上、再向左的收敛压缩:对每件物品,只要上方还有空就往上顶,顶不动了再往左推,反复扫到没有任何物品能动为止。

function compact(placed, W, H, maxPass = 60, skipPinned) {
  const movers = skipPinned ? placed.filter(p => !p.pinned) : placed;   // 钉住的物品不参与移动
  const cells = new Uint8Array(W * H);
  for (const p of placed) markCells(cells, W, p, 1);

  for (let pass = 0; pass < maxPass; pass++) {
    let moved = false;
    const order = movers.slice().sort((a, b) => (a.y - b.y) || (a.x - b.x));   // 从最上面开始扫
    for (const p of order) {
      markCells(cells, W, p, 0);                                   // 先把自己从位图上抹掉
      while (p.y > 0 && regionFree(cells, W, H, p.x, p.y - 1, p.w, p.h)) { p.y--; moved = true; }  // 向上顶到底
      while (p.x > 0 && regionFree(cells, W, H, p.x - 1, p.y, p.w, p.h)) { p.x--; moved = true; }  // 再向左推到底
      markCells(cells, W, p, 1);
    }
    if (!moved) break;                                             // 一整轮没人动 → 已收敛
  }
  return placed;
}
const compactMulti = (states, skipPinned) => {                      // 对每个容器各压一遍
  for (const st of states) compact(st.placed, st.width, st.height, 60, skipPinned);
  return states;
};

压缩带来的三个好处:① 大量等价解被归一到同一形态,评分不再被"虚高"的空隙干扰;② 剩余空间被挤到一侧,更容易连成整块,方便继续塞货(页面里的"最大空白矩形"就是这么算出来的);③ 输出给玩家的图天然是"贴着上边左边"的,好照着摆。

与"沿用上次摆放"的配合:启用 pin 时压缩会跳过被固定的物品(skipPinned = true),否则一次压缩就可能把上轮辛苦保住的坐标全部挪走,稳定性就白做了。而普通物品仍会被吸到空隙里,所以新补进来的货依然是紧贴的。

7 · 搜索框架:确定性穷举 + 随机重启 + 局部搜索

async function solveAsync(items, bins, opts) {
  const rnd = opts.rnd || mulberry32(seed);        // 固定种子 → 可复现(见第 12 节)
  const baseStates = makeStates(NB, opts.pin);     // 建一次初始状态(可含"沿用上次摆放"的固定物品)
  const policies = ordered ? [true] : [false, true];

  // 一次"完整试排 + 压缩 + 打分",只有更优才留下
  const evaluate = (order, heur, orderLabel) => {
    iterations++;
    const clean = order.filter(Boolean);
    for (const pol of policies) {
      const r = packInto(cloneStates(baseStates), clean, heur, allowRotate, pol);
      compactMulti(r.states, pinnedCount > 0);
      const score = scoreMulti(r.states, mode, ordered);          // 见第 8 节
      if (!best || cmpArr(score, best.score) > 0) { best = { ... }; bestOrderInfo = clean.slice(); }
    }
  };

  // 阶段 1:确定性穷举 —— 5 种落点口味 × 7 种排序 = 35 次完整试排(保底,任何档位都跑满)
  const orders = buildOrders(active);
  for (const h of HEURISTICS) for (const o of orders) evaluate(o.order, h.id, o.label);
  const baseIters = iterations;                    // = 35

  // 阶段 2:随机重启 + 局部搜索 —— 轮次上限与时间预算谁先到就停
  while (iterations - baseIters < maxIters && nowMs() - t0 < budgetMs) {
    for (let k = 0; k < 120; k++) {
      evaluate(mutate(), pickHeuristic(), '随机扰动搜索结果');
      if (iterations - baseIters >= maxIters) break;
      if (nowMs() - t0 >= budgetMs) { truncated = true; break; }   // 慢机器上的硬保险
    }
    await new Promise(r => setTimeout(r, 0));       // 分片让出主线程:界面不卡、进度条能动
  }
  return best;
}

7.1 四种扰动算子(mutate)

① 邻域交换:在最优顺序上随机做 1~3 次位置互换        —— 微调,往山头上爬(45% 概率)
② 失败者前置:把上一轮"放不下"的物品全部提到最前面    —— 改变布局结构,用小事换出大事的位置(20%)
③ 部分打乱  :取一种经验排序,随机打乱其中约 30% 的位置 —— 中等幅度探索(20%)
④ 完全随机  :整体 shuffle                              —— 跳出局部最优(15%)
// ② 的实现:失败者前置
const unIds = new Set(best.unplaced.map(u => u.id));
const front = bestOrderInfo.filter(it =>  unIds.has(it.id));   // 上轮没放下的
const back  = bestOrderInfo.filter(it => !unIds.has(it.id));   // 上轮放下的
const cand  = front.concat(shuffleInPlace(back, rnd));         // 先安排失败者,其余随机
// 若上一轮全部放下(没有失败者),退化为整体 shuffle

落点口味也在跟着变:每次扰动有一半概率沿用"当前最优口味",另一半概率从 5 种里随机换一种。也就是说搜索同时在"物品顺序"和"落点口味"两个维度上游走,但每次给定顺序时落点规则是固定的贪心——这就是它只能算"够好"而不能保证最优的原因(见第 11 节)。

7.2 精度档的真实含义:轮次上限 = 规模 × 档位

下拉框里的"光速 0.01s / 极速 0.1s / …"现在同时决定两件事:随机阶段的轮次上限 maxIters(保证同样的输入跑出同样的结果),以及墙钟时间上限(慢机器上的硬保险)。轮次按"问题规模 + 档位"推算,规模越大单轮越贵,轮次相应减少:

maxIters = 4000 × (60 / max(30, 物品数)) × (预算毫秒 / 1500)      // 夹在 0 ~ 60000
总轮次 = max(35, maxIters)        // 35 是保底地板,任何档位都不会少于它

下面是在本机(Node 24,无头环境)实测的完整数据,物品为 7 种预设尺寸的混合批:

档位预算120 件 / 3 仓库:轮次 → 耗时 → 放入600 件 / 9 仓库:轮次 → 耗时 → 放入
光速 0.01s10 ms35 轮 → 26 ms → 91/12035 轮 → 98 ms → 368/600
极速 0.1s100 ms133 轮 → 78 ms → 91/12035 轮 → 97 ms → 368/600
快速 0.4s400 ms533 轮 → 247 ms → 92/120107 轮 → 314 ms → 368/600
标准 1.5s1500 ms2000 轮 → 837 ms → 92/120400 轮 → 1172 ms → 370/600
深度 4s4000 ms5333 轮 → 2.3 s → 93/1201067 轮 → 3.2 s → 371/600
极限 10s10000 ms13333 轮 → 5.3 s → 93/1202667 轮 → 7.9 s → 371/600
三个值得注意的结论:①光速档只跑 35 次保底,所以它和"极速"在小实例上结果完全相同(91 件),而 600 件时两者也一样(都是 35 轮,因为轮次上限被规模压到了 35 以下)——档位越高,收益越小;②件数越多,单轮越贵,同样预算下的轮次被压得越低,所以大实例靠的是"够好的贪心"而不是"更多的随机";③实测中没有出现"被时间上限截断"的情况(truncated = false),说明轮次上限的标定留了余量;万一真的截断了,结果里会明确标出来。

8 · 解的评价与比较

所有候选解都用同一个打分函数排序,再靠字典序比较逐项裁决。把所有项取负号,就把"越小越好"统一成了"越大越好"。

function cmpArr(a, b) {                 // 字典序:从第 1 项开始比,谁小谁优
  for (let i = 0; i < Math.min(a.length, b.length); i++)
    if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
  return a.length - b.length;
}

function scoreMulti(states, mode, ordered) {
  let count = 0, area = 0, totalH = 0, weighted = 0;
  const ranks = binRanks(states);     // 受限 → 万能 → 主仓库
  states.forEach((st, i) => {
    let usedH = 0;
    for (const p of st.placed) { area += p.w * p.h; usedH = Math.max(usedH, p.y + p.h); }
    count   += st.placed.length;
    totalH  += usedH;                   // 总占用行数
    weighted += ranks[i] * usedH;       // 优先级名次 × 占用行数:名次小的先装满
  });
  return (mode === 'area' ? [area, count] : [count, area])
         .concat([-totalH, -weighted]);  // 取负 = 越大越好
}

第 4 项 -weighted 就是"装填优先级"的数学表达:每个容器按 受限仓库(名次 0) → 万能仓库(名次 1) → 主仓库(名次 2) 编排名次,名次越小越先装满。于是"没设任何白名单、什么都能放"的万能仓库、以及天然万能的主仓库会被自动留到最后——同分时它们占用越少越好,这就是"给万能仓库留余量"的量化方式。

function binTier(b) {                        // 装填优先级:数字越小越先装
  if (b && b.main) return 2;                                    // 主仓库:天然万能,永远最后
  const hasSize = b && b.allow && b.allow.length;
  const hasType = b && b.allowTypes && b.allowTypes.length;
  return (hasSize || hasType) ? 0 : 1;                         // 受限仓库 0 / 万能仓库 1
}
function binPriority(states) {               // 按名次给出容器的遍历顺序
  return states.map((st, i) => i)
    .sort((a, b) => binTier(states[a]) - binTier(states[b]) || a - b);
}
function binRanks(states) {                  // 每个容器的名次(同级保持传入顺序)
  const rank = new Array(states.length);
  binPriority(states).forEach((bi, r) => { rank[bi] = r; });
  return rank;
}

这个名次同时用于三处:顺序装填时按名次遍历容器、全局最优模式下的同分并列项、以及评分函数的加权项——三处一致,才能保证"万能仓库与主仓库永远留到最后"。

9 · 统计量:利用率、占用行数与最大空白矩形

结果区那几个数字全部是现算的,不是估的。

利用率 = 已放物品面积之和 / 该容器面积 × 100%
占用行数 usedH = max(p.y + p.h)              // 最下面那件物品压到第几行
剩余整块空行 = Σ(容器高 - 该容器 usedH)

最大空白矩形用直方图法求:逐行维护"这一列往上连续空了多少格"的高度数组,再用单调栈求直方图里的最大矩形。它回答的是"现在还能再塞一件多大的货"。

for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) heights[x] = cells[y * W + x] ? 0 : heights[x] + 1;
  // 对 heights 跑一遍单调栈 → 得到以 y 为底的最大矩形,全局取最大
}

10 · 交互层用到的同一套判定

手动微调不是"看起来差不多就行",它复用同一套位图合法性判定,所以任何时刻都不可能摆出非法局面。

10.1 拖动:把自己的格子排除后再试算

function occupancyFor(binId, excludeId) {      // 该容器当前的占用位图(不含被拖的那件)
  const ct = containerById(binId);
  const cells = new Uint8Array(ct.width * ct.height);
  const bin = state.solution.bins.find(b => b.id === binId);
  for (const p of bin.placed) if (p.id !== excludeId) markCells(cells, ct.width, p, 1);
  return cells;
}
// 指针移动时实时判定:几何合法 且 白名单也收(两张名单一起查)
const free = regionFree(cells, ct.width, ct.height, nx, ny, p.w, p.h) && containerAllows(ct, p);

跨容器拖动通过命中测试 gridAtPoint() 判断指针落在哪个网格上(扫描整个棋盘区,所以主仓库那独立的一列同样能作为落点)。命中另一个容器时直接改变物体归属,并按新容器的格子尺寸重新换算坐标——主仓库与普通仓库的格子大小不同,切换时会自动换比例。判定仍旧复用同一套位图 + 白名单检查,所以"搬错箱子"同样会被拦下。

const cell = cellOf[found.bin.id] || state.cellPx;   // 每块棋盘有自己的格子尺寸
// 换容器后:drag.cell = cellOf[新容器] 再换算 nx/ny,方块宽高也按新比例重设

10.2 双击旋转:环形候选搜索

旋转后尺寸变成 (h, w),原地往往放不下。于是以原中心为圆心,按半径 0、1、2、3 的环形顺序寻找第一个合法落点——优先原地,实在不行就微移一两格。

for (let r = 0; r <= 3; r++)
  for (const [dx, dy] of ring(r))                       // 半径 r 上的一圈偏移
    if (regionFree(cells, W, H, bx + dx, by + dy, p.h, p.w)) { /* 旋转成功 */ }

10.3 📐 最小尺寸试算:对容器尺寸做一次线性搜索

// 仓库模式:宽度固定 9,只扫高度 8~16(9 个候选)
// 背包模式:枚举 5 × 9 = 45 个候选,按"先窄后矮"的顺序试
const perBudget = Math.max(60, Math.min(900, state.budgetMs / 2));   // 每个候选的试算预算
for (let w = 1; w <= 5; w++)
  for (let h = 1; h <= 9; h++) {
    const r = await solveAsync(scope, bins, { budgetMs: perBudget });
    if (r.placedAll.length === r.total) return { w, h };   // 找到第一个装得下的最小尺寸
    if (nowMs() - t0 > 15000) break;                       // 安全阀:最多试算 15 秒
  }

已有方案时,scope 是该容器当前装着的物品(问:这口箱子能压到多小?);尚未求解时,scope 是全部物品且其它容器保持不变(问:这口箱子至少要多大?)。主仓库不参与试算——尺寸固定 9×51,点它只会得到一句提示;它也没有白名单,因此传进求解器时两张名单都强制为空。

11 · 正确性自检、已知边界与实测结论

  • 每次求解后自动校验:validateSolution() 逐容器重放一遍,检查每件物品是否越界、是否重叠、binId 是否与所在容器一致(含钉住的物品);不通过会在控制台报警。
  • 启发式没有最优性证明:工具给出的是"时间预算内试过的最好解",不是数学意义上的最优解。随机阶段的搜索只游走于"物品顺序 × 落点口味"两个维度,给定顺序后落点规则是固定的贪心、绝不回溯——因此理论上存在"必须先走一步坏棋才能达到的更优解",本工具够不到。0.01 秒档省掉的只是随机试探,35 次经验规则永远会跑完(见第 7 节),所以有一个稳定的质量下限。
  • 为什么启发式通常够用:本题宽度极窄(仓库 9 格、背包最多 5 格),一件物品的可放位置只有几十到一百来个(9×12 里放 4×4 只有 54 个位置,同样的物品放进 100×100 的通用水箱是 9409 个),9 格宽的一行用宽度 ≤6 的物品去铺只有 248 种切法。选择空间小到"试 35 次经验规则"就基本到底了。
  • 实测结论(可复现):
    ① 200 件混合物品 / 9 个仓库:35 轮试排 70 毫秒就得到与 40000 轮 48 秒完全相同的结果(都是 200 件、利用率 91.8%);
    ② 同一实例换 8 个随机种子各算一遍,结果极差 0 件;
    ③ 2000 件 1×1 超载:305 毫秒放入 1296 件 = 总容量,零浪费;
    ④ 9×16 的仓库装 4×4:实测 8 件——这不是算法不够好,而是可以证明的上限:3 个 4×4 并排需要 12 格 > 9 格宽,任意水平线最多被 2 件覆盖,每件占 4 行,故 4k ≤ 2×16 ⇒ k ≤ 8。
  • 不可行时的结论是可信的:"尺寸超出容器"由几何直接判定(两种朝向都放不进任何一个容器),与搜索质量无关;"白名单不收"由名单直接判定;只有"空间不足"才与搜索质量有关,而按上面的实测,它绝大多数时候真正的瓶颈是总容量而不是拼合技巧。

12 · 结果稳定性:固定随机种子 + 确定性轮次 + 沿用上次摆放

朴素实现里,"随机重启"用 Math.random() 取随机数、用墙钟时间决定什么时候停,于是:机器快一点多跑几千轮、慢一点少跑几千轮,加上随机数每次都不同,同一批物品每次算出来的摆法就会差很多。这里做了三件事把它稳住。

1)固定种子的伪随机数

function mulberry32(seed) {          // 同一个种子 → 同一串随机数
  let a = seed >>> 0;
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const DEFAULT_SEED = 0x5F3A21C7;     // 默认基准种子("换个方案"时 +1 偏移)

2)确定性的轮次上限

除了时间预算,再按"问题规模 + 精度档"算出一个轮次上限,两者谁先到就停。这样跑多少轮只取决于输入,不取决于机器快慢:

maxIters = 4000 × (60 / max(30, 物品数)) × (预算毫秒 / 1500)   // 夹在 0 ~ 60000
while (iterations < maxIters && nowMs() - t0 < budgetMs) { ... }

时间预算退化为"慢机器上的硬保险":真被它截断时,结果里会带 truncated = true,界面上也会提示"本次被时间上限截断",避免你以为方案本该如此。

3)沿用上次摆放(增量稳定)

同输入必然同输出,但"加了新战利品"毕竟换了输入,全局重排仍会让整个仓库面目全非。所以再加一层:把上次已经摆好的物品钉在原坐标上,本轮只把新增/未放入的物品填进空隙。

function makeStates(bins, pinned) {        // pinned = { 容器id: [{id,x,y,w,h}] }
  // 1. 正常建空状态
  // 2. 对每件固定物品:占格子 + 从自由矩形里挖掉它
  markCells(st.cells, st.width, p, 1);
  st.free = splitFree(st.free, { x: p.x, y: p.y, w: p.w, h: p.h });
  st.placed.push({ ...p, pinned: true });
  // 3. 后续装箱循环完全不变,只是自由矩形少了一块
}
// 每轮评价克隆一份初始状态(比重新构造省得多)
const r = packInto(cloneStates(baseStates), order, heur, allowRotate, pol);
compactMulti(r.states, true);              // 重力压缩时跳过 pinned,保证不动

触发条件很克制,避免"我想算得更好却没反应":

  • 工具栏「沿用上次摆放」勾选中(默认开)
  • 并且问题签名没变:容器 id / 尺寸 / 两张白名单 / 优化目标 / 是否允许旋转 / 是否顺序装填,任一变化都视为新的问题 → 全局重排
  • 点「🎲 换个方案」= 种子 +1 且强制全局重排;求解精度档变化也会走全局重排

这样一来,「同样的输入」两次结果逐件完全一致;「只加了几件战利品」时老物品坐标100% 保持不动,只有新物品被塞进空隙。

13 · 复杂度定位:这个模型相对通用 2D 装箱简化在哪

通用的"二维装箱 / 条带装箱"是强 NP 难问题:把每件物品的宽度取满箱宽,它就退化成 1D 装箱(由 3-PARTITION 归约而来),所以除非 P=NP,既没有多项式精确算法,也不存在 FPTAS。而这个模型落在一个结构化得多的角落——难度的来源从"组合爆炸"换成了"约束与意图"。

维度通用 2D 装箱本模型后果
容器任意 W×H,箱型待定宽度恒为 9,高度取自 {8…16} ∪ {51}落点从"平面上任意一点"变成"格子填空",位置可以数出来
物品任意整数矩形预设 14 种规格(1×1…4×4、1×6、2×6…),小整数大量物品完全相同可互换,可归并为"类型"
朝向常见版本禁止旋转允许 90°,等价于2 种姿态每件只有两种放法
目标最小化箱子数 / 最小化条带高度容器是已给定的,改成"尽量多放 → 尽量少占行"不需要全局最优,贪心即可逼近
约束无尺寸白名单 + 类型白名单 + 装填优先级 + 主仓库反而更难:这是"带冲突/带兼容性的装箱"

把简化量化成三个数字:

  • 一件物品的可放位置:9×12 仓库里放 4×4 是 54 个,放进 100×100 的通用水箱是 9409 个(差 174 倍);1×1 则是 108 对 10000。
  • 9 格宽的一行,用宽度 ≤6 的物品去铺,只有 248 种切法(限制在 1~2 格宽的小件时只有 55 种)。通用问题里"怎么摆"是无穷多种连续方案,这里是一张能枚举的清单。
  • 整张棋盘最多 459 格(主仓库 9×51),任何摆法就是"哪些格子被占",几百字节的位图即可描述,判断"这块空着吗"是一次内存查表。
理论上还能更快更准:因为宽度固定(9)且高度有界(≤51),这个模型属于"伪多项式可解 / 类型数固定时可精确求解"的一类——用剖面 DP(skyline DP)或"行样式 + 有限维整数规划"可以把最优解算出来,代价是实现复杂、常数极大。本工具选择了另一条工程路线:够快的贪心 + 大规模随机重启 + 人工拖动兜底,把"求最优"降级为"给一个好起点"。实测表明,对你实际会遇到的实例,这个取舍的收益差通常是 0 件(见第 11 节的实测结论)。

14 · 交互提示

  • 类型物品分 藏品 / 装备 / 武器 / 药品 / 弹药 五类。添加模式下先选中一个类型标签,再点尺寸或填宽高数量,加进来的就是该类型;设置模式下同样先选类型,下面的数量即该类型各尺寸的件数,切换标签即切换统计口径(每种尺寸上限 999)。随机战利品会按权重产出多种类型。名称默认按"类型 + 尺寸"规范生成,也可自己填。
  • 白名单仓库模式下,每个仓库行都有 🔒 白名单 按钮:展开后可分别勾选可接收的 类型 与 尺寸(都可多选,空选=不限,旋转等价,两张名单同时生效);标题与棋盘头部会显示 🔒 标记。手动把物品拖进不合规的仓库会被拦下。
  • 稳定工具栏「沿用上次摆放」(默认开):再次求解时保留上次已摆好的物品位置,只把新增物品补进空隙;改动仓库尺寸/白名单/优化目标/精度档会自动改为全局重排。「🎲 换个方案」= 换一个随机种子全局重排一次。默认种子固定,所以同样的物品+仓库+设置每次结果完全一致。
  • 拖动方案生成后可拖动方块微调;拖到另一个容器的网格上即可跨容器搬运(跨容器会自动换算两边的格子比例),红色描边表示非法,松手自动回弹(白名单不允许的位置同样会被拦下)。
  • 主仓库仓库模式下可添加 1 个主仓库:尺寸固定 9×51,不可改尺寸、不能试算最小尺寸、也不能设白名单(天然万能),但可改名、删除后重新添加;棋盘区里它独占右侧一列并按自己的比例缩放,因此不会影响其它仓库"每行 3 个"的排版。背包模式下会自动隐藏(数据保留)。
  • 双击方块原地旋转 90°,按环形候选搜索自动找一个放得下的位置。
  • 复原点"复原为自动方案"撤销全部手动改动,回到求解器给出的原始方案。
  • 📐试算某个容器的最小可行尺寸,算完直接应用。
  • 顺序装填勾选(默认)=只用"顺序装填"策略,靠前的容器优先塞满;取消=同时跑"全局最优"和"顺序装填"两种策略,取评分更优者(所以取消勾选的结果不会更差)。
  • 精度光速/极速/快速/标准/深度/极限决定随机阶段的轮次上限与墙钟时间上限;无论选哪档,35 次经验试排都会跑满。实测:大物品量下档位收益很小(600 件时光速与标准只差 2 件),真正放不下的原因通常是总容量。
  • 修改容器尺寸、白名单或物品清单会作废当前方案(方案区提示重新点「开始放置」);所有配置、主题、物品与仓库都保存在浏览器本地,刷新不丢。
  • 结果区会显示本轮搜索信息:「本次搜索:N / M 轮」(N=实跑、M=上限)、是否沿用了上次摆放、随机种子;被时间上限截断时会明确提示"⚠️ 本次被时间上限截断"。