第30章:综合实战——多算法融合、系统设计思维、经典例题

终于到了最后一章。说实话,写到这里我有点感慨。

前面29章,我们拆解了各种算法模式——二分、回溯、DP、图论……但现实中的问题,从来不会贴个标签告诉你「我用动态规划」。真正的工程难题,往往是多种算法的组合拳。

今天我们就拿两个经典题目来练手:设计Twitter单词搜索II。一个偏系统设计,一个偏算法融合。你想想看,这两个题恰好代表了面试中的两个极端——一个考你架构能力,一个考你代码功底。

30.1 单词搜索II:Trie + 回溯的经典组合

先讲单词搜索II。这题我当年在面试Google时遇到过,当时差点翻车。

题目很简单:给你一个字符矩阵,再给你一堆单词,找出所有能在矩阵中拼出来的单词。你可以上下左右走,不能重复用同一个格子。

最笨的办法是什么?对每个单词做一次DFS。但如果有1000个单词,矩阵是12x12,那复杂度就炸了——O(N * M * 4^L),N是单词数,L是单词长度。

我当时第一反应也是这么干,写到一半突然意识到不对。面试官看了我一眼,说:「你想想,有没有办法复用搜索路径?」

嗯,这就是关键——Trie树

核心思路:

  • 把所有单词建成一棵Trie树
  • 在矩阵上做DFS,同时走Trie树的节点
  • 如果当前路径在Trie里找不到,直接剪枝
  • 走到Trie的叶子节点,就找到了一个单词

说白了,就是把「对每个单词单独搜索」变成了「一次搜索匹配所有单词」。Trie在这里起到了字典加速的作用。

class TrieNode {
    TrieNode[] children = new TrieNode[26];
    String word = null;  // 存储完整单词,方便回溯时收集结果
}

class Solution {
    List<String> result = new ArrayList<>();
    
    public List<String> findWords(char[][] board, String[] words) {
        // 1. 建Trie
        TrieNode root = new TrieNode();
        for (String w : words) {
            TrieNode node = root;
            for (char c : w.toCharArray()) {
                int idx = c - 'a';
                if (node.children[idx] == null) {
                    node.children[idx] = new TrieNode();
                }
                node = node.children[idx];
            }
            node.word = w;
        }
        
        // 2. 对每个格子做DFS
        for (int i = 0; i < board.length; i++) {
            for (int j = 0; j < board[0].length; j++) {
                dfs(board, i, j, root);
            }
        }
        return result;
    }
    
    void dfs(char[][] board, int i, int j, TrieNode node) {
        char c = board[i][j];
        if (c == '#' || node.children[c - 'a'] == null) return;
        
        node = node.children[c - 'a'];
        if (node.word != null) {
            result.add(node.word);
            node.word = null;  // 防止重复添加
        }
        
        board[i][j] = '#';  // 标记已访问
        int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}};
        for (int[] d : dirs) {
            int ni = i + d[0], nj = j + d[1];
            if (ni >= 0 && ni < board.length && nj >= 0 && nj < board[0].length) {
                dfs(board, ni, nj, node);
            }
        }
        board[i][j] = c;  // 回溯恢复
    }
}

个人经验:我曾经在项目里用这个思路做敏感词过滤。用户输入一段文本,我们要快速检测是否包含黑名单里的词。Trie + 回溯的变体,配合AC自动机,性能比正则表达式快了10倍不止。

这里有个坑要注意——重复单词。如果矩阵里同一个单词出现多次,你只应该返回一次。我一开始没加 node.word = null 这行,结果结果集里一堆重复。嗯,这种小细节,debug起来挺烦的。

30.2 设计Twitter:系统设计中的算法思维

接下来这个题,很多人觉得是系统设计,跟算法没关系。其实不然。

设计Twitter的核心功能:

  • 用户发推
  • 用户关注/取关
  • 获取用户的时间线(自己发的 + 关注的人发的,按时间倒序)
  • 获取热点新闻流(这个我们简化,先不做)

你想想看,如果让你用纯SQL做,怎么查「我关注的所有人的最近10条推文」?

嗯,JOIN + ORDER BY + LIMIT。但如果关注了1000个人,每人发了10000条推文,这个查询就慢了。

这时候,算法就派上用场了——多路归并

核心设计:

  • 每个用户维护一个自己的推文链表(按时间倒序)
  • 获取时间线时,从所有关注的人的链表头部取数据
  • 用优先队列(最大堆)做多路归并,取最新的K条

说白了,这就是合并K个有序链表的变体。你看,算法模式又出现了。

