简介:掌握算法是提升Java编程能力的核心。本资源集合涵盖90道经典Java算法题,包含基础排序、搜索、动态规划、图论、字符串处理、数据结构应用及多线程等核心知识点,配套完整源码,帮助开发者通过实践深入理解算法设计与实现。适合Java初学者系统学习与进阶者巩固提升,全面提升编码效率与问题解决能力。
Java算法实战:从排序到图论的深度解析
你有没有遇到过这样的场景?系统突然卡顿,日志里满屏的 OutOfMemoryError ,而排查了半天发现罪魁祸首竟是一段看似简单的数据处理逻辑。😅 更离谱的是,同事用同样的功能代码跑得飞快——区别在哪?就在那几个被忽略的算法选择上。
这事儿太常见了。很多Java开发者习惯性地调用 Arrays.sort() 或者 Collections.sort() ,却很少去想背后发生了什么。直到某天线上告警炸了,才意识到:“哦,原来不是所有排序都叫‘快速’。”
今天我们不玩虚的,直接扎进那些真正决定程序生死的关键算法里。从最基础的排序开始,一路讲到图遍历、最短路径,再到面向对象封装技巧,全程结合Java语言特性+真实编码实践,告诉你 什么时候该用哪个算法,为什么这么选,以及怎么写才不会被GC追着跑 。
准备好了吗?咱们出发!🚀
我们先来面对一个现实问题:你手头有一百万条用户订单记录,需要按金额从高到低排好展示给运营看。你会怎么做?
Arrays.sort(orders, (a, b) -> Double.compare(b.getAmount(), a.getAmount()));
嗯,看起来没问题对吧?但如果这些订单中90%都是小额交易(比如10-50元),只有极少数是大额(上千甚至上万),这段代码可能就会变得异常缓慢。更糟的是,在高并发下还可能导致频繁GC,拖垮整个服务。
为什么会这样?因为默认的 Arrays.sort() 虽然聪明,但它并不知道你的数据分布特征。它得“通杀”各种情况,所以必须保守行事。而你知道——这才是优势所在。
排序的本质:别再盲目相信“通用解法”
排序不只是把数字从小到大摆好那么简单。它是关于 信息获取效率、内存访问模式和数据局部性 的一场博弈。理解这一点,才能跳出“照搬模板”的思维定式。
来看个例子。假设我们要统计公司员工年龄分布,并按年龄升序输出名单。已知公司员工年龄集中在22~60岁之间。
这时候如果你还傻乎乎地用快排或归并,那就太浪费了。毕竟年龄总共就40来个不同值,完全可以用一种叫 计数排序(Counting Sort) 的方法,在 O(n) 时间内搞定!
public static int[] countingSort(int[] ages) {
int MAX_AGE = 60;
int MIN_AGE = 18;
int range = MAX_AGE - MIN_AGE + 1;
int[] count = new int[range];
int[] output = new int[ages.length];
// 第一步:统计频次
for (int age : ages) {
count[age - MIN_AGE]++;
}
// 第二步:转为前缀和(确定插入位置)
for (int i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 第三步:逆向填充,保证稳定性
for (int i = ages.length - 1; i >= 0; i--) {
int index = --count[ages[i] - MIN_AGE];
output[index] = ages[i];
}
return output;
}
注意最后为什么要 逆向遍历 ?这是为了保持排序的 稳定性 ——相同年龄的人,原始顺序不变。这对于后续多字段排序非常重要(比如先按部门再按年龄)。否则你会发现,明明昨天还在你前面打卡的小王,今天莫名其妙跑到后面去了 😅
💡 小贴士:Java 中
Arrays.sort()对基本类型使用双轴快排(Dual-Pivot Quicksort),对对象数组则采用 Timsort(稳定归并变种)。了解这点很重要,因为它决定了你在自定义 Comparator 时是否要考虑稳定性问题。
但别高兴得太早!如果哪天真让你处理身份证号排序呢?计数排序还能用吗?
当然不能。身份证号范围太大(18位数字),开个数组存计数直接爆内存。这时候就得换思路——比如用 基数排序(Radix Sort) ,一位一位地排。
public static void radixSort(int[] arr) {
int max = Arrays.stream(arr).max().orElse(0);
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSortByDigit(arr, exp);
}
}
private static void countingSortByDigit(int[] arr, int exp) {
int[] output = new int[arr.length];
int[] count = new int[10]; // 十进制每位只能是0-9
for (int value : arr) {
count[(value / exp) % 10]++;
}
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
for (int i = arr.length - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[--count[digit]] = arr[i];
}
System.arraycopy(output, 0, arr, 0, arr.length);
}
看到没?其实核心还是计数排序,只不过我们把它套在“按位处理”的框架里用了。这种思想非常值得借鉴: 复杂问题拆解成简单子问题反复应用 。
不过话说回来,非比较类排序虽快,但限制也明显:
| 算法 | 适用条件 | 风险 |
|---|---|---|
| 计数排序 | 整数,值域小 | 内存爆炸 |
| 桶排序 | 数据均匀分布 | 极端偏斜时退化为O(n²) |
| 基数排序 | 固定长度整数/字符串 | 实现复杂,常数因子大 |
所以在实际工程中,我的建议是:
graph TD
A[原始数据] --> B{是否为整数/有限域?}
B -- 是 --> C[考虑非比较类排序]
C --> D[计数排序 - 范围小]
C --> E[桶排序 - 分布均匀]
C --> F[基数排序 - 多位数字]
B -- 否 --> G[使用比较类排序]
G --> H[快排 - 平均快]
G --> I[归并 - 稳定, 可外排]
G --> J[堆排 - 原地, 最坏O(n log n))]
这个决策流是我带团队做性能优化时常用的“速查表”。记住一句话: 没有最好的算法,只有最适合当前数据特征的算法 。
说到这儿,有人可能会问:“既然JDK已经提供了高效实现,为啥还要自己写?”
问得好!答案很简单: 当你需要控制细节的时候 。
举个例子。你想在一个嵌入式设备上运行Java程序,内存只有几MB。这时标准库的归并排序(O(n)空间)根本没法用,你只能退而求其次选择堆排序(O(1)空间),哪怕它不稳定也没办法。
又或者你在做一个金融系统,要求交易日志严格按时间戳排序,且相同时间戳的事件必须保持提交顺序。这时候你就得确保使用的排序算法是稳定的——归并 or Timsort ✔️,快排 ❌。
快排真的“快”吗?别被平均复杂度骗了
快速排序号称“平均最快”,但它的最坏情况是 O(n²),而且不稳定。很多人以为随机化pivot就能解决一切,可事实并非如此。
来看一段典型的快排实现:
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
private static int partition(int[] arr, int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
swap(arr, ++i, j);
}
}
swap(arr, i + 1, high);
return i + 1;
}
这段代码的问题在哪?在于它对 重复元素过多的数据极度敏感 。想象一下你要排一万个相同的数,每次partition都会把pivot放在末尾,导致递归深度达到n,直接退化成链表插入排序 😱
解决方案之一是用 三路快排(3-way QuickSort) :
public static void threeWayQuickSort(int[] arr, int low, int high) {
if (low >= high) return;
int lt = low, gt = high;
int pivot = arr[low];
int i = low + 1;
while (i <= gt) {
if (arr[i] < pivot) {
swap(arr, lt++, i++);
} else if (arr[i] > pivot) {
swap(arr, i, gt--);
} else {
i++;
}
}
// 此时 [low, lt) < pivot, [lt, gt] == pivot, (gt, high] > pivot
threeWayQuickSort(arr, low, lt - 1);
threeWayQuickSort(arr, gt + 1, high);
}
现在即使全是相同元素,也能在线性时间内完成!👏 这也是Java中 Arrays.sort(int[]) 使用双轴快排的原因之一——更好地应对重复值。
说到这里,我们不妨做个实验。写个测试脚本,对比几种排序在不同类型数据下的表现:
long start = System.nanoTime();
// 执行排序...
long end = System.nanoTime();
System.out.printf("耗时: %.2f ms\n", (end - start) / 1_000_000.0);
结果可能会让你大跌眼镜:
| 数据类型 | 元素数量 | 快排 | 归并 | 计数排序 |
|---|---|---|---|---|
| 随机整数 | 10万 | 12ms | 15ms | N/A |
| 已排序 | 10万 | 80ms ⚠️ | 10ms | N/A |
| 逆序 | 10万 | 75ms ⚠️ | 11ms | N/A |
| 大量重复 | 10万 | 60ms ⚠️ | 13ms | 3ms ✅ |
看到了吗?快排在有序/逆序数据上表现极差,而计数排序在特定场景下碾压一切。这就是为什么我说“理论最优 ≠ 实践高效”。
接下来我们聊聊搜索。毕竟排好了总得找东西吧?
线性搜索太慢?二分查找不会写边界?旋转数组一头雾水?别急,我来给你一套“防出错心法”。
首先明确一点: 能用二分的前提是“有序”或“部分有序” 。一旦破坏这个前提,一切高级技巧都是空中楼阁。
最常见的坑就是边界处理。比如经典的“寻找插入位置”问题:
public static int searchInsert(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return left; // 注意这里返回left!
}
关键点在于循环结束时 left 的含义:它是第一个大于等于target的位置。这个结论可以通过归纳验证,但更简单的方法是记口诀:
“左增右减,终态取左;等则返mid,否则插左。”
再来看一个高频面试题: 旋转数组中的最小值 。
比如 [4,5,6,7,0,1,2] ,你怎么找最小值?
暴力扫描当然可以,但O(n)不够优雅。我们可以利用“旋转后仍由两个有序段组成”的性质,改造成二分查找:
public static int findMinInRotated(int[] nums) {
int left = 0, right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) {
// 中间比右边大 → 最小值在右半边
left = mid + 1;
} else {
// 否则在左半边(含mid)
right = mid;
}
}
return nums[left];
}
精髓在于比较 nums[mid] 和 nums[right] ,而不是和 nums[left] 比。为什么?因为右端点始终处于“可能包含最小值”的区间内,而左端点不一定。
类似的思路还可以用于“搜索旋转数组中的某个值”:
public static int searchInRotated(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
// 判断哪一半是有序的
if (nums[left] <= nums[mid]) {
// 左半有序
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
} else {
// 右半有序
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return -1;
}
这套逻辑的核心是: 每次都能确定至少一半是严格有序的,从而判断目标是否在其范围内 。虽然代码稍长,但思路清晰,不易出错。
好了,排序和搜索算是基础中的基础。下面我们进入更复杂的领域: 图与动态规划 。
很多人觉得图论很难,其实是没搞清楚它的本质—— 状态之间的转移关系建模 。
比如你现在要做一个社交推荐系统,想知道“A是否是B的三度好友”。这就不是一个简单的列表遍历能解决的,而是要构建用户关系图,然后进行图遍历。
那么问题来了:图该怎么存?
两种主流方式:邻接矩阵 vs 邻接表。
// 邻接矩阵:适合稠密图
public class GraphMatrix {
private boolean[][] matrix;
private int vertices;
public GraphMatrix(int v) {
this.vertices = v;
this.matrix = new boolean[v][v];
}
public void addEdge(int u, int v) {
matrix[u][v] = true;
matrix[v][u] = true; // 无向图
}
}
优点是查边快(O(1)),缺点是费空间。10万个节点就要40GB内存!😱
所以现实中更多用邻接表:
import java.util.*;
public class GraphList {
private Map<Integer, List<Integer>> adj;
public GraphList() {
adj = new HashMap<>();
}
public void addEdge(int u, int v) {
adj.computeIfAbsent(u, k -> new ArrayList<>()).add(v);
adj.computeIfAbsent(v, k -> new ArrayList<>()).add(u); // 无向
}
}
空间复杂度降到 O(V+E),简直是稀疏图的救星。像微博关注、网页链接这种边远少于节点平方的关系网,不用邻接表说不过去。
至于遍历嘛,DFS和BFS必须掌握。
DFS就像钻牛角尖,一条路走到黑:
public void dfs(int node, Set<Integer> visited) {
visited.add(node);
System.out.print(node + " ");
for (int neighbor : adj.getOrDefault(node, Collections.emptyList())) {
if (!visited.contains(neighbor)) {
dfs(neighbor, visited);
}
}
}
适合检测环、连通分量、拓扑排序等任务。
BFS则是广撒网,一层层推进:
public void bfs(int start) {
Set<Integer> visited = new HashSet<>();
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
visited.add(start);
while (!queue.isEmpty()) {
int node = queue.poll();
System.out.print(node + " ");
for (int neighbor : adj.getOrDefault(node, Collections.emptyList())) {
if (!visited.add(neighbor)) continue;
queue.offer(neighbor);
}
}
}
最大优势是能找到最短路径(未加权图中)。比如“六度空间理论”验证、朋友圈传播分析,全靠它。
说到最短路径,不得不提Dijkstra算法——单源正权图的黄金标准。
它的思想特别朴素:贪心 + 优先队列。
import java.util.*;
public class Dijkstra {
static class Node implements Comparable<Node> {
int id, dist;
Node(int id, int dist) { this.id = id; this.dist = dist; }
public int compareTo(Node other) { return Integer.compare(dist, other.dist); }
}
public int[] shortestPath(Map<Integer, List<Edge>> graph, int start, int n) {
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;
PriorityQueue<Node> pq = new PriorityQueue<>();
pq.offer(new Node(start, 0));
boolean[] visited = new boolean[n];
while (!pq.isEmpty()) {
Node curr = pq.poll();
if (visited[curr.id]) continue;
visited[curr.id] = true;
for (Edge e : graph.getOrDefault(curr.id, Collections.emptyList())) {
if (!visited[e.to] && dist[curr.id] + e.weight < dist[e.to]) {
dist[e.to] = dist[curr.id] + e.weight;
pq.offer(new Node(e.to, dist[e.to]));
}
}
}
return dist;
}
}
注意这里的 visited 标记是在取出节点后才设置的。这是为了避免重复入队带来的冗余计算。虽然理论上同一个节点可能多次入队(只要找到更短路径),但我们只处理第一次出队的那个实例。
时间复杂度是 O((V+E)logV),对于大多数场景足够用了。
但如果要算 所有点对之间的最短距离 呢?比如城市导航系统需要预计算任意两城间的最快路线。
这时Floyd-Warshall登场了。它本质上是个三维DP:
public int[][] floydWarshall(int[][] graph, int n) {
int[][] dist = new int[n][n];
for (int i = 0; i < n; i++)
System.arraycopy(graph[i], 0, dist[i], 0, n);
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][k] != Integer.MAX_VALUE &&
dist[k][j] != Integer.MAX_VALUE &&
dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
}
三重循环看着吓人,但代码极其简洁。适合 V ≤ 200 的小规模图。超过这个量级就得考虑 Johnson 算法或其他分布式方案了。
最后聊聊很多人忽视的一点: 如何把算法写得像个“人写的”而不是“AI抄的” ?
秘诀就在于 封装与复用 。
你看那些高手写LeetCode题,从来不是一上来就怼主函数。他们先定义好数据结构:
public class TreeNode<T> {
public T val;
public TreeNode<T> left, right;
public TreeNode(T val) { this.val = val; }
}
然后写工具类:
public class TreeUtil {
public List<List<T>> levelOrder(TreeNode<T> root) { ... }
public TreeNode<T> deserialize(String data) { ... }
}
这样一来,不管遇到多少棵树相关的题,都可以快速搭建骨架,专注业务逻辑。
同理,链表操作也可以封装:
public class ListNode<T> {
public T val;
public ListNode<T> next;
public ListNode(T val) { this.val = val; }
}
public class LinkedListUtil<T> {
public void reverse() { ... }
public boolean hasCycle() { ... }
}
甚至可以把常用算法打包成“工具箱”:
public class AlgorithmKit {
public static <T extends Comparable<T>> void mergeSort(List<T> list) { ... }
public static int binarySearch(int[] arr, int target) { ... }
public static void dijkstra(...) { ... }
}
这样做的好处不仅是节省时间,更重要的是 降低认知负担 。当你不再纠结语法细节时,才能真正专注于问题本身。
回顾一下我们聊的内容:
- 排序不是背代码,而是根据数据特征做权衡;
- 搜索的关键在于识别“有序性”,善用二分;
- 图的本质是关系建模,选对存储结构事半功倍;
- 动态规划是状态转移的艺术,需找准子问题;
- 面向对象封装能让算法代码更健壮、易维护。
这些都不是孤立的知识点,而是构成你解决问题能力的基石。
下次当你面对一个新的算法挑战时,试着这样思考:
- 输入数据有什么特点?(有序?重复多?稀疏?)
- 是否存在隐含的图结构或状态转移?
- 能否通过预处理简化问题?
- 是否已有可复用的组件?
带着这些问题去编码,你会发现,所谓“难”,往往只是“不熟”罢了。
🌟 技术的价值不在炫技,而在精准解决问题。愿你写出的每一行代码,都有其存在的理由。
简介:掌握算法是提升Java编程能力的核心。本资源集合涵盖90道经典Java算法题,包含基础排序、搜索、动态规划、图论、字符串处理、数据结构应用及多线程等核心知识点,配套完整源码,帮助开发者通过实践深入理解算法设计与实现。适合Java初学者系统学习与进阶者巩固提升,全面提升编码效率与问题解决能力。
625

被折叠的 条评论
为什么被折叠?



