[TOC]

数据结构与算法

有句老话是程序就是数据结构与算法,即使是操作系统这样复杂的程序。
数据结构是算法的前提,以leetcode之类的刷题网站来说,考察的算法较为朴素,更多的是考察数据结构的运用。而学好数据结构,在日常工作中解决一些棘手问题,往往能起到事半功倍的效果。
除了教材中学到的链表、树、图、哈希、堆栈之类的常见数据结构外,有些高阶的数据结构也值得学习,其设计堪称诡谲、其用途堪称巧妙。

前缀树

前缀树(Trie)又称字典树,是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。使用过solr或者es等倒排索引工具的开发者应该对此比较敏感。208. 实现 Trie (前缀树) 即是前缀树的设计题目。
前缀树本质是一种树,不过这种树比较特殊,以英文小写字串为例,每个节点有26个子节点,同时每个节点除了存储字母外,附带一个是否结束的标识,如果结束则说明能搜索到结果,否则仅仅是前缀。原理简单,检索的复杂度也极低(O(n)),大道至简。
首先是初始化:

    private final Trie[] children;
    private boolean isEnd;

    public Trie() {
        children = new Trie[26];
        isEnd = false;
    }

接着是插入,这里比较巧妙的一点是利用char -'a'获取下标,实现按下标取值:
    private final Trie[] children;
    private boolean isEnd;

    public Trie() {
        children = new Trie[26];
        isEnd = false;
    }

然后就是检索,这里只列举核心的前缀检索,实际结果判断下是否结束即可实现全词匹配搜索和前缀搜索的功能:
    private Trie searchPrefix(String prefix) {
        Trie node = this;
        for (int i = 0; i < prefix.length(); i++) {
            char ch = prefix.charAt(i);
            int index = ch - 'a';
            if (node.children[index] == null) {
                return null;
            }
            node = node.children[index];
        }
        return node;
    }

前缀和

前缀和是典型的原理粗暴却效率很高的数据结构与算法。前缀和分两种:一维前缀和和多维前缀和。303. 区域和检索 - 数组不可变 是一维前缀和的题目。一维前缀和的原理非常简单,类似差分:比如求一个数组第三条到第六条之间的数据和,只需要把前6条相加的和,减去前2条相加的和,即可。具体操作上,O(n)的复杂度,记录每一项的前缀和,然后一劳永逸,直接下标相减即可得到区间和:


//初始化前缀和数组
    public NumArray(int[] nums) {
        int len = nums.length;
        ps = new int[len + 1];

        for (int i = 0; i < len; i++) {
            ps[i + 1] = ps[i] + nums[i];
        }

    }

//相减得到前缀和
    public int sumRange(int i, int j) {
        return ps[j + 1] - ps[i];
    }

二维前缀和理解起来要费劲很多,原理可以用下图表示: 前缀和
S(O,D)=S(O,C)+S(O,B)−S(O,A)+D
减去 S(O, A)S(O,A) 的原因是 S(O, C)S(O,C) 和 S(O, B)S(O,B) 中都有 S(O, A)S(O,A),即加了两次 S(O, A)S(O,A),所以需要减去一次 S(O, A)S(O,A)。

304. 二维区域和检索 - 矩阵不可变 是二维前缀和的题目。实际上这个题目也可以用一维前缀和解决,将每一行的区间和相加即可,不过这种O(n)的复杂度与前缀和要实现的O(1)的复杂度相悖。故应当用二维解决:

//初始化前缀和数组
public NumMatrix304(int[][] matrix) {
        int height = matrix.length, width = matrix[0].length;
        prex = new int[height + 1][width + 1];
        for (int i = 0; i < height; i++) {
            for (int j = 0; j < width; j++) {
                prex[i + 1][j + 1] = prex[i][j + 1] + prex[i + 1][j] - prex[i][j] + matrix[i][j];//多加了一次重叠的区域,剪掉
            }
        }

    }

//相减得到前缀和
    public int sumRegion(int row1, int col1, int row2, int col2) {
        return prex[row2 + 1][col2 + 1] - prex[row1][col2 + 1] - prex[row2 + 1][col1] + prex[row1][col1];//多减了一次重叠的区域,加回来
    }

