转载地址:

注意要点:

1.希尔排序:实际是将元素按步距h分为几组,每组元素没有关系,是组里每个元素跨步距h得到的一组元素是有序的,那么剩下的问题就是组内有序,再处理好组间边界即可。实际解决的方式是不断缩小h,每趟h都采用的是插入排序,保障这种跨步距h有序,那么h缩减为1时,就是跨步距1有序,也就是全部有序,完成排序。

这里的例子让h是元素总数的1/3,然后每次除3,直到为1。

一、前言

二、算法分析

数学模型

1. 近似

N3/6-N2/2+N/3 ~ N3/6。使用 ~f(N) 来表示所有随着 N 的增大除以 f(N) 的结果趋近于 1 的函数。

2. 增长数量级

N3/6-N2/2+N/3 的增长数量级为 O(N3)。增长数量级将算法与它的实现隔离开来,一个算法的增长数量级为 O(N3) 与它是否用 Java 实现,是否运行于特定计算机上无关。

3. 内循环

执行最频繁的指令决定了程序执行的总时间,把这些指令称为程序的内循环。

4. 成本模型

使用成本模型来评估算法,例如数组的访问次数就是一种成本模型。

注意事项

1. 大常数

在求近似时,如果低级项的常数系数很大,那么近似的结果就是错误的。

2. 缓存

计算机系统会使用缓存技术来组织内存,访问数组相邻的元素会比访问不相邻的元素快很多。

3. 对最坏情况下的性能的保证

在核反应堆、心脏起搏器或者刹车控制器中的软件,最坏情况下的性能是十分重要的。

4. 随机化算法

通过打乱输入,去除算法对输入的依赖。

5. 均摊分析

将所有操作的总成本除于操作总数来将成本均摊。例如对一个空栈进行 N 次连续的 push() 调用需要访问数组的元素为 N+4+8+16+...+2N=5N-4(N 是向数组写入元素,其余的都是调整数组大小时进行复制需要的访问数组操作),均摊后每次操作访问数组的平均次数为常数。

ThreeSum

ThreeSum 用于统计一个数组中和为 0 的三元组数量。

public interface ThreeSum {
int count(int[] nums);
}

1. ThreeSumSlow

该算法的内循环为 if (nums[i] + nums[j] + nums[k] == 0) 语句,总共执行的次数为 N(N-1)(N-2) = N3/6-N2/2+N/3,因此它的近似执行次数为 ~N3/6,增长数量级为 O(N3)。

public class ThreeSumSlow implements ThreeSum {
@Override
public 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) {
cnt++;
}
}
}
}
return cnt;
}
}

2. ThreeSumBinarySearch

通过将数组先排序,对两个元素求和,并用二分查找方法查找是否存在该和的相反数,如果存在,就说明存在三元组的和为 0。

应该注意的是,只有数组不含有相同元素才能使用这种解法,否则二分查找的结果会出错。

该方法可以将 ThreeSum 算法增长数量级降低为 O(N2logN)。

public class ThreeSumBinarySearch implements ThreeSum {

    @Override
public int count(int[] nums) {
Arrays.sort(nums);
int N = nums.length;
int cnt = 0;
for (int i = 0; i < N; i++) {
for (int j = i + 1; j < N; j++) {
int target = -nums[i] - nums[j];
int index = BinarySearch.search(nums, target);
// 应该注意这里的下标必须大于 j,否则会重复统计。
if (index > j) {
cnt++;
}
}
}
return cnt;
}
}
public class BinarySearch {

    public static int search(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;
}
}
return -1;
}
}

3. ThreeSumTwoPointer

更有效的方法是先将数组排序,然后使用双指针进行查找,时间复杂度为 O(N2)。

public class ThreeSumTwoPointer implements ThreeSum {

    @Override
public int count(int[] nums) {
int N = nums.length;
int cnt = 0;
Arrays.sort(nums);
for (int i = 0; i < N - 2; i++) {
int l = i + 1, h = N - 1, target = -nums[i];
if (i > 0 && nums[i] == nums[i - 1]) continue;
while (l < h) {
int sum = nums[l] + nums[h];
if (sum == target) {
cnt++;
while (l < h && nums[l] == nums[l + 1]) l++;
while (l < h && nums[h] == nums[h - 1]) h--;
l++;
h--;
} else if (sum < target) {
l++;
} else {
h--;
}
}
}
return cnt;
}
}

倍率实验

如果 T(N) ~ aNblogN,那么 T(2N)/T(N) ~ 2b。

例如对于暴力的 ThreeSum 算法,近似时间为 ~N3/6。进行如下实验:多次运行该算法,每次取的 N 值为前一次的两倍,统计每次执行的时间,并统计本次运行时间与前一次运行时间的比值,得到如下结果:

N Time(ms) Ratio
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。

public class RatioTest {

    public static void main(String[] args) {
int N = 500;
int loopTimes = 7;
double preTime = -1;
while (loopTimes-- > 0) {
int[] nums = new int[N];
StopWatch.start();
ThreeSum threeSum = new ThreeSumSlow();
int cnt = threeSum.count(nums);
System.out.println(cnt);
double elapsedTime = StopWatch.elapsedTime();
double ratio = preTime == -1 ? 0 : elapsedTime / preTime;
System.out.println(N + " " + elapsedTime + " " + ratio);
preTime = elapsedTime;
N *= 2;
}
}
}
public class StopWatch {

    private static long start;

    public static void start() {
start = System.currentTimeMillis();
} public static double elapsedTime() {
long now = System.currentTimeMillis();
return (now - start) / 1000.0;
}
}

三、排序

待排序的元素需要实现 Java 的 Comparable 接口,该接口有 compareTo() 方法,可以用它来判断两个元素的大小关系。

研究排序算法的成本模型时,计算的是比较和交换的次数。

使用辅助函数 less() 和 swap() 来进行比较和交换的操作,使得代码的可读性和可移植性更好。

public abstract class Sort<T extends Comparable<T>> {

    public abstract void sort(T[] nums);

    protected boolean less(T v, T w) {
return v.compareTo(w) < 0;
} protected void swap(T[] a, int i, int j) {
T t = a[i];
a[i] = a[j];
a[j] = t;
}
}

选择排序

选择出数组中的最小元素,将它与数组的第一个元素交换位置。再从剩下的元素中选择出最小的元素,将它与数组的第二个元素交换位置。不断进行这样的操作,直到将整个数组排序。

选择排序需要 ~N2/2 次比较和 ~N 次交换,它的运行时间与输入无关,这个特点使得它对一个已经排序的数组也需要这么多的比较和交换操作。

public class Selection<T extends Comparable<T>> extends Sort<T> {

    @Override
public void sort(T[] nums) {
int N = nums.length;
for (int i = 0; i < N - 1; i++) {
int min = i;
for (int j = i + 1; j < N; j++) {
if (less(nums[j], nums[min])) {
min = j;
}
}
swap(nums, i, min);
}
}
}

