Sunday 算法
Sunday 算法由 Daniel M. Sunday 于 1990 年提出,是一种极其高效的单模式字符串匹配算法,尤其在处理长文本与较短模式串时表现出色。
Sunday 算法的匹配过程从左向右进行,但它在遇到不匹配时,只关注当前滑动窗口之外紧跟的下一个字符。
设文本串为 \(T\),长度为 \(n\),模式串为 \(P\),长度为 \(m\),当前匹配窗口为 \(T_{i \dots i+m-1}\)。如果在窗口内发现字符不匹配,算法会直接查看紧跟在窗口后的字符,即 \(T_{i+m}\):
- 情况 A:如果 \(T_{i+m}\) 不在模式串 \(P\) 中存在,说明包含该字符的任何窗口都无法匹配。窗口可以直接向后滑动,跳过这个字符,滑动距离为 \(m+1\)。
- 情况 B:如果 \(T_{i+m}\) 在模式串 \(P\) 中存在,为了不漏掉可能的匹配,需要将模式串中最靠右的该字符与 \(T_{i+m}\) 对齐。
为了快速决定滑动距离,需要预处理出一个偏移表,记录每个字符对应的滑动步长。
- 默认值:对于所有不在模式串中的字符,其偏移量为 \(m+1\)。
- 模式串内的字符:遍历模式串,对于字符 \(P_j \ (0 \le j \lt m)\),它距离模式串末尾的距离是 \(m-1-j\)。为了让该字符与窗口外的下一个字符对齐,模式串需要滑动的距离为 \(m-j\)。
- 注意:如果一个字符在模式串中出现多次,以最靠右的出现位置为准。
vector<int> sunday_search(const string &text, const string &pattern) {
int n = text.length(), m = pattern.length();
vector<int> results;
// 边界情况处理
if (m == 0 || n < m) return results;
// 1. 初始化偏移表,默认偏移量为 m + 1
// 假设字符集为 128 个 ASCII 字符
vector<int> shift(128, m + 1);
// 2. 计算模式串中每个字符的偏移量
for (int i = 0; i < m; i++) {
shift[pattern[i]] = m - i;
}
int i = 0;
// 3. 开始匹配
while (i <= n - m) {
// 检查当前窗口是否完全匹配
bool match = true;
for (int j = 0; j < m; j++) {
if (text[i + j] != pattern[j]) {
match = false;
break;
}
}
if (match) results.push_back(i);
// 如果已经到了文本末尾,无需再获取下一个字符
if (i + m >= n) break;
// 核心:根据窗口紧跟的下一个字符决定跳跃距离
char next_char = text[i + m];
i += shift[next_char];
}
return results;
}
如果题目中的字符串是由随机字符组成的(例如普通的英文文章、日志数据),Sunday 算法的速度往往很快。但如果题目刻意构造极端数据,Sunday 算法会退化为 \(O(n \times m)\) 的暴力搜索。
2022 CSP-S1 阅读程序 T1
#include <iostream>
#include <string>
#include <vector>
using namespace std;
int f(const string &s, const string &t)
{
int n = s.length(), m = t.length();
vector<int> shift(128, m + 1);
int i, j;
for (j = 0; j < m; j++)
shift[t[j]] = m - j;
for (i = 0; i <= n - m; i += shift[s[i + m]]) {
j = 0;
while (j < m && s[i + j] == t[j]) j++;
if (j == m) return i;
}
return -1;
}
int main()
{
string a, b;
cin >> a >> b;
cout << f(a, b) << endl;
return 0;
}
假设输入字符串由 ASCII 可见字符组成:
- 当输入为
abcde fg时,输出为-1。(正确/错误) - 当输入为
abbababbbab abab时,输出为4。(正确/错误) - 当输入为
GoodLuckCsp2022 22时,第 20 行的j++语句执行次数为 \(2\)。(正确/错误) - 该算法最坏情况下的时间复杂度为?
f(a, b)与下列哪个语句的功能最类似?
- A.
a.find(b) - B.
a.rfind(b) - C.
a.substr(b) - D.
a.compare(b)
- 当输入为
baaabaaabaaabaaaa aaaa,第 20 行的j++语句执行次数为?
答案
判断:正确
计算过程:
输入 a = "abcde", b = "fg"(\(n=5, m=2\))。shift 数组初始全为 \(3\)。
根据 b 计算偏移表:shift['f'] = 2, shift['g'] = 1。
- \(i=0\):当前窗口为
"ab",'a' != 'f',在 \(j=0\) 处失配。查看下一字符a[0+2] = a[2] = 'c',shift['c'] = 3,更新 \(i = 0 + 3 = 3\)。 - \(i=3\):当前窗口为
"de",'d' != 'f',在 \(j=0\) 处失配。查看下一字符a[3+2] = a[5] = '\0'(C++中字符串末尾后的字符访问),shift['\0'] = 3,更新 \(i = 3 + 3 = 6\)。
此时 \(i = 6 > n - m = 3\),循环条件不满足,跳出循环并返回-1。
判断:错误
计算过程:
输入 a = "abbababbbab", b = "abab"(\(n=11, m=4\))。默认 shift 为 \(5\)。
根据 b 计算偏移表:shift['a'] = 2, shift['b'] = 1。
- \(i=0\):窗口
"abba",在 \(j=2\) 处失配(a[2]='b' != t[2]='a')。下个字符为a[0+4] = a[4] = 'b',更新 \(i = 0 + shift['b'] = 1\)。 - \(i=1\):窗口
"bbab",在 \(j=0\) 处失配('b' != 'a')。下个字符为a[1+4] = a[5] = 'a',更新 \(i = 1 + shift['a'] = 1 + 2 = 3\)。 - \(i=3\):窗口
"abab",逐个比对全部成功(\(j\) 最终递增到 \(4\))。循环结束,直接返回当前的起始位置 \(3\)。
判断:正确
计算过程:
输入 a = "GoodLuckCsp2022", b = "22"(\(n=15, m=2\))。默认 shift 为 \(3\)。shift['2'] = 1。
- \(i=0, 3, 6\):窗口分别为
"Go","dL","ck"。每次都在首字符发生失配(j++执行 \(0\) 次),且下一位字符分别为'o','u','C',其shift值均为 \(3\),因此 \(i\) 依次递增为 \(3, 6, 9\)。 - \(i=9\):窗口
"sp",在 \(j=0\) 失配(j++执行 \(0\) 次)。下一位字符为a[11]='2',shift['2']=1,更新 \(i = 9+1=10\)。 - \(i=10\):窗口
"p2",首字符'p' != '2'再次在 \(j=0\) 失配(j++执行 \(0\) 次)。下一位字符为a[12]='0',shift['0']=3,更新 \(i = 10+3=13\)。 - \(i=13\):窗口
"22",与目标串"22"逐位比对。首字符相等(执行第 \(1\) 次j++),第二个字符也相等(执行第 \(2\) 次j++)。匹配成功,返回 \(13\)。
全局累计下来,j++语句仅在最后一次完全匹配时执行了 \(2\) 次。
\(O(n \times m)\)
要让 Sunday 算法退化到最坏情况,必须同时满足两个极其苛刻的条件:
- 单趟比较代价极大:每次窗口匹配时,都要比对到模式串的末尾或接近末尾才发生失配。
- 滑动步长极小:每次失配后,根据主串参与匹配窗口的下一个字符计算出的偏移量(
shift)尽可能小(最好每次只滑动 1 步)。
极端数据构造示例解析:
假设构造这样的文本串(主串)和模式串:
- 主串(\(a\)):
"aaaaa...aaaaa"(长度为 \(n\),全为'a') - 模式串(\(b\)):
"aaa...aba"(长度为 \(m\),前 \(m-2\) 个全为'a',倒数第 2 个为'b',最后 1 个为'a')
执行过程推演:
- 构建 shift 表:
对于模式串"aaa...aba",字符'a'最后一次出现的位置是下标 \(m-1\)(即最后一位)。
根据 Sunday 算法的偏移规则:shift['a'] = m - (m - 1) = 1。 - 窗口匹配与失配:
在任何一个起始位置 \(i\),由于主串全为'a',前 \(m-2\) 次比对('a' == 'a')都会成功。
直到比对到模式串的倒数第 2 位(即 \(j = m-2\) 时),主串的'a'与模式串的'b'才会发生失配。
此时,算法已经执行了 \(m-1\) 次比对(包含了 \(m-2\) 次成功和 \(1\) 次失败)。 - 计算偏移并滑动:
失配后,算法查看主串该窗口后的下一个字符(即 \(a[i+m]\))。
因为主串全为'a',该字符必然是'a'。
查表得到shift['a'] = 1,因此窗口只能向右滑动 1 步,即 \(i \gets i + 1\)。 - 循环往复:
滑动 1 步后,新的窗口再次重复上述过程:执行 \(m-1\) 次比较后失配,然后再次只能滑动 1 步。
复杂度计算:
由于每次只前进 1 步,外层循环大约会执行 \(n - m\) 次;而内层每次匹配都会进行 \(m-1\) 次字符比较。
总比较次数约为 \((n - m) \times (m - 1)\)。
当 \(n \gg m\) 时,时间复杂度退化为标准的 \(O(n \times m)\)。
A. a.find(b)
f(a, b) 的核心逻辑是寻找字符串 b 在 a 中首次出现的起始下标位置,若未找到则返回 -1。这与 C++ 标准库中 std::string::find 的行为高度一致(找不到时返回 string::npos,强转为 int 时值即为 -1)。
10
输入 a = "baaabaaabaaabaaaa", b = "aaaa"(\(n=17, m=4\))。shift['a']=1,shift['b']=5。
- \(i=0\):窗口
"baaa",首字符失配(j++\(0\) 次)。下个字符为a[4]='b',\(i \gets 0+5=5\)。 - \(i=5\):窗口
"aaab",前 \(3\) 位匹配(j++\(3\) 次)。下个字符为a[9]='a',\(i \gets 5+1=6\)。 - \(i=6\):窗口
"aaba",前 \(2\) 位匹配(j++\(2\) 次)。下个字符为a[10]='a',\(i \gets 6+1=7\)。 - \(i=7\):窗口
"abaa",首位匹配(j++\(1\) 次)。下个字符为a[11]='a',\(i \gets 7+1=8\)。 - \(i=8\):窗口
"baaa",首字符失配(j++\(0\) 次)。下个字符为a[12]='b',\(i \gets 8+5=13\)。 - \(i=13\):窗口
"aaaa",全部匹配(j++\(4\) 次)。返回结果。
累计执行次数为:\(0 + 3 + 2 + 1 + 0 + 4 = 10\)。

浙公网安备 33010602011771号