news 2026/10/18 20:50:46

有效数独判断:一次遍历完成行列与九宫格查重,从暴力法到位运算

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
有效数独判断:一次遍历完成行列与九宫格查重,从暴力法到位运算

前阵子在评论区看到有人讨论第36题到底算不算“简单题”,我的看法是:它表面上是一个模拟题,但真正把它吃透的人并不多。这道题核心不是“怎么判断重复”,而是“怎么把一行、一列、一个3x3九宫格这三层约束,在一个遍历里干净地表达出来”。如果你正准备面试,或者刚开始刷题想找一道既能练手又能学到细节的题目,这道题非常合适。这篇笔记我打算把暴力解法、布尔数组解法和位运算解法都过一遍,重点是讲清楚两件事:九宫格坐标公式(i / 3) * 3 + j / 3到底怎么来的,以及“先查再标记”的顺序为什么不能反过来。

1. 题目到底在考什么:不是“查重”,是“状态管理”

1.1 题干解读与常见误区

题目给了一个9 x 9的棋盘,里面有一部分格子已经填上了数字1-9,剩下的是'.'表示空格。要求判断:每一行里有没有重复数字、每一列里有没有重复数字、每个3 x 3小宫格里有没有重复数字。如果有任何重复,就返回false,否则返回true。

这道题有一个容易混淆的地方:它只是“校验”,不是“求解”。也就是说,我们不需要去填数字,也不需要去回溯尝试每一种可能性,只需要判断当前这个盘面是否符合数独规则。很多第一次接触的人会把思路往“搜索”上带,结果把自己绕进去了。把“校验”和“求解”分开,后面才会走得顺。

常见的误区有两个。第一个是只检查行和列,忘了九宫格。第二个是知道要检查九宫格,但不知道怎么优雅地拿到某个格子属于第几个九宫格。第一个问题好解决,想清楚就行;第二个问题是大多数人的真实卡点,后面的章节我会专门拆开讲。

1.2 换一个视角:从“按维度遍历”到“按格子录入”

大多数人第一反应是:先遍历所有行查一遍重复,再遍历所有列查一遍重复,再遍历所有九宫格查一遍重复。这个思路没毛病,逻辑也正确,但写出来的代码是三段几乎相同的逻辑,很容易在某一段里漏掉条件或者写错边界。比如“按宫检查”时,要自己手算每个宫格的起始坐标,代码会变得又长又丑。

更好的方式是反过来:把整个棋盘上的 81 个格子逐个遍历,每遇到一个数字,就同时去三个“台账”里查重。行台账记录这一行出现过哪些数字,列台账记录这一列出现过哪些数字,宫台账记录这个宫格出现过哪些数字。只要其中任何一个台账显示“这个数字我已经见过了”,就直接判非法。

这样做的本质是把“查重”变成了“状态录入”:每处理一个格子,同时更新三个维度的状态。代码只需要写一次判断逻辑,而不是写三遍。这个视角的转变,比单纯背一套模板更重要。

2. 三种直接解法:暴力遍历、布尔数组、位运算

2.1 暴力解法:能过,但不够干净

先不追求最优,第一时间能想到的暴力写法是分三段独立检查。

// 结构示意,不推荐直接这么提交 for (int i = 0; i < 9; ++i) { unordered_set<char> seen; for (int j = 0; j < 9; ++j) { if (board[i][j] == '.') continue; if (seen.count(board[i][j])) return false; seen.insert(board[i][j]); } } // 再对每一列做同样的事情 // 再对每一个3x3宫格做同样的事情

这版代码能通过测试,因为题目固定是9 x 9的棋盘,无论怎么写,遍历上限都是 81 个格子,时间复杂度都是常数级别。但问题也显而易见:重复代码太多,尤其在“按宫检查”那段,如果不用循环而是手写 9 个宫格的起始坐标,非常容易出错。我个人建议是:暴力解法只用来帮助理解题意,真正上提交或者讲给面试官听,最好还是用后面的统一遍历方案。

2.2 布尔数组方案:一眼能看懂的常规写法

这种写法的核心是用三个固定的二维布尔数组来记账:

  • rows[i][num]表示第i行里,数字num是否出现过;
  • cols[j][num]表示第j列里,数字num是否出现过;
  • boxes[idx][num]表示第idx个宫格里,数字num是否出现过。

