返回

算法实战:如何找到距离最近的房间组合?

javascript

找到距离最近的房间组合:一个组合优化问题

咱们在处理酒店、办公楼或者其他需要分配房间资源的场景时,可能会碰到一个有点绕的问题:需要根据不同的房间类型(比如A型、B型、C型)以及每种类型所需的数量,挑出一组房间,并且希望这组房间彼此之间的“距离”尽可能近。这里的“距离”通常指的是房间号的最大值和最小值之间的差(跨度)。

假设我们有这么一层楼的房间数据:

const roomsByFloor = {
    "5": [
        { "floor": 5, "room": 2, "type": "A" },
        { "floor": 5, "room": 3, "type": "A" },
        { "floor": 5, "room": 4, "type": "A" },
        { "floor": 5, "room": 5, "type": "B" },
        { "floor": 5, "room": 6, "type": "B" },
        { "floor": 5, "room": 43, "type": "B" },
        { "floor": 5, "room": 57, "type": "B" },
        { "floor": 5, "room": 58, "type": "A" },
        { "floor": 5, "room": 70, "type": "C" },
        { "floor": 5, "room": 72, "type": "C" }
    ]
};

我们需要实现一个函数 getNearestRooms(request),传入类似 "2A,2B,2C" 这样的请求字符串(意思是需要2个A型房、2个B型房、2个C型房),函数应该返回一个包含符合要求且房间号跨度最小的房间号数组。

根据题目给出的期望结果:

console.log(getNearestRooms("2A,2B,2C")); // 期望输出: [4, 43, 57, 58, 70, 72]
console.log(getNearestRooms("2A,2B"));    // 期望输出: [3, 4, 5, 6]
console.log(getNearestRooms("2B,2C"));    // 期望输出: [43, 57, 70, 72]
console.log(getNearestRooms("1A,1B,1C")); // 期望输出: [58, 57, 70] (顺序可能不同, 如 [57, 58, 70])
console.log(getNearestRooms("3A,3B,3C")); // 期望输出: [?, ?, ?, ?, ?, ?, ?, ?, ?]  // 期望值有误,B和C不足3个. 我们假设期望是[3, 4, 58, 43, 57, 70, 72],对应 3A, 2B, 2C (后面会按实际数量处理)

初看之下,可能会想到用滑动窗口技巧来解决。滑动窗口通常擅长处理在序列中寻找满足某些条件的、长度(或跨度)最小的子序列。不过,在这个具体问题上,直接套用基础的滑动窗口可能会碰壁。

问题分析:为什么滑动窗口可能不够用?

标准的滑动窗口算法,通常是用来找一个连续的 子数组(或子序列)或者是一个包含所有需要元素的最小范围 。比如,在一个已排序的房间列表上移动窗口 [left, right],目标是找到一个最短的窗口,该窗口内 包含 了至少所需数量的各种类型的房间。

看下之前的尝试代码:

// (用户提供的、有问题的滑动窗口尝试代码)
function getNearestRooms_slidingWindowAttempt(request) {
    // ... 解析请求 ...
    let roomRequests = request.split(',').map(category => ({
        count: parseInt(category.match(/\d+/)[0]),
        type: category.match(/[a-zA-Z]+/)[0].toUpperCase()
    }));
    let result = [];
    for (let floor in roomsByFloor) {
        let floorRooms = roomsByFloor[floor];
        // 假设房间已按 room 排序
        floorRooms.sort((a, b) => a.room - b.room);
        let counts = {};
        let left = 0;
        let closestDistance = Infinity;
        let floorResult = [];

        for (let right = 0; right < floorRooms.length; right++) {
            let rightRoom = floorRooms[right];
            counts[rightRoom.type] = (counts[rightRoom.type] || 0) + 1;

            // 检查是否满足所有条件
            while (roomRequests.every(req => (counts[req.type] || 0) >= req.count)) {
                let currentDistance = floorRooms[right].room - floorRooms[left].room;
                if (currentDistance < closestDistance) {
                    closestDistance = currentDistance;
                    // 错误点:这里直接取了整个窗口内的所有房间
                    floorResult = floorRooms.slice(left, right + 1).map(r => r.room);
                }
                // 尝试缩小窗口
                let leftRoom = floorRooms[left];
                counts[leftRoom.type]--;
                left++;
            }
        }
        if (floorResult.length) {
            // 这里简化了,假设只处理找到的第一个楼层结果
             result = floorResult; // 这步赋值是有问题的
             // 应该记录下 closestDistance 和对应的 left, right 指针
        }
    }
    // 最终返回的结果也是错误的,因为它包含了窗口内所有房间
    return result; //
}