冒泡排序

从左到右不断交换相邻逆序的元素,在一轮的循环之后,可以让未排序的最大元素上浮到右侧。

在一轮循环中,如果没有发生交换,就说明数组已经是有序的,此时可以直接退出。

以下演示了在一轮循环中,将最大的元素 5 上浮到最右侧。

public class Bubble<T extends Comparable<T>> extends Sort<T> {

    @Override
public void sort(T[] nums) {
int N = nums.length;
boolean hasSorted = false;
for (int i = N - 1; i > 0 && !hasSorted; i--) {
hasSorted = true;
for (int j = 0; j < i; j++) {
if (less(nums[j + 1], nums[j])) {
hasSorted = false;
swap(nums, 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 次交换,最坏的情况是数组是倒序的;
  • 最好的情况下需要 N-1 次比较和 0 次交换,最好的情况就是数组已经有序了。

以下演示了在一轮循环中,将元素 2 插入到左侧已经排序的数组中。

public class Insertion<T extends Comparable<T>> extends Sort<T> {

    @Override
public void sort(T[] nums) {
int N = nums.length;
for (int i = 1; i < N; i++) {
for (int j = i; j > 0 && less(nums[j], nums[j - 1]); j--) {
swap(nums, j, j - 1);
}
}
}
}

希尔排序

对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素,每次只能将逆序数量减少 1。

希尔排序的出现就是为了解决插入排序的这种局限性,它通过交换不相邻的元素,每次可以将逆序数量减少大于 1。

希尔排序使用插入排序对间隔 h 的序列进行排序。通过不断减小 h,最后令 h=1,就可以使得整个数组是有序的。

public class Shell<T extends Comparable<T>> extends Sort<T> {

    @Override
public void sort(T[] nums) { int N = nums.length;
int h = 1; 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(nums[j], nums[j - h]); j -= h) {
swap(nums, j, j - h);
}
}
h = h / 3;
}
}
}

希尔排序的运行时间达不到平方级别,使用递增序列 1, 4, 13, 40, ... 的希尔排序所需要的比较次数不会超过 N 的若干倍乘于递增序列的长度。后面介绍的高级排序算法只会比希尔排序快两倍左右。

归并排序

归并排序的思想是将数组分成两部分,分别进行排序,然后归并起来。

1. 归并方法

归并方法将数组中两个已经排序的部分归并成一个。

public abstract class MergeSort<T extends Comparable<T>> extends Sort<T> {

    protected T[] aux;

    protected void merge(T[] nums, int l, int m, int h) {

        int i = l, j = m + 1;

        for (int k = l; k <= h; k++) {
aux[k] = nums[k]; // 将数据复制到辅助数组
} for (int k = l; k <= h; k++) {
if (i > m) {
nums[k] = aux[j++]; } else if (j > h) {
nums[k] = aux[i++]; } else if (aux[i].compareTo(nums[j]) <= 0) {
nums[k] = aux[i++]; // 先进行这一步,保证稳定性 } else {
nums[k] = aux[j++];
}
}
}
}

2. 自顶向下归并排序

将一个大数组分成两个小数组去求解。

因为每次都将问题对半分成两个子问题,这种对半分的算法复杂度一般为 O(NlogN)。

public class Up2DownMergeSort<T extends Comparable<T>> extends MergeSort<T> {

    @Override
public void sort(T[] nums) {
aux = (T[]) new Comparable[nums.length];
sort(nums, 0, nums.length - 1);
} private void sort(T[] nums, int l, int h) {
if (h <= l) {
return;
}
int mid = l + (h - l) / 2;
sort(nums, l, mid);
sort(nums, mid + 1, h);
merge(nums, l, mid, h);
}
}

3. 自底向上归并排序

先归并那些微型数组,然后成对归并得到的微型数组。

public class Down2UpMergeSort<T extends Comparable<T>> extends MergeSort<T> {

    @Override
public void sort(T[] nums) { int N = nums.length;
aux = (T[]) new Comparable[N]; for (int sz = 1; sz < N; sz += sz) {
for (int lo = 0; lo < N - sz; lo += sz + sz) {
merge(nums, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1));
}
}
}
}

快速排序

1. 基本算法

  • 归并排序将数组分为两个子数组分别排序,并将有序的子数组归并使得整个数组排序;
  • 快速排序通过一个切分元素将数组分为两个子数组,左子数组小于等于切分元素,右子数组大于等于切分元素,将这两个子数组排序也就将整个数组排序了。
public class QuickSort<T extends Comparable<T>> extends Sort<T> {

    @Override
public void sort(T[] nums) {
shuffle(nums);
sort(nums, 0, nums.length - 1);
} private void sort(T[] nums, int l, int h) {
if (h <= l)
return;
int j = partition(nums, l, h);
sort(nums, l, j - 1);
sort(nums, j + 1, h);
} private void shuffle(T[] nums) {
List<Comparable> list = Arrays.asList(nums);
Collections.shuffle(list);
list.toArray(nums);
}
}

2. 切分

取 a[l] 作为切分元素,然后从数组的左端向右扫描直到找到第一个大于等于它的元素,再从数组的右端向左扫描找到第一个小于它的元素,交换这两个元素。不断进行这个过程,就可以保证左指针 i 的左侧元素都不大于切分元素,右指针 j 的右侧元素都不小于切分元素。当两个指针相遇时,将切分元素 a[l] 和 a[j] 交换位置。

private int partition(T[] nums, int l, int h) {
int i = l, j = h + 1;
T v = nums[l];
while (true) {
while (less(nums[++i], v) && i != h) ;
while (less(v, nums[--j]) && j != l) ;
if (i >= j)
break;
swap(nums, i, j);
}
swap(nums, l, j);
return j;
}

3. 性能分析

快速排序是原地排序,不需要辅助数组,但是递归调用需要辅助栈。

快速排序最好的情况下是每次都正好能将数组对半分,这样递归调用次数才是最少的。这种情况下比较次数为 CN=2CN/2+N,复杂度为 O(NlogN)。

最坏的情况下,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般。因此最坏的情况下需要比较 N2/2。为了防止数组最开始就是有序的,在进行快速排序时需要随机打乱数组。

4. 算法改进

4.1 切换到插入排序

因为快速排序在小数组中也会递归调用自己,对于小数组,插入排序比快速排序的性能更好,因此在小数组中可以切换到插入排序。

4.2 三数取中

最好的情况下是每次都能取数组的中位数作为切分元素,但是计算中位数的代价很高。人们发现取 3 个元素并将大小居中的元素作为切分元素的效果最好。

4.3 三向切分

对于有大量重复元素的数组,可以将数组切分为三部分,分别对应小于、等于和大于切分元素。

三向切分快速排序对于只有若干不同主键的随机数组可以在线性时间内完成排序。

public class ThreeWayQuickSort<T extends Comparable<T>> extends QuickSort<T> {

    @Override
protected void sort(T[] nums, int l, int h) {
if (h <= l) {
return;
}
int lt = l, i = l + 1, gt = h;
T v = nums[l];
while (i <= gt) {
int cmp = nums[i].compareTo(v);
if (cmp < 0) {
swap(nums, lt++, i++);
} else if (cmp > 0) {
swap(nums, i, gt--);
} else {
i++;
}
}
sort(nums, l, lt - 1);
sort(nums, gt + 1, h);
}
}

5. 基于切分的快速选择算法

快速排序的 partition() 方法,会返回一个整数 j 使得 a[l..j-1] 小于等于 a[j],且 a[j+1..h] 大于等于 a[j],此时 a[j] 就是数组的第 j 大元素。

可以利用这个特性找出数组的第 k 个元素。

该算法是线性级别的,假设每次能将数组二分,那么比较的总次数为 (N+N/2+N/4+..),直到找到第 k 个元素,这个和显然小于 2N。

public T select(T[] nums, int k) {
int l = 0, h = nums.length - 1;
while (h > l) {
int j = partition(nums, l, h); if (j == k) {
return nums[k]; } else if (j > k) {
h = j - 1; } else {
l = j + 1;
}
}
return nums[k];
}

堆排序

1. 堆

堆的某个节点的值总是大于等于子节点的值,并且堆是一颗完全二叉树。

堆可以用数组来表示,这是因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系。

public class Heap<T extends Comparable<T>> {

    private T[] heap;
private int N = 0; public Heap(int maxN) {
this.heap = (T[]) new Comparable[maxN + 1];
} public boolean isEmpty() {
return N == 0;
} public int size() {
return N;
} private boolean less(int i, int j) {
return heap[i].compareTo(heap[j]) < 0;
} private void swap(int i, int j) {
T t = heap[i];
heap[i] = heap[j];
heap[j] = t;
}
}

2. 上浮和下沉

在堆中,当一个节点比父节点大,那么需要交换这个两个节点。交换后还可能比它新的父节点大,因此需要不断地进行比较和交换操作,把这种操作称为上浮。

private void swim(int k) {
while (k > 1 && less(k / 2, k)) {
swap(k / 2, k);
k = k / 2;
}
}

类似地,当一个节点比子节点来得小,也需要不断地向下进行比较和交换操作,把这种操作称为下沉。一个节点如果有两个子节点,应当与两个子节点中最大那个节点进行交换。

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;
swap(k, j);
k = j;
}
}

