本文目录如图:
一、背景
近来因为业务需要,学习下如何更好的展示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 deleted2// take a shortcut and just build a delete-everything result3if (newCount == 0) {4if (returnIndexPaths) {5return [[IGListIndexPathResult alloc] initWithInserts:[NSArray new]6deletes:indexPathsAndPopulateMap(oldArray, fromSection, oldMap)7updates:[NSArray new]8moves:[NSArray new]9oldIndexPathMap:oldMap10newIndexPathMap:newMap];11} else {12...13}14} - 旧数组为空,则说明新数组的所有元素都进行了插入操作;接着是在新旧数组都不为空时的对比:
1// if no old objects, everything from the newArray is inserted2// take a shortcut and just build an insert-everything result3if (oldCount == 0) {4if (returnIndexPaths) {5return [[IGListIndexPathResult alloc] initWithInserts:indexPathsAndPopulateMap(newArray, toSection, newMap)6deletes:[NSArray new]7updates:[NSArray new]8moves:[NSArray new]9oldIndexPathMap:oldMap10newIndexPathMap: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