从零开始学习Java数据结构与算法)
一、简介
1. 数据结构与算法
数据结构通常是指计算机存储组织数据的方式,需要使用数据结构来处理数据。而算法则是指解决问题的方法和步骤,简单来说数据结构是为算法服务的
2. 数据结构与算法的应用场景
Java 是一种强大的编程语言,可用于开发各种应用程序。在许多情况下需要将算法应用到数据结构中。例如实现一个应用程序该应用程序需要对大量数据进行排序或搜索。 Java 中提供了许多内置数据结构例如数组、链表、堆栈、队列和散列表等,这些数据结构可以帮助我们完成很多任务。
3. 学习数据结构与算法的必要性
在开发应用程序时选择正确的算法和数据结构非常重要。学习 Java 数据结构和算法可以帮助我们更好地理解和解决复杂问题。同时还可以提高代码的性能,并且减少资源的浪费。
二、Java 基础
1. Java 环境配置
要在计算机上运行 Java 程序,必须首先安装 Java 开发套件(JDK)。可以通过在计算机上设置 PATH 和 JAVA_HOME 环境变量来配置 Java 环境变量。在安装完成后,可以验证 Java 是否正确安装,通过在命令行输入 javac -version 命令。
2. Java 基础语法回顾
Java 编程语言是一种面向对象的编程语言,它支持封装、集成和多态性。在 Java 中定义类,使用 public、private 和 protected 关键字对方法和属性进行修饰。
3. 面向对象编程思想
面向对象编程思想是指将程序分解成一个或多个对象,这些对象包含属性和行为,并且与其他对象相互交互。在 Java 中,可以使用类来定义对象。类是定义对象的模板,而对象则是类的实例。
三、算法基础
1. 时间复杂度和空间复杂度
在编写和分析算法时需要考虑时间复杂度和空间复杂度。时间复杂度表示算法的执行时间,通常用大 O 表示法表示。空间复杂度则表示算法所需的内存空间。选择合适的算法可以最小化时间复杂度和空间复杂度。
2. 排序算法
算法必须具备排序能力以便在处理数据时将其按照某种特定的顺序排列。Java 提供了许多排序算法例如冒泡排序、选择排序、插入排序、快速排序和归并排序。
3. 查找算法
查找算法用于在给定集合中查找特定项。 Java 中提供了许多查找算法例如线性查找、二分查找和散列表查找等。
四、Java常见数据结构
1. 数组
Java的数组是相同类型的元素集合,使用[]
来定义。数组具有固定长度,可以根据下标进行访问
int[] nums = new int[5];
nums[0] = 1;
nums[1] = 2;
nums[2] = 3;
nums[3] = 4;
nums[4] = 5;
2. 链表
链表是由节点组成的线性数据结构,每个节点包含了数据和一个指向下一个节点的指针,可以实现动态内存管理。常见的链表有单向链表、双向链表和循环链表
class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x; }
}
ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
3. 栈和队列
栈和队列是一种特殊的线性数据结构,它们只允许在某一端进行插入和删除操作,被称为先进后出和先进先出结构
// 栈
Stack<Integer> stack = new Stack<>();
stack.push(1); //插入
stack.push(2);
int top = stack.peek(); //取出顶部元素
int pop = stack.pop(); //取出并删除栈顶元素
// 队列
Queue<Integer> queue = new LinkedList<>();
queue.offer(1); //插入
queue.offer(2);
int front = queue.peek(); //取出队首元素
int poll = queue.poll(); //取出并删除队首元素
4. 堆
堆是一种特殊的数据结构通常用来实现优先队列。堆分为大根堆和小根堆,大根堆要求每个节点的值都不大于其父节点,小根堆则相反
// 小根堆
PriorityQueue<Integer> heap = new PriorityQueue<>();
heap.offer(3);
heap.offer(1);
heap.offer(2);
int top = heap.peek(); //取出堆顶元素
int poll = heap.poll(); //取出并删除堆顶元素
5. 树
树是一种非线性数据结构,由节点和边组成,每个节点包含一个数据元素和若干指向子树的指针
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) {
val = x; }
}
TreeNode root = new TreeNode(1);
TreeNode node1 = new TreeNode(2);
TreeNode node2 = new TreeNode(3);
root.left = node1;
root.right = node2;
6. 图
图是由节点和边组成的非线性数据结构,最常见的表示方式是邻接表和邻接矩阵。图的遍历有广度优先搜索和深度优先搜索。
五、算法高级技巧
1. 分治策略
分治策略是一种非常基础的算法思想它将问题分为若干个规模更小并结构相同的子问题,然后递归求解子问题,最后将子问题的解组合成原问题的解
public int divide(int[] nums, int left, int right) {
// 终止条件
if (left == right) {
return nums[left];
}
// 拆分子问题
int mid = left + (right - left) / 2;
int leftMax = divide(nums, left, mid);
int rightMax = divide(nums, mid + 1, right);
// 合并子问题的解
return Math.max(leftMax, rightMax);
}
2. 动态规划
动态规划是一种通过将原问题分解为相对简单的子问题的方式求解复杂问题的方法。它通常用于需要求解多阶段决策过程的最优值的问题
public int rob(int[] nums) {
int n = nums.length;
if (n == 0) {
return 0;
} else if (n == 1) {
return nums[0];
}
// 定义状态和状态转移方程
int[] dp = new int[n];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i]);
}
return dp[n-1];
}
3. 贪心算法
贪心算法是一种通过局部最优解来求全局最优解的思想,它通常无法得到最优解但可以得到一个比较好的解
public int jump(int[] nums) {
int end = 0;
int maxPos = 0;
int steps = 0;
int n = nums.length - 1;
// 贪心策略
for (int i = 0; i < n; i++) {
maxPos = Math.max(maxPos, i + nums[i]);
if (i == end) {
end = maxPos;
steps++;
}
}
return steps;
}
六、经典算法实现
1. 归并排序
public static void mergeSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
sortProcess(arr, 0, arr.length - 1);
}
public static void sortProcess(int[] arr, int L, int R) {
if (L == R) {
return;
}
int mid = L + ((R - L) >> 1);
sortProcess(arr, L, mid);
sortProcess(arr, mid + 1, R);
merge(arr, L, mid, R);
}
public static void merge(int[] arr, int L, int mid, int R) {
int[] help = new int[R - L + 1];
int i = 0;
int p1 = L;
int p2 = mid + 1;
while (p1 <= mid && p2 <= R) {
help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
}
while (p1 <= mid) {
help[i++] = arr[p1++];
}
while (p2 <= R) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[L + i] = help[i];
}
}
归并排序的主要思想是分治先把大问题划分成子问题,在将子问题划分成更细小的子问题,最终使用分治思想再合并答案
2. 快速排序
public static void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
sortProcess(arr, 0, arr.length - 1);
}
public static void sortProcess(int[] arr, int L, int R) {
if (L < R) {
swap(arr, L + (int)(Math.random() * (R - L + 1)), R);
int[] p = partition(arr, L, R);
sortProcess(arr, L, p[0] - 1);
sortProcess(arr, p[1] + 1, R);
}
}
public static int[] partition(int[] arr, int L, int R) {
int less = L - 1;
int more = R;
int cur = L;
while (cur < more) {
if (arr[cur] < arr[R]) {
swap(arr, ++less, cur++);
} else if (arr[cur] > arr[R]) {
swap(arr, --more, cur);
} else {
cur++;
}
}
swap(arr, more, R);
return new int[]{
less + 1, more};
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
快速排序的主要思想是选一个基准值,通过比较将数组分成小于基准值和大于基准值两部分,不断递归地排序子数组。递归结束后便得到排序好的数组
3. 堆排序
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);
}
int size = arr.length;
swap(arr, 0, --size);
while (size > 0) {
heapify(arr, 0, size);
swap(arr, 0, --size);
}
}
public static void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
public static void heapify(int[] arr, int index, int size) {
int left = index * 2 + 1;
while (left < size) {
int largest = left + 1 < size && arr[left + 1] > arr[left] ? left + 1 : left;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) {
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
堆排序的主要思想是将待排序的序列构造成一个大根堆,此时整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换后,将剩余的 n-1 个元素重新构造成一个堆。d重复此操作就能得到一个有序的序列。
4. 图的遍历
public void dfs(Node node) {
if (node == null) {
return;
}
Stack<Node> stack = new Stack<>();
HashSet<Node> set = new HashSet<>();
stack.push(node);
System.out.print(node.value + " ");
set.add(node);
while (!stack.isEmpty()) {
Node cur = stack.pop();
for (Node next : cur.nexts) {
if (!set.contains(next)) {
stack.push(cur);
stack.push(next);
System.out.print(next.value + " ");
set.add(next);
break;
}
}
}
}
public void bfs(Node node) {
if (node == null) {
return;
}
Queue<Node> queue = new LinkedList<>();
HashSet<Node> set = new HashSet<>();
queue.offer(node);
set.add(node);
while (!queue.isEmpty()) {
Node cur = queue.poll();
System.out.print(cur.value + " ");
for (Node next : cur.nexts) {
if (!set.contains(next)) {
set.add(next);
queue.offer(next);
}
}
}
}
图的遍历分为深度优先遍历(DFS)和广度优先遍历(BFS)。其中DFS使用栈结构,BFS使用队列
5. 最短路径算法
// Dijkstra算法
public static HashMap<Node, Integer> dijkstra1(Node start) {
PriorityQueue<Edge> priorityQueue = new PriorityQueue<>(new EdgeComparator());
HashMap<Node, Integer> distanceMap = new HashMap<>();
distanceMap.put(start, 0);
priorityQueue.add(new Edge(start, 0));
while (!priorityQueue.isEmpty()) {
Edge edge = priorityQueue.poll();
Node cur = edge.to;
int distance = edge.weight;
for (Edge next : cur.edges) {
Node to = next.to;
if (!distanceMap.containsKey(to)) {
priorityQueue.add(new Edge(to, distance + next.weight));
distanceMap.put(to, distance + next.weight);
}
distanceMap.put(to, Math.min(distanceMap.get(to), distance + next.weight));
}
}
return distanceMap;
}
// Bellman-Ford算法
public static HashMap<Node, Integer> bellmanFord(Node start) {
HashMap<Node, Integer> distanceMap = new HashMap<>();
distanceMap.put(start, 0);
int size = start.edges.size();
for (int i = 1; i < size; i++) {
for (Edge edge : start.edges) {
Node from = edge.from;
Node to = edge.to;
int weight = edge.weight;
if (distanceMap.containsKey(from)) {
distanceMap.put(to, Math.min(distanceMap.getOrDefault(to, Integer.MAX_VALUE), distanceMap.get(from) + weight));
}
}
}
for (Edge edge : start.edges) {
Node from = edge.from;
Node to = edge.to;
int weight = edge.weight;
if (distanceMap.containsKey(from) && distanceMap.get(from) + weight < distanceMap.getOrDefault(to, Integer.MAX_VALUE)) {
return null;
}
}
return distanceMap;
}
最短路径算法包括Dijkstra算法和Bellman-Ford算法。其中Dijkstra算法使用贪心策略每次选取距离最近的点,依次更新相邻且未访问过的节点的距离。Bellman-Ford算法用来解决带负权边的最短路径问题,它采用的是动态规划思想,不断松弛距离数组,直到没有新的情况产生。如果松弛完成后存在从起点可达的负环则表示问题无解
七、实践案例
1. LRU Cache 缓存算法实现
import java.util.*;
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int CACHE_SIZE;
public LRUCache(int cacheSize) {
super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
CACHE_SIZE = cacheSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > CACHE_SIZE;
}
}
该类继承自LinkedHashMap
, 是一个 LRU Cache 的实现。其中:
cacheSize
是缓存的容量大小super()
中的参数分别为: initial capacity(初始容量), load factor(负载因子) 和 access order(存取顺序)removeEldestEntry()
方法的复写,若链表长度大于缓存容量,则移除链表尾元素。
2. Hash 表算法实现
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.TreeMap;
public class HashTable {
private TreeMap<Long, String> virtualNodes = new TreeMap<>();
// 每个节点在哈希环上的虚拟节点数量
private final static int VIRTUAL_NODES = 5;
public HashTable(String[] nodes) throws NoSuchAlgorithmException {
for (String nodeName : nodes) {
for (int i = 0; i < VIRTUAL_NODES; i++) {
long hashValue = hash(nodeName + "_" + i);
virtualNodes.put(hashValue, nodeName);
}
}
}
// hash 函数,把字符串映射到一个 64位的整数空间内
private long hash(String str) throws NoSuchAlgorithmException {
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.reset();
md5.update(str.getBytes());
byte[] byteArray = md5.digest();
long result = 0;
for (int i = 0; i < byteArray.length; i++) {
result <<= 8;
result |= (byteArray[i] & 0xff);
}
return result;
}
// 获取 key 对应的节点
public String getNode(String key) throws Exception {
if (virtualNodes.isEmpty()) {
throw new Exception("没有可用的节点");
}
long keyHashValue = hash(key);
// 在 TreeMap 上找到第一个大于或等于 keyHashValue 的节点
Map.Entry<Long, String> entry = virtualNodes.ceilingEntry(keyHashValue);
if (entry == null) {
// 如果没有比 keyHashValue 大的节点,则返回第一个节点
entry = virtualNodes.firstEntry();
}
// 返回对应的节点
return entry.getValue();
}
}
该类实现了 Hash 表算法。其中:
virtualNodes
是一个TreeMap
类型的虚拟节点集合,VIRTUAL_NODES
是每个节点生成虚拟节点的数量hash()
函数将输入字符串通过 MD5 算法,映射到一个 64位 的整数空间内。getNode()
函数根据 hash 值找到其对应的节点,如果没有比该 hash 值大的节点,就会返回第一个节点。
3. Top K 问题解决方案
import java.util.*;
public class TopK {
//Solution 1: 利用小根堆
public static List<String> topKFrequent1(String[] words, int k) {
Map<String, Integer> freqMap = new HashMap<>();
for (String word : words) {
freqMap.put(word, freqMap.getOrDefault(word, 0) + 1);
}
PriorityQueue<Map.Entry<String, Integer>> minHeap = new PriorityQueue<>(
(o1, o2) -> (o1.getValue().equals(o2.getValue()) ? o2.getKey().compareTo(o1.getKey()) : o1.getValue() - o2.getValue()));
for (Map.Entry<String, Integer> entry : freqMap.entrySet()) {
minHeap.offer(entry);
if (minHeap.size() > k) {
minHeap.poll();
}
}
List<String> res = new ArrayList<>();
while (!minHeap.isEmpty()) {
res.add(0, minHeap.poll().getKey());
}
return res;
}
// Solution 2: 利用桶排序
public static List<String> topKFrequent2(String[] words, int k) {
Map<String, Integer> freqMap = new HashMap<>();
List<String>[] bucket = new List[words.length + 1];
for (String word : words) {
freqMap.put(word, freqMap.getOrDefault(word, 0) + 1);
}
for (Map.Entry<String, Integer> entry : freqMap.entrySet()) {
int freq = entry.getValue();
if (bucket[freq] == null) {
bucket[freq] = new ArrayList<>();
}
bucket[freq].add(entry.getKey());
}
List<String> res = new ArrayList<>();
for (int i = bucket.length - 1; i >= 0 && res.size() < k; i--) {
if (bucket[i] != null) {
Collections.sort(bucket[i]);
res.addAll(bucket[i]);
}
}
return res.subList(0, k);
}
}
该类提供了两种实现 Top K 问题的方案:
-
Solution 1:在频率字典上利用小根堆,用优先队列动态维护K个高频字。PriorityQueque 默认是小根堆,因此在比较器中逆序返回,每次 poll 出队的就是目前为止出现次数第 K 多的元素。
-
Solution 2:利用桶排序,生成频率到单词的映射。遍历整个哈希表并更新桶中的信息。最后我们按照顺序遍历桶,并在其中遍历每个桶中的链表,返回 k 个单词