题目
从一个 N * M(N ≤ M)的矩阵中选出 N 个数,任意两个数字不能在同一行或同一列,求选出来的 N 个数中第 K 大的数字的最小值是多少。
输入描述
输入矩阵要求:1 ≤ K ≤ N ≤ M ≤ 150
输入格式
N M K
N*M矩阵
输出描述
N*M 的矩阵中可以选出 M! / N! 种组合数组,每个组合数组种第 K 大的数中的最小值。无需考虑重复数字,直接取字典排序结果即可。
备注
注意:结果是第 K 大的数字的最小值
用例
输入 | 输出 | 说明 |
---|---|---|
3 4 2 1 5 6 6 8 3 4 3 6 8 6 3 | 3 | N*M的矩阵中可以选出 M!/ N!种组合数组,每个组合数组种第 K 大的数中的最小值; 上述输入中选出数组组合为: 1,3,6; 1,3,3; 1,4,8; 1,4,3; ...... 上述输入样例中选出的组合数组有24种,最小数组为1,3,3,则第2大的最小值为3 |
思考一(暴力解法)
题目说明N*M的矩阵中可以选出 M!/ N!种组合数组,暴力解法就是根据约束条件枚举这么多组合计算每个组合的第 K 小的数字再更新全局第 K 小的数字。由于所有数字不能同行同列,不能像DFS搜索矩阵那样上下左右递归搜索。但是思路应该是相似的,难点在于没有限制只能访问当前位置的相邻元素,可以是不相邻的,而且必须是和当前搜索路径中已经搜索的所有数字不同行同列。以当前位置搜索下一个元素就不是从当前位置周边展开搜索了,这样代码也不好写,试想我可以搜索周围四个角的位置元素,它们和当前位置不同行不同列,但是未必和之前搜索过的数字也不同行不同列,那么正确的做法是不是从头开始遍历整个矩阵,排除和已经访问过数字同行或同列的数字,其余就是可以访问的数字。可以定义行哈希集合和列哈希集合存储每条路径访问过的数字位置行列索引,这样下次访问别的数字就可以进行位置一一比对筛选不同行不同列数字,回溯的时候再移除最新加入的位置。每次怎么记录第 K 小的数字,每次路径搜索结束时访问的数字序列长度达到 N 时就对序列降序排序取第 K 个元素,用快速排序需要O(N log(N))复杂度,感觉有些浪费,但可以利用快速排序分治的原理快速选择第K大的元素,也可以用优先队列查找第K大(小)元素。这里做法是用优先队列存放固定 K 个元素,用二叉堆实现,堆顶存放最小的数字,每次插入数字都重新调整堆的结构维持堆顶数字最小,这种操作复杂度是O(log n)要比排序更好。
基本思路:
- 枚举矩阵中每个数字作为起点进行搜索,定义一个count作为dfs函数参数记录访问的数字数量,到K时终止一次搜索路径。定义rowSet和colSet存放行列索引,每次搜索时查询set筛选不同行不同列数字;
- 选择下一个不同行不同列且未访问过的数字进行搜索,利用备忘录visited记录访问过的位置避免下次回溯又重复访问,维护一个优先队列(最小堆)存放 K 个数字;
- 当搜索序列长度达到 N 时从优先队列中取出堆顶的数字即局部第K大的数字更新全局所有第K大的数字中的最小值。
最小堆的作用:动态维护前 K 大元素
- 最小堆的堆顶是堆中最小的元素。
- 当需要维护当前最大的 K 个元素时,最小堆可以保证:
- 堆中始终保存当前已知的最大 K 个元素。
- 堆顶是这 K 个元素中的最小值(即第 K 大元素)。
算法过程
该算法通过深度优先搜索(DFS)结合最小堆(优先队列)的方式,暴力枚举所有可能的 N 个不同行不同列元素组合,从而找到其中第 K 大元素的最小值。以下是详细的算法步骤:
1. 输入处理与初始化
-
读取输入参数:
- 读取矩阵的行数
N
、列数M
和目标值K
。 - 读取矩阵的每个元素。
- 读取矩阵的行数
-
初始化全局变量:
visited
:二维数组,标记矩阵中每个位置是否已被访问。result
:初始化为无穷大,用于记录最终结果(第 K 大元素的最小值)。
2. 深度优先搜索(DFS)
递归函数参数:
rowIndex
,colIndex
:当前处理的元素位置。count
:当前已选择的元素数量。heap
:最小堆,维护当前路径中最大的 K 个元素。rowSet
,colSet
:已选择元素的行号和列号集合,用于确保不同行不同列。
递归终止条件:
- 若当前位置越界、已访问或已选择的元素数量达到 N,终止递归。
处理当前元素:
- 将当前元素加入堆中,并更新行、列集合和访问标记。
- 若已选择 N 个元素:
- 堆顶元素即为当前路径的第 K 大元素。
- 更新全局结果
result
为当前堆顶元素和result
中的较小值。
递归遍历剩余元素:
- 遍历矩阵中所有未被选择的行和列(通过
rowSet
和colSet
排除已选的行列)。 - 对每个符合条件的位置,递归调用 DFS 继续搜索。
3. 堆操作(MinHeap 类)
堆结构:
- 固定大小为
K
的最小堆,堆顶元素为当前最大的 K 个元素中最小的一个(即第 K 大元素)。
插入操作:
- 若堆未满(元素数量 < K),直接插入并调整堆。
- 若堆已满且新元素大于堆顶元素,替换堆顶并调整堆。
堆调整:
- heapifyUp():从下往上调整堆,确保父节点小于子节点。
- heapifyDown():从上往下调整堆,确保父节点小于子节点。
4. 主程序流程
-
枚举所有可能的起点:
- 对矩阵中的每个元素
(i, j)
作为起点,初始化堆和访问标记。 - 调用 DFS 开始搜索。
- 对矩阵中的每个元素
-
输出结果:
- 遍历所有可能的组合后,
result
即为第 K 大元素的最小值。
- 遍历所有可能的组合后,
复杂度分析
- 时间复杂度:\(O(N! * M^N)\),其中 N 为行数,M 为列数。每个路径需要 O (N log K) 维护堆。
- 空间复杂度:\(O(N + K)\),主要用于存储行、列集合和堆。
参考代码
function solution() {let [N, M, K] = readline().split(" ").map(Number);const mtx = [];for (let i = 0; i < N; i++) {mtx[i] = readline().split(" ").map(Number);}const visited = Array.from({ length: N }, () => new Array(M).fill(false));let result = Infinity;const dfs = function (rowIndex, colIndex, count, heap, rowSet, colSet) {if (rowIndex < 0 ||rowIndex >= N ||colIndex < 0 ||colIndex >= M ||visited[rowIndex][colIndex] ||count >= N) {return;}const num = mtx[rowIndex][colIndex];heap.insert(num);rowSet.add(rowIndex);colSet.add(colIndex);visited[rowIndex][colIndex] = true;if (count === N - 1) {result = Math.min(result, heap.peek());// console.log(heap.toString());return;}for (let i = 0; i < N; i++) {for (let j = 0; j < M; j++) {if (!rowSet.has(i) && !colSet.has(j) && !visited[i][j]) {dfs(i, j, count + 1, heap, rowSet, colSet);}}}};for (let i = 0; i < N; i++) {const heap = new MinHeap(K);// const heap = new PriorityQueue(K);visited.forEach((item) => item.fill(false));for (let j = 0; j < M; j++) {let rowSet = new Set();let colSet = new Set();dfs(i, j, 0, heap, rowSet, colSet);}}console.log(result);
}class MinHeap {constructor(capacity) {this.capacity = capacity; // 堆的固定大小this.heap = [];}// 插入元素insert(num) {if (this.heap.length < this.capacity) {this.heap.push(num);this.heapifyUp();} else if (num > this.heap[0]) {this.heap[0] = num;this.heapifyDown();}}// 从下往上调整堆heapifyUp() {let index = this.heap.length - 1;while (index > 0) {const parentIndex = Math.floor((index - 1) / 2);if (this.heap[parentIndex] <= this.heap[index]) break;[this.heap[parentIndex], this.heap[index]] = [this.heap[index],this.heap[parentIndex],];index = parentIndex;}}// 从上往下调整堆heapifyDown() {let index = 0;while (true) {const leftChild = 2 * index + 1;const rightChild = 2 * index + 2;let smallest = index;if (leftChild < this.heap.length &&this.heap[leftChild] < this.heap[smallest]) {smallest = leftChild;}if (rightChild < this.heap.length &&this.heap[rightChild] < this.heap[smallest]) {smallest = rightChild;}if (smallest === index) break;[this.heap[index], this.heap[smallest]] = [this.heap[smallest],this.heap[index],];index = smallest;}}// 获取堆顶元素(第 K 大的元素)peek() {return this.heap[0];}toString() {return this.heap.join("-");}
}// js 数组模拟二叉堆(最小堆)
class PriorityQueue {constructor(capaicity) {this._list = [];this._size = 0;this._capacity = capaicity;}insert(num) {// 如果队列已满且新元素比当前最小值大,则替换最小值if (this._size >= this._capacity) {if (num > this._list[0]) {this._list[0] = num;this._list.sort((a, b) => a - b); // 重新排序维持最小堆}return; // 无论是否替换,队列大小不变}// 队列未满时直接插入this._list.push(num);this._list.sort((a, b) => a - b); // 保持升序排列this._size++;}peek() {return this._list[0];}toString() {return this._list.join("-");}
}const cases = [`3 4 2
1 5 6 6
8 3 4 3
6 8 6 3`,
];let caseIndex = 0;
let lineIndex = 0;const readline = (function () {let lines = [];return function () {if (lineIndex === 0) {lines = cases[caseIndex].trim().split("\n").map((line) => line.trim());}return lines[lineIndex++];};
})();cases.forEach((_, i) => {caseIndex = i;lineIndex = 0;solution();
});
思考二(二分查找+二分图最大匹配)
暴力解法(DFS + 最小堆)在理论上可以解决问题,但 无法满足题目给定的规模输入(N≤M≤150)。查阅资料得知可以用二分搜索 + 匈牙利算法解决。在一个矩阵中选取 N 个元素,要求这些元素位于不同的行和列。可以将行号和列号分别看作二分图的两个部分,寻找 N 个互不同行同列的元素,就相当于在这个二分图中找到 N 条边的匹配。假设已经构建了二分图,理论上可以找到多种这样的匹配。但若逐一列出所有匹配并比较其中第 K 大的元素,还是暴力解法,效率低下。转换思路,我们假设已知第 K 大元素的最小值为 kth。那么,矩阵中至多有 N−K+1 个元素值 ≤kth,且这些元素需互不同行同列。因为在这 N 个元素中,有 K−1 个元素比 kth 大,剩下的 N−(K−1)=N−K+1 个元素 ≤kth,这 N−K+1 个元素中包含了 kth(第 K 大值)本身。kth 的大小和二分图的最大匹配数存在正相关关系。当 kth 越小时,满足 ≤kth 的矩阵元素就越少;而 kth 越大,满足 ≤kth 的元素就越多。基于这种关系,我们可以采用二分法来枚举 kth 的值。二分枚举的范围是 1 到矩阵元素的最大值。即使枚举到的 kth 不是矩阵中的元素也无需担心,因为最终我们要找到的第 K 大元素必然是矩阵中的某个值,只有当枚举到矩阵中的某个元素时,才能满足找到足够多 ≤kth 元素的要求。在二分枚举过程中,若当前枚举的 kth 值使得二分图的最大匹配数 ≥N−K+1,则说明 kth 取大了,应将二分的右边界缩小为 kth - 1;反之,若最大匹配数 < N−K+1,则 kth 取小了,需将二分的左边界扩大为 kth + 1。如此反复,即可高效地找到满足条件的第 K 大元素的最小值。
算法过程
-
输入处理:读取输入的矩阵维度(N, M, K)和矩阵数据。
-
二分搜索初始化:确定搜索范围,左边界为矩阵最小值,右边界为矩阵最大值。
-
二分搜索过程:
-
构建二分图:对于当前候选值mid,构建一个二分图,其中边表示矩阵中小于等于mid的元素位置。
-
匈牙利算法:计算二分图的最大匹配数,即最多可以选择多少个不同行和列的小于等于mid的元素。
-
判定条件:如果最大匹配数至少为N-K+1,说明当前mid可行,记录并尝试更小的mid值;否则,尝试更大的mid值。
-
-
输出结果:最终输出的ans即为满足条件的第K大数字的最小值。近似 \(O(N^2 \cdot M \cdot \log C)\)
function solution() {let input = readline().split(" ");if (input.length < 3) return;let [N, M, K] = input.map(Number);const matrix = [];for (let i = 0; i < N; i++) {let row = readline().split(" ");if (row.length >= M) {matrix[i] = row.map(Number);}}const buildGraph = function(matrix, mid, N, M) {const graph = Array.from({ length: N }, () => []);for (let i = 0; i < N; i++) {for (let j = 0; j < M; j++) {if (matrix[i][j] <= mid) {graph[i].push(j);}}}return graph;};const hungarianAlgorithm = function(graph, N, M) {const matchTo = new Array(M).fill(-1);let result = 0;for (let u = 0; u < N; u++) {const visited = new Array(M).fill(false);if (dfs(u, graph, matchTo, visited)) {result++;}}return result;};const dfs = function(u, graph, matchTo, visited) {for (const v of graph[u]) {if (!visited[v]) {visited[v] = true;if (matchTo[v] === -1 || dfs(matchTo[v], graph, matchTo, visited)) {matchTo[v] = u;return true;}}}return false;};const flat = matrix.flat();let left = Math.min(...flat);let right = Math.max(...flat);let ans = right;while (left <= right) {const mid = Math.floor((left + right) / 2);const graph = buildGraph(matrix, mid, N, M);const maxMatch = hungarianAlgorithm(graph, N, M);if (maxMatch >= N - K + 1) {ans = mid;right = mid - 1;} else {left = mid + 1;}}console.log(ans);
}const cases = [`3 4 2
1 5 6 6
8 3 4 3
6 8 6 3`,
];let caseIndex = 0;
let lineIndex = 0;const readline = (function () {let lines = [];return function () {if (lineIndex === 0) {lines = cases[caseIndex].trim().split("\n").map((line) => line.trim());}return lines[lineIndex++];};
})();cases.forEach((_, i) => {caseIndex = i;lineIndex = 0;solution();
});