数据结构-串

简介: 数据结构-串

串(String)的定义与实现

串的定义

串(即字符串)是一种特殊的线性表,它的数据元素仅有字符组成。
:是由零个或多个字符组成的有限序列。一般记为:
$$ S = "a_0a_1 \cdots a_{n - 1}"(n \ge 0) $$
其中。$S$是串名,单引号括起来的字符序列是串的值;$a_i$可以是字母、数字或其他字符;串中字符的个数$n$称为串的长度。$n = 0$时的串称为空串(用$\varnothing$表示),不包含任何字符。在C语言中,串一般使用不可显示的字符"\0"作为串的结束符。

子串:串中任意多个连续的字符组成的子序列称为该串的子串。
主串:包含子串的串称为主串。
子串的第一个字符在主串中的序号,定义为子串在主串中的位置,该位置索引(或序号)从0开始。特别的:空串是任何串的子串,任意串是其自身的子串。

串与线性表的区别

  • 串的逻辑结构与线性表极为相似,区别仅在于串的数据对象约束为字符集。
  • 串的基本操作和线性表有很大差别。
    • 在线性表中,大多以“单个元素”作为操作对象。
    • 在串中,通常以“串的整体”(子串)作为操作对象。

      串的基本操作

  • StrCopy(&Str1, Str2):复制操作,将串Str2的值赋给串变量Str1。
  • StrConcat(&Str1, Str2):串连接,将串Str2接在Str1末尾,组成一个新串Str1。
  • StrLength(Str):求串长,返回串Str的元素个数。
  • SubString(&Sub, Str, index, len):求子串,表示从Str中index位置的字符开始抽出len个字符构成一个新的串用Sub串返回。
  • StrCompare(Str1, Str2):比较操作,比较串的编码,从串的一个序号开始比较其编码,若为0比较下一个序号,直到非0或比较完毕。若返回值大于0,表示Str1 > Str2;返回值等于0,表示Str1 = Str2;返回值小于0,表示Str1 < Str2。
  • StrInsert(Str1, Str2, index):插入子串,表示把串Str2插入到Str1的index位置处。
  • StrDelete(Str, index, len):删除子串,表示删除Str串的index位置及以后的的连续共len个字符。
  • StrIndex(Str1, Str2):子串定位,若主串Str1中存在子串Str2,则返回主串中子串的起始位置,否则返回-1。

    串的存储结构

    定长顺序存储

    串的顺序存储结构简称顺序串。顺序串的字符被依次存放在一片连续的单元中。我们可以用一个特定的、不会出现在串中的字符作为串的终结符,放在串的尾部,表示结束。在C语言中用字符"\0"作为串的结束符。
    ```C
    // 顺序串的最大长度

    define MaxSize 255

typedef struct {
// 数据域
char ch[MaxSize];
// 串的长度
int len;
} SeqString;

### 堆分配存储表示
堆分配存储仍然使用一组连续的存储单元存放串值的字符序列,但是它们的存储空间实在执行过程中动态分配的。
```C
typedef struct {
    // 数据域,动态分配存储空间ch的地址为串的基地址
    char *ch;
    // 串的长度
    int len;
} HeapString;

C语言中,存在一个称为“堆”的自由存储区,并用molloc()和free()函数来动态存储管理。

链式存储表示

串的链式存储结构简称为链串。

typedef struct Node{
    char data;
    struct Node *next;
} LinkString;

索引存储

在索引存储中,除了存放串值外,还要建立一个串名和串值之间对应关系的索引表

带长度的索引表

#define MaxSize 255

typedef struct{
    // 串名
    char name[MaxSize];
    // 串长
    int len;
    // 串值基地址
    char *strAdr;
} LinkString;

带末指针的索引表

带末指针的索引表用存放的末地址的指针endAdr来代替长度len

#define MaxSize 255

typedef struct{
    // 串名
    char name[MaxSize];
    // 串值基地址,末指针
    char *strAdr, *endAdr;
} LinkString;

串的基本运算

串的连接运算

/**
 * 串连接,将Str2连接在Str1并赋值给Str1
 * @param Str1 
 * @param Str2 
 */
void StrConcat(SeqString *Str1, SeqString Str2) {
    if (Str1->len + Str2.len > MaxSize) {
        // 判断两个串连接后是否溢出
        printf("上溢");
    } else {
        // 将Str2接在Str1后
        for (int i = 0; i < Str2.len; i++) {
            Str1->ch[Str1->len + i] = Str2.ch[i];
        }
        // 重置Str1长度和结束符
        Str1->len = Str1->len + Str2.len;
        Str1->ch[Str1->len] = '\0';
    }
}

求子串运算

/**
 * 求子串
 * @param Sub 子串,传入传出参数
 * @param Str 主串
 * @param index 子串在主串的下标号
 * @param len 子串长度
 */
void SubString(SeqString *Sub, SeqString Str, int index, int len) {
    if ((index + len - 1) > Str.len) {
        printf("超界");
    } else {
        for (int i = 0; i < len; i++) {
            Sub->ch[i] = Str.ch[i + index];
        }
        // 重置Str1长度和结束符
        Sub->len = len;
        Sub->ch[Sub->len] = '\0';
    }
}