数组第二维我习惯开成10,这样可以直接用数字1-9当作下标,不用每次再减一个偏移量。虽然下标0会浪费掉,但换来的是代码更好读,这个取舍非常划算。

#include <vector> using namespace std; class Solution { public: bool isValidSudoku(vector<vector<char>>& board) { bool rows[9][10] = {}; bool cols[9][10] = {}; bool boxes[9][10] = {}; for (int i = 0; i < 9; ++i) { for (int j = 0; j < 9; ++j) { char c = board[i][j]; if (c == '.') continue; int num = c - '0'; int boxIdx = (i / 3) * 3 + j / 3; if (rows[i][num] || cols[j][num] || boxes[boxIdx][num]) { return false; } rows[i][num] = true; cols[j][num] = true; boxes[boxIdx][num] = true; } } return true; } };

用一个具体例子走一遍。假设board[0][0] = '5',那么num = 5,boxIdx = 0。此时三份台账里都还没有数字 5 的记录,于是把rows[0][5]、cols[0][5]、boxes[0][5]都置为true。接着遍历到board[0][1] = '5',在查rows[0][5]时发现已经是true,立刻返回false。

这里有一个值得注意的细节:为什么用布尔数组而不是真正的哈希集合?因为数字范围固定且很小,只有 1 到 9。数组下标的天然哈希能力已经足够,完全没有必要引入unordered_set的开销。面试中如果被追问“你这里为什么不用哈希表”,你要能答出这一点。

三种方案的复杂度对比如下:

方案时间复杂度空间复杂度代码可读性
暴力三层遍历O(1)O(1)一般,重复代码多
布尔数组O(1)O(1)最好,结构清晰
位运算O(1)O(1)依赖个人对二进制的熟悉度

这里的 O(1) 都是因为棋盘固定为 9x9,格子数量上限是 81,不会随着输入规模变化。

2.3 位运算方案:把一行状态装进一个整数

如果你已经看过一些高手的题解,可能会遇到位运算版本。思路是:用一个整数的低 9 位,分别代表数字 1 到 9 是否出现过。如果某一位是 1,说明这个数字已经出现过;如果是 0,说明还没出现。

#include <vector> using namespace std; class Solution { public: bool isValidSudoku(vector<vector<char>>& board) { int rows[9] = {}; int cols[9] = {}; int boxes[9] = {}; for (int i = 0; i < 9; ++i) { for (int j = 0; j < 9; ++j) { char c = board[i][j]; if (c == '.') continue; int num = c - '0'; int mask = 1 << num; int boxIdx = (i / 3) * 3 + j / 3; if ((rows[i] & mask) || (cols[j] & mask) || (boxes[boxIdx] & mask)) { return false; } rows[i] |= mask; cols[j] |= mask; boxes[boxIdx] |= mask; } } return true; } };

判断是否重复时,先做按位与。如果rows[i] & mask的结果不是 0,说明第num位已经被置为 1,也就是这一行已经出现过num。没有重复时,用按位或把这个数字的状态记录下来。

这个写法的内存只有三个长度为 9 的整数数组,每个整数只用到 9 个比特位,非常省空间。但我得说实话:位运算版本更适合在刷题笔记里作为进阶理解,不太适合作为面试中的首选答案。因为不是所有面试官都能第一时间看懂这种写法,你还要花额外的时间去解释每一行位运算的含义。更好的策略是:先讲布尔数组方案,如果面试官追问“空间还能再优化吗”,再展示位运算思路,这会显得你理解得足够深。

3. 九宫格编号:公式推导与坐标映射细节

3.1(i / 3) * 3 + j / 3到底是怎么凑出来的

很多题解会直接给你这个公式,但你照着写完之后,并不知道它为什么是对的。下次再遇到坐标转换的题,依旧容易卡壳。其实这个公式不需要背,完全可以现场推出来。

我们给九宫格编号,按从左到右、从上到下的顺序,编成 0 到 8。现在有一个格子,行是i,列是j。先看行方向:i / 3得到的是这个格子落在第几“横带”。i取 0、1、2 时,i / 3 = 0,属于最上面一行宫格;i取 3、4、5 时,属于中间一行;i取 6、7、8 时,属于最下面一行。所以i / 3的实际含义是“宫格的行组号”。

每一行宫格组里有 3 个宫格,编号分别为行组号 * 3 + 0、行组号 * 3 + 1、行组号 * 3 + 2。因此基础偏移量是(i / 3) * 3。再看列方向:j / 3得到的是这个格子落在第几“纵带”。j取 0、1、2 时,j / 3 = 0,表示在宫格的左列;取 3、4、5 时,是中间列;取 6、7、8 时,是右列。所以最终编号就是基础偏移量加上列组号。

