算法和数据结构笔记(四) 字符串

1 单词查找树:Trie树

如果一个关键字可以成字符串的形式,那么可以用键树(Keyword tree),又称数字搜索树(Digital Search Tree)。

键树的存储通常有两种方式:

  • 树的左孩子-右兄弟链表表示,每个Node有三个域(当前字符,左孩子的根,右兄弟的链表),称为双链树

如图所示:
《算法和数据结构笔记(四) 字符串》

《算法和数据结构笔记(四) 字符串》

  • 用多重链表表示,每个Node应包含R个指针域(R为字符集的个数),称为Trie树

下面重点讲述Trie树的实现

1.1 Trie树的结点和数据结构定义

Trie树的结点基本定义如下:


/**
 * R表示字符集的基,字符的种类数-单个结点所含有的最大子树个数
 * ALPHA表示设定字符集的起始字符,比如小写字母字符集起始为'a',数字字符集开始为'0'
 */

#define R 26
#define ALPHA 'a'

/**
 * is_str标记当前字符是否位于串的末尾
 * next数组表示指向各子树的指针
 */
struct trie_node_t {
    bool is_str;
    trie_node_t *next[R];
    trie_node_t(): is_str(false) {
        memset(next,0,sizeof(next));
    } 
};

说明两点:

  1. 在使用Trie树前需要了解字符集的特点,尤其是基数R。比如十进制数字中R为10,小写字母R为26,DNA序列R为4,二进制序列R为2,ASCII序列R为128。可以看到字典树的应用不限于字符串,还可以是很多具有特定字符基数的表示序列。另外字符集起始字符与next数组下标的映射关系也需要明确下来!
  2. 根据实际需求,Trie树的结点结构会有所不同。比如说添加一个count域用于统计单词的个数,添加一个实值用于字符串到该值的映射关系,字符串作为键

Trie树的结构和API如下:

/**
 * Trie树的用途
 * 统计和排序大量的字符串,如结点上设置计数域,用于进行文本字符串的词频统计
 * 用于查找Trie树中某字符串是否存在前缀串、最长前缀串等等
 * 也可用作查找树,键为字符集R的字符串,对应的值可以根据需求设置,如次数,
 */
class Trie {
public:

    Trie():root(new trie_node_t()) {}

    ~Trie() {
        delete_trie_node(root);
    }

    void insert(string word);

    bool find(string word);

    void remove(string word);

    /*是否含有前缀串pre*/
    bool contains_prefix(string pre);

    /*找出所有以pre开头的字符串*/
    vector<string> keys_with_prefix(string pre);

    /*找出所有的字符串*/
    vector<string> keys_all();

    /*找出匹配模式串pat的字符串*/
    vector<string> keys_that_match(string pat);

    /*找到给定字符串的前缀中最长的字符串*/
    string longest_prefix_of(string word);

private:
    trie_node_t *root;


private:
    void delete_trie_node(trie_node_t *root);
    trie_node_t* remove_trie_word(trie_node_t *root,string word,int d);
    void collect_string(trie_node_t *root,string pre,vector<string>& res);
    void collect_string_pat(trie_node_t *root,string pre,string pat,vector<string>& res);
};

所以除了常规Trie树的插入、查找和删除算法外,还另外添加了五个公有方法:

  • 是否含有前缀串pre: bool contains_prefix(string pre)

  • 找出所有以pre开头的字符串: vector<string> keys_with_prefix(string pre)

  • 列举出所有的字符串: vector<string> keys_all();

  • 找出匹配模式串pat的字符串: vector<string> keys_that_match(string pat);

  • 找到给定字符串的前缀中最长的字符串: string longest_prefix_of(string word);

1.2 Trie树的插入、查找和删除算法

A 插入、查找算法
这两个操作比较简单,用非递归的方式比较直白,上代码:

void Trie::insert(string word) {
    trie_node_t *cur = root;
    auto iter = word.begin();
    while(iter != word.end()) {
        int index = *iter - ALPHA;
        if(cur->next[index] == NULL) {
            cur->next[index] = new trie_node_t();
        }
        cur = cur->next[index]; 
        ++iter; /*移到下一个字符*/
    }
    cur->is_str = true;
    //cur->count++;
}

