众所周知,现在很多网上的电子地图都使用矢量地图,用切图引擎切成栅格式的,存放与服务端,通过URL的方式访问,这对我们的GIS项目开发提供了一个非常好的地图来源,我们可以拥有自己的地图引擎,只要知道切好的栅格图排布规律,就可以在完美的展现。但是往往在做GIS项目的时候会遇到一个问题:有时候客户的机器并没有连接到互联网,或者有各种各样的问题,导致通过URL的方式访问地图速度会很慢,更有一个大的问题,有的地图供应商虽然开放免费的地图数据,但对访问次数上做了限制,比如说谷歌地图。由此就产生了众多的地图下载器下载地图。下载完的地图或者直接提供给客户,或者自架服务器来解除这种限制。但网上很多地图下载器虽然提供下载功能,但是却没有提供一个很好的数据结构来存储地图,由此我们引入这个话题。
我毕业时就业于福州的一家GIS公司,有幸遇到一个很好的导师,我的部门经理,在那时我第一次接触到了Google地图的金字塔模型。这个数据模型就是目前我所使用的无限级索引数据结构的鼻祖,因为目前我已不再该公司工作,和朋友们一起出来创办了一家GPS行业的公司,所以在这里不对原公司所用的数据结构做太详细的分析。
所谓的金字塔模型,实际上是一个四叉树,每一个层级的节点个数是(2^n)^2个,层级索引从0开始计数,第0级为1个节点,通常可以视为根节点,第1级为(2^1)^2个节点即4个节点……以此类推。这刚好可以用来描述一个地图有规律的精细度区块划分。在第0级时,我们可以看到整个世界地图,在第1级的时候,每一张图片我们只能1/4的世界地图,在第2级的时候,就只能看到1/8了,如下图
显然,这种几何式增长的数据量用文件系统来存储的话是很不明智的。
既然有如此规律的排布,用一种带索引的数据文件来存储的话,是一个非常好的办法,我们只要知道每一张图片的唯一标识,就可以通过索引来找到它。从上图不难看出,层级编号和图片编号很自然地形成了一个唯一标识,在每一个层级中,图片的编号是唯一的,或者干脆说,在每一个层级中,这实际上就是一个行列式排布,由此我们的每一个层级中的图片就有了它们之间的大小关系,如此这般,我们就可以用二分查找的方式来找到我们所需要的那一张图片。
这就是初步金字塔模型,目前原有的公司可能还在沿用这这种模型,未经同意,我这里不做代码做分析。接下来会进一步讲到这个数据模型是如何进化成“无限级索引的数据结构”,届时将分享源代码,可能还有需要改进的地方,我们可以一起讨论。
最初的金字塔模型是分为两个文件进行存储,一个是索引文件,另外一个是纯粹的数据文件,索引文件存储了层级索引,以及图片单元索引,每一个索引的键值从小到大排序。层级索引存储了自身索引值以及图片单元索引在索引文件中的储位置(有点小绕口,那就再看一遍),数据单元索引存储了每个数据单元的索引值以及每一个数据单元在数据文件中的存储位置。
层级索引的元素格式是固定的,索引值从0到24,而图片单元索引的元素个数是不固定的,这种设计很有针对性。为GoogleMap的层级准备25个层级来存储已经是绰绰有余了,而每一个层级中的图片单元个数不固定的原因也很简单:并不是所有区域都有任意等级的图片单元。
如图所示:
这样的具有针对性的数据结构在按顺序写入和查询图片单元上性能得到了最大化,但在需要修改、插入、删除时就显得有些疲惫了。
1.没有一个文件空间的复用机制,导致在频繁的修改过程中,数据文件变得庞大。
2.双文件存储的特点导致在使用时需要打开2个文件句柄,查询到图片单元进行读取的时候增加了磁盘寻道时间,使用的时候也不是很方便。
3.使用文件流进行数据访问,导致在存储数据量大的时候,打开和关闭文件时速度变慢,同时两个程序在修改文件时,很难保证数据完整性。
在了解现有的数据结构和存在的问题之后,我们就可以开始着手修改……
这一节阐述了怎样复用空闲的文件存储区。
曾经有一段时间遇到这么一个需求:已下载的Google地图在用户看来不够详细,很多标注点没有,或者有的路已经不存在了却在地图上仍然显示着,有些最近刚新建的建筑在地图上没有体现出来,诸多原因引出了最终的结果,这些不准确的地图信息必须被修正。 也就是说,要在现有地图的基础上进行修改或者替换。当时我是这么解决这个问题的:当客户提出一些地图数据不准确的时候,我会要求客户提供当地的测绘数据,拿到这些数据之后,使用程序按照Google地图的样式进行绘制,然后切块,将这些数据替换到地图包中去。由此就产生了一个问题,原本存储这些地图数据的区块变成了没有索引指向的区块,当需要修改的地图区域比较大的时候,这种区块就会大量产生,并且作为一个没有用的空间占用着磁盘导致数据文件臃肿无比,从内存管理的角度上来讲这是一块应该被复用的存储区。
复用这些存储区的思路很简单,每次在修改一块地图块的时候,将这些存储区记录下来,当下次有地图块需要写入的时候优先从已记录的空闲存储区中查找符合大小的存储区,如果没有的话就将数据写到文件末尾,否则使用这个存储区。
设计一个空闲空间管理器,这个管理器应该有如下特点:
1.能够快速找到一个大于或者等于指定大小的空闲空间
2.当取出一个空闲区域使用时,被截断的那一部分应该还是被标记为未使用
3.当新加入一个空闲区域时,应该自动和已有的空闲区域融合
为了能够快速找到符合大小的空闲区域,只要一个按照大小有序的空闲空间列表即可,然后使用二分查找法查找。
取出一个空闲区域使用时,我们只要重新把被截断的那部分空间重新加入空闲列表就可以满足第二点
自动融合新加入的空闲区域的意为:当新加入的空闲区域和已有的空闲区域连续的时候,应该自动合并这两个空闲区域,并且重新调整列表。
- /// <summary>
- /// 按照空间位置排序的空闲列表,该列表只有在添加空闲空间时才会作出改变,并且元素和Frees_Size是一样的,但是顺序不一样
- /// </summary>
- public readonly List<Range> Frees_Position = new List<Range>();
- /// <summary>
- /// 按照空间大小进行排序的空闲列表,该列表只有在添加空闲空间时才会作出改变,并且元素和Frees_Position是一样的,但是顺序不一样
- /// </summary>
- public readonly List<Range> Frees_Size = new List<Range>();
- public void UnsafeAddRange(Range freeItem)
- {
- if (freeItem.Start >= freeItem.End) return;
- if (Frees_Position.Count > 0)
- {
- retest:
- //找到第一个起始位置比目标对象要大或者等于的元素
- long index = SMath.BinarySearch(Frees_Position, (source, position, target) =>
- {
- Range cur = source[(int)position];
- if (cur.End == target.Start) return SentenceResult.Equal;
- if (cur.Start == target.End) return SentenceResult.Equal;
- if (cur.Start > target.Start) return SentenceResult.More;
- if (cur.Start < target.Start) return SentenceResult.Less;
- return SentenceResult.Equal;
- }, freeItem, 1, 0, Frees_Position.Count,false);
- Range item = null;
- //如果所找到的位置在集合中间
- if (index >= 0 && index < Frees_Position.Count)//&& Frees_Position[index].Start == freeItem.End)
- {
- //重新检测标志
- bool preItemTested = false;
- testPreItem:
- item = Frees_Position[(int)index];
- //如果所需要添加的空闲块已经被包含了,那么就直接退出
- if (item.Start <= freeItem.Start && item.End >= freeItem.End) return;
- //已知的空间被需要添加的空间包含,那么直接剔除掉原有的空间,并且重新检测
- if (item.Start > freeItem.Start && item.End < freeItem.End)
- {
- Frees_Size.Remove(item);
- Frees_Position.Remove(item);
- goto retest;
- }
- //已知的空间和需要添加的空间首尾相连,剔除掉原有的空间,合并之后重新检测
- if (item.Start == freeItem.End || (item.Start > freeItem.Start && item.Start < freeItem.End && item.End > freeItem.End))
- {
- item.Start = freeItem.Start;
- Frees_Size.Remove(item);
- Frees_Position.Remove(item);
- freeItem = item;
- goto retest;
- }
- //或者尾首相连
- if (item.End == freeItem.Start || (freeItem.Start > item.Start && freeItem.Start < item.End && freeItem.End > item.End))
- {
- item.End = freeItem.End;
- Frees_Size.Remove(item);
- Frees_Position.Remove(item);
- freeItem = item;
- goto retest;
- }
- //如果不满足以上的所有条件(即需要插入的空闲块和插入位置后面的那个空闲块没有交集),那么检测前一个空闲块和当前需要插入的空闲块的关系
- if (!preItemTested)
- {
- preItemTested = true;
- if (index > 0)
- {
- index--;
- goto testPreItem;
- }
- else
- {
- //如果前后两个空闲块都检测过没有交集的时候,直接插入列表
- Frees_Position.Insert((int)index, freeItem);
- item = freeItem;
- }
- }
- else
- {
- index++;
- Frees_Position.Insert((int)index, freeItem);
- item = freeItem;
- }
- }
- //否则直接插入
- else
- {
- if (index == long.MinValue)
- Frees_Position.Insert(0, freeItem);
- else
- Frees_Position.Add(freeItem);
- item = freeItem;
- }
- //找到大小最相近的元素项,更新按照大小排序的列表
- index = SMath.BinarySearch(Frees_Size, (source, position, target) =>
- {
- Range cur = source[(int)position];
- if (cur.Size > target.Size)
- return SentenceResult.More;
- else if (cur.Size < target.Size)
- return SentenceResult.Less;
- return SentenceResult.Equal;
- }, item, 1, 0, Frees_Size.Count,false);
- if (index >= 0 && index < Frees_Size.Count)
- {
- if (Frees_Size[(int)index].Size > item.Size)
- Frees_Size.Insert((int)index, item);
- else
- Frees_Size.Insert((int)index + 1, item);
- }
- else
- {
- if (index == long.MinValue)
- Frees_Size.Insert(0, item);
- else
- Frees_Size.Add(item);
- }
- OnRangeModifired(item);
- }
- else
- {
- Frees_Position.Add(freeItem);
- Frees_Size.Add(freeItem);
- OnRangeModifired(freeItem);
- }
- }
一个通用的二分查找算法
- /// <summary>
- /// 在二分查找法中,指定判别方法的委托,该方法判别当前所指向的位置的数据和目标数据的大小关系, 例如:Position位置的数据大于目标值,则返回More,如果相等则返回Equal,否则返回Less
- /// </summary>
- public delegate SentenceResult StreamBinarySearchSentenceHandler<TTarget, TSource>(TSource stream, long position, TTarget value);
- /// <summary>
- /// 表示对两个值大小的判别结果,
- /// More表示 Left > Right,
- /// </summary>
- public enum SentenceResult
- {
- /// <summary>
- /// 大于 即: Left > Right
- /// </summary>
- More = 1,
- /// <summary>
- /// 等于 即: Left == Right
- /// </summary>
- Equal = 0,
- /// <summary>
- /// 小于 即: Left < Right
- /// </summary>
- Less = -1,
- }
- /// <summary>
- /// 找到大于等于Value值的位置,如果目标元素大于所有集合对象,则返回long.MaxValue,如果目标元素小于所有集合对象,则返回long.MinValue
- /// </summary>
- /// <typeparam name="TTarget">输入:需要查找的目标类型</typeparam>
- /// <typeparam name="TSource">输入:需要进行查找的数据集类型</typeparam>
- /// <param name="source">需要进行查找的数据集</param>
- /// <param name="SentenceMethod">判别方法,该方法是由三个参数构成的:source,position,value 分别为源,位置偏移和值</param>
- /// <param name="value">需要查找的目标</param>
- /// <param name="stride">给定的目标在数据集中的单元大小,查找过程中,该值用于二分跳转</param>
- /// <param name="startPos">开始查找的位置</param>
- /// <param name="endPos">结束查找的位置</param>
- /// <param name="outOfRangeInvalidate">true = 标识当所要查找的对象超过给定的数据源界限时,标记为无效,即返回long.MinValue</param>
- /// <param name="equalMoveEnd">true=当找到所给定的目标值相等的元素时,是否将返回的位置移动到其后面</param>
- /// <returns></returns>
- public static long BinarySearch<TTarget, TSource>(TSource source, StreamBinarySearchSentenceHandler<TTarget, TSource> SentenceMethod, TTarget value, int stride, long startPos, long endPos,bool equalMoveEnd)
- {
- if ((endPos - startPos) % stride != 0)
- throw new ArgumentException("传入的参数可能有误,结束位置和起始位置的差不能被数据元整除!");
- if (endPos - startPos < stride)
- {
- return long.MinValue;
- }
- //如果开始位置的数据大于等于目标数据,说明所需要查询的数据不在指定的区域范围内,直接返回最前面的数据
- SentenceResult res = SentenceMethod(source, startPos, value);
- if (res == SentenceResult.Equal)
- {
- if (equalMoveEnd)
- return startPos + stride;
- else
- return startPos;
- }
- if (res == SentenceResult.More)
- return long.MinValue;
- //如果结束位置的数据小于等于目标数据,说明所需要查询的数据不在指定的区域范围内,直接返回最后面的数据
- res = SentenceMethod(source, endPos - stride, value);
- if (res == SentenceResult.Equal)
- {
- if (equalMoveEnd)
- return endPos;
- else
- return endPos - stride;
- }
- if (res == SentenceResult.Less)
- return long.MaxValue;
- long subtract = endPos - startPos;
- long middle = startPos;
- while (subtract > stride)
- {
- subtract = subtract / 2 / stride * stride;
- middle = startPos + subtract;
- //stream.Position = middle;
- res = SentenceMethod(source, middle, value);
- if (res == SentenceResult.Equal)
- {
- if (equalMoveEnd)
- return middle + stride;
- else
- return middle;
- }
- if (res == SentenceResult.Less)
- {
- startPos = middle;
- }
- else
- {
- endPos = middle;
- }
- subtract = endPos - startPos;
- }
- if (res == SentenceResult.More)
- return middle;
- else if (res == SentenceResult.Less)
- return middle + stride;
- else
- {
- if (equalMoveEnd)
- return middle + stride;
- else
- return middle;
- }
- }
当我们可以最大限度地复用文件存储区时,我们就可以对原有的数据结构进行调整,比如:可以把索引文件和数据文件合并到一个文件中而不必考虑图片的增长而导致的索引存储区空间不足的情况,可以在增加和删除数据单元时不必更新整个索引区而只更新更改的那部分索引区……等等,所以上一节的内容对于旧版本的数据结构进化起到了一个重要的作用。
重新设计后的数据结构不再局限于存储Google地图,而是面向了所有带有可排序的键的数据单元,如下图:
整个数据结构存储与一个文件中,使用文件流进行数据访问,数据结构说明:
数据头部分:
1.文件格式字符串:存储在文件头部的固定字符内容,用于区分不同的文件格式。
2.创建时间,修改时间
3.索引入口点:数据索引的入口点,两个long型数据单元,占用16字节,第一个long型指向了数据存储区中的索引起始位置,第二个long型指向了数据结束位置
4.空闲空间存储区入口点:同索引入口点,只是指向的位置是空闲空间存储区
5.文件实际长度:这是一个后来加入的属性,因为考虑到要支持大文件时要用的文件的映射而增加的
6.160字节预留区:预留以后扩展时需要用的属性存储区
7.数据存储区:
数据存储区的部分数据部分是不固定的,由空闲空间管理器管理(第三节所设计的类型FreeRange)
8.二级索引入口点:存储了一个二级索引列表,以及这些索引所对应的数据单元入口点,如果所指向的数据单元也是一个索引存储区,那么可以称之为三级索引…… 类推,可以产生N级索引
9.空闲空间存储区:用于存储这个数据文件中有哪些部分是空闲的空间,可以复用的空间
访问这种数据结构的关键在于查询多级索引,前面已经提到过,索引的存储是按照从小到大排序的,所以针对这种级联的关系,我们应该设计一个通用的对单个索引区的搜索方法,然后根据传入的索引层级遍历搜索。例如:当需要搜索第5层地图的第32张图片时,我们可以根据索引入口点搜索到第5层的二级索引存储区,然后在二级索引存储区中搜索第32个图片存储区,最后返回。
至此,无限级索引的数据结构设计已经基本完成。
需要浏览代码的,可以到我的资源箱中下载IndexicalData,额…… CSDN中帖子不知道怎么上传附件
注:代码主要以研究学习为主,还未投入使用,并且有许多需要整理改进的地方,比如:写入和读取的缓存机制等……
如果你对这部分研究比较感兴趣的话,我们可以一起探讨,共同进步。