数据结构:检查两个字符串是否是字母异位词(anagram)
目录
第一步:思考原始方法
第二步:我们尝试“构造结构”来记录字母出现次数
第三步:用“配料表”比较两个字符串
第四步:代码实现
问题定义
给定两个字符串,例如:
str1 = \"listen\"str2 = \"silent\"
我们想判断:它们是否是字母异位词(anagram)?
我们把这个概念分解:
-
两个字符串的长度必须一样
-
字符的种类和数量必须一一对应:
A 有 1 个 \'l\',B 也必须有 1 个 \'l\'
A 有 1 个 \'s\',B 也必须有 1 个 \'s\'
第一步:思考原始方法
最原始的直觉做法:
-
对两个字符串分别排序,再逐字符比较
这个方法可行,但它依赖排序:
sort(str1);sort(str2);return str1 == str2;
但我们现在不允许直接使用这种技巧,我们要从头思考:有没有不用排序就能比较两个字符串的本质?
❓真正的本质是什么?
我们深入想一下 anagram 的定义:
-
两个字符串的长度必须相同
-
每个字母的“出现次数”必须完全一样
⚠️ 注意:不是是否包含某些字符,而是包含了几次也要一样!
这是一个典型的“配料清单”问题:
如果 A 和 B 是同一种面包的配方,虽然制作顺序不同,但他们用的材料和数量一模一样,就算作是“同一种面包”。
所以我们的问题转换成:
如何比较两个“配料表”是否完全一致?
第二步:我们尝试“构造结构”来记录字母出现次数
这就回到了我们之前在“查找重复字符”时构造的想法:
我们可以为每个字母设置一个计数器,记录它出现的次数。
参考:数据结构:找出字符串中重复的字符(Finding Duplicates in a String)——使用哈希表 -CSDN博客
我们必须为每一个可能的字符,记录:它出现了几次
但是问题来了:
英文中有26个小写字母,我们不可能为每一个字符手写一个变量。
这显然不现实,我们会意识到:
❗我需要一个“容器”来批量记录 a~z 每个字母的“数量”信息。
我们于是想到:“我们需要一个能按字符索引、可存数量的结构”
如果我们能做出这样的结构:
-
\'a\'
→ 第 0 格子 -
\'b\'
→ 第 1 格子 -
...
-
\'z\'
→ 第 25 格子
那我们就能:
-
对于字符串 A,往里面加材料
-
对于字符串 B,从里面减材料
最后检查这个配料表是否“归零”,即可知道是否完全配平。
第三步:用“配料表”比较两个字符串
设想一个盒子(配料表)H,里面有 26 个格子,初始都是 0。
我们进行以下操作:
阶段 1:处理字符串 A
-
每看到一个字符,就往对应的格子里“加 1”
-
看到
\'l\'
→ H[11]++ -
看到
\'i\'
→ H[8]++ -
最后 H 会记录下所有原料的数量
例如:
A = \"listen\"处理后:H[\'l\'-\'a\'] = 1H[\'i\'-\'a\'] = 1H[\'s\'-\'a\'] = 1H[\'t\'-\'a\'] = 1H[\'e\'-\'a\'] = 1H[\'n\'-\'a\'] = 1
阶段 2:处理字符串 B
-
每看到一个字符,就在对应格子“减 1”
B = \"silent\"
每遇到 \'s\'
→ H[18]--;
每遇到 \'i\'
→ H[8]--;
如果两个字符串完美配对,最后:
-
每个格子都变成 0!
关键点来了:
如果我们某次减法后,某个格子变成了 负数,说明:
-
字符 B 某个字母出现得比 A 多
-
那肯定不可能是 anagram!
所以我们可以在处理 B 的过程中提前终止判断:
“只要我看到有一个位置减到负数,那就不是了!”
🔍 基本思想
-
准备一个计数器数组
H[26]
,用于记录每个字母的出现情况 -
遍历字符串 A:
-
每出现一个字符,就把对应
H
中的计数加 1
-
-
遍历字符串 B:
-
每出现一个字符,就在
H
中减去 1 -
如果某个字母的计数变成负数,说明 B 中该字符比 A 多 → 肯定不是 anagram
-
-
如果全部字符都匹配,最终
H
中每个位置都应为 0
第四步:代码实现
数据结构设计:
我们需要一个「配料表」:
int H[26] = { 0 };
解释:
-
H[i]
记录字母\'a\' + i
的数量差 -
先加 A 的字符 →
H[...] += 1
-
再减 B 的字符 →
H[...] -= 1
🔹 步骤 1:准备字符串和变量
char A[] = \"listen\";char B[] = \"silent\";int H[26] = { 0 }; // 初始化26个位置为0int i;
🔹 步骤 2:遍历第一个字符串 A,进行“加法”
for (i = 0; A[i] != \'\\0\'; i++) { H[A[i] - \'a\'] += 1;}
解释:
-
每读取一个字符
ch
,将H[ch - \'a\']
对应的格子加 1 -
\'a\' - \'a\' = 0
,\'b\' - \'a\' = 1
,...,\'z\' - \'a\' = 25
-
这样刚好把字符映射到 0~25 的范围
🔹 步骤 3:遍历第二个字符串 B,进行“减法”
for (i = 0; B[i] != \'\\0\'; i++) { H[B[i] - \'a\'] -= 1; if (H[B[i] - \'a\'] < 0) { printf(\"Not anagram\\n\"); break; }}
解释:
-
每遇到一个字符就“扣除”一次对应配料
-
一旦发现有某种配料少了(变成负数),立即判定失败!
🔹 步骤 4:如果 B 成功走完(说明没有字符过多)
if (B[i] == \'\\0\') { printf(\"Is anagram\\n\");}
为什么这样判断?
-
如果前面的
for
循环没有被break
中断,那B[i]
走到了\'\\0\'
-
说明整个 B 字符串配对成功,没有任何字符超出配料表
完整代码
#include int main() { char A[] = \"listen\"; char B[] = \"silent\"; int H[26] = { 0 }; int i; // 阶段 1:处理 A,构造配料表 for (i = 0; A[i] != \'\\0\'; i++) { H[A[i] - \'a\'] += 1; } // 阶段 2:处理 B,逐一扣除配料 for (i = 0; B[i] != \'\\0\'; i++) { H[B[i] - \'a\'] -= 1; // 提前终止:某个字母用多了 if (H[B[i] - \'a\'] < 0) { printf(\"Not anagram\\n\"); break; } } // 阶段 3:如果没有被 break 打断,说明是 anagram if (B[i] == \'\\0\') { printf(\"Is anagram\\n\"); } return 0;}