bool Trie::find(string word) {
    trie_node_t *cur = root;
    auto iter = word.begin();
    while(iter != word.end() && cur) {
        cur = cur->next[*iter - ALPHA];
        ++iter;
    }
    return (cur != NULL && cur->is_str == true);
}

B 删除算法
用递归思路更为简单: 即先查找到该字符串对应的结点,将其值设为空。然后再讨论

  • 如果结点还有非空子链接,不作任何事;
  • 如果所有链接全为空,删除该结点并且如果使得它的父节点的所有链接也均为空,就需要递归向上删除父节点

代码实现如下:

trie_node_t* Trie::remove_trie_word(trie_node_t *root,string word,int d) {
    if(root == NULL) return NULL;

    /*继续查找到对应的结点*/
    if(d == static_cast<int>(word.length())) {
        root->is_str = false;
    } else {
        int index = word[d] - ALPHA;
        root->next[index] = remove_trie_word(root->next[index],word,d + 1); 
    }

    /*向上删除发现是字符串的标记,该结点递归返回*/
    if(root->is_str == true) return root; 

    /*如果不是字符串标记但是有非空子链接,该结点递归返回; 否则删除该结点*/
    int i;
    for(i = 0;i < R;i++) {
        if(root->next[i] != NULL)
            return root;
    }
    if(i == R) {
        delete root;
    }
    return NULL;
}

void Trie::remove(string word) {
    root = remove_trie_word(root,word,0);
}

1.3 与前缀有关的查找

A 查找含前缀串的所有字符串
第一个函数就是直接遍历完前缀串,找到对应的字典树结点,不为空则为true。

bool Trie::contains_prefix(string pre) {
    trie_node_t *cur = root;
    auto iter = pre.begin();
    while(iter != pre.end() && cur) {
        cur = cur->next[*iter - ALPHA];
        ++iter;
    }
    return (cur != NULL);
}

第二个方法,首先用判断是否有前缀的思路看前缀串是否存在并返回对应的单词查找树结点,然后再以递归地方式收集所有从匹配前缀字符串所对应的字典树的字符串。列举出所有的字符串比较简单,即是keys_with_prefix(""),即找到了所有的字符串,并且已经排好序。

/**
 * 收集所有从匹配前缀字符串所对应的字典树的字符串
 */
void Trie::collect_string(trie_node_t *root,string pre,vector<string>& res) {
    if(root == NULL) return;
    if(root->is_str == true) res.push_back(pre);
    for(int i = 0;i < R;i++){
        char c = ALPHA + i;
        collect_string(root->next[i],pre + c,res);
    }
}

vector<string> Trie::keys_with_prefix(string pre) {
    vector<string>  res;

    /*第一步: 首先看前缀是否存在并返回对应的单词查找树*/
    trie_node_t *cur = root;
    auto iter = pre.begin();
    while(iter != pre.end() && cur) {
        cur = cur->next[*iter - ALPHA];
        ++iter;
    }

    /*第二步: 收集匹配前缀的字符串*/
    collect_string(cur,pre,res);
    return res;
}

/*找出所有的字符串,且已经排序好*/
vector<string> Trie::keys_all() {
    return keys_with_prefix("");
}

B 通配符匹配
找出匹配模式串pat的字符串,只要遍历模式串,判断当前字符:

  • 如果模式中含有通配符.需要递归调用所有的子树
  • 其他字符只需要处理指定字符的子树

这里的实现不考虑超过模式串长度的字符串,且并未考虑星号或其他的通配符

void Trie::collect_string_pat(trie_node_t *root,string pre,string pat,vector<string>& res) {
    if(root == NULL) return;
    auto d = pre.length();
    if(d == pat.length() && root->is_str == true) res.push_back(pre);
    if(d == pat.length()) return; //不作处理

    char cur = pat[d];//取当前字符
    /*若是通配符,所有的字符都要考虑;否则只需考虑对应字符*/
    if(cur == '.') {
        for(int i = 0;i < R;i++) {
            char c = ALPHA + i;
            collect_string_pat(root->next[i],pre + c,pat,res); 
        }
    } else {
        int index = cur - ALPHA;
        collect_string_pat(root->next[index],pre + cur,pat,res);
    }
}

vector<string> Trie::keys_that_match(string pat) {
    vector<string> res;
    collect_string_pat(root,"",pat,res);
    return res;
}

C 最长前缀匹配
要想找到给定字符串的前缀中最长的字符串,需要考虑清楚最长前缀匹配的可能情形。例如在字符串she,shells中分别查找 she,shell,shellsort,shelters中最长的前缀串,分别对应如下情况:

  • 被查找的字符串结束且该结点是字符串末尾
  • 被查找的字符串结束且该结点不是字符串末尾
  • 查找在空链接时结束(正常结束),返回之前最近的一个字符串
  • 查找在空链接时结束(字符不匹配时),返回之前最近的一个字符串

实现如下:

string Trie::longest_prefix_of(string word) {
    trie_node_t *cur = root;
    auto iter = word.begin();
    int len = 0;
    while(iter != word.end() && cur) {
        cur = cur->next[*iter - ALPHA];
        if(cur == NULL) break;
        if(cur->is_str == true) {
            len = iter - word.begin() + 1;
        }
        ++iter;
    }
    return word.substr(0,len);
}

1.4 Trie树的总结

完整的Trie树实现请查看Trie树的实现。 Trie树具有如下特点:

  • Trie树尽管使用了多重链接指针,占用一定的内存但实际上它借助了字符串的公共前缀已经大大降低了空间的开销,最大限度地减少了一些无谓的字符串比较。

  • 在最坏情况下插入和查找访问数组的次数最多为键的长度加1,并且查找未命中一般只需要检查很少的几个结点,与键的长度无关。这是字典树得到广泛应用的一个重要原因

  • 如果存在大量字符串或者超长的字符串(单向分支深度)或者这些字符串基本没有公共前缀,就不适合用Trie树。Trie树适用于较短的字符串和较小的字符集合

  • 假设有N个不重复的字符串(键),M表示字符串的平均长度,Trie树的空间复杂度为O(NM)。注还有个常系数R,缩小基数R能够节省大量的空间.

2 子字符串查找

子字符串查找的定义如下: 给定一段长度为N的文本和一个长度为M的模式串,在文本中找到第一个和该模式相符的字符串。

解决该问题的算法可以很容易地扩展为找出文本中所有和模式相符的子串、统计该模式在文本中的出现次数或者找出上下文(和该模式相符的子字符串周围的文字)的算法。子串匹配算法也常常应用于其他方面,如文本编辑器中的查找、在通信内容中寻找重要的模式、DNA序列中搜索重要的模式。

一般地,模式串相对于文本串是很短的(M可能为100或1000,N可能为100万或者10亿)。相比暴力搜索方法,为了支持在文本的快速查找,一般会对模式串进行预处理。下面简要介绍下子串查找的历史

  • 它有一个简单而使用广泛的暴力算法,尽管它在最坏情况下的运行时间与MN成正比,但处理一些较短的字符串时,运行时间还是比较合适的(不适合处理大规模的字符串)
  • 1976年,Knuth、Morris、Pratt先后独立的提出它们的算法,后来被称为KMP算法。
  • 1977年,R.S.Boyer和J.S.Moore教授发明了一种在许多应用程序中都非常快的算法,该算法只会检查文本串中的一部分字符。许多文本编辑器的查找功能,大多采用了Boyer-Moore算法,以降低字符串查找的响应时间。KMP算法和BM算法都需要对模式串进行复杂的预处理,也限制了它的应用范围
  • 1980年,M.O.Rabin和R.M.Karp基于散列开发出了一种与暴力算法几乎一样简单但运行时间与M+N成正比的概率极高的算法,另外此算法还可以扩展到二维的模式匹配中,这使得它与其他算法更适用于图像处理

2.1 暴力算法

暴力算法思路比较简单,使用两个指针: 指针i跟踪文本,指针j跟踪模式

A 实现1思路
i表示匹配串在文本中的可能位置为0-n-m。对于每个位置i,重置j为0并不断将它增大,直至找到一个不匹配的字符或者模式结束(j==m)为止。

/**
 * @param pat 模式串
 * @param txt 文本串
 * @return 在文本串返回第一次匹配模式串的位置
 * 匹配串在文本中的可能位置为0-n-m 
 * 对于每个位置i,重置j为0并不断将它增大
 * 直至找到一个不匹配的字符或者模式结束(j==m)为止
 */
public static int search1(String pat,String txt){
    int n=txt.length();
    int m=pat.length();
    for(int i=0;i<=n-m;i++){
        int j=0;
        while(j<m){
            if(pat.charAt(j)==txt.charAt(i+j))
                j++;
            else
                break;
        }
        if(j==m) return i;
    }
    return n;
}

B 实现2思路
指针i跟踪文本,指针j跟踪模式。当i和j指向的字符匹配时,同时继续下一个字符匹配;否则不匹配时,需要回退这两个指针值:j重新指向模式的开头,i回退到指向本次匹配开始位置的下一个字符i -=j;i++

/**
 * 指针i跟踪文本,指针j跟踪模式。当i和j指向的字符匹配时,同时右移一位
 * 否则不匹配时,需要回退这两个指针值:
 * j重新指向模式的开头,i回退到指向本次匹配开始位置的下一个字符(i -=j;i++)
 */
public static int search2(String pat,String txt){
    int n=txt.length();
    int m=pat.length();
    int i,j;
    for(i=0,j=0;i<n&&j<m;i++){
        if(txt.charAt(i)==pat.charAt(j)) j++;
        else {
            i -=j;
            j=0; 
        }
    }
    if(j==m) return i-m;
    else return n;
}   

采取如下的测试用例,后面的几个算法都将同样使用。如下:

public static void test(String pat,String txt){
    int offset1=search1(pat,txt);
    StdOut.println(txt);

    for(int i=0;i<offset1;i++)
        StdOut.print(" ");
    StdOut.println(pat);
}

public static void main(String[] args) {
    test("ababacb","abababaababacb");
    test("abracadabra","abacadabrabracabracadabrabrabracad");
    test("rab", "abacadabrabracabracadabrabrabracad");
    test("rabrabracad","abacadabrabracabracadabrabrabracad");
    test("bcara","abacadabrabracabracadabrabrabracad");
    test("abacad","abacadabrabracabracadabrabrabracad");
}

2.2 KMP算法

KMP算法的基本思想是当出现不匹配时,就能知晓一部分文本的内容(因为在匹配失败之前就已经和模式相匹配)。关键就在于判断如何重新开始查找,而且这种判断只取决于模式本身,保证在匹配失败时模式指针j能回退到某个值使模式前缀串与当前串串尾部分字符已经匹配,而文本指针i不用回退。如图下:

《算法和数据结构笔记(四) 字符串》

可以看到,KMP算法的特点就是模式串中头部和尾部有重复。如果模式串中无重复字符,KMP算法能处理但是不适合。它的实现有两种方式:

  • 基于确定有限状态自动机(DFA)的实现: 根据模式串构造出DFA,KMP的子串匹配算法就是一段模拟自动机运行的程序。空间复杂度为O(MR),R为字母表的基数,
  • 基于部分匹配表的实现:根据模式串构造出模式串中的每个字符应当回退的下标索引。空间复杂度为O(M)

2.2.1 基于有限自动机DFA的实现

有限自动机我们比较熟悉,基于模式串我们可以构造出处理不同长度的DFA,这里仅说明下DFA的表示(自动机画法比较容易)。定义基于模式串的有限自动机DFA如下:

  • DFA由状态(数字表示的圆圈)和转换(带标签的箭头)组成
  • 在子串查找的DFA转换中,当文本串字符与模式串字符匹配时,状态由j到j+1,其他的均是非匹配转换
  • 从文本的开头进行查找,起始状态为0,它停留在状态0扫描文本。自动机每次从做向右从文本中读取一个字符并转换到一个新的状态。如果到达了状态M ,表示找到一个完整匹配,否则文本中就不存在匹配该模式的字符串。

《算法和数据结构笔记(四) 字符串》

要点有两个:

A. 如何实现KMP查找算法

KMP算法并不回退文本指针i,而是使用一个自动机数组dfa[][],记录dfa[txt.chatAt[i]][j]

在比较了txt.chatAt[i]与pat.chatAt[j]之后应该和下一个文本字符txt.chatAt[i+1]比较的模式字符位置(即回退模式指针j到某个位置而不需回退文本指针i)。分析当前两字符的比较情形:

  • 如果匹配,继续比较下一个字符设置模式指针j为j+1(即dfa[pat.charAt[j][j]总为j+1)
  • 如果不匹配,通过回退指针j使得原串的尾部与新串的头部字符完全重叠,既保证了跳过了部分已匹配的字符,又无需回退指针i

dfa数组就是要构造出这样的回退值,并且dfa[txt.chatAt[i]][j]这个值就是重叠字符的数量,也就是从左向右滑动已匹配的j-1个字符直到所有重叠的字符都互相匹配或者没有相匹配的字符。如图为模式串”ABABAC”在某个文本串的查找轨迹:

《算法和数据结构笔记(四) 字符串》

B. 如何由长度为M的pattern串构造出DFA

要求就是对于每个可能匹配失败的位置都能预先找到重启DFA的可能状态。根据DFA的定义,如果找到一个和模式的首字母相同的字符,则跳转到下一个状态并等待下一个字符的判断,持续这个匹配过程直到自动机状态不断前进到状态M。对于每次匹配的情形:

  • 每次匹配成功都会将DFA带向下一个状态,等价于增加模式串指针j
  • 每次匹配失败都会使DFA回到较早前的状态(等价于将模式串的指针j变为一个较小的值)

总的说来就是: 正文指针i不断前进,一次一个字符,但索引j会在DFA的指导下在模式串中左右移动。因而重启的状态X就是当前的文本字符(第j列下)在原始状态X下的值,要么前进要么维持在X中甚至回退更早的状态(由自动机的图形表示可以看出)。如图为模式”ABABAC”的DFA构造过程:

《算法和数据结构笔记(四) 字符串》

Java代码实现如下:

public class KMP {

    private final int R; //字母表中的字符种类,奇数
    private int[][] dfa; //KMP的dfa数组
    private String pat; //模式串


    /**
     * 如何由长度为M的pattern构造出DFA
     * 如果找到一个和模式的首字母相同的字符,则跳转到下一个状态并等待下一个字符的判断
     * 持续这个匹配过程直到自动机状态不断前进到状态M
     * 每次匹配成功都会将DFA带向下一个状态,等价于增加模式串指针j
     * 每次匹配失败都会使DFA回到较早前的状态(等价于将模式串的指针j变为一个较小的值)
     * 重启的状态X就是当前的文本字符(第j列下)在原始状态X下的值
     * 要么前进要么维持在X中甚至回退更早的状态
     */
    public KMP(String pat) {
        this.R=256;
        this.pat=pat;
        
        /*从模式串中构造出DFA*/
        int M=pat.length();
        dfa=new int[R][M];
        dfa[pat.charAt(0)][0]=1;
        for(int X=0,j=1;j<M;j++){
            for(int c=0;c<R;c++)
                dfa[c][j]=dfa[c][X]; //复制匹配失败的情形
            dfa[pat.charAt(j)][j]=j+1; //设置匹配成功的为j+1
            X=dfa[pat.charAt(j)][X]; //更新重启状态
        }
    }
    
    /**
    * 在比较了txt.chatAt[i]与pat.chatAt[j]之后应该和下一个文本字符txt.chatAt[i+1]比较的模式字符位置
       * 如果匹配,继续比较下一个字符设置模式指针j为j+1(即dfa[pat.charAt[j][j]总为j+1)
       * 如果不匹配,通过回退指针j使得原串的尾部与新串的头部字符完全重叠
    * 既保证了跳过了部分已匹配的字符,又无需回退指针i
    */
    public int search(String txt){
        int N=txt.length();
        int M=pat.length();
        int i,j;
        for(i=0,j=0;i<N&&j<M;i++)
            j=dfa[txt.charAt(i)][j];
        if(j==M) return i-M;
        else return N;
    }

    public static void test(String pat,String txt){
        KMP kmp=new KMP(pat);
        int offset1=kmp.search(txt);
        StdOut.println(txt);    
        for(int i=0;i<offset1;i++)
            StdOut.print(" ");
        StdOut.println(pat);
    }

    public static void main(String[] args) {
        test("ababacb","abababaababacb");
        test("abracadabra","abacadabrabracabracadabrabrabracad");
        test("rab", "abacadabrabracabracadabrabrabracad");
        test("rabrabracad","abacadabrabracabracadabrabrabracad");
        test("bcara","abacadabrabracabracadabrabrabracad");
        test("abacad","abacadabrabracabracadabrabrabracad");

    }
}

2.2.2 基于部分匹配表(next数组)的实现

上面基于DFA的方法,需要引入字母表的参数R,空间复杂度与MR成正比,于是有一个改进版的算法,它基于部分匹配表的思路,仅仅花费O(M)的空间而不依赖字母表。

讲部分匹配表的方法(即构造next数组),最通俗易懂,图文并茂的是阮一峰写的字符串匹配的KMP算法。这里的实现借鉴了他的思路,并做了部分修改-如果找到前缀和后缀有相同的串,取最长串的末尾下标值为next值。

A 如何由模式串构造next数组

例如模式串ABCDABD不匹配目标串中一段序列ABCDABE...,可以看到:

  • 考虑到前六个字符ABCDAB是匹配的,且B的匹配值是2,因为模式串有前缀AB和它匹配则,所以文本串中后面的AB无需再次比较了
  • 所以模式串移动4位

因此思路就是以空间换取时间,提前计算出next数组,核心在于前缀串与当前串的后缀串字符匹配比较。

首先我们构造一个next数组,得到该模式串以每个字符结尾其前缀串和后缀串的最长匹配下标。也就是说我们判断的是pat[j+1](前缀字符)pat[i](当前字符)是否匹配。这其实是一个动态规划问题,即已知dp[0..i-1]求出dp[i],讨论如下:

  • 当pat[j+1] == pat[i]时匹配了dp[i]=dp[i-1]+1=++j;
  • 不相等呢回退指针看看有没有匹配的字符能使前缀串与当前串尾匹配或者到达不匹配的标志

无论怎样,更新当前串位置的next值(显然也可能存在前缀串与后缀串完全不匹配的情况此时对应的next[i]=-1,例如next[0]始终为-1,空串匹配,其他的诸如next[5]也为-1)。
例如模式串ababacb对应的next数组如下:

0123456
ababacb
-1-1012-1-1

解释思路:

  • next[0]=-1,next[1]=-1,无前缀串与后缀串匹配
  • next[2]=0: 因为前缀串pat[0]与后缀串pat[2]相同
  • next[3]=1: 因为前缀串pat[0..1]与后缀串pat[2..2]相同
  • next[4]=2: 因为前缀串pat[0..2]与后缀串pat[2..4]相同
  • next[5]=-1,next[6]=-1,无前缀串与后缀串匹配

代码如下:

void get_next(const char *pat,int *next,int n) {
    int j = -1;
    next[0] = -1;
    for(int i = 1; i < n;i++) {
        /**
             * 条件1的退出条件为j=-1,说明是空串,前后缀无匹配,必须结束;
             * 条件2表示只有当前缀串尾字符与后缀串尾字符相等时才更新next值
             * 否则要一直回退到合适的匹配位置,如果回退位置为j=-1就必须退出
             */
        while(j != -1 && pat[j + 1] != pat[i])  j = next[j]; //回退到匹配尾字符的位置
        if(pat[j + 1] == pat[i]) j++;
        next[i] = j; //更新当前位置的next值
    }
}

在文本串中的查找算法中,思路类似于next数组的构造: 即判断正文串当前字符是否于模式串的前缀串串尾字符是否匹配,根据匹配的情况进行合适的回退直到前缀串为整个串,已经完全匹配。

int kmp_strstr(const char *src,const char *pat) {
    int slen = my_strlen(src);
    int plen = my_strlen(pat);
    int *next = malloc(sizeof(int) * plen);
    get_next(pat,next,plen);

    int j = -1;
    for(int i = 0; i < slen;i++) {
        while(j > -1 && pat[j + 1] != src[i])  j = next[j];
        if(pat[j + 1] == src[i]) j++;
        if(j == plen - 1) { //表明模式串最后一个字符被匹配
            free(next);
            return i - j;
        }
    }
    free(next);
    return -1;
}

尽管KMP算法很高效,但在实际应用中,它比暴力算法的速度优势并不明显,因为极少有应用程序需要在重复性很高的文本中查找重复性很高的模式。不过该方法有一优点就是不需要在输入流中回退,适合在长度不确定的输入流中进行查找。下面就来学习一种利用回退获得巨大性能收益的算法。

2.3 BM算法

2.4 Robin-Karp指纹字符串查找算法

3 正则表达式实现

4 扩展结构: 后缀数组与AC自动机

5 应用

    原文作者:charlesxiong
    原文地址: https://www.cnblogs.com/xionghj/p/4342905.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