前缀树与非比较排序
介绍 Trie、计数排序和基数排序,并比较不同排序算法的稳定性与适用场景。
1. 引言
在算法的世界中,数据结构和排序算法是两大基石。本文将深入探讨一种高效处理字符串前缀的数据结构——前缀树(Trie),以及两种不依赖元素间比较的排序算法——计数排序和基数排序。我们将通过分析具体的Java实现代码,来理解它们的核心思想和实现细节。
2. 前缀树 (Trie)
前缀树,又称字典树或Trie,是一种专门用于存储和检索字符串集合的树形数据结构。它在处理字符串前缀相关的查询时表现得极为高效。
2.1 节点设计
前缀树的实现首先需要定义其节点结构。在本次的实现中,每个节点包含三个关键信息:
pass:一个整数,记录有多少个字符串曾经经过这个节点。end:一个整数,记录有多少个字符串以这个节点作为结尾。nexts:一个哈希表(HashMap<Character, Node>),用于存储指向下一个字符节点的引用。使用哈希表可以使其支持任意字符,而不仅限于小写字母。
2.2 核心操作
基于上述节点设计,我们可以实现Trie的几个核心操作。
2.2.1 插入 (insert)
插入一个字符串时,我们从根节点开始,沿着字符串的字符路径遍历。
- 根节点的
pass计数加1。 - 对于字符串中的每一个字符,检查当前节点的
nexts中是否已存在该字符的路径。 - 如果路径不存在,则创建一个新节点,并将其放入
nexts中。 - 移动到下一个节点,并将其
pass计数加1。 - 当字符串遍历完成后,将最后一个节点的
end计数加1,表示有一个字符串在这里结束。
2.2.2 查找字符串 (countStrNum)
要查询一个完整的字符串在Trie中出现了多少次,我们只需:
- 从根节点开始,沿着字符串的字符路径进行遍历。
- 如果在遍历过程中发现路径中断(即
nexts中不包含某个字符),说明该字符串从未被插入过,返回0。 - 如果成功遍历完整个字符串,返回最后一个节点的
end计数即可。
2.2.3 查找前缀 (countPrefixNum)
查询以特定字符串为前缀的字符串有多少个,其过程与查找字符串类似:
- 从根节点开始,沿着前缀字符串的字符路径遍历。
- 如果路径中断,说明没有任何字符串以此为前缀,返回0。
- 如果成功遍历完整个前缀,返回最后一个节点的
pass计数。这个pass值即代表了有多少个字符串共享这个前缀。
2.2.4 删除 (remove)
删除字符串是插入逆操作,需要更新 pass 和 end 计数,并包含一个剪枝的优化:
- 首先检查该字符串是否存在(
countStrNum(str) > 0),不存在则直接返回。 - 从根节点开始,
pass计数减1。 - 沿着字符串路径遍历,对于每一个字符:
- 获取下一个节点,并将其
pass计数减1。 - 优化:如果下一个节点的
pass计数变为0,说明没有任何其他字符串再经过这个节点。此时,可以直接从当前节点的nexts中移除该字符路径,实现剪枝,然后返回。 - 如果
pass不为0,则继续移动到下一个节点。
- 获取下一个节点,并将其
- 遍历完成后,将最后一个节点的
end计数减1。
2.3 代码实现
import java.util.HashMap;
/**
* 前缀树的实现:
* 1. 基本结构
* 2. 操作集合:插入、删除、查找字符串、计数以xx为前缀的字符串的个数
*
*/
public class Trie {
// 节点类
private static class Node {
public HashMap<Character, Node> nexts;
public int pass;
public int end;
public Node() {
pass = 0;
end = 0;
nexts = new HashMap<>();
}
}
private Node root;
public Trie() {
root = new Node();
}
/**
* 插入元素
*/
public void insert(String str) {
if (str == null) {
return;
}
Node curNode = root;
curNode.pass++;
for (int i = 0; i < str.length(); i++) {
char curChar = str.charAt(i);
if (!curNode.nexts.containsKey(curChar)) {
curNode.nexts.put(curChar, new Node());
curNode = curNode.nexts.get(curChar);
} else {
curNode = curNode.nexts.get(curChar);
}
curNode.pass++;
}
curNode.end++;
}
/**
* 删除指定字符串
*/
public void remove(String str) {
if (str == null || root == null) {
return;
}
if (countStrNum(str) == 0) {
return;
}
Node curNode = root;
curNode.pass--;
for (int i = 0; i < str.length(); i++) {
char curChar = str.charAt(i);
if (curNode.nexts.get(curChar).pass - 1 == 0) {
curNode.nexts.remove(curChar);
return;
}
curNode = curNode.nexts.get(curChar);
curNode.pass--;
}
curNode.end--;
}
/**
* 查找前缀树中存储了字符串str的个数
*/
public int countStrNum(String str) {
if (str == null) {
return 0;
}
Node curNode = root;
for (int i = 0; i < str.length(); i++) {
char curChar = str.charAt(i);
if (!curNode.nexts.containsKey(curChar)) {
return 0;
}
curNode = curNode.nexts.get(curChar);
}
return curNode.end;
}
/**
* 查找前缀树中以pref为前缀的字符串有多少个
*/
public int countPrefixNum(String pref) {
if (pref == null) {
return 0;
}
Node curNode = root;
for (int i = 0; i < pref.length(); i++) {
char curChar = pref.charAt(i);
if (!curNode.nexts.containsKey(curChar)) {
return 0;
}
curNode = curNode.nexts.get(curChar);
}
return curNode.pass;
}
}
3. 非比较排序
大多数常见的排序算法,如快速排序、归并排序,都依赖于元素之间的比较操作(大于、小于、等于)。这类算法的时间复杂度下限被证明为 $O(N \log N)$。
然而,存在一类不依赖比较的排序算法,它们可以突破这个下限,在特定条件下达到 $O(N)$ 的时间复杂度。我们将探讨其中的两种:计数排序和基数排序。
3.1 计数排序 (Counting Sort)
3.1.1 核心思想
计数排序的核心思想是利用数组下标来确定元素的正确位置。它假设输入数据是确定范围内的(通常是
非负)整数。
- 查找范围:遍历数组,找到最大值
max。 - 创建计数数组:创建一个大小为
max + 1的计数数组count。 - 计数:再次遍历原数组
arr,对于每个元素num = arr[i],执行count[num]++。遍历完成后,count[j]就存储了元素j在arr中出现的次数。 - 回填:遍历
count数组,从i = 0到max。如果count[i]的值(例如为 $k$),则说明元素i出现了 $k$ 次,就将 $k$ 个i依次写回原数组arr。
3.1.2 适用场景与局限
计数排序非常快,时间复杂度为 $O(N + K)$,其中 $N$ 是数组长度,$K$ 是数据的范围(即 max 值)。
- 优点:当 $K$ 相对于 $N$ 较小(例如,排序100万个0到1000范围内的数)时,它接近线性时间,效率极高。
- 局限:
- 它依赖于数据范围。如果数据范围极大(例如,排序100个0到1亿的数),
count数组会占用巨量内存,导致算法不可行。 - 它通常只适用于非负整数。对于浮点数或负数,需要额外处理。
- 如下面代码所示的这种实现是不稳定的(详见 4.3 节)。
- 它依赖于数据范围。如果数据范围极大(例如,排序100个0到1亿的数),
3.1.3 代码实现
import java.util.Arrays;
public class CountSort {
public void countSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int max = Integer.MIN_VALUE;
for (int i = 0; i < arr.length; i++) {
max = Math.max(arr[i], max);
}
int[] count = new int[max + 1];
for (int i = 0; i < arr.length; i++) {
count[arr[i]]++;
}
int k = 0;
for (int i = 0; i < count.length; i++) {
while (count[i] != 0) {
arr[k++] = i;
count[i]--;
}
}
}
3.2 基数排序 (Radix Sort)
基数排序(LSD, Least Significant Digit)是一种巧妙的排序算法,它不比较整个数的大小,而是将数按“位”拆分,从最低位(个位)开始,依次对每一位进行排序,直到最高位。
3.2.1 核心思想
- 确定最大位数:遍历数组,找到最大值
max,并计算出它的十进制位数maxPlace。这决定了我们需要循环排序的次数。 - 按位排序:从最低位开始(
curPlace = 0,即个位),循环到最高位(curPlace = maxPlace - 1)。 - 内部排序:在每一次循环中,使用一种稳定的排序算法,按照当前位(
curPlace)上的数字(0-9)对整个数组arr进行排序。 - 当最高位排序完成后,整个数组就有序了。
3.2.2 实现细节(基于稳定计数排序)
基数排序的正确性严重依赖其内部排序算法的稳定性。在下面的实现中,内部排序采用了一种稳定版的计数排序。
对于arr中的每一个数,我们通过 getPlace(num, curPlace) 方法获取其在 curPlace 位上的数字。然后,对这些0-9的数字进行稳定排序:
count数组:int[] count = new int[10]。用于统计当前位上,数字0到9各自出现了多少次。preSum数组(前缀和):preSum[i]存储的是 "当前位上小于等于 $i$ 的数" 一共有多少个。- 例如,
preSum[4] = 15,意味着个位上是0、1、2、3、4的数一共有15个。
- 例如,
help辅助数组:用于临时存放本次按位排序的结果。- 关键步骤(从右向左遍历):
- 我们从右向左(
i = arr.length - 1; i >= 0; i--)遍历原数组arr。 - 取出一个数
arr[i],获取它当前位的数字curNum。 - 查找
preSum[curNum]。这个值(例如15)告诉我们,arr[i]应该被放在help数组中索引为 $0$ 到 $14$ 的位置上。 - 因为我们是从右向左遍历的,所以我们把
arr[i]放在这个范围的最右边,即help[preSum[curNum] - 1]的位置,也就是help[14]。 - 放置后,我们将
preSum[curNum]减1(变为14),为下一个同样是 $4$ 的数(如果存在的话)准备位置help[13]。 - 这个 "从右向左" 的遍历配合前缀和数组,确保了相同位值的数,它们在
help数组中的相对顺序与在arr中一致,从而保证了稳定性。
- 我们从右向左(
- 拷贝:将
help数组的内容拷贝回arr,完成这一位的排序。
3.2.3 代码实现
import java.util.Arrays;
public class RadixSort {
public void radixSort(int[] arr) {
// 1
int max = Integer.MIN_VALUE;
for (int i = 0; i < arr.length; i++) {
max = Math.max(max, arr[i]);
}
int maxPlace = 0;
while (max > 0) {
maxPlace++;
max /= 10;
}
// 2
for (int curPlace = 0; curPlace < maxPlace; curPlace++) {
// 2.1
int[] count = new int[10];
for (int j = 0; j < arr.length; j++) {
int curNum = getPlace(arr[j], curPlace);
count[curNum]++;
}
// 2.2
int[] preSum = new int[10];
preSum[0] = count[0];
for (int i = 1; i < preSum.length; i++) {
preSum[i] = preSum[i - 1] + count[i];
}
// 2.3
int[] help = new int[arr.length];
// 2.4
for(int i = arr.length - 1; i >= 0; i--) {
int curNum = getPlace(arr[i], curPlace);
help[--preSum[curNum]] = arr[i];
}
// 3
for (int i = 0; i < arr.length; i++) {
arr[i] = help[i];
}
}
}
public int getPlace(int num, int place) {
num /= (int) Math.pow(10, place);
return num % 10;
}
4. 排序的稳定性
4.1 什么是稳定性
排序算法的稳定性指的是:对于数组中两个值相等的元素,它们在排序后的相对位置与排序前的相对位置保持不变。
例如,排序 [(5, 'a'), (3, 'b'), (5, 'c')]:
- 稳定的排序结果必须是:
[(3, 'b'), (5, 'a'), (5, 'c')]。(5, 'a')在(5, 'c')之前。 - 不稳定的排序结果可能是:
[(3, 'b'), (5, 'c'), (5, 'a')]。(5, 'a')和(5, 'c')的顺序发生了改变。
稳定性在某些场景下至关重要,例如当排序键(Key)相同,但需要保留原始次序时。