3. 插入元素

将新元素放到数组末尾,然后上浮到合适的位置。

public void insert(Comparable v) {
heap[++N] = v;
swim(N);
}

4. 删除最大元素

从数组顶端删除最大的元素,并将数组的最后一个元素放到顶端,并让这个元素下沉到合适的位置。

public T delMax() {
T max = heap[1];
swap(1, N--);
heap[N + 1] = null;
sink(1);
return max;
}

5. 堆排序

把最大元素和当前堆中数组的最后一个元素交换位置,并且不删除它,那么就可以得到一个从尾到头的递减序列,从正向来看就是一个递增序列,这就是堆排序。

5.1 构建堆

无序数组建立堆最直接的方法是从左到右遍历数组进行上浮操作。一个更高效的方法是从右至左进行下沉操作,如果一个节点的两个节点都已经是堆有序,那么进行下沉操作可以使得这个节点为根节点的堆有序。叶子节点不需要进行下沉操作,可以忽略叶子节点的元素,因此只需要遍历一半的元素即可。

5.2 交换堆顶元素与最后一个元素

交换之后需要进行下沉操作维持堆的有序状态。

public class HeapSort<T extends Comparable<T>> extends Sort<T> {
/**
* 数组第 0 个位置不能有元素
*/
@Override
public void sort(T[] nums) {
int N = nums.length - 1;
for (int k = N / 2; k >= 1; k--)
sink(nums, k, N); while (N > 1) {
swap(nums, 1, N--);
sink(nums, 1, N);
}
} private void sink(T[] nums, int k, int N) {
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(nums, j, j + 1))
j++;
if (!less(nums, k, j))
break;
swap(nums, k, j);
k = j;
}
} private boolean less(T[] nums, int i, int j) {
return nums[i].compareTo(nums[j]) < 0;
}
}

6. 分析

一个堆的高度为 logN,因此在堆中插入元素和删除最大元素的复杂度都为 logN。

对于堆排序,由于要对 N 个节点进行下沉操作,因此复杂度为 NlogN。

堆排序是一种原地排序,没有利用额外的空间。

现代操作系统很少使用堆排序,因为它无法利用局部性原理进行缓存,也就是数组元素很少和相邻的元素进行比较。

小结

1. 排序算法的比较

算法 稳定性 时间复杂度 空间复杂度 备注
选择排序 × N2 1  
冒泡排序 N2 1  
插入排序 N ~ N2 1 时间复杂度和初始顺序有关
希尔排序 × N 的若干倍乘于递增序列的长度 1  
快速排序 × NlogN logN  
三向切分快速排序 × N ~ NlogN logN 适用于有大量重复主键
归并排序 NlogN N  
堆排序 × NlogN 1  

快速排序是最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。它的运行时间近似为 ~cNlogN,这里的 c 比其它线性对数级别的排序算法都要小。使用三向切分快速排序,实际应用中可能出现的某些分布的输入能够达到线性级别,而其它排序算法仍然需要线性对数时间。

2. Java 的排序算法实现

Java 主要排序方法为 java.util.Arrays.sort(),对于原始数据类型使用三向切分的快速排序,对于引用类型使用归并排序。

四、并查集

用于解决动态连通性问题,能动态连接两个点,并且判断两个点是否连通。

方法 描述
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 节点是否连通
public abstract class UF {

    protected 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 abstract int find(int p); public abstract void union(int p, int q);
}

Quick Find

可以快速进行 find 操作,即可以快速判断两个节点是否连通。

需要保证同一连通分量的所有节点的 id 值相等。

但是 union 操作代价却很高,需要将其中一个连通分量中的所有节点 id 值都修改为另一个节点的 id 值。

public class QuickFindUF extends UF {

    public QuickFindUF(int N) {
super(N);
} @Override
public int find(int p) {
return id[p];
} @Override
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 值只是用来指向另一个节点。因此需要一直向上查找操作,直到找到最上层的节点。

public class QuickUnionUF extends UF {

    public QuickUnionUF(int N) {
super(N);
} @Override
public int find(int p) {
while (p != id[p]) {
p = id[p];
}
return p;
} @Override
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q); if (pRoot != qRoot) {
id[pRoot] = qRoot;
}
}
}

这种方法可以快速进行 union 操作,但是 find 操作和树高成正比,最坏的情况下树的高度为节点的数目。

加权 Quick Union

为了解决 quick-union 的树通常会很高的问题,加权 quick-union 在 union 操作时会让较小的树连接较大的树上面。

理论研究证明,加权 quick-union 算法构造的树深度最多不超过 logN。

