数据结构与算法

写代码越久越发现——业务千变万化,但底层都是数据结构 + 算法。一个 list 怎么存最快查找?一段代码为什么慢?怎么在百万数据里找前 K 个?这些都要靠数据结构和算法知识。这一章我们把核心——复杂度、链表栈队列、树、哈希、排序、常见算法套路——一次过一遍。

一、复杂度分析

时间复杂度(Time Complexity)——算法运行时间随输入规模 n 增长的趋势,用大 O 表示法(Big-O Notation)。

复杂度名字例子n=100 万时
O(1)常数数组取下标1 步
O(log n)对数二分查找20 步
O(n)线性遍历数组100 万步
O(n log n)线性对数快排/归并2000 万步
O(n²)平方冒泡/选择1 万亿步
O(2ⁿ)指数暴力子集宇宙爆炸

空间复杂度(Space Complexity)——额外内存随 n 的增长趋势。

经验:

  • O(n²) 在 n > 1 万时已经慢得不行——必须优化。
  • O(n log n) 是排序算法的”下界”——比较排序最快就这样。
  • O(log n) 几乎等于”常数”——二分、平衡树的高度都是 log n。

二、线性结构

2.1 数组

数组——内存连续、固定大小、O(1) 随机访问、O(n) 插入删除。Java 的 ArrayList 底层是数组——容量不够时自动扩容(1.5 倍)。

2.2 链表

链表——内存不连续、节点串起来、O(1) 头插删、O(n) 随机访问。

class Node {
    int val;
    Node next;
    Node(int v) { val = v; }
}

// 单链表反转 (经典面试题)
Node reverse(Node head) {
    Node prev = null, cur = head;
    while (cur != null) {
        Node next = cur.next;
        cur.next = prev;
        prev = cur;
        cur = next;
    }
    return prev;
}

LinkedList 是双向链表——add/remove 头尾 O(1),随机 get(i) 是 O(n)。所以LinkedList 几乎不用——ArrayList 即使插入也大多比它快(CPU 缓存友好)。

2.3 栈

栈——后进先出(LIFO)。push/pop/peek 都 O(1)。

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);
stack.peek();   // 2
stack.pop();    // 2

应用:括号匹配、表达式求值、函数调用栈、DFS。ArrayDequeStack(老类)快——Stack 继承 Vector 全方法同步,开销大。

2.4 队列

队列——先进先出(FIFO)。offer/poll/peek 都 O(1)。

Queue<Integer> queue = new ArrayDeque<>();
queue.offer(1); queue.offer(2);
queue.peek();   // 1
queue.poll();   // 1

应用:BFS、生产者消费者、任务调度。ArrayDeque 普通队列,PriorityQueue 优先队列(堆),LinkedBlockingQueue 阻塞队列(线程池用)。

三、树

3.1 二叉树

每个节点最多两个子节点。

class TreeNode {
    int val;
    TreeNode left, right;
}

// 三种遍历
void preOrder(TreeNode n) {   // 前序: 根-左-右
    if (n == null) return;
    System.out.println(n.val);
    preOrder(n.left);
    preOrder(n.right);
}
void inOrder(TreeNode n) {    // 中序: 左-根-右 (BST 得到有序)
    if (n == null) return;
    inOrder(n.left);
    System.out.println(n.val);
    inOrder(n.right);
}

3.2 二叉搜索树(BST)

左子树都 < 根,右子树都 > 根。中序遍历得到有序序列。查找/插入/删除平均 O(log n),最坏 O(n)(退化成链表)。

3.3 平衡树:红黑树 / AVL

BST 退化成链表就慢——平衡树通过旋转保持平衡。AVL 严格平衡(左右高度差 ≤ 1),红黑树弱平衡(最长路径不超最短 2 倍)。

  • AVL —— 查询多增删少(严格平衡,查询快)。
  • 红黑树 —— 增删多(弱平衡,旋转少)。Java 的 TreeMap/HashMap(链表超 8 转红黑树)都是红黑树。

3.4 B 树 / B+ 树

多路平衡查找树——每个节点多个子节点、多个关键字。B+ 树的所有数据在叶子,叶子串成链表。

为什么数据库用 B+ 树不用红黑树?因为磁盘 IO 是块读的——B+ 树矮胖,一次读一个节点(一页)能拿很多关键字,IO 次数少(树高度低)。MySQL 索引就是 B+ 树。

3.5 堆

堆——完全二叉树,父节点 ≥/≤ 子节点(最大/最小堆)。PriorityQueue 是最小堆。

PriorityQueue<Integer> minHeap = new PriorityQueue<>();
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());

maxHeap.offer(3); maxHeap.offer(1); maxHeap.offer(2);
maxHeap.peek();   // 3 (最大)
maxHeap.poll();   // 3

应用:Top K 问题、合并 K 个有序链表、任务调度。堆的插入/删除是 O(log n)。

四、哈希表

哈希表——key 经过哈希函数算出数组下标,O(1) 平均查找。

Map<String, Integer> map = new HashMap<>();
map.put("张三", 20);   // hash("张三") % 桶数 -> 桶下标
map.get("张三");       // 同样算下标, O(1) 取

4.1 哈希冲突

不同 key 哈希到同一下标——冲突。两种解法:

  • 链地址法(Separate Chaining) —— 每个桶挂链表。Java HashMap 用这个。链表超 8 转红黑树(防哈希攻击)。
  • 开放地址法(Open Addressing) —— 冲突就找下一个空位(线性探测、二次探测、双哈希)。ThreadLocalMap 用这个。

4.2 负载因子与扩容

HashMap 默认初始 16 桶,负载因子 0.75——元素数 > 16×0.75=12 就扩容到 32,重新哈希。扩容代价大(O(n)),所以预估大小可以预分配。

4.3 一致性哈希

分布式缓存用——节点增减时只影响相邻段数据,不全部重新哈希。Redis Cluster 的哈希槽是类似思想。

五、排序算法

算法平均最坏空间稳定
冒泡O(n²)O(n²)O(1)
选择O(n²)O(n²)O(1)
插入O(n²)O(n²)O(1)
快排O(n log n)O(n²)O(log n)
归并O(n log n)O(n log n)O(n)
堆排O(n log n)O(n log n)O(1)

稳定——相等元素相对顺序不变。Arrays.sort 对基本类型用快排(不稳定),对象用 TimSort(归并改进,稳定)。

5.1 快排

void quickSort(int[] arr, int low, int high) {
    if (low >= high) return;
    int pivot = partition(arr, low, high);
    quickSort(arr, low, pivot - 1);
    quickSort(arr, pivot + 1, high);
}

int partition(int[] arr, int low, int high) {
    int pivot = arr[high];   // 选最右为基准
    int i = low - 1;          // i 是"小于 pivot 区"的边界
    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            i++;
            swap(arr, i, j);
        }
    }
    swap(arr, i + 1, high);
    return i + 1;
}

void swap(int[] arr, int a, int b) {
    int t = arr[a]; arr[a] = arr[b]; arr[b] = t;
}

快排思想——选基准,比它小的放左、大的放右,递归排两边。最坏 O(n²)(已排序数组 + 选端点为基准),随机化基准或三数取中可避免。

5.2 归并

void mergeSort(int[] arr, int[] temp, int low, int high) {
    if (low >= high) return;
    int mid = (low + high) / 2;
    mergeSort(arr, temp, low, mid);
    mergeSort(arr, temp, mid + 1, high);
    merge(arr, temp, low, mid, high);
}

void merge(int[] arr, int[] temp, int low, int mid, int high) {
    int i = low, j = mid + 1, k = low;
    while (i <= mid && j <= high) {
        if (arr[i] <= arr[j]) temp[k++] = arr[i++];
        else temp[k++] = arr[j++];
    }
    while (i <= mid) temp[k++] = arr[i++];
    while (j <= high) temp[k++] = arr[j++];
    System.arraycopy(temp, low, arr, low, high - low + 1);
}

归并——分治拆到单个元素,再两两合并。稳定排序,O(n log n) 稳定,但要 O(n) 额外空间。

5.3 堆排

建最大堆 → 顶交换到末尾 → 缩堆 → 重新堆化。原地排序,空间 O(1)。

六、常见算法套路

6.1 双指针

  • 快慢指针 —— 链表找中点、检测环。
  • 左右指针 —— 二分、两数之和(排序数组)。
// 判断链表有环
boolean hasCycle(Node head) {
    Node slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true;
    }
    return false;
}

6.2 滑动窗口

维护一个窗口,左右指针滑动——求最长/最短子串。

// 无重复字符的最长子串长度
int lengthOfLongestSubstring(String s) {
    Map<Character, Integer> last = new HashMap<>();
    int left = 0, max = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (last.containsKey(c) && last.get(c) >= left) {
            left = last.get(c) + 1;
        }
        last.put(c, right);
        max = Math.max(max, right - left + 1);
    }
    return max;
}

6.3 二分查找

int binarySearch(int[] arr, int target) {
    int low = 0, high = arr.length - 1;
    while (low <= high) {
        int mid = low + (high - low) / 2;   // 防 int 溢出
        if (arr[mid] == target) return mid;
        if (arr[mid] < target) low = mid + 1;
        else high = mid - 1;
    }
    return -1;
}

二分前提——数组有序。mid = low + (high - low) / 2 而不是 (low + high) / 2——后者 low + high 可能 int 溢出(Joshua Bloch 在 JDK 9 修的著名 bug)。

6.4 动态规划入门

动态规划(DP)——把大问题拆成子问题,记忆子问题答案避免重复计算。

经典:斐波那契

// 递归 (O(2^n), 重复计算)
int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

// DP (O(n), 记忆化)
int fibDp(int n) {
    if (n <= 1) return n;
    int[] dp = new int[n + 1];
    dp[0] = 0; dp[1] = 1;
    for (int i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];
    return dp[n];
}

经典:爬楼梯——n 阶楼梯,每次走 1 或 2 步,多少种走法?

// 状态转移: dp[i] = dp[i-1] + dp[i-2]
int climbStairs(int n) {
    if (n <= 2) return n;
    int a = 1, b = 2;
    for (int i = 3; i <= n; i++) {
        int c = a + b;
        a = b; b = c;
    }
    return b;
}

DP 三步——定义状态写出转移方程确定初始值和计算顺序

6.5 BFS / DFS

// 二叉树层序遍历 (BFS)
List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> res = new ArrayList<>();
    if (root == null) return res;
    Queue<TreeNode> q = new ArrayDeque<>();
    q.offer(root);
    while (!q.isEmpty()) {
        int size = q.size();
        List<Integer> level = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            TreeNode n = q.poll();
            level.add(n.val);
            if (n.left != null) q.offer(n.left);
            if (n.right != null) q.offer(n.right);
        }
        res.add(level);
    }
    return res;
}

BFS——队列、层序、最短路径(无权图)。DFS——栈/递归、深度优先、连通性、拓扑排序。

七、实战:快排与算法演示

Java · 在线运行

观察重点

  • 快排原地排序——分区 + 递归,O(n log n) 平均。
  • 二分查找每次折半——O(log n),100 万数据只 20 步。
  • 最小堆求 Top K——堆大小固定 K,超了踢最小,剩下就是 Top K。
  • 快慢指针检测环——快指针追上慢指针就有环。
  • 滑动窗口维护不重复字符——左右指针夹出无重复区间。
  • DP 把递归 O(2ⁿ) 优化到 O(n)——记忆化避免重复计算。

八、本章小结

概念核心要点
大 O时间/空间复杂度趋势
链表O(1) 增删头尾,O(n) 随机访问
栈/队列LIFO/FIFO,O(1) 操作
二叉搜索树中序有序,平均 O(log n)
红黑树弱平衡,Java TreeMap/HashMap
B+ 树数据库索引,矮胖适配磁盘 IO
PriorityQueue,Top K 问题
哈希冲突链地址 / 开放地址
快排O(n log n) 平均,O(n²) 最坏,不稳定
归并稳定 O(n log n),O(n) 空间
动态规划状态 + 转移方程 + 初始值

记忆口诀

  • 大 O 看趋势——O(1) 最好,O(n²) 要优化。
  • 数组随机访问快,链表增删快——权衡选。
  • BST 中序有序——左根右遍历。
  • HashMap 链表 > 8 转红黑树——防哈希攻击。
  • 快排平均 O(n log n)——分区 + 递归。
  • 二分 O(log n)——前提是有序。
  • DP 三步——状态、转移、初始。

结语:算法是内功

这一章我们过了一遍数据结构和算法核心——它们是编程的”内功”,业务框架千变万化,内功永远用得上。下一章看分布式缓存的王者——Redis