yata线性数据插入算法
- 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
- 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
- 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。
yata线性数据插入算法
线性数据插入操作的定义模仿了 Yjs 中对插入操作的定义。
如上面的类型所示,一个插入操作 Item 包含了唯一的操作标识符 ID、插入操作的内容和插入的意图 originLeft 和 originRight。
这里需要注意的是,YATA 中的插入意图只有 originLeft,Yjs 为了解决一种特殊情况,添加了 originRight,下文会讲述。
操作的唯一标识符ID 是由 Client 和 Clock 组成的元组,其中 Client 是副本的唯一标识符,Clock 是一个副本产生操作的累加计数器,每产生一个操作,计数器加 1。
在 Yjs 中,如果连续的插入操作的内容都是同类型的文本,为了降低空间复杂度,会将这些连续的文本操作合并成一个操作。
考虑到以后有把这些操作拆分的需求,所以在 Yjs 中,Clock
每次加的是内容的长度。
除了插入操作Item的定义外,这里还给出了一个文档的定义Doc。
考虑一个纯文本文档,对于文档的编辑只包含插入和删除两种操作。
在 YATA 中,删除采用了墓碑法,对于文档的操作只有插入文本一个操作。
所以,文档可以看成是插入操作的集合。
为了保证各个副本之间的收敛,需要插入操作的集合满足全序关序,即每两个插入操作都有一个前后关系。
YATA 插入算法正是在这样一个前提下,提出了三条规则,最后根据这三条规则,提出了线形数据的插入算法。
具体的细节,请读者移步论文原文或者我给出的论文翻译,有具体的解释。
Yjs 对于文档内容Doc.content的定义采用的是操作的链表结构,这里为了更方便的解释算法,在不考虑时间复杂度的情况下,采
用了数组结构 Item<T>[]。
插入算法的感性认识
在讲述本节之前,我们先对符号做一个约定。
a < b 指 a 在 b 的左边且相邻;a << b 指 a 在 b 的左边,可能相邻;a = b 指 a 和b 是一个元素。
假设 ono_non在 originnorigin_noriginn 后插入,originnorigin_noriginn 是 ono_non 的插入意图,则满足
originn<<onorigin_n << o_noriginn<<on,而不一定满足
originn<onorigin_n < o_noriginn<on,因为在
originnorigin_noriginn 和 ono_non 之间可能还会插入其他的元素。
(注:这个约定只适合本节,跟论文没有关系)
为了解释插入算法,不失一般性,我们假设有两个副本在进行协作。
副本 1 在 origin1origin_1origin1 后面插入了 o1o_1o1 ,即origin1<<o1origin_1 << o_1origin1<<o1;副本 2 在
origin2origin_2origin2 后面插入了 o2o_2o2 ,即
origin2<<o2origin_2 << o_2origin2<<o2。
插入算法的核心在于插入操作的集合满足全序关序,每两个插入都有一个前后关系。
现在要将副本 1 产生的 o1o_1o1与副本 2 产生的 o2o_2o2 集合在一起,为了保证副本 1 和副本 2 有相同的前后顺序,就需要计算出
o1o_1o1 和 o2o_2o2 的前后关系。
我们以将 o1o_1o1 插入到副本 2 为例,计算o1o_1o1与
o2o_2o2的前后关系。
通过总结归纳,我们知道此时的副本 2 有以下四种排列。
origin1<<origin2<<o2origin_1 << origin_2 << o_2origin1
<<origin2<<o2
origin2<<origin1<<o2origin_2 << origin_1 << o_2origin2
<<origin1<<o2
origin2<<o2<<origin1origin_2 << o_2 << origin_1origin2
<<o2<<origin1
origin2=origin1<<o2origin_2 = origin_1 << o_2origin2
=origin1<<o2
考虑第三种排列。
因为 origin1<<o1origin_1 << o_1origin1
<<o1,我们可以得出副本 2 此时的排列是
origin2<<o2<<origin1<<o1origin_2 << o_2 << origin_1 <<
o_1origin2<<o2<<origin1<<o1,即 o2<<o1o_2 << o_1o2<<o1。
接下来,通过分析剩下的三种排列,得出插入算法的规则。
第二种排列:origin2<<origin1origin_2 << origin_1origin2 <<origin1
考虑第二种排列。
此时 origin2<<origin1origin_2 <<
origin_1origin2<<origin1,即需要根据这个前提得出 o1o_1o1 和o2o_2o2 的相对位置,o2<<o1o_2 << o_1o2<<o1 或者 o1<<o2o_1 << o_2o1<<o2。
假设可以通过 origin2<<origin1origin_2 << origin_1origin2 <<origin1 可以得出 o2<<o1o_2 << o_1o2<<o1。
我们考虑一种极端情况,origin1<o2origin_1 < o_2origin1<o2 并且在当前副本下的
origin1origin_1origin1 后插入了一个 o1o_1o1,此时这四个的相对顺序是 origin2<<origin1<o1<o2origin_2 << origin_1 < o_1 < o_2origin2<<origin1<o1<o2。
插入完成后,当前副本将 o1o_1o1 广播到其他副本后,根据当前规则,可以得出接到广播的副本的相对位置,即origin2<origin1<o2<<o1origin_2 < origin_1 < o_2 <<
o_1origin2<origin1<o2<<o1。
可以发现两个副本的相对位置不一样了,即两个副本发生了冲突。
综上所述,不可以根据此规则来判断的相对位置。
上述的假设失败后,只剩下通过 origin2<<origin1origin_2 << origin_1origin2<<origin1 可以得出 o1<<o2o_1 << o_2o1<<o2。
该假设就是 YATA 提出的规则之一。
在论文中有严格的证明,这里不做赘述。
根据此规则,我们可以得出副本 2 中各个插入的相对顺序origin2<<origin1<<o1<<o2origin_2 << origin_1 << o_1 <<
o_2origin2<<origin1<<o1<<o2。
第一种排列:origin1<<origin2origin_1 << origin_2origin1 <<origin2
当 origin1<<origin2origin_1 << origin_2origin1<<origin2,我们可以发现 origin1origin_1origin1 和 o2o_2o2 的之间有一个origin2origin_2origin2。
所以先分析 origin1origin_1origin1、origin2origin_2origin2 和 o1o_1o1 的关系。
不失一般性,我们总是可以找到在 origin1origin_1origin1 和
origin2origin_2origin2 之间的元素,直到找到一个与
origin1origin_1origin1 相邻的元素
neighbor1neighbor_1neighbor1 。
这种情况下,
origin1<neighbor1<<=origin2<<o2origin_1 < neighbor_1 <<= origin_2 << o_2origin1<neighbor1<<=origin2<<o2。
因为neighbor1neighbor_1neighbor1 的插入意图
originneighbororigin_{neighbor}originneighbor 和
origin1origin_1origin1 的关系是
originneighbor<<=origin1origin_{neighbor} <<=
origin_1originneighbor<<=origin1。
如果 originneighbor=origin1origin_{neighbor} =
origin_1originneighbor=origin1,则此时元素的相对位置是originneighbor=origin1<neighbor1origin_{neighbor} = origin_1 < neighbor_1originneighbor=origin1<neighbor1。
我们发现此时,o1o_1o1 的在副本 2 中的位置转化成了 o1o_1o1 与
neighbor1neighbor_1neighbor1 的关系。
又因为
originneighbor=origin1origin_{neighbor} =
origin_1originneighbor=origin1,则与第四种排列是一样的。
这里不做赘述。
如果 originneighbor<<origin1origin_{neighbor} <<
origin_1originneighbor<<origin1,则此时元素的相对位置是originneighbor<<origin1<neighbor1origin_{neighbor} <<
origin_1 < neighbor_1originneighbor<<origin1<neighbor1。
我们
发现这种关系跟第二种排列是相同的,同样不做赘述。
可以得出
o1o_1o1 应该插在 neighbor1neighbor_1neighbor1 的前面,即originneighbor<<origin1<o1<neighbor1origin_{neighbor} << origin_1 < o_1 < neighbor_1originneighbor<<origin1<o1
<neighbor1。
又因origin1<neighbor1<<=origin2<<o2origin_1 < neighbor_1 <<= origin_2 << o_2origin1<neighbor1<<=origin2<<o2,可以得出
originneighbor<<origin1<o1<neighbor1<<=origin2<<o2origin_{n eighbor} << origin_1 < o_1 < neighbor_1 <<= origin_2 <<
o_2originneighbor<<origin1<o1<neighbor1<<=origin2<<o2。
综上,可以得出 o1o_1o1 与 o2o_2o2 的关系是 o1<<o2o_1 << o_2o1<<o2 第四种排列:origin2=origin1origin_2 = origin_1origin2
=origin1
当 origin2=origin1origin_2 = origin_1origin2=origin1, 意味着 o1o_1o1 和 o2o_2o2 同时在一个操作后面插入。
这是一种特殊的关系,YATA 通过约定来解决这个问题。
即通过比较副本 1 和副本2 的客户端唯一标识 Client 来决定 o1o_1o1 和 o2o_2o2 的相对
顺序。
但是,不能通过 Client 的大小,直接决定操作的插入位置。
下面通过两个实际的例子去讨论。
考虑一种实际情况,文档的初始排列是 origin<<endorigin << endorigin<<end,副本 2 插入 o2o_2o2 后的排列是
origin<<o2origin << o_2origin<<o2,副本 1 在副本 2 的 o2o_2o2
前面插入了 o1o_1o1,它的排列是 origin<<o1<o2origin << o_1 < o_2origin<<o1<o2。
我们可以发现 o1o_1o1 和 o2o_2o2的插入意图都是 originoriginorigin。
假设根据 Client 大小,可得出
o2<<o1o_2 << o_1o2<<o1。
当副本 1 的 o1o_1o1集成到副本 2 后,如果直接根据该规则,可以得出副本 2 的排列是
origin<<o2<<o1origin << o_2 << o_1origin<<o2<<o1。
这会与副本1 的排列冲突。
为了解决这个问题。
需要引入插入意图的右链接originrightorigin_{right}originright。
一个操作必须在它的右链接前插入。
这样就可以避免这种冲突的出现。
除了上述的情况。
还需要考虑一种情况。
副本 1 的排列是origin<<o1<o2origin << o_1 < o_2origin<<o1<o2,副本 3 的排列是 origin<<o3origin << o_3origin<<o3。
假设直接通过 Client大小,决定操作的插入位置,并且约定 o3<<o2<<o1o_3 << o_2 << o_1o3 <<o2<<o1。
那么当 o3o_3o3 插入副本 1 时,可以得到排练
origin<<o3<<o1<o2origin << o_3 << o_1 < o_2origin<<o3<<o1<o2;当操作 o1o_1o1 和 o2o_2o2 插入副本 3 时,可以得倒排练origin<<o3<<o2<<o1origin << o_3 << o_2 << o_1origin<<o3<<o2 <<o1。
这会造成副本 1 和副本 3 冲突。
为了解决这个问题,需要保持插入的位置,继续向后比较,直到满足第二种排列的比较为止。
综上所述,我们可以得出线性插入算法。
// 把一个新的操作 Item 插入到一个副本Doc 中export function integrate<T>(doc: Doc<T>, newItem: Item<T>) {
let left = findItem(doc, newItem.originLeft);
let destIdx = left + 1;
let right = newItem.originRight == null
doc.content.length
: findItem(doc, newItem.originRight);
let scanning = false;
for (let i = destIdx;; i++) {
if (!scanning) destIdx = i;
if (i === doc.content.length) break;
if (i === right) break;
const other = doc.content[i];
let oleft = findItem(doc, other.originLeft);
let oright = other.originRight == null
doc.content.length
: findItem(doc, other.originRight);
if (
oleft < left ||
(oleft === left && oright === right && newItem.id[0] <= other.id[0])
) {
break;
}
if (oleft === left) scanning = newItem.id[0] <= other.id[0];
}
doc.content.splice(destIdx, 0, newItem);
}
// 寻找 needle 操作在 doc.content 的索引。
function findItem<T>(doc: Doc<T>, needle: Id | null): number { if (needle == null) return -1;
const idEq = (a: Id | null, agent: Agent, seq: Seq): boolean => a != null && a[0] === agent && a[1] === seq;
const [agent, seq] = needle;
const idx = doc.content.findIndex(({ content, id }) => idEq(id, agent, seq)); return idx;}。