在Bash中求两个字符串的最长公共前缀

39

我有两个字符串。为了举例,它们被设置成这样:

string1="test toast"
string2="test test"

我想要的是在字符串开头找到重叠部分。重叠指的是我的上面例子中的字符串“test t”。
# So I look for the command 
command "$string1" "$string2"
# that outputs:
"test t"

如果字符串是string1="atest toast"; string2="test test",它们将没有重叠,因为检查从开头开始,并且在string1的开头有一个"a"。

哦,太好了,看到其他人也有这些问题感到很开心 :D - Karoly Horvath
@ajreal:那里提供的函数相当冗长,并且无法处理字符串中的空格。尽管如此,我的问题是重复的。对此很抱歉。我会在那里发表评论。 - con-f-use
1
不是重复的:交集需求不同。 - jfg956
4
请勿在多个网站之间发布重复内容!如何在bash中查找两个字符串的重叠部分? - Caleb
15个回答

35
在sed中,假设字符串不包含任何换行符:
string1="test toast"
string2="test test"
printf "%s\n%s\n" "$string1" "$string2" | sed -e 'N;s/^\(.*\).*\n\1.*$/\1/'

6
请注意,并非所有的sed支持在替换命令中使用“\n”(苹果的不支持),但GNU sed支持。读者可能需要运行gsed而不是sed - outis
2
GNU sed也支持\x0printf '%s\x0%s' "$string1" "$string2" | sed 's/\(.*\).*\x0\1.*/\1/'更加安全。如果你正在处理路径名并想要一个共同的路径前缀,则可以将\(.*/\)替换为\(. *\) - jthill
@jthill有一个好主意,但是sed命令也必须被修改以处理换行符,类似这样:printf '%s\x0%s\n' "$string1" "$string2" | sed 'H;$!d;g;s/\`.\(.*\).*\x0\1.*/\1/' - Dan R

22

这是 sed 示例的改进版本,它可以找到 N 个字符串(N≥0)的公共前缀:

string1="test toast"
string2="test test"
string3="teaser"
{ echo "$string1"; echo "$string2"; echo "$string3"; } | sed -e 'N;s/^\(.*\).*\n\1.*$/\1\n\1/;D'

如果字符串被存储在数组中,可以使用printf将它们传输到sed中:

strings=("test toast" "test test" "teaser")
printf "%s\n" "${strings[@]}" | sed -e '$!{N;s/^\(.*\).*\n\1.*$/\1\n\1/;D;}'
你也可以使用一个here-string字符串:
strings=("test toast" "test test" "teaser")
oIFS=$IFS
IFS=$'\n'
<<<"${strings[*]}" sed -e '$!{N;s/^\(.*\).*\n\1.*$/\1\n\1/;D;}'
IFS=$oIFS
# for a local IFS:
(IFS=$'\n'; sed -e '$!{N;s/^\(.*\).*\n\1.*$/\1\n\1/;D;}' <<<"${strings[*]}")

这里字符串(和所有重定向)可以放置在简单命令的任何位置。


这是一个更好的现实世界的解决方案,因为我们不知道有多少个字符串需要处理,需要处理整个字符串数组。在我的情况下,一个包含四个字符串的数组,其中子目录深度达到25级,前19级是共同的。如何快速找到最深层次的子目录 - WinEunuuchs2Unix
我非常喜欢这个,但更喜欢聪明的grep技巧。能否将其转换为使用grep??仅在前两行上工作太受限制了! - Steven Lu
不错。在我的回答中,我将其概括为处理嵌入式换行符。 - Robin A. Meade
1
虽然看起来很整洁,但这个解决方案似乎依赖于特定版本的 sed。例如,在 macOS 上,捆绑的 sed 不返回任何内容,但是通过 homebrew/macports 安装的 GNU 版本的 sed 可以按预期工作,尽管在 macOS 版本中没有缺少任何选项。有什么想法吗?它不会产生任何错误,只是不返回任何内容。 - Haravikk
@Haravikk 请参见 https://stackoverflow.com/a/65245765/900078 - pynexj
对替换字符串进行轻微优化,移除第一个\1,改为\1\n\1。这是因为D会在开始下一个循环之前移除第一个\1,而不会打印出来。 - undefined

13

另一种变体,使用GNU grep:

$ string1="test toast"
$ string2="test test"
$ grep -zPo '(.*).*\n\K\1' <<< "$string1"$'\n'"$string2"
test t

