前缀树、堆结构和贪心算法
介绍前缀树
何为前缀树?如何生成前缀树?
字典树又称为前缀树或Trie树,是处理字符串常见的数据结构。假设组成所有单词的字符仅是“a”~”z”,请实现字典树结构,并包含以下四个主要功能:
- void insert(String word):添加word,可重复添加。
- void delete(String word):删除word,如果word添加过多次,仅删除一次。
- boolean search(String word):查询word是否在字典树中。
- int prefixNumber(String pre):返回以字符串pre为前缀的单词数量。
思考:
字典树的介绍。字典树是一种树形结构,优点是利用字符串的公共前缀来节约存储空间。
字典树的基本性质如下:
- 根节点没有字符路径。除根节点外,每一个节点都被一个字符路径找到。
- 从根节点到某一节点,将路径上经过的字符连接起来,为扫过的对应字符串。
- 每个节点向下所有的字符路径上的字符都不同。
在字典树上搜索添加过的单词的步骤为:
- 从根节点开始搜索。
- 取得要查找单词的第一个字母,并根据该字母选择对应的字符路径向下继续搜索。
- 字符路径指向的第二层节点上,根据第二个字母选择对应的字符路径向下继续搜索。
- 一直向下搜索,如果单词搜索完后,找到的最后一个节点是一个终止节点,说明字典树中含有这个单词,如果找到的最后一个节点不是一个终止节点,说明单词不是字典树中添加过的单词。如果单词没搜索完,但是已经没有后续的节点,也说明单词不是字典树中添加过的单词。
字典树的节点类型:
public static class TrieNode {
public int path; //表示由多少个字符串共用这个节点
public int end;//表示有多少个字符串是以这个节点结尾的
public TrieNode[] map;//哈希表结构,key代表该节点的一条字符路径,value表示字符路径指向的节点
public TrieNode() {
path = 0;
end = 0;
map = new TrieNode[26];
}
}
Trie树如何实现:
- void insert(String word):假设单词word的长度为N。从左到右遍历word中的每个字符,并依次从头节点开始根据每一个word[i],找到下一个节点。如果找的过程中节点不存在,就建立新节点,记为a,并令a.path=1。如果节点存在,记为b,令b.path++。通过最后一个字符(word[N-1])找到最后一个节点时记为e,令e.path++,e.end++;
- boolean search(String word):从左到右遍历word中的每个字符,并依次从头节点开始根据每一个word[i],找到下一个节点。如果找的过程中节点不存在,说明这个单词的整个部分没有添加进Trie树,否则不可能找的过程中节点不存在,直接返回false。如果能通过word[N-1]找到最后一个节点,记为e,如果e.end!=0,说明有单词通过word[N-1]的字符路径,并以节点e结尾,返回true,如果e.end==0,返回false。
- void delete(String word):先调用search(word),看word在不在Trie树中,若在,则执行后面的过程,若不在,则直接返回。从左到右遍历word中的每个字符,并依次从头节点开始根据每一个word[i]找到下一个的节点。在找的过程中,把扫过每一个节点的path减1。如果发现下一个节点的path值减完之后已经为0,直接从当前节点的map中删除后续的所有路径,返回即可。如果扫到最后一个节点,记为e,令e.path–,e.end–。
- int prefixNumber(String pre):和查找操作同理,根据pre不断找到节点,假设最后的节点记为e,返回e.path的值即可。
代码:
/**
* 前缀树
*/
public class TrieTree {
public static class TrieNode {
public int path; //表示由多少个字符串共用这个节点
public int end;//表示有多少个字符串是以这个节点结尾的
public TrieNode[] map;//哈希表结构,key代表该节点的一条字符路径,value表示字符路径指向的节点
public TrieNode() {
path = 0;
end = 0;
map = new TrieNode[26];
}
}
public static class Trie {
private TrieNode root;//头
public Trie() {
root = new TrieNode();
}
public void insert(String word) {
if (word == null) {
return;
}
char[] chs = word.toCharArray();
TrieNode node = root;
int index = 0; //哪条路
for (int i = 0; i < chs.length; i++) {
index = chs[i] - 'a'; //0~25
if (node.map[index] == null) {
node.map[index] = new TrieNode();
}
node = node.map[index];
node.path++;
}
node.end++;
}
public void delete(String word) {
if (search(word)) {
char[] chs = word.toCharArray();
TrieNode node = root;
int index = 0;
for (int i = 0; i < chs.length; i++) {
index = chs[i] - 'a';
if (node.map[index].path-- == 1) {//path减完之后为0
node.map[index] = null;
return;
}
node = node.map[index];
}
node.end--;
}
}
public boolean search(String word) {
if (word == null) {
return false;
}
char[] chs = word.toCharArray();
TrieNode node = root;
int index = 0;
for (int i = 0; i < chs.length; i++) {
index = chs[i] - 'a';
if (node.map[index] == null) {
return false;
}
node = node.map[index];
}
return node.end != 0;
}
public int prefixNumber(String pre) {
if (pre == null) {
return 0;
}
char[] chs = pre.toCharArray();
TrieNode node = root;
int index = 0;
for (int i = 0; i < chs.length; i++) {
index = chs[i] - 'a';
if (node.map[index] == null) {
return 0;
}
node = node.map[index];
}
return node.path;
}
}
}
相关题目:
一个字符串类型的数组arr1,另一个字符串类型的数组arr2。
arr2中有哪些字符,是arr1中出现的?请打印。
arr2中有哪些字符,是作为arr1中某个字符串前缀出现的?请打印。
arr2中有哪些字符,是作为arr1中某个字符串前缀出现的?请打印arr2中出现次数最大的前缀。
一块金条切成两半,是需要花费和长度数值一样的铜板的。比如长度为20的金条,不管切成长度多大的两半,都要花费20个铜板。一群人想整分整块金条,怎么分最省铜板?
例如,给定数组{10,20,30},代表一共三个人,整块金条长度为10+20+30=60,金条要分成10,20,30三个部分。如果,先把长度60的金条分成10和50,花费60,再把长度为50的金条分成20和30,花费50,一共花费110个铜板。
但是如果,先把长度60的金条分成30和30,花费60,再把长度30金条分成10和30,花费30,一共花费90个铜板。
输入一个数组,返回分割的最小代价。
思考:本题采用哈夫曼编码的思想:
(1)首先构造小根堆
(2)每次取最小的两个数(小根堆),使其代价最小。并将其和加入到小根堆中
(3)重复(2)过程,直到最后堆中只剩下一个节点。
注意:代价不是最后一个值,而是所有非叶节点之和,即上面求得两两节点之和。
例如:1,3 6 6 9 16组成一个小根堆。
1. 1和3合并等于4,代价为4。把4加入到小根堆,此时小根堆含有4,6,6,9,16,调成小根堆形式。
2. 4和6合并等于10,代价为10。把10加入到小根堆,此时小根堆为10,6,9,16,调成小根堆形式。
3. 6和9合并等于15,代价为15。把15加入到小根堆,此时小根堆为10,15,16,调成小根堆形式。
4. 10和15合并等于25,代价为25。把25加入到小根堆,此时小根堆为25,16,调成小根堆形式。
5. 25和16合并等于41,代价为41。把41加入到小根堆,此时小根堆为41。
因此,总共代价为4+10+15+25+41。
import java.util.Comparator;
import java.util.PriorityQueue;
public class LessMoney {
public static void main(String[] args) {
//solution
int[] arr = {6, 7, 8, 9};
System.out.println(lessMoney(arr));
int[] arrForHeap = {3, 5, 2, 7, 0, 1, 6, 4};
// minH heap
PriorityQueue<Integer> minQ1 = new PriorityQueue<>();
for (int i = 0; i < arrForHeap.length; i++) {
minQ1.add(arrForHeap[i]);
}
while (!minQ1.isEmpty()) {
System.out.print(minQ1.poll() + " ");
}
System.out.println();
// min heap use Comparator
PriorityQueue<Integer> minQ2 = new PriorityQueue<>(new MinheapComparator());
for (int i = 0; i < arrForHeap.length; i++) {
minQ2.add(arrForHeap[i]);
}
while (!minQ2.isEmpty()) {
System.out.print(minQ2.poll() + " ");
}
System.out.println();
// max heap use Comparator
PriorityQueue<Integer> maxQ = new PriorityQueue<>(new MaxheapComparator());
for (int i = 0; i < arrForHeap.length; i++) {
maxQ.add(arrForHeap[i]);
}
while (!maxQ.isEmpty()) {
System.out.print(maxQ.poll() + " ");
}
}
/**
* 大根堆
*/
public static class MaxheapComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
}
/**
* 大根堆
*/
public static class MinheapComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
}
public static int lessMoney(int[] arr) {
if (arr == null || arr.length == 0)
return 0;
PriorityQueue<Integer> pQ = new PriorityQueue<>();
//把所有数加入到堆中
for (int i = 0; i < arr.length; i++) {
pQ.add(arr[i]);
}
//切割金条的总花费
int sum = 0;
int curr = 0;
while (pQ.size() > 1) {
curr = pQ.poll() + pQ.poll();
sum += curr;
pQ.add(curr);
}
return sum;
}
}
项目最大收益(贪心问题)
输入:参数1,正数数组costs,参数2,正数数组profits,参数3,正数k,参数4,正数m
costs[i]表示i号项目的花费profits[i]表示i号项目在扣除花费之后还能挣到的钱(利润),k表示你不能并行,只能串行的最多做k个项目,m表示你初始的资金。
说明:你每做完一个项目,马上获得的收益,可以支持你去做下一个项目。
输出:你最后获得的最大钱数。
思考:给定一个初始化投资资金,给定N个项目,想要获得其中最大的收益,并且一次只能做一个项目。这是一个贪心策略的问题,按照花费的多少放到一个小根堆里面,然后要是小根堆里面的头节点的花费少于给定资金,就将头节点一个个取出来,放到按照收益的大根堆里面,这样就然后将大根堆的堆顶弹出。
代码:
package NewCoder.class_07;
import java.util.Comparator;
import java.util.PriorityQueue;
public class IPO {
//一个节点代表一个项目
public static class Node {
int profit;//收益
int cost;//花费
public Node(int profit, int cost) {
this.profit = profit;
this.cost = cost;
}
}
/**
* 计算最大项目收益
*
* @param k:最多可以做多少个项目
* @param m:总的初始资金
* @param costs:花费
* @param profits:收益
* @return
*/
public static int ipo(int k, int m, int[] costs, int[] profits) {
//小根堆、大根堆
Node[] nodes = new Node[profits.length];
for (int i = 0; i < profits.length; i++) {
nodes[i] = new Node(profits[i], costs[i]);
}
PriorityQueue<Node> minQueue = new PriorityQueue<Node>(new minHeapCompatator());
PriorityQueue<Node> maxQueue = new PriorityQueue<Node>(new maxHeapCompatator());
for (int i = 0; i < costs.length; i++) {
minQueue.add(nodes[i]);
}
for (int i = 0; i < k; i++) {
while (!minQueue.isEmpty() && minQueue.peek().cost <= m) {
maxQueue.add(minQueue.poll());
}
if (maxQueue.isEmpty()) {
return m;
}
m += maxQueue.poll().profit;
}
return m;
}
public static class minHeapCompatator implements Comparator<Node> {
@Override
public int compare(Node o1, Node o2) {
return o1.cost - o2.cost;//按照花费排序的小根堆
}
}
public static class maxHeapCompatator implements Comparator<Node> {
@Override
public int compare(Node o1, Node o2) {
return o2.profit - o1.profit;//按照收益排序的大根堆
}
}
public static void main(String[] args) {
int[] costs = new int[]{10, 20, 100};
int[] profits = new int[]{11, 10, 200};
int k = 3;
int m = 50;
System.out.println(ipo(k, m, costs, profits));
}
}
一个数据流中,随时可以取得中位数。
题目描述:有一个源源不断地吐出整数的数据流,假设你有足够的空间来保存吐出的数。请设计一个名叫MedianHolder的结构,MedianHolder可以随时取得之前吐出所有树的中位数。
要求:1.如果MedianHolder已经保存了吐出的N个数,那么任意时刻将一个新的数加入到MedianHolder的过程中,其时间复杂度为O(logN)。2.取得已经吐出的N个数整体的中位数的过程,时间复杂度为O(1).
思考:设计的MedianHolder中有两个堆,一个是大根堆,一个是小根堆。大根堆中含有接收的所有数中较小的一半,并且按大根堆的方式组织起来,那么这个堆的堆顶就是较小一半的数中最大的那个。小根堆中含有接收的所有数中较大的一半,并且按小根堆的方式组织起来,那么这个堆的堆顶就是较大一半的数中最小的那个。
例如,如果已经吐出的数为6,1,3,0,9,8,7,2.
较小的一半为:0,1,2,3,那么3就是这一半的数组成的大根堆的堆顶
较大的一半为:6,7,8,9,那么6就是这一半的数组成的小根堆的堆顶
因为此时数的总个数为偶数,所以中位数就是两个堆顶相加,再除以2.
如果此时新加入一个数10,那么这个数应该放进较大的一半里,所以此时较大的一半数为6,7,8,9,10,此时6依然是这一半的数组成的小根堆的堆顶,因为此时数的总个数为奇数,所以中位数应该是正好处在中间位置的数,而此时大根堆有4个数,小根堆有5个数,那么小根堆的堆顶6就是此时的中位数。如果此时又新加入一个数11,那么这个数也应该放进较大的一半里,此时较大一半的数为:6,7,8,9,10,11.这个小根堆大小为6,而大根堆的大小为4,所以要进行如下调整:
1.如果大根堆的size比小根堆的size大2,那么从大根堆里将堆顶元素弹出,并放入小根堆里
2,如果小根堆的size比大根堆的size大2,那么从小根堆里将堆顶弹出,并放入大根堆里。
经过这样的调整之后,大根堆和小根堆的size相同。
总结如下:
- 大根堆每时每刻都是较小的一半的数,堆顶为这一堆数的最大值
- 小根堆每时每刻都是较大的一半的数,堆顶为这一堆数的最小值
- 新加入的数根据与两个堆堆顶的大小关系,选择放进大根堆或者小根堆里
- 当任何一个堆的size比另一个size大2时,进行如上调整的过程。
这样随时都可以知道已经吐出的所有数处于中间位置的两个数是什么,取得中位数的操作时间复杂度为O(1),同时根据堆的性质,向堆中加一个新的数,并且调整堆的代价为O(logN)。
代码:
import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;
/**
* 随时找到数据流的中位数
* 思路:
* 利用一个大根堆和一个小根堆去保存数据,保证前一半的数放在大根堆,后一半的数放在小根堆
* 在添加数据的时候,不断地调整两个堆的大小,使得两个堆保持平衡
* 要取得的中位数就是两个堆堆顶的元素
*/
public class MedianQuick {
public static class MedianHolder {
private PriorityQueue<Integer> maxHeap = new PriorityQueue<Integer>(new MaxHeapComparator());
private PriorityQueue<Integer> minHeap = new PriorityQueue<Integer>(new MinHeapComparator());
/**
* 调整堆的大小
* 当两个堆的大小差值变大时,从数据多的堆中弹出一个数据进入另一个堆中
*/
private void modifyTwoHeapsSize() {
if (this.maxHeap.size() == this.minHeap.size() + 2) {
this.minHeap.add(this.maxHeap.poll());
}
if (this.minHeap.size() == this.maxHeap.size() + 2) {
this.maxHeap.add(this.minHeap.poll());
}
}
/**
* 添加数据的过程
*
* @param num
*/
public void addNumber(int num) {
if (this.maxHeap.isEmpty()) {
this.maxHeap.add(num);
return;
}
if (this.maxHeap.peek() >= num) {
this.maxHeap.add(num);
} else {
if (this.minHeap.isEmpty()) {
this.minHeap.add(num);
return;
}
if (this.minHeap.peek() > num) {
this.maxHeap.add(num);
} else {
this.minHeap.add(num);
}
}
modifyTwoHeapsSize();
}
/**
* 获取中位数
*
* @return
*/
public Integer getMedian() {
int maxHeapSize = this.maxHeap.size();
int minHeapSize = this.minHeap.size();
if (maxHeapSize + minHeapSize == 0) {
return null;
}
Integer maxHeapHead = this.maxHeap.peek();
Integer minHeapHead = this.minHeap.peek();
if (((maxHeapSize + minHeapSize) & 1) == 0) {
return (maxHeapHead + minHeapHead) / 2;
}
return maxHeapSize > minHeapSize ? maxHeapHead : minHeapHead;
}
}
/**
* 大根堆比较器
*/
public static class MaxHeapComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
if (o2 > o1) {
return 1;
} else {
return -1;
}
}
}
/**
* 小根堆比较器
*/
public static class MinHeapComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
if (o2 < o1) {
return 1;
} else {
return -1;
}
}
}
// for test
public static int[] getRandomArray(int maxLen, int maxValue) {
int[] res = new int[(int) (Math.random() * maxLen) + 1];
for (int i = 0; i != res.length; i++) {
res[i] = (int) (Math.random() * maxValue);
}
return res;
}
// for test, this method is ineffective but absolutely right
public static int getMedianOfArray(int[] arr) {
int[] newArr = Arrays.copyOf(arr, arr.length);
Arrays.sort(newArr);
int mid = (newArr.length - 1) / 2;
if ((newArr.length & 1) == 0) {
return (newArr[mid] + newArr[mid + 1]) / 2;
} else {
return newArr[mid];
}
}
public static void printArray(int[] arr) {
for (int i = 0; i != arr.length; i++) {
System.out.print(arr[i] + " ");
}
System.out.println();
}
public static void main(String[] args) {
boolean err = false;
int testTimes = 200000;
for (int i = 0; i != testTimes; i++) {
int len = 30;
int maxValue = 1000;
int[] arr = getRandomArray(len, maxValue);
MedianHolder medianHold = new MedianHolder();
for (int j = 0; j != arr.length; j++) {
medianHold.addNumber(arr[j]);
}
if (medianHold.getMedian() != getMedianOfArray(arr)) {
err = true;
printArray(arr);
break;
}
}
System.out.println(err ? "Oops..what a fuck!" : "today is a beautiful day^_^");
}
}
题目七:
给定一个字符串类型的数组strs,找到一种拼接方式,使得把所有字符串拼起来之后形成的大写字符串是所有可能性中字典顺序最小的,并返回这个大写字符串。
例如:strs=[“abc”,”de”],可以拼成“abcde”,也可以拼成“deabc”,但是前者的字典顺序更小,所以返回“abcde”。
strs=[“b”,”ba”],可以拼成“bba”,也可以拼成“bab”,但是后者的字典顺序更小,所以返回“bab”。
思路:假设有两个字符串,分别记为a和b,a和b拼起来的字符串表示为a.b。那么如果a.b的字典顺序小于b.a。就把字符串a放在前面,否则把字符串b放在前面。每两个字符串之间都按照这个标准进行比较,以此标准排序后,再依次串起来的大写字符串就是结果。
假设有a,b,c三个字符串,它们有如下关系:
a.b<b.a
b.c<c.b
如果能够根据上面两式证明出a.c<c.a,说明这种比较方式具有传递性。证明略
证明传递性后,还需要证明通过这种比较方式排序后,如果交换任意两个字符串的位置所得到的总字符串,将拥有更大的字典顺序。证明略。
整个解法的时间复杂度就是排序本身的复杂度,即O(logN)。
代码:
import java.util.Arrays;
import java.util.Comparator;
import java.util.Scanner;
public class LowestStringMain {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
String[] strs = sc.nextLine().split(" ");
String res = lowestString(strs);
System.out.println(res);
}
sc.close();
}
private static String lowestString(String[] strs) {
if (strs == null || strs.length == 0) {
return "";
}
//根据新的比较方式排序
Arrays.sort(strs, new MyComparator());
String res = "";
for (int i = 0; i < strs.length; i++) {
res += strs[i];
}
return res;
}
private static class MyComparator implements Comparator<String> {
@Override
public int compare(String o1, String o2) {
return (o1 + o2).compareTo(o2 + o1);
}
}
}
题目八:
一些项目要占用一个会议室宣讲,会议室不能同时容纳两个项目的宣讲。给你每一个项目开始的时间和结束的时间(给你一个数组,里面是一个个具体的项目),你来安排宣讲的日程,要求会议室进行的宣讲场次最多。返回这个最多的宣讲场次。
输入:
5
1 3
2 5
4 7
6 9
8 10
输出
3
思考:
区间问题都是选用早结束为策略,谁结束早就先做哪个宣讲。
代码:
package NewCoder.class_07;
import java.io.BufferedInputStream;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Scanner;
public class BastArrange {
public static class Program {
int start;
int end;
public Program(int start, int end) {
this.start = start;
this.end = end;
}
}
private static int bastArrange(Program[] p) {
int ans = 0;
Arrays.sort(p, new Comparator<Program>() {//按照会议结束时间从小到大进行排序
@Override
public int compare(Program o1, Program o2) {
return o1.end - o2.end;
}
});
int start = 0;
for (int i = 0; i < p.length; i++) {
if (start <= p[i].start) {//端点不重合可以去掉等号
start = p[i].end;//记录下一场宣讲开始的时间
++ans;
}
}
return ans;
}
public static void main(String[] args) {
Scanner in = new Scanner(new BufferedInputStream(System.in));
int n = in.nextInt();
Program[] p = new Program[n];
for (int i = 0; i < n; i++) {
int start = in.nextInt();
int end = in.nextInt();
p[i] = new Program(start, end);
}
in.close();
System.out.println(bastArrange(p));
}
}