寻找矩阵中的宝藏:二维二分查找的奇妙冒险

98 阅读7分钟

寻找矩阵中的宝藏:二维二分查找的奇妙冒险

从前,有一个叫小码的程序员,他收到了一个藏宝图(一个二维矩阵)。藏宝图有个神奇的特性:每一行的宝藏从左到右越来越值钱,而且下一行的第一个宝藏一定比上一行的最后一个更值钱。小码要找到价值正好为 9 的宝藏。他能成功吗?让我们跟随他的冒险,揭开二分查找的神秘面纱!

📜 藏宝图的秘密:有序矩阵

小码拿到的藏宝图是这样的:

宝物分布图:
[1,  3,  5]   ← 第0行
[7,  9,  11]  ← 第1行
[13, 15, 17]  ← 第2

藏宝图的规则很简单:

  1. 每一行,从左到右,宝物越来越值钱(升序排列)
  2. 下一行的第一个宝物,比上一行的最后一个宝物更值钱

这就意味着,如果把所有宝物排成一条直线,它们也是完全有序的!这就是我们算法的突破口。

🧭 小码的寻宝策略:两次缩小搜索范围

小码很聪明,他想到了一个绝妙的策略:

"我不需要一个个宝物找,我可以先确定宝藏在哪一行,再确定在哪一列!"

这个策略的精髓就是两次二分查找

  1. 第一步(纵向锁定行):只看每行的第一个宝物,用二分法找到目标行
  2. 第二步(横向锁定列):在找到的那一行里,再用二分法找到目标宝物

让我们看看小码是如何操作的:

🎯 第一阶段:锁定目标行(看每行的"门牌号")

小码发现,每行的第一个宝物就像是这一行的"门牌号"。他要找价值为 9 的宝藏,所以要先看看这个宝藏可能在哪一行。

初始状态:

  • 搜索范围:所有行(第0行到第2行)
  • 关注的"门牌号":[1, 7, 13]

二分查找过程:

第1轮:
看看中间那行(第1行)的门牌号:7
小码思考:7 <= 9 吗?是的!
说明宝藏可能在当前行或下面几行
小码记下:候选行 = 第1行
然后继续往下面找:说不定下面有更接近的

第2轮:
看看下面几行的中间(第2行)的门牌号:13
小码思考:13 <= 9 吗?不是!太大了!
说明宝藏肯定在第2行以上
现在搜索范围重叠了,停止搜索

最终确定:宝藏可能在刚才记下的第1行

为什么这样有效?

  • 如果一行的门牌号比目标小,宝藏可能在这一行或下面
  • 如果门牌号比目标大,宝藏肯定在上面某行
  • 我们记录下最后一个门牌号小于等于目标的行,就是最可能的目标行

🎯 第二阶段:在目标行中精确查找

现在小码知道宝藏很可能在第1行,这一行的宝物是:[7, 9, 11]

二分查找过程:

第1轮:
看看中间位置(第1列)的宝物:9
小码兴奋地比较:9 == 9 吗?是的!完全相等!
宝藏找到了!在第1行第1列!

任务完成! 小码成功找到了价值为 9 的宝藏。

💻 小码的寻宝代码(JavaScript版)

小码把他的寻宝过程写成了代码:

/**
 * 在有序矩阵中寻找宝藏
 * @param {number[][]} treasureMap - 藏宝图(矩阵)
 * @param {number} targetValue - 要寻找的宝藏价值
 * @return {boolean} - 是否找到宝藏
 */
function findTreasure(treasureMap, targetValue) {
    // 获取藏宝图的大小
    const totalRows = treasureMap.length;      // 有多少行
    const totalCols = treasureMap[0].length;   // 每行有多少个宝物
    
    // --- 第一步:确定宝藏可能在哪一行 ---
    let topRow = 0;
    let bottomRow = totalRows - 1;
    let targetRow = -1;  // 记录目标行,初始为-1表示没找到
    
    while (topRow <= bottomRow) {
        const middleRow = Math.floor((topRow + bottomRow) / 2);
        const doorplate = treasureMap[middleRow][0];  // 这一行的"门牌号"
        
        if (doorplate <= targetValue) {
            // 这个门牌号 <= 目标价值,宝藏可能在这一行或下面
            targetRow = middleRow;    // 记下这个候选行
            topRow = middleRow + 1;   // 继续往下找,看有没有更合适的
        } else {
            // 门牌号太大了,宝藏肯定在上面
            bottomRow = middleRow - 1;
        }
    }
    
    // 如果没有找到任何候选行,说明宝藏不存在
    if (targetRow === -1) {
        return false;
    }
    
    // --- 第二步:在目标行中精确寻找宝藏 ---
    let leftCol = 0;
    let rightCol = totalCols - 1;
    
    while (leftCol <= rightCol) {
        const middleCol = Math.floor((leftCol + rightCol) / 2);
        const treasure = treasureMap[targetRow][middleCol];
        
        if (treasure === targetValue) {
            return true;  // 找到宝藏了!
        } else if (treasure < targetValue) {
            leftCol = middleCol + 1;  // 宝藏可能在右边
        } else {
            rightCol = middleCol - 1; // 宝藏可能在左边
        }
    }
    
    return false;  // 这一行找遍了,没有找到
}

