Velvet 基因组组装器详解:从论文到 C 代码实现

系统讲解 Velvet 基因组组装算法的原理、设计和实现,包括 de Bruijn 图构建、错误校正和图简化等核心模块

引言

基因组组装(Genome Assembly)是生物信息学中最基础也最具挑战性的问题之一。2008 年,Daniel Zerbino 和 Ewan Birney 在《Genome Research》上发表了 Velvet 算法,开创了短读长基因组组装的新纪元。

本文是我学习基因组组装算法的笔记,旨在从论文原理算法设计C 代码实现三个层面,系统梳理 Velvet 的来龙去脉。

作者注:本文所有内容均为本人学习和理解的总结,如有错误,欢迎指正。


一、背景与挑战

1.1 基因组组装问题

基因组组装的核心问题是:给定大量短的 DNA 序列片段(reads),重建原始的基因组序列

Reads:     ATGCGA    CGATGC    GCTAGC    TACGCT
           ||||||    ||||||    ||||||    ||||||
           ATGCGA    CGATGC    GCTAGC    TACGCT
           
Assembly:  ATGCGATGCGCTAGCTACGCT

对于短读长测序(如 Illumina),我们面临三大挑战:

  1. 数据量大:数百万到数十亿条 reads
  2. 测序错误:错误率约 0.1%-1%
  3. 重复序列:基因组中存在大量重复区域

1.2 为什么选择 de Bruijn 图?

在 Velvet 之前,主流的组装算法基于重叠 - 布局 - 共识(Overlap-Layout-Consensus, OLC)方法。但 OLC 需要计算所有 reads 之间的两两比对,时间复杂度为 O(n²),无法处理海量短读长数据。

Velvet 采用 de Bruijn 图(de Bruijn Graph, DBG)方法,将序列分解为固定长度的 k-mer,时间复杂度降为 O(n)。


二、de Bruijn 图的理论基础

2.1 k-mer 与 de Bruijn 图

定义:对于一个 DNA 序列,将其拆分为所有长度为 k 的子串,称为 k-mer

序列:ATGCGAT
k = 3 时的 k-mers:

ATG → TGC → GCG → CGA → GAT

每个 k-mer 是图中的一个节点,相邻 k-mer 之间有边连接

de Bruijn 图的构建

  • 节点(Node):每个唯一的 k-mer
  • 边(Edge):如果两个 k-mer 有 k-1 个碱基重叠,则存在一条有向边

2.2 双向链表示

由于 DNA 是双链结构,Velvet 同时存储 正向链反向互补链

// DNA 碱基互补配对
A ↔ T
C ↔ G

// 反向互补示例
// 正向:5'-ATGCGA-3'
// 反向:3'-TACGCT-5'
// 反向互补:5'-TCGCAT-3'

在 de Bruijn 图中,每个 k-mer 节点都有对应的反向互补节点,两者通过特殊边连接。


三、Velvet 算法设计

3.1 算法流程概览

┌─────────────────────────────────────────────────────────────┐
│                    Velvet Assembly Pipeline                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 预处理                                                   │
│     └─→ 读取 FASTQ 文件,提取 k-mers                           │
│                                                             │
│  2. 构建 de Bruijn 图                                         │
│     └─→ 创建节点和边,建立哈希表索引                          │
│                                                             │
│  3. 错误校正                                                 │
│     └─→ 去除测序错误产生的"气泡"和"尖端"                       │
│                                                             │
│  4. 图简化                                                   │
│     └─→ 合并线性路径,解析简单分支                           │
│                                                             │
│  5. 配对末端信息整合                                          │
│     └─→ 利用 paired-end reads 解决重复区域                    │
│                                                             │
│  6. 路径搜索与 contig 输出                                    │
│     └─→ 寻找最优路径,输出组装结果                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.2 核心数据结构

Velvet 的核心数据结构设计非常精巧:

// 简化版的节点结构(基于 Velvet 源码)
typedef struct _Node {
    char *sequence;        // k-mer 序列
    long coverage;         // 覆盖度(支持该 k-mer 的 reads 数)
    struct _Arc *arcs;     // 出边列表
    struct _Node *next;    // 哈希表链地址法的下一个节点
    unsigned int flags;    // 节点标记(用于图简化)
} Node;