public class WeightedQuickUnionUF extends UF {

    // 保存节点的数量信息
private int[] sz; public WeightedQuickUnionUF(int N) {
super(N);
this.sz = new int[N];
for (int i = 0; i < N; i++) {
this.sz[i] = 1;
}
} @Override
public int find(int p) {
while (p != id[p]) {
p = id[p];
}
return p;
} @Override
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
Quick Find N 1
Quick Union 树高 树高
加权 Quick Union logN logN
路径压缩的加权 Quick Union 非常接近 1 非常接近 1

五、栈和队列

public interface MyStack<Item> extends Iterable<Item> {

    MyStack<Item> push(Item item);

    Item pop() throws Exception;

    boolean isEmpty();

    int size();

}

1. 数组实现

public class ArrayStack<Item> implements MyStack<Item> {

    // 栈元素数组,只能通过转型来创建泛型数组
private Item[] a = (Item[]) new Object[1]; // 元素数量
private int N = 0; @Override
public MyStack<Item> push(Item item) {
check();
a[N++] = item;
return this;
} @Override
public Item pop() throws Exception { if (isEmpty()) {
throw new Exception("stack is empty");
} Item item = a[--N]; 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++) {
tmp[i] = a[i];
} a = tmp;
} @Override
public boolean isEmpty() {
return N == 0;
} @Override
public int size() {
return N;
} @Override
public Iterator<Item> iterator() { // 返回逆序遍历的迭代器
return new Iterator<Item>() { private int i = N; @Override
public boolean hasNext() {
return i > 0;
} @Override
public Item next() {
return a[--i];
}
}; }
}

2. 链表实现

需要使用链表的头插法来实现,因为头插法中最后压入栈的元素在链表的开头,它的 next 指针指向前一个压入栈的元素,在弹出元素时就可以通过 next 指针遍历到前一个压入栈的元素从而让这个元素成为新的栈顶元素。

public class ListStack<Item> implements MyStack<Item> {

    private Node top = null;
private int N = 0; private class Node {
Item item;
Node next;
} @Override
public MyStack<Item> push(Item item) { Node newTop = new Node(); newTop.item = item;
newTop.next = top; top = newTop; N++; return this;
} @Override
public Item pop() throws Exception { if (isEmpty()) {
throw new Exception("stack is empty");
} Item item = top.item; top = top.next;
N--; return item;
} @Override
public boolean isEmpty() {
return N == 0;
} @Override
public int size() {
return N;
} @Override
public Iterator<Item> iterator() { return new Iterator<Item>() { private Node cur = top; @Override
public boolean hasNext() {
return cur != null;
} @Override
public Item next() {
Item item = cur.item;
cur = cur.next;
return item;
}
}; }
}

队列

First-In-First-Out

下面是队列的链表实现,需要维护 first 和 last 节点指针,分别指向队首和队尾。

这里需要考虑 first 和 last 指针哪个作为链表的开头。因为出队列操作需要让队首元素的下一个元素成为队首,所以需要容易获取下一个元素,而链表的头部节点的 next 指针指向下一个元素,因此可以让 first 指针链表的开头。

public interface MyQueue<Item> extends Iterable<Item> {

    int size();

    boolean isEmpty();

    MyQueue<Item> add(Item item);

    Item remove() throws Exception;
}
public class ListQueue<Item> implements MyQueue<Item> {

    private Node first;
private Node last;
int N = 0; private class Node {
Item item;
Node next;
} @Override
public boolean isEmpty() {
return N == 0;
} @Override
public int size() {
return N;
} @Override
public MyQueue<Item> add(Item item) { Node newNode = new Node();
newNode.item = item;
newNode.next = null; if (isEmpty()) {
last = newNode;
first = newNode;
} else {
last.next = newNode;
last = newNode;
} N++;
return this;
} @Override
public Item remove() throws Exception { if (isEmpty()) {
throw new Exception("queue is empty");
} Node node = first;
first = first.next;
N--; if (isEmpty()) {
last = null;
} return node.item;
} @Override
public Iterator<Item> iterator() { return new Iterator<Item>() { Node cur = first; @Override
public boolean hasNext() {
return cur != null;
} @Override
public Item next() {
Item item = cur.item;
cur = cur.next;
return item;
}
};
}
}

六、符号表

符号表(Symbol Table)是一种存储键值对的数据结构,可以支持快速查找操作。

符号表分为有序和无序两种,有序符号表主要指支持 min()、max() 等根据键的大小关系来实现的操作。

有序符号表的键需要实现 Comparable 接口。

public interface UnorderedST<Key, Value> {

    int size();

    Value get(Key key);

    void put(Key key, Value value);

    void delete(Key key);
}
public interface OrderedST<Key extends Comparable<Key>, Value> {

    int size();

    void put(Key key, Value value);

    Value get(Key key);

    Key min();

    Key max();

    int rank(Key key);

    List<Key> keys(Key l, Key h);
}

初级实现

1. 链表实现无序符号表

public class ListUnorderedST<Key, Value> implements UnorderedST<Key, Value> {

    private Node first;

    private class Node {
Key key;
Value value;
Node next; Node(Key key, Value value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
} @Override
public int size() {
int cnt = 0;
Node cur = first;
while (cur != null) {
cnt++;
cur = cur.next;
}
return cnt;
} @Override
public void put(Key key, Value value) {
Node cur = first;
// 如果在链表中找到节点的键等于 key 就更新这个节点的值为 value
while (cur != null) {
if (cur.key.equals(key)) {
cur.value = value;
return;
}
cur = cur.next;
}
// 否则使用头插法插入一个新节点
first = new Node(key, value, first);
} @Override
public void delete(Key key) {
if (first == null)
return;
if (first.key.equals(key))
first = first.next;
Node pre = first, cur = first.next;
while (cur != null) {
if (cur.key.equals(key)) {
pre.next = cur.next;
return;
}
pre = pre.next;
cur = cur.next;
}
} @Override
public Value get(Key key) {
Node cur = first;
while (cur != null) {
if (cur.key.equals(key))
return cur.value;
cur = cur.next;
}
return null;
}
}

2. 二分查找实现有序符号表

使用一对平行数组,一个存储键一个存储值。

二分查找的 rank() 方法至关重要,当键在表中时,它能够知道该键的位置;当键不在表中时,它也能知道在何处插入新键。

二分查找最多需要 logN+1 次比较,使用二分查找实现的符号表的查找操作所需要的时间最多是对数级别的。但是插入操作需要移动数组元素,是线性级别的。

public class BinarySearchOrderedST<Key extends Comparable<Key>, Value> implements OrderedST<Key, Value> {