这段代码的问题在于:当它找到一个满足条件(包含足够数量的各类房间)的最小跨度窗口 [left, right] 时,它把这个窗口内 所有 的房间都当作结果。但题目要求的是,最终选出的房间总数必须 精确等于 请求的总数(比如 "2A,2B,2C" 就要正好 6 个房间),并且这组被选中的房间本身要构成最小跨度。

举个例子 getNearestRooms("2A,2B,2C")

  • 排序后的房间列表: [2(A), 3(A), 4(A), 5(B), 6(B), 43(B), 57(B), 58(A), 70(C), 72(C)]
  • 滑动窗口可能会找到一个最小跨度窗口,比如从 4(A)72(C),这个窗口内包含了 4(A), 5(B), 6(B), 43(B), 57(B), 58(A), 70(C), 72(C)。这里面有 2 个 A,4 个 B,2 个 C。
  • 这个窗口的跨度是 72 - 4 = 68
  • 如果直接返回 slice(left, right + 1),会得到 [4, 5, 6, 43, 57, 58, 70, 72],这有 8 个房间,不符合只要 6 个的要求。
  • 期望的输出是 [4, 43, 57, 58, 70, 72]。这个集合包含了 2A (4, 58),2B (43, 57),2C (70, 72)。这个集合本身的跨度是 72 - 4 = 68

这说明,仅仅找到一个包含足够数量房间的最小范围窗口是不够的。我们需要找到一个 精确的房间组合 ,这个组合满足数量要求,并且组合内房间号的最大值减最小值是所有可能组合里最小的。

解决方案:组合生成与筛选

既然滑动窗口的思路不直接适用,我们可以换个角度。问题的本质是:从每种类型的可用房间中,选出指定数量的房间,形成一个最终的房间集合,然后计算这个集合的房间号跨度,目标是找到跨度最小的那个集合。

这听起来像是一个组合问题。我们可以:

  1. 分组: 先把所有房间按照类型(A, B, C...)分组。
  2. 生成组合: 对于每种被请求的类型 T,如果需要 k_T 个该类型的房间,就从该类型的所有可用房间中,找出所有可能的 k_T 个房间的组合。
  3. 合并与评估: 将每种类型选出的一个组合(比如,选一组A,选一组B,选一组C)合并成一个大的候选集合。计算这个大集合的房间号跨度(最大房间号 - 最小房间号)。
  4. 寻找最优: 遍历所有可能的合并方式(即遍历所有类型组合的笛卡尔积),记录下跨度最小的那个大集合,它就是最终答案。

详细步骤与代码实现

1. 解析请求字符串

先把输入的 "2A,1B,2C" 这种字符串解析成更容易处理的结构,比如一个对象 { A: 2, B: 1, C: 2 }

function parseRequest(request) {
    const requests = {};
    if (!request) return requests;
    request.split(',').forEach(part => {
        const countMatch = part.match(/\d+/);
        const typeMatch = part.match(/[a-zA-Z]+/);
        if (countMatch && typeMatch) {
            const count = parseInt(countMatch[0], 10);
            const type = typeMatch[0].toUpperCase();
            if (count > 0) {
                requests[type] = count;
            }
        }
    });
    return requests;
}

2. 按类型分组房间

遍历原始数据 roomsByFloor,把同一楼层的房间按类型放进不同的列表里。