// 小码使用他的寻宝函数
const treasureMap = [
    [1, 3, 5],
    [7, 9, 11],
    [13, 15, 17]
];

console.log("寻找价值为9的宝藏:", findTreasure(treasureMap, 9));  // 输出:true
console.log("寻找价值为8的宝藏:", findTreasure(treasureMap, 8));  // 输出:false

🎨 可视化寻宝过程

为了更清楚地理解,让我们看看小码的搜索路径:

初始藏宝图:
行号  宝物
0     [1,  3,  5]
1     [7,  9,  11]  ← 最终在这里找到宝藏!
2     [13, 15, 17]

第一步:锁定行(看每行的第一个数)
搜索 [1, 7, 13] 寻找最后一个 <=9 的数
✓ 1 <= 9,记下行0,继续往下
✓ 7 <= 9,记下行1,继续往下
✗ 13 > 9,往上找
最终确定:行1

第二步:在行1中搜索
搜索 [7, 9, 11] 寻找9
第一次就命中中间位置:9 == 9

🔍 算法的精妙之处

1. 边界的巧妙处理

if (doorplate <= targetValue) {
    targetRow = middleRow;    // 记下候选行
    topRow = middleRow + 1;   // 但还继续往下找
}

这种写法确保我们找到的是最后一个门牌号小于等于目标的行,避免了这种情况:

  • 目标值=10,门牌号有[7, 9, 12]
  • 如果找到9就停止,会错过第2行(实际宝藏可能在第1行)

2. 为什么是两次二分查找?

  • 时间复杂度:O(log m + log n) = O(log(m×n))
  • 这和把整个矩阵拉直做一次二分查找的效率一样
  • 但逻辑更清晰,容易理解和调试

3. 错误处理

if (targetRow === -1) {
    return false;
}

这个检查很重要:如果所有行的第一个数都比目标值大,那宝藏肯定不存在。

📊 效率分析:小码的方法有多快?

方法时间复杂度空间复杂度优点
暴力搜索O(m×n)O(1)简单直接
两次二分法O(log m + log n)O(1)效率极高,逻辑清晰
一维二分法O(log(m×n))O(1)代码简洁

举个例子:

  • 对于 1000×1000 的矩阵(100万个宝物)
  • 暴力搜索:最多查看100万次
  • 二分查找:最多查看 log₂(1000) + log₂(1000) ≈ 10 + 10 = 20次!
  • 效率相差5万倍!

🧠 思维拓展:如果规则变了怎么办?

变体1:每行有序,但行间不一定连续(LeetCode 240题)

新的藏宝图:
[1,  4,  7,  11]
[2,  5,  8,  12]
[3,  6,  9,  13]

这时不能用两次二分法了,因为不能通过第一列确定行。解决方法:从右上角开始搜索,每次排除一行或一列。

变体2:更大的矩阵怎么办?

  • 如果矩阵特别大,无法完全装入内存,可以考虑分批加载
  • 仍然可以使用二分思想,先确定在哪一批数据中

🎯 实战练习:考考你!

题目1: 在下面的矩阵中寻找 target = 14

[1,  4,  7,  11]
[8,  9,  12, 15]
[10, 13, 16, 19]
[12, 14, 17, 22]

题目2: 如果 target = 0,算法会怎样处理?

题目3: 如果矩阵是空的,代码需要怎么修改?

💎 总结:小码的智慧

小码的这次寻宝经历教会了我们:

  1. 分解问题:复杂问题分解为简单步骤(先找行,再找列)
  2. 利用有序性:有序数据是二分查找的完美舞台
  3. 边界思维:仔细考虑各种边界情况(空矩阵、目标值太小/太大)
  4. 贪心策略:找到一个可行解后,继续寻找更优解

算法的本质,就是用计算机能理解的方式,把人类的智慧表达出来。 就像小码的寻宝过程,我们不是盲目搜索,而是有策略、有方法地缩小范围,最终精准定位目标。

现在,你也掌握了这种"两次二分法"的寻宝技巧。下次遇到类似的问题,记得像小码一样,先思考、再分解、最后精准出击!