    private Key[] keys;
private Value[] values;
private int N = 0; public BinarySearchOrderedST(int capacity) {
keys = (Key[]) new Comparable[capacity];
values = (Value[]) new Object[capacity];
} @Override
public int size() {
return N;
} @Override
public int rank(Key key) {
int l = 0, h = N - 1;
while (l <= h) {
int m = l + (h - l) / 2;
int cmp = key.compareTo(keys[m]);
if (cmp == 0)
return m;
else if (cmp < 0)
h = m - 1;
else
l = m + 1;
}
return l;
} @Override
public List<Key> keys(Key l, Key h) {
int index = rank(l);
List<Key> list = new ArrayList<>();
while (keys[index].compareTo(h) <= 0) {
list.add(keys[index]);
index++;
}
return list;
} @Override
public void put(Key key, Value value) {
int index = rank(key);
// 如果找到已经存在的节点键为 key,就更新这个节点的值为 value
if (index < N && keys[index].compareTo(key) == 0) {
values[index] = value;
return;
}
// 否则在数组中插入新的节点,需要先将插入位置之后的元素都向后移动一个位置
for (int j = N; j > index; j--) {
keys[j] = keys[j - 1];
values[j] = values[j - 1];
}
keys[index] = key;
values[index] = value;
N++;
} @Override
public Value get(Key key) {
int index = rank(key);
if (index < N && keys[index].compareTo(key) == 0)
return values[index];
return null;
} @Override
public Key min() {
return keys[0];
} @Override
public Key max() {
return keys[N - 1];
}
}

二叉查找树

二叉树 是一个空链接,或者是一个有左右两个链接的节点,每个链接都指向一颗子二叉树。

二叉查找树 (BST)是一颗二叉树,并且每个节点的值都大于等于其左子树中的所有节点的值而小于等于右子树的所有节点的值。

BST 有一个重要性质,就是它的中序遍历结果递增排序。

基本数据结构:

public class BST<Key extends Comparable<Key>, Value> implements OrderedST<Key, Value> {

    protected Node root;

    protected class Node {
Key key;
Value val;
Node left;
Node right;
// 以该节点为根的子树节点总数
int N;
// 红黑树中使用
boolean color; Node(Key key, Value val, int N) {
this.key = key;
this.val = val;
this.N = N;
}
} @Override
public int size() {
return size(root);
} private int size(Node x) {
if (x == null)
return 0;
return x.N;
} protected void recalculateSize(Node x) {
x.N = size(x.left) + size(x.right) + 1;
}
}

为了方便绘图,下文中二叉树的空链接不画出来。

1. get()

  • 如果树是空的,则查找未命中;
  • 如果被查找的键和根节点的键相等,查找命中;
  • 否则递归地在子树中查找:如果被查找的键较小就在左子树中查找,较大就在右子树中查找。
@Override
public Value get(Key key) {
return get(root, key);
} private Value get(Node x, Key key) {
if (x == null)
return null;
int cmp = key.compareTo(x.key);
if (cmp == 0)
return x.val;
else if (cmp < 0)
return get(x.left, key);
else
return get(x.right, key);
}

2. put()

当插入的键不存在于树中,需要创建一个新节点,并且更新上层节点的链接指向该节点,使得该节点正确地链接到树中。

 @Override
public void put(Key key, Value value) {
root = put(root, key, value);
} private Node put(Node x, Key key, Value value) {
if (x == null)
return new Node(key, value, 1);
int cmp = key.compareTo(x.key);
if (cmp == 0)
x.val = value;
else if (cmp < 0)
x.left = put(x.left, key, value);
else
x.right = put(x.right, key, value);
recalculateSize(x);
return x;
}

3. 分析

二叉查找树的算法运行时间取决于树的形状,而树的形状又取决于键被插入的先后顺序。

最好的情况下树是完全平衡的,每条空链接和根节点的距离都为 logN。

在最坏的情况下,树的高度为 N。

4. floor()

floor(key):小于等于键的最大键

  • 如果键小于根节点的键,那么 floor(key) 一定在左子树中;
  • 如果键大于根节点的键,需要先判断右子树中是否存在 floor(key),如果存在就返回,否则根节点就是 floor(key)。
public Key floor(Key key) {
Node x = floor(root, key);
if (x == null)
return null;
return x.key;
} private Node floor(Node x, Key key) {
if (x == null)
return null;
int cmp = key.compareTo(x.key);
if (cmp == 0)
return x;
if (cmp < 0)
return floor(x.left, key);
Node t = floor(x.right, key);
return t != null ? t : x;
}

5. rank()

rank(key) 返回 key 的排名。

  • 如果键和根节点的键相等,返回左子树的节点数;
  • 如果小于,递归计算在左子树中的排名;
  • 如果大于,递归计算在右子树中的排名,加上左子树的节点数,再加上 1(根节点)。
@Override
public int rank(Key key) {
return rank(key, root);
} private int rank(Key key, Node x) {
if (x == null)
return 0;
int cmp = key.compareTo(x.key);
if (cmp == 0)
return size(x.left);
else if (cmp < 0)
return rank(key, x.left);
else
return 1 + size(x.left) + rank(key, x.right);
}

6. min()

@Override
public Key min() {
return min(root).key;
} private Node min(Node x) {
if (x == null)
return null;
if (x.left == null)
return x;
return min(x.left);
}

7. deleteMin()

令指向最小节点的链接指向最小节点的右子树。

public void deleteMin() {
root = deleteMin(root);
} public Node deleteMin(Node x) {
if (x.left == null)
return x.right;
x.left = deleteMin(x.left);
recalculateSize(x);
return x;
}

8. delete()

  • 如果待删除的节点只有一个子树, 那么只需要让指向待删除节点的链接指向唯一的子树即可;
  • 否则,让右子树的最小节点替换该节点。
public void delete(Key key) {
root = delete(root, key);
}
private Node delete(Node x, Key key) {
if (x == null)
return null;
int cmp = key.compareTo(x.key);
if (cmp < 0)
x.left = delete(x.left, key);
else if (cmp > 0)
x.right = delete(x.right, key);
else {
if (x.right == null)
return x.left;
if (x.left == null)
return x.right;
Node t = x;
x = min(t.right);
x.right = deleteMin(t.right);
x.left = t.left;
}
recalculateSize(x);
return x;
}

9. keys()

利用二叉查找树中序遍历的结果为递增的特点。

@Override
public List<Key> keys(Key l, Key h) {
return keys(root, l, h);
} private List<Key> keys(Node x, Key l, Key h) {
List<Key> list = new ArrayList<>();
if (x == null)
return list;
int cmpL = l.compareTo(x.key);
int cmpH = h.compareTo(x.key);
if (cmpL < 0)
list.addAll(keys(x.left, l, h));
if (cmpL <= 0 && cmpH >= 0)
list.add(x.key);
if (cmpH > 0)
list.addAll(keys(x.right, l, h));
return list;
}

10. 分析

二叉查找树所有操作在最坏的情况下所需要的时间都和树的高度成正比。

2-3 查找树

2-3 查找树引入了 2- 节点和 3- 节点,目的是为了让树平衡。一颗完美平衡的 2-3 查找树的所有空链接到根节点的距离应该是相同的。

1. 插入操作

插入操作和 BST 的插入操作有很大区别,BST 的插入操作是先进行一次未命中的查找,然后再将节点插入到对应的空链接上。但是 2-3 查找树如果也这么做的话,那么就会破坏了平衡性。它是将新节点插入到叶子节点上。

根据叶子节点的类型不同,有不同的处理方式:

  • 如果插入到 2- 节点上,那么直接将新节点和原来的节点组成 3- 节点即可。
  • 如果是插入到 3- 节点上,就会产生一个临时 4- 节点时,需要将 4- 节点分裂成 3 个 2- 节点,并将中间的 2- 节点移到上层节点中。如果上移操作继续产生临时 4- 节点则一直进行分裂上移,直到不存在临时 4- 节点。

2. 性质

2-3 查找树插入操作的变换都是局部的,除了相关的节点和链接之外不必修改或者检查树的其它部分,而这些局部变换不会影响树的全局有序性和平衡性。

2-3 查找树的查找和插入操作复杂度和插入顺序无关,在最坏的情况下查找和插入操作访问的节点必然不超过 logN 个,含有 10 亿个节点的 2-3 查找树最多只需要访问 30 个节点就能进行任意的查找和插入操作。

红黑树

红黑树是 2-3 查找树,但它不需要分别定义 2- 节点和 3- 节点,而是在普通的二叉查找树之上,为节点添加颜色。指向一个节点的链接颜色如果为红色,那么这个节点和上层节点表示的是一个 3- 节点,而黑色则是普通链接。

红黑树具有以下性质:

  • 红链接都为左链接;
  • 完美黑色平衡,即任意空链接到根节点的路径上的黑链接数量相同。

画红黑树时可以将红链接画平。

public class RedBlackBST<Key extends Comparable<Key>, Value> extends BST<Key, Value> {

    private static final boolean RED = true;
private static final boolean BLACK = false; private boolean isRed(Node x) {
if (x == null)
return false;
return x.color == RED;
}
}

1. 左旋转

因为合法的红链接都为左链接,如果出现右链接为红链接,那么就需要进行左旋转操作。

public Node rotateLeft(Node h) {
Node x = h.right;
h.right = x.left;
x.left = h;
x.color = h.color;
h.color = RED;
x.N = h.N;
recalculateSize(h);
return x;
}

2. 右旋转

进行右旋转是为了转换两个连续的左红链接,这会在之后的插入过程中探讨。

public Node rotateRight(Node h) {
Node x = h.left;
h.left = x.right;
x.color = h.color;
h.color = RED;
x.N = h.N;
recalculateSize(h);
return x;
}

3. 颜色转换

一个 4- 节点在红黑树中表现为一个节点的左右子节点都是红色的。分裂 4- 节点除了需要将子节点的颜色由红变黑之外,同时需要将父节点的颜色由黑变红,从 2-3 树的角度看就是将中间节点移到上层节点。

void flipColors(Node h) {
h.color = RED;
h.left.color = BLACK;
h.right.color = BLACK;
}

4. 插入

先将一个节点按二叉查找树的方法插入到正确位置,然后再进行如下颜色操作:

  • 如果右子节点是红色的而左子节点是黑色的,进行左旋转;
  • 如果左子节点是红色的,而且左子节点的左子节点也是红色的,进行右旋转;
  • 如果左右子节点均为红色的,进行颜色转换。
@Override
public void put(Key key, Value value) {
root = put(root, key, value);
root.color = BLACK;
} private Node put(Node x, Key key, Value value) {
if (x == null) {
Node node = new Node(key, value, 1);
node.color = RED;
return node;
}
int cmp = key.compareTo(x.key);
if (cmp == 0)
x.val = value;
else if (cmp < 0)
x.left = put(x.left, key, value);
else
x.right = put(x.right, key, value); if (isRed(x.right) && !isRed(x.left))
x = rotateLeft(x);
if (isRed(x.left) && isRed(x.left.left))
x = rotateRight(x);
if (isRed(x.left) && isRed(x.right))
flipColors(x); recalculateSize(x);
return x;
}

可以看到该插入操作和二叉查找树的插入操作类似,只是在最后加入了旋转和颜色变换操作即可。

根节点一定为黑色,因为根节点没有上层节点,也就没有上层节点的左链接指向根节点。flipColors() 有可能会使得根节点的颜色变为红色,每当根节点由红色变成黑色时树的黑链接高度加 1.

5. 分析

一颗大小为 N 的红黑树的高度不会超过 2logN。最坏的情况下是它所对应的 2-3 树,构成最左边的路径节点全部都是 3- 节点而其余都是 2- 节点。

红黑树大多数的操作所需要的时间都是对数级别的。

散列表

散列表类似于数组,可以把散列表的散列值看成数组的索引值。访问散列表和访问数组元素一样快速,它可以在常数时间内实现查找和插入操作。

由于无法通过散列值知道键的大小关系,因此散列表无法实现有序性操作。

1. 散列函数

对于一个大小为 M 的散列表,散列函数能够把任意键转换为 [0, M-1] 内的正整数,该正整数即为 hash 值。

散列表存在冲突,也就是两个不同的键可能有相同的 hash 值。

散列函数应该满足以下三个条件:

  • 一致性:相等的键应当有相等的 hash 值,两个键相等表示调用 equals() 返回的值相等。
  • 高效性:计算应当简便,有必要的话可以把 hash 值缓存起来,在调用 hash 函数时直接返回。
  • 均匀性:所有键的 hash 值应当均匀地分布到 [0, M-1] 之间,如果不能满足这个条件,有可能产生很多冲突,从而导致散列表的性能下降。

除留余数法可以将整数散列到 [0, M-1] 之间,例如一个正整数 k,计算 k%M 既可得到一个 [0, M-1] 之间的 hash 值。注意 M 必须是一个素数,否则无法利用键包含的所有信息。例如 M 为 10k,那么只能利用键的后 k 位。

对于其它数,可以将其转换成整数的形式,然后利用除留余数法。例如对于浮点数,可以将其的二进制形式转换成整数。

对于多部分组合的类型,每个部分都需要计算 hash 值,这些 hash 值都具有同等重要的地位。为了达到这个目的,可以将该类型看成 R 进制的整数,每个部分都具有不同的权值。

例如,字符串的散列函数实现如下:

int hash = 0;
for (int i = 0; i < s.length(); i++)
hash = (R * hash + s.charAt(i)) % M;

再比如,拥有多个成员的自定义类的哈希函数如下:

int hash = (((day * R + month) % M) * R + year) % M;

R 通常取 31。

Java 中的 hashCode() 实现了哈希函数,但是默认使用对象的内存地址值。在使用 hashCode() 时,应当结合除留余数法来使用。因为内存地址是 32 位整数,我们只需要 31 位的非负整数,因此应当屏蔽符号位之后再使用除留余数法。

int hash = (x.hashCode() & 0x7fffffff) % M;

使用 Java 的 HashMap 等自带的哈希表实现时,只需要去实现 Key 类型的 hashCode() 函数即可。Java 规定 hashCode() 能够将键均匀分布于所有的 32 位整数,Java 中的 String、Integer 等对象的 hashCode() 都能实现这一点。以下展示了自定义类型如何实现 hashCode():

public class Transaction {