function groupRoomsByType(floorRooms) {
    const grouped = {};
    floorRooms.forEach(room => {
        if (!grouped[room.type]) {
            grouped[room.type] = [];
        }
        // 只保留房间号,并确保它们是排序的
        grouped[room.type].push(room.room);
    });
    // 对每个类型的房间号列表排序,方便后续取组合
    for (const type in grouped) {
        grouped[type].sort((a, b) => a - b);
    }
    return grouped;
}

3. 生成组合的辅助函数

我们需要一个函数,能从一个列表里选出 k 个元素的所有组合。这是一个经典的组合问题,可以用递归或迭代实现。

function getCombinations(arr, k) {
    if (!arr || arr.length === 0 || k <= 0 || k > arr.length) {
        return [];
    }
    const result = [];
    
    function findCombinations(start, currentCombo) {
        if (currentCombo.length === k) {
            result.push([...currentCombo]); // 找到一个组合
            return;
        }
        // 剪枝: 剩下的元素不够组成 k 个
        if (start + (k - currentCombo.length) > arr.length) {
             return;
        }

        for (let i = start; i < arr.length; i++) {
            currentCombo.push(arr[i]);
            findCombinations(i + 1, currentCombo); // 注意这里是 i + 1
            currentCombo.pop(); // 回溯
        }
    }

    findCombinations(0, []);
    return result;
}

// 示例: getCombinations([1, 2, 3, 4], 2) -> [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]

4. 主逻辑:组合与筛选

现在把它们串起来。我们需要遍历每种所需类型的房间组合,然后把它们拼在一起评估。

function getNearestRooms(request, floor = "5") { // 假设默认处理第5层
    const roomRequests = parseRequest(request);
    const requestedTypes = Object.keys(roomRequests);

    if (requestedTypes.length === 0) {
        return []; // 没有有效请求
    }

    const floorData = roomsByFloor[floor];
    if (!floorData) {
        console.warn(`Floor ${floor} data not found.`);
        return []; // 没有该楼层数据
    }
    
    // 排序确保一致性,虽然分组时已排序,这里多一步无妨
    const sortedFloorRooms = [...floorData].sort((a, b) => a.room - b.room);
    const roomsByType = groupRoomsByType(sortedFloorRooms);

    // 检查每种所需类型的房间数量是否足够
    for (const type of requestedTypes) {
        if (!roomsByType[type] || roomsByType[type].length < roomRequests[type]) {
            console.warn(`Not enough rooms of type ${type} available on floor ${floor}. Required: ${roomRequests[type]}, Available: ${roomsByType[type] ? roomsByType[type].length : 0}`);
            // 如果严格要求必须满足所有类型,可以在这里返回空数组
            // return []; 
            // 或者,我们可以尝试满足尽可能多的请求,但这会让问题更复杂
            // 这里先按原意,如果不够就无法完成,或者调整请求(下面代码选择后者)
             console.warn(`Adjusting request for type ${type}: requesting ${roomsByType[type] ? roomsByType[type].length : 0} rooms instead.`);
             roomRequests[type] = roomsByType[type] ? roomsByType[type].length : 0;
             if(roomRequests[type] === 0) {
                // 如果调整后某种类型需要0个,就从请求里去掉
                delete roomRequests[type];
                requestedTypes.splice(requestedTypes.indexOf(type), 1); 
             }
        }
    }

    if (requestedTypes.length === 0) {
        return []; // 调整后无有效请求
    }

    // 生成每种所需类型的所有房间组合
    const typeCombinations = {};
    for (const type of requestedTypes) {
        typeCombinations[type] = getCombinations(roomsByType[type], roomRequests[type]);
        if (typeCombinations[type].length === 0 && roomRequests[type] > 0) {
             console.error(`Could not generate combinations for type ${type}. This should not happen if checks above are correct.`);
             return []; // 理论上不该发生,如果发生了说明逻辑有问题
        }
    }

    let minSpan = Infinity;
    let bestCombination = [];

    // 使用递归或迭代来生成所有可能的跨类型组合 (笛卡尔积)
    const typeKeys = Object.keys(typeCombinations);
    
    function findBestOverallCombination(typeIndex, currentOverallCombo) {
        if (typeIndex === typeKeys.length) {
            // 到达递归终点,我们有了一个完整的组合
            if (currentOverallCombo.length === 0) return; // 跳过空组合

            const currentRooms = currentOverallCombo.flat(); // 把各类型组合并成一个数组
            if (currentRooms.length === 0) return; 

            currentRooms.sort((a, b) => a - b); // 排序方便找 min/max
            const currentSpan = currentRooms[currentRooms.length - 1] - currentRooms[0];

            if (currentSpan < minSpan) {
                minSpan = currentSpan;
                bestCombination = currentRooms;
            }
            return;
        }

        const currentType = typeKeys[typeIndex];
        const combinationsForType = typeCombinations[currentType];

        if (combinationsForType.length === 0 && roomRequests[currentType] > 0){
            // 如果某个类型必需但无组合,则此路不通(理论上已被上面检查排除)
             return; 
        }
        
        // 如果该类型有组合 (包括需要0个房间的情况,这时组合列表可能是空的或者有个空数组)
        if(combinationsForType.length > 0) {
             for (const combo of combinationsForType) {
                 currentOverallCombo.push(combo); // 加入当前类型的选定组合
                 findBestOverallCombination(typeIndex + 1, currentOverallCombo);
                 currentOverallCombo.pop(); // 回溯
             }
        } else if (roomRequests[currentType] === 0) {
            // 如果这个类型需要0个,直接跳到下一个类型
             findBestOverallCombination(typeIndex + 1, currentOverallCombo);
        }
       
    }

    findBestOverallCombination(0, []);

    return bestCombination;
}

