本文目录如图:
一、背景
近来因为业务需要,学习下如何更好的展示Feed流,而 IGListKit 是 Instagram 用来重构 Feed 流的开源库,可以实现在Feed流灵活的展示各种类型的数据,其中用到的 diff 算法是用来对比两个不同的数组的,使得可以在设置新数组时,界面只对有变化的数据进行更新。这个算法能做到时间复杂度为线性增长,是比较高效的算法。
下面分别介绍 IGListKit 参考的文献(也可以在这里下载)中介绍的算法以及 IGListKit 是如何用 Objective-C 来实现这个算法的。
二、文献中的算法
我们称原文件(old file)为O,新文件(new file)为N。
把文件中每个基本单元当做一行(line),可以是个字符,单词,句子,或者段落等
通过修改O可以得到N。
用到的数据结构有3个:一个符号表(symbol table)和两个数组(OA, NA)。
对于符号表,以每行L的文本(text)作为key,value则为一个entry,一个entry包含三个数据:
- OC(old counter),表示L在文件O中出现的次数;
- NC(new counter),表示L在文件N中出现的次数;
- OLNO,表示L在文件O中出现的位置,只有当OC=1时才有用。
图1. 符号表的结构
O(N)文件中的每行L都有一个entry,这个entry会存储到数组OA(NA)中。
每个entry要么持有指向符号表的L对应的entry,要么记录了L在文件N(O)中的位置。(and a bit to specify which?)
这个算法有6个pass:
1、遍历文件N的每个line i
- 使得每个line i在符号表都有对应的entry;
- entry中的NC为line i的出现次数;
- 把指向这个entry的指针存储到NA[i]中。
图2. 经过步骤1之后,符号表和NA的结果
2、和1类似,遍历文件O的每个line j
- 使得每个line j在符号表都有对应的entry;
- entry中的OC为line j的出现次数;
- entry中的OLNO为line j在文件O中的行号;
- 把指向这个entry的指针存储到OA[j]中。
图3. 经过步骤2之后,符号表和OA的结果,注意如果某line在O、N都出现了,位置分别为j、i,则OA[j]、NA[i]指向同一个entry
3、找出符号表中满足“OC=NC=1”的entry(比如上图中的entry,对应N中的Line i、O中的Line j),这表示该line在文件修改前后没有变化,则把NA[i]、OA[j]分别改为该line在另一个文件O、N中的行号,即:
NA[i] = j
OA[j] = i
图4. 对于修改前后没变化的line
4、对于步骤3中找出的 NA[i] 与 OA[j] 对应(即 NA[i] = j 且 OA[j] = i), 如果NA[i+1]、OA[j+1]也指向同一个entry,则说明该line在文件修改前后也没有变化,则使:
NA[i+1] = j+1
OA[j+1] = i+1
5、类似步骤4,检查NA[i-1]、OA[j-1],如果都指向同一个entry,则:
NA[i-1] = j-1
OA[j-1] = i-1
(这里没理解,在步骤3找到的line没包含步骤4、5中的line么?)
6、输出结果:
- 如果NA[i]指向一个符号表的entry,说明新文件的对应的 line i 是新增的(如图5中NA的10、9);
- 如果 NA[i] 与 OA[j] 对应(即 NA[i] = j 且 OA[j] = i),但 NA[i+1] 与 OA[j+1] 不对应(即 NA[i+1] 要么指向一个符号表的entry,要么是不等于j的另一个数字),则说明 line i 处于删除或者移动范围的边界(如图5中NA的8)
图5. 新旧文件的对应的两数组(NA, OA)的对比,根据原文的图1简化所得
(这里没理解,NA中的2、6也应该是移动范围的边界?)
三、IGListKit 中 diffable 算法的实现
算法实现是在 IGListDiff.mm 中IGListDiffing函数中:
1 | static id IGListDiffing(BOOL returnIndexPaths, |
2 | NSInteger fromSection, |
3 | NSInteger toSection, |
4 | NSArray<id<IGListDiffable>> *oldArray, |
5 | NSArray<id<IGListDiffable>> *newArray, |
6 | IGListDiffOption option, |
7 | IGListExperiment experiments) |
作用是:传入新旧两个数组(oldArray、newArray),用算法进行对比,得到具体差异(分别有哪些元素进行了插入(insert)、删除(delete)、移动(move)、更新(move)操作)。
其中更新操作是上面文献的算法没有提到的,因为文献中只对比文案,文案不同则代表不同的元素。
IGListDiffing 先处理两种简单的情况:
- 新数组为空,则说明旧数组的所有元素都进行了删除操作;
1
// if no new objects, everything from the oldArray is deleted
2
// take a shortcut and just build a delete-everything result
3
if (newCount == 0) {
4
if (returnIndexPaths) {
5
return [[IGListIndexPathResult alloc] initWithInserts:[NSArray new]
6
deletes:indexPathsAndPopulateMap(oldArray, fromSection, oldMap)
7
updates:[NSArray new]
8
moves:[NSArray new]
9
oldIndexPathMap:oldMap
10
newIndexPathMap:newMap];
11
} else {
12
...
13
}
14
}
- 旧数组为空,则说明新数组的所有元素都进行了插入操作;接着是在新旧数组都不为空时的对比:
1
// if no old objects, everything from the newArray is inserted
2
// take a shortcut and just build an insert-everything result
3
if (oldCount == 0) {
4
if (returnIndexPaths) {
5
return [[IGListIndexPathResult alloc] initWithInserts:indexPathsAndPopulateMap(newArray, toSection, newMap)
6
deletes:[NSArray new]
7
updates:[NSArray new]
8
moves:[NSArray new]
9
oldIndexPathMap:oldMap
10
newIndexPathMap:newMap];
11
} else {
12
...
13
}
14
}
假设数组的每个元素包含两个值:
图6. 本文例子中的数组元素的结构
其中key对应的值用作符号表中的key,text对应的值用作判断数据是否相等。
现在假设新旧数组分别为:
1 | oldArray : ( |
2 | "key:1, text:UnchangedObj", |
3 | "key:2, text:DeletedObj", |
4 | "key:3, text:MovedObj1", |
5 | "key:4, text:MovedObj2", |
6 | "key:5, text:UpdateObjOld", |
7 | "key:6, text:SameObj" |
8 | ) |
9 | |
10 | newArray : ( |
11 | "key:1, text:UnchangedObj", |
12 | "key:7, text:InsertedObj", |
13 | "key:5, text:UpdateObjNew", |
14 | "key:6, text:SameObj", |
15 | "key:6, text:SameObj", |
16 | "key:3, text:MovedObj1", |
17 | "key:4, text:MovedObj2" |
18 | ) |
然后对比过程为:
1、先定义一个unordered_map类型的 table 作为文章中的符号表
1 | // symbol table uses the old/new array diffIdentifier as the key and IGListEntry as the value |
2 | // using id<NSObject> as the key provided by https://lists.gnu.org/archive/html/discuss-gnustep/2011-07/msg00019.html |
3 | unordered_map<id<NSObject>, IGListEntry, IGListHashID, IGListEqualID> table; |
新旧数组都用这个 table 来存储对应的 entry,entry的结构为:
1 | /// 用来追踪对比过程中的数据状态. |
2 | struct IGListEntry { |
3 | /// 数据在旧数组出现的次数 |
4 | NSInteger oldCounter = 0; |
5 | /// 数据在新数组出现的次数 |
6 | NSInteger newCounter = 0; |
7 | /// 数据在新数组出现的索引 |
8 | stack<NSInteger> oldIndexes; |
9 | /// 通过isEqual:对比对应的数据,判断数据是否有更新 |
10 | BOOL updated = NO; |
11 | }; |
2、进行第一个pass(对应文献中的第一个pass),遍历新数组newArray,记录每个元素的出现情况
1 | // pass 1 |
2 | // 为新数组的每个元素创建一个entry |
3 | vector<IGListRecord> newResultsArray(newCount); |
4 | for (NSInteger i = 0; i < newCount; i++) { |
5 | id<NSObject> key = IGListTableKey(newArray[i]); |
6 | IGListEntry &entry = table[key]; |
7 | |
8 | // 每出现一次,就将newCounter属性加1 |
9 | entry.newCounter++; |
10 | |
11 | // 往entry的oldIndexes栈push一个NSNotFound |
12 | entry.oldIndexes.push(NSNotFound); |
13 | |
14 | // 让newResultsArray数组元素的entry变量指向table的一个entry |
15 | newResultsArray[i].entry = &entry; |
16 | } |
newResultsArray 对应上文中的NA,其中的每一个元素类型是IGListRecord:
1 | /// 追踪entry和索引。索引默认为NSNotFound |
2 | struct IGListRecord { |
3 | IGListEntry *entry; |
4 | mutable NSInteger index; |
5 | |
6 | IGListRecord() { |
7 | entry = NULL; |
8 | index = NSNotFound; |
9 | } |
10 | }; |
因为上文的NA中的元素NA[i]可以指向一个entry,也可以存储另一个文件的对应数据的行号,这两个值在这里分别用entry、index来表示
在这个例子中,第一个pass结束后的结果如图7:
图7. 红色部分是在第一个pass中,对符号表中的entry的修改。NF是NSNotFound的缩写。
3、进行第二个pass(对应文献中的第二个pass),遍历旧数组oldArray,记录每个元素的出现情况
1 | // pass 2 |
2 | // 为新数组的每个元素创建一个entry(如果符号表里没有对的entry的话) |
3 | vector<IGListRecord> oldResultsArray(oldCount); |
4 | for (NSInteger i = oldCount - 1; i >= 0; i--) { |
5 | id<NSObject> key = IGListTableKey(oldArray[i]); |
6 | IGListEntry &entry = table[key]; |
7 | |
8 | // 每出现一次,就将newCounter属性加1 |
9 | entry.oldCounter++; |
10 | |
11 | // 往entry的oldIndexes栈push i, 存储在旧数组的位置 |
12 | entry.oldIndexes.push(i); |
13 | |
14 | // 让oldResultsArray数组元素的entry变量指向table的一个entry |
15 | oldResultsArray[i].entry = &entry; |
16 | } |
在这个例子中,第二个pass结束后的结果如图8:
图8. 红色部分是在第二个pass中,对符号表中的entry的修改。和第一个pass得到的结果相比,除了最后一个entry是新增的(对应旧数组中被delete的元素),其他entry都是用已有的。
4、进行第三个pass(对应文献中的第三个pass),处理在新旧数组都出现的元素,这里做了两件事:
(1)对比这两个元素是否相等,不等的话把对应的entry的updated设置为YES;
(2)类似文献中的把NA[i] 与 OA[j] 对应起来(即 NA[i] = j 且 OA[j] = i),这里是把该元素在新旧数组的位置分别存储到oldResultsArray、newResultsArray对应的元素的index中。
代码实现为:
1 | // pass 3 |
2 | // 遍历新数组,处理在新旧数组都出现的元素 |
3 | for (NSInteger i = 0; i < newCount; i++) { |
4 | // 取newResultsArray中对应的元素的entry |
5 | IGListEntry *entry = newResultsArray[i].entry; |
6 | |
7 | // 取该entry的oldIndexes的栈顶数字,如果是新插入的,则该数字为NSNotFound |
8 | NSCAssert(!entry->oldIndexes.empty(), @"Old indexes is empty while iterating new item %li. Should have NSNotFound", (long)i); |
9 | const NSInteger originalIndex = entry->oldIndexes.top(); |
10 | // 用了该entry的oldIndexes的栈顶数字,将其出栈 |
11 | entry->oldIndexes.pop(); |
12 | |
13 | // 如果originalIndex不为NSNotFound,说明和该entry对应的元素在旧数组也出现,则这个元素是在新旧数组都出现的元素 |
14 | // 对比这两个元素是否相等,不等的话把对应的entry的updated设置为YES; |
15 | // 对比策略有两种,一是直接对比地址,二是让数组的元素实现 IGListDiffable 协议的 -isEqualToDiffableObject :方法 |
16 | if (originalIndex < oldCount) { |
17 | const id<IGListDiffable> n = newArray[i]; |
18 | const id<IGListDiffable> o = oldArray[originalIndex]; |
19 | switch (option) { |
20 | case IGListDiffPointerPersonality: |
21 | // 这里是直接对比新旧元素 |
22 | if (n != o) { |
23 | entry->updated = YES; |
24 | } |
25 | break; |
26 | case IGListDiffEquality: |
27 | // 如果新旧两个元素地址不同,则用 -[IGListDiffable isEqualToDiffableObject:] 方法进行对比 |
28 | if (n != o && ![n isEqualToDiffableObject:o]) { |
29 | entry->updated = YES; |
30 | } |
31 | break; |
32 | } |
33 | } |
34 | |
35 | // 对于新旧数组都出现的元素,类似文献中的把NA[i] 与 OA[j] 对应起来(即 NA[i] = j 且 OA[j] = i) |
36 | if (originalIndex != NSNotFound |
37 | && entry->newCounter > 0 |
38 | && entry->oldCounter > 0) { |
39 | newResultsArray[i].index = originalIndex; |
40 | oldResultsArray[originalIndex].index = i; |
41 | } |
42 | } |
从代码可以看到,为什么是遍历新数组而不是遍历旧数组呢?因为entry中的oldIndexes存储的就是该entry对应的元素在旧数组的位置,这样就可以在遍历新数组的时候,也能获取旧数组中对应的元素了。
在这个例子中,第三个pass结束后的结果如图9:
图9. 红色部分是在第三个pass中新增的修改。
此时可以看到:
1)对于在新旧数组(newArray、oldArray)都出现的元素,在newResultArray、oldResultArray中对应的元素的index变量都分别记录了oldArray、newArray中的位置;
2)对于在新旧数组(newArray、oldArray)都出现的元素,如果两者的key相同、text不同,则对应的entry的updated为YES,表示该元素有更新操作;
3)对于只在新(newArray)都出现的元素,在这个pass中只是把对应的entry的oldIndexes栈的栈顶数字pop了出来;
4)对于只在旧(oldArray)都出现的元素,在这个pass中没有修改对应的entry。
5、分析结果
就是将前面得到结果,转化为具体的diff信息(insert、delete、move、update)。
(1)遍历oldResultArray数组,如果某个元素的index为NSNotFound,则说明它没出现在新数组中,也就是被删除了:
1 | for (NSInteger i = 0; i < oldCount; i++) { |
2 | // 记录当前位置之前有多少元素被删除了的,后面计算move数组会用到 |
3 | deleteOffsets[i] = runningOffset; |
4 | |
5 | const IGListRecord record = oldResultsArray[i]; |
6 | if (record.index == NSNotFound) { |
7 | // 把在旧数组的位置添加到delete数组里 |
8 | addIndexToCollection(returnIndexPaths, mDeletes, fromSection, i); |
9 | runningOffset++; |
10 | } |
11 | |
12 | addIndexToMap(returnIndexPaths, fromSection, i, oldArray[i], oldMap); |
13 | } |
(2)遍历newResultArray数组,可以找到增加、移动、更新了的元素:
1 | for (NSInteger i = 0; i < newCount; i++) { |
2 | insertOffsets[i] = runningOffset; |
3 | const IGListRecord record = newResultsArray[i]; |
4 | |
5 | const NSInteger oldIndex = record.index; |
6 | // 如果index为NSNotFound,说明该元素是新增的 |
7 | if (record.index == NSNotFound) { |
8 | // 把在新数组的位置添加到insert数组里 |
9 | addIndexToCollection(returnIndexPaths, mInserts, toSection, i); |
10 | // 记录新增的元素的数字加1 |
11 | runningOffset++; |
12 | |
13 | } else { |
14 | // 走到这里,说明index不为NSNotFound,则该元素是在新旧数组都出现的元素,有三种可能:不变,移动了位置,更新了值 |
15 | // 如果updated为YES,说明该元素有更新其值 |
16 | if (record.entry->updated) { |
17 | // 把在旧数组的位置添加到update数组里 |
18 | addIndexToCollection(returnIndexPaths, mUpdates, fromSection, oldIndex); |
19 | } |
20 | |
21 | // 如果除去该位置之前的insert、delete了的元素的影响,在新旧数组位置不一样的话,则说明位置有所移动 |
22 | const NSInteger insertOffset = insertOffsets[i]; |
23 | const NSInteger deleteOffset = deleteOffsets[oldIndex]; |
24 | if ((oldIndex - deleteOffset + insertOffset) != i) { |
25 | id move; |
26 | if (returnIndexPaths) { |
27 | NSIndexPath *from = [NSIndexPath indexPathForItem:oldIndex inSection:fromSection]; |
28 | NSIndexPath *to = [NSIndexPath indexPathForItem:i inSection:toSection]; |
29 | move = [[IGListMoveIndexPath alloc] initWithFrom:from to:to]; |
30 | } else { |
31 | move = [[IGListMoveIndex alloc] initWithFrom:oldIndex to:i]; |
32 | } |
33 | // 把记录了移动的前后位置的信息添加到move数组里 |
34 | [mMoves addObject:move]; |
35 | } |
36 | } |
37 | |
38 | addIndexToMap(returnIndexPaths, toSection, i, newArray[i], newMap); |
39 | } |
在这个例子中,最后得到的结果是:
变化类型 | 位置数组 | 记录的位置在哪个数组 |
---|---|---|
insert | [1, 4] | 新数组 |
delete | [1] | 旧数组 |
update | [4] | 旧数组 |
move | [(from: 4, to: 2), (from: 5, to: 3),(from: 2, to: 5), (from: 3, to: 6)] | from是旧数组,to 是新数组 |
也可以表示为图10:
图10. 本文例子中新旧两个数组对比得到的具体差异
四、讨论
1、文献的算法和 IGListKit 实现的算法对比
前者会关注move的范围,后者没有;
后者会关注元素是否有update操作,前者没有(因为对比的只是文本)。
2、时间复杂度
平均情况都是O(m+n)。
3、空间复杂度
平均情况都是O(m+n)。
参考资料:
IGListKit
https://instagram.github.io/IGListKit/
A technique for isolating differences between files
https://dl.acm.org/citation.cfm?id=359467&dl=ACM&coll=DL