本文介绍了BWT算法。
bwa是目前最流行的二代测序比对工具,其中就用到了BWT算法。BWT(Burrows-Wheeler Transform)算法是一种数据转换算法,它将一个字符串中的相似字符放在相邻的位置,以便于后续的压缩。
简要回顾
BWT算法可以分为编码和解码两部分。编码后,原始字符串中的相似字符会处在比较相邻的位置;解码就是将编码后的字符串重新恢复成原始字符串的过程。BWT的一个特点就是经过编码后的字符串可以完全恢复成原始字符串。
BWT编码分为以下几步:
- 输入一个字符串sss,假设其中所有字符都介于a-z\text{a-z}a-z之间。
- 在sss的末尾加上一个标记字符,该字符要比sss中的所有字符都要小。比如$\text{\textdollar}$字符。这样将末尾加上标记的新字符串记为s′s^{'}s′。
- 重复地将s′s^{'}s′中的最后一个字符转移到开头,每转移一次就得到一个新的字符串。
- 将上一步得到的所有新字符串从小到大排序,排序后的字符串数组记为MMM。
- MMM中每个字符串的第一个字符构成FFF列,MMM中每个字符串的最后一个字符构成LLL列。
- 输出LLL列。

BWT解码分为以下几步:
(至于为什么这样处理就可以恢复原始字符串,下文会说明。)
- 输入LLL列。
- 对LLL列进行从小到大排序得到FFF列。
- LLL列的第一个字符是原始字符串的最后一个字符。
- 根据LLL列的字符LiL_iLi找到FFF列中的相同字符FjF_jFj,然后得到FjF_jFj所在行的最后一个字符LjL_jLj。将LjL_jLj记录下来。
- 重复上面一步,直到FjF_jFj等于标记字符为止。
- 按照上述步骤找到的各个LjL_jLj进行反向排列,得到字符串rrr。
- 输出字符串rrr。
用图示表示就是:
Step1:输入LLL列;对LLL列进行从小到大排序得到FFF列。

Step2:循环解码。
根据LLL列的字符LiL_iLi找到FFF列中的相同字符FjF_jFj,然后得到FjF_jFj所在行的最后一个字符LjL_jLj。将LjL_jLj记录下来;重复上面一步,直到FjF_jFj等于标记字符为止;按照上述步骤找到的各个LjL_jLj进行反向排列,得到字符串rrr。
循环解码的具体步骤如下:
Step2-1:
LLL列的第一个字符ccc是原始字符串的最后一个字符。

Step2-2:
根据LLL列中第一行L1L_1L1的ccc这个字符找到FFF列中同样字符所在的行。由于FFF列中只有一个ccc,所以就是最后一行,对应的F6F_6F6。
根据F6F_6F6找到同一行的L6L_6L6,即字符bbb。

Step2-3:
根据L6L_6L6的bbb这个字符找到FFF列中同样字符所在的行。由于FFF列中有两个bbb,那选择哪一个呢?
一般地,如果Li(i=1,2,…,n)L_i (i=1,2,\ldots,n)Li(i=1,2,…,n)这个字符在FFF列中出现过多次,分别是Fj,Fj+1,…F_j, F_{j+1}, \ldotsFj,Fj+1,…;并且假设L1L_1L1到LiL_iLi里这iii个字符中一共有kkk个(k≤ik \le ik≤i)字符等于LiL_iLi这个字符,那么我们要为LiL_iLi在FFF列中找的对应的字符就是Fj+k−1F_{j+k-1}Fj+k−1。
比如对应到这一步,i=6i=6i=6,L6L_6L6是字符bbb,而FFF列中共有222个bbb:F4F_4F4和F5F_5F5,L6L_6L6对应哪一个呢?我们看到L1L_1L1到L6L_6L6中共有222个bbb,按照上面说的,选择F4+2−1F_{4+2-1}F4+2−1,即F5F_5F5。
根据F5F_5F5找到同一行的L5L_5L5,即字符aaa。

Step2-4:
根据L5L_5L5的aaa这个字符找到FFF列中同样字符所在的行。由于FFF列中有两个aaa,那选择哪一个呢?
同样地,FFF列中共有222个aaa:F2F_2F2和F3F_3F3;而L1L_1L1到L5L_5L5中共有222个aaa,按照上面说的,选择F2+2−1F_{2+2-1}F2+2−1,即F3F_3F3。
根据F3F_3F3找到同一行的L3L_3L3,即字符bbb。

Step2-5:
根据L3L_3L3的bbb这个字符找到FFF列中同样字符所在的行。FFF列中共有222个bbb:F4F_4F4和F5F_5F5;而L1L_1L1到L3L_3L3中共有111个bbb,按照上面说的,选择F4+1−1F_{4+1-1}F4+1−1,即F4F_4F4。
根据F4F_4F4找到同一行的L4L_4L4,即字符aaa。

Step2-6:
根据L4L_4L4的aaa这个字符找到FFF列中同样字符所在的行。FFF列中共有222个aaa:F2F_2F2和F3F_3F3;而L1L_1L1到L4L_4L4中共有111个aaa,按照上面说的,选择F2+1−1F_{2+1-1}F2+1−1,即F2F_2F2。
根据F2F_2F2找到同一行的L2L_2L2,即字符$\text{\textdollar}$。至此,解码结束。

Step3:输出原始字符串。

关键步骤
现在我们来说明为什么按照上述解码过程就可以恢复原始字符串。关键就是要解答两个问题:
- 为什么要在FFF列中找和LiL_iLi相同的字符?
- 如果FFF列中有多个字符和LiL_iLi相同,怎么办?
问题一:为什么要在FFF列中找和LiL_iLi相同的字符?
我们重新看BWT编码中的“循环转移”这一步。我们将某一行字符串的Latter String定义为其在“循环转移”这一步中的下一行字符串;而将某一行字符串的Former String定义为其在“循环转移”这一步中的上一行字符串。

我们可以看出,某一行字符串的最后一个字符是其Latter String的第一个字符;某一行字符串的最后一个字符和其Latter String的最后一个字符的关系是:在原始字符串中,上述两个字符紧挨在一起并且Latter String的最后一个字符排在前面。
比如,根据定义,在“循环转移”这一步中,第一行的 Latter String是第二行;在MMM数组中,第一行的 Latter String是第六行。在“循环转移”这一步中,第一行字符串的最后一个字符是ccc,第一行字符串的 Latter String(也就是第二行字符串)的最后一个字符是bbb,二者的关系是:在原始字符串中,bbb排在ccc的前面一位。
现在我们可以回答问题了:我们在FFF列中找和LiL_iLi相同的字符,就可以找到LiL_iLi所在行的字符串的Latter String。如上所述,在原始字符串中,Latter String的最后一个字符排在LiL_iLi前面。
比如,L1L_1L1和F6F_6F6相同,那么L1L_1L1所在的行(也就是MMM数组的第一行)的 Latter String 就是F6F_6F6所在的行(也就是MMM数组的第六行)。
并且,我们让Latter String的最后一个字符成为新的Li’L_{i^’}Li’,不断重复这个过程,就可以恢复原始字符串。

问题二:如果FFF列中有多个字符和LiL_iLi相同,怎么办?
首先我们要说明一个规律:MMM数组中,以某字符结尾的一个字符串在以该字符结尾的所有字符串中的相对顺序和该字符串的Latter String在以该字符串开头的所有字符串中的相对顺序是一样的。
比如, MMM数组中,以字符bbb结尾的字符串共有两行,第三行和第六行,第六行的相对顺序是222。而以字符bbb开头的字符串也是两行,第四行和第五行,第五行的相对顺序是222。而第五行正是第六行的 Latter String,二者的相对顺序是一样的。

因此,在MMM数组中,假设L1L_1L1到LiL_iLi这iii个字符中一共有kkk个(k≤ik \le ik≤i)字符等于LiL_iLi这个字符,那么LiL_iLi所在行的字符串在以LiL_iLi这个字符结尾的所有字符串中的相对顺序就是kkk,从而LiL_iLi所在行的字符串的Latter String在以LiL_iLi这个字符开头的所有字符串中的相对顺序也是kkk。假设LiL_iLi这个字符在FFF列中出现过多次,分别是Fj,Fj+1,…F_j, F_{j+1}, \ldotsFj,Fj+1,…,那么以LiL_iLi这个字符开头的所有字符串所在的行就是第j,j+1,…j,j+1,\ldotsj,j+1,…行;其中相对顺序为kkk的行是j+k−1j+k-1j+k−1行。也就是说Latter String所在的行是第j+k−1j+k-1j+k−1行。

红色方框为所有以b开头的字符串;绿色方块为所有以b结尾的字符串。黑色箭头指向的是对应的Latter String。
比如,在MMM数组中,L6=bL_6=bL6=b,L1L_1L1到L6L_6L6共有222个字符是bbb,所以L6L_6L6所在的第六行字符串在所有以bbb结尾的字符串中的相对顺序是222。从而其 Latter String在所有以bbb开头的字符串中的相对顺序也是222,也就是第555行。
综上所述,LiL_iLi所在行的字符串的Latter String是第j+k−1j+k-1j+k−1行。也就是说,当FFF列中有多个字符和LiL_iLi相同时,我们可以通过计算从L1L_1L1到LiL_iLi共有几个字符和LiL_iLi相同从而知道Latter String是第几行。
实现代码一
代码的实现有几点要说明:
-
在编码过程中,“循环转移”这一步中会产生nnn个新字符串,对这些字符串进行排序得到MMM数组。为了存储“循环转移”这一步中产生的各个新字符串,空间利用为O(n2)O(n^2)O(n2)。更具体地,需要占用n2n^2n2个字节,但是如果我们只存储每个新字符串的一部分,即开头字符到标记字符的那一部分,总共可以节省约一半的空间而不影响后续的排序。
-
解码的实现有两种方法,都是为了解决如何根据LiL_iLi找到同字符的FjF_jFj。本小节的方法是构建一个“跳转数组”,这个方法的巧妙之处是预先在
线性时间内计算出每个Li(i=1,2,…,n)L_i(i=1,2,\ldots,n)Li(i=1,2,…,n)之前有几个字符和它相同,从而减少根据LiL_iLi查找FjF_jFj所需的总的计算量。该方法的缺点是“跳转数组”需要额外的空间开销。
效果如下:

具体的实现代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAXSTR 1000
#define MARKER '$'
#define NUM_ALPHA 26
int comp(const void *s, const void *t) {
return strcmp(*(char**)s, *(char**) t); /* 注意这里 *(char**) 的用法 */
}
/* the last char of s is not MARKER */
char* bwtEncode(char *s, const int n) {
char *L;
char **M;
int i, j, l;
if ((M = (char**) malloc(sizeof(char*) * n)) == NULL || \
(L = (char*) malloc(sizeof(char) * (n + 2))) == NULL) {
fputs("Error: out of space!\n", stderr);
exit(1);
}
for (i = 0; i < n; i++) {
if ((M[i] = (char*) malloc(sizeof(char) * (i + 2))) == NULL) { /* 只需保存开头到标记字符的那一部分字符串,这样总共可以节省大约一半的空间 */
fputs("Error: out of space!\n", stderr);
exit(1);
}
for (j = 0; j < i + 1; j++)
M[i][j] = s[n - 1 - i + j]; /* 这里的字符串没有存储 MARKER */
M[i][i + 1] = '\0';
}
qsort(M, n, sizeof(M[0]), comp); /* 对旋转后的多个字符串排序 */
for (i = 0, L[0] = s[n - 1]; i < n; i++) {
if ((l = strlen(M[i])) < n)
L[i + 1] = s[n - 1 - l];
else
L[i + 1] = MARKER;
}
L[n + 1] = '\0';
for (i = 0; i < n; i++)
free(M[i]);
free(M);
return L;
}
char* bwtDecode(char *L, const int n) {
int i;
int *a, *b;
char *r; /* original string. */
int pos;
if ((a = (int*) calloc(NUM_ALPHA + 1, sizeof(int))) == NULL || \
(b = (int*) calloc(n, sizeof(int))) == NULL || \
(r = (char*) malloc(sizeof(char) * (n + 1))) == NULL) {
fputs("Error: out of space!\n", stderr);
exit(1);
}
for (i = 0; i < n; i++) { /* L列中每种字符的个数 */
if (L[i] == MARKER)
a[0]++;
else
a[L[i] - 'a' + 1]++;
}
for (i = 1; i < NUM_ALPHA + 1; i++) { /* F列中排在每种字符前面的其他字符的个数 */
a[i] += a[i - 1];
}
for (i = 0; i < n; i++) { /* L列中每个字符跳转到F列中的位置 */
if (L[i] == MARKER)
b[i] = 0;
else
b[i] = a[L[i] - 'a']++;
}
for (i = 0, pos = 0; i < n; i++) {
r[n - 1 - i] = L[pos];
pos = b[pos];
}
r[n] = '\0';
free(a);
free(b);
return r;
}
int main(void) {
char s[MAXSTR];
char *L;
int n;
char *r;
printf("input str: ");
fgets(s, MAXSTR - 1, stdin);
n = strlen(s);
if (s[n - 1] == '\n')
s[--n] = '\0';
L = bwtEncode(s, n);
printf("The L column: %s\n", L);
r = bwtDecode(L, ++n);
printf("The original str: %s\n", r);
free(L);
free(r);
}
实现代码二
如上面一小节所说的,根据LiL_iLi找到同字符的FjF_jFj还有另一种实现方法,就是每遇到一个LiL_iLi,就计算一次LiL_iLi前面有几个字符和它相同。理论上,这种查找方法需要O(n2)O(n^2)O(n2)的时间,而上一小节的方法只需要线性时间。但是,这种方法也有好处,就是不需要构建“跳转数组”,从而节省空间。
效果如下:

具体代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAXSTR 1000
#define MARKER '$'
#define NUM_ALPHA 26
int comp(const void *s, const void *t) {
return strcmp(*(char**)s, *(char**) t); /* 注意这里 *(char**) 的用法 */
}
/* the last char of s is not MARKER */
char* bwtEncode(char *s, const int n) {
char *L;
char **M;
int i, j, l;
if ((M = (char**) malloc(sizeof(char*) * n)) == NULL || \
(L = (char*) malloc(sizeof(char) * (n + 2))) == NULL) {
fputs("Error: out of space!\n", stderr);
exit(1);
}
for (i = 0; i < n; i++) {
if ((M[i] = (char*) malloc(sizeof(char) * (i + 2))) == NULL) { /* 只需保存开头到标记字符的那一部分字符串,这样总共可以节省大约一半的空间 */
fputs("Error: out of space!\n", stderr);
exit(1);
}
for (j = 0; j < i + 1; j++)
M[i][j] = s[n - 1 - i + j]; /* 这里的字符串没有存储 MARKER */
M[i][i + 1] = '\0';
}
qsort(M, n, sizeof(M[0]), comp); /* 对旋转后的多个字符串排序 */
for (i = 0, L[0] = s[n - 1]; i < n; i++) {
if ((l = strlen(M[i])) < n)
L[i + 1] = s[n - 1 - l];
else
L[i + 1] = MARKER;
}
L[n + 1] = '\0';
for (i = 0; i < n; i++)
free(M[i]);
free(M);
return L;
}
char* bwtDecode(char *L, const int n) {
int i, j, k;
int *a;
char *r; /* original string. */
int pos;
if ((a = (int*) calloc(NUM_ALPHA + 1, sizeof(int))) == NULL || \
(r = (char*) malloc(sizeof(char) * (n + 1))) == NULL) {
fputs("Error: out of space!\n", stderr);
exit(1);
}
for (i = 0; i < n; i++) { /* L列中每种字符的个数 */
if (L[i] == MARKER)
a[0]++;
else
a[L[i] - 'a' + 1]++;
}
for (i = 1; i < NUM_ALPHA + 1; i++) { /* F列中排在每种字符前面的其他字符的个数 */
a[i] += a[i - 1];
}
for (i = 0, pos = 0; i < n; i++) {
r[n - 1 - i] = L[pos];
for (j = 0, k = 0; j < pos; j++)
if (L[j] == L[pos])
k++;
pos = a[L[pos] - 'a'] + k;
}
r[n] = '\0';
free(a);
return r;
}
int main(void) {
char s[MAXSTR];
char *L;
int n;
char *r;
printf("input str: ");
fgets(s, MAXSTR - 1, stdin);
n = strlen(s);
if (s[n - 1] == '\n')
s[--n] = '\0';
L = bwtEncode(s, n);
printf("The L column: %s\n", L);
r = bwtDecode(L, ++n);
printf("The original str: %s\n", r);
free(L);
free(r);
}
小结
本文比较详细地介绍了BWT算法,包括编码和解码过程,并且给出了解释。随后,给出了实现代码。遗留的一个问题是为什么经过BWT编码后,原始字符串中的相似字符会到相邻的位置?这个有待后续学习了。
参考
https://www.cnblogs.com/xudong-bupt/p/3763814.html
https://www.iteye.com/blog/emily2ly-742869
(公众号:生信了)
本文深入探讨BWT(Burrows-Wheeler Transform)算法,包括编码和解码过程。BWT通过数据转换使得相似字符相邻,常用于生物信息学中的序列比对。文中提供了两种实现代码,并解释了如何通过L列恢复原始字符串。
BWT算法&spm=1001.2101.3001.5002&articleId=102081350&d=1&t=3&u=96c8d2f192734df5b6b174811667e6ad)
2007

被折叠的 条评论
为什么被折叠?