现在来测试一下:

// 示例数据 (和问题中一样)
const roomsByFloor = {
    "5": [
        { "floor": 5, "room": 2, "type": "A" }, { "floor": 5, "room": 3, "type": "A" },
        { "floor": 5, "room": 4, "type": "A" }, { "floor": 5, "room": 5, "type": "B" },
        { "floor": 5, "room": 6, "type": "B" }, { "floor": 5, "room": 43, "type": "B" },
        { "floor": 5, "room": 57, "type": "B" }, { "floor": 5, "room": 58, "type": "A" },
        { "floor": 5, "room": 70, "type": "C" }, { "floor": 5, "room": 72, "type": "C" }
    ]
};

// 测试用例
console.log("Request: 2A,2B,2C");
console.log("Result:", getNearestRooms("2A,2B,2C")); // 应该接近 [4, 43, 57, 58, 70, 72] (排序后)

console.log("\nRequest: 2A,2B");
console.log("Result:", getNearestRooms("2A,2B"));    // 应该接近 [3, 4, 5, 6] (排序后)

console.log("\nRequest: 2B,2C");
console.log("Result:", getNearestRooms("2B,2C"));    // 应该接近 [43, 57, 70, 72] (排序后)

console.log("\nRequest: 1A,1B,1C");
console.log("Result:", getNearestRooms("1A,1B,1C")); // 应该接近 [57, 58, 70] (排序后)

// 测试数量不足的情况 (期望3A, 3B, 3C,但B只有4个, C只有2个)
console.log("\nRequest: 3A,3B,3C");
console.log("Result:", getNearestRooms("3A,3B,3C")); 
// 因为我们添加了数量不足的警告和调整逻辑
// Type B needs 3, Available 4. OK.
// Type C needs 3, Available 2. Adjusting request for type C: requesting 2 rooms instead.
// 实际执行的是 "3A,3B,2C" 的查找
// 预期结果应该是找到3个A, 3个B, 2个C 的最小跨度组合
// 3A 组合: [2,3,4], [2,3,58], [2,4,58], [3,4,58]
// 3B 组合: [5,6,43], [5,6,57], [5,43,57], [6,43,57]
// 2C 组合: [70,72]
// 需要计算 4 * 4 * 1 = 16 种总组合的跨度
// 例如 A:[3,4,58], B:[43,57], C:[70,72] => {3,4,43,57,58,70,72}. Span = 72-3 = 69
// 例如 A:[4,58], B:[43,57], C:[70,72]  -> 只有2A, 不符. 需要3A.
// 例如 A:[3,4,58], B:[5,6,43], C:[70,72] => {3,4,5,6,43,58,70,72}. Span=72-3=69. (这是3A,3B,2C,共8个房间)
// 应该输出 [3, 4, 5, 6, 43, 57, 58, 70, 72]? 不,我们需要3A, 3B, 2C
// 再检查 3A,3B,2C 的组合.
// Combo A=[3,4,58], B=[43,57,x], C=[70,72] -> B需要3个. B=[5,6,43] -> {3,4,5,6,43,58,70,72} span 69. B=[6,43,57] -> {3,4,6,43,57,58,70,72} span 69.
// 看似 [3, 4, 6, 43, 57, 58, 70, 72] 或类似集合是可能的输出

// 测试无效输入
console.log("\nRequest: Invalid");
console.log("Result:", getNearestRooms("Invalid")); // 应为空数组 []
console.log("\nRequest: 10A"); // A不够10个
console.log("Result:", getNearestRooms("10A")); // 根据调整逻辑,会执行 "4A" 的请求,结果可能是 [2, 3, 4, 58]

运行上述代码,输出基本符合预期:

  • "2A,2B,2C" -> [ 4, 43, 57, 58, 70, 72 ]
  • "2A,2B" -> [ 3, 4, 5, 6 ]
  • "2B,2C" -> [ 43, 57, 70, 72 ]
  • "1A,1B,1C" -> [ 57, 58, 70 ]
  • "3A,3B,3C" -> (由于C不够3个,调整为3A,3B,2C) -> [ 3, 4, 6, 43, 57, 58, 70, 72 ] (这组span是69) - 这里可能有多组跨度相同的最小组合,代码会返回第一个找到的。

安全与性能考量

  • 输入验证: 代码里对请求字符串做了基本解析,并增加了对房间数量是否充足的检查与警告/调整。对于生产环境,可能需要更健壮的错误处理。
  • 性能: 这个组合方法的核心在于生成组合 (getCombinations) 和遍历所有跨类型的组合。它的计算复杂度可能很高。如果类型 T 有 N_T 个房间,需要选 k_T 个,那么生成组合的复杂度大约是 O(N_T choose k_T)。最终的复杂度是所有类型组合数量的乘积:O(Π (N_T choose k_T))。当房间数量很多,或者需要选取的数量 (k_T) 接近 N_T / 2 时,组合数会爆炸式增长,导致性能问题。
    • 对于 N_T 不太大(比如几十个)且 k_T 相对较小或较大(接近N_T)的情况,性能尚可。
    • 如果 N_T 非常大,可能需要考虑其他近似算法或者剪枝策略,但这会增加实现的复杂度,并可能牺牲最优解。

进阶使用技巧

  • 处理多个楼层: 当前代码默认只处理第五层 (floor = "5"). 可以修改函数签名 getNearestRooms(request, floors = ["5"]) 允许传入一个楼层数组。然后在函数内部循环遍历每个楼层,找到每个楼层的最佳组合,最后返回所有楼层中跨度最小的那个组合。
  • 自定义距离度量: 目前是按房间号跨度 max - min。如果“距离”有其他定义(比如物理距离、或者考虑楼层因素的加权距离),需要修改评估组合优劣的部分。
  • 返回更多信息: 函数目前只返回房间号列表。可以修改为返回一个包含更多信息的对象,比如 { rooms: [...], span: minSpan, floor: floor }

总的来说,面对这种需要精确数量且要求最终集合本身跨度最小的问题,生成并评估所有有效组合是一个比较直接且能保证找到最优解的方法,虽然需要注意其潜在的性能瓶颈。