尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java实现稀疏数组:从棋盘游戏到数据压缩的实战指南

Java实现稀疏数组:从棋盘游戏到数据压缩的实战指南 1. 从棋盘到代码为什么我们需要稀疏数组如果你写过一些处理二维数据的程序比如一个简单的五子棋或扫雷游戏大概率会遇到一个场景你需要用一个二维数组来保存棋盘的状态。一个10x10的棋盘用int[][] chessBoard new int[10][10]来表示逻辑上非常清晰。0代表空位1代表黑子2代表白子。看起来一切都很完美直到你开始考虑“保存”和“加载”这个棋盘。假设一盘棋刚下了两步棋盘上只有两个棋子其余98个格子都是空的值为0。当你尝试把这个chessBoard数组保存到文件时会发生什么你会把10x10100个整数全部写进去其中98个都是重复的0。这不仅浪费了大量的存储空间在网络传输或磁盘IO时更是对性能的严重损耗。这种二维数组我们称之为“稠密数组”Dense Array它的特点是无论有效数据有多少都必须为每一个“位置”分配存储空间。稀疏数组Sparse Array就是为了解决这个问题而生的。它的核心思想非常直观只记录那些有意义非默认值的数据。对于那个10x10的棋盘我们不再存储100个数字而是用一种更紧凑的结构只记录那两个棋子的位置和值。这种数据结构在图像处理存储大量空白或单色背景的图片、科学计算大型矩阵中非零元素稀少、地图数据存储等领域应用极广。今天我就来手把手带你用Java实现一个完整的稀疏数组并围绕它展开聊聊背后的设计取舍、代码细节以及在实际项目中可能遇到的“坑”。我们不止于实现更要弄懂为什么这么做以及如何做得更好。2. 稀疏数组的核心逻辑与数据结构设计稀疏数组不是一个Java内置的数据结构而是一种通用的数据压缩思想。它的实现通常依赖于一个简单的二维数组或列表。最经典、也最易于理解的实现方案是使用一个(n1) x 3的二维数组其中n是原始二维数组中非默认值的个数。2.1 标准三列式稀疏数组结构这个(n1) x 3的数组其第一行行索引0存储的是原始数组的“元信息”后续每一行存储一个有效数据点的信息。具体每列的含义如下第0列行索引row。记录有效数据在原始数组中的行号。第1列列索引col。记录有效数据在原始数组中的列号。第2列值value。记录该位置存储的具体数值。第一行索引0是一个特例sparseArr[0][0] 原始数组的总行数。sparseArr[0][1] 原始数组的总列数。sparseArr[0][2] 原始数组中有效数据非默认值的总个数。我们用一个更具体的例子来说明。假设有一个11行11列的棋盘为了演示更多数据其中只有3个棋子黑子值1在第2行第3列。白子值2在第3行第4列。黑子值1在第6行第6列。那么转换后的稀疏数组将是一个(31) x 3 4 x 3的数组内容如下rowcolvalue说明11113元信息行原始数组11行11列3个有效值231有效数据1第2行第3列值为1黑子342有效数据2第3行第4列值为2白子661有效数据3第6行第6列值为1黑子可以看到原本需要存储11 * 11 121个整数的棋盘现在只需要存储4 * 3 12个整数。当有效数据非常稀少时这种压缩效率是指数级提升的。注意这里有一个关键前提默认值必须是零。稀疏数组的压缩原理是忽略所有等于“默认值”的单元格。在绝大多数场景下这个默认值就是0。如果你的业务逻辑中默认值是其他数字比如-1那么算法需要相应调整判断条件要从val ! 0改为val ! defaultVal。2.2 为什么是二维数组其他方案可行吗你可能会问为什么用二维数组来存用Listint[]或者自定义一个SparseData类包含row, col, value三个属性的列表不行吗当然可以而且在实际的、更复杂的项目中后者往往是更好的选择。使用ListSparseData的面向对象方式代码可读性和可维护性更强也更容易扩展比如未来需要增加一个时间戳字段。JDK甚至提供了javax.swing.RowFilter.Entry之类的类似结构。但是我们这里选择最经典的二维数组实现原因有三教学目的它最直观地体现了稀疏数组“用数据表示数据”的核心思想剥离了面向对象的封装让初学者能聚焦于算法逻辑本身。序列化简单二维数组可以非常方便地序列化到文件或网络。无论是用ObjectOutputStream直接写入还是遍历写入文本文件格式都极其规整。基础性它是理解更高级稀疏数据结构如CSR、CSC格式的基石。许多底层库在处理极端稀疏的大矩阵时最终在内存中的优化布局思想与此一脉相承。所以我们从这个“原始”但强大的方案开始。理解了它你就能轻松驾驭任何其他形式的稀疏存储方案。3. 手把手实现从稠密数组到稀疏数组的转换理论说清楚了我们开始写代码。整个过程分为两个核心步骤压缩稠密 - 稀疏和解压缩稀疏 - 稠密。我们先创建一个原始的11x11棋盘二维数组并放入几个棋子作为有效数据。public class SparseArrayDemo { public static void main(String[] args) { // 1. 创建一个原始的 11 * 11 二维数组 // 0表示没有棋子1表示黑子2表示白子 int[][] chessArr new int[11][11]; chessArr[1][2] 1; // 第二行第三列有一个黑子 chessArr[2][3] 2; // 第三行第四列有一个白子 chessArr[4][5] 2; // 第五行第六列有一个白子 chessArr[7][8] 1; // 第八行第九列有一个黑子 // 打印原始二维数组 System.out.println(原始的二维数组棋盘); for (int[] row : chessArr) { for (int data : row) { // 为了美观用制表符分隔 System.out.printf(%d\t, data); } System.out.println(); } } }运行这段代码你会看到一个大部分是0只有四个位置有值的棋盘。接下来我们实现压缩逻辑。3.1 关键步骤一遍历与计数要创建稀疏数组我们首先必须知道有多少个有效数据。这需要遍历整个原始数组。// 2. 统计原始数组中非0数据的个数 int sum 0; for (int i 0; i chessArr.length; i) { for (int j 0; j chessArr[i].length; j) { if (chessArr[i][j] ! 0) { sum; } } } System.out.println(有效数据个数 sum sum);得到sum后我们就可以创建稀疏数组了int[][] sparseArr new int[sum 1][3];。这里sum1就是总行数1行元信息 sum行数据。3.2 关键步骤二填充稀疏数组创建好数组后先填入元信息再遍历原始数组将有效数据填入稀疏数组的后续行。// 3. 创建对应的稀疏数组 int[][] sparseArr new int[sum 1][3]; // 初始化稀疏数组的第一行元数据 sparseArr[0][0] chessArr.length; // 原始数组行数 sparseArr[0][1] chessArr[0].length; // 原始数组列数假设每行列数相同 sparseArr[0][2] sum; // 有效数据总数 // 4. 遍历原始数组将非0值存入稀疏数组 int count 0; // 计数器用于记录是第几个非0数据也作为稀疏数组的行索引 for (int i 0; i chessArr.length; i) { for (int j 0; j chessArr[i].length; j) { if (chessArr[i][j] ! 0) { count; // 注意这里先因为稀疏数组第0行已占用 sparseArr[count][0] i; // 行号 sparseArr[count][1] j; // 列号 sparseArr[count][2] chessArr[i][j]; // 值 } } }实操心得count的初始化是0还是1这是一个初学者常混淆的点。因为sparseArr[0]已经被元信息占用我们的第一个有效数据应该放在sparseArr[1]。所以循环内的逻辑是count在前赋值在后。你也可以初始化为0在赋值时使用sparseArr[count1][x]但我觉得count的写法更简洁意图也更明显——count直接代表当前稀疏数组填充到的行索引。3.3 关键步骤三打印与验证稀疏数组现在我们可以打印出稀疏数组看看压缩后的效果。// 5. 打印稀疏数组 System.out.println(\n生成的稀疏数组为); for (int i 0; i sparseArr.length; i) { System.out.printf(%d\t%d\t%d\t\n, sparseArr[i][0], sparseArr[i][1], sparseArr[i][2]); }输出会类似于原始的二维数组棋盘 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 ... 有效数据个数 sum 4 生成的稀疏数组为 11 11 4 1 2 1 2 3 2 4 5 2 7 8 1对比一下121个数据压缩成了15个5行x3列效果立竿见影。更重要的是这个稀疏数组的格式非常规整非常适合进行下一步的持久化操作。4. 逆向工程从稀疏数组恢复稠密数组保存和传输用稀疏数组但程序在内存中运算时通常还是需要恢复成原始的二维数组形式。这个“解压缩”过程比压缩更简单直接。4.1 恢复的逻辑与代码实现恢复的核心就是读取稀疏数组第一行的元信息创建出指定大小的空二维数组全部填充默认值0然后从第二行开始遍历稀疏数组的每一行根据其记录的行列索引将值赋给新数组的对应位置。// 6. 将稀疏数组恢复为原始的二维数组 // 6.1 先读取稀疏数组的第一行创建原始数组 int rowNum sparseArr[0][0]; int colNum sparseArr[0][1]; int[][] recoveredChessArr new int[rowNum][colNum]; // 6.2 读取稀疏数组后续行的数据并赋值给原始数组 // 注意i从1开始因为第0行是元数据 for (int i 1; i sparseArr.length; i) { int r sparseArr[i][0]; int c sparseArr[i][1]; int v sparseArr[i][2]; recoveredChessArr[r][c] v; } // 6.3 打印恢复后的数组应与原始数组完全一致 System.out.println(\n从稀疏数组恢复后的二维数组); for (int[] row : recoveredChessArr) { for (int data : row) { System.out.printf(%d\t, data); } System.out.println(); }这段代码运行后recoveredChessArr应该和最初的chessArr一模一样。这个过程没有任何复杂的算法就是简单的数据映射其正确性完全依赖于稀疏数组本身数据的准确性。4.2 为什么恢复过程不需要考虑默认值这是一个值得思考的问题。在恢复时我们只操作了稀疏数组中记录的那些位置。那么其他位置的值呢在Java中new int[rowNum][colNum]创建出的数组其每个元素的初始值就是0这正好是我们的默认值。所以我们不需要显式地去填充0。如果你的默认值不是0比如是-1那么你就需要先遍历整个新数组将所有元素初始化为-1然后再用稀疏数组的数据去覆盖。恢复过程的隐含前提是你知道默认值是什么并且在新数组中预先完成了该默认值的填充。5. 核心进阶稀疏数组的持久化与实战踩坑点到这一步我们已经实现了稀疏数组在内存中的转换。但对于一个实用的工具我们肯定需要把它保存到文件或者从文件读取。这里才是真正容易出问题的地方。5.1 文件IO方案选择与实现常见的保存方案有两种序列化对象和存储为文本。方案A使用Java对象序列化这是最简单粗暴的方法直接将sparseArr这个二维数组对象写入文件。// 将稀疏数组写入文件 (对象流) try (ObjectOutputStream oos new ObjectOutputStream(new FileOutputStream(map.data))) { oos.writeObject(sparseArr); System.out.println(稀疏数组已序列化保存到 map.data); } catch (IOException e) { e.printStackTrace(); } // 从文件读取稀疏数组 try (ObjectInputStream ois new ObjectInputStream(new FileInputStream(map.data))) { int[][] loadedSparseArr (int[][]) ois.readObject(); // 后续可以用loadedSparseArr恢复棋盘... } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); }优点代码极其简单Java原生支持。缺点生成的文件是二进制的不可读且严重依赖Java环境。如果其他语言如Python、C写的程序需要读取这个文件会非常困难。此外对象流会写入完整的类信息对于这种简单的整数数组会产生额外的开销。方案B存储为规整的文本文件这是我们更推荐的做法尤其是需要跨语言、跨平台交换数据时。我们可以将稀疏数组按行写入文本文件每行的三个数字用制表符或逗号分隔。// 将稀疏数组写入文本文件 try (BufferedWriter writer new BufferedWriter(new FileWriter(map.txt))) { for (int[] row : sparseArr) { // 用制表符分隔写入一行 writer.write(row[0] \t row[1] \t row[2]); writer.newLine(); } System.out.println(稀疏数组已保存到 map.txt); } catch (IOException e) { e.printStackTrace(); } // 从文本文件读取稀疏数组 Listint[] list new ArrayList(); try (BufferedReader reader new BufferedReader(new FileReader(map.txt))) { String line; while ((line reader.readLine()) ! null) { String[] temp line.split(\t); // 按制表符分割 if (temp.length 3) { int r Integer.parseInt(temp[0]); int c Integer.parseInt(temp[1]); int v Integer.parseInt(temp[2]); list.add(new int[]{r, c, v}); } } } catch (IOException e) { e.printStackTrace(); } // 将List转换回二维数组 int[][] loadedSparseArrFromTxt new int[list.size()][3]; for (int i 0; i list.size(); i) { loadedSparseArrFromTxt[i] list.get(i); }优点文件是纯文本人类可读任何编程语言都能轻松解析。格式清晰数据量小。缺点需要自己编写解析逻辑比对象流稍复杂。在实际项目中我几乎总是选择文本文件方案。它的可移植性和可调试性直接打开文件就能看数据带来的好处远超过多写几行代码的成本。5.2 实战中必须警惕的“坑”边界检查缺失在恢复数组时recoveredChessArr[r][c] v这一行是危险的。如果稀疏数组文件被篡改或者生成时有bug导致r或c的值超出了recoveredChessArr的边界就会抛出ArrayIndexOutOfBoundsException。健壮的代码应该在赋值前进行检查if (r 0 r rowNum c 0 c colNum) { recoveredChessArr[r][c] v; } else { // 记录错误日志或抛出受检异常 System.err.println(警告无效的索引 ( r , c )跳过此数据。); }默认值的误判这是逻辑错误的高发区。如果你的业务数据中合法值本身就包含0比如温度值0摄氏度那么用0作为“无效”或“默认”值就不合适了。你必须重新定义一个不会在业务数据中出现的值作为“默认值”例如Integer.MIN_VALUE并在压缩和恢复的所有逻辑中将判断条件! 0替换为! DEFAULT_VALUE。文件格式的兼容性使用文本存储时分隔符的选择很重要。如果用逗号就要考虑数据值本身是否可能包含逗号。制表符通常更安全。更好的做法是使用标准格式如CSV逗号分隔值并处理好转义。或者对于更复杂的数据可以考虑JSON格式。用JSON存储稀疏数组结构会非常清晰{ rows: 11, cols: 11, defaultValue: 0, data: [ {row: 1, col: 2, value: 1}, {row: 2, col: 3, value: 2} ] }虽然文件体积会大一些但可读性和可扩展性是无与伦比的。性能与空间的权衡稀疏数组是“以时间换空间”的典型。压缩过程需要遍历整个原始数组O(n²)恢复过程也需要遍历稀疏数组O(k)其中k是有效数据量。当数据不是特别稀疏时比如超过1/3的数据都是有效的使用稀疏数组可能反而得不偿失因为IO节省的空间可能抵不上编解码消耗的时间。在决定使用稀疏数组前一定要评估数据的稀疏程度。6. 不止于棋盘稀疏数组的变体与应用扩展理解了基础的三列式稀疏数组我们可以看看它在其他场景下的变体和优化。6.1 针对超大型稀疏矩阵的优化格式在科学计算和机器学习中面对动辄数万维的稀疏矩阵(n1)x3的格式效率不够高。于是有了更专业的存储格式CSR (Compressed Sparse Row) 行压缩格式它用三个一维数组代替二维数组。values: 按行顺序存储所有非零元素的值。columnIndices: 存储每个非零元素所在的列索引。rowPointers: 存储每一行第一个非零元素在values中的起始位置。 这种格式对于按行访问矩阵运算如矩阵-向量乘法非常高效。Apache Commons Math、SciPy等库都支持此格式。CSC (Compressed Sparse Column) 列压缩格式原理与CSR类似只是改为按列压缩适合按列访问的操作。我们的三列式可以看作是COO (Coordinate Format 坐标格式) 的一种简单实现它记录了每个非零元的坐标和值格式最简单直观但进行矩阵运算不如CSR/CSC高效。6.2 在图像处理中的应用二值图与游程编码稀疏数组的思想在图像处理中无处不在。考虑一个简单的黑白二值图像比如扫描的文档图像中大部分是白色像素值255只有黑色的文字部分像素值0是有效信息。我们可以用类似稀疏数组的方法只记录黑色像素的位置。更进一步对于二值图像有一种更极致的压缩算法叫游程编码Run-Length Encoding, RLE。它不再记录每个黑点的位置而是记录“连续的黑点”从哪开始有多长。例如一行像素[255,255,255,0,0,0,0,255,255,0,255]用RLE可以表示为(白3)(黑4)(白2)(黑1)(白1)。这在处理条形码、传真等场景下压缩率惊人。你可以把RLE理解为稀疏数组在“连续相同值”这个特例下的超级优化版。6.3 自定义对象数组的稀疏化我们的例子中数组元素是基本类型int。如果是一个ChessPiece[][]对象数组呢原理完全一样只是“默认值”变成了null稀疏数组的第二列存储的不再是int值而是对象的引用或序列化后的数据。// 假设有一个棋盘上面只有少数几个棋子对象 ChessPiece[][] chessBoard new ChessPiece[11][11]; chessBoard[2][3] new ChessPiece(Black, Queen); // ... 其他位置为null // 稀疏数组可以设计为存储行、列、棋子数据 // 数据部分可能需要序列化成JSON字符串或二进制 String[][] sparseObjArr new String[sum1][3]; sparseObjArr[0][0] 11; sparseObjArr[0][1] 11; sparseObjArr[0][2] String.valueOf(sum); // sparseObjArr[1][2] {\color\:\Black\,\type\:\Queen\};这带来了新的复杂度对象序列化/反序列化的成本。但核心思想——只存有效数据——始终未变。7. 总结与个人实践建议走完这一趟稀疏数组对你来说应该不再是一个抽象的概念。它本质上是一种针对具有大量重复默认值的数据场景的空间优化策略。其实现的关键在于两点1. 准确识别并统计有效数据2. 设计一种能完整还原原始数据结构的元信息格式。在我自己的项目经验中使用稀疏数组或类似思想时我会遵循以下原则评估先行不要无脑用。先用数据量的估算来说话。如果原始数组是1000x1000有效数据预计有10万个那稀疏化意义不大因为要存10万行1行共30万个整数而原始数据是100万个整数。如果有效数据只有100个那压缩比就是10000:3非常划算。格式显式定义无论是用文本、JSON还是二进制一定要将格式文档化。特别是第一行的元信息每一列代表什么必须清晰无误。最好在文件开头加一个简单的魔术数字或版本号如SPARSE_V1方便后续程序兼容性判断。工具方法封装将稠密转稀疏、稀疏转稠密、保存到文件、从文件读取这四个核心功能封装成一个工具类比如SparseArrayUtils。这样业务代码只需要调用compress(),persistToFile(),loadFromFile(),recover()等方法代码会干净很多。考虑使用成熟库如果是在做严肃的科学计算或机器学习直接使用像Apache Commons Math中的SparseRealMatrix或者EJML库中的稀疏矩阵实现。它们经过了高度优化支持各种运算比自己从头实现要可靠和高效得多。最后稀疏数组的练习价值在于它完美地体现了数据结构是算法的基础而算法是对现实问题的抽象这一思想。从一个小小的棋盘存盘问题出发我们触及了数据压缩、序列化、空间与时间的权衡等多个编程核心概念。希望你在实现它之后下次再遇到类似“地图中大部分是空地”、“矩阵中大部分元素是零”、“配置表中大部分是默认项”的场景时能立刻想到“这里是不是可以用稀疏数组的思想来优化”
返回列表