【题目描述】
长度为n的环状串有n种表示法,分别为从某个位置开始顺时针得到。例如,图3-4的环状串有10种表示:
CGAGTCAGCT,GAGTCAGCTC,AGTCAGCTCG等。在这些表示法中,字典序最小的称为"最小表示"。
输入一个长度为n(n≤100)的环状DNA串(只包含A、C、G、T这4种字符)的一种表示法,你的任务是输出该环状串的最小表示。例如,CTCC的最小表示是CCCT,CGAGTCAGCT的最小表示为AGCTCGAGTC。
【样例输入】
2
CGAGTCAGCT
CTCC
【样例输出】
AGCTCGAGTC
CCCT
【题目来源】
刘汝佳《算法竞赛入门经典 第2版》 例题3-6 环状序列(Circular Sequence, ACM/ICPC Seoul 2004, UVa1584)
【解析】
一、原书代码:
1 .字典序的概念
字典序,就是像英文字典中的顺序一样对字母排序。一般地,对于两个字符串,从第一个字符开始比较,当某一个位置的字符不同时,该位置字符较小的串,字典序较小(例如,abc比bcd小);如果其中一个字符串已经没有更多字符,但另一个字符串还没结束,则较短的字符串的字典序较小(例如,hi比history小)。
字典序的概念可以推广到任意序列,例如,序列1, 2, 4, 7比1, 2, 5小。
2 .问题解决思路
解决问题的思路是怎么来的?把这个问题浓缩成一句话,就是按字典序排序环状串。这里面有两个关键词:字典序、环状串,它们一个是算法,一个是数据结构。
环状串的核心词是"串",本质上还是字符串,只不过是环形的,因此比较的对象是字符串。把"字符串比较"这个问题简化到极致,最少要有两个字符串才能比较,所以先考虑怎么比较两个普通字符串。再由简至繁去思考:比较两个普通字符串→比较两个环状字符串→比较多个环状字符串。普通串到环状串的处理很简单,和钟由12点到1点的处理方法一样,对12取余即可。由两个字符串到多个字符串就更简单了,一个循环遍历搞定。
字典序的核心词是"序",本质上也是排序问题,排序问题都需要遍历(一一比较),从这个思路出发,就很容易想到用遍历的方式进行字典序排序。只不过本题只是找出最小字典序,是排序的简化版,其算法本质上就像"求n个元素中的最小值"一样,只要拿其中1个与其它每个元素进行比较,一直取其中的最小值即可。不同的是,字典序比较的不再只是一个值,而是依次比较每一位(相同时继续往下比较,不同时字符较小的字典序较小)。可用变量ans表示目前为止,字典序最小串在输入串中的起始位置,然后不断更新ans。
代码如下:
cpp
#include<stdio.h>
#include<string.h>
#define maxn 105
//环状串s的表示法p是否比表示法q的字典序小
int less(const char* s, int p, int q) {
int n = strlen(s);
for(int i = 0; i < n; i++)
if(s[(p+i)%n] != s[(q+i)%n])
return s[(p+i)%n] < s[(q+i)%n];
return 0; //相等
}
int main() {
int T;
char s[maxn];
scanf("%d", &T);
while(T--) {
scanf("%s", s);
int ans = 0;
int n = strlen(s);
for(int i = 1; i < n; i++)
if(less(s, i, ans)) ans = i;
for(int i = 0; i < n; i++)
putchar(s[(i+ans)%n]);
putchar('\n');
}
return 0;
}
二、老金代码
说实话,这道题着实把老金难住了,解题过程思路混乱,折腾了一小天才总算写出了代码。
老金也明白解决问题的关键是找到字典序最小的环状串在输入串中的起始位置。
但是老金在考虑算法时深深陷入人的思维不能自拔。如果让我们人去解决这个问题,都会想当然地首先去找到字符串中最小的字母,然后只需依次比较其后面的每一位就可以了。
拿输入样例为例,CGAGTCAGCT的比较过程:
|------|------|-----|-----|
| 最小位置 | 最小字母 | 后1位 | 后2位 |
| 3 | A | G | T |
| 7 | A | G | C |
CTCC的比较过程:
|------|------|-----|-----|
| 最小位置 | 最小字母 | 后1位 | 后2位 |
| 1 | C | T | |
| 3 | C | C | C |
| 4 | C | C | T |
这种方法能够逐步缩小比较范围,最终找到只有1个最小字母时,就是最小的字典序了。
这其实是一种走捷径的思维。因为人的计算能力远低于电脑,所以会优先选择计算量少的方法,因而自然不会傻到逐一比较以每位字母开始的环状串的字典序。
但正是基于这个人走捷径的思维,才导致老金算法实现的复杂。
①找最小字母。让人找一个字符串中的所有最小字母,扫一眼就能找出,但让电脑找就不那么简单了。老金想到的方法是先拿最小的字符A与串中的每一位比较,如果找到相等的就说明是最小字母,如果没找到就再依次拿C、G、T继续找。
②存储首个最小字母下标。要存储最开始找到的所有最小字母的下标,这就需要用到数组min[]。
③依次判断首字母后面的字母。还得再一次用第①步找最小字母的方法,因为找的位置与①不同,还要单独写代码。
④实时去除非最小字典序的环状串。还有一点更麻烦的,每比较一次,都要从min[]数组中去掉不再属于最小字典序的下标。老金采取的方法是每次都对min[]数组的元素值和元素个数重新赋值。
可见,老金的算法的问题就是算法的种类多,要考虑的事情也就越大、自然需要写的代码量就大。相比之下,原书中的算法是通过遍历比较字典序,算法只有一种,自然代码量就小。
cpp
#include<stdio.h>
#include<string.h>
char dna[105];
int min[105]; //dna中最小字母的下标
int main(){
char c[4]={'A', 'C', 'G', 'T'};
int n;
scanf("%d", &n);
while(n--){
int y=0, index;
memset(dna, 0, sizeof(dna));
scanf("%s", dna);
int len = strlen(dna);
//为min数组赋值
int flag=1, x=0;
for(int i=0; i<4 && flag; i++){
for(int j=0; j<len; j++){
if(c[i]==dna[j]) {
flag=0;
min[x++]=index=j;
}
}
}
//如果找到多个最小字母,依次判断其后面的最小字母的数量,直到其数量为1
if(1!=x){
for(int y=1; y<len; y++){
int flag=1, cnt=0;
for(int i=0; i<4 && flag; i++){
for(int j=0; j<x; j++){
if(c[i]==dna[(min[j]+y)%len]) {
flag=0;
index=min[j];
min[cnt]=min[j];
cnt++;
}
}
if(cnt) x=cnt;
}
if(1==cnt) break;
}
}
for(int k=index; k<len+index; k++) printf("%c", dna[k%len]);
printf("\n");
}
return 0;
}