字符串相关(排序, 数据结构)
前言
在算法的第五章, 是与字符串相关的各种处理操作, 在平时的处理中, 其实发现所有的语言, 都离不开字符串, 甚至于数值等等的相关操作也可以被转换成字符串有关操作, 所有的数据, 在对应语言的处理中, 都是字符串.
应用范围如此之广, 但在 Java中并未为字符串做相应的特殊处理(在这里仅指排序, 数据结构这两方面.)
排序
键索引排序
在字符串的排序之前, 先来看另一种很有趣也非常有用的排序方式.
在常规的数据背景下, 使用希尔排序, 归并排序, 插入排序, 快速排序, 三向快速排序这几种已经能满足我们的正常需求.
但是不得不注意到的另一个问题是: CompareTo()方法的实现, 在以往的排序中, 总是需要进行比较, 然后排序. 排序在比较之后.
对待越复杂的数据结构, 比较所需的代价越高. 考虑这样一种常见的场景, 将全校学生按照班级排序.
稍稍分析会发现: key 是班级号, value是学生. 班级号只有R个, 数量有限, 且值不会太大, 学生的数量远远大于key. 为 Student类实现对应的 Comparator借口吗? 然后采用 三向快速排序的方式, 为这种重复量较多的数据进行排序.
这是一种方式, 但在这里有另一种更有趣的排序方法, 有趣在这种方法无需进行比较, 即可排序.
不比较? 怎么排序? 怎么知道谁大谁小.
在这里, 其实是利用了默认的比较方式, 1-10本身就是从小到大的数据.一步步来看究竟是怎么实现的.
int[] count = new int[R + 1];
for (int i = 0; i < students.length; i++) {
count[students[i].getClassNumber() + 1]++;
}
这一步是统计所有数据的班级 频次. 将出现的次数存入数组中. 这样做的意义在于:
我们可以直观的知道, 1班有多少个人, 2班有多少个人..., 如果取到 students[1], 发现班级是2, 假设1班20人, 2班 22人, 就可以直接将 students[1] 放在 students[20]的位置, 且保证不会超员.
可以看出来, 我们获取了对应的元素所需要存入的索引, 按照不同的 classNumber存入对应的索引即可. 即:
将number为0的频次存入count[0], number为1 存入count[1].
那么下一步就是将获取到的频次转换为对应的索引.
for (int i = 0; i < R; i++) {
count[i + 1] += count[i];
}
由于保存的都是对应classNumber的起始索引, 因此 count[0]为0.
下一步将数据存入即可.但需要一个辅助数组.
Student[] aux = new Student[students.length];
for (int i = 0, length = students.length; i < length; i++) {
aux[count[students[i].getClassNumber()]++] = students[i];
}
在每存入一个数据后, 都需要将索引向前推动一位, count[] 数组中的索引始终指向即将存入的下一个位置.
最后回写即可.
System.arrayCopy(aux, 0, students, 0, students.length);
扩展
键索引排序的核心就是将键本身与count的索引相关联, 在上面的例子中, 由于ClassNumber是天然的索引, 因此无需相关联即可使用.
但我们在平时的使用中遇到的绝大多数情况都不是这样的, 可能需要按照字符归类, 字符串归类这样的特殊情况, 又该怎么处理呢?
对于字符, 在Java中还是有比较简单的处理方式的, 存在char, 会自动按照 ASCII码或是 Unicode码将之转换为对应的数字.将char直接使用做下标也是可以的.
那更复杂的字符串呢?比如中国的省不按照编码, 而是直接按照名称进行分组.这时候需要做些特殊处理即可.
我们知道, 字符之所以可以和数字相互转换, 是因为存在对应的字母表, 无论是 ASCII还是Unicode码. 与之类似, 我们可以定义自己的码表.
public class Alphabet {
private String[] alphabet;
private int size;
public Alphabet(String[] alphabet);
public String toStr(int index);
public int toIndex(String s);
public boolean contains(String s);
public int R() {
return size;
}
}
即可完成相应的转换.
低位优先的字符串排序
在理解了键索引排序之后, 低位优先就不难理解了. 无非是将字符串的每一个字符都用 键索引排序的方式进行排序, 字符串的长度为 maxLength;
User[] aux = new User[users.length];
for (int i = maxLength - 1; i >= 0; i--) {
int[] count = new int[125];
/*对待如身份证号 固定位15 18位的这两种类型,可以设置超出界限的数值. 在15位-18位的统计时, 跳过所有为15位的,
不再次进行统计*/
String tempStr;
for (int j = 0; j < users.length; j++) {
//System.out.println(users[j] + ",j:" + j + ",i:" + i);
if ((tempStr = users[j].getsGroup()).length() - 1 < i) {
count[1]++;
} else {
count[tempStr.charAt(i) + 2]++;
}
}
for (int j = 0; j < 124; j++) {
count[j + 1] += count[j];
}
for (int j = 0; j < users.length; j++) {
if ((tempStr = users[j].getsGroup()).length() - 1 < i) {
aux[count[0]++] = users[j];
} else {
aux[count[tempStr.charAt(i) + 1]++] = users[j];
}
}
System.arraycopy(aux, 0, users, 0, users.length);
}
由低位向高位逐次排序, 得益于键索引排序的结果是稳定的.
在这里对原本的算法做了微调, 可以支持不等长字符串的排序,处理思路则是, 对于当前 I 超过字符长度的,则放在 count[1]的位置, 其余则用 +2向后顺延.
高位优先的字符串排序
高位优先的字符串排序 是同样的基于键索引排序所进行的.有所区别的是, 高位优先对字符串长度没有要求, 可以不等长, 同时高位优先是按照 字母表 进行递归排序处理的.
public class MSD {
private static int insertBound = 15;
private static String[] aux;
private static int toChar(String str, int index) {
return index < str.length() ? -1 : str.charAt(index);
}
public static void sort(String[] str) {
}
private static void sort(String[] str, int lo, int hi, int d) {
if (hi <= lo + insertBound) {
//切换成插入排序, 从 lo 到 hi
return;
}
int[] count = new int[123+2];
for (int i = lo; i <= hi; i++) {
count[toChar(str[i], d) + 2]++;
}
for (int i = 0; i < 124; i++) {
count[i + 1] += count[i];
}
for (int i = lo; i <= hi; i++) {
aux[count[toChar(str[i], d) + 1]++] = str[i];
}
for (int i = lo; i <= hi; i++) {
str[i] = aux[i - lo];
}
for (int i = 0; i < 123; i++) {
sort(str, lo + count[i], lo + count[i + 1] - 1, d + 1);
}
}
}
在这里就凸显出一种对不等长字符串更好的处理方式, 也就是 toChar()方法, 由于返回值可能为 -1, 因此统一 +2, 达到向前推进的目的.
但仍然存在几个问题:
仔细分析来看, 如果字符串均不相同, 递归的次数会相当之多, 同时随着 R 也就是例子中的 123, 编码集的大小增加, 处理的时间也会增长的特别快. 所以特别需要注意字符集的大小问题, 以免使得速度降低到难以想象的程度.
对于含有大量相同前缀的字符串排序也会相当缓慢, 因为很难会切换到插入排序. 却仍然需要统计频次, 转换, 递归.
它的效率受限因素恰恰又是 常常会发生的情况, 因此我们需要一种能够避免这种种缺陷的算法.
三向字符串排序.
可以理解为 标准快速排序在 字符串这种特殊场景下的变形.
public class Quick3Sort {
private static int toChar(String str, int index) {
return index < str.length() ? -1 : str.charAt(index);
}
public static void sort(String[] str) {
sort(str, 0, str.length - 1, 0);
}
private static void sort(String[] str, int lo, int hi, int d) {
if (lo >= hi) {
return;
}
int cmp = toChar(str[lo], d);
int i = lo + 1, lt = lo, gt = hi, t;
while (i <= gt) {
t = toChar(str[i], d);
if (t < cmp) {
exchange(str, lt++, i++);
} else if (t > cmp) {
exchange(str, i, gt--);
} else {
i++;
}
}
sort(str, lo, lt - 1, d);
if (cmp > 0) {
sort(str, lt, gt, d + 1);
}
sort(str, gt + 1, hi, d);
}
private static void exchange(String[] str, int l, int r) {
String temp = str[l];
str[l] = str[r];
str[r] = temp;
}
}
在三向快速排序中, 主要就是为了应对在 快速排序中遇到的 大量重复键时会导致的效率低下, 而相似的, 在三向字符串的快速排序中, 为了应对大量前缀相同的字符串, 且与字符集R的大小关系不大.
假设所有字符串前10个字符均相同, 在第一次拆分时, 前后两个子数组长度均为0, 在生成中间数组的时候, 并没有进行字符串的移动, 而仅仅是比较了对应位置的 单个字符而已, 能够非常快速的将前缀过滤掉, 然后进行处理.
单词查找树
需要较大的空间进行储存, 优点是速度相当之快.
查找命中的时间与被查找的键的长度成正比.
查找未命中时只需要查找若干个字符.
在二叉树中的比较方式采取的是对不同的key 进行比较, 大于在右, 小于在左, 逐层深入, 查找所需的时间主要受到 树的高度, 比较本身 的影响.
在这里给出实现的简单思路, 在已经实现了二叉树的基础上来看, 单词查找树的基础实现是相当简单的.
以 ASCII 128位为例.
public class WST {
private static final int R = 128;
private static Node root;
private class Node {
private Object val;
private Node[] next = new Node[R];
}
}
核心数据结构为 Node数组,同时也能够看出来,在这里无需存储字符串, 而是将字符串拆解的字符与 node数组的 索引相一一对应. 实现了 key.
首先来看它的优点, 对含有共同前缀的大量字符串而言, 无论是从空间还是时间上来看, 都是相当友好的.
但随着R的逐渐增大, 空链接也会越来越多, 这些无效链接会占据大量的空间. 在不考虑空间消耗的情况下, 这种数据结构无疑是目前所见到最优的数据结构.
但在我看来, 对于目前所拥有的条件而言, 往往都还是需要考虑到空间资源的. 因此它的优越性并不是很强.
然而从另一点来看, 才会发现它的强大. 也是这种数据结构的必要性所在.即以下API.
String longestPrefixOf(String s); // 以s为前缀的最长的键
Iterable<String> keysWithPrefix(String s); //所有以s为前缀的键
Iterable<String> keysMath(String s); //所有和s 通配符匹配
在以往的数据结构中, 实现这几个 接口的代价是相当高昂的.
有所不同的是, 在单词查找树中并不会真正的删除一个节点, 查找未命中有两种形式, 其一是 并未找到对应的 字符, 另一种是, 按树查找到字符串的最后一个字符, 但 value为null. 这两种都表示未命中.
在单词查找树中, 无论是命中还是未命中查找, 时间都是最优的, 而空间, 对于所有字符串较短的情况来说:所需链接数平均为: RN (R为字符集大小, N为存储的字符串总数)
而对于字符串较长的而言:
所需链接数平均为: RNW(W为字符串长度);
所以当使用单词查找树时, 务必把握字符集特点以及字符串本身的特点.
三向单词查找树
为了解决在 单词查找树中所遇到的空间消耗巨大的问题, 因此才有了这种数据结构, 在三向单词查找树中, 空间消耗与 R 无关, 它的实现方式与二叉树的实现相类似, 有所不同的是, 无需 key的概念, 将 key本身拆解为 字符数, 通过这种方式表示 key.
private class Node {
Object val;
Node left, mid, right;
char c;
}
而它的查找路径为, 如果c命中, 且没有到达字符串的末尾, 向中键 mid向下查找, 查找未命中, 大于 right, 小于 left. 同样的可以实现 单词查找树中的 API, 但查找速度要低于前者.
是在 时间和空间消耗之间取一个平衡点.
三向单词查找树的优势在于: 大型字母表 且在 大型字母表中的 字符频率非常不均衡的情况下.
至于改进: 可以将 根节点, 或前几个节点, 视需求和 R 的大小而定. 变为 R 向查找树.
但依然存有一个问题, 子字符串的查找, 在数据库查找中较为常用的一种操作, like '%xxx%'; 又或者是单纯的 ctrl + f 搜索字符串.查找出含有某些子字符串的字符串. 但前面的几种实现都对这一点表示无可奈何.
子字符串查找(KMP算法)
在最简单的考虑中, 如果需要查找一段文本中的字符串, 往往会是使用从头到尾查找的方式, 在这里将被匹配的字符串称作 模式字符串, 需要与模式字符串的每一个字符都匹配, 如果不匹配则对于模式字符串是从头再来, 对于文本字符串则是从匹配的字符串的后一位开始继续匹配查找.
而这种方式就被称作是暴力破解法. 实现方式一:
public static int search(String pat, String text) {
int M = pat.length();
int N = pat.length();
for (int i = 0; i < N; i++) {
int j;
for (j = 0; j < M; j++) {
if (text.charAt(i + j) != pat.charAt(j)) {
break;
}
}
if (j == M) {
return i;
}
}
return N;
}
很简单的算法, 也很好理解. 这种算法在绝大多数情况下都是很好用的, 查找时间也并不需要太多, 但是并不适用于所有情况, 它的查找时间取决于文本的特点 和 模式字符串的特点. 在最坏的情况下需要NM次查找才能够解决问题.
而下面这种暴力破解法则提供的是另一种稍有不同的思路.
public static int search(String pat, String text) {
int M = pat.length();
int N = pat.length();
int i, j;
for (int i = 0; i < N && j < M; i++) {
if (text.charAt(i) == pat.charAt(j++)) {
;
} else {
i -= j;
i = 0;
}
}
if (j == M) {
return i - M;
}
return N;
}
只有一个for循环, 用索引的回退 来代替循环. 这给了我们一种新的思路, 即用回退来控制索引, 位置.
但事实上, 在当我们得到这个 模式 字符串的时候, 就已经得到一定量的信息来帮我们解决问题.
对于这样一个字符串 ABABAC, 当我们前进到 str[5]的时候[C], 匹配失败, 我们的可以将字符串依旧向前推进, 不必回退text的i, 如果匹配失败的字符串是 B的话, 那我们只要检查 text的下一个字符是否是A即可,无需回退, 充分利用已知信息.
不仅仅是利用已经检查的字符串, 甚至将不匹配字符也当成一条信息利用起来.不必进行二次检查. 提高效率.
但是如何利用呢?
KMP算法
个人感觉KMP算法的理解还是很不容易的, 在有着图文对照的情况下也花了三四天的时间才做到理解. 我们先来看较难的版本. 我就不粘贴图文. 仅仅通过代码来进行说明. 如果觉得有困难, 建议看看算法四 P498 及其前后文.
同时有一个比较有趣的名词: 确定有限自动状态机(DFA).
/*在这里定义一个 dfa二维数组, R指的是所采用的 字符集大小,M指的是模式字符串的长度. 而数组中储存的值即称为当前的状态. 状态值也是从0~M.*/
/*所以需要理解这个状态值得意义, 对于字符串 ABABAC而言, 当每检查一个字符, 且匹配的时候, 状态值前进一位, 不难发现如果六个字母都匹配, 则表示 模式字符串 匹配成功, 此时的 状态值, 自然是6. 状态值的起始值为0, 表示一个字符都没有匹配. 因此需要从头开始匹配.
*/
int[][] dfa = new int[R][M];
/*pat指的是模式字符串. 从这里可以看出, 令 dfa['A'][0] = 1, 也就是匹配到这个的时候, 进入状态 1, 此时表示匹配成功, 由于 pat的第一个字符就是'A', 也能够发现 dfa[pat.charAt(j)][j] = j + 1;表示匹配成功.也是这里的核心*/
dfa[pat.charAt(0)][0] = 1;
/*从这里不难看出,外循环又 M次,也就是每一次会判断相应位置的字符*/
for (int X = 0, j = 1; j < M; j++) {
for (int c = 0; c < R; c++) {
/*在这里对数组进行赋值,也就是在第j个字符的位置, 如果用来匹配的字符为c时, 应该回到哪一个状态值. 也就是当前在当前位置匹配失败,应该进入哪一个状态.但又如何通过X来确定呢?*/
dfa[c][j] = dfa[c][X];
}
/*在这里是表示匹配成功的时候,状态值应该为 j+1;dfa[][j]中的j此时也可以理解为状态.表示当前在状态j匹配成功,应该进入j+1,下一位.*/
dfa[pat.charAt[j]][j] = j + 1;
/*至关重要的是 X表示什么意思, X表示, 如果在下一位匹配失败,当前字符所处的位置应该在状态几. 对于ABABAC而言, 如果在第三位B处匹配失败,上一位是A, X = dfa['A'][0]. 此时在状态一. 在不考虑第三位匹配值究竟是多少, 无论是 A C, 此时唯一能确定的是,至少要从状态1开始再次计数, 因为A已经匹配成功.*/
/*在这之后,进入下一次循环,dfa[c][j] = dfa[c][X],因为前一位A已经匹配成功,此时处在状态1, 在状态1的情况下,如果再遇到c此时应该处在状态几? 在本次循环之前,我们已经验证了 ABA三位, 如果在状态1的情况下,再遇到 B,应该进入状态dfa['B'][1]. 这里的状态这个概念,就表示此时已经匹配过多少位.*/
/*进入新的一轮, X储存的原值是上一位匹配成功的情况下,至少应该处在状态几, 在例子中, 处在状态1, 那么在状态1的情况下, 如果匹配B成功,也即是,在状态1的情况下,下一位值为B时,所应该处在的状态,此时至少应该处在 dfa['B'][1]; 如果例子换为ABACAC,则此时的dfa['C'][1]为0,又需要回到起点.*/
/*这句话简单的说明就是: 在状态 X的情况下匹配到 pat.charAt[j],此时应该进入状态几? 更新X*/
/*而这得益于在这之前,我们已经清晰地了解了,字符的前几位构成,此时X同样也是由前几位所决定的.*/
X = dfa[pat.charAt[j]][X];
}
仅仅只需要几行代码就实现了相应的工作.
public static int search(String text) {
int i = 0, j = 0, N = text.length(), M = pat.length();
for (;i < N && j < M; i++) {
j = dfa[text.charAt(i)][j];
if (j == M) {
return i - j;
}
}
return N;
}
这种方法的一大优点是不需要在输入中进行回退, 同时保证了在最坏情况下依然为线性级别的查找速度. 但事实上, 在含有大量重复文本中查找含有同样大量重复的模式字符串, 并不是很常见的情况.
它更适合用在不确定的输入流中, 无法回退, 或者说回退需要较大代价的情况下.
子字符串查找(BM算法)
在用于子字符串查找的算法中, BM在目前被认为是最高效的子字符串查找算法.
在KMP算法中, 算法复杂度为 O(N + M), 而在BM算法中,则为 O(N / M);
BM算法的理解难度比起KMP算法要简单许多. 它是通过 前移值 来完成字符串的预匹配工作的.
对于文本字符串F I N D I N A H A Y S T A C K N N E E D L E 中查找 模式字符串 N E E D L E ;
每次匹配都从模式字符串的最右侧开始进行匹配. 以最大程度的可以跳过 文本字符串中的字符. text[5] 为N != pat[5], 模式字符串从右向左数遇到的第一个N在第0位, 因此将模式字符串向右移动 5位.
F I N D I N A H A Y S T A C K N N E E D L E
N E E D L E
与模式字符串的第5位相对其, 开始进行下一次匹配, 也即text[10] 为S != pat[5], 且在pat字符串中,并不存在相应的字符与之匹配. 因此前移6位.
F I N D I N A H A Y S T A C K N N E E D L E
N E E D L E
以此类推进行下一次比较.
在这里模式字符串的 前移位 通过预处理就可以得到.
/*R为字符集大小, right为在匹配到对应字符时 文本字符串的指针应该前移的位数.*/
right[] int = new int[R];
/*j表示模式字符串的指针, 不断左移, i表示文本字符串的指针, 不断右移*/
int j, i;
/*当在模式字符串中找不到被匹配字符时, i应该前进 j + 1位. 当不匹配时设为 -1, 则自然实现了 j + 1*/
for (int c = 0; c < R; c++) {
right[c] = -1;
}
/*M位模式字符串的长度, 因为在判断右移位数的时候, 需要从右向左第一次出现的位置来决定, 因此需要从左向右, 查找, 覆盖*/
for (j = 0; j < M; j++) {
right[pat.charAt[j]] = j;
}
通过这样简单易懂的代码就实现了BM算法的核心, 预处理工作.
但仍然需要注意的一种较为特殊的情况:
. . . . . . . . E L E . . . . . . .
N E E D L E
在D的位置匹配到E, 此时应该向左移动两位, 但这样是不对的, 因此, 规定, 在最小于等于零的情况下, 向右移动一位即可. 重新匹配.
. . . . . . . . E L E . . . . . . .
N E E D L E
查找:
public static search(String text, String pat) {
... 省略之前的right[] 数组生成.
int skip;
for (int i = 0; i <= N - M; i += skip) {
skip = 0;
for (int j = i + M; j >= 0; j--) {
if (text.charAt(i) != pat.charAt(j)) {
skip = j - right[text.charAt[i + j]];
if (skip <= 0) {
skip = 1;
}
}
}
if (skip == 0) {
return i;
}
}
return N;
}
子字符串查找(Rabin Karp算法)
这种算法的核心思路在以前也有所接触, 类似于 equals() 方法, 遍历text文本, 不断查找与 模式字符串 equals()的字符串.
但存在几个问题:
-
String的 equals() 是要比较每个字符, 在循环查找中依然要比较每个字符, 速度甚至不如暴力破解法.
1:解决, 在这里采取的是模式字符串的 hash()值, 和hashCode的计算相类似,只要将被除数(也即散列表中的容量) 设定的足够大, 如 10的20次方, 这样 冲突的的概率就只有 1/10^20, 可以忽略不计.
但这里我们仅需要保存模式字符串的散列值即可.
public long hash(String key, int M) { long h = 0; for (int i = 0; i < M; i++) { h = (R * h + key.charAt(i)) % Q } return h; }
-
在文本字符串如果采取同样的方式, 如对 01234 12345 23456 位都这样计算hash值, 效率也是要比暴力破解法低下的, 因为不仅要遍历, 还需要计算, 比较hash值.
1:解决, 问题就在于如何高效的算出来对应的 hash值.
如果用 Ti 表示 text.charAt(i),对于 text的起始位置为i的前M个字符计算值如下.
Xi = Ti * R^(M-1) + Ti+1 * R^(M-2) + Ti+2 * R^(M-1) + ... + T(i+M-1) * R^0
hash(xi) = Xi % Q;
X(i+1) = (Xi - Ti * R^(M - 1)) * R + T(i+M) * R^0;
用C表示常数,则:
X(i+1) = C1 * Xi - C2 * Ti + T(i+M);
这样就能在常数时间内求取对应的散列值.
最后只需要比较散列值是否相同, 就可以判断字符串是否相同, 如果还是想更进一步的话, 可以在散列值相同的情况下再比较字符串.
而这种算法的优点则是, 在空间 和 最坏情况下的时间取了折中效果. 它不需要额外的空间, 而在所有情况下的求取时间都是相同的, 为 7N;
总结如下:
Java的 indexOf() 采取的方法就是暴力破解法, 缺点是最坏的情况下O(MN), KMP算法为线性级别, 且需要额外的空间, 优点是 算法无需回退, 适用于流的情况. BM算法也同样需要额外的空间, O(N/M), 将速度提升M倍;而 Rabin-Karp算法无需额外的空间, 计算时间为线性的.
因此在常规情况下使用暴力破解法已经足够, 其他几种视情况而定.