问题日志记录的算法解决方案
最近在思考一个需求,类似于知乎这种问答平台,或者各种支持 markdown 编辑贡献的平台,允许每个用户对问题进行修改做贡献,同时记录每个用户的修改日志,如何实现?
业务系统的交互形态
在问题界面,前端请求后端拿到题目的文本,然后在前端使用 markdown 编辑器修改,然后点击提交按钮,将修改的文本提交给后端,后端计算原文本和修改后文本的修改日志,存储日志和操作信息。
其他考虑:哪些用户可以修改问题?有没有权限限制?角色限制和属性限制?
技术方案
存储数据结构
关于问题文本的存储,目前考虑方案是 checkout + 修改日志计算出最新内容,比如每10个修改日志存储一个 checkout,两个方向的考虑:节省存储成本,可以根据 checkout 进行分页。
前端直接请求问题:后端计算生成后返回 + 缓存 checkout 优化
前端请求问题日志:后端直接返回 checkout + log
后续优化:对频繁访问的文本内容,可以记录热点数据然后再定时任务预热生成 checkout,弱一致。
文本修改日志
前端传回新文本,后端接收到之后,上锁,从数据库取得最新的文本,计算两者的最小编辑距离,生成修改日志。
为什么采用修改日志的方案:
- 问题的修改操作不会修改太多的文本,也就是增量数据是比较少的,如果直接存储新文本,会存在大量的重复数据存储,浪费空间。
- 一个问题的文本最高不会超过400个字符,按最小编辑距离算法 DP 的时间复杂度 O(nm) ,最多计算 16w 次,最多不过80ms,计算成本可以接收。
技术细节:最小编辑成本算法
编辑成本:DELETE(删除),INSERT(插入),SUBSTITUTE(替换)操作代价相等,计算操作代价最少的方案。
实现:
- 动态规划表:
- 使用一个二维数组
dp
,其中dp[i][j]
表示将字符串str1
的前i
个字符转换为字符串str2
的前j
个字符所需的最小编辑距离。
- 使用一个二维数组
- 初始化:
dp[i][0] = i
:将str1
的前i
个字符转换为空字符串,需要i
次删除操作。dp[0][j] = j
:将空字符串转换为str2
的前j
个字符,需要j
次插入操作。
- 填充动态规划表:
- 如果
str1.charAt(i - 1)
等于str2.charAt(j - 1)
,则cost = 0
,否则cost = 1
。 dp[i][j]
的值取以下三种操作的最小值:- 删除操作:
dp[i - 1][j] + 1
- 插入操作:
dp[i][j - 1] + 1
- 替换操作:
dp[i - 1][j - 1] + cost
- 删除操作:
- 如果
- 返回结果:
- 最终结果存储在
dp[str1.length()][str2.length()]
中。
- 最终结果存储在
PS:这种只需加记录前一次代价的 DP 可以压缩到一维节省空间。
由于 dp 数组只记录代价,所以需要自己维护操作记录。
public class EditorUtils {
/* 计算最短修改日志 */
public static List<Operation> diff(String fromText, String toText) {
int m = fromText.length();
int n = toText.length();
// dp arrays
int[] previous = new int[n + 1];
int[] current = new int[n + 1];
List<CharOp[]> previousOps = new ArrayList<>(n + 1);
List<CharOp[]> currentOps = new ArrayList<>(n + 1);
for (int j = 0; j <= n; j++) {
previous[j] = j;
if (j == 0) {
previousOps.add(null);
} else {
previousOps.add(copyAndAppend(previousOps.get(j - 1), CharOp.insert(0, toText.charAt(j - 1))));
}
}
for (int i = 1; i <= m; i++) {
current[0] = i;
currentOps.add(copyAndAppend(previousOps.get(0), CharOp.delete(i - 1)));
for (int j = 1; j <= n; j++) {
int cost = (fromText.charAt(i - 1) == toText.charAt(j - 1)) ? 0 : 1;
int substitution = previous[j - 1] + cost;
int insertion = current[j - 1] + 1;
int deletion = previous[j] + 1;
current[j] = Math.min(Math.min(substitution, insertion), deletion);
// Record the operation
if (current[j] == substitution) {
if (cost == 1) {
// substitute copy&append dp[i-1][j-1] 的 CharOp[]
currentOps.add(copyAndAppend(
previousOps.get(j - 1),
CharOp.substitute(i - 1, toText.charAt(j - 1))
));
} else {
// 无变化,直接引用 dp[i-1][j-1] 的 CharOp[] 避免频繁 copy 带来的 gc
currentOps.add(previousOps.get(j - 1));
}
} else if (current[j] == insertion) {
// insertion copy&append dp[i][j-1] 的 CharOp[]
currentOps.add(copyAndAppend(
currentOps.get(j - 1),
CharOp.insert(i, toText.charAt(j - 1))
));
} else if (current[j] == deletion) {
// deletion copy&append dp[i-1][j] 的 CharOp[]
currentOps.add(copyAndAppend(previousOps.get(j), CharOp.delete(i - 1)));
}
}
int[] temp = previous;
previous = current;
current = temp;
List<CharOp[]> tmpOps = previousOps;
previousOps = currentOps;
currentOps = tmpOps;
currentOps.clear();
}
return optimize(previousOps.get(previousOps.size() - 1));
}
private static CharOp[] copyAndAppend(CharOp[] ops, CharOp operation) {
if (Objects.isNull(ops)) {
return new CharOp[]{operation};
}
CharOp[] copy = Arrays.copyOf(ops, ops.length + 1);
copy[ops.length] = operation;
return copy;
}
private static List<Operation> optimize(CharOp[] ops) {
if (Objects.isNull(ops)) {
return new ArrayList<>(0);
}
List<Operation> result = new ArrayList<>(ops.length / 2);
for (CharOp op : ops) {
if (result.isEmpty()) {
result.add(buildOperation(op));
continue;
}
Operation operation = result.get(result.size() - 1);
if (!op.type.equals(operation.getType())) {
result.add(buildOperation(op));
continue;
}
switch (op.type) {
case INSERT:
if (op.position == operation.getBeginIdx()) {
operation.appendChange(op.change);
} else {
result.add(buildOperation(op));
}
break;
case SUBSTITUTE:
if (op.position == operation.getEndIdx() + 1){
operation.setEndIdx(op.position);
operation.appendChange(op.change);
} else {
result.add(buildOperation(op));
}
break;
case DELETE:
if (op.position == operation.getEndIdx() + 1){
operation.setEndIdx(op.position);
} else {
result.add(buildOperation(op));
}
break;
}
}
return result;
}
private static Operation buildOperation(CharOp op) {
Operation operation = new Operation();
operation.setBeginIdx(op.position);
operation.setEndIdx(op.position);
operation.setType(op.type);
if (OperationType.INSERT.equals(op.type) || OperationType.SUBSTITUTE.equals(op.type)) {
operation.setChange(String.valueOf(op.change));
}
return operation;
}
private static class CharOp {
public final int position;
public final char change;
public final OperationType type;
public CharOp(int position, OperationType type, char change) {
this.position = position;
this.change = change;
this.type = type;
}
public static CharOp delete(int position) {
return new CharOp(position, OperationType.DELETE, '\0');
}
public static CharOp insert(int position, char insertion) {
return new CharOp(position, OperationType.INSERT, insertion);
}
public static CharOp substitute(int position, char change) {
return new CharOp(position, OperationType.SUBSTITUTE, change);
}
}
}
数据结构:
public class Operation {
private int beginIdx;
private int endIdx;
private OperationType type;
private StringBuilder change;
public int getBeginIdx() {
return beginIdx;
}
public void setBeginIdx(int beginIdx) {
this.beginIdx = beginIdx;
}
public int getEndIdx() {
return endIdx;
}
public void setEndIdx(int endIdx) {
this.endIdx = endIdx;
}
public OperationType getType() {
return type;
}
public void setType(OperationType type) {
this.type = type;
}
public String getChange() {
return change.toString();
}
public void setChange(String change) {
this.change = new StringBuilder(change);
}
public void appendChange(char change) {
this.change.append(change);
}
@Override
public String toString() {
switch (type){
case DELETE:
return String.format("%s %d~%d",type,beginIdx,endIdx);
case INSERT:
return String.format("%s %d `%s`",type,beginIdx,change);
case SUBSTITUTE:
return String.format("%s %d~%d `%s`",type,beginIdx,endIdx,change);
}
return null;
}
}
public enum OperationType {
INSERT,
DELETE,
SUBSTITUTE,
}
实验数据:
public class Main {
public static void main(String[] args) {
String str1 = "夜色如墨,笼罩着这座古老而繁华的城市。霓虹灯闪烁,似是无数星星坠入凡间,却也掩盖不了街角那家老式唱片店透出的微弱光晕。林悦站在店门前,手中紧紧攥着一张泛黄的信笺,那是她与他唯一的联系。\n" +
"\n" +
"信上只有寥寥数语:“悦,我在唱片店等你,最后一首歌。”熟悉的笔迹,却带着陌生的疏离。她的心跳得厉害,像是要冲破胸腔。三年了,三年的思念与等待,她以为他早已消失在她的世界里,可如今,这封信却又将她拉回那个未解的谜团。\n" +
"\n" +
"推开门,旧唱片机的旋律悠悠传来,是那首老得掉牙的《月亮代表我的心》。灯光昏黄,空气中弥漫着旧时光的味道。她缓缓走进,视线在店内扫过,却不见他的身影。正当她有些失落时,角落里传来一声轻笑:“你来了。”她猛地转身,只见他倚在唱片架旁,眉眼如初,只是多了几分沧桑。";
String str2 = "夜色如墨,温柔地拥抱着这座古老而繁华的城市。霓虹闪烁,似是无数星辰坠入凡间,却也掩盖不了街角那家老式唱片店透出的微弱光晕。林悦站在店门前,手中紧紧攥着一张泛黄的信笺,那是她与他唯一的联系。\n" +
"\n" +
"信上只有寥寥数语:“悦,我在唱片店等你,最后一首歌。”熟悉的笔迹,带着淡淡的墨香,却也透着陌生的疏离。她的心跳得厉害,像是要冲破胸腔。三年了,三年的思念与等待,她以为他早已消失在她的世界里,可如今,这封信却又将她拉回那个未解的谜团。\n" +
"\n" +
"她深吸一口气,缓缓推开门。旧唱片机的旋律悠悠传来,是那首老得掉牙的《月亮代表我的心》,熟悉的旋律在空气中轻轻荡漾,仿佛时光从未走远。灯光昏黄,空气中弥漫着旧时光的味道,每一寸空间都似乎在诉说着过往的故事。\n" +
"\n" +
"她小心翼翼地走进,视线在店内扫过,却不见他的身影。失落感正要涌上心头时,角落里传来一声轻笑:“你来了。”她猛地转身,只见他倚在唱片架旁,眉眼如初,只是多了几分沧桑。他的眼神温柔而深邃,仿佛藏着无尽的星辰大海,瞬间将她的世界点亮。";
long begin = System.currentTimeMillis();
List<Operation> diff =EditorUtils.diff(str1, str2);
// diff.forEach(System.out::println);
long end = System.currentTimeMillis();
System.out.println("cost(ms) = " + (end-begin));
StringBuilder builder = new StringBuilder(str1);
for (int i = diff.size() - 1; i >= 0; i--) {
Operation op = diff.get(i);
switch (op.getType()) {
case SUBSTITUTE:
builder.replace(op.getBeginIdx(), op.getEndIdx() + 1, op.getChange());
break;
case INSERT:
builder.insert(op.getBeginIdx(), op.getChange());
break;
case DELETE:
builder.delete(op.getBeginIdx(), op.getEndIdx() + 1);
break;
}
}
System.out.println("operations = "+ diff.size());
System.out.println("compare = " + str2.equals(builder.toString()));
}
}
结果:
cost(ms) = 48
operations = 17
compare = true