diff --git a/notes/算法.md b/notes/算法.md index b6faef12..0669b910 100644 --- a/notes/算法.md +++ b/notes/算法.md @@ -7,27 +7,32 @@ * [二、栈和队列](#二栈和队列) * [栈](#栈) * [队列](#队列) -* [三、union-find](#三union-find) - * [quick-find](#quick-find) - * [quick-union](#quick-union) - * [加权 quick-union](#加权-quick-union) - * [路径压缩的加权 quick-union](#路径压缩的加权-quick-union) - * [各种 union-find 算法的比较](#各种-union-find-算法的比较) -* [四、排序](#四排序) +* [三、排序](#三排序) * [选择排序](#选择排序) + * [冒泡排序](#冒泡排序) * [插入排序](#插入排序) * [希尔排序](#希尔排序) * [归并排序](#归并排序) * [快速排序](#快速排序) - * [优先队列](#优先队列) - * [应用](#应用) -* [五、查找](#五查找) + * [堆排序](#堆排序) + * [桶排序](#桶排序) + * [基数排序](#基数排序) + * [外部排序](#外部排序) + * [排序算法的比较](#排序算法的比较) + * [Java 的排序算法实现](#java-的排序算法实现) +* [四、查找](#四查找) * [二分查找实现有序符号表](#二分查找实现有序符号表) * [二叉查找树](#二叉查找树) * [2-3 查找树](#2-3-查找树) * [红黑二叉查找树](#红黑二叉查找树) * [散列表](#散列表) * [应用](#应用) +* [五、union-find](#五union-find) + * [quick-find](#quick-find) + * [quick-union](#quick-union) + * [加权 quick-union](#加权-quick-union) + * [路径压缩的加权 quick-union](#路径压缩的加权-quick-union) + * [各种 union-find 算法的比较](#各种-union-find-算法的比较) * [参考资料](#参考资料) @@ -54,28 +59,25 @@ N3/6-N2/2+N/3 的增长数量级为 O(N3)。增 ## ThreeSum -ThreeSum 用于统计一个数组中三元组的和为 0 的数量。 +ThreeSum 用于统计一个数组中和为 0 的三元组数量。 ```java public class ThreeSum { - public int count(int[] nums) { + public static int count(int[] nums) { int N = nums.length; int cnt = 0; - for (int i = 0; i < N; i++) { - for (int j = i + 1; j < N; j++) { - for (int k = j + 1; k < N; k++) { - if (nums[i] + nums[j] + nums[k] == 0) { + for (int i = 0; i < N; i++) + for (int j = i + 1; j < N; j++) + for (int k = j + 1; k < N; k++) + if (nums[i] + nums[j] + nums[k] == 0) cnt++; - } - } - } - } + return cnt; } } ``` -该算法的内循环为 if(a[i]+a[j]+a[k]==0) 语句,总共执行的次数为 N(N-1)(N-2) = N3/6-N2/2+N/3,因此它的近似执行次数为 \~N3/6,增长数量级为 O(N3)。 +该算法的内循环为 `if (nums[i] + nums[j] + nums[k] == 0)` 语句,总共执行的次数为 N(N-1)(N-2) = N3/6-N2/2+N/3,因此它的近似执行次数为 \~N3/6,增长数量级为 O(N3)。 **改进**
@@ -85,28 +87,34 @@ public class ThreeSum { ```java public class ThreeSumFast { - public int count(int[] nums) { + public static int count(int[] nums) { Arrays.sort(nums); int N = nums.length; int cnt = 0; - for (int i = 0; i < N; i++) { + for (int i = 0; i < N; i++) for (int j = i + 1; j < N; j++) { + int target = -nums[i] - nums[j]; + int index = binarySearch(nums, target); // 应该注意这里的下标必须大于 j,否则会重复统计。 - if (binarySearch(nums, -nums[i] - nums[j]) > j) { + if (index <= j) + continue; + while (index < N && nums[index++] == target) cnt++; - } } - } + return cnt; } - private int binarySearch(int[] nums, int target) { + private static int binarySearch(int[] nums, int target) { int l = 0, h = nums.length - 1; while (l <= h) { int m = l + (h - l) / 2; - if (target == nums[m]) return m; - else if (target > nums[m]) l = m + 1; - else h = m - 1; + if (target == nums[m]) + return m; + else if (target > nums[m]) + l = m + 1; + else + h = m - 1; } return -1; } @@ -117,19 +125,40 @@ public class ThreeSumFast { 如果 T(N) \~ aNblogN,那么 T(2N)/T(N) \~ 2b。 -例如对于暴力方法的 ThreeSum 算法,近似时间为 \~N3/6。进行如下实验:多次运行该算法,每次取的 N 值为前一次的两倍,统计每次执行的时间,并统计本次运行时间与前一次运行时间的比值,得到如下结果: +例如对于暴力的 ThreeSum 算法,近似时间为 \~N3/6。进行如下实验:多次运行该算法,每次取的 N 值为前一次的两倍,统计每次执行的时间,并统计本次运行时间与前一次运行时间的比值,得到如下结果: -| N | Time | Ratio | +| N | Time(ms) | Ratio | | :---: | :---: | :---: | -| 250 | 0.0 | 2.7 | -| 500 | 0.0 | 4.8 | -| 1000 | 0.1 | 6.9 | -| 2000 | 0.8 | 7.7 | -| 4000 | 6.4 | 8.0 | -| 8000 | 51.1 | 8.0 | +| 500 | 48 | / | +| 1000 | 320 | 6.7 | +| 2000 | 555 | 1.7 | +| 4000 | 4105 | 7.4 | +| 8000 | 33575 | 8.2 | +| 16000 | 268909 | 8.0 | 可以看到,T(2N)/T(N) \~ 23,因此可以确定 T(N) \~ aN3logN。 +```java +public class RatioTest { + public static void main(String[] args) { + int N = 500; + int K = 7; + long preTime = -1; + while (K-- > 0) { + int[] nums = new int[N]; + long startTime = System.currentTimeMillis(); + int cnt = ThreeSum.count(nums); + long endTime = System.currentTimeMillis(); + long time = endTime - startTime; + double ratio = preTime == -1 ? 0 : (double) time / preTime; + System.out.println(N + " " + time + " " + ratio); + preTime = time; + N *= 2; + } + } +} +``` + ## 注意事项 ### 1. 大常数 @@ -156,36 +185,47 @@ public class ThreeSumFast { ## 栈 -> First-In-Last-Out +First-In-Last-Out - **1. 数组实现**
+### 1. 数组实现 ```java -public class ResizeArrayStack implements Iterable { - private Item[] a = (Item[]) new Object[1]; +import java.util.Iterator; + +public class ResizingArrayStack implements Iterable { + // 栈元素数组 + private Item[] a = (Item[]) new Object[1]; // 只能通过转型来创建泛型数组 + // 元素数量 private int N = 0; public void push(Item item) { - if (N >= a.length) { - resize(2 * a.length); - } + check(); a[N++] = item; } - public Item pop() { + public Item pop() throws Exception { + if (isEmpty()) + throw new Exception("stack is empty"); Item item = a[--N]; - if (N <= a.length / 4) { - resize(a.length / 2); - } + check(); + a[N] = null; // 避免对象游离 return item; } - // 调整数组大小,使得栈具有伸缩性 + private void check() { + if (N >= a.length) + resize(2 * a.length); + else if (N > 0 && N <= a.length / 4) + resize(a.length / 2); + } + + /** + * 调整数组大小,使得栈具有伸缩性 + */ private void resize(int size) { Item[] tmp = (Item[]) new Object[size]; - for (int i = 0; i < N; i++) { + for (int i = 0; i < N; i++) tmp[i] = a[i]; - } a = tmp; } @@ -199,7 +239,7 @@ public class ResizeArrayStack implements Iterable { @Override public Iterator iterator() { - // 需要返回逆序遍历的迭代器 + // 返回逆序遍历的迭代器 return new ReverseArrayIterator(); } @@ -219,12 +259,37 @@ public class ResizeArrayStack implements Iterable { } ``` - **2. 链表实现**
+```java +public static void main(String[] args) throws Exception { + ResizingArrayStack stack = new ResizingArrayStack(); + stack.push(1); + stack.push(2); + stack.push(3); + stack.push(4); + System.out.println(stack.isEmpty()); + System.out.println(stack.size()); + System.out.println(stack.pop()); + System.out.println(stack.pop()); + for (Integer item : stack) + System.out.println(item); +} +``` + +```html +false +4 +4 +3 +2 +1 +``` + +### 2. 链表实现 需要使用链表的头插法来实现,因为头插法中最后压入栈的元素在链表的开头,它的 next 指针指向前一个压入栈的元素,在弹出元素使就可以通过 next 指针遍历到前一个压入栈的元素从而让这个元素称为新的栈顶元素。 ```java -public class Stack { +public class ListStack { private Node top = null; private int N = 0; @@ -250,7 +315,9 @@ public class Stack { N++; } - public Item pop() { + public Item pop() throws Exception { + if (isEmpty()) + throw new Exception("stack is empty"); Item item = top.item; top = top.next; N--; @@ -259,9 +326,30 @@ public class Stack { } ``` +```java +public static void main(String[] args) throws Exception { + ListStack stack = new ListStack(); + stack.push(1); + stack.push(2); + stack.push(3); + stack.push(4); + System.out.println(stack.isEmpty()); + System.out.println(stack.size()); + System.out.println(stack.pop()); + System.out.println(stack.pop()); +} +``` + +```html +false +4 +4 +3 +``` + ## 队列 -> First-In-First-Out +First-In-First-Out 下面是队列的链表实现,需要维护 first 和 last 节点指针,分别指向队首和队尾。 @@ -272,203 +360,80 @@ public class Queue { private Node first; private Node last; int N = 0; - private class Node{ + + private class Node { Item item; Node next; } - public boolean isEmpty(){ + public boolean isEmpty() { return N == 0; } - public int size(){ + public int size() { return N; } - // 入队列 - public void enqueue(Item item){ + public void add(Item item) { Node newNode = new Node(); newNode.item = item; newNode.next = null; - if(isEmpty()){ + if (isEmpty()) { last = newNode; first = newNode; - } else{ + } else { last.next = newNode; last = newNode; } N++; } - // 出队列 - public Item dequeue() { - if (isEmpty()) return null; + public Item remove() throws Exception { + if (isEmpty()) + throw new Exception("queue is empty"); Node node = first; first = first.next; N--; - if (isEmpty()) last = null; + if (isEmpty()) + last = null; return node.item; } } ``` -# 三、union-find - - -用于解决动态连通性问题,能动态连接两个点,并且判断两个点是否连通。 - -

- - -| 方法 | 描述 | -| :---: | :---: | -| UF(int N) | 构造一个大小为 N 的并查集 | -| void union(int p, int q) | 连接 p 和 q 节点 | -| int find(int p) | 查找 p 所在的连通分量 | -| boolean connected(int p, int q) | 判断 p 和 q 节点是否连通 | - ```java -public class UF { - private int[] id; - - public UF(int N) { - id = new int[N]; - for (int i = 0; i < N; i++) { - id[i] = i; - } - } - - public boolean connected(int p, int q) { - return find(p) == find(q); - } +public static void main(String[] args) throws Exception { + Queue queue = new Queue(); + queue.add(1); + queue.add(2); + System.out.println(queue.remove()); + System.out.println(queue.remove()); + queue.add(3); + queue.add(4); + System.out.println(queue.size()); } ``` -## quick-find - -可以快速进行 find 操作,即可以快速判断两个节点是否连通。 - -同一连通分量的所有节点的 id 值相等。 - -但是 union 操作代价却很高,需要将其中一个连通分量中的所有节点 id 值都修改为另一个节点的 id 值。 - -

- -```java -public int find(int p) { - return id[p]; -} -public void union(int p, int q) { - int pID = find(p); - int qID = find(q); - - if (pID == qID) return; - for (int i = 0; i < id.length; i++) { - if (id[i] == pID) id[i] = qID; - } -} +```html +1 +2 +2 ``` -## quick-union - -可以快速进行 union 操作,只需要修改一个节点的 id 值即可。 - -但是 find 操作开销很大,因为同一个连通分量的节点 id 值不同,id 值只是用来指向另一个节点。因此需要一直向上查找操作,直到找到最上层的节点。 - -

- -```java - public int find(int p) { - while (p != id[p]) p = id[p]; - return p; - } - - public void union(int p, int q) { - int pRoot = find(p); - int qRoot = find(q); - if (pRoot == qRoot) return; - id[pRoot] = qRoot; - } -``` - -这种方法可以快速进行 union 操作,但是 find 操作和树高成正比,最坏的情况下树的高度为触点的数目。 - -

- -## 加权 quick-union - -为了解决 quick-union 的树通常会很高的问题,加权 quick-union 在 union 操作时会让较小的树连接较大的树上面。 - -理论研究证明,加权 quick-union 算法构造的树深度最多不超过 logN。 - -

- -```java -public class WeightedQuickUnionUF { - private int[] id; - // 保存节点的数量信息 - private int[] sz; - - public WeightedQuickUnionUF(int N) { - id = new int[N]; - sz = new int[N]; - for (int i = 0; i < N; i++) { - id[i] = i; - sz[i] = 1; - } - } - - public boolean connected(int p, int q) { - return find(p) == find(q); - } - - public int find(int p) { - while (p != id[p]) p = id[p]; - return p; - } - - public void union(int p, int q) { - int i = find(p); - int j = find(q); - if (i == j) return; - if (sz[i] < sz[j]) { - id[i] = j; - sz[j] += sz[i]; - } else { - id[j] = i; - sz[i] += sz[j]; - } - } -} -``` - -## 路径压缩的加权 quick-union - -在检查节点的同时将它们直接链接到根节点,只需要在 find 中添加一个循环即可。 - -## 各种 union-find 算法的比较 - -| 算法 | union | find | -| :---: | :---: | :---: | -| quick-find | N | 1 | -| quick-union | 树高 | 树高 | -| 加权 quick-union | logN | logN | -| 路径压缩的加权 quick-union | 非常接近 1 | 非常接近 1 | - -# 四、排序 +# 三、排序 待排序的元素需要实现 Java 的 Comparable 接口,该接口有 compareTo() 方法,可以用它来判断两个元素的大小关系。 研究排序算法的成本模型时,计算的是比较和交换的次数。 -使用辅助函数 less() 和 exch() 来进行比较和交换的操作,使得代码的可读性和可移植性更好。 +使用辅助函数 less() 和 swap() 来进行比较和交换的操作,使得代码的可读性和可移植性更好。 ```java -private boolean less(Comparable v, Comparable w) { +private static boolean less(Comparable v, Comparable w) { return v.compareTo(w) < 0; } -private void exch(Comparable[] a, int i, int j) { +private static void swap(Comparable[] a, int i, int j) { Comparable t = a[i]; a[i] = a[j]; a[j] = t; @@ -477,20 +442,20 @@ private void exch(Comparable[] a, int i, int j) { ## 选择排序 -找到数组中的最小元素,将它与数组的第一个元素交换位置。再从剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。不断进行这样的操作,直到将整个数组排序。 +选择出数组中的最小元素,将它与数组的第一个元素交换位置。再从剩下的元素中选择出最小的元素,将它与数组的第二个元素交换位置。不断进行这样的操作,直到将整个数组排序。 -

+

```java public class Selection { - public void sort(Comparable[] a) { + public static void sort(Comparable[] a) { int N = a.length; for (int i = 0; i < N; i++) { int min = i; - for (int j = i + 1; j < N; j++) { - if (less(a[j], a[min])) min = j; - } - exch(a, i, min); + for (int j = i + 1; j < N; j++) + if (less(a[j], a[min])) + min = j; + swap(a, i, min); } } } @@ -498,57 +463,79 @@ public class Selection { 选择排序需要 \~N2/2 次比较和 \~N 次交换,它的运行时间与输入无关,这个特点使得它对一个已经排序的数组也需要这么多的比较和交换操作。 -## 插入排序 +## 冒泡排序 -插入排序从左到右进行,每次都将当前元素插入到左部已经排序的数组中,使得插入之后左部数组依然有序。 +通过从左到右不断交换相邻逆序的相邻元素,在一轮的交换之后,可以让未排序的元素上浮到最右侧,是的右侧是已排序的。 -

+在一轮交换中,如果没有发生交换,就说明数组已经是有序的,此时可以直接退出。 ```java -public class Insertion { - public void sort(Comparable[] a) { +public class Bubble { + public static void sort(Comparable[] a) { int N = a.length; - for (int i = 1; i < N; i++) { - for (int j = i; j > 0 && less(a[j], a[j - 1]); j--) { - exch(a, j, j - 1); + boolean hasSorted = false; + for (int i = 0; i < N && !hasSorted; i++) { + hasSorted = true; + for (int j = 0; j < N - i - 1; j++) { + if (less(a[j + 1], a[j])) { + hasSorted = false; + swap(a, j, j + 1); + } } } } } ``` -插入排序的复杂度取决于数组的初始顺序,如果数组已经部分有序了,那么插入排序会很快。 +## 插入排序 + +插入排序从左到右进行,每次都将当前元素插入到左侧已经排序的数组中,使得插入之后左部数组依然有序。 + +第 j 元素是通过不断向左比较并交换来实现插入过程:当第 j 元素小于第 j - 1 元素,就将它们的位置交换,然后令 j 指针向左移动一个位置,不断进行以上操作。 + +

+ +```java +public class Insertion { + public static void sort(Comparable[] a) { + int N = a.length; + for (int i = 1; i < N; i++) + for (int j = i; j > 0 && less(a[j], a[j - 1]); j--) + swap(a, j, j - 1); + } +} +``` + +对于数组 {3, 5, 2, 4, 1},它具有以下逆序:(3, 2), (3, 1), (5, 2), (5, 4), (5, 1), (2, 1), (4, 1),插入排序每次只能交换相邻元素,令逆序数量减少 1,因此插入排序需要交换的次数为逆序数量。 + +插入排序的复杂度取决于数组的初始顺序,如果数组已经部分有序了,逆序较少,那么插入排序会很快。 - 平均情况下插入排序需要 \~N2/4 比较以及 \~N2/4 次交换; -- 最坏的情况下需要 \~N2/2 比较以及 \~N2/2 次交换,最坏的情况是数组是逆序的; +- 最坏的情况下需要 \~N2/2 比较以及 \~N2/2 次交换,最坏的情况是数组是倒序的; - 最好的情况下需要 N-1 次比较和 0 次交换,最好的情况就是数组已经有序了。 -插入排序对于部分有序数组和小规模数组特别高效。 - ## 希尔排序 -对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素,如果要把元素从一端移到另一端,就需要很多次操作。 +对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素,每次只能将逆序数量减少 1。 -希尔排序的出现就是为了改进插入排序的这种局限性,它通过交换不相邻的元素,使得元素更快的移到正确的位置上。 +希尔排序的出现就是为了改进插入排序的这种局限性,它通过交换不相邻的元素,每次可以将逆序数量减少大于 1。 -希尔排序使用插入排序对间隔 h 的序列进行排序,如果 h 很大,那么元素就能很快的移到很远的地方。通过不断减小 h,最后令 h=1,就可以使得整个数组是有序的。 +希尔排序使用插入排序对间隔 h 的序列进行排序。通过不断减小 h,最后令 h=1,就可以使得整个数组是有序的。

```java public class Shell { - public void sort(Comparable[] a) { + public static void sort(Comparable[] a) { int N = a.length; int h = 1; - while (h < N / 3) { + while (h < N / 3) h = 3 * h + 1; // 1, 4, 13, 40, ... - } + while (h >= 1) { - for (int i = h; i < N; i++) { - for (int j = i; j >= h && less(a[j], a[j - h]); j -= h) { - exch(a, j, j - h); - } - } + for (int i = h; i < N; i++) + for (int j = i; j >= h && less(a[j], a[j - h]); j -= h) + swap(a, j, j - h); h = h / 3; } } @@ -569,20 +556,23 @@ public class Shell { ```java public class MergeSort { - private Comparable[] aux; + private static Comparable[] aux; - private void merge(Comparable[] a, int lo, int mid, int hi) { - int i = lo, j = mid + 1; + private static void merge(Comparable[] a, int l, int m, int h) { + int i = l, j = m + 1; - for (int k = lo; k <= hi; k++) { + for (int k = l; k <= h; k++) aux[k] = a[k]; // 将数据复制到辅助数组 - } - for (int k = lo; k <= hi; k++) { - if (i > mid) a[k] = aux[j++]; - else if (j > hi) a[k] = aux[i++]; - else if (aux[i].compareTo(a[j]) <= 0) a[k] = aux[i++]; // 先进行这一步,保证稳定性 - else a[k] = aux[j++]; + for (int k = l; k <= h; k++) { + if (i > m) + a[k] = aux[j++]; + else if (j > h) + a[k] = aux[i++]; + else if (aux[i].compareTo(a[j]) <= 0) + a[k] = aux[i++]; // 先进行这一步,保证稳定性 + else + a[k] = aux[j++]; } } } @@ -594,38 +584,37 @@ public class MergeSort { ```java -public void sort(Comparable[] a) { +public static void sort(Comparable[] a) { aux = new Comparable[a.length]; sort(a, 0, a.length - 1); } -private void sort(Comparable[] a, int lo, int hi) { - if (hi <= lo) return; - int mid = lo + (hi - lo) / 2; - sort(a, lo, mid); - sort(a, mid + 1, hi); - merge(a, lo, mid, hi); +private static void sort(Comparable[] a, int l, int h) { + if (h <= l) + return; + int mid = l + (h - l) / 2; + sort(a, l, mid); + sort(a, mid + 1, h); + merge(a, l, mid, h); } ``` 因为每次都将问题对半分成两个子问题,而这种对半分的算法复杂度一般为 O(NlogN),因此该归并排序方法的时间复杂度也为 O(NlogN)。 -小数组的递归操作会过于频繁,可以在数组过小时切换到插入排序来提高性能。 - ### 3. 自底向上归并排序 -先归并那些微型数组,然后成对归并得到的子数组。 +先归并那些微型数组,然后成对归并得到的微型数组。 ```java -public void busort(Comparable[] a) { - int N = a.length; - aux = new Comparable[N]; - for (int sz = 1; sz < N; sz += sz) { - for (int lo = 0; lo < N - sz; lo += sz + sz) { - merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1)); - } - } -} + public static void sort(Comparable[] a) { + int N = a.length; + aux = new Comparable[N]; + for (int sz = 1; sz < N; sz += sz) { + for (int lo = 0; lo < N - sz; lo += sz + sz) { + merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1)); + } + } + } ``` ## 快速排序 @@ -639,19 +628,20 @@ public void busort(Comparable[] a) { ```java public class QuickSort { - public void sort(Comparable[] a) { + public static void sort(Comparable[] a) { shuffle(a); sort(a, 0, a.length - 1); } - private void sort(Comparable[] a, int lo, int hi) { - if (hi <= lo) return; - int j = partition(a, lo, hi); - sort(a, lo, j - 1); - sort(a, j + 1, hi); + private static void sort(Comparable[] a, int l, int h) { + if (h <= l) + return; + int j = partition(a, l, h); + sort(a, l, j - 1); + sort(a, j + 1, h); } - private void shuffle(Comparable[] array) { + private static void shuffle(Comparable[] array) { List list = Arrays.asList(array); Collections.shuffle(list); list.toArray(array); @@ -663,19 +653,20 @@ public class QuickSort { 取 a[lo] 作为切分元素,然后从数组的左端向右扫描直到找到第一个大于等于它的元素,再从数组的右端向左扫描找到第一个小于等于它的元素,交换这两个元素,并不断进行这个过程,就可以保证左指针 i 的左侧元素都不大于切分元素,右指针 j 的右侧元素都不小于切分元素。当两个指针相遇时,将切分元素 a[lo] 和 a[j] 交换位置。 -

+

```java -private int partition(Comparable[] a, int lo, int hi) { - int i = lo, j = hi + 1; - Comparable v = a[lo]; +private static int partition(Comparable[] a, int l, int h) { + int i = l, j = h + 1; + Comparable v = a[l]; while (true) { - while (less(a[++i], v)) if (i == hi) break; - while (less(v, a[--j])) if (j == lo) break; - if (i >= j) break; - exch(a, i, j); + while (less(a[++i], v) && i != h) ; + while (less(v, a[--j]) && j != l) ; + if (i >= j) + break; + swap(a, i, j); } - exch(a, lo, j); + swap(a, l, j); return j; } ``` @@ -692,9 +683,9 @@ private int partition(Comparable[] a, int lo, int hi) { (一)切换到插入排序 -因为快速排序在小数组中也会调用自己,对于小数组,插入排序比快速排序的性能更好,因此在小数组中可以切换到插入排序。 +因为快速排序在小数组中也会递归调用自己,对于小数组,插入排序比快速排序的性能更好,因此在小数组中可以切换到插入排序。 -(二)三取样 +(二)三数取中 最好的情况下是每次都能取数组的中位数作为切分元素,但是计算中位数的代价很高。人们发现取 3 个元素并将大小居中的元素作为切分元素的效果最好。 @@ -706,42 +697,72 @@ private int partition(Comparable[] a, int lo, int hi) { ```java public class Quick3Way { - public void sort(Comparable[] a, int lo, int hi) { - if (hi <= lo) return; - int lt = lo, i = lo + 1, gt = hi; - Comparable v = a[lo]; + public static void sort(Comparable[] a) { + sort(a, 0, a.length - 1); + } + + private static void sort(Comparable[] a, int l, int h) { + if (h <= l) + return; + int lt = l, i = l + 1, gt = h; + Comparable v = a[l]; while (i <= gt) { int cmp = a[i].compareTo(v); - if (cmp < 0) exch(a, lt++, i++); - else if (cmp > 0) exch(a, i, gt--); - else i++; + if (cmp < 0) + swap(a, lt++, i++); + else if (cmp > 0) + swap(a, i, gt--); + else + i++; } - sort(a, lo, lt - 1); - sort(a, gt + 1, hi); + sort(a, l, lt - 1); + sort(a, gt + 1, h); } } ``` -## 优先队列 +### 5. 基于切分的快速选择算法 -优先队列主要用于处理最大元素。 +快速排序的 partition() 方法,会返回一个整数 j 使得 a[l..j-1] 小于等于 a[j],且 a[j+1..h] 大于等于 a[j],此时 a[j] 就是数组的第 j 大元素。 + +可以利用这个特性找出数组的第 k 个元素。 + +```java +public static Comparable select(Comparable[] a, int k) { + int l = 0, h = a.length - 1; + while (h > l) { + int j = partition(a, l, h); + if (j == k) + return a[k]; + else if (j > k) + h = j - 1; + else + l = j + 1; + } + return a[k]; +} +``` + +该算法是线性级别的,因为每次正好将数组二分,那么比较的总次数为 (N+N/2+N/4+..),直到找到第 k 个元素,这个和显然小于 2N。 + +## 堆排序 ### 1. 堆 堆的某个节点的值总是大于等于子节点的值,并且堆是一颗完全二叉树。 - -堆可以用数组来表示,因为堆是一种完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里我们不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系。 +堆可以用数组来表示,因为堆是一种完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系。

```java -public class MaxPQ { - private Key[] pq; +public class Heap { + private Comparable[] heap; private int N = 0; - public MaxPQ(int maxN) { - pq = (Key[]) new Comparable[maxN + 1]; + public Heap(int maxN) { + heap = new Comparable[maxN + 1]; + N = maxN; } public boolean isEmpty() { @@ -753,33 +774,33 @@ public class MaxPQ { } private boolean less(int i, int j) { - return pq[i].compareTo(pq[j]) < 0; + return heap[i].compareTo(heap[j]) < 0; } - private void exch(int i, int j) { - Key t = pq[i]; - pq[i] = pq[j]; - pq[j] = t; + private void swap(int i, int j) { + Comparable t = heap[i]; + heap[i] = heap[j]; + heap[j] = t; } } ``` ### 2. 上浮和下沉 -在堆中,当一个节点比父节点大,那么需要交换这个两个节点。交换后还可能比它新的父节点大,因此需要不断地进行比较和交换操作。把这种操作称为上浮。 +在堆中,当一个节点比父节点大,那么需要交换这个两个节点。交换后还可能比它新的父节点大,因此需要不断地进行比较和交换操作,把这种操作称为上浮。

```java private void swim(int k) { while (k > 1 && less(k / 2, k)) { - exch(k / 2, k); + swap(k / 2, k); k = k / 2; } } ``` -类似地,当一个节点比子节点来得小,也需要不断的向下比较和交换操作,把这种操作称为下沉。一个节点有两个子节点,应当与两个子节点中最大那么节点进行交换。 +类似地,当一个节点比子节点来得小,也需要不断地向下进行比较和交换操作,把这种操作称为下沉。一个节点有两个子节点,应当与两个子节点中最大那么节点进行交换。

@@ -787,9 +808,11 @@ private void swim(int k) { private void sink(int k) { while (2 * k <= N) { int j = 2 * k; - if (j < N && less(j, j + 1)) j++; - if (!less(k, j)) break; - exch(k, j); + if (j < N && less(j, j + 1)) + j++; + if (!less(k, j)) + break; + swap(k, j); k = j; } } @@ -800,8 +823,8 @@ private void sink(int k) { 将新元素放到数组末尾,然后上浮到合适的位置。 ```java -public void insert(Key v) { - pq[++N] = v; +public void insert(Comparable v) { + heap[++N] = v; swim(N); } ``` @@ -811,10 +834,10 @@ public void insert(Key v) { 从数组顶端删除最大的元素,并将数组的最后一个元素放到顶端,并让这个元素下沉到合适的位置。 ```java -public Key delMax() { - Key max = pq[1]; - exch(1, N--); - pq[N + 1] = null; +public Comparable delMax() { + Comparable max = heap[1]; + swap(1, N--); + heap[N + 1] = null; sink(1); return max; } @@ -834,19 +857,34 @@ public Key delMax() { 交换之后需要进行下沉操作维持堆的有序状态。 -

+


```java -public static void sort(Comparable[] a){ - int N = a.length; - for(int k = N/2; k >= 1; k--){ - sink(a, k, N); +public class HeapSort { + public static void sort(Comparable[] a) { // 数组第 0 个位置不能有元素 + int N = a.length - 1; + for (int k = N / 2; k >= 0; k--) + sink(a, k, N); + + while (N > 1) { + swap(a, 1, N--); + sink(a, 1, N); + } } - while(N > 1){ - exch(a, 1, N--); - sink(a, 1, N); + + + private static void sink(Comparable[] a, int k, int N) { + while (2 * k <= N) { + int j = 2 * k; + if (j < N && less(a, j, j + 1)) + j++; + if (!less(a, k, j)) + break; + swap(a, k, j); + k = j; + } } } ``` @@ -861,9 +899,13 @@ public static void sort(Comparable[] a){ 现代操作系统很少使用堆排序,因为它无法利用缓存,也就是数组元素很少和相邻的元素进行比较。 -## 应用 +## 桶排序 -### 1. 排序算法的比较 +## 基数排序 + +## 外部排序 + +## 排序算法的比较 | 算法 | 稳定 | 原地排序 | 时间复杂度 | 空间复杂度 | 备注 | | :---: | :---: | :---: | :---: | :---: | :---: | @@ -875,34 +917,13 @@ public static void sort(Comparable[] a){ | 归并排序 | yes | no | NlogN | N | | | 堆排序 | no | yes | NlogN | 1 | | | -快速排序是最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。它的运行时间近似为 \~cNlogN,这里的 c 比其他线性对数级别的排序算法都要小。使用三向切分之后,实际应用中可能出现的某些分布的输入能够达到线性级别,而其它排序算法仍然需要线性对数时间。 +快速排序是最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。它的运行时间近似为 \~cNlogN,这里的 c 比其他线性对数级别的排序算法都要小。使用三向切分快速排序,实际应用中可能出现的某些分布的输入能够达到线性级别,而其它排序算法仍然需要线性对数时间。 -### 2. Java 的排序算法实现 +## Java 的排序算法实现 -Java 系统库中的主要排序方法为 java.util.Arrays.sort(),对于原始数据类型使用三向切分的快速排序,对于引用类型使用归并排序。 +Java 主要排序方法为 java.util.Arrays.sort(),对于原始数据类型使用三向切分的快速排序,对于引用类型使用归并排序。 -### 3. 基于切分的快速选择算法 - -快速排序的 partition() 方法,会返回一个整数 j 使得 a[lo..j-1] 小于等于 a[j],且 a[j+1..hi] 大于等于 a[j],此时 a[j] 就是数组的第 j 大元素。 - -可以利用这个特性找出数组的第 k 个元素。 - -```java -public static Comparable select(Comparable[] a, int k) { - int lo = 0, hi = a.length - 1; - while (hi > lo) { - int j = partion(a, lo, hi); - if (j == k) return a[k]; - else if (j > k) hi = j - 1; - else lo = j + 1; - } - return a[k]; -} -``` - -该算法是线性级别的,因为每次正好将数组二分,那么比较的总次数为 (N+N/2+N/4+..),直到找到第 k 个元素,这个和显然小于 2N。 - -# 五、查找 +# 四、查找 符号表是一种存储键值对的数据结构,主要支持两种操作:插入一个新的键值对、根据给定键得到值。 @@ -1612,6 +1633,149 @@ public class SparseVector { } ``` +# 五、union-find + + +用于解决动态连通性问题,能动态连接两个点,并且判断两个点是否连通。 + +

+ + +| 方法 | 描述 | +| :---: | :---: | +| UF(int N) | 构造一个大小为 N 的并查集 | +| void union(int p, int q) | 连接 p 和 q 节点 | +| int find(int p) | 查找 p 所在的连通分量 | +| boolean connected(int p, int q) | 判断 p 和 q 节点是否连通 | + +```java +public class UF { + private int[] id; + + public UF(int N) { + id = new int[N]; + for (int i = 0; i < N; i++) { + id[i] = i; + } + } + + public boolean connected(int p, int q) { + return find(p) == find(q); + } +} +``` + +## quick-find + +可以快速进行 find 操作,即可以快速判断两个节点是否连通。 + +同一连通分量的所有节点的 id 值相等。 + +但是 union 操作代价却很高,需要将其中一个连通分量中的所有节点 id 值都修改为另一个节点的 id 值。 + +

+ +```java +public int find(int p) { + return id[p]; +} +public void union(int p, int q) { + int pID = find(p); + int qID = find(q); + + if (pID == qID) return; + for (int i = 0; i < id.length; i++) { + if (id[i] == pID) id[i] = qID; + } +} +``` + +## quick-union + +可以快速进行 union 操作,只需要修改一个节点的 id 值即可。 + +但是 find 操作开销很大,因为同一个连通分量的节点 id 值不同,id 值只是用来指向另一个节点。因此需要一直向上查找操作,直到找到最上层的节点。 + +

+ +```java + public int find(int p) { + while (p != id[p]) p = id[p]; + return p; + } + + public void union(int p, int q) { + int pRoot = find(p); + int qRoot = find(q); + if (pRoot == qRoot) return; + id[pRoot] = qRoot; + } +``` + +这种方法可以快速进行 union 操作,但是 find 操作和树高成正比,最坏的情况下树的高度为触点的数目。 + +

+ +## 加权 quick-union + +为了解决 quick-union 的树通常会很高的问题,加权 quick-union 在 union 操作时会让较小的树连接较大的树上面。 + +理论研究证明,加权 quick-union 算法构造的树深度最多不超过 logN。 + +

+ +```java +public class WeightedQuickUnionUF { + private int[] id; + // 保存节点的数量信息 + private int[] sz; + + public WeightedQuickUnionUF(int N) { + id = new int[N]; + sz = new int[N]; + for (int i = 0; i < N; i++) { + id[i] = i; + sz[i] = 1; + } + } + + public boolean connected(int p, int q) { + return find(p) == find(q); + } + + public int find(int p) { + while (p != id[p]) p = id[p]; + return p; + } + + public void union(int p, int q) { + int i = find(p); + int j = find(q); + if (i == j) return; + if (sz[i] < sz[j]) { + id[i] = j; + sz[j] += sz[i]; + } else { + id[j] = i; + sz[i] += sz[j]; + } + } +} +``` + +## 路径压缩的加权 quick-union + +在检查节点的同时将它们直接链接到根节点,只需要在 find 中添加一个循环即可。 + +## 各种 union-find 算法的比较 + +| 算法 | union | find | +| :---: | :---: | :---: | +| quick-find | N | 1 | +| quick-union | 树高 | 树高 | +| 加权 quick-union | logN | logN | +| 路径压缩的加权 quick-union | 非常接近 1 | 非常接近 1 | + # 参考资料 - Sedgewick, Robert, and Kevin Wayne. _Algorithms_. Addison-Wesley Professional, 2011.