打个比方,把宫格编号想象成电影院座位:先根据行数确定你坐在第几排大区域,再根据列数确定你是这片区域的左侧、中间还是右侧。两步一合,位置就唯一确定了。

如果记不住这个推导也没关系,你在草稿纸上画一个 3x3 的宫格编号表,填上 0 到 8,然后看任何一个格子,都能迅速验证公式。多推两遍,比硬背要牢固得多。

3.2 坐标映射示例和常见误写

为了确认公式正确,可以选几个特殊坐标代入计算。

格子坐标 (i, j)i / 3j / 3(i / 3) * 3 + j / 3所在位置
(0, 0)000左上宫格
(2, 2)000左上宫格
(3, 0)103左中宫格
(4, 4)114正中宫格
(5, 8)125右中宫格
(6, 0)206左下宫格
(8, 8)228右下宫格

这里最常见的误写是(j / 3) * 3 + i / 3,也就是行列弄反。举一个具体的反例:坐标(1, 7),正确结果应该是(0) * 3 + 2 = 2,也就是右上角那一行中间偏右的宫格。如果把公式误写成(j / 3) * 3 + i / 3,得到2 * 3 + 0 = 6,直接跳到左下区域去了,差得十万八千里。

如果你写完代码心里没底,可以在循环里临时加一行调试输出,打印每个数字格子对应的boxIdx,配合一个手画的宫格编号表逐个核对。这种笨办法在起步阶段非常有效。

3.3 不用公式的替代写法:按宫格为单位遍历

如果你确实不喜欢这个公式,还有另一种等价写法,适合在初学时用来辅助理解:四重循环,先按宫格编号遍历,再在这个宫格内部遍历三行三列。

// 结构示意:按宫遍历,每个宫格内再走 3x3 for (int box = 0; box < 9; ++box) { int startRow = (box / 3) * 3; int startCol = (box % 3) * 3; bool seen[10] = {false}; for (int r = startRow; r < startRow + 3; ++r) { for (int c = startCol; c < startCol + 3; ++c) { char ch = board[r][c]; if (ch == '.') continue; int num = ch - '0'; if (seen[num]) return false; seen[num] = true; } } }

这段代码只能验证九个宫格内部的情况,还需要再配一个行检查和一个列检查,才能覆盖完整流程。所以它更适合作为“理解宫格坐标”的辅助代码,不适合作为最终提交的最优结构。我的建议是:讲解思路时可以用四重循环来给对方建立直觉,提交代码时还是用统一的单层双循环加公式,代码最简洁。

4. 复盘最容易翻车的几个点,以及代码走读

4.1 “先查再标记”的顺序,说反就会凉

这是我在评论区和同事的代码评审里见到最多的问题。正确的逻辑是:先检查三个台账里有没有出现过这个数字,如果出现过就直接返回false,否则再把这个数字标记为“已出现”。

如果把顺序写成先标记再检查,会发生什么?第一个格子board[0][0] = '5',你先把rows[0][5]改成true,然后才去检查rows[0][5],发现是true,直接返回false。也就是说,自己刚写上去的记录立刻把自己出卖了,哪怕整盘数独完全合法,也会被误判成非法。这种逻辑错误在没有覆盖到同类样例的时候很难发现,因为它只会在“第一个数字恰好出现在合法位置”时触发误报。

写代码时把检查放在前面,标记放在后面,这个顺序应该形成肌肉记忆。

4.2 字符转换和下标的坑

棋盘里的元素类型是char,不是int。'5'对应的 ASCII 码是 53,如果你直接拿board[i][j]去当数组下标,轻则数据错乱,重则数组越界。必须先执行int num = c - '0',把字符数字转成真正的数值数字。

另一个容易忽略的地方是'.'的处理。没有跳过空格的话,'.' - '0'会得到一个负数,后面的数组访问就全乱了。正确做法是在拿到字符之后第一时间判断:如果是'.',直接continue,不参与任何记账。

还有一个细节:题目默认输入一定是合法的 9x9 棋盘,字符只可能是数字或'.',所以不需要做额外校验。但如果你把这道题的逻辑放进实际工程里,就不能这么乐观了,至少应该先验证board.size()以及每一行的长度。竞赛和面试场景可以大胆假设,工程代码不能。

