/*
* Copyright 2015 Evgeny Dolganov (evgenij.dolganov@gmail.com).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package och.util.string;
import static java.lang.Character.*;
import static java.util.Collections.*;
import static och.util.ArrayUtil.*;
import static och.util.FileUtil.*;
import static och.util.StreamUtil.*;
import static och.util.StringUtil.*;
import static och.util.Util.*;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.RandomAccessFile;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
/**
* Счетчик слов.
* Для хранения промежуточных результатов использует
* оперативную память и, если ее недостаточно, файловую систему.
*
* <p>
* Кодировка входящего потока задается системной переменной -Dfile.encoding
*
* <p>
* Если для работы счетчика требуется мало RAM, можно запустить его с флагом 'smallram',
* или же программно задать RamTopSize (размер списка уникальных слов в оперативной памяти).
*/
public class WordsCounter {
public static void main(String[] args) throws IOException {
//нужно ли использовать мало оперативной памяти
boolean useSmallRam = args.length > 0 && "smallram".equals(args[0]);
WordsCounter counter = new WordsCounter();
counter.setRamTopSize(useSmallRam? 10000 : DEFAULT_RAM_TOP_SIZE);
//подсчет слов
int topSize = 10;
int wordMaxSize = DEFAULT_WORD_MAX_SIZE;
List<WordStat> result = counter.getTop(System.in, topSize, wordMaxSize);
//вывод результата
for (WordStat stat : result) {
System.out.println(stat.word + " " + stat.count);
}
}
/** Размер списка уникальных слов в оперативной памяти по умолчанию */
public static final int DEFAULT_RAM_TOP_SIZE = 1000000;
/** Максимальная длина слова по умолчанию */
public static final int DEFAULT_WORD_MAX_SIZE = 30;
/** Результат работы */
public static class WordStat implements Comparable<WordStat>{
/** найденное слово */
public final String word;
/** количество вхождений */
private long count;
public WordStat(String word, long count) {
this.word = word;
this.count = count > 0? count : 0;
}
/** увеличить счетчик слова на 1 */
public void incCount(){
if(count == Long.MAX_VALUE) return;
count++;
}
/** уменьшить счетчик слова на 1 */
public void decCount(){
if(count == 0) return;
count--;
}
public long count(){
return count;
}
@Override
public int compareTo(WordStat o) {
//count - desc
//word - asc
int result = Long.compare(o.count, count);
return result != 0? result : word.compareTo(o.word);
}
@Override
public String toString() {
return "WordStat [word=" + word + ", count=" + count + "]";
}
}
private boolean debug = false;
private File parentDir = new File(".");
private boolean removeIndexesFiles = true;
private int ramTopSize = DEFAULT_RAM_TOP_SIZE;
/**
* Размер списка уникальных слов в оперативной памяти.
* Если все слова умещяются в списке, то подсчет происходит быстро
* (т.к. полностью идет в RAM). Иначе промежуточные данные
* сохраняются в файловой системе. Это замедляет подсчет слов, но
* позволяет экономить RAM.
*/
public void setRamTopSize(int ramTopSize) {
this.ramTopSize = ramTopSize;
}
/** Вывод доп. информации в логи. По умолчанию - нет */
public void setDebug(boolean debug) {
this.debug = debug;
}
/**
* Директория, в которой можно создать индексный
* файл при нехватке оперативной памяти. По умолчанию - текущая директория.
*/
public void setTmpIndexDirParent(File parentDir) {
this.parentDir = parentDir;
}
/** Нужно ли удалять созданный файл после окончания работы */
public void setRemoveIndexesFiles(boolean removeIndexesFiles) {
this.removeIndexesFiles = removeIndexesFiles;
}
/**
* Подсчет слов из потока
*/
public List<WordStat> getTop(InputStream in, int topMaxSize) throws IOException {
return getTop(in, topMaxSize, DEFAULT_WORD_MAX_SIZE);
}
/**
* Подсчет слов из строки
*/
public List<WordStat> getTop(String words, int topMaxSize) throws IOException {
return getTop(new ByteArrayInputStream(getBytesUTF8(words)), topMaxSize, DEFAULT_WORD_MAX_SIZE);
}
public List<WordStat> getTop(String words, int topMaxSize, int wordMaxSize) throws IOException {
return getTop(new ByteArrayInputStream(getBytesUTF8(words)), topMaxSize, wordMaxSize);
}
/**
* Подсчет слов из потока
* @param in - входной поток
* @param topMaxSize - макс.размер результата
* @param wordMaxSize - макс.размер слова (слова большего размера будут игнорироваться)
* @return список топ слов
* @throws IOException ошибка при работе с файловой системой
*/
public List<WordStat> getTop(InputStream in, int topMaxSize, int wordMaxSize) throws IOException {
if(in == null) return emptyList();
if(topMaxSize < 1) return emptyList();
if(wordMaxSize < 1) return emptyList();
WordsIndex index = new WordsIndex(parentDir, ramTopSize, topMaxSize, wordMaxSize);
index.setDebug(debug);
BufferedReader reader = null;
try {
reader = new BufferedReader(new InputStreamReader(in));
//Обход потока
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = reader.read()) != -1) {
ch = toLowerCase(ch);
//слово закончилось
if(isSeparator(ch)){
String word = sb.toString();
sb = new StringBuilder();
if( word.length() > 0){
//добавляем слово в индекс
index.add(word);
}
}
//в середине слова
else {
sb.append((char)ch);
}
}
String lastWord = sb.toString();
if( lastWord.length() > 0) {
index.add(lastWord);
}
return index.getTop();
} finally {
close(reader);
if(removeIndexesFiles) index.clean();
}
}
/** Является ли символ разделителем */
public static boolean isSeparator(int ch) {
if(ch == ' ') return true;
if(ch == '\n') return true;
if(ch == '\t') return true;
if(ch == '\r') return true;
if(ch == '\0') return true;
//if(Character.isSpaceChar(ch)) return true;
//if(Character.isWhitespace(ch)) return true;
return false;
}
/**
* Хранилище индексов слов
*/
public static class WordsIndex {
private int ramTopSize;
private int topMaxSize;
private int wordMaxSize;
private ArrayList<WordStat> top;
private HashMap<String, WordStat> topByWord;
private boolean isDebug;
private IndexesStorage storage;
private boolean sorted = true;
public WordsIndex(File parentDir, int ramTopSize, int topMaxSize, int wordMaxSize) throws IOException {
this.topMaxSize = topMaxSize;
this.wordMaxSize = wordMaxSize;
if(ramTopSize < topMaxSize) ramTopSize = topMaxSize;
this.ramTopSize = ramTopSize;
top = new ArrayList<WordsCounter.WordStat>(ramTopSize);
topByWord = new HashMap<String, WordsCounter.WordStat>(ramTopSize);
storage = new IndexesStorage(parentDir, wordMaxSize);
}
public void setDebug(boolean isDebug) {
this.isDebug = isDebug;
}
/** Добавить слово в индекс */
public void add(String word) throws IOException {
if(word == null) return;
if(word.length() > wordMaxSize) {
if(isDebug) System.out.println("skip too long word: " + (word.length() < 1000? word : "[long word]"));
return;
}
if(storage.isInvaild(word)) {
if(isDebug) System.out.println("skip invalid for storage word: " + (word.length() < 1000? word : "[long word]"));
return;
}
//Слово есть в топе RAM
WordStat inTop = topByWord.get(word);
if(inTop != null){
inTop.incCount();
sorted = false;
return;
}
//Слова нет в топе
WordStat stat = null;
if(top.size() < ramTopSize) {
stat = new WordStat(word, 0);
}
else {
//если топ полный - ищем слово в файловом хранилище
stat = storage.getFromStorage(word);
if(stat == null) {
stat = new WordStat(word, 0);
}
}
stat.incCount();
//Топ еще не заполнен - просто добавляем в топ
if(top.size() < ramTopSize){
top.add(stat);
sorted = false;
topByWord.put(word, stat);
return;
}
//Топ заполнен
//Берем последнее слово из топа
if(!sorted){
sort(top);
sorted = true;
}
int lastTopIndex = ramTopSize-1;
WordStat lastInTop = top.get(lastTopIndex);
//Слово меньше чем все из топа
if(lastInTop.compareTo(stat) < 0){
storage.putToStorage(stat);
return;
}
//Добавляем новое слово в топ
//-- удаляем последнее
top.remove(lastTopIndex);
topByWord.remove(lastInTop.word);
storage.putToStorage(lastInTop);
//-- добавляем новое
top.add(stat);
sorted = false;
topByWord.put(word, stat);
}
/**
* Получить текущее состояние топа
*/
public List<WordStat> getTop() {
if(!sorted) {
sort(top);
sorted = true;
}
return top.size() > topMaxSize? top.subList(0, topMaxSize) : top;
}
/** Очистить файловую систему (если она использовалась) */
public void clean() {
storage.remove();
}
}
/**
* Файловое хранилище промежуточных результатов.
* Используется при нехватке RAM.
*
* Реализация напоминает HashMap:
* - у слова берется hash, который является индексом к нужному смещению в файле
* - по данному смещению считывается, либо записывается информация о словах с данным hash
*/
public static class IndexesStorage {
public static final int DEFAULT_HASH_LIMIT = 1000;
public static final int DEFAULT_PAGE_ELEM_COUNT = 10000;
public static final int HASH_RECORD_SIZE = 8 + 4;
private File parentDir;
private int hashLimit;
private int pageElemCount;
private File root;
private byte[] hashesInfo;
private File pagesFile;
private RandomAccessFile pagesRaf;
private int wordBytesSize;
private int recordSize;
private int pageSize;
public IndexesStorage(File parentDir, int wordMaxSize) throws IOException {
this(parentDir, wordMaxSize, DEFAULT_HASH_LIMIT, DEFAULT_PAGE_ELEM_COUNT);
}
public IndexesStorage(File parentDir, int wordMaxSize, int hashLimit, int pageElemCount) throws IOException {
this.parentDir = parentDir;
this.hashLimit = hashLimit;
this.pageElemCount = pageElemCount;
wordBytesSize = wordMaxSize*2;
recordSize = wordBytesSize+1+8+1; //wordBytes+' '+count+'\n\'
pageSize = recordSize*pageElemCount;
}
/** создание файлов */
private void initFiles() throws IOException{
if(root != null) return;
hashesInfo = new byte[hashLimit*HASH_RECORD_SIZE];
root = new File(parentDir, "/index-tmp-"+randomSimpleId());
root.mkdirs();
pagesFile = new File(root, "pages.data");
writeFileUTF8(pagesFile, "/* pages by hash */\n");
pagesRaf = new RandomAccessFile(pagesFile, "rw");
}
/** подходит ли размер слова под размер выделенного для него блока в файле */
public boolean isInvaild(String word) {
byte[] bytes = getBytesUTF8(word);
return bytes.length > wordBytesSize;
}
/** удаляем созданные файлы */
public void remove() {
if(root != null){
close(pagesRaf);
deleteDirRecursive(root);
}
}
/** добавление/обновление статистики в файл */
public void putToStorage(WordStat stat)throws IOException {
initFiles();
int hashOffset = getHashOffset(stat.word);
long pageOffset = getLong(hashesInfo, hashOffset);
int recordCount = getInt(hashesInfo, hashOffset+8);
byte[] recordBytes = toRecordBytes(stat);
//слов с подобным хешом, еще не было
if(recordCount == 0){
byte[] page = new byte[pageSize];
copyFromSmallToBig(recordBytes, page, 0);
//запись
pageOffset = pagesFile.length();
pagesRaf.seek(pageOffset);
pagesRaf.write(page);
recordCount = 1;
//обновление таблицы индексов
writeLongToArray(pageOffset, hashesInfo, hashOffset);
writeIntToArray(recordCount, hashesInfo, hashOffset+8);
return;
}
//ищем слово среди всех уже добавленных по данному хешу
FindResult findResult = findWordStatInPage(pageOffset, recordCount, stat.word);
//новое - добавляем в список
if(findResult.stat == null){
if(recordCount == pageElemCount) {
throw new RecordPageIsFullException();
}
copyFromSmallToBig(recordBytes, findResult.pageBytes, recordCount*recordSize);
recordCount++;
//запись
pagesRaf.seek(pageOffset);
pagesRaf.write(findResult.pageBytes);
//обновление таблицы индексов
writeIntToArray(recordCount, hashesInfo, hashOffset+8);
return;
}
//уже есть в файле - обновляем
copyFromSmallToBig(recordBytes, findResult.pageBytes, findResult.recordOffset);
//запись
pagesRaf.seek(pageOffset);
pagesRaf.write(findResult.pageBytes);
}
/** поиск статистики в файле */
public WordStat getFromStorage(String word)throws IOException {
initFiles();
int hashOffset = getHashOffset(word);
long pageOffset = getLong(hashesInfo, hashOffset);
int recordCount = getInt(hashesInfo, hashOffset+8);
if(recordCount == 0){
return null;
}
FindResult findResult = findWordStatInPage(pageOffset, recordCount, word);
return findResult == null? null : findResult.stat;
}
private static class FindResult {
public WordStat stat;
public byte[] pageBytes;
public int recordOffset;
}
private FindResult findWordStatInPage(long pageOffset, int recordCount, String word) throws IOException{
FindResult out = new FindResult();
//читаем весь список слов с данным хешем из файла
out.pageBytes = new byte[pageSize];
pagesRaf.seek(pageOffset);
pagesRaf.read(out.pageBytes);
//ищем нужное слово
int recordOffset;
byte[] recordBytes = new byte[recordSize];
for (int i = 0; i < recordCount; i++) {
recordOffset = i * recordSize;
arrayCopy(out.pageBytes, recordOffset, recordBytes, 0, recordSize);
WordStat stat = fromRecordBytes(recordBytes);
if(stat != null){
if(stat.word.equals(word)){
out.stat = stat;
out.recordOffset = recordOffset;
break;
}
}
}
return out;
}
/** расчет хеша слова (смещения в таблице индексов) */
public int getHashOffset(String word){
return getHashOffset(word, hashLimit) * HASH_RECORD_SIZE;
}
public static int getHashOffset(String word, int hashLimit){
int out = word.hashCode();
out = Math.abs(out) % hashLimit;
return out;
}
/** запись статистики в байтовый массив */
public byte[] toRecordBytes(WordStat stat){
byte[] out = new byte[recordSize];
byte[] str = getBytesUTF8(stat.word);
copyFromSmallToBig(str, out, 0);
out[str.length] = ' ';
writeLongToArray(stat.count, out, str.length+1);
out[str.length+1+8] = '\n';
return out;
}
/** чтение статистики из байтового массива */
public WordStat fromRecordBytes(byte[] record){
int endIndex = 0;
for (int i = record.length-1; i > -1 ; i--){
if(record[i] == '\n'){
endIndex = i;
break;
}
}
if(endIndex == 0) return null;
String word = getStr(record, 0, endIndex-9);
long count = getLong(record, endIndex-8);
return new WordStat(word, count);
}
}
/**
* Ошибка переполнения списка слов с одинаковым хешем.
* При получении такой ошибки, нужно задать большую длину hashLimit:
* это увеличит число разлчиных вариантов хешей, но и увеличит размер файла.
*/
public static class RecordPageIsFullException extends IOException {
private static final long serialVersionUID = 1L;
}
}