串的模式匹配(子串定位)

子串定位运算又称为串的模式匹配,是串处理中最重要的运算之一。

设有两个串S和T,$S = "s_0s_1 \cdots s_{n - 1}"$,$T = "t_0t_1 \cdots t_{m - 1}"$,其中$0 < m \le n$。子串定位是在主串S中找出和子串T相同的子串。
一般把主串S称为目标;把子串T称为模式;把从目标S中查找模式为T的子串的过程称为模式匹配

朴素的模式匹配(BF算法)

朴素的模式匹配思想:从目标S中的第一个字符开始和模式T中的第一个字符比较,若相等,则继续逐个比较后续字符,否则从目标S的第二个字符开始重新与模式串的第一个字符比较,依次类推,直到模式T中的每个字符依次和目标S中的一个连续字符序列相等为止,则匹配成功,返回模式T中第一个字符在目标S中的位置。否则匹配失败,返回-1。
朴素模式匹配算法是一种不依赖于其他串的暴力匹配算法。

/**
 * 朴素模式匹配算法
 * @param Str1 主串
 * @param Str2 子串
 * @return 
 */
int SimpleIndex(SeqString Str1, SeqString Str2) {
    // i:控制主串下标,j:控制子串下标
    int i = 0, j = 0;
    // 因为i和j都是下标,所以必不小于对应的串长度
    while (i < Str1.len && j < Str2.len) {
        // 比较字符
        if (Str1.ch[i] == Str2.ch[j]) {
            // 相等,比较下一个字符
            i++;
            j++;
        } else {
            // 不想等,此时j表示本次匹配,匹配了j个字符,那i - j则主串下标回到本次子串还未开始匹配的位置
            // 令其加1,从下一个字符重新开始子串匹配,所以i = i - j + 1
            i = i - j + 1;
            // 子串回到其第一个字符下标处重新匹配
            j = 0;
        }
    }
    // 若此时j即子串的下标等于子串的长度,即为子串匹配成功,否则是主串遍历所以字符仍未找到对应子串
    if (j == Str2.len) {
        // 此时i的值对应的是匹配串后的一个位置,i - 子串长度Str2.len即为子串在主串中的索引号
        return i - Str2.len;
    } else {
        return -1;
    }
}
  • 朴素匹配算法的最好平均时间复杂度为:$O(n + m)$。
  • 朴素匹配算法的最坏平均时间复杂度为:$O(nm)$。

    KMP模式匹配算法

    在朴素匹配算法中,每次回溯都会回到比较字符的下一个位置,即回溯$i$值,这其中的大部分回溯都是不必要的。
    KMP算法相比朴素匹配算法的改进之处在于:每当一趟匹配过程中出现字符比较不相等时,不需回溯$i$值,而是利用已经得到的“部分匹配”的结果将模式向右“滑动”尽可能远的一段距离后,再进行比较。

    字符串的前缀、后缀和部分匹配值

    要了解子串的结构,首先要弄清:前缀、后缀和部分匹配值等概念。
    前缀:除最后一个字符外,字符串的所有头部字符。
    后缀:除第一个字符外,字符串的所有尾部字符。
    部分匹配值:字符串的前缀和后缀的最长相等前后缀长度。
    下面以“ababa”为例进行说明:
    | 模式串的各子串 | 前缀 | 后缀 | 交集 |部分匹配值 |
    | --- | --- | --- | --- | --- |
    | a | $\varnothing$ | $\varnothing$ | $\varnothing$ | 0 |
    | ab | a | b | $\varnothing$ | 0 |
    | aba | a, ab | a, ba | a | 0 |
    | abab | a, ab, aba | b, ab, bab | ab | 2 |
    |ababa | a, ab, aba, abab | a, ba, aba, baba | a, aba | 3 |

    next数组求法

    next数组本质上就是模式串$i$前面的子串部分匹配值(有点教材里面是部分匹配值 + 1)。
    next数组的开始两个为固定值==next[0] = 0; next[1] = 1;== 或者 ==next[0] = -1; next[1] = 0==
    下面以等于部分匹配值为例给出next函数定义(next[0] = -1; next[1] = 0):
    $$ y(x)=\left\{ \begin{aligned} -1 && j = 0 \\ max\{k |0 < k < j,且使得t_0t_1 \cdots t_{k - 1} = t_{j - k} \cdots t_{j - 1} \} && 当此集合不空时 \\ 0 && 其他情况 \end{aligned} \right . $$
    对于next[0] = 0; next[1] = 1;的情况,只需将对应值加1即可。

下面以具体示例来更好演示求模式串的next值(这里以两种方法穿插求解next值,一种是通过求解前缀后缀后求其交集来求解next(根据next值本质来求),一种是通过前缀后缀的性质来求解。)。
计算$T = "abaabcac"$的next值:
0:固定给next[0]赋值为-1;
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | ? | ? | ? | ? | ? | ? | ? |

1:下标为1对应求解的子串为a,a的前缀和后缀皆为空,对应的部分匹配值为0,所有next[1] = 0
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | 0 | ? | ? | ? | ? | ? | ? |

2:下标为2对应求解的子串为ab,去掉头字符后为b,因为头字符为a,所以对应匹配的后缀必以a开头,所以b不是其后缀,所有next[2] = 0
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | 0 | 0 | ? | ? | ? | ? | ? |

3:下标为3对应求解的子串为aba,aba的前缀为{a, ab},后缀为{a, ba},交集为{a},对应的部分匹配值为1,所有next[3] = 1
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | 0 | 0 | 1 | ? | ? | ? | ? |

4:下标为4对应求解的子串为abaa,去掉头字符后为baa,因为头字符为a,所以匹配后缀必以a开头,baa不可能是其匹配后缀。使用aa从头字符开始对比两个字符为ab,aa != ab,所以aa不是匹配后缀;使用a和头字符对比,对比成功,长度为1,所有next[4] = 1
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | 0 | 0 | 1 | 1 | ? | ? | ? |

5:下标为5对应求解的子串为abaab,去掉头字符后为baab,因为头字符为a,所以匹配后缀必以a开头,baab不可能是其匹配后缀。使用aab从头字符开始对比三个字符为aba,aab != aba,所以aab不是匹配后缀;使用ab和头字符对比两个字符为ab,对比成功,长度为2,所有next[5] = 2
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | 0 | 0 | 1 | 1 | 2 | ? | ? |

6:下标为6对应求解的子串为abaabc,abaabc的前缀为{a, ab, aba, abaa, abaab},后缀为{c, bc, abc, aabc, baabc},交集为{},对应的部分匹配值为,所有next[6] = 0
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | 0 | 0 | 1 | 1 | 2 | 0 | ? |

7:下标为6对应求解的子串为abaabca,abaabc的前缀为{a, ab, aba, abaa, abaab, abaabca},后缀为{a, ca, bca, abca, aabca, baabca},交集为{a},对应的部分匹配值为1,所有next[7] = 1
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 模式 | a | b | a | a | b | c | a | c |
|next[j] | -1 | 0 | 0 | 1 | 1 | 2 | 0 | 1 |

next数组代码求解

/**
 * 求取next数组
 * @param Str 模式串
 * @param next next数组
 */
void GetNext(SeqString Str, int next[]) {
    int i = 0, j = -1;
    next[0] = -1;
    while (i < Str.len) {
        if (j == -1 || Str.ch[i] == Str.ch[j]) {
            ++i;
            ++j;
            next[i] = j;
        } else {
            j = next[j];
        }
    }
}

KMP算法的代码

KMP算法的代码和朴素模式匹配算法代码基本类似,主要区别在于回溯的移动的区别。

/**
 * kmp模式匹配算法
 * @param Str1 
 * @param Str2 
 * @return 
 */
int KmpIndex(SeqString Str1, SeqString Str2) {
    // 求解next数组
    int next[Str2.len];
    GetNext(Str2, next);
    // i:控制主串下标,j:控制子串下标
    int i = 0, j = 0;
    // 因为i和j都是下标,所以必不小于对应的串长度
    while (i < Str1.len && j < Str2.len) {
        // 比较字符
        if ((-1 == j) || (Str1.ch[i] == Str2.ch[j])) {
            // 相等,比较下一个字符
            i++;
            j++;
        } else {
            // 不想等,根据next数组将模式串向右移动
            j = next[j];
        }
    }
    // 若此时j即子串的下标等于子串的长度,即为子串匹配成功,否则是主串遍历所以字符仍未找到对应子串
    if (j == Str2.len) {
        // 此时i的值对应的是匹配串后的一个位置,i - 子串长度Str2.len即为子串在主串中的索引号
        return i - Str2.len;
    } else {
        return -1;
    }
}

KMP算法的时间复杂度为$O(m + n)$。KMP算法只是在主串和子串有很多“部分匹配”是才明显由于朴素算法。
KMP算法的主要优点是:主串不回溯。

相关文章
|
5月前
数据结构 字符串 (第6天)
数据结构 字符串 (第6天)
|
存储 算法 JavaScript
数据结构:字符串
数据结构:字符串
88 0
|
6月前
|
算法 Java 索引
数据结构之串—字符串而不是羊肉串
数据结构之串—字符串而不是羊肉串
119 0
|
存储 Java 容器
数据结构 - 3(链表12000字详解)
数据结构 - 3(链表12000字详解)
47 0
|
算法 Python
数据结构|字符串匹配
数据结构|字符串匹配
93 0
|
存储 算法
【数据结构】串的定义以及算法
【数据结构】串的定义以及算法
178 0
|
存储 C# C语言
大话数据结构--串的存储结构
大话数据结构--串的存储结构
75 0
|
存储 算法
数据结构 | 串的存储结构之链串
数据结构中串的存储结构之链串,了解其基本实现及算法
144 0
数据结构 | 串的存储结构之链串
|
算法
数据结构 串
数据结构 串
96 0
数据结构 串
|
存储 算法 JavaScript
数据结构串
数据结构串
161 1
数据结构串