1
这似乎比sed方法更具可移植性(Linux,Mac)。 - kermatt
但是为什么要使用z标志呢? - Steven Lu
抱歉,我正在查看BSD的man页面,这里没有相关的z标记。但是对于GNU来说,z标记寻找行末的空字节,这意味着多行输入可以进行正则表达式匹配以产生OP想要的结果。不错。 - Steven Lu
在 macOS 上,您需要 brew install grep 来获取 GNU grep 作为 ggrep。我的做法是通过逐步“安装它们”,例如 ln -s /usr/local/bin/gdate /usr/local/bin/date,逐渐感染我的 Mac 运行时与 GNU 工具,因为这样我的 $PATH 中的 /usr/local/bin 更早,这样我就可以保持 BSD 实用程序不变,例如 /usr/bin/date,同时减少对 Darwin 的分支需求,在我的 shell 脚本中越来越依赖于 GNU 功能。 - Steven Lu

11
这可以完全在bash中完成。虽然在bash中循环进行字符串操作速度较慢,但有一种简单的算法,其对shell操作数量呈对数级别,因此纯bash也可以是处理长字符串的可行选择。
longest_common_prefix () {
  local prefix= n
  ## Truncate the two strings to the minimum of their lengths
  if [[ ${#1} -gt ${#2} ]]; then
    set -- "${1:0:${#2}}" "$2"
  else
    set -- "$1" "${2:0:${#1}}"
  fi
  ## Binary search for the first differing character, accumulating the common prefix
  while [[ ${#1} -gt 1 ]]; do
    n=$(((${#1}+1)/2))
    if [[ ${1:0:$n} == ${2:0:$n} ]]; then
      prefix=$prefix${1:0:$n}
      set -- "${1:$n}" "${2:$n}"
    else
      set -- "${1:0:$n}" "${2:0:$n}"
    fi
  done
  ## Add the one remaining character, if common
  if [[ $1 = $2 ]]; then prefix=$prefix$1; fi
  printf %s "$prefix"
}

标准工具箱包括cmp来比较二进制文件。默认情况下,它指示第一个不同字节的字节偏移量。当一个字符串是另一个字符串的前缀时,有一个特殊情况: cmp 会在 STDERR 上产生不同的消息; 处理这种情况的简单方法是取最短的字符串。

longest_common_prefix () {
  local LC_ALL=C offset prefix
  offset=$(export LC_ALL; cmp <(printf %s "$1") <(printf %s "$2") 2>/dev/null)
  if [[ -n $offset ]]; then
    offset=${offset%,*}; offset=${offset##* }
    prefix=${1:0:$((offset-1))}
  else
    if [[ ${#1} -lt ${#2} ]]; then
      prefix=$1
    else
      prefix=$2
    fi
  fi
  printf %s "$prefix"
}

请注意,cmp 操作字节,但是 bash 的字符串操作是基于字符的。这在多字节语言环境下会有区别,例如使用 UTF-8 字符集的语言环境。上面的函数打印了一个字节串的最长前缀。为了处理字符串,我们可以先将它们转换成固定宽度的编码方式。假设语言环境的字符集是 Unicode 的子集,则 UTF-32 可以胜任。
longest_common_prefix () {
  local offset prefix LC_CTYPE="${LC_ALL:=LC_CTYPE}"
  offset=$(unset LC_ALL; LC_MESSAGES=C cmp <(printf %s "$1" | iconv -t UTF-32)
                                           <(printf %s "$2" | iconv -t UTF-32) 2>/dev/null)
  if [[ -n $offset ]]; then
    offset=${offset%,*}; offset=${offset##* }
    prefix=${1:0:$((offset/4-1))}
  else
    if [[ ${#1} -lt ${#2} ]]; then
      prefix=$1
    else
      prefix=$2
    fi
  fi
  printf %s "$prefix"
}

一个适用于多字节字符的解决方案变体是使用diff而不是cmp,并将其输入作为printf%s“$1”| fold -w 1 - jfg956
@jfgagne 不太对,这样会压制换行符。顺便说一下,我喜欢你的sed解决方案,但它也不总是适用于多行字符串。 - Gilles 'SO- stop being evil'
如何将此适应于 n 个字符串? - Paolo
@Paolo 对字符串进行排序(例如使用sort,但如果字符串可能包含换行符,则需要额外的工作)。然后将任何算法应用于第一个和最后一个字符串。确保在不将不同的字符串视为等效的语言环境中进行排序(简单的方法是env LC_ALL=C sort …)。 - Gilles 'SO- stop being evil'

7

Grep的简短变体(从sed借来的想法):

$ echo -e "String1\nString2" | grep -zoP '^(.*)(?=.*?\n\1)'
String

假设字符串中没有换行符。但是可以轻松地调整为使用任何分隔符。

更新于2016年10月24日:在现代版本的grep上,您可能会收到投诉grep:unescaped ^或$不支持-Pz,只需使用\A而不是^

$ echo -e "String1\nString2" | grep -zoP '\A(.*)(?=.*?\n\1)'
String

你可以通过将该命令的输出管道传递到tail -1来获取两个以上字符串的最长公共前缀。 - Philippe Carphin

6

好的,在Bash中:

#!/bin/bash

s="$1"
t="$2"
l=1

while [ "${t#${s:0:$l}}" != "$t" ]
do
  (( l = l + 1 ))
done
(( l = l - 1 ))

echo "${s:0:$l}"

这是与其他语言相同的算法,但纯粹基于Bash功能。而且,我可以说,它有点更丑陋 :-)


虽然这种方法可以勉强工作,但如果s不比t长,它将陷入(接近)无限循环,因为${s:0:$l}的输出不关心l是否高于s中的字符数,所以它将继续满足循环条件。我个人会重写成使用循环,例如for l in ${1..$((${#s} + 1))},并在内部添加一个检查以跳出循环。 - Haravikk

5

使用cmp实用程序获取第一个不同字符的索引,使用进程替换获取要比较的2个字符串,如果没有sed:

string1="test toast"
string2="test test"
first_diff_char=$(cmp <( echo "$string1" ) <( echo "$string2" ) | cut -d " " -f 5 | tr -d ",")
echo ${string1:0:$((first_diff_char-1))}

使用sed是更好的解决方案,因为只需要启动一个进程。 - jfg956
2
工具选择不错,但是前处理和后处理有问题。echo "$string1"会破坏一些字符串,并且你没有处理其中一个字符串是另一个字符串的前缀的情况。你不需要调用cut,因为shell完全可以从cmp输出中提取偏移量。这种方法的一个限制是cmp操作的是字节而不是字符。 - Gilles 'SO- stop being evil'
@Gilles:你能给我展示一个echo会破坏字符串的例子吗?在bash的手册中,我找到了一个使用echo -e“toto \ ntata”的例子,所以使用echo -E是否安全(不过感谢您提供printf的例子)。关于一个字符串是另一个字符串的前缀的情况,我使用cmp(GNU diffutils)2.8.1没有不同的输出。确实可以避免使用cut,但对于多字节字符无法正常工作是完全正确的。 - jfg956
1
在bash下,使用单个参数时,echo仅会破坏^-[neE]+$;但如果设置了xpg_echo,则echo也会破坏反斜杠。此外,echo会添加一个换行符,这就解释了为什么您没有看到foofoobar的前缀:您将foo\nfoobar\n传递给了cut。尝试使用echo -nfoo\nfoo\nbar - Gilles 'SO- stop being evil'

3
也许用另一种语言会更简单。这是我的解决方案:
common_bit=$(perl -le '($s,$t)=@ARGV;for(split//,$s){last unless $t=~/^\Q$z$_/;$z.=$_}print $z' "$string1" "$string2")

如果这不是一个一行代码的话,我会使用更长的变量名、更多的空格和更多的大括号等。我也确信即使在perl中有更快的方法,但是再次强调,这是速度和空间之间的权衡:这在一个已经很长的一行代码上使用了更少的空间。

2

这是仅使用Bash的又一种方法。

string1="test toast"
string2="test test"
len=${#string1}

for ((i=0; i<len; i++)); do
   if [[ "${string1:i:1}" == "${string2:i:1}" ]]; then
      continue
   else
      echo "${string1:0:i}"                       
      i=len
   fi
done

这只是一个小的改进,但你应该真正测试一下string1string2的长度,然后选择最短的作为len。例如,如果string1是'foobar',而string2是'foo',那么没有必要比较超过3个字符。你也不需要if语句的then分支,只需使用!=让循环自行继续即可。 - Haravikk

1
如果您有安装Python包的选项,可以使用此 Python实用工具
# install pythonp
pythonp -m pip install pythonp

echo -e "$string1\n$string2" | pythonp 'l1,l2=lines
res=itertools.takewhile(lambda a: a[0]==a[1], zip(l1,l2)); "".join(r[0] for r in res)'

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接