1.背包(数据结构)
1.1 定义
背包(Bag)是一种数据结构,它可以存储任意数量的元素,但没有顺序。在背包中,元素的添加和删除是无序的,也不支持随机访问。背包通常用来表示一个集合,并且支持添加、删除和遍历操作。
1.2 特点
- 可以存储任意数量的元素。
- 元素的添加和删除是无序的。
- 不支持随机访问。
- 用来表示一个集合,并且支持添加、删除和遍历操作。
1.3 代码实现(Java)
public class Bag<Item> implements Iterable<Item> {
private Node first; // 栈顶元素
private int size; // 元素数量
private class Node {
Item item;
Node next;
}
public boolean isEmpty() {
return first == null;
}
public int size() {
return size;
}
public void add(Item item) { // 添加元素
Node oldFirst = first;
first = new Node();
first.item = item;
first.next = oldFirst;
size++;
}
@Override
public Iterator<Item> iterator() { // 实现 Iterable 接口的 iterator 方法
return new ListIterator();
}
private class ListIterator implements Iterator<Item> {
private Node current = first;
@Override
public boolean hasNext() {
return current != null;
}
@Override
public Item next() {
Item item = current.item;
current = current.next;
return item;
}
}
1.4 API
Java语言中可调用的背包数据结构API如下:
1. void add(Item item):将一个元素添加到背包中。
2. boolean isEmpty():检查背包是否为空。
3. int size():返回背包中元素的数量。
4. Iterator<Item> iterator():返回一个迭代器,可以遍历背包中的所有元素。
5. Item[] toArray():将背包中的元素转换为数组形式。
6. String toString():返回表示背包内容的字符串。
7. void clear():清空背包,删除所有元素。
8. Item remove():从背包中删除并返回任意一个元素。
1.5 应用
背包可以应用于很多场景,比如:
- 用来存储一组数据,并且不需要按照任何顺序进行访问。
- 在图论中,我们可以使用背包来存储每个节点的邻居节点。
- 在机器学习算法中,我们可以使用背包来存储特征值。
1.6 总结
背包是一种简单、常用的数据结构,适用于存储一组数据并且不需要按照任何顺序进行访问的场景。它可以支持添加、删除和遍历操作,并且不支持随机访问。在实际应用中,我们可以根据具体的场景选择使用不同类型的背包实现。
2.先进先出队列
2.1 定义
先进先出队列(FIFO Queue)是一种数据结构,它按照元素的添加顺序进行访问。在先进先出队列中,新元素被添加到队列的末尾,而最早添加的元素位于队列的前面。当需要访问元素时,我们按照添加的顺序依次访问。
2.2 特点
- 按照元素的添加顺序进行访问。
- 新元素被添加到队列的末尾。
- 最早添加的元素位于队列的前面。
- 需要按照添加顺序依次访问。
2.3 代码实现(Java)
public class Queue<Item> implements Iterable<Item> {
private Node first; // 队首指针
private Node last; // 队尾指针
private int size; // 元素数量
private class Node {
Item item;
Node next;
}
public boolean isEmpty() {
return first == null;
}
public int size() {
return size;
}
public void enqueue(Item item) { // 入队操作
Node oldLast = last;
last = new Node();
last.item = item;
last.next = null;
if (isEmpty()) {
first = last;
} else {
oldLast.next = last;
}
size++;
}
public Item dequeue() { // 出队操作
if (isEmpty()) {
throw new NoSuchElementException("Queue underflow");
}
Item item = first.item;
first = first.next;
if (isEmpty()) {
last = null;
}
size--;
return item;
}
@Override
public Iterator<Item> iterator() { // 实现 Iterable 接口的 iterator 方法
return new ListIterator();
}
private class ListIterator implements Iterator<Item> {
private Node current = first;
@Override
public boolean hasNext() {
return current != null;
}
@Override
public Item next() {
Item item = current.item;
current = current.next;
return item;
}
}
2.4 API
Java语言提供了许多先进先出队列的API和函数,包括:
1. java.util.Queue接口:这是Java中定义先进先出队列的标准接口。它提供了常用的队列操作方法,如add、offer、remove、poll等。
2. java.util.LinkedList类:这是一个实现Queue接口的双向链表。它提供了所有Queue接口中定义的方法,以及其他一些操作方法,如getFirst、getLast、addFirst、addLast等。
3. java.util.concurrent.ConcurrentLinkedQueue类:这是一个线程安全的无界队列实现。它使用CAS(Compare-And-Swap)算法来保证线程安全性,并且具有高并发性能。
4. java.util.concurrent.LinkedBlockingQueue类:这是一个基于链表实现的阻塞队列。它提供了put和take方法,当队列为空时take会阻塞等待元素加入,当队列满时put会阻塞等待元素取出。
5. java.util.concurrent.ArrayBlockingQueue类:这是一个基于数组实现的阻塞队列。它具有固定容量,并且提供了put和take方法来实现阻塞功能。
6. java.util.concurrent.DelayQueue类:这是一个支持延时元素(Delayed)的无界阻塞队列。它可以用来执行定时任务或者在指定时间后执行某些操作。
除此之外,Java还提供了一些其他与先进先出队列相关的API和函数,如Deque接口(双端队列)、PriorityQueue类(优先级队列)等。
2.5 应用
先进先出队列可以应用于很多场景,比如:
- 在计算机程序中,我们可以使用先进先出队列来缓存任务。
- 在图论中,我们可以使用先进先出队列来实现广度优先搜索算法。
- 在网络通信中,我们可以使用先进先出队列来实现请求处理。
2.6 总结
先进先出队列是一种常用的数据结构,在很多场景下都有着广泛应用。它按照元素的添加顺序进行访问,并且支持入队、出队和遍历操作。在实际应用中,我们可以根据具体需求选择使用不同类型的先进先出队列实现。
3.下压栈
3.1 定义
下压栈(Stack)是一种数据结构,它按照元素的添加顺序进行访问。在下压栈中,新元素被添加到栈的顶部,而最早添加的元素位于栈底。当需要访问元素时,我们按照添加的顺序依次访问。
3.2 特点
- 按照元素的添加顺序进行访问。
- 新元素被添加到栈的顶部。
- 最早添加的元素位于栈底。
- 需要按照添加顺序依次访问。
3.3 代码实现(Java)
public class Stack<Item> implements Iterable<Item> {
private Node first; // 栈顶指针
private int size; // 元素数量
private class Node {
Item item;
Node next;
}
public boolean isEmpty() {
return first == null;
}
public int size() {
return size;
}
public void push(Item item) { // 入栈操作
Node oldFirst = first;
first = new Node();
first.item = item;
first.next = oldFirst;
size++;
}
public Item pop() { // 出栈操作
if (isEmpty()) {
throw new NoSuchElementException("Stack underflow");
}
Item item = first.item;
first = first.next;
size--;
return item;
}
@Override
public Iterator<Item> iterator() { // 实现 Iterable 接口的 iterator 方法
return new ListIterator();
}
private class ListIterator implements Iterator<Item> {
private Node current = first;
@Override
public boolean hasNext() {
return current != null;
}
@Override
public Item next() {
Item item = current.item;
current = current.next;
return item;
}
}
3.4 API
Java语言提供了很多关于下压栈数据结构的API和函数,包括:
1. void push(Item item):将一个元素入栈。
2. Item pop():将一个元素出栈,并返回它。
3. boolean isEmpty():检查当前栈是否为空。
4. int size():返回当前栈中元素数量。
5. Iterator<Item> iterator():返回一个迭代器,可以遍历整个下压栈中所有元素。
6. Item[] toArray():将下压栈中所有元素转换为数组形式。
7. String toString():返回表示整个下压栈内容的字符串。
8. void clear():清空当前下压栈,并删除所有元素。
3.5 应用
下压栈可以应用于很多场景,比如:
- 在计算机程序中,我们可以使用下压栈来实现表达式求值、括号匹配等功能。
- 在图论中,我们可以使用下压栈来实现深度优先搜索算法。
- 在编译器和解释器中,我们可以使用下压栈来存储变量和函数调用信息等数据信息。
3.6 例题:算术表达式求值
算术表达式求值的原理:
算术表达式求值通常使用栈来实现,其基本思想是将中缀表达式转换为后缀表达式,然后再根据后缀表达式进行计算。具体步骤如下:
-
创建两个栈,一个用来存储运算符和括号(操作符栈),另一个用来存储操作数和结果(数值栈)。
-
从左到右扫描中缀表达式的每个字符,如果遇到数字则直接入栈数值栈中。
-
如果遇到运算符或括号,则分以下情况处理:
(1)如果该运算符或括号是左括号“(”,则直接入栈操作符栈中。
(2)如果该运算符或括号是右括号“)”,则将操作符栈中的元素依次出栈并加入数值栈中,直到遇到左括号为止。此时左括号应该被弹出而不加入数值栈中。
(3)如果该运算符是除法“/”或乘法“*”,则比较其与操作符栈顶元素的优先级。如果优先级高于等于操作符栈顶元素,则将该运算符入操作符栈;否则将操作符和数字出栈,并计算结果,再将结果压入数值栈中。重复此过程直到当前运算符的优先级高于等于操作符站顶元素为止。
(4)如果该运算符是加法“+”或减法“-”,则一直从操作数字出站,并计算结果,直到当前运算符优先级大于堆定位置为止。
-
当所有字符都扫描完毕后,检查操作符站是否为空。若不为空,则依次从堆定位置取出元素,并按照相反顺序加入数值站中,最后得到的就是后缀表达式。
-
对后缀表达式进行求值:从左至右扫描每个字符,如果遇到数字就压入数值站;如果遇到运算符就取出相应数量的数据进行计算,并将结果压回数值站。最终得到的即为表达式的结果。
代码实现:
import java.util.Stack;
public class EvaluateExpression {
public static void main(String[] args) {
String expression = "1 + 2 * (3 - 4) / 5";
double result = evaluate(expression);
System.out.println(result); //输出:0.6
}
public static double evaluate(String expression) {
Stack<Double> values = new Stack<>();
Stack<Character> operators = new Stack<>();
for (int i = 0; i < expression.length(); i++) {
char ch = expression.charAt(i);
if (ch == ' ') { //忽略空格
continue;
}
if (ch >= '0' && ch <= '9') { //处理数字
StringBuilder sb = new StringBuilder();
sb.append(ch);
while (i + 1 < expression.length() && expression.charAt(i + 1) >= '0' && expression.charAt(i + 1) <= '9') {
sb.append(expression.charAt(++i));
}
values.push(Double.parseDouble(sb.toString()));
} else if (ch == '(') { //处理左括号
operators.push(ch);
} else if (ch == ')') { //处理右括号
while (!operators.isEmpty() && operators.peek() != '(') {
double result = applyOperator(operators.pop(), values.pop(), values.pop());
values.push(result);
}
operators.pop(); //弹出左括号
} else if (isOperator(ch)) { //处理运算符
while (!operators.isEmpty() && hasHigherPrecedence(ch, operators.peek())) {
double result = applyOperator(operators.pop(), values.pop(), values.pop());
values.push(result);
3.7 可动态调整数组大小的栈的基本原理及实现
栈是一种数据结构,它可以在一端进行插入和删除操作,这一端被称为栈顶。栈具有"先进后出"的特性,即后进入的元素先被删除。在实际应用中,通常采用数组或链表来实现栈。
可调整大小的数组栈是指,在进行插入或删除操作时,如果数组已满或者空间不足,则可以动态地调整数组大小。该算法的时间复杂度为O(1)。
以下是Java代码实现:
public class ResizingArrayStack<Item> {
private Item[] stack;
private int size;
public ResizingArrayStack() {
stack = (Item[]) new Object[1];
size = 0;
}
public void push(Item item) {
if (size == stack.length) {
resize(stack.length * 2);
}
stack[size++] = item;
}
public Item pop() {
if (isEmpty()) throw new NoSuchElementException("Stack underflow");
Item item = stack[size - 1];
stack[size - 1] = null;
size--;
if (size > 0 && size == stack.length / 4) {
resize(stack.length / 2);
}
return item;
}
public boolean isEmpty() {
return size == 0;
}
public int size() {
return size;
}
private void resize(int capacity) {
assert capacity >= size;
Item[] temp = (Item[]) new Object[capacity];
for (int i = 0; i < size; i++) {
temp[i] = stack[i];
}
// 将原数组指向新数组
stack = temp;
}
}
在上面的代码中,我们使用了一个私有方法resize(int capacity),该方法负责扩容和缩容操作。当数据量达到数组容量时,我们将数组长度扩大两倍;当数据量小于等于数组长度的四分之一时,我们将数组长度缩小为原来的二分之一。
这样就能保证在任何情况下都可以利用最少的内存完成任务,并且不会浪费太多时间去处理大小调整问题。
3.8 总结
下压栈是一种简单、常用的数据结构,在往复读取某些数据时能够非常方便地实现后进先出(LIFO)操作。它按照元素的添加顺序进行访问,并且支持入栈、出战和遍历操作。在实际应用中,我们可以选择不同类型的下压站实现来满足具体需求。
4.链表
4.1 定义
链表是一种常见的数据结构,它由多个节点(Node)组成,每个节点包含两部分:数据和指向下一个节点的指针。链表中的每个节点都可以表示为一个对象(Object),对象中包含了数据和指向下一个节点的指针。
链表可以分为单向链表、双向链表和循环链表等多种形式。在单向链表中,每个节点只有一个指针,它指向下一个节点;在双向链表中,每个节点有两个指针,一个指向前一个节点,另一个指向后一个节点;在循环链表中,最后一个节点的指针不是空值而是指向第一个节点。
由于链表具有动态性、灵活性等特点,在计算机科学领域中被广泛应用于各种算法和数据结构的实现。
4.2 特点
链表的主要特点包括:
-
动态性:链表具有动态性,可以在运行时动态地添加、删除和修改节点,而不需要预先分配固定长度的空间。
-
灵活性:链表可以在任意位置插入或删除节点,而不会影响其他节点的位置。
-
没有大小限制:链表的大小没有限制,可以根据需要动态增加或减少。
-
存储效率较低:由于每个节点都需要存储指向下一个节点的指针,因此链表的存储效率较低。
-
随机访问效率较低:由于链表中的节点并不是按照顺序存储的,因此随机访问效率较低。如果要查找某个节点,需要从头开始遍历整个链表。
-
可以实现高效的插入和删除操作:由于链表可以在任意位置插入或删除节点,因此可以实现高效的插入和删除操作。但如果要对链表进行排序等操作,则可能会比较困难。
4.3 代码实现(Java)
public class ListNode {
int val;
ListNode next;
public ListNode(int val) {
this.val = val;
this.next = null;
}
}
public class LinkedList {
private ListNode head;
public LinkedList() {
head = null;
}
//在链表尾部添加节点
public void addNode(int val) {
ListNode newNode = new ListNode(val);
if (head == null) {
head = newNode;
} else {
ListNode cur = head;
while (cur.next != null) {
cur = cur.next;
}
cur.next = newNode;
}
}
//在链表指定位置插入节点
public void insertNode(int index, int val) {
if (index < 0 || index > getLength()) {
throw new IndexOutOfBoundsException("索引越界");
}
if (index == 0) { //头部插入
ListNode newNode = new ListNode(val);
newNode.next = head;
head = newNode;
} else { //中间或者尾部插入
int i = 0;
ListNode cur = head, prev = null;
while (i < index) {
prev = cur;
cur = cur.next;
i++;
}
ListNode newNode=new ListNode(val);
prev.next=newNode;
newNode.next=cur;
}
}
//删除指定位置的节点
public void deleteNode(int index){
if(index<0||index>=getLength()){
throw new IndexOutOfBoundsException("索引越界");
}
if(index==0){ //头部删除
head=head.next;
}else{ //中间或者尾部删除
int i=0;
ListNode cur=head,prev=null;
while(i<index){
prev=cur;
cur=cur.next;
i++;
}
prev.next=cur.next;
}
}
//获取链表长度
public int getLength(){
int count=0;
ListNode cur=head;
while(cur!=null){
count++;
cur=cur.next;
}
return count;
}
//遍历链表并打印出所有节点的值
public void printList(){
ListNode cur=head;
while(cur!=null){
System.out.print(cur.val+" ");
cur=cur.next;
}
System.out.println();
}
}
4.4 API
在Java中,可以使用以下API操作链表:
1. LinkedList类:Java中提供的链表实现,具有在链表头部和尾部插入、删除元素等方法。
2. List接口:Java中的List接口提供了一系列操作元素的方法,如添加、删除、获取和遍历等。
3. Iterator接口:Java中的Iterator接口可以用来遍历集合或者列表等数据结构。
4. ListIterator接口:Java中的ListIterator接口继承了Iterator接口,并且可以在遍历时进行添加、删除和修改操作。
5. Collection接口:Java中的Collection接口是所有集合类的父类,提供了一些通用的方法,如判断是否包含某个元素、获取集合大小等。
6. Arrays类:Java中提供了Arrays类来操作数组和列表等数据结构,其中包含了一些常用的方法,如排序、查找和复制等。
4.5 应用
链表作为一种常见的数据结构,在计算机科学中有着广泛应用,一些常见的应用场景包括:
-
链式存储结构:由于链表具有动态性和灵活性等特点,因此可以被广泛应用于各种算法和数据结构的实现。
-
LRU缓存淘汰算法:LRU缓存淘汰算法是一种常见的缓存淘汰策略,它会优先淘汰最近最少使用的缓存。
-
大整数计算:由于Java等编程语言对基本数据类型的支持有限,因此在进行大整数计算时通常需要使用链表来实现。
-
垃圾回收:在Java虚拟机中,垃圾回收器通常使用链表来管理内存中的对象。当一个对象不再被引用时,它会被加入到一个“可回收”的链表中。
4.6 链表实现下压堆栈的基本原理与代码实现(Java)
链表可以被用来实现下压堆栈(Stack)数据结构。下压堆栈是一种常见的数据结构,它具有后进先出(LIFO)的特点,即最后一个入栈的元素最先出栈。
下面是链表实现下压堆栈的基本原理:
-
创建一个链表对象,并将其初始化为空。
-
入栈操作:在链表头部插入一个新节点,将元素值存储在节点中。
-
出栈操作:从链表头部删除一个节点,并返回该节点中存储的元素值。
-
判断堆栈是否为空:检查链表是否为空,如果为空则表示堆栈也为空。
以下是Java代码实现:
public class LinkedListStack {
private ListNode head;
public LinkedListStack() {
head = null;
}
// 入栈操作
public void push(int val) {
ListNode newNode = new ListNode(val);
newNode.next = head;
head = newNode;
}
// 出栈操作
public int pop() {
if (head == null) {
throw new NoSuchElementException("堆栈已空");
}
int val = head.val;
head = head.next;
return val;
}
// 判断堆栈是否为空
public boolean isEmpty() {
return (head == null);
}
}
4.7 链表实现先进先出队列的基本原理与代码实现(Java)
链表同样可以被用来实现先进先出(FIFO)的队列数据结构。队列是一种常见的数据结构,它具有先进先出的特点,即最先进入队列的元素最先被取出。
下面是链表实现队列的基本原理:
-
创建一个链表对象,并将其初始化为空。
-
入队操作:在链表尾部插入一个新节点,将元素值存储在节点中。
-
出队操作:从链表头部删除一个节点,并返回该节点中存储的元素值。
-
判断队列是否为空:检查链表是否为空,如果为空则表示队列也为空。
以下是Java代码实现:
public class LinkedListQueue {
private ListNode head;
private ListNode tail;
public LinkedListQueue() {
head = null;
tail = null;
}
// 入队操作
public void enqueue(int val) {
ListNode newNode = new ListNode(val);
if (head == null) {
head = newNode;
tail = newNode;
} else {
tail.next = newNode;
tail = newNode;
}
}
// 出队操作
public int dequeue() {
if (head == null) {
throw new NoSuchElementException("队列已空");
}
int val = head.val;
head = head.next;
if (head == null) { // 队列已空
tail = null;
}
return val;
}
// 判断队列是否为空
public boolean isEmpty() {
return (head == null);
}
}
4.8 链表实现背包的基本原理与代码实现(Java)
链表同样可以被用来实现背包(Bag)数据结构。背包是一种常见的数据结构,它可以用来存储任意数量的元素,并支持添加、遍历和判断是否为空等操作。
下面是链表实现背包的基本原理:
-
创建一个链表对象,并将其初始化为空。
-
添加元素:在链表头部插入一个新节点,将元素值存储在节点中。
-
遍历元素:从链表头部开始遍历整个链表,并输出每个节点中存储的元素值。
-
判断背包是否为空:检查链表是否为空,如果为空则表示背包也为空。
以下是Java代码实现:
public class LinkedListBag {
private ListNode head;
public LinkedListBag() {
head = null;
}
// 添加元素
public void add(int val) {
ListNode newNode = new ListNode(val);
newNode.next = head;
head = newNode;
}
// 遍历元素
public void forEach() {
ListNode cur = head;
while (cur != null) {
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
// 判断背包是否为空
public boolean isEmpty() {
return (head == null);
}
}
4.9 总结
链表是一种常见的数据结构,它由多个节点组成,每个节点包含数据和指向下一个节点的指针。链表可以分为单向链表、双向链表和循环链表等多种形式,具有动态性、灵活性等特点。在计算机科学领域中被广泛应用于各种算法和数据结构的实现。常见的应用场景包括:链式存储结构、LRU缓存淘汰算法、大整数计算和垃圾回收等。在Java中,链表可以被用来实现下压堆栈、先进先出队列和背包等数据结构。
5.算法分析
5.1 时间复杂度分析
时间复杂度是衡量算法运行效率的重要指标,通常用大O表示法来表示。在分析算法时间复杂度时,我们需要考虑算法中基本操作的执行次数与输入数据规模之间的关系。
例如,在排序算法中,比较操作和交换操作是基本操作。对于n个元素的数组,冒泡排序算法需要进行(n-1)+(n-2)+…+2+1 = (n2-n)/2次比较和交换操作。因此,其时间复杂度为O(n2)。而快速排序算法的时间复杂度为O(nlogn),因为它利用了分治策略,将大问题分解成小问题,并且每个子问题的规模都是原问题规模的一半。
在实际应用中,我们通常关注最坏情况下的时间复杂度,因为它反映了算法在最不利情况下的运行效率。同时,我们也需要考虑空间复杂度、稳定性等其他指标。
5.2 空间复杂度分析
空间复杂度是衡量算法所需内存空间大小的指标。通常使用常数符号O(1)、线性符号O(n)、对数符号O(log n)等来表示。在分析空间复杂度时,我们需要考虑算法所使用的辅助空间大小与输入数据规模之间的关系。
例如,在插入排序中,只需要一个额外变量来保存当前待插入元素即可,因此其空间复杂度为O(1)。而归并排序则需要额外申请一个长度为n的数组来辅助归并操作,因此其空间复杂度为O(n)。
在实际应用中,我们通常也关注平均情况下和最坏情况下的空间复杂度,并根据实际需求选择合适的算法。
5.3 稳定性分析
稳定性是衡量排序算法是否能够保证相同元素相对位置不变化的指标。例如,在进行学生信息按照成绩排序时,如果有两个学生成绩相同,则按照姓名字典序进行排序后应该保持原有顺序不变。
稳定性可以通过比较相邻元素是否交换来判断。如果相邻元素交换后相对位置发生改变,则说明该排序算法不具有稳定性;反之,则具有稳定性。
例如,在冒泡排序中相邻元素始终按照从左到右进行比较和交换操作,并且只有当右侧元素小于左侧元素时才会进行交换。因此可以证明冒泡排序具有稳定性。而快速排序则可能会改变相同元素之间的顺序,因此不具备稳定性。
总体而言,在实际应用中我们要根据具体需求选择合适的算法,并结合时间、空间、稳定性等指标进行综合评估和优化。
6.案例研究:Union-find 算法
6.1 动态连通性
在计算机科学中,动态连通性是一种表示对象之间连接关系的问题。一个典型的例子是社交网络中的朋友关系。给定一组对象和它们之间的连接关系,我们需要能够快速回答以下两个问题:
- 任意两个对象是否联通?
- 对象之间的连接关系是否随时间发生了变化?
6.2 quick-find 算法的基本原理及代码实现(java)
quick-find 算法是 Union-find 算法的最简单实现之一,其基本原理是将所有对象初始时都看作是孤立的个体,将它们放在一个数组 id[] 中,并用相应的 id 值来表示它们所属的连通分量。当两个对象需要连接时,我们只需要找到它们各自所属的连通分量(即它们在数组 id[] 中对应的 id 值),然后将其中一个对象所属的连通分量中所有对象的 id 值都修改为另一个对象所属的连通分量中所有对象的 id 值,这样就完成了两个对象之间的连接。具体实现如下:
public class QuickFindUF {
private int[] id;
public QuickFindUF(int N) {
// 初始化数组
id = new int[N];
for (int i = 0; i < N; i++) {
id[i] = i;
}
}
public boolean connected(int p, int q) {
// 判断两个对象是否在同一个连通分量中
return id[p] == id[q];
}
public void union(int p, int q) {
// 将两个对象所在连通分量合并
int pid = id[p];
int qid = id[q];
for (int i = 0; i < id.length; i++) {
if (id[i] == pid) {
id[i] = qid;
}
}
}
}
上述代码中,connected() 方法用于判断两个对象是否在同一个连通分量中,union() 方法用于将两个对象所在连通分量合并。
该算法最坏时间复杂度为 O(N^2),因为每次合并操作都需要遍历整个数组。对于大规模问题,这种算法效率很低。接下来我们会介绍更高效的 Union-find 算法实现方法。
6.3 quick-union 算法的基本原理及代码实现
quick-union 算法是另一种 Union-find 算法的实现方法,其基本原理是将每个对象看作是一棵树的根节点,用一个数组 parent[] 来表示它们之间的连接关系,其中 parent[i] 表示对象 i 的父节点。当两个对象需要连接时,我们只需要找到它们各自所在树的根节点(即它们在数组 parent[] 中对应的值),然后将其中一个根节点指向另一个根节点,这样就完成了两个对象之间的连接。具体实现如下:
public class QuickUnionUF {
private int[] parent;
public QuickUnionUF(int N) {
// 初始化数组
parent = new int[N];
for (int i = 0; i < N; i++) {
parent[i] = i;
}
}
private int find(int p) {
// 找到 p 所在树的根节点
while (p != parent[p]) {
p = parent[p];
}
return p;
}
public boolean connected(int p, int q) {
// 判断两个对象是否在同一个连通分量中
return find(p) == find(q);
}
public void union(int p, int q) {
// 将两个对象所在连通分量合并
int rootP = find(p);
int rootQ = find(q);
parent[rootP] = rootQ;
}
}
上述代码中,find() 方法用于找到给定对象所在树的根节点,connected() 方法用于判断两个对象是否在同一个连通分量中,union() 方法用于将两个对象所在连通分量合并。
该算法最坏时间复杂度为 O(N),因为每次查找和合并操作都只需要遍历一棵子树。但是当形成链状结构时(即所有对象都直接或间接地连接到第一个对象上),该算法仍然会退化为 O(N^2) 的复杂度。
6.4 加权 quick-union 算法的基本原理及代码实现
加权 quick-union 算法是 quick-union 算法的改进版本,它通过记录每个根节点所对应的子树大小来保证在合并两个连通分量时始终将较小的子树连接到较大的子树上,从而避免了形成链状结构。具体实现如下:
public class WeightedQuickUnionUF {
private int[] parent;
private int[] size;
public WeightedQuickUnionUF(int N) {
// 初始化数组
parent = new int[N];
size = new int[N];
for (int i = 0; i < N; i++) {
parent[i] = i;
size[i] = 1;
}
}
private int find(int p) {
// 找到 p 所在树的根节点
while (p != parent[p]) {
p = parent[p];
}
return p;
}
public boolean connected(int p, int q) {
// 判断两个对象是否在同一个连通分量中
return find(p) == find(q);
}
public void union(int p, int q) {
// 将两个对象所在连通分量合并
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ) {
return;
}
if (size[rootP] < size[rootQ]) {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
} else {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
}
}
}
上述代码中,size[] 数组用于记录每个根节点所对应的子树大小。在合并两个连通分量时,我们总是将较小的子树连接到较大的子树上,并更新相应的大小值。这样可以保证整棵树始终是平衡的,并且避免了形成链状结构。
该算法最坏时间复杂度为 O(NlogN),因为每次查找和合并操作都需要遍历一棵平衡树。对于大规模问题,该算法效率比 quick-find 和 quick-union 算法都要高。
6.5 使用路径压缩的加权quick-union算法的基本原理及代码实现
路径压缩是对加权 quick-union 算法的优化,其基本思想是在查找根节点时将路径上的所有节点都直接连接到根节点上,从而缩短后续查找的路径长度。具体实现如下:
public class WeightedQuickUnionPathCompressionUF {
private int[] parent;
private int[] size;
public WeightedQuickUnionPathCompressionUF(int N) {
// 初始化数组
parent = new int[N];
size = new int[N];
for (int i = 0; i < N; i++) {
parent[i] = i;
size[i] = 1;
}
}
private int find(int p) {
// 找到 p 所在树的根节点,并进行路径压缩
while (p != parent[p]) {
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
public boolean connected(int p, int q) {
// 判断两个对象是否在同一个连通分量中
return find(p) == find(q);
}
public void union(int p, int q) {
// 将两个对象所在连通分量合并
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ) {
return;
}
if (size[rootP] < size[rootQ]) {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
} else {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
}
}
}
上述代码中,在查找根节点时,我们不仅要找到根节点,还需要将路径上的所有节点都直接连接到根节点上,这样可以保证后续查找的效率。该算法最坏时间复杂度为 O(NlogN),但实际运行效率比加权 quick-union 算法更高。
6.6 理想情况和最优算法
在理想情况下,可以使用常数时间完成任意两个对象之间的连接和查询操作。但是,目前还没有找到这样一种算法。在实际情况下,根据问题的规模和复杂性,需要选择合适的算法来解决动态连通性问题。
从上述四种算法来看,在大规模问题下,加权 quick-union 算法和使用路径压缩的加权 quick-union 算法是最优的选择。它们的时间复杂度均为 O(NlogN),并且能够有效处理形成链状结构和树高度不平衡的情况。因此,在实际应用中,可以优先考虑使用这两种算法。