流式输出场景下的字符串替换——一次颇具挑战性的需求

背景

最近在赶项目的过程中出现了一个需求,有多段带有引用的文本,为了防止引用之间出现索引冲突,需要登记并重新编排所有引用。随后会收到一个 chan string, 需要将输入的文本中的索引用替换成超链接的格式。大概抽象出来的问题可以表述成如下:

要求实现一个引用管理的模块 refManager

对外提供一个 Recv(in chan) 接口,将得到的文本按照 refs 中的 (k,v)k 替换成 k+v,例如

refs 中包含 {"ab": "1", "e", "2"} ,那么:

case1: abcdefg -> ab1cde2fg
case2: aabb -> aab1b

可以保证的是,任意 v 不包含任意 k ,因此不需要担心循环替换的情况出现,正式的表达为: k i ∉ v j ∣ 0 < i , j < l e n ( r e f s ) k_i \notin v_j | 0 < i, j < len(refs) ki/vj∣0<i,j<len(refs)

type refManager struct{
    refs map[string]string
}

func (m *refManager) Recv(in <-chan string, out chan string) {
}

in, out 分别表示生产者管道和消费者管道,都是字符串类型。

这里一开始有两个思路,后面分析这两个思路的时间复杂度和空间复杂度:

假设 Recv() 中的输入管道 in 发送 n 个字符串 s ,每个字符串平均长度为 m,其中:

0 < l e n ( k ) < 4 ; 0 < m < 100 ; 0 < l e n ( r e f s ) < 100 ; 0 < n < 1000 0 < len(k) < 4; 0 < m < 100; 0<len(refs)<100; 0<n<1000 0<len(k)<4;0<m<100;0<len(refs)<100;0<n<1000

方法一:维护字符串

给定一个字符串列表 buf ,假设 L = m a x ( l e n ( k i ) , k i ∈ r e f s ) L=max(len(k_i), k_i \in refs) L=max(len(ki),kirefs)buf 固定维护Ls。方法一的思想:当收到的字符串个数大于 L 时,去查找是否存在子字符串等于 k i k_i ki ,然后将 buf 合并成一个字符串 allS ,然后替换所有的(k,v)。代码如下:

type refManager struct {
	refs map[string]string
}

func (m *refManager) replaceAll(s string) string {
	newS := s
	for k, v := range m.refs {
		newS = strings.ReplaceAll(newS, k, v)
	}
	return newS
}

func (m *refManager) Recv(in <-chan string, out chan string) {
	L := 4
	tail := 0
	buf := make([]string, L)
	for s := range in {
		// buf overflow, remove the first, and the first element cannot be the part of k
		if tail >= L {
			out <- buf[0]
			for i := 1; i < tail; i++ {
				buf[i] = buf[i-1]
			}
			tail--
		}
		buf[tail] = s
		tail++
		allS := strings.Join(buf[:tail], "")
		newS := m.replaceAll(allS)
		if newS != allS {
			out <- newS
			tail = 0
		}
	}
	// send the rest in buf
	out <- m.replaceAll(strings.Join(buf[:tail], ""))
}