4.3 空间复杂度的面试回答和自测用例设计

关于复杂度,面试官偶尔会追问“你的算法空间复杂度是多少”。这里有一个容易答错的点:棋盘是题目限定死的 9x9,所以无论是三个长度为 9 的数组,还是布尔数组,空间都可以说是 O(1)。你不用纠结“如果棋盘变成更大怎么办”,因为这道题的输入约束就决定了规模是固定的。回答时可以直接说:由于棋盘固定,时间上最多遍历 81 个格子,空间上也是三个固定大小的数组,所以时间和空间都是常数级。

自测用例也值得认真设计,不然你把代码写完后,很难判断到底有没有覆盖到三个维度。我个人习惯构造以下几个用例:

  1. 全空棋盘:所有格子都是'.',应该返回true。这个用例主要用来验证代码不会因为空值报错。
  2. 行冲突:board[0][0] = '5',board[0][1] = '5',其余全空,应该返回false。
  3. 列冲突:board[0][0] = '5',board[1][0] = '5',其余全空,应该返回false。
  4. 宫格冲突:board[0][0] = '5',board[1][1] = '5',虽然不同行也不同列,但它们在同一个3x3宫格里,应该返回false。
  5. 跨宫格不冲突:board[0][0] = '5',board[0][3] = '5',两个格子不在同一行宫格,也不再同一列宫格,应该返回true。

把这些用例在本地跑一遍,基本就能确定三个维度的检查都写对了。至于那种“只检查行和列,忘掉宫格”的典型错误,第 4 个用例一眼就能揪出来。

我个人在刷这一题时最大的体会是:它真正想考察的不是某个惊为天人的算法,而是你愿不愿意把“查重”这个动作组织得干净、不重不漏。如果能亲手把布尔数组和位运算两个版本都写出来,并且对着面试官把(i / 3) * 3 + j / 3推导一遍,这道题才算真正变成了你自己的东西。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 20:50:46

医疗OCR系统设计:结构感知+词典引导+事件索引

简介&#xff1a;本资源是一个面向人工智能与医疗信息化交叉领域的实战项目&#xff0c;专为高校学生、AI初学者及医疗IT从业者设计&#xff0c;旨在解决纸质/扫描版医疗文献难以高效检索的痛点。系统基于OCR技术实现图像文字识别与结构化处理&#xff0c;并集成Vue前端构建交互…

作者头像 李华
网站建设 2026/10/18 20:47:58

Kafka接入AI实战:从集群部署到性能调优全解析

1. 从"消息管道"到"AI数据中枢"&#xff1a;这次接入到底接的是什么先说结论&#xff1a;这次把Kafka接入AI&#xff0c;不是简单地在某个服务里调一个大模型接口&#xff0c;而是把Kafka从传统的消息中间件&#xff0c;升级成了整个AI业务的事件中枢。所有…

作者头像 李华
网站建设 2026/10/18 20:44:59

从信息猎人进化到资产哨兵:Google与GitHub搜索语法实战

1. 破题&#xff1a;这不是攻击教程&#xff0c;而是防御者的资产排查清单看到"Google/GitHub Hacking"这个标题&#xff0c;可能有人会下意识认为这是攻击者的技巧合集。我先说清楚我的立场&#xff1a;我在这里讲的是防御视角的主动暴露面排查——也就是用搜索语法…

作者头像 李华
网站建设 2026/10/18 20:43:57

Fast-DDS深度解析:工业级DDS通信的确定性设计与实时调优

1. 项目概述&#xff1a;Fast-DDS不是“快的DDS”&#xff0c;而是实时通信系统的工业级底座Fast-DDS这个名字&#xff0c;刚接触的人十有八九会下意识理解成“某个叫DDS的东西的加速版”——比如“快一点的DDS协议实现”。但实际完全相反&#xff1a;Fast-DDS本身就是DDS&…

作者头像 李华
网站建设 2026/10/18 20:43:10

Android Compose AI组件库:从流式文本到图像生成的架构实践

最近在忙一个跨平台的智能应用项目&#xff0c;AI能力要在Android端做完整的对话、图片生成、语义搜索体验。搭到第三块页面的时候我就开始烦了&#xff1a;每次都是把网络流、状态管理、UI拼装从头粘一遍&#xff0c;代码长得几乎一样&#xff0c;效果却总有细微出入。后来看到…

作者头像 李华