前缀树与非比较排序

介绍 Trie、计数排序和基数排序,并比较不同排序算法的稳定性与适用场景。

约 6 分钟2,121 字#算法#数据结构

1. 引言

在算法的世界中,数据结构和排序算法是两大基石。本文将深入探讨一种高效处理字符串前缀的数据结构——前缀树(Trie),以及两种不依赖元素间比较的排序算法——计数排序和基数排序。我们将通过分析具体的Java实现代码,来理解它们的核心思想和实现细节。

2. 前缀树 (Trie)

前缀树,又称字典树或Trie,是一种专门用于存储和检索字符串集合的树形数据结构。它在处理字符串前缀相关的查询时表现得极为高效。

2.1 节点设计

前缀树的实现首先需要定义其节点结构。在本次的实现中,每个节点包含三个关键信息:

  • pass:一个整数,记录有多少个字符串曾经经过这个节点。
  • end:一个整数,记录有多少个字符串以这个节点作为结尾。
  • nexts:一个哈希表(HashMap<Character, Node>),用于存储指向下一个字符节点的引用。使用哈希表可以使其支持任意字符,而不仅限于小写字母。

2.2 核心操作

基于上述节点设计,我们可以实现Trie的几个核心操作。

2.2.1 插入 (insert)

插入一个字符串时,我们从根节点开始,沿着字符串的字符路径遍历。

  1. 根节点的 pass 计数加1。
  2. 对于字符串中的每一个字符,检查当前节点的 nexts 中是否已存在该字符的路径。
  3. 如果路径不存在,则创建一个新节点,并将其放入 nexts 中。
  4. 移动到下一个节点,并将其 pass 计数加1。
  5. 当字符串遍历完成后,将最后一个节点的 end 计数加1,表示有一个字符串在这里结束。

2.2.2 查找字符串 (countStrNum)

要查询一个完整的字符串在Trie中出现了多少次,我们只需:

  1. 从根节点开始,沿着字符串的字符路径进行遍历。
  2. 如果在遍历过程中发现路径中断(即 nexts 中不包含某个字符),说明该字符串从未被插入过,返回0。
  3. 如果成功遍历完整个字符串,返回最后一个节点的 end 计数即可。

2.2.3 查找前缀 (countPrefixNum)

查询以特定字符串为前缀的字符串有多少个,其过程与查找字符串类似:

  1. 从根节点开始,沿着前缀字符串的字符路径遍历。
  2. 如果路径中断,说明没有任何字符串以此为前缀,返回0。
  3. 如果成功遍历完整个前缀,返回最后一个节点的 pass 计数。这个 pass 值即代表了有多少个字符串共享这个前缀。

2.2.4 删除 (remove)

删除字符串是插入逆操作,需要更新 passend 计数,并包含一个剪枝的优化:

  1. 首先检查该字符串是否存在(countStrNum(str) > 0),不存在则直接返回。
  2. 从根节点开始,pass 计数减1。
  3. 沿着字符串路径遍历,对于每一个字符:
    • 获取下一个节点,并将其 pass 计数减1。
    • 优化:如果下一个节点的 pass 计数变为0,说明没有任何其他字符串再经过这个节点。此时,可以直接从当前节点的 nexts 中移除该字符路径,实现剪枝,然后返回。
    • 如果 pass 不为0,则继续移动到下一个节点。
  4. 遍历完成后,将最后一个节点的 end 计数减1。

2.3 代码实现

java
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 核心思想

计数排序的核心思想是利用数组下标来确定元素的正确位置。它假设输入数据是确定范围内的(通常是

非负)整数。

  1. 查找范围:遍历数组,找到最大值 max
  2. 创建计数数组:创建一个大小为 max + 1 的计数数组 count
  3. 计数:再次遍历原数组 arr,对于每个元素 num = arr[i],执行 count[num]++。遍历完成后,count[j] 就存储了元素 jarr 中出现的次数。
  4. 回填:遍历 count 数组,从 i = 0max。如果 count[i] 的值(例如为 $k$),则说明元素 i 出现了 $k$ 次,就将 $k$ 个 i 依次写回原数组 arr

3.1.2 适用场景与局限

计数排序非常快,时间复杂度为 $O(N + K)$,其中 $N$ 是数组长度,$K$ 是数据的范围(即 max 值)。

  • 优点:当 $K$ 相对于 $N$ 较小(例如,排序100万个0到1000范围内的数)时,它接近线性时间,效率极高。
  • 局限
    1. 它依赖于数据范围。如果数据范围极大(例如,排序100个0到1亿的数),count 数组会占用巨量内存,导致算法不可行。
    2. 它通常只适用于非负整数。对于浮点数或负数,需要额外处理。
    3. 如下面代码所示的这种实现是不稳定的(详见 4.3 节)。

3.1.3 代码实现

java
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 核心思想

  1. 确定最大位数:遍历数组,找到最大值 max,并计算出它的十进制位数 maxPlace。这决定了我们需要循环排序的次数。
  2. 按位排序:从最低位开始(curPlace = 0,即个位),循环到最高位(curPlace = maxPlace - 1)。
  3. 内部排序:在每一次循环中,使用一种稳定的排序算法,按照当前位(curPlace)上的数字(0-9)对整个数组 arr 进行排序。
  4. 当最高位排序完成后,整个数组就有序了。

3.2.2 实现细节(基于稳定计数排序)

基数排序的正确性严重依赖其内部排序算法的稳定性。在下面的实现中,内部排序采用了一种稳定版的计数排序。

对于arr中的每一个数,我们通过 getPlace(num, curPlace) 方法获取其在 curPlace 位上的数字。然后,对这些0-9的数字进行稳定排序:

  1. count 数组int[] count = new int[10]。用于统计当前位上,数字0到9各自出现了多少次。
  2. preSum 数组(前缀和)preSum[i] 存储的是 "当前位上小于等于 $i$ 的数" 一共有多少个。
    • 例如,preSum[4] = 15,意味着个位上是0、1、2、3、4的数一共有15个。
  3. help 辅助数组:用于临时存放本次按位排序的结果。
  4. 关键步骤(从右向左遍历)
    • 我们从右向左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 中一致,从而保证了稳定性。
  5. 拷贝:将 help 数组的内容拷贝回 arr,完成这一位的排序。

3.2.3 代码实现

java
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)相同,但需要保留原始次序时。