363. 矩形区域不超过 K 的最大数值和 是二维前缀和的应用题。可以在304的基础上嵌套2次循环,暴力解决:

 int[][] pres;

    public int maxSumSubmatrix(int[][] matrix, int k) {

        int height = matrix.length,width = matrix[0].length;
        pres = new int[height+1][width+1];
        for(int i=0;i<height;++i){
            for(int j=0;j<width;++j){
                pres[i+1][j+1] = pres[i][j+1] + pres[i+1][j] - pres[i][j] + matrix[i][j];
            }
        }
        int max = Integer.MIN_VALUE;
        int temp;
        for(int i=0;i<height;++i){
            for(int j=0;j<width;++j){
                for(int m=0;m<=i;++m){
                    for(int n=0;n<=j;++n){
                        temp = pres[i+1][j+1] - pres[i+1][n] - pres[m][j+1] + pres[m][n];
                        if(temp <=k)
                            max = Math.max(max,temp);
                    }
                }
            }
        }
        return max;
    }

单调队列

单调队列有点像单调栈,在 算法笔记(二):栈与单调栈 一文描述过单调栈的用法。单调栈利用单调性巧妙地降低了算法复杂度,不过由于需要虚拟标兵节点,较难理解。单调队列则简单很多,无非是一个维护单调性的双端队列,因为具有单调性,所以队列的最大值必然在队首(假设为单调递减队列);因为具备双端性,所以可以从两端取数据;因为是队列,所以仅从队尾入队。三条特性结合,无疑可以在O(1)的时间内取到最值数据。
239.滑动窗口的最大值 是单调队列的入门题目,虽然难度是hard,但对单调队列数列的话,解决起来相较medium题目反而更简单。:

    public int[] maxSlidingWindow(int[] nums, int k) {
        int lens = nums.length;
        if (lens==0)return new int[]{};

        int[] ans = new int[lens - k +1];
        Deque<Integer> deque = new LinkedList<>();
        int index;
        for (int i = 0; i < lens; i++) {
            //维护单调性,如果单调性被破坏,则从队尾删除数据,一直到单调性恢复为止,如此可以保证最左则始终为当前最大值
            while(!deque.isEmpty() && nums[deque.getLast()]  < nums[i]){
                deque.removeLast();
            }
            deque.addLast(i);

            if (i>=k-1){
                index = i-k+1;
                ans[index] = nums[deque.peekFirst()];
                if(deque.peekFirst() == index)deque.removeFirst();
                //取到滑动窗口的最大值,如果当前最大值是原数组滑动窗口的最左则,窗口右滑会将之删掉
            }

        }
        return ans;

    }

单调队列与滑动窗口结合,恰如双指针与单调栈结合。注意由于是双端队列,需要调用removeLast()方法而不是pop()。

862. 和至少为 K 的最短子数组 是单调队列的应用题目,难度同为hard,此题明显更难:

  public int shortestSubarray(int[] A, int K) {
          int len = A.length;
          long[] prefixs = new long[len+1];//前缀和
          for (int i = 0; i < len; ++i)
              prefixs[i+1] = prefixs[i] + (long) A[i];
  
          int ans = len+1;
          Deque<Integer> deque = new LinkedList<>();//双端队列+单调性
  
          for (int y = 0; y < prefixs.length; ++y) {
              //如果现在队列里存在更大的前缀,说明当前值是负数,那么后续满足条件的,肯定在负数这个点的后面,保存前面大的数据就没有用,维持单调即可
              // 比如4,-1,2,1查找和大于等于6,则前缀和为0,4,3,5,6;在6满足条件,前面的4完全没必要
              while (!deque.isEmpty() && prefixs[y] <= prefixs[deque.getLast()])
                  deque.removeLast();
              //左边的满足条件,记录位移后即可删掉,右侧再移动,如果遇到与左边的相减满足条件,肯定宽度比当前的要大,故可以删除,因为是单调递增,所以左右不满足,左右之间的肯定也不满足,继续向右移动即可
              while (!deque.isEmpty() && prefixs[y] >= prefixs[deque.getFirst()] + K)
                  ans = Math.min(ans, y - deque.removeFirst());
  
              deque.addLast(y);
          }
  
          return ans < len+1 ? ans : -1;
      }

这个题目实际实现上,与上一个题目几乎完全相同。或者说,单调队列的题目都大致如此。细节的差异,其一此题没有滑动窗口的概念,而是要记录最短的那个长度,故记录后直接删掉队首即可;其二由于是前缀和,这个单调队列必然是单调递增,与上一个题目相反。


Is this article useful to you? How about buy me a coffee ?