上面的代码第 21~24 行对 buf 的循环移位可以替换成循环列表,那么这一步的时间复杂度就变成 O ( 1 ) O(1) O(1) 。总的空间复杂度为 O ( m ) O(m) O(m) 。时间复杂度体现在 replaceAll 函数中,总的时间复杂度为 O ( n ∗ l e n ( r e f s ) ∗ O ( s t r i n g s . R e p a l c e A l l ) ) = O ( n ∗ l e n ( r e f s ) ∗ m ∗ L ∗ L ) = O ( n ∗ m ∗ l e n ( r e f s ∗ L 2 ) O(n*len(refs)*O(strings.RepalceAll))=O(n*len(refs)*m*L*L)=O(n*m*len(refs*L^2) O(nlen(refs)O(strings.RepalceAll))=O(nlen(refs)mLL)=O(nmlen(refsL2)(这里只考虑 Send 函数)。然而,上面的方法是有些缺陷的,因为每次匹配替换的时候,整个 buf 都会被弹出来,因此如果最后一个 s 的后缀恰好是某个 k 的前缀,这样就会有问题,例如:

refs: {"ab": "1", "e", "2"}
inputs: ["aba", "b"]
output: 1ab

这个缺陷是可以弥补的,也就是我们只弹出匹配到的最后一个下标之前的字符串,并把剩余的字符串合并成一个字符串加入到 buf 中,但每次匹配替换都维护一个下标未免过于麻烦,并且需要保证 prefix(k) != suffix(v)。嗯,上面这个方法暴露了各种各样的问题。问题根源在于每次需要去遍历 refs 多次匹配替换,导致我们需要考虑这些边界case。 因此,我们能不能保证每次最多匹配替换一次?

方法二:维护字符

仔细分析上面的时间复杂度主要再 replaceAll 函数中,也就是查找是否存在子字符串 k。与上面维护字符串的方式不同,这里我们仅维护长度固定的字符,buf 的后缀是否能够和 k 匹配上,map 的查找时间复杂度为 O ( log ⁡ N ) O(\log N) O(logN) ,因此查找是否存在k 的时间复杂度就变成了 O ( log ⁡ ( l e n ( r e f s ) ) ) O(\log(len(refs))) O(log(len(refs)))

type refManager struct {
	refs map[string]string
}

func (m *refManager) replaceAll(s string) string {
	newS := s
	for k, v := range m.refs {
		newS = strings.ReplaceAll(newS, k, v)
	}
	return newS
}

func (m *refManager) Recv(in <-chan string, out chan string) {
	L := 4
	tail := 0
	buf := make([]byte, L)
	// collect len of all keys
	keyLensMap := make(map[int]struct{}, 1)
	for k := range m.refs {
		keyLensMap[len(k)] = struct{}{}
	}
	keyLens := make([]int, 0, len(keyLensMap))
	for l := range keyLensMap {
		keyLens = append(keyLens, l)
	}
	for s := range in {
		for _, c := range []byte(s) {
			// buf overflow, remove the first, and the first element cannot be the part of k
			if tail >= L {
				out <- string(buf[0])
				for i := 1; i < tail; i++ {
					buf[i] = buf[i-1]
				}
				tail--
			}
			buf[tail] = c
			for _, l := range keyLens {
				if l > tail {
					continue
				}
				if v, ok := m.refs[string(buf[tail-l:tail])]; ok {
					out <- v
					tail = tail - l
				}
			}
		}
	}
	// send the rest in buf
	out <- string(buf[:tail])
}

代码和第一个方法有点像,空间复杂度是一样的为 O ( m ) O(m) O(m),时间复杂度为 O ( n ∗ m ∗ log ⁡ ( l e n ( r e f s ) ) ) O(n*m*\log(len(refs))) O(nmlog(len(refs))),时间复杂度确实是降下来了。并且每次我们只会替换 buf 的后缀, 但这个方法却不适合单独用在业务中,为什么?因为多字节码字会被截断,导致输出乱码。

因此在输出端,我们需要一个 RuneStreamReader 用于缓存输出字节,使得被截断的多字节码字重新组成成正常的码字。RuneStreamReader 实现两个接口 io.WriterRead() stringRead() string 贪心读取 buffer 中完整的多字节码字。由于 RuneStreamReader 是串联到输出端的,因此时间复杂度不会增大,并且方法二能用在通用场景下,对数据没有很严格的要求。

后面

其实在一开始做这个需求的时候,我思考的方向就是这两个策略,然后分析时间复杂度与实现的难度,选择了后者。但一开始没有考虑到多字节码字被截断的情况,后面在单测环节出现了乱码,然后分析出原因,在输出端加上了缓存队列,导致整个复杂度拔高了,后面打算切换到第一种方法。但是就在写这篇文章的时候,我发现了第一个方法有很多边界case其实是难以处理的,幸运的是业务逻辑我还没开始动,因此最终还是沿用了第二种方法,其实写博客也是相当于自行梳理了一遍。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值