class Twitter {
    // 用户 - 推文链表(用LinkedList模拟,头插法保证最新在前)
    Map<Integer, LinkedList<Tweet>> tweets;
    // 用户 - 关注列表
    Map<Integer, Set<Integer>> follows;
    int timestamp = 0;
    
    class Tweet {
        int id, time;
        Tweet(int id) { this.id = id; this.time = timestamp++; }
    }
    
    public Twitter() {
        tweets = new HashMap<>();
        follows = new HashMap<>();
    }
    
    public void postTweet(int userId, int tweetId) {
        tweets.putIfAbsent(userId, new LinkedList<>());
        tweets.get(userId).addFirst(new Tweet(tweetId));
    }
    
    public void follow(int followerId, int followeeId) {
        follows.putIfAbsent(followerId, new HashSet<>());
        follows.get(followerId).add(followeeId);
    }
    
    public void unfollow(int followerId, int followeeId) {
        if (follows.containsKey(followerId)) {
            follows.get(followerId).remove(followeeId);
        }
    }
    
    public List<Integer> getNewsFeed(int userId) {
        // 多路归并:用最大堆,按时间戳排序
        PriorityQueue<Tweet> heap = new PriorityQueue<>(
            (a, b) -> b.time - a.time
        );
        
        // 把自己加进去
        if (tweets.containsKey(userId)) {
            heap.addAll(tweets.get(userId));
        }
        
        // 把关注的人加进去
        Set<Integer> followees = follows.getOrDefault(userId, new HashSet<>());
        for (int f : followees) {
            if (tweets.containsKey(f)) {
                heap.addAll(tweets.get(f));
            }
        }
        
        // 取前10条
        List<Integer> res = new ArrayList<>();
        for (int i = 0; i < 10 && !heap.isEmpty(); i++) {
            res.add(heap.poll().id);
        }
        return res;
    }
}

注意:上面的实现有个性能问题——heap.addAll 会把所有推文都塞进堆里。如果关注的人每人有几千条推文,堆的大小会很大。更好的做法是:只从每个链表的头部取一条,然后边取边补充。这才是真正的「多路归并」。我在实际项目中踩过这个坑,后来改成了懒加载的方式。

30.3 知识体系总览

这两个题,其实代表了算法学习的两个方向:

  • 单词搜索II:数据结构(Trie)+ 搜索算法(回溯/DFS)的融合
  • 设计Twitter:系统设计 + 经典算法模式(多路归并)的结合

我个人的体会是,面试到了高级阶段,考的不是你会不会背模板,而是你能不能把学过的模式灵活组合。就像乐高积木,单个模块谁都会拼,但能搭出复杂结构才是真本事。

避坑指南:我曾经在系统设计面试里,只关注了「用什么数据结构」,忽略了「数据量级」。面试官问「如果用户有1亿,每天发推10亿条,你的方案还扛得住吗?」我当场愣住了。所以,设计时一定要考虑——内存够不够?读写比例是多少?需不需要分片?

30.4 核心逻辑流程图

下面这张图,把本章两个题的核心逻辑串在了一起。你看,左边是单词搜索II的Trie+回溯流程,右边是Twitter的多路归并流程。它们底层都依赖同一个思想——用高效的数据结构减少搜索空间

单词搜索II:Trie + 回溯 Step 1: 所有单词建Trie树 Step 2: 遍历矩阵每个格子 Step 3: DFS + Trie节点同步移动 Step 4: 匹配到单词 → 收集结果 剪枝:Trie无此路径 → 回溯 时间复杂度:O(M·N·4^L) 但剪枝后大幅优化 设计Twitter:多路归并 Step 1: 每个用户维护推文链表 Step 2: 获取关注列表 Step 3: 所有链表头部入堆 Step 4: 最大堆取最新推文 优化:懒加载,只取链表头部 时间复杂度:O(K·logK) K为关注人数

30.5 写在最后

30章,从基础的数据结构到高级算法模式,从理论分析到实战应用,我们走了一段不短的路。

我记得刚开始写这个课程时,就在想——到底什么样的内容对读者最有价值?后来我想明白了:不是堆砌知识点,而是帮你建立「算法直觉」。看到一个问题,能快速判断出它属于哪类模式,能用什么数据结构优化,边界条件在哪里。

这种直觉,靠背是背不出来的。得多练,多总结,多踩坑。

嗯,课程到这里就结束了。但你的算法之路,才刚刚开始。

最后送你三句话:

  1. 没有银弹——没有一种算法能解决所有问题,但模式可以复用
  2. 纸上得来终觉浅——光看课程不写代码,等于没学
  3. 保持好奇心——每一个「为什么这样写」的背后,都可能藏着一个更好的方案

加油,我们江湖再见。

公众号:蓝海资料掘金营,微信deep3321