    private final String who;
private final Date when;
private final double amount; public Transaction(String who, Date when, double amount) {
this.who = who;
this.when = when;
this.amount = amount;
} public int hashCode() {
int hash = 17;
int R = 31;
hash = R * hash + who.hashCode();
hash = R * hash + when.hashCode();
hash = R * hash + ((Double) amount).hashCode();
return hash;
}
}

2. 拉链法

拉链法使用链表来存储 hash 值相同的键,从而解决冲突。

查找需要分两步,首先查找 Key 所在的链表,然后在链表中顺序查找。

对于 N 个键,M 条链表 (N>M),如果哈希函数能够满足均匀性的条件,每条链表的大小趋向于 N/M,因此未命中的查找和插入操作所需要的比较次数为 ~N/M。

3. 线性探测法

线性探测法使用空位来解决冲突,当冲突发生时,向前探测一个空位来存储冲突的键。

使用线性探测法,数组的大小 M 应当大于键的个数 N(M>N)。

public class LinearProbingHashST<Key, Value> implements UnorderedST<Key, Value> {

    private int N = 0;
private int M = 16;
private Key[] keys;
private Value[] values; public LinearProbingHashST() {
init();
} public LinearProbingHashST(int M) {
this.M = M;
init();
} private void init() {
keys = (Key[]) new Object[M];
values = (Value[]) new Object[M];
} private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
}

3.1 查找

public Value get(Key key) {
for (int i = hash(key); keys[i] != null; i = (i + 1) % M)
if (keys[i].equals(key))
return values[i]; return null;
}

3.2 插入

public void put(Key key, Value value) {
resize();
putInternal(key, value);
} private void putInternal(Key key, Value value) {
int i;
for (i = hash(key); keys[i] != null; i = (i + 1) % M)
if (keys[i].equals(key)) {
values[i] = value;
return;
} keys[i] = key;
values[i] = value;
N++;
}

3.3 删除

删除操作应当将右侧所有相邻的键值对重新插入散列表中。

public void delete(Key key) {
int i = hash(key);
while (keys[i] != null && !key.equals(keys[i]))
i = (i + 1) % M; // 不存在,直接返回
if (keys[i] == null)
return; keys[i] = null;
values[i] = null; // 将之后相连的键值对重新插入
i = (i + 1) % M;
while (keys[i] != null) {
Key keyToRedo = keys[i];
Value valToRedo = values[i];
keys[i] = null;
values[i] = null;
N--;
putInternal(keyToRedo, valToRedo);
i = (i + 1) % M;
}
N--;
resize();
}

3.5 调整数组大小

线性探测法的成本取决于连续条目的长度,连续条目也叫聚簇。当聚簇很长时,在查找和插入时也需要进行很多次探测。例如下图中 2~5 位置就是一个聚簇。

α = N/M,把 α 称为使用率。理论证明,当 α 小于 1/2 时探测的预计次数只在 1.5 到 2.5 之间。为了保证散列表的性能,应当调整数组的大小,使得 α 在 [1/4, 1/2] 之间。

private void resize() {
if (N >= M / 2)
resize(2 * M);
else if (N <= M / 8)
resize(M / 2);
} private void resize(int cap) {
LinearProbingHashST<Key, Value> t = new LinearProbingHashST<Key, Value>(cap);
for (int i = 0; i < M; i++)
if (keys[i] != null)
t.putInternal(keys[i], values[i]); keys = t.keys;
values = t.values;
M = t.M;
}

小结

1. 符号表算法比较

算法 插入 查找 是否有序
二分查找实现的有序表 N logN yes
二叉查找树 logN logN yes
2-3 查找树 logN logN yes
链表实现的有序表 N N no
拉链法实现的散列表 N/M N/M no
线性探测法实现的散列表 1 1 no

应当优先考虑散列表,当需要有序性操作时使用红黑树。

2. Java 的符号表实现

  • java.util.TreeMap:红黑树
  • java.util.HashMap:拉链法的散列表

3. 稀疏向量乘法

当向量为稀疏向量时,可以使用符号表来存储向量中的非 0 索引和值,使得乘法运算只需要对那些非 0 元素进行即可。

public class SparseVector {
private HashMap<Integer, Double> hashMap; public SparseVector(double[] vector) {
hashMap = new HashMap<>();
for (int i = 0; i < vector.length; i++)
if (vector[i] != 0)
hashMap.put(i, vector[i]);
} public double get(int i) {
return hashMap.getOrDefault(i, 0.0);
} public double dot(SparseVector other) {
double sum = 0;
for (int i : hashMap.keySet())
sum += this.get(i) * other.get(i);
return sum;
}
}

七、其它

汉诺塔

这是一个经典的递归问题,分为三步求解:

① 将 n-1 个圆盘从 from -> buffer

② 将 1 个圆盘从 from -> to

③ 将 n-1 个圆盘从 buffer -> to

如果只有一个圆盘,那么只需要进行一次移动操作。

从上面的讨论可以知道,an = 2 * an-1 + 1,显然 an = 2n - 1,n 个圆盘需要移动 2n - 1 次。

public class Hanoi {
public static void move(int n, String from, String buffer, String to) {
if (n == 1) {
System.out.println("from " + from + " to " + to);
return;
}
move(n - 1, from, to, buffer);
move(1, from, buffer, to);
move(n - 1, buffer, from, to);
} public static void main(String[] args) {
Hanoi.move(3, "H1", "H2", "H3");
}
}
from H1 to H3
from H1 to H2
from H3 to H2
from H1 to H3
from H2 to H1
from H2 to H3
from H1 to H3

哈夫曼编码

根据数据出现的频率对数据进行编码,从而压缩原始数据。

例如对于一个文本文件,其中各种字符出现的次数如下:

  • a : 10
  • b : 20
  • c : 40
  • d : 80

可以将每种字符转换成二进制编码,例如将 a 转换为 00,b 转换为 01,c 转换为 10,d 转换为 11。这是最简单的一种编码方式,没有考虑各个字符的权值(出现频率)。而哈夫曼编码采用了贪心策略,使出现频率最高的字符的编码最短,从而保证整体的编码长度最短。

首先生成一颗哈夫曼树,每次生成过程中选取频率最少的两个节点,生成一个新节点作为它们的父节点,并且新节点的频率为两个节点的和。选取频率最少的原因是,生成过程使得先选取的节点位于树的更低层,那么需要的编码长度更长,频率更少可以使得总编码长度更少。

生成编码时,从根节点出发,向左遍历则添加二进制位 0,向右则添加二进制位 1,直到遍历到叶子节点,叶子节点代表的字符的编码就是这个路径编码。

public class Huffman {

    private class Node implements Comparable<Node> {
char ch;
int freq;
boolean isLeaf;
Node left, right; public Node(char ch, int freq) {
this.ch = ch;
this.freq = freq;
isLeaf = true;
} public Node(Node left, Node right, int freq) {
this.left = left;
this.right = right;
this.freq = freq;
isLeaf = false;
} @Override
public int compareTo(Node o) {
return this.freq - o.freq;
}
} public Map<Character, String> encode(Map<Character, Integer> frequencyForChar) {
PriorityQueue<Node> priorityQueue = new PriorityQueue<>();
for (Character c : frequencyForChar.keySet()) {
priorityQueue.add(new Node(c, frequencyForChar.get(c)));
}
while (priorityQueue.size() != 1) {
Node node1 = priorityQueue.poll();
Node node2 = priorityQueue.poll();
priorityQueue.add(new Node(node1, node2, node1.freq + node2.freq));
}
return encode(priorityQueue.poll());
} private Map<Character, String> encode(Node root) {
Map<Character, String> encodingForChar = new HashMap<>();
encode(root, "", encodingForChar);
return encodingForChar;
} private void encode(Node node, String encoding, Map<Character, String> encodingForChar) {
if (node.isLeaf) {
encodingForChar.put(node.ch, encoding);
return;
}
encode(node.left, encoding + '0', encodingForChar);
encode(node.right, encoding + '1', encodingForChar);
}
}

参考资料

  • Sedgewick, Robert, and Kevin Wayne. Algorithms. Addison-Wesley Professional, 2011.

算法总结(转自CS-Notes)的更多相关文章

  1. 程序代写, CS代写, 代码代写, CS编程代写, java代写, python代写, c++/c代写, R代写, 算法代写, web代写

    互联网一线工程师程序代写 微信联系 当天完成 查看大牛简介特色: 学霸代写,按时交付,保证原创,7*24在线服务,可加急.用心代写/辅导/帮助客户CS作业. 客户反馈与评价 服务质量:保证honor ...

  2. PLSA及EM算法

    前言:本文主要介绍PLSA及EM算法,首先给出LSA(隐性语义分析)的早期方法SVD,然后引入基于概率的PLSA模型,其参数学习采用EM算法.接着我们分析如何运用EM算法估计一个简单的mixture ...

  3. 逻辑回归原理_挑战者飞船事故和乳腺癌案例_Python和R_信用评分卡(AAA推荐)

    sklearn实战-乳腺癌细胞数据挖掘(博客主亲自录制视频教程) https://study.163.com/course/introduction.htm?courseId=1005269003&a ...

  4. 使用 IL 实现类型转换

    在之前的文章中,我大致介绍过一些类型间的隐式和显式类型转换规则.但当时并未很仔细的研究过<CSharp Language Specification>,因此实现并不完整.而且只部分解决了类 ...

  5. 【HDOJ】3007 Buried memory

    1. 题目描述有n个点,求能覆盖这n个点的半径最小的圆的圆心及半径. 2. 基本思路算法模板http://soft.cs.tsinghua.edu.cn/blog/?q=node/1066定义Di表示 ...

  6. [IR] String Matching

    BWT KMP Boyer-Moore BWT [IR] BWT+MTF+AC 中已经介绍了BWT (Burrows–Wheeler_transform)数据转换算法, 这种变换方式不仅方便压缩,同时 ...

  7. Linux时间子系统之五:低分辨率定时器的原理和实现

    专题文档汇总目录 Notes:低精度timer在内核中的数据结构以及API接口:低精度timer精巧高效的分组,使用cascade进行定时器移位,组内Timer FIFO:低精度Timer的初始化流程 ...

  8. CAS、原子操作类的应用与浅析及Java8对其的优化

    前几天刷朋友圈的时候,看到一段话:如果现在我是傻逼,那么我现在不管怎么努力,也还是傻逼,因为我现在的傻逼是由以前决定的,现在努力,是为了让以后的自己不再傻逼.话糙理不糙,如果妄想现在努力一下,马上就不 ...

  9. 图形管线之旅 Part5

    原文:<A trip through the Graphics Pipeline 2011> 翻译:往昔之剑   转载请注明出处   在上一篇关于纹理采样器之后,我们现在回到了3D前端.那 ...

  10. Visual Studio编辑类模板的位置

    VS的版本一直在不断更新,每个版本的安装目录都是有一点变化,所以模板文件的位置也是不一样的,下面是从StackOverflow看到的一个大合集,转发记录一下: Extract, edit and re ...

随机推荐

  1. 学习笔记<4>初步控件布局

    一.控件布局基本概念 指控制控件在Activity当中的位置.大小.颜色以及其他控件样式属性 二.控件布局两种方法 1.使用布局文件完成控件布局(eclipse可视化拖拽控件实现) 2.在JAVA代码 ...

  2. Openstack CentOS6.5 ALL IN ONE 安装

    本文档以RDO的方式安装单节点.单网卡的Openstack. RDO是可在Red Hat Enterprise Linux.Fedora及其变体上运行的社区支持OpenStack版本.部署简单方便,R ...

  3. Must Know Tips/Tricks in Deep Neural Networks (by Xiu-Shen Wei)

    http://lamda.nju.edu.cn/weixs/project/CNNTricks/CNNTricks.html Deep Neural Networks, especially Conv ...

  4. C# WPF Halcon HDevEngine混合编程

    1. WPF+Halcon 引用halcondotnet.dll和hdevenginedotnet.dll XAML中导入命名空间xmlns:halcon=”clr-namespace:HalconD ...

  5. Java基础(basis)-----抽象类和接口详解

    1.抽象类 1.1 abstract修饰类:抽象类 不可被实例化 抽象类有构造器 (凡是类都有构造器) 抽象方法所在的类,一定是抽象类 抽象类中可以没有抽象方法 1.2 abstract修饰方法:抽象 ...

  6. base_review

    简述Python的字符串驻留机制. - 字符串驻留是一种仅保存一份相同且不可变字符串的方法. - 原理 - 系统维护interned字典,记录已被驻留的字符串对象. - 当字符串对象a需要驻留时,先在 ...

  7. python 将字节写入文本文件

    想在文本模式打开的文件中写入原始的字节数据 将字节数据直接写入文件的缓冲区即可 >>> import sys >>> sys.stdout.write(b'Hell ...

  8. java 的Colections类(Java也有python类似列表的反转、排序等方法)

    1.Collections类概述         针对集合操作 的工具类,都是静态方法   2.Collections成员方法         public static <T> void ...

  9. python迭代器以及生成器

    迭代器iter():节省内存 Iter()迭代器 每一次输出下一个值 >>> a=iter(range(10)) >>> a.next() 0 >>&g ...

  10. python添加新的模块

    添加新的模块可以把路径放到环境变量中 或者放到site-packages文件夹下