// 边的结构
typedef struct _Arc {
    struct _Node *destination;  // 目标节点
    int overlap;                 // 重叠长度(通常为 k-1)
    struct _Arc *next;           // 下一条边
} Arc;

// 图的全局结构
typedef struct _Graph {
    Node **hashTable;       // 哈希表,用于快速查找 k-mer
    unsigned int kmerSize;  // k 值
    long totalNodes;        // 节点总数
    long totalArcs;         // 边总数
} Graph;

四、C 代码实现详解

4.1 k-mer 编码

Velvet 使用 2-bit 编码 将 DNA 序列压缩存储:

// 2-bit 编码表
// A = 00, C = 01, G = 10, T = 11

// 将 k-mer 转换为整数(用于哈希表索引)
unsigned long encodeKmer(const char *sequence, int k) {
    unsigned long hash = 0;
    
    for (int i = 0; i < k; i++) {
        hash <<= 2;  // 左移 2 位
        
        switch (sequence[i]) {
            case 'A': case 'a': hash |= 0; break;  // 00
            case 'C': case 'c': hash |= 1; break;  // 01
            case 'G': case 'g': hash |= 2; break;  // 10
            case 'T': case 't': hash |= 3; break;  // 11
            default:
                // 遇到 N 或其他模糊碱基,返回特殊值
                return INVALID_KMER;
        }
    }
    
    return hash;
}

// 反向互补 k-mer
unsigned long reverseComplementKmer(unsigned long kmer, int k) {
    unsigned long rc = 0;
    
    for (int i = 0; i < k; i++) {
        // 取出最低 2 位,取反,放到对应位置
        unsigned int base = kmer & 3;  // 取出最低 2 位
        base = 3 - base;               // A↔T, C↔G
        rc = (rc << 2) | base;         // 左移并添加新碱基
        kmer >>= 2;                    // 右移处理下一个碱基
    }
    
    return rc;
}

4.2 哈希表构建

// 初始化哈希表
Graph* initGraph(unsigned int kmerSize, unsigned int tableSize) {
    Graph *graph = (Graph*)malloc(sizeof(Graph));
    graph->kmerSize = kmerSize;
    graph->totalNodes = 0;
    graph->totalArcs = 0;
    
    // 分配哈希表
    graph->hashTable = (Node**)calloc(tableSize, sizeof(Node*));
    
    return graph;
}

// 向图中添加 k-mer
Node* addKmerToGraph(Graph *graph, const char *sequence, long readId) {
    unsigned long hash = encodeKmer(sequence, graph->kmerSize);
    unsigned int index = hash % HASH_TABLE_SIZE;
    
    // 检查是否已存在
    Node *node = graph->hashTable[index];
    while (node != NULL) {
        if (strcmp(node->sequence, sequence) == 0) {
            node->coverage++;
            return node;
        }
        node = node->next;
    }
    
    // 创建新节点
    Node *newNode = (Node*)malloc(sizeof(Node));
    newNode->sequence = strdup(sequence);
    newNode->coverage = 1;
    newNode->arcs = NULL;
    newNode->next = graph->hashTable[index];
    newNode->flags = 0;
    
    graph->hashTable[index] = newNode;
    graph->totalNodes++;
    
    return newNode;
}

4.3 图的构建

// 从 reads 构建 de Bruijn 图
Graph* buildDeBruijnGraph(char **reads, int numReads, int k) {
    Graph *graph = initGraph(k, HASH_TABLE_SIZE);
    
    for (int r = 0; r < numReads; r++) {
        int readLen = strlen(reads[r]);
        
        // 滑动窗口提取 k-mers
        for (int i = 0; i <= readLen - k; i++) {
            char *kmer = strndup(reads[r] + i, k);
            
            // 添加节点
            Node *currentNode = addKmerToGraph(graph, kmer, r);
            
            // 添加边(与前一个 k-mer 连接)
            if (i > 0) {
                char *prevKmer = strndup(reads[r] + i - 1, k);
                unsigned long prevHash = encodeKmer(prevKmer, k);
                unsigned int prevIndex = prevHash % HASH_TABLE_SIZE;
                
                Node *prevNode = graph->hashTable[prevIndex];
                while (prevNode != NULL) {
                    if (strcmp(prevNode->sequence, prevKmer) == 0) {
                        addArc(prevNode, currentNode, k - 1);
                        break;
                    }
                    prevNode = prevNode->next;
                }
                free(prevKmer);
            }
            
            free(kmer);
        }
    }
    
    return graph;
}

// 添加边
void addArc(Node *from, Node *to, int overlap) {
    Arc *arc = (Arc*)malloc(sizeof(Arc));
    arc->destination = to;
    arc->overlap = overlap;
    arc->next = from->arcs;
    from->arcs = arc;
    from->outDegree++;
}

4.4 错误校正:去除尖端(Tips)

测序错误会在图中产生短的”尖端”结构:

        A → T → G → C
       /
ATG → C
       \
        G → A → T
// 去除尖端:短的线性路径
void removeTips(Graph *graph, int maxTipLength) {
    for (unsigned int i = 0; i < HASH_TABLE_SIZE; i++) {
        Node *node = graph->hashTable[i];
        
        while (node != NULL) {
            // 检查是否为尖端起点(入度为 0,出度为 1)
            if (node->inDegree == 0 && node->outDegree == 1) {
                int tipLength = 0;
                Node *current = node;
                
                // 遍历尖端路径
                while (current != NULL && 
                       current->inDegree <= 1 && 
                       current->outDegree == 1 &&
                       tipLength < maxTipLength) {
                    tipLength++;
                    current = current->arcs->destination;
                }
                
                // 如果尖端足够短,删除整个路径
                if (tipLength < maxTipLength) {
                    removePath(graph, node);
                }
            }
            
            node = node->next;
        }
    }
}

4.5 错误校正:去除气泡(Bubbles)

测序错误还会产生”气泡”结构:

         A → T → G
        /         \
ATG → C             C → GAT
        \         /
         G → C → A
// 去除气泡:并行路径
void removeBubbles(Graph *graph, int maxBubbleLength) {
    for (unsigned int i = 0; i < HASH_TABLE_SIZE; i++) {
        Node *node = graph->hashTable[i];
        
        while (node != NULL) {
            // 检查是否有多个出边(气泡起点)
            if (node->outDegree >= 2) {
                Arc *arc1 = node->arcs;
                Arc *arc2 = arc1->next;
                
                while (arc1 != NULL && arc2 != NULL) {
                    // 寻找两条路径的汇合点
                    Node *mergePoint = findMergePoint(
                        arc1->destination, 
                        arc2->destination, 
                        maxBubbleLength
                    );
                    
                    if (mergePoint != NULL) {
                        // 保留覆盖度更高的路径
                        if (arc1->destination->coverage > 
                            arc2->destination->coverage) {
                            removePath(arc2->destination, mergePoint);
                        } else {
                            removePath(arc1->destination, mergePoint);
                        }
                    }
                    
                    arc2 = arc2->next;
                }
            }
            
            node = node->next;
        }
    }
}

4.6 图简化:合并线性路径

// 合并非分支路径
void simplifyGraph(Graph *graph) {
    int changed = 1;
    
    while (changed) {
        changed = 0;
        
        for (unsigned int i = 0; i < HASH_TABLE_SIZE; i++) {
            Node *node = graph->hashTable[i];
            
            while (node != NULL) {
                // 入度=1 且 出度=1 的节点可以合并
                if (node->inDegree == 1 && node->outDegree == 1) {
                    Node *nextNode = node->arcs->destination;
                    
                    // 如果下一个节点也是入度=1 且 出度=1
                    if (nextNode->inDegree == 1 && nextNode->outDegree == 1) {
                        mergeNodes(node, nextNode);
                        changed = 1;
                    }
                }
                
                node = node->next;
            }
        }
    }
}

// 合并两个节点
void mergeNodes(Node *node1, Node *node2) {
    // 拼接序列(重叠部分只保留一次)
    int overlap = node1->arcs->overlap;
    int newLen = strlen(node1->sequence) + strlen(node2->sequence) - overlap;
    
    char *newSeq = (char*)malloc(newLen + 1);
    strcpy(newSeq, node1->sequence);
    strcat(newSeq, node2->sequence + overlap);
    
    free(node1->sequence);
    node1->sequence = newSeq;
    
    // 更新边
    node1->arcs = node2->arcs;
    node1->outDegree = node2->outDegree;
    
    // 标记 node2 为已删除
    node2->flags |= NODE_DELETED;
    
    graph->totalNodes--;
}

五、配对末端信息整合

Velvet 的核心创新之一是有效利用 paired-end reads 信息解决重复区域。

5.1 配对末端约束

Read1: 5'────────────3'
       ←── insert size ──→
                    5'────────────3'
                          Read2

已知:Read1 和 Read2 之间的距离约为 insert size(±标准差)

5.2 约束传播算法

// 使用配对末端信息解析重复区域
void resolveRepeatsWithPairedEnds(Graph *graph, PairedEndReads *pairs) {
    for (int p = 0; p < pairs->numPairs; p++) {
        Node *node1 = findNodeByKmer(graph, pairs[p].read1);
        Node *node2 = findNodeByKmer(graph, pairs[p].read2);
        
        if (node1 != NULL && node2 != NULL) {
            // 添加距离约束
            addDistanceConstraint(node1, node2, 
                                  pairs[p].insertSize, 
                                  pairs[p].stdDev);
        }
    }
    
    // 传播约束,解决冲突
    propagateConstraints(graph);
}

六、路径搜索与 Contig 输出

6.1 寻找最优路径

// 深度优先搜索寻找最长路径
void findContigs(Graph *graph, FILE *output) {
    for (unsigned int i = 0; i < HASH_TABLE_SIZE; i++) {
        Node *node = graph->hashTable[i];
        
        while (node != NULL) {
            // 从起始节点(入度=0)开始搜索
            if (node->inDegree == 0 && !(node->flags & NODE_VISITED)) {
                List *path = createList();
                dfsTraverse(node, path, output);
                freeList(path);
            }
            
            node = node->next;
        }
    }
}

// DFS 遍历
void dfsTraverse(Node *node, List *path, FILE *output) {
    node->flags |= NODE_VISITED;
    appendToList(path, node->sequence);
    
    if (node->outDegree == 0) {
        // 到达终点,输出 contig
        writeContig(output, path);
    } else {
        // 选择覆盖度最高的边继续搜索
        Arc *bestArc = selectBestArc(node);
        if (bestArc != NULL) {
            dfsTraverse(bestArc->destination, path, output);
        }
    }
}

七、总结与思考

7.1 Velvet 的核心贡献

特性描述
de Bruijn 图将 O(n²) 复杂度降为 O(n)
错误校正自动去除测序错误产生的气泡和尖端
配对末端整合利用距离约束解决重复区域
内存优化2-bit 编码,紧凑存储

7.2 局限性

  1. k 值选择:需要手动指定,不同 k 值影响组装质量
  2. 高重复基因组:对于重复序列比例高的基因组效果有限
  3. 杂合度:对高杂合度基因组的处理不够理想

7.3 后续发展

Velvet 之后,基因组组装算法继续演进:

  • SPAdes:多 k-mer 策略,自动选择最优 k 值
  • MEGAHIT:succinct de Bruijn 图,更低内存占用
  • Flye/Canu:针对长读长(PacBio/Nanopore)的组装算法

参考文献

  1. Zerbino DR, Birney E. Velvet: algorithms for de novo short read assembly using de Bruijn graphs. Genome Research. 2008;18(5):821-829. doi:10.1101/gr.074492.107

  2. Compeau PEC, Pevzner PA, Tesler G. How to apply de Bruijn graphs to genome assembly. Nature Biotechnology. 2011;29(11):987-991. doi:10.1038/nbt.2023

  3. Velvet GitHub Repository. https://github.com/dzerbino/velvet

  4. Pevzner PA, Tang H, Waterman MS. An Eulerian path approach to DNA fragment assembly. PNAS. 2001;98(17):9748-9753. doi:10.1073/pnas.171285098


附录:完整代码示例

完整的 Velvet 风格 de Bruijn 图实现可以在我的 GitHub 上找到: