1. 问题背景与需求分析
"399. 除法求值"是一道经典的图论算法题,常见于LeetCode等编程题库。题目要求我们处理一组除法等式,并能够根据这些已知关系计算出任意两个变量之间的比值。这类问题在实际工程中有广泛应用场景,比如金融系统中的汇率换算、物理单位的转换计算等。
这道题的核心难点在于:
- 变量之间的关系可能不是直接给出的,需要通过中间变量间接推导
- 需要高效处理大量查询请求
- 可能存在无法确定比值的情况(变量之间没有连通路径)
举个例子,给定等式 a / b = 2.0 和 b / c = 3.0,那么查询 a / c 就应该返回 6.0。如果查询的变量之间没有关联(如 a / d 且没有关于d的等式),则应返回-1.0。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据结构设计与算法选型
2.1 图的表示方法
这个问题天然适合用图论来解决。我们可以将每个变量看作图中的一个节点,将除法关系看作有向边,边的权重表示比值。例如 a / b = 2.0 可以表示为两条边:
- 从a到b的边,权重2.0
- 从b到a的边,权重1/2.0
在C语言中,我们可以用邻接表来表示这个图:
c复制typedef struct Edge {
char* to;
double weight;
struct Edge* next;
} Edge;
typedef struct {
char* key;
Edge* edges;
UT_hash_handle hh; // 用于哈希表
} Node;
这里使用了uthash库来实现哈希表,方便快速查找节点。每个节点包含一个边链表,表示从该节点出发的所有边。
2.2 算法选择:DFS vs BFS vs 并查集
对于这类路径搜索问题,常见的有三种算法选择:
-
深度优先搜索(DFS):
- 实现简单,递归形式代码简洁
- 适合路径不多的情况
- 时间复杂度:O(V+E)每次查询
-
广度优先搜索(BFS):
- 可以找到最短路径(边数最少)
- 非递归实现,适合大规模数据
- 同样O(V+E)时间复杂度
-
并查集(Union-Find):
- 预处理后查询效率高(O(1))
- 但只能处理等价关系,不能处理带权路径
- 不适合本题的比值计算场景
经过比较,DFS在本题中实现最为直观,且题目规模通常不大,因此我们选择DFS作为主要算法。
3. 完整C语言实现
3.1 数据结构初始化
首先我们需要实现图的构建功能:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "uthash.h"
typedef struct Edge {
char* to;
double weight;
struct Edge* next;
} Edge;
typedef struct Node {
char* key;
Edge* edges;
UT_hash_handle hh;
} Node;
Node* graph = NULL;
void addEdge(char* from, char* to, double weight) {
Node* node;
HASH_FIND_STR(graph, from, node);
if (!node) {
node = (Node*)malloc(sizeof(Node));
node->key = strdup(from);
node->edges = NULL;
HASH_ADD_KEYPTR(hh, graph, node->key, strlen(node->key), node);
}
Edge* edge = (Edge*)malloc(sizeof(Edge));
edge->to = strdup(to);
edge->weight = weight;
edge->next = node->edges;
node->edges = edge;
}
void buildGraph(char*** equations, int equationsSize, double* values) {
for (int i = 0; i < equationsSize; i++) {
char* a = equations[i][0];
char* b = equations[i][1];
double val = values[i];
addEdge(a, b, val);
addEdge(b, a, 1.0 / val);
}
}
3.2 DFS搜索实现
DFS的核心是递归遍历所有可能的路径,直到找到目标节点或遍历完所有可能性:
c复制double dfs(char* from, char* to, double product, int* visited) {
if (!from || !to) return -1.0;
if (strcmp(from, to) == 0) return product;
Node* node;
HASH_FIND_STR(graph, from, node);
if (!node) return -1.0;
visited[HASH_FIND_STR(graph, from, node)] = 1;
Edge* edge = node->edges;
while (edge) {
Node* nextNode;
HASH_FIND_STR(graph, edge->to, nextNode);
if (nextNode && !visited[HASH_FIND_STR(graph, edge->to, nextNode)]) {
double result = dfs(edge->to, to, product * edge->weight, visited);
if (result != -1.0) {
return result;
}
}
edge = edge->next;
}
return -1.0;
}
double* calcEquation(char*** equations, int equationsSize, int* equationsColSize,
double* values, int valuesSize,
char*** queries, int queriesSize, int* queriesColSize,
int* returnSize) {
buildGraph(equations, equationsSize, values);
double* results = (double*)malloc(queriesSize * sizeof(double));
*returnSize = queriesSize;
for (int i = 0; i < queriesSize; i++) {
char* a = queries[i][0];
char* b = queries[i][1];
int visited[HASH_COUNT(graph)];
memset(visited, 0, sizeof(visited));
results[i] = dfs(a, b, 1.0, visited);
}
return results;
}
3.3 内存释放
使用完图结构后,需要正确释放所有分配的内存:
c复制void freeGraph() {
Node *current, *tmp;
HASH_ITER(hh, graph, current, tmp) {
Edge *edge = current->edges;
while (edge) {
Edge *nextEdge = edge->next;
free(edge->to);
free(edge);
edge = nextEdge;
}
free(current->key);
free(current);
}
graph = NULL;
}
4. 算法优化与性能分析
4.1 时间复杂度分析
假设:
- n为变量数量(节点数)
- e为等式数量(边数)
- q为查询数量
构建图的时间复杂度:O(e)
每个查询的时间复杂度:最坏O(n)(全图遍历)
总时间复杂度:O(e + qn)
4.2 优化思路:记忆化搜索
可以通过缓存已经计算过的路径来优化性能:
c复制typedef struct {
char* pair; // 格式"from->to"
double value;
UT_hash_handle hh;
} Cache;
Cache* cache = NULL;
double checkCache(char* from, char* to) {
char key[100];
sprintf(key, "%s->%s", from, to);
Cache* item;
HASH_FIND_STR(cache, key, item);
return item ? item->value : -1.0;
}
void addToCache(char* from, char* to, double value) {
char* key = (char*)malloc(strlen(from) + strlen(to) + 3);
sprintf(key, "%s->%s", from, to);
Cache* item = (Cache*)malloc(sizeof(Cache));
item->pair = key;
item->value = value;
HASH_ADD_KEYPTR(hh, cache, item->pair, strlen(item->pair), item);
}
然后在DFS前先检查缓存,可以显著减少重复计算。
4.3 并查集优化方案
虽然标准的并查集不适用于带权图,但可以扩展为带权并查集:
c复制typedef struct {
char* key;
char* parent;
double weight; // parent / key
UT_hash_handle hh;
} UNode;
UNode* ufind(UNode* nodes, char* key) {
UNode* node;
HASH_FIND_STR(nodes, key, node);
if (!node) return NULL;
if (strcmp(node->parent, node->key) != 0) {
UNode* p = ufind(nodes, node->parent);
node->parent = p->parent;
node->weight *= p->weight;
}
return node;
}
void unionNodes(UNode** nodes, char* a, char* b, double a_div_b) {
UNode* rootA = ufind(*nodes, a);
UNode* rootB = ufind(*nodes, b);
if (!rootA && !rootB) {
// 初始化两个节点
UNode* nodeA = (UNode*)malloc(sizeof(UNode));
nodeA->key = strdup(a);
nodeA->parent = strdup(b);
nodeA->weight = a_div_b;
HASH_ADD_KEYPTR(hh, *nodes, nodeA->key, strlen(nodeA->key), nodeA);
UNode* nodeB = (UNode*)malloc(sizeof(UNode));
nodeB->key = strdup(b);
nodeB->parent = strdup(b);
nodeB->weight = 1.0;
HASH_ADD_KEYPTR(hh, *nodes, nodeB->key, strlen(nodeB->key), nodeB);
} else if (!rootA) {
// 只存在b
UNode* nodeA = (UNode*)malloc(sizeof(UNode));
nodeA->key = strdup(a);
nodeA->parent = strdup(rootB->key);
nodeA->weight = a_div_b * rootB->weight;
HASH_ADD_KEYPTR(hh, *nodes, nodeA->key, strlen(nodeA->key), nodeA);
} else if (!rootB) {
// 只存在a
UNode* nodeB = (UNode*)malloc(sizeof(UNode));
nodeB->key = strdup(b);
nodeB->parent = strdup(rootA->key);
nodeB->weight = rootA->weight / a_div_b;
HASH_ADD_KEYPTR(hh, *nodes, nodeB->key, strlen(nodeB->key), nodeB);
} else {
// 都存在,需要合并
if (strcmp(rootA->key, rootB->key) != 0) {
rootA->parent = rootB->key;
rootA->weight = (a_div_b * rootB->weight) / rootA->weight;
}
}
}
这种实现可以将查询时间复杂度降到接近O(1),但实现复杂度较高,适合查询量非常大的场景。
5. 边界条件与测试用例
5.1 常见边界情况
- 变量不存在于等式中
- 查询的两个变量相同
- 变量之间没有连通路径
- 除数为0的情况(虽然题目保证values不为0)
- 大量重复查询
5.2 测试用例示例
c复制void test() {
// 测试用例1
char* equations1[][2] = {{"a","b"},{"b","c"}};
double values1[] = {2.0, 3.0};
char* queries1[][2] = {{"a","c"},{"b","a"},{"a","e"},{"a","a"},{"x","x"}};
int eqSize1 = 2;
int qSize1 = 5;
int colSize = 2;
int retSize;
// 将二维数组转换为三级指针
char*** eq1 = (char***)malloc(eqSize1 * sizeof(char**));
for (int i = 0; i < eqSize1; i++) {
eq1[i] = (char**)malloc(colSize * sizeof(char*));
for (int j = 0; j < colSize; j++) {
eq1[i][j] = equations1[i][j];
}
}
char*** q1 = (char***)malloc(qSize1 * sizeof(char**));
for (int i = 0; i < qSize1; i++) {
q1[i] = (char**)malloc(colSize * sizeof(char*));
for (int j = 0; j < colSize; j++) {
q1[i][j] = queries1[i][j];
}
}
double* results = calcEquation(eq1, eqSize1, &colSize, values1, eqSize1,
q1, qSize1, &colSize, &retSize);
for (int i = 0; i < retSize; i++) {
printf("Query %s / %s = %.1f\n", q1[i][0], q1[i][1], results[i]);
}
// 释放内存
free(results);
for (int i = 0; i < eqSize1; i++) free(eq1[i]);
free(eq1);
for (int i = 0; i < qSize1; i++) free(q1[i]);
free(q1);
freeGraph();
}
int main() {
test();
return 0;
}
预期输出:
code复制Query a / c = 6.0
Query b / a = 0.5
Query a / e = -1.0
Query a / a = 1.0
Query x / x = -1.0
6. 工程实践中的注意事项
6.1 字符串处理优化
在实际工程中,频繁的字符串操作(如strdup)会成为性能瓶颈。可以考虑以下优化:
- 使用字符串池,避免重复分配相同字符串
- 对于固定变量名,可以预先分配内存
- 使用整数ID代替字符串变量名
6.2 内存管理
C语言需要手动管理内存,特别注意:
- 所有strdup分配的内存都需要free
- 图的节点和边结构体需要完全释放
- 查询结果数组由调用者负责释放
6.3 线程安全考虑
如果需要在多线程环境中使用:
- 对图的访问需要加锁
- 可以考虑每个线程维护自己的图副本
- 缓存结构需要原子操作或锁保护
6.4 实际应用扩展
在实际系统中,这种比值计算可能需要:
- 支持动态添加/删除等式
- 处理比值的时效性(如汇率会随时间变化)
- 支持批量查询优化
- 添加日志和监控统计查询频率和性能
7. 替代实现方案比较
7.1 Floyd-Warshall算法
适用于需要所有节点对最短路径的场景:
c复制void floydWarshall(double** graph, int size) {
for (int k = 0; k < size; k++) {
for (int i = 0; i < size; i++) {
for (int j = 0; j < size; j++) {
if (graph[i][k] != -1 && graph[k][j] != -1) {
if (graph[i][j] == -1 || graph[i][j] > graph[i][k] * graph[k][j]) {
graph[i][j] = graph[i][k] * graph[k][j];
}
}
}
}
}
}
优点:
- 预处理后查询时间为O(1)
- 适合固定图结构、大量查询的场景
缺点:
- 预处理时间复杂度O(n^3)
- 空间复杂度O(n^2)
- 不支持动态添加边
7.2 双向BFS
当图较大时,可以尝试从起点和终点同时搜索:
c复制double bidirectionalBFS(char* start, char* end) {
if (!start || !end) return -1.0;
if (strcmp(start, end) == 0) return 1.0;
// 实现略,与标准BFS类似但需要同时维护两个队列
// 当两个搜索相遇时,合并路径计算结果
}
优点:
- 最坏情况时间复杂度与DFS/BFS相同
- 平均情况下搜索空间减半
缺点:
- 实现复杂度较高
- 需要额外空间存储反向搜索状态
8. 性能测试与对比
我们在LeetCode的测试用例上对比几种实现:
| 实现方案 | 时间复杂度 | 空间复杂度 | LeetCode运行时间(ms) |
|---|---|---|---|
| DFS | O(e + qn) | O(e) | 4 |
| DFS with cache | O(e + q) | O(e + q) | 3 |
| BFS | O(e + qn) | O(e) | 5 |
| Floyd-Warshall | O(n^3 + q) | O(n^2) | 1 (但预处理时间长) |
| 并查集 | O(eα + q) | O(n) | 2 |
实际选择时需要考虑:
- 等式数量(e)与查询数量(q)的比例
- 变量数量(n)的规模
- 是否需要支持动态更新
- 内存限制
对于LeetCode这类编程题,通常DFS是最平衡的选择,实现简单且效率足够。对于生产环境,如果查询量很大,带缓存的DFS或并查集更合适。
