想象一下你在组织一场大型会议,参会者需要按照特定规则排队入场。每个人有两个属性:身高(hi)和"前面比自己高或相等的人数"(ki)。现在给你一组打乱顺序的(hi, ki)数据,如何重建这个队列?
这个看似简单的问题其实考察了我们对排序算法和贪心策略的综合运用能力。核心难点在于:直接按ki排序显然不行,因为每个人的ki值依赖于前面更高的人;而单纯按身高排序也无法满足ki的要求。
关键突破点在于逆向思考:先处理高个子的人。因为高个子在队列中的位置不会受后面矮个子的影响。具体来说:
这种做法的精妙之处在于:当我们要插入某个人时,已经插入的所有人都比他高(或相等),所以ki值直接对应着应该插入的位置索引。
在Java中实现自定义排序,我们需要使用Comparator接口。以下是排序逻辑的详细解释:
java复制Arrays.sort(people, new Comparator<int[]>() {
public int compare(int[] person1, int[] person2) {
if (person1[0] != person2[0]) { // 身高不同
return person2[0] - person1[0]; // 降序排列
} else { // 身高相同
return person1[1] - person2[1]; // 按k值升序
}
}
});
这个比较器实现了:
注意:这里使用person2[0] - person1[0]来实现降序,是因为当person2更高时返回正数,表示person2应该排在前面。
排序后的插入操作是算法的核心:
java复制List<int[]> ans = new ArrayList<int[]>();
for (int[] person : people) {
ans.add(person[1], person);
}
为什么这样可以保证正确性?
算法的时间复杂度主要来自两个部分:
但实际上,使用ArrayList的插入操作在大多数情况下性能优于理论值。对于n≤2000的问题规模(如LeetCode常见测试用例),这个算法是完全可行的。
让我们看一个完整的实现,包括测试用例:
java复制import java.util.*;
public class QueueReconstruction {
public int[][] reconstructQueue(int[][] people) {
// 自定义排序
Arrays.sort(people, (a, b) -> {
if (a[0] != b[0]) {
return b[0] - a[0]; // 身高降序
} else {
return a[1] - b[1]; // k值升序
}
});
// 贪心插入
List<int[]> result = new ArrayList<>();
for (int[] p : people) {
result.add(p[1], p);
}
return result.toArray(new int[result.size()][]);
}
public static void main(String[] args) {
QueueReconstruction solution = new QueueReconstruction();
// 测试用例1
int[][] people1 = {{7,0},{4,4},{7,1},{5,0},{6,1},{5,2}};
int[][] result1 = solution.reconstructQueue(people1);
System.out.println(Arrays.deepToString(result1));
// 预期输出: [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
// 测试用例2
int[][] people2 = {{6,0},{5,0},{4,0},{3,2},{2,2},{1,4}};
int[][] result2 = solution.reconstructQueue(people2);
System.out.println(Arrays.deepToString(result2));
// 预期输出: [[4,0],[5,0],[2,2],[3,2],[1,4],[6,0]]
}
}
为什么这个算法能保证重建的队列满足所有条件?我们可以用数学归纳法来证明:
基础情况:当插入第一个(最高)的人时,列表为空,他的ki必须为0(因为前面没有人),直接插入位置0,满足条件。
归纳假设:假设前k个人的插入都满足各自的条件。
归纳步骤:当插入第k+1个人时:
因此,算法对所有n个人都能正确重建队列。
这种队列重建算法在实际中有多种应用:
排序逻辑错误:
插入操作错误:
边界条件处理不当:
调试示例:对于输入[[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]],排序后应为:
[[7,0],[7,1],[6,1],[5,0],[5,2],[4,4]]
然后依次插入:
- [7,0] → [[7,0]]
- [7,1] → [[7,0],[7,1]]
- [6,1] → [[7,0],[6,1],[7,1]]
- [5,0] → [[5,0],[7,0],[6,1],[7,1]]
- [5,2] → [[5,0],[7,0],[5,2],[6,1],[7,1]]
- [4,4] → [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
虽然O(nlogn)的排序不可避免,但插入操作可以优化:
当前算法需要O(n)额外空间(用于结果列表),可以考虑:
另一种思路是使用优先队列(堆):
虽然我们以Java为例,但算法思想可以应用于各种语言。以下是不同语言中的实现差异:
python复制def reconstructQueue(people):
people.sort(key=lambda x: (-x[0], x[1]))
output = []
for p in people:
output.insert(p[1], p)
return output
Python的优势在于简洁的lambda表达式和列表操作。
cpp复制vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort(people.begin(), people.end(), [](const vector<int>& a, const vector<int>& b) {
return a[0] > b[0] || (a[0] == b[0] && a[1] < b[1]);
});
vector<vector<int>> res;
for (const auto& p : people) {
res.insert(res.begin() + p[1], p);
}
return res;
}
C++需要注意vector的插入效率问题。
javascript复制function reconstructQueue(people) {
people.sort((a, b) => a[0] === b[0] ? a[1] - b[1] : b[0] - a[0]);
let res = [];
for (let p of people) {
res.splice(p[1], 0, p);
}
return res;
}
JavaScript中数组的splice方法可以方便地实现插入。
为了加深对这个算法的理解,建议尝试以下扩展问题:
这里给出第一个扩展问题的解决方案:
java复制public int[][] reconstructQueueStrict(int[][] people) {
// 排序:hi降序,ki升序
Arrays.sort(people, (a, b) -> {
if (a[0] != b[0]) {
return b[0] - a[0];
} else {
return a[1] - b[1];
}
});
List<int[]> result = new ArrayList<>();
for (int[] p : people) {
// 需要找到前面正好有p[1]个严格大于p[0]的位置
int count = 0;
int pos = 0;
while (pos < result.size() && count < p[1]) {
if (result.get(pos)[0] > p[0]) {
count++;
}
pos++;
}
result.add(pos, p);
}
return result.toArray(new int[result.size()][]);
}
这个修改后的版本在插入时需要额外检查严格大于的条件,时间复杂度变为O(n²),但解决了更严格的问题定义。