另开一文分析字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树。

先来一个汇总,

算法:

本文中提到的字符串匹配算法有:KMP, BM, Horspool, Sunday, BF, KR, AC(其中用到了Trie树)

统计字符出现个数、获取KV内容:Trie树(字典树、前缀树)

回文子串长度算法有:Manacher’s Algorithm

题目:

最长回文子串

最长重复子串

最长不重复子串

以下为正文:

最长连续回文串(Longest Palindromic Substring)

里面用到了 DP 和 KMP .

KMP算法。其思想就如同我们上边所看到的一样:“利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让模式串尽量地移动到有效的位置。”

匹配失败时,j要移动的下一个位置k。存在着这样的性质:最前面的k个字符和j之前的最后k个字符是一样的

如果用数学公式来表示是这样的

P[0 ~ k-1] == P[j-k ~ j-1]

用一个数组next来保存,next[j] = k,表示当T[i] != P[j]时,j指针的下一个位置。

public static int[] getNext(String ps) {

    char[] p = ps.toCharArray();

    int[] next = new int[p.length];

    next[] = -;

    int j = ;

    int k = -;

    while (j < p.length - ) {

       if (k == - || p[j] == p[k]) {

           next[++j] = ++k;

       } else {

           k = next[k];

       }

    }

    return next;

}

上面的详细解释见:

http://www.cnblogs.com/yjiyjige/p/3263858.html

上面算法代码有一步,有一个问题,如下图,B和C已经不等,却又把B和C对比了一下。发生问题的原因在于P[j] == P[next[j]]。需要添加一个判断条件即可:

优化后的代码如下:

public static int[] getNext(String ps) {

    char[] p = ps.toCharArray();

    int[] next = new int[p.length];

    next[] = -;

    int j = ;

    int k = -;

    while (j < p.length - ) {

       if (k == - || p[j] == p[k]) {

           if (p[++j] == p[++k]) { // 当两个字符相等时要跳过

              next[j] = next[k];

           } else {

              next[j] = k;

           }

       } else {

           k = next[k];

       }

    }

    return next;

} 

以上为KMP。

如果文本串的长度为n,模式串的长度为m,那么匹配过程的时间复杂度为O(n),算上计算next的O(m)时间,KMP的整体时间复杂度为O(m + n)。

还有一种求回文子串最大长度的方法是:Manacher’s Algorithm的算法

解释可以看上面原文。

代码如下,很巧妙:

// Transform S into T.
// For example, S = "abba", T = "^#a#b#b#a#$".
// ^ and $ signs are sentinels appended to each end to avoid bounds checking
string preProcess(string s) {
int n = s.length();
if (n == ) return "^$";
string ret = "^";
for (int i = ; i < n; i++)
ret += "#" + s.substr(i, ); ret += "#$";
return ret;
} string longestPalindrome(string s) {
string T = preProcess(s);
int n = T.length();
int *P = new int[n];
int C = , R = ;
for (int i = ; i < n-; i++) {
int i_mirror = *C-i; // equals to i' = C - (i-C) P[i] = (R > i) ? min(R-i, P[i_mirror]) : ; // Attempt to expand palindrome centered at i
while (T[i + + P[i]] == T[i - - P[i]])
P[i]++; // If palindrome centered at i expand past R,
// adjust center based on expanded palindrome.
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
} // Find the maximum element in P.
int maxLen = ;
int centerIndex = ;
for (int i = ; i < n-; i++) {
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
delete[] P; return s.substr((centerIndex - - maxLen)/, maxLen);
}

另外字符串匹配还有一个sunday算法,很不错的。可以参考下文:

http://dsqiu.iteye.com/blog/1700312

其中先介绍了BM Boyer-Moore(BM)算法

字符串匹配的关键就是模式串的如何移动才是最高效的,Boyer-Moore为了做到这点定义了两个规则:坏字符规则和好后缀规则,下面图解给出定义:

模式串,先比后面,而不是比前面。

具体对于坏字符和好后缀的移动规则,可以参考上文。

算法步骤

1.对模式子串进行预处理,获得坏字符规则和好后缀规则移动的映射表

2.利用这两个规则,取大者。

模式表生成的代码见上文。比较的逻辑如下:

        skip_stride = skip[(unsigned char)buf[b_idx]];//根据坏字符规则计算跳跃的距离
shift_stride = shift[p_idx];//根据好后缀规则计算跳跃的距离
b_idx += (skip_stride > shift_stride) ? skip_stride : shift_stride;//取大者

不确定:最差(匹配不上)是O(n×m),平均 O(n),其中n为母串的长度,m为模式串的长度。BM算法时间复杂度最好是O(n/(m+1))

Horspool算法

Horspool算法相对于Boyer-Moore算法改进了坏字符规则。不看好后缀前不匹配的字符的位置,而是看母串匹配窗口最后一个字符在模式串最后一次的位置。

主串的长度为n,模式串的长度为m,那么Horspool算法最坏情况下的时间复杂度是O(mn),但平均情况下它的时间复杂度是O(n)。

Sunday算法

Sunday算法是Daniel M.Sunday于1990年提出的字符串模式匹配。其效率在匹配随机的字符串时比其他匹配算法还要更快。Sunday算法的实现可比KMP,BM的实现容易太多。(参考 Link)

在匹配失败时关注的是文本串中参加匹配的最末位字符的下一位字符。

如果该字符没有在匹配串中出现则直接跳过,即移动步长= 匹配串长度+1;否则,移动步长=匹配串中最右端的该字符到末尾的距离+1。

其实也是基于后缀数组的比较,因为关注的是字符从后往前看出现的位置。

直接上代码:

#include <iostream>
#include <cstring>
using namespace std; int sunday(const char* src, const char* des)
{
int len_s = strlen(src);
int len_d = strlen(des);
int next[26] = {0};
for (int j = 0; j < 26; ++j)
next[j] = len_d + 1;
for (int j = 0; j < len_d; ++j)
next[des[j] - 'a'] = len_d - j; //记录字符到最右段的最短距离+1
//例如:des = "abcedfb"
//next = {7 1 5 4 3 2 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8}
int pos = 0;
while (pos < (len_s - len_d + 1)) //末端对齐
{
int i = pos;
int j;
for (j = 0; j < len_d; ++j, ++i)
{
if (src[i] != des[j])
{
pos += next[src[pos + len_d] - 'a'];
//不等于就跳跃,跳跃是核心
break;
}
}
if ( j == len_d )
return pos;
}
return -1;
} int main()
{
char src[]="abcdacdaahfacabcdabcdeaa";
char des[]="abcde";
cout<<sunday(src,des)<<endl;
return 0;
}

原理与BM算法相仿(忽略好后缀规则),有点像其删减版,所以其时间复杂度和BM算法差不多,平均性能的时间复杂度也为O(n),最差情况的时间复杂度为O(n * m),但是要容易理解。

如上文所示,直接用字符作为key,每个key只记录Pattern中最右出现的位置,不随着Pattern规模增加而增加记录表的大小。(这也是为什么数据越大,BM/Sunday算法越高效的原因之一;KMP需要有跟Pattern长度匹配的next数组)。

Boyer-Moore、Horspool、Sunday算法小结

Boyer-Moore、Horspool、Sunday算法都是基于后缀数组的匹配算法,区别在于移动的方式不一样(好像网上有些都没有说的Boyer-Moore算法的好后缀规则,有可能是优化方法吧,没有去深究,抱歉)。下面给出三种方法的对比:

0 1 2 3 4 5 6 7 8 9 ...

a b c a b d a a c b a
b c a a b            
  b c a a b          
  0 1 2 3 4 5 6 7 8 9 ...

a b c a b d a a c b a
b c a a b            
        b c a a b    
  0 1 2 3 4 5 6 7 8 9 ...

a b c a b d a a c b a
b c a a b            
            b c a a b
         
(a)   Boyer-Moore   (b)   Horspool   (c)   Sunday
 

这里面也提到了性能:

http://blog.csdn.net/chong232/article/details/5806968

与经典的KMP算法相比,BM算法在很多情况下效率更高,它有两个特点:

一是它在遍历正文时的平均比较次数与pattern的长度反比,这一点很历害。

二是它在处理大字母表时候性能更佳。

从表面上看KMP算法号称O(m+n)的时间复杂度,BM最坏为O(m*n),但实际效率反而是BM更高(见上文参考3),这是由于实际情况时经常能达到BM的平均效率。所以在内核的iptables匹配时,选择bm算法的时候居多。

上文中对BM再做一下复习:

BM算法的核心是两个原则,这在下面的上文参考2中可以找到,具体原理它已经说的很清楚了。  
一。 坏字符原则
i. 如果字符x在模式P中没有出现,那么从字符x开始的m个文本显然不可能与P匹配成功,直接全部跳过该区域即可。
ii. 如果x在模式P中出现,则以该字符进行对齐。 二。 好后缀原则
i. 如果在P中位置t处已匹配部分P'在P中的某位置t'也出现,且位置t'的前一个字符与位置t的前一个字符不相同,则将P右移使t'对应t方才的所在的位置。
ii. 如果在P中任何位置已匹配部分P'都没有再出现,则找到与P'的后缀P''相同的P的最长前缀x,向右移动P,使x对应方才P''后缀所在的位置。 个人的一点理解
需要说明的是原则一是核心,原则二是对原则一的补充优化;换句话说,仅仅利用原则一就能在正文中完成匹配,但如果结合原则二可以很好地优化某些情况,从而提高效率。

这篇文章也讲了性能:http://blog.csdn.net/huangkuizuiniu/article/details/51971619

KMP算法和BM算法,这两个算法在最坏情况下均具有线性的查找时间。但实际上,KMP算法并不比最简单的c库函数strstr()快多少,而BM算法虽然通常比KMP算法快,但BM算法也还不是现有字符串查找算法中最快的算法,还有一种比BM算法更快的查找算法即Sunday算法。

回顾Sunday算法的整个过程,我们只移动了两次模式串就找到了匹配位置,缘于Sunday算法每一步的移动量都比较大,效率很高。 另外,Sunday算法,看起来用不到好后缀规则。

当然还有个BF算法,Bruce-Force,当然是最差的,复杂度O(m*n)。

还有个Karp-Rabin(KR)算法

Karp-Rabin算法是利用hash函数的特性进行字符串匹配的。 KR算法对模式串和循环中每一次要匹配的子串按一定的hash函数求值,如果hash值相同,才进一步比较这两个串是否真正相等。

Karp-Rabin算法适用于多个字符串匹配较好。

Aho-Corasick算法

Aho-Corasick算法又叫AC自动机算法,是一种多模式匹配算法。Aho-Corasick算法可以在目标串查找多个模式串,出现次数以及出现的位置。

下图是由多模式串{he,she,his,hers}构成的一个有限状态机:

1.该状态当字符匹配是按实线标注的状态进行转换,当所有实线路径都不满足(即下一个字符都不匹配时)按虚线状态进行转换。

2.对ushers匹配过程如下图所示:

Aho-Corasick算法和前面的算法一样都要对模式串进行预处理,预处理主要包括字典树Tire的构造,构建状态转移表(goto),失效函数(failure function),输出表(Output)。

Aho-Corasick算法包括以下3个步骤

1.构建字典树Tire

2.构建状态转移表,失效函数(failure function),输出表(Output)

3.搜索路径(进行匹配)

1. 构建字典树Tire

Tire是哈希树的变种,Tire树的边是模式串的字符,结点就是Tire的状态表,下图是多模式串{he,she,his,hers}的Tire树结构:

2. 构建goto函数、failure function和Output函数

goto函数(状态转移函数):goto(pre,v)=next,完成这样的任务:在当前状态pre,输入字符v,得到下一个状态next,如果没有下个状态则next=failure。

failure function:失效函数是处理当前状态是failure时的处理。

output函数:当完成匹配是根据状态输出匹配的模式串。

看代码如下,其中也有Trie树的构造。对于goto函数的构造,非常精妙。利用了队列,每个节点的fail指针其实也包括了goto,构造依赖了parent节点的fail指针的构造,再加上字符[i]来进行。对于找不到的字符,因为Trie树里面直接就是Null。统一了处理。

////////////////////////////////////////////////////
/*
程序说明:多模式串匹配的AC自动机算法
自动机算法可以参考《柔性字符串匹配》里的相应章节,讲的很清楚
*/
#include <stdio.h>
#include <string.h> const int MAXQ = 500000+10;
const int MAXN = 1000000+10;
const int MAXK = 26; //自动机里字符集的大小
struct TrieNode
{
TrieNode* fail;
TrieNode* next[MAXK];
bool danger; //该节点是否为某模式串的终结点
int cnt; //以该节点为终结点的模式串个数
TrieNode()
{
fail = NULL;
memset(next, NULL, sizeof(next));
danger = false;
cnt = 0;
}
}*que[MAXQ], *root;
//文本字符串
char msg[MAXN];
int N;
void TrieInsert(char *s)
{
int i = 0;
TrieNode *ptr = root;
while(s[i])
{
int idx = s[i]-'a';
if(ptr->next[idx] == NULL)
ptr->next[idx] = new TrieNode();
ptr = ptr->next[idx];
i++;
}
ptr->danger = true; // 单词结束的地方标记此处可以构成一个单词  
ptr->cnt++; // 这行代码用于统计每个单词前缀出现的次数(也包括统计每个单词出现的次数) 
} void Init()
{
int i;
char s[100];
root = new TrieNode();
scanf("%d", &N);
for(i = 0; i < N; i++)
{
scanf("%s", s);
TrieInsert(s);
}
} void Build_AC_Automation()
{
int rear = 1, front = 0, i;
que[0] = root;
root->fail = NULL;
while(rear != front)
{
TrieNode *cur = que[front++];
for(i = 0; i < 26; i++)
if(cur->next[i] != NULL)
{
if(cur == root)
cur->next[i]->fail = root;
else
{
TrieNode *ptr = cur->fail;
while(ptr != NULL)
{
if(ptr->next[i] != NULL)
{
cur->next[i]->fail = ptr->next[i];
if(ptr->next[i]->danger == true)
cur->next[i]->danger = true;
break;
}
ptr = ptr->fail;
}
if(ptr == NULL) cur->next[i]->fail = root;
}
que[rear++] = cur->next[i];
}
}
}
int AC_Search()
{
int i = 0, ans = 0;
TrieNode *ptr = root;
while(msg[i])
{
int idx = msg[i]-'a';
while(ptr->next[idx] == NULL && ptr != root) ptr = ptr->fail;
ptr = ptr->next[idx];
if(ptr == NULL) ptr = root;
TrieNode *tmp = ptr;
while(tmp != NULL && tmp->cnt != -1)
{
ans += tmp->cnt;
tmp->cnt = -1;
tmp = tmp->fail;
}
i++;
}
return ans;
}
int main()
{
int T;
scanf("%d", &T);
while(T--)
{
Init();
Build_AC_Automation();
//文本
scanf("%s", msg);
printf("%d\n", AC_Search());
}
return 0;
}

小结

这篇文章把字符串匹配的六个算法——BM、Horspool、Sunday、KMP、KR、AC算法,从原理到步骤,再从流程到实现都做了讲解了,能有了一定的认识和理解,基本可以掌握这些算法。

Trie树也叫前缀树。除了上面的代码构造,也可以参考这篇文章,关于其应用方法:

http://blog.csdn.net/hackbuteer1/article/details/7964147

直接上代码,其中还用到了Trans,作为字典value的记录,用于结果返回输出的:

下面的应用是获取字符串出现次数:

#include<iostream>
#include<cstring>
using namespace std; typedef struct Trie_node
{
int count; // 统计单词前缀出现的次数
struct Trie_node* next[26]; // 指向各个子树的指针
bool exist; // 标记该结点处是否构成单词
}TrieNode , *Trie; TrieNode* createTrieNode()
{
TrieNode* node = (TrieNode *)malloc(sizeof(TrieNode));
node->count = 0;
node->exist = false;
memset(node->next , 0 , sizeof(node->next)); // 初始化为空指针
return node;
} void Trie_insert(Trie root, char* word)
{
Trie node = root;
char *p = word;
int id;
while( *p )
{
id = *p - 'a';
if(node->next[id] == NULL)
{
node->next[id] = createTrieNode();
}
node = node->next[id]; // 每插入一步,相当于有一个新串经过,指针向下移动
++p;
node->count += 1; // 这行代码用于统计每个单词前缀出现的次数(也包括统计每个单词出现的次数)
}
node->exist = true; // 单词结束的地方标记此处可以构成一个单词
} int Trie_search(Trie root, char* word)
{
Trie node = root;
char *p = word;
int id;
while( *p )
{
id = *p - 'a';
node = node->next[id];
++p;
if(node == NULL)
return 0;
}
return node->count;
} int main(void)
{
Trie root = createTrieNode(); // 初始化字典树的根节点
char str[12] ;
bool flag = false;
while(gets(str))
{
if(flag)
printf("%d\n",Trie_search(root , str));
else
{
if(strlen(str) != 0)
{
Trie_insert(root , str);
}
else
flag = true;
}
} return 0;
}

下面的应用是字典树的KV(翻译)查找:

#include<iostream>
#include<cstring>
using namespace std; typedef struct Trie_node
{
int count; // 统计单词前缀出现的次数
struct Trie_node* next[26]; // 指向各个子树的指针
bool exist; // 标记该结点处是否构成单词
char trans[11]; // 翻译
}TrieNode , *Trie; TrieNode* createTrieNode()
{
TrieNode* node = (TrieNode *)malloc(sizeof(TrieNode));
node->count = 0;
node->exist = false;
memset(node->next , 0 , sizeof(node->next)); // 初始化为空指针
return node;
} void Trie_insert(Trie root, char* word , char* trans)
{
Trie node = root;
char *p = word;
int id;
while( *p )
{
id = *p - 'a';
if(node->next[id] == NULL)
{
node->next[id] = createTrieNode();
}
node = node->next[id]; // 每插入一步,相当于有一个新串经过,指针向下移动
++p;
node->count += 1; // 这行代码用于统计每个单词前缀出现的次数(也包括统计每个单词出现的次数)
}
node->exist = true; // 单词结束的地方标记此处可以构成一个单词
strcpy(node->trans , trans);
} char* Trie_search(Trie root, char* word)
{
Trie node = root;
char *p = word;
int id;
while( *p )
{
id = *p - 'a';
node = node->next[id];
++p;
if(node == NULL)
return 0;
}
if(node->exist) // 查找成功
return node->trans;
else // 查找失败
return NULL;
} int main(void)
{
Trie root = createTrieNode(); // 初始化字典树的根节点
char str1[3003] , str2[3003] , str[3003] , *p;
int i , k; scanf("%s",str1);
while(scanf("%s",str1) && strcmp(str1 , "END") != 0)
{
scanf("%s",str2);
Trie_insert(root , str2 , str1);
} getchar();
gets(str1);
k = 0;
while(gets(str1))
{
if(strcmp(str1 , "END") == 0)
break;
for(i = 0 ; str1[i] != '\0' ; ++i)
{
if(str1[i] >= 'a' && str1[i] <= 'z')
{
str[k++] = str1[i];
}
else
{
str[k] = '\0';
p = Trie_search(root , str);
if(p)
printf("%s", p);
else
printf("%s", str);
k = 0;
printf("%c", str1[i]);
}
}
printf("\n");
} return 0;
}

从上可以看出,Trie树(字典树、前缀树),可以用于统计出现次数,获取字典key-value内容等。

(完)

【Todo】字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树的更多相关文章

  1. 字符串 --- KMP Eentend-Kmp 自动机 trie图 trie树 后缀树 后缀数组

    涉及到字符串的问题,无外乎这样一些算法和数据结构:自动机 KMP算法 Extend-KMP 后缀树 后缀数组 trie树 trie图及其应用.当然这些都是比较高级的数据结构和算法,而这里面最常用和最熟 ...

  2. Python 细聊从暴力(BF)字符串匹配算法到 KMP 算法之间的精妙变化

    1. 字符串匹配算法 所谓字符串匹配算法,简单地说就是在一个目标字符串中查找是否存在另一个模式字符串.如在字符串 "ABCDEFG" 中查找是否存在 "EF" ...

  3. [算法]从Trie树(字典树)谈到后缀树

    我是好文章的搬运工,原文来自博客园,博主July_,地址:http://www.cnblogs.com/v-July-v/archive/2011/10/22/2316412.html 从Trie树( ...

  4. 洛谷P5284 [十二省联考2019]字符串问题 [后缀树]

    传送门 思路 设\(dp_i\)表示以\(i\)结尾的\(A\)串,能达到的最长长度. 然后发现这显然可以\(i\)往自己控制的\(k\)连边,\(k\)往能匹配的\(j\)连边,就是个最长路,只要建 ...

  5. Java数据结构和算法总结-字符串相关高频面试题算法

    前言:周末闲来无事,看了看字符串相关算法的讲解视频,收货颇丰,跟着视频讲解简单做了一下笔记,方便以后翻阅复习同时也很乐意分享给大家.什么字符串在算法中有多重要之类的大路边上的客套话就不多说了,直接上笔 ...

  6. 【数据结构】 字符串&KMP子串匹配算法

    字符串 作为人机交互的途径,程序或多或少地肯定要需要处理文字信息.如何在计算机中抽象人类语言的信息就成为一个问题.字符串便是这个问题的答案.虽然从形式上来说,字符串可以算是线性表的一种,其数据储存区存 ...

  7. python字符串、字符串处理函数及字符串相关操作

    python字符串.字符串处理函数及字符串相关操作 字符串介绍 python字符串表示 Python除处理数字外还可以处理字符串,字符串用单撇号或双撇号包裹: >>> 'spam e ...

  8. 常用linux 命令 -字符串相关

    参考网络文章,个人工作总结 题记:一般对字符串的操作有以下几种:求长度,截取字符串,拼接字符串,找字符串中某个字符的索引 1 expr 命令 1.1 定义 man 手册 Print the value ...

  9. 字符串模式匹配之KMP算法图解与 next 数组原理和实现方案

    之前说到,朴素的匹配,每趟比较,都要回溯主串的指针,费事.则 KMP 就是对朴素匹配的一种改进.正好复习一下. KMP 算法其改进思想在于: 每当一趟匹配过程中出现字符比较不相等时,不需要回溯主串的 ...

随机推荐

  1. windows phone MVVM开发心得第一天

    之前刚刚学了asp.net网站的三层架构,为其中的优点着迷,可惜寒假本来决定学下MVC的计划泡汤了,刚开学,学了下windows phone 的MVVM模式的开发,在此留下点心得和脚印,第一天只是学了 ...

  2. mysql存储过程和事件

    1.会员表member和车辆表car,更新每个会员下面的车辆数量have_car字段. DELIMITER $$ USE $$ DROP PROCEDURE IF EXISTS `sp_update_ ...

  3. iOS导航栏-导航栏透明

    设置一张透明图片:nav_bargound.png  //导航栏背景     [self.navigationController.navigationBar setBackgroundImage:[ ...

  4. DF学Mysql(一)——数据库基本操作

    1.创建数据库 create Database <数据库名>; 注意:1)数据库名由字母.下划线.@.#和$组成 2)首字母不能是数字和$符号 3)不允许有空格和特殊字符 2.查看数据库 ...

  5. 《Thinking in C++》学习笔记(二)【第三章】

    第三章 C++中的C 3.4.4 指针简介 ‘&’运算符:只要在标识符前加上‘&’,就会得出标识符的地址. C和C++有一个专门存放地址的变量类型.这个变量类型叫做指针(pointer ...

  6. VS2010 Notes

    1.fatal error LNK1123: 转换到 COFF 期间失败: 文件无效或损坏 VS2010在经历一些更新后,建立Win32 Console Project时会出“error LNK112 ...

  7. DevExpress GridView 自定义搜索按钮改为中文内容

    首先将 GridControl 控件的搜索功能显示出来. http://www.cnblogs.com/DeepLearing/p/3887601.html 显示效果如下: 可以通过 GridLoca ...

  8. **关于PHP如何定义一个空对象(REST API如何处理空对象和空数组)

    在写接口的过程当中,手机端有需求说不让返回json数组,要返回一个对象. 那么我们可以怎么做呢? 其实很简单,强制转换即可. 声明空对象: $empty_object=(object)array(); ...

  9. Microsoft SDK 中Sample案例之Amcap項目 的运行方法(转)

    http://blog.csdn.net/erick08/article/details/7194575 Microsoft  SDK 中Sample之Amcap 的运行方法      写这篇文章的由 ...

  10. UVA 557 Burger 排列组合递推

    When Mr. and Mrs. Clinton's twin sons Ben and Bill had their tenth birthday, the party was held at t ...