1.大数据题目技巧
1)哈希函数可以把数据按照种类均匀分流。
2)布隆过滤器用于集合的建立与查询,并可以节省大量空间。
3)一致性哈希解决数据服务器的负载管理问题。
4)利用并查集结构做岛问题的并行计算。
5)位图解决某一范围上数字的出现情况,并可以节省大量空间。
6)利用分段统计思想,并进一步节省空间。
7)利用堆、外排序来做多个处理单元的结果合并。
2.小内存查找未知数
32位无符号整数的范围是0~4,294,967,295(0~2^32-1),现在有一个正好包含40亿个无符号整数的文件,所以在整个范围中必然存在没出现过的数。可以使用最多1GB的内存,怎么找到所有未出现过的数?
【进阶】
(1)内存限制为10MB,但是只用找到一个没出现过的数即可。
(2)更为激进的问题:内存限制为3KB,如何找到一个没有出现过的数
分析:面对第一个问题,range:0~2^32-1,40亿个count,需要准备2^32/8字节(Byte)=512M的内存。常规方法用之前第九章的位图。
面对第二个问题,我们可以把内存设计为一个整型数组(无符号整型),int\[\] 在0~42亿之间,对于3KB,来说,3000/4=750,那么离它最近的2^n为512,那么我们申请一个int512长度的数组,将0~2^32-1之间的数分为512份,一定能够整除,2^32/512=2^23=8388608。
我们将arr512中的arr0表示0~8388608之间的数出现了多少次,arr1表示8388608~2^24之间的数出现了多少次,可以通过x/8388608判断出现在arr\[\]第几个,则arri中的值++。利用词频统计一定会不够的现象定位它是哪个范围。因为文件只有40亿个数,而全部512个区间的总容量是4294967296,所以至少存在一个区间,它的出现次数小于区间容量8388608。
找到这个区间后:
-
确定它的起始值和结束值。
-
申请一个只覆盖该区间的位图。
-
第二次扫描文件,只标记属于该区间的数字。
-
查找位图中第一个为0的位置。
局部位图需要:
因此总辅助内存约1MiB,远低于10MB。
核心是利用鸽巢原理,把32位无符号整数的整个范围分为若干个区间,通过统计每个区间出现的数字数量,逐步缩小存在缺失数字的范围。不过区间中数字的出现次数,不等于不同数字的数量,即使文件里存在重复值,也必须保证算法能够找到没出现过的数字。
具体步骤:
java
第一遍:
512个大区间,每个容量2^23
使用int[512]统计
找到一个出现次数不足2^23的区间
第二遍:
把选中的大区间再分成512个小区间
每个小区间容量2^14=16384
继续使用int[512]统计
找到一个出现次数不足16384的小区间
第三遍:
把int[512]清零,直接当成位图使用
512×32=16384个bit
记录选中小区间内每个数字是否出现
最后:
找到第一个为0的bit
计算它对应的原始32位整数
返回结果
这三遍扫描可以重复使用同一个 int[512] 数组,因此无需同时申请多个2KB数组。
还要处理重复数字带来的计数问题:如果某个区间的出现次数很多,甚至达到40亿,直接用 int 累加可能溢出。所以计数器达到区间容量后就停止增加,采用饱和计数。
这是安全的,因为我们只需要寻找出现次数严格小于容量的区间,而不是记录真实的全部出现次数。
代码:
java
package Class013;
import java.io.*;
import java.nio.file.*;
import java.util.Arrays;
public class Code_FindMissingNumber {
//32位无符号整数总数
private static final long RANGE=1L<<32;
//第一层划分512个区间
private static final int BUCKETS=512;
//每个大区间包含2^23个整数
private static final int BIG_SIZE=1<<23;
//每个小区间包含2^14个整数
private static final int SMALL_SIZE=1<<14;
//检查二进制文件,并返回整数总数
public static long numberCount(Path file)
throws IOException{
long bytes= Files.size(file);
if (bytes%4!=0){
throw new IllegalArgumentException("文件必须每四个字节存储一个整数");
}
return bytes/4;
}
//用512字节的缓冲区读取文件
public static DataInputStream open(Path file)
throws IOException{
return new DataInputStream(new BufferedInputStream(Files.newInputStream(file),512));
}
//找到第一个出现次数小于区间容量的区间
public static int firstUnderfull(int[] count,int capacity){
for (int i=0;i<count.length;i++){
if (count[i]<capacity){
return i;
}
}
throw new IllegalStateException("没有找到出现次数不足的区间");
}
//===================================
//方法1:3KB内存限制,寻找一个缺失数字
//===================================
public static long findMissing3KB(Path file)
throws IOException {
long N = numberCount(file);
//512*4=2048字节
//下面三轮重复使用这一个数组
int[] count = new int[BUCKETS];
//第一遍扫描:
//将2^32个数字划分为512个大区间
try (DataInputStream in = open(file)) {
for (long i = 0; i < N; i++) {
long x = Integer.toUnsignedLong(in.readInt());
//x/2^23,确定所在大区间
int big = (int) (x >>> 23);
//饱和计数,防止重复值导致溢出
if (count[big] < BIG_SIZE) {
count[big]++;
}
}
}
//找到出现次数不足2^23的大区间
int big=firstUnderfull(count,BIG_SIZE);
//第二次扫描:
//将选中的大区间再次划分为512个小区间
Arrays.fill(count,0);
try (DataInputStream in =open(file)){
for (long i=0;i<N;i++){
long x=Integer.toUnsignedLong(in.readInt());
//只统计选中大区间内的数字
if((x>>>23)==big){
//取地14~22位,定位小区间
int small=(int)((x>>>14)&511L);
if (count[small]<SMALL_SIZE){
count[small]++;
}
}
}
}
//找到出现频次不足16384的小区间
int small=firstUnderfull(count,SMALL_SIZE);
//第三次扫描
//把int[512]当成16384位的位图
Arrays.fill(count,0);
//选中小区间的起始数字
long start=(((long)big)<<23)+(((long)small)<<14);
try (DataInputStream in=open(file)){
for (long i=0;i<N;i++){
long x=Integer.toUnsignedLong(in.readInt());
if (x>=start&&x<start+SMALL_SIZE){
//x在小区间内部的偏移量
int offset=(int)(x-start);
//将相应bit设置为1
count[offset>>>5]|=1<<(offset&31);
}
}
}
//查找第一个没有出现的整数
for (int offset=0;offset<SMALL_SIZE;offset++){
int wordIndex=offset>>>5;
int bitIndex=offset&31;
if ((count[wordIndex]&(1<<bitIndex))==0){
return start+offset;
}
}
throw new IllegalStateException("选中的小区间没有缺失数字");
}
//==================================
//方法2:10MB内存限制,寻找一个缺失数字
//==================================
public static long findMissing10MB(Path file)
throws IOException{
long N=numberCount(file);
int[] count=new int[BUCKETS];
//第一遍,统计512个大区间
try (DataInputStream in=open(file)){
for (long i=0;i<N;i++){
long x=Integer.toUnsignedLong(in.readInt());
int big=(int)(x>>>23);
if (count[big]<BIG_SIZE){
count[big]++;
}
}
}
int big=firstUnderfull(count,BIG_SIZE);
long start=((long)big)<<23;
//8388608位=1MiB
byte[]bits=new byte[1<<20];
//第二遍,对选中的大区间使用位图
try (DataInputStream in=open(file)){
for (long i=0;i<N;i++){
long x=Integer.toUnsignedLong(in.readInt());
if (x>=start&&x<start+BIG_SIZE){
int offset=(int)(x-start);
bits[offset>>>3]|=(byte) (1<<(offset&7));
}
}
}
//寻找第一个为0的bit;
for (int offset=0;offset<BIG_SIZE;offset++){
if ((bits[offset>>>3]&(1<<(offset&7)))==0){
return start+offset;
}
}
throw new IllegalStateException("选中的大区间没有缺失数字");
}
//======================================
//方法3:1G内存限制,输出所有缺失数字
//======================================
public static long writeAllMissing1GB(
Path file,Path output)
throws IOException{
if (file.toAbsolutePath().normalize().equals(output.toAbsolutePath().normalize())){
throw new IllegalStateException("输入文件和输出文件不能相同");
}
long N=numberCount(file);
//2^27个int,每个int有32个bit
//共2^32个bit,即512Mib
int[]bits=new int[1<<27];
//第一遍:标记所有出现过的数字
try(DataInputStream in=open(file)){
for (long i=0;i<N;i++){
long x=Integer.toUnsignedLong(in.readInt());
int wordIndex=(int) (x>>>5);
int bitIndex=(int)(x&31);
bits[wordIndex]|=1<<bitIndex;
}
}
//遍历整个32位整数范围,输出未出现的数字
long missingCount=0;
try (DataOutputStream out=
new DataOutputStream(
new BufferedOutputStream(
Files.newOutputStream(output),512))){
for (long x=0;x<RANGE;x++){
int wordIndex=(int) (x>>>5);
int bitIndex=(int) (x&31);
if ((bits[wordIndex]&(1<<bitIndex))==0){
//以原始4字节形式输出
out.writeInt((int) x);
missingCount++;
}
}
}
return missingCount;
}
// ========================================
// 测试
// ========================================
public static void main(String[] args) throws IOException {
// 1. 指定项目根目录下的data文件夹
Path dir = Paths.get("data");
Files.createDirectories(dir);
// 2. 指定唯一的测试文件路径
Path file = dir.resolve("numbers.bin");
// 3. 只有文件不存在时才生成测试数据
if (!Files.exists(file)) {
try (DataOutputStream out =
new DataOutputStream(
new BufferedOutputStream(
Files.newOutputStream(file),
512))) {
// 写入0~9999,故意遗漏1234
for (int i = 0; i < 10000; i++) {
if (i != 1234) {
out.writeInt(i);
}
}
// 再写入重复值42
out.writeInt(42);
}
System.out.println("测试数据创建成功");
}
// 4. 显示真实文件路径
System.out.println("文件:" + file.toAbsolutePath());
System.out.println("文件大小:" + Files.size(file) + "字节");
System.out.println("整数数量:" + numberCount(file));
// 5. 执行两种算法
System.out.println("3KB方法:" + findMissing3KB(file));
System.out.println("10MB方法:" + findMissing10MB(file));
}
}
运行结果:
java
测试数据创建成功
文件:D:\Learn\Java\workspace\project1\untitled1\data\numbers.bin
文件大小:40000字节
整数数量:10000
3KB方法:1234
10MB方法:1234
问题对比:
| 比较项目 | 1GB限制 | 10MB限制 | 3KB限制 |
|---|---|---|---|
| 目标 | 找到所有缺失数字 | 找到一个缺失数字 | 找到一个缺失数字 |
| 核心结构 | 完整位图 | 分区计数+局部位图 | 分层计数+局部位图 |
| 计数数组 | 不需要 | int[512] |
复用 int[512] |
| 位图大小 | 512MiB | 1MiB | 2KiB(复用计数数组) |
| 扫描原文件次数 | 1次 | 2次 | 3次 |
| 是否支持重复值 | 支持 | 支持 | 支持 |
| 是否能找到所有缺失值 | 可以 | 不可以,只保证找到一个 | 不可以,只保证找到一个 |
3.找出重复URL
有一个包含100亿个URL的大文件,假设每个URL占用64B,请找出其中所有重复的URL。
【补充】
某搜索公司一天的用户搜索词汇是海量的(百亿数据量),请设计一种求出每天热门Top100词汇的可行办法。
方法:建立二维堆。把海量文件通过hash函数分流到小文件里面去,一种重复的url只会出现在一个小文件里,然后在小文件中,使用hash表统计出小文件中每天热门的top100词汇。接下来用堆的方式,把这100个小文件中的词频进行如下操作:
java
例如:
file1:
A:100
K:96
C:87
...
file2:
甲:103
丙:94
乙:90
...
file3:
a:99
b:72
...
我们把100个小文件中的堆顶单独拿出来,组成一个新的大根堆:
总堆:
甲:103
A:100
a:99
...
第一次弹出(甲,103),发现它来自file2,接着file2弹出(丙,94)加入总堆中,调整得到:
A:100
a:99
丙:94
...
第二次弹出(A,100),发现它来自file1,接着file1弹出(K,96)加入总堆中,调整得到:
a:99
K:96
丙:94
...
以此类推
这样的操作的时间复杂度为O(logN)的水平。
为了简单地演示这种操作,我们创建一个Input.txt出现频次最多的词的频次用10个小文件存起来,然后采用大根堆动态地收集这些频次。
代码:
java
package Class013;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.*;
import java.util.List;
/**
* 哈希分流 + 局部 TopK 小根堆 + 全局大根堆归并。
* 只在明确指定的项目 data/top10 下创建文件;不会创建系统临时文件。
* 每行一个词(或 URL),文件使用 UTF-8 编码。
*/
public class Top10WithHash {
private static final int SHARDS=10;
private static final int K=10;
public static class WordCount {
public final String word;
public final long count;
public WordCount(String word, long count) {
this.word = word;
this.count = count;
}
@Override
public String toString() {
return word + ":" + count;
}
}
//排名从高到低:词频大的优先;词频相同,字典序小的优先。
private static final Comparator<WordCount>BEST_FIRST=
Comparator.comparingLong((WordCount wc)-> wc.count)
.reversed()
.thenComparing(wc->wc.word);
//局部TopK的小根堆:最差候选排在堆顶,便于淘汰
private static final Comparator<WordCount>WORST_FIRST=
Comparator.comparingLong((WordCount wc)->wc.count)
.thenComparing((a,b)->b.word.compareTo(a.word));
//-----------第一步:生成视频原始文件-------------
//只在文件不存在时创建;CREATE_NEW保证不覆盖已有数据
public static void generateExampleINput(Path input)throws IOException{
Path parent=input.getParent();
if (parent!=null){
Files.createDirectories(parent);
}
LinkedHashMap<String,Integer>samples=new LinkedHashMap<>();
samples.put("Java", 130);
samples.put("算法", 120);
samples.put("Morris遍历", 110);
samples.put("哈希", 100);
samples.put("AI", 90);
samples.put("数据结构", 80);
samples.put("并查集", 70);
samples.put("二叉树", 60);
samples.put("单调栈", 50);
samples.put("机器人", 40);
samples.put("滑动窗口", 30);
samples.put("数据库", 25);
samples.put("搜索", 20);
samples.put("Python", 18);
samples.put("C++", 16);
samples.put("GitHub", 14);
samples.put("动态规划", 12);
samples.put("网络", 10);
samples.put("排序", 8);
samples.put("数学", 6);
try (BufferedWriter out=Files.newBufferedWriter(input, StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,StandardOpenOption.WRITE)){
for (Map.Entry<String,Integer>entry:samples.entrySet()){
for (int j=0;j<entry.getValue();j++){
out.write(entry.getKey());
out.newLine();
}
}
//添加120个只出现一次的词,使得10个分片都有数据
for(int i=0;i<120;i++){
out.write("低频词"+i);
out.newLine();
}
}
}
//------------第二步:按Hash分流为10个文件----------
//同一个词始终进入同一个分片,不能按行号轮流分配
public static List<Path>partionByHash(Path input,Path shardDir,int shardCount)throws IOException{
Files.createDirectories(shardDir);//存在则报错,避免覆盖原分片
List<Path>paths=new ArrayList<>();
BufferedWriter[] writers=new BufferedWriter[shardCount];
try {
for (int i=0;i<shardCount;i++){
Path part=shardDir.resolve(String.format("file%02d.txt",i+1));
paths.add(part);
writers[i]=Files.newBufferedWriter(part,StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,StandardOpenOption.WRITE);
}
try (BufferedReader reader=Files.newBufferedReader(input,StandardCharsets.UTF_8)){
String word;
while ((word=reader.readLine())!=null){
word=word.trim();
if (word.isEmpty()){
continue;
}
int index=Math.floorMod(word.hashCode(),shardCount);
writers[index].write(word);
writers[index].newLine();
}
}
}finally {
for (BufferedWriter writer:writers){
if (writer!=null){
writer.close();
}
}
}
return paths;
}
//--------------------第三步:单个文件求局部Top10------------------------
public static List<WordCount>localTopK(Path shard,int k)throws IOException{
//示例用HashMap统计词频;真实海量数据需要保证每份能够装入内存
HashMap<String,Long>frequency=new HashMap<>();
try (BufferedReader reader=Files.newBufferedReader(
shard,StandardCharsets.UTF_8)){
String word;
while ((word=reader.readLine())!=null){
word=word.trim();
if (!word.isEmpty()){
frequency.merge(word,1L,Long::sum);
}
}
}
PriorityQueue<WordCount>minHeap=new PriorityQueue<>(WORST_FIRST);
for (Map.Entry<String,Long>entry:frequency.entrySet()){
WordCount candidate=new WordCount(entry.getKey(),entry.getValue());
if (minHeap.size()<k){
minHeap.offer(candidate);
}else if (WORST_FIRST.compare(candidate,minHeap.peek())>0){
minHeap.poll();
minHeap.offer(candidate);
}
}
List<WordCount>result=new ArrayList<>(minHeap);
result.sort(BEST_FIRST);//局部列表按从高到低排列,供全局大根堆归并
return result;
}
//--------------------第四步:归并10组局部top10---------------------
private static class Cursor {
final int shardIndex;
final int listIndex;
final WordCount item;
Cursor(int shardIndex, int listIndex, WordCount item) {
this.shardIndex = shardIndex;
this.listIndex = listIndex;
this.item = item;
}
}
public static List<WordCount>mergeTopK(
List<List<WordCount>>localLists,int k){
//每个分片最多有一个候选在总大根堆中
PriorityQueue<Cursor>maxHeap=new PriorityQueue<>(
(a,b)->BEST_FIRST.compare(a.item,b.item)
);
for (int s=0;s<localLists.size();s++){
if (!localLists.get(s).isEmpty()){
maxHeap.offer(new Cursor(s,0,localLists.get(s).get(0)));
}
}
List<WordCount>result=new ArrayList<>();
while (result.size()<k&&!maxHeap.isEmpty()){
Cursor best=maxHeap.poll();
result.add(best.item);
//从被弹出的元素的所属分片,补进下一名
int nextIndex=best.listIndex+1;
List<WordCount>sameShard=localLists.get(best.shardIndex);
if (nextIndex<sameShard.size()){
maxHeap.offer(new Cursor(best.shardIndex,nextIndex,sameShard.get(nextIndex)));
}
}
return result;
}
public static List<Path>shardPaths(Path shardDir,int shardCount){
List<Path>files=new ArrayList<>();
for (int i=0;i<shardCount;i++){
files.add(shardDir.resolve(String.format("file%02d.txt",i+1)));
}
return files;
}
public static List<WordCount>globalTopL(
List<Path>files,int k,boolean printLocal)throws IOException{
List<List<WordCount>>localLists=new ArrayList<>();
for (Path file:files){
if (!Files.isReadable(file)) {
throw new IOException("找不到分片:" + file.toAbsolutePath());
}
List<WordCount>local=localTopK(file,k);
localLists.add(local);
if (printLocal){
System.out.println("\n"+file.getFileName()+"局部Top"+k+": ");
for (WordCount item:local){
System.out.println(" "+item);
}
}
}
return mergeTopK(localLists,k);
}
//------------------额外:列出所有重复URL--------------
//输入10个分片必须按同一个hash函数生成:可以替换为URL数据
//对于大数据,分片数需要足够多,让单份HashMap放得下
public static long writeDuplicateUrls(List<Path>files,Path output)throws IOException{
long duplicateKinds=0;
try (BufferedWriter out= Files.newBufferedWriter(output,StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,StandardOpenOption.WRITE)){
for(Path file:files){
Map<String,Long>frequency=new HashMap<>();
try (BufferedReader reader=Files.newBufferedReader(
file,StandardCharsets.UTF_8)){
String url;
while ((url= reader.readLine())!=null){
url=url.trim();
if (!url.isEmpty()){
frequency.merge(url,1L,Long::sum);
}
}
}
for (Map.Entry<String, Long>entry:frequency.entrySet()){
if (entry.getValue()>1L){
out.write(entry.getKey()+"\t"+entry.getValue());
out.newLine();
duplicateKinds++;
}
}
}
}
return duplicateKinds;
}
//------------main-------------------------
public static void main(String [] args)throws IOException{
//项目工作目录下唯一的数据存放位置,不创建系统临时文件
Path root= Paths.get("data","top10");
Path input=root.resolve("input.txt");
Path shardDir=root.resolve("shards");
String mode=args.length==0?"demo":args[0];
if ("generate".equals(mode)){
generateExampleINput(input);
System.out.println("已生成原始文件:"+input.toAbsolutePath());
return;
}
if ("shard".equals(mode)){
partionByHash(input,shardDir,SHARDS);
System.out.println("10个分片已经生成:"+shardDir.toAbsolutePath());
return;
}
if ("demo".equals(mode)){
Files.createDirectories(root);
if (!Files.exists(input)){
generateExampleINput(input);
System.out.println("已经生成示例文件:"+input.toAbsolutePath());
}
if (!Files.exists(shardDir)){
partionByHash(input,shardDir,SHARDS);
System.out.println("已经生成10个分片:"+shardDir.toAbsolutePath());
}else {
System.out.println("检测到已有分片,不覆盖;直接读取。修改input后需要自行重新分片。");
}
}else if (!"top".equals(mode)&&!"duplicates".equals(mode)){
throw new IllegalArgumentException("参数只能是demo,generate,shard,top,duplicates");
}
List<Path>files=shardPaths(shardDir,SHARDS);
if ("duplicates".equals(mode)){
Path output=root.resolve("duplicates.tsv");
long count=writeDuplicateUrls(files,output);
System.out.println("重复词/URL种类数:"+count);
System.out.println("输出文件: "+output.toAbsolutePath());
return;
}
List<WordCount>result=globalTopL(files,K,true);
System.out.println("\n=========全局Top"+K+"==========");
for (int i=0;i<result.size();i++){
System.out.println((i+1)+"."+result.get(i));
}
}
}
运行结果:
java
已经生成10个分片:D:\Learn\Java\workspace\project1\untitled1\data\top10\shards
file01.txt局部Top10:
算法:120
哈希:100
机器人:40
低频词101:1
低频词110:1
低频词15:1
低频词24:1
低频词33:1
低频词4:1
低频词42:1
file02.txt局部Top10:
低频词102:1
低频词111:1
低频词16:1
低频词25:1
低频词34:1
低频词43:1
低频词5:1
低频词52:1
低频词61:1
低频词70:1
file03.txt局部Top10:
搜索:20
低频词103:1
低频词112:1
低频词17:1
低频词26:1
低频词35:1
低频词44:1
低频词53:1
低频词6:1
低频词62:1
file04.txt局部Top10:
数据库:25
C++:16
低频词104:1
低频词113:1
低频词18:1
低频词27:1
低频词36:1
低频词45:1
低频词54:1
低频词63:1
file05.txt局部Top10:
低频词105:1
低频词114:1
低频词19:1
低频词28:1
低频词37:1
低频词46:1
低频词55:1
低频词64:1
低频词73:1
低频词8:1
file06.txt局部Top10:
动态规划:12
低频词10:1
低频词106:1
低频词115:1
低频词29:1
低频词38:1
低频词47:1
低频词56:1
低频词65:1
低频词74:1
file07.txt局部Top10:
Java:130
Python:18
数学:6
低频词0:1
低频词107:1
低频词11:1
低频词116:1
低频词20:1
低频词39:1
低频词48:1
file08.txt局部Top10:
Morris遍历:110
并查集:70
滑动窗口:30
排序:8
低频词1:1
低频词108:1
低频词117:1
低频词12:1
低频词21:1
低频词30:1
file09.txt局部Top10:
AI:90
二叉树:60
单调栈:50
低频词109:1
低频词118:1
低频词13:1
低频词2:1
低频词22:1
低频词31:1
低频词40:1
file10.txt局部Top10:
数据结构:80
GitHub:14
网络:10
低频词100:1
低频词119:1
低频词14:1
低频词23:1
低频词3:1
低频词32:1
低频词41:1
=========全局Top10==========
1.Java:130
2.算法:120
3.Morris遍历:110
4.哈希:100
5.AI:90
6.数据结构:80
7.并查集:70
8.二叉树:60
9.单调栈:50
10.机器人:40
复杂度分析
设:
-
M:原始文件中的词汇总数。
-
U:所有不同词汇的数量。
-
S:分片数量,本例为10。
-
K:需要选出的高频词数量,本例为10。
| 操作 | 时间复杂度 |
|---|---|
| Hash分流 | 平均 O(M) |
| HashMap统计词频 | 平均 O(M) |
| 局部小根堆维护 | O(U log K) |
| 局部TopK排序 | O(S K log K) |
| 全局大根堆归并 | O(K log S) |
4.小内存查找重复数和中位数
32位无符号整数的范围是0~4294967295,现在有40亿个无符号整数,可以使用最多1GB的内存,找出所有出现了两次的数。
【补充】
可以使用最多10MB的内存,怎么找到这40亿个整数的中位数?
重复数分析:
方法一,用hash函数分流,分成若干个小文件,小文件中存词频,统计出现两次的数的词频即可。
方法二,用两个位图来表示一个数是否重复出现,比如00代表出现0次,01代表出现1次,10代表出现2次,11代表出现>2次。此时两位可以代表0~4,对于0~2^32-1,有2^32个数,2^32*2bit/8=2^30Byte,1G内存可以存下。
伪代码:
java
函数 findExactlyTwice(file):
RANGE = 2^32
// 每个整数占用2bit
// 总共需要2^33bit = 2^30Byte
bitmap = 创建2位状态数组(RANGE)
// 第一阶段:扫描40亿个整数
对于文件中的每个整数x:
state = bitmap[x]
如果 state == 00:
bitmap[x] = 01
否则如果 state == 01:
bitmap[x] = 10
否则如果 state == 10:
bitmap[x] = 11
否则:
// 已经出现至少3次
// 保持11不变
不进行操作
// 第二阶段:检查所有整数的状态
对于 x 从0到2^32-1:
如果 bitmap[x] == 10:
输出x到结果文件
结束
中位数分析:
采用累加和的方法:range(0~2^32-1),40亿个数,若采用无符号整型存储,只用10KB的内存,10KB/4B=2500长度,最接近的数是2048,于是设置unsign int\[\]总数为2048,记录词频(arri++)后,比如0~2047上arr0出现了100万次,2048~2048*2-1上arr1出现10亿,2048*2~2048*3-1上arr2出现5亿次......如果arri的范围刚刚让累加和=20亿,那么中位数就出现在这个范围上。接着再把这个范围上的数再次进行2048份的划分,比如,Y=2048,arr499范围是Y*499~Y*500-1,arr0~arr499的累加和为18亿,而arr500统计了Y*500~Y*500-1总共有5亿个数,那么arr500的第2亿个数就是中位数,接着再做划分,可以保证内存不超。
java
函数 findKth(file, k):
// 32位无符号整数的完整范围
L = 0
R = 2^32
// 当前目标在候选区间内的排名
rank = k
// 每轮最多划分2048份
BUCKETS = 2048
当 R-L > 1 时:
// 清空桶计数
count[0...2047] = 0
// 当前每个桶负责的整数数量
width = 向上取整((R-L)/2048)
// 扫描整个文件,统计当前范围内的词频
对于文件中的每个整数x:
如果 L <= x < R:
index = (x-L)/width
count[index]++
// 按桶的数值顺序累积词频
sum = 0
对于 i 从0到2047:
如果 sum + count[i] >= rank:
// 第rank个数一定在第i个桶
// 排除前面桶中的所有出现次数
rank = rank-sum
// 更新候选范围
newL = L+i*width
newR = min(R,newL+width)
L = newL
R = newR
跳出当前桶查找循环
否则:
sum = sum+count[i]
// 最后只剩一个候选整数
返回L
函数 findMedian(file):
N = 4000000000
// 总数为偶数,寻找中间两个数
k1 = N/2
k2 = N/2+1
// 分别使用分段统计定位
a = findKth(file,k1)
b = findKth(file,k2)
// 防止整数相加溢出
median = (a+b)/2.0
返回median
示例代码:
java
package Class013;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
public class Code_TwiceAndMedian {
private static final long RANGE=1L<<32;
private static final int BUCKETS=2048;
private static final int BUFFER_SIZE=8192;
//输入为二进制文件,每个无符号32位整数占4字节
public static long numberCount(Path input) throws IOException{
long bytes= Files.size(input);
if (bytes%4!=0){
throw new IllegalArgumentException("文件长度必须是4字节的倍数");
}
return bytes/4;
}
public static DataInputStream open(Path input) throws IOException{
return new DataInputStream(new BufferedInputStream(
Files.newInputStream(input),BUFFER_SIZE));
}
// 正式版本:扫描整个0~2^32-1范围,两次使用512MiB状态位图。
public static long writeExactlyTwice(Path input, Path output)
throws IOException {
return writeExactlyTwiceInRange(input, output, RANGE, 1L << 31);
}
//通用版本:universe表示[0,universe);segmentValues表示每批处理多少个值。
//供小数据测试时缩小范围,避免扫描全部2^32个候选值
public static long writeExactlyTwiceInRange(Path input,Path output,long universe,long segmentValues)
throws IOException{
if (input.toAbsolutePath().normalize().equals(output.toAbsolutePath().normalize())){
throw new IllegalArgumentException("输入与输出文件不能相同");
}
if (universe<=0||universe>RANGE||segmentValues<=0||segmentValues>(1L<<31)){
throw new IllegalArgumentException("范围或分段大小不合法");
}
long n=numberCount(input);
int byteCount=(int) ((Math.min(universe,segmentValues)+3)/4);
byte[]bitmap=new byte[byteCount];//每个数2bit
long exactlyTwice=0;
//仅写入明确指定的output,同名结果文件会被覆盖,原始输入不会被修改
try (BufferedWriter out=Files.newBufferedWriter(output, StandardCharsets.UTF_8,
StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){
for (long base=0;base<universe;base+=segmentValues){
long end=Math.min(universe,base+segmentValues);
Arrays.fill(bitmap,(byte) 0);
//每批重新扫描输入文件,统计当前区间内的出现次数(最多计数到3)
try (DataInputStream in=open(input)){
for (long i=0;i<n;i++){
long x=Integer.toUnsignedLong(in.readInt());
if (x<base||x>=end){
continue;
}
long offset=x-base;
int byteIndex=(int) (offset>>>2);
int shift=(int) ((offset&3L)<<1);
int oldByte=bitmap[byteIndex]&0xff;
int state=(oldByte>>>shift)&3;
if (state<3){
int newByte=(oldByte&~(3<<shift))|((state+1)<<shift);
bitmap[byteIndex]=(byte) newByte;
}
}
}
//只有状态10(二进制,即2)才是恰好出现两次。
for (long offset=0;offset<end-base;offset++){
int byteIndex=(int) (offset>>>2);
int shift=(int) ((offset&3L)<<1);
int state=((bitmap[byteIndex]&0xff)>>>shift)&3;
if (state==2){
out.write(Long.toString(base+offset));
out.newLine();
exactlyTwice++;
}
}
}
}
return exactlyTwice;
}
//在最多2048个计数桶里,逐渐缩小范围,寻找排序后第k个数(k从1开始)
public static long findKth(Path input,long k)throws IOException{
long n=numberCount(input);
if (k<1||k>n){
throw new IllegalArgumentException("k必须在1到文件总数之间");
}
long low=0;
long high=RANGE;//左闭右开:[low,high)
long rank=k;
long[] count=new long[BUCKETS];
while (high-low>1){
Arrays.fill(count,0);
long width=(high-low+BUCKETS-1)/BUCKETS;
try (DataInputStream in=open(input)){
for (long i=0;i<n;i++){
long x=Integer.toUnsignedLong(in.readInt());
if (x>=low&&x<high){
int bucket=(int)((x-low)/width);
count[bucket]++;
}
}
}
long before=0;
boolean found=false;
for (int bucket=0;bucket<BUCKETS;bucket++){
if (before+count[bucket]>=rank){
long newLow=low+bucket*width;
long newHigh=Math.min(high,newLow+width);
low=newLow;
high=newHigh;
rank-=before;
found=true;
break;
}
before+=count[bucket];
}
if (!found){
throw new IllegalArgumentException("未找到目标排名,请检查数据文件是否改变");
}
}
return low;
}
//偶数长度取中间两个数的平均数
public static double findMedian(Path input)throws IOException{
long n=numberCount(input);
if (n==0){
throw new IllegalArgumentException("空文件没有中位数");
}
if ((n&1L)==1L){
return findKth(input,n/2+1);
}
long a=findKth(input,n/2);
long b=findKth(input,n/2+1);
return (a+b)/2.0;
}
public static void main(String[] args) throws IOException {
// 1. 指定项目中的数据目录
Path dir = Paths.get("data", "class013");
Files.createDirectories(dir);
// 指定输入与输出文件
Path input = dir.resolve("example_100000_three_twice.bin");
Path output = dir.resolve("exactly_twice_100000_three.txt");
// 2. 只在文件不存在时生成测试数据
if (!Files.exists(input)) {
// 创建长度为100000的数组
int[] demo = new int[100000];
// 初始化:0~99999
for (int i = 0; i < demo.length; i++) {
demo[i] = i;
}
// 构造3种出现两次的数字
demo[0] = 12345; // 12345出现2次
demo[1] = 54321; // 54321出现2次
demo[2] = 87654; // 87654出现2次
// 将数组写入指定二进制文件
try (DataOutputStream out =
new DataOutputStream(
new BufferedOutputStream(
Files.newOutputStream(
input,
StandardOpenOption.CREATE_NEW),
BUFFER_SIZE))) {
for (int x : demo) {
out.writeInt(x);
}
}
System.out.println("100000个整数生成成功!");
} else {
System.out.println("输入文件已存在,直接读取,不覆盖。");
}
// 3. 显示文件信息
System.out.println("输入文件:" + input.toAbsolutePath());
System.out.println("整数总数:" + numberCount(input));
System.out.println("文件大小:" + Files.size(input) + "字节");
// 4. 测试2位状态位图
System.out.println("\n===== 测试1:查找恰好出现两次的数 =====");
long kinds = writeExactlyTwiceInRange(
input,
output,
100001,
16384
);
System.out.println("恰好出现两次的数字种数:" + kinds);
for (String line : Files.readAllLines(
output, StandardCharsets.UTF_8)) {
System.out.println("重复数字:" + line);
}
// 5. 测试分段统计中位数
System.out.println("\n===== 测试2:查找中位数 =====");
double median = findMedian(input);
System.out.println("中位数:" + median);
// 6. 显示结果文件
System.out.println("\n结果文件:" + output.toAbsolutePath());
}
}
运行结果:
java
100000个整数生成成功!
输入文件:D:\Learn\Java\workspace\project1\untitled1\data\class013\example_100000_three_twice.bin
整数总数:100000
文件大小:400000字节
===== 测试1:查找恰好出现两次的数 =====
恰好出现两次的数字种数:3
重复数字:12345
重复数字:54321
重复数字:87654
===== 测试2:查找中位数 =====
中位数:50001.5
结果文件:D:\Learn\Java\workspace\project1\untitled1\data\class013\exactly_twice_100000_three.txt
5.大文件无序数转有序数
10G的文件,存储int类型的无序数,如何使用5G内存,把它变成一个有序的文件存储到硬盘里?
思路:
方法一:阈值分段计数。建立一个小根堆,存储一组记录:词和词频,比如"x:4个""z:8个"每一对数占8个字节,加上额外的内存消耗,一条记录可能要占16字节,总共能够存下5*2^30/2^4=5*2^26条记录,接近2^27条记录(降低一个数量级)。总共有2^32的规模,一个范围是2^27,能够划分出2^5个范围。
我们先用小根堆统计最低范围上的数字。int的大小为2^32,表示的数的范围为(-2^31~2^31-1),第一个范围为(-2^31~-2^31+2^27-1),第二个范围为(-2^31+2^27~-2^31+2^27*2-1)......比如用小根堆得到第一个小文件遍历10G文件后得到{a:2,f:4,g:16.......},然后依次弹出存放。
具体为:
方法一:Hash词频统计 + 小根堆
- 将int的完整数值范围划分为32个区间。
每个区间包含2^27种可能的整数。
-
对每个区间,重新扫描10GiB原始文件。
-
只处理属于当前区间的整数:
Hash表统计数字及其出现次数。
数字首次出现时,将其加入小根堆。
- 扫描结束后,依次弹出小根堆堆顶。
根据Hash表中的词频,将数字重复写入输出文件。
-
清空当前区间的数据,继续下一个区间。
-
32个区间全部完成后,得到全局有序文件。
方法二:大根堆分批筛选。建立一个滑动的大根堆,遍历时寻找比大根堆中现在最小的记录组,如果有小于大根堆最大的记录组则弹出记录进行调整,直到遍历完这个文件,最后得到数量为堆大小的最小的数字;此时再更新这个大根堆的标准,一旦遍历的数小于上一轮遍历时最终得到的大根堆的堆顶元素的大小,则跳过,否则按照上一轮的标准继续筛选,以此类推。简单说,在不考虑重复的情况下,就是0~19个数乱序排列,建立一个大小为4的大根堆,遍历一遍后得到{3:1,2:1,1:1,0:1},遍历两遍后得到{7:1,6:1,5:1,4:1}......以此类推(有重复值的类似)。
方法三:外部归并排序。第一阶段,把大文件拆成若干个已经排序的小文件;第二阶段,通过小根堆,将所有有序的文件合并为一个完整的有序文件。
外部归并排序(External Merge Sort)
第一阶段:分块排序
-
每次从原始文件读取一部分整数(例如256MB)。
-
使用Arrays.sort()在内存中对这部分整数升序排序。
-
将排好序的数据写入一个临时文件。
-
重复上述过程,直到原始文件全部读取完毕。
-
最终得到多个内部有序的小文件。
第二阶段:小根堆多路归并
-
为每个有序小文件建立输入流。
-
从每个小文件读取第一个整数,放入小根堆。
-
弹出堆顶最小值,将其写入最终结果文件。
-
从该最小值所属的小文件读取下一个整数,加入小根堆。
-
重复步骤3、4,直到所有小文件读取完毕。
-
删除临时文件,得到完整的升序文件。
核心思想:大文件无法一次加载到内存,就先分块排序,再利用小根堆合并多个有序文件。
空间复杂度:O(B + R),B为每块整数数量,R为临时文件数量。
时间复杂度:分块排序:O(NlogB);多路归并:O(NlogR)
主要优点:无需反复扫描原始文件,内存占用可控,适合海量数据排序。
主要缺点:需要额外的磁盘空间保存临时有序文件。
代码如下:
java
package Class013;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
import java.util.Random;
/**
* 三种大文件排序算法:
* 1) 按 int 的数值范围分段,用原生数组哈希表统计实际出现的数及其词频,
* 用小根堆按数值从小到大输出。不遍历整个 2^32 值域。
* 2) 反复扫描源文件,用原生数组实现的大根堆提取尚未输出的最小 K 条记录。
* 3) 外部归并:分块内存排序 -> 多路小根堆归并。
*
* 输入和输出都是二进制 int 文件,每个整数 4 字节,使用有符号 int 升序。
* 不使用系统临时文件,不改写原文件;结果文件若已存在则拒绝覆盖。
*/
public class Code_EXternalSortTwoMethods {
private static final int IO_BUFFER = 1 << 16;
private static final int REAL_RANGE_WIDTH = 1 << 27;
private static final int REAL_HEAP_K = 1 << 27;
private static final int REAL_MERGE_CHUNK_INTS = 1 << 26; // 每块约256MiB
private static final int MAX_MERGE_WAYS = 64; // 限制同时打开的文件数
public static long numberCount(Path input) throws IOException {
long bytes = Files.size(input);
if (bytes % 4 != 0) {
throw new IllegalArgumentException("二进制输入文件长度必须是4字节的倍数");
}
return bytes / 4;
}
private static DataInputStream openInput(Path path) throws IOException {
return new DataInputStream(new BufferedInputStream(
Files.newInputStream(path), IO_BUFFER));
}
private static DataOutputStream openNewOutput(Path path) throws IOException {
return new DataOutputStream(new BufferedOutputStream(
Files.newOutputStream(path, StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE), IO_BUFFER));
}
private static void checkDifferentFiles(Path input, Path output) throws IOException {
if (input.toAbsolutePath().normalize().equals(output.toAbsolutePath().normalize())
|| (Files.exists(input) && Files.exists(output)
&& Files.isSameFile(input, output))) {
throw new IllegalArgumentException("输入文件和输出文件不能是同一个文件");
}
}
/**
* 方法一:值域分段 + 哈希统计词频 + 小根堆按值输出。
*
* 每轮重新扫描原始文件,只统计 [base,end) 中实际出现的整数。
* 哈希表记录 x->词频;数字第一次出现时同时加入小根堆,避免对空值域进行遍历。
* 文件按区间升序输出,小根堆保证区间内部升序,因此结果全局有序。
*
* 为适应5GiB内存限制,这里不使用 HashMap<Integer,Long> + PriorityQueue<Integer>
* 的海量装箱对象,而用原生数组实现等价的紧凑哈希表和小根堆。
* 词频按无符号32位储存,因此本方法要求输入记录数不超过 2^32-1。
* 对10GiB的二进制int文件(约26.8亿个整数)足够。
*/
public static void sortByValueRange(Path input, Path output,
long minInclusive, long maxExclusive,
int rangeWidth) throws IOException {
checkDifferentFiles(input, output);
long n = numberCount(input);
if (minInclusive < Integer.MIN_VALUE
|| maxExclusive > (long) Integer.MAX_VALUE + 1
|| minInclusive >= maxExclusive || rangeWidth <= 0) {
throw new IllegalArgumentException("数值范围或单段宽度不合法");
}
if (n > 0xffff_ffffL) {
throw new IllegalArgumentException("此紧凑哈希实现要求文件整数数量不超过2^32-1");
}
try (DataOutputStream out = openNewOutput(output)) {
for (long base = minInclusive; base < maxExclusive; base += rangeWidth) {
long end = Math.min(maxExclusive, base + rangeWidth);
// 哈希表只存真正出现过的数字及其词频。
CompactIntFrequencyMap frequency = new CompactIntFrequencyMap();
// 堆只存每种数字一次;最大不同数字数目不会超过当前区间的宽度。
IntMinHeap heap = new IntMinHeap((int) (end - base));
// 每个区间从头流式扫描一次原文件,不把整个文件读入内存。
try (DataInputStream in = openInput(input)) {
for (long i = 0; i < n; i++) {
int x = in.readInt();
if ((long) x < minInclusive || (long) x >= maxExclusive) {
throw new IllegalArgumentException("输入包含超出设置范围的值:" + x);
}
if ((long) x >= base && (long) x < end) {
// 第一次出现返回true:只需把这个值入堆一次。
if (frequency.increment(x)) {
heap.offer(x);
}
}
}
}
// 小根堆弹出的值递增;根据哈希表中的词频重复输出。
// 只输出本段实际出现的整数,不扫描本段所有可能的数值。
while (!heap.isEmpty()) {
int x = heap.poll();
long times = frequency.getCount(x);
for (long j = 0; j < times; j++) {
out.writeInt(x);
}
}
}
}
}
/**
* 精简版整数HashMap:原生long[]开放寻址哈希表。
* 槽位低32bit储存无符号词频,高32bit储存整数键。
* 词频为0代表空槽;负数键和整数0也都能正确储存。
* 没有Java Integer/Long/Entry包装对象;扩容时只对已有哈希槽重排。
*/
private static class CompactIntFrequencyMap {
private static final int INITIAL_CAPACITY = 1024;
private long[] slots = new long[INITIAL_CAPACITY];
private int size = 0;
private int threshold = INITIAL_CAPACITY * 7 / 10;
private static int hash(int x) {
x ^= x >>> 16;
x *= 0x7feb352d;
x ^= x >>> 15;
x *= 0x846ca68b;
x ^= x >>> 16;
return x;
}
private static long pack(int key, long count) {
return (((long) key) << 32) | (count & 0xffff_ffffL);
}
// 如果第一次出现,插入词频1并返回true;否则词频+1并返回false。
boolean increment(int key) {
int mask = slots.length - 1;
int i = hash(key) & mask;
while (true) {
long entry = slots[i];
if (entry == 0L) {
if (size >= threshold) {
grow();
return increment(key);
}
slots[i] = pack(key, 1);
size++;
return true;
}
if ((int) (entry >>> 32) == key) {
long oldCount = entry & 0xffff_ffffL;
if (oldCount == 0xffff_ffffL) {
throw new ArithmeticException("整数出现次数超过无符号32位上限");
}
slots[i] = pack(key, oldCount + 1);
return false;
}
i = (i + 1) & mask;
}
}
long getCount(int key) {
int mask = slots.length - 1;
int i = hash(key) & mask;
while (slots[i] != 0L) {
long entry = slots[i];
if ((int) (entry >>> 32) == key) {
return entry & 0xffff_ffffL;
}
i = (i + 1) & mask;
}
throw new IllegalStateException("哈希表不存在数字:" + key);
}
private void grow() {
if (slots.length >= (1 << 28)) {
throw new IllegalStateException("单区间不同整数过多,请减小rangeWidth");
}
long[] old = slots;
slots = new long[old.length << 1];
threshold = slots.length * 7 / 10; // 防止整数乘法溢出,长度最多2^28
size = 0;
int mask = slots.length - 1;
for (long entry : old) {
if (entry != 0) {
int key = (int) (entry >>> 32);
int i = hash(key) & mask;
while (slots[i] != 0) {
i = (i + 1) & mask;
}
slots[i] = entry;
size++;
}
}
}
}
// 原生int[]小根堆:只保存每个数字一次,避免PriorityQueue<Integer>装箱开销。
private static class IntMinHeap {
private int[] values;
private int size;
private final int maxDistinct;
IntMinHeap(int maxDistinct) {
if (maxDistinct <= 0) {
throw new IllegalArgumentException("区间宽度必须大于0");
}
this.maxDistinct = maxDistinct;
values = new int[Math.min(maxDistinct, 1024)];
}
boolean isEmpty() {
return size == 0;
}
void offer(int x) {
if (size == values.length) {
if (size == maxDistinct) {
throw new IllegalStateException("区间中的不同整数超过区间宽度");
}
int next = (int) Math.min((long) maxDistinct, (long) values.length * 2);
values = Arrays.copyOf(values, next);
}
int index = size++;
while (index > 0) {
int parent = (index - 1) >>> 1;
if (values[parent] <= x) break;
values[index] = values[parent];
index = parent;
}
values[index] = x;
}
int poll() {
if (size == 0) throw new IllegalStateException("小根堆为空");
int smallest = values[0];
int last = values[--size];
if (size != 0) {
int i = 0;
while (true) {
int left = i * 2 + 1;
if (left >= size) break;
int child = left;
if (left + 1 < size && values[left + 1] < values[left]) {
child = left + 1;
}
if (last <= values[child]) break;
values[i] = values[child];
i = child;
}
values[i] = last;
}
return smallest;
}
}
// 比较 (数值, 原始位置),解决大量相同数字跨越两个批次时的边界问题。
private static int comparePair(int valueA, long posA, int valueB, long posB) {
int c = Integer.compare(valueA, valueB);
return c != 0 ? c : Long.compare(posA, posB);
}
// 使用两个原生数组,避免 PriorityQueue<Record> 的大量对象开销。
private static class PairMaxHeap {
final int[] values;
final long[] positions;
int size;
PairMaxHeap(int capacity) {
values = new int[capacity];
positions = new long[capacity];
}
void clear() {
size = 0;
}
void offerIfSmall(int value, long position) {
if (size < values.length) {
values[size] = value;
positions[size] = position;
siftUp(size);
size++;
} else if (comparePair(value, position, values[0], positions[0]) < 0) {
values[0] = value;
positions[0] = position;
siftDown(0, size);
}
}
private void siftUp(int index) {
while (index > 0) {
int parent = (index - 1) >>> 1;
if (comparePair(values[index], positions[index],
values[parent], positions[parent]) <= 0) {
return;
}
swap(index, parent);
index = parent;
}
}
private void siftDown(int index, int heapSize) {
while (true) {
int left = index * 2 + 1;
if (left >= heapSize) {
return;
}
int largest = left;
int right = left + 1;
if (right < heapSize && comparePair(values[right], positions[right],
values[left], positions[left]) > 0) {
largest = right;
}
if (comparePair(values[index], positions[index],
values[largest], positions[largest]) >= 0) {
return;
}
swap(index, largest);
index = largest;
}
}
private void swap(int a, int b) {
int tmpValue = values[a];
values[a] = values[b];
values[b] = tmpValue;
long tmpPosition = positions[a];
positions[a] = positions[b];
positions[b] = tmpPosition;
}
// 堆本身是大根堆;原地堆排序之后,数组按 (数值, 位置) 升序排列。
void sortAscending() {
for (int end = size - 1; end > 0; end--) {
swap(0, end);
siftDown(0, end);
}
}
}
/**
* 方法二:每轮扫描源文件,用大根堆筛出尚未输出的最小 K 条记录。
* 记录的原始位置作为同值时的第二排序键,保证跨批次的重复值不会丢失。
*/
public static void sortByBatchMaxHeap(Path input, Path output, int k)
throws IOException {
checkDifferentFiles(input, output);
long n = numberCount(input);
if (k <= 0) {
throw new IllegalArgumentException("堆容量K必须大于0");
}
int capacity = (int) Math.min((long) k, n);
PairMaxHeap heap = new PairMaxHeap(capacity);
long emitted = 0;
boolean hasBoundary = false;
int lastValue = 0;
long lastPosition = -1;
try (DataOutputStream out = openNewOutput(output)) {
while (emitted < n) {
heap.clear();
// 从头扫描,原始位置 i 每轮保持不变。
try (DataInputStream in = openInput(input)) {
for (long i = 0; i < n; i++) {
int value = in.readInt();
if (hasBoundary && comparePair(
value, i, lastValue, lastPosition) <= 0) {
continue; // 前面批次已输出的记录
}
heap.offerIfSmall(value, i);
}
}
if (heap.size == 0) {
throw new IllegalStateException("没有找到剩余数据,输入文件可能发生变化");
}
// 大根堆的 poll() 是降序,先使用原地堆排序得到升序。
heap.sortAscending();
for (int i = 0; i < heap.size; i++) {
out.writeInt(heap.values[i]);
}
emitted += heap.size;
lastValue = heap.values[heap.size - 1];
lastPosition = heap.positions[heap.size - 1];
hasBoundary = true;
}
}
}
// 方法三:外部归并排序(External Merge Sort)。
// 1. 流式读取每块 chunkInts 个整数,在内存用 Arrays.sort 排序后写成有序分片。
// 2. 每次从每个有序分片读取一个整数,用小根堆执行多路归并。
// 超过64个分片时,先分组归并,再做最终归并,限制文件句柄数量。
// 3. 专用中间目录是 output 文件旁的 <输出文件名>.merge_work;创建时必须不存在。
// 成功后删除自己生成的中间文件;失败时保留分片以供检查,不覆盖已有文件。
public static void sortByExternalMerge(Path input, Path output, int chunkInts)
throws IOException {
checkDifferentFiles(input, output);
long n = numberCount(input);
if (chunkInts <= 0 || chunkInts > (1 << 27)) {
throw new IllegalArgumentException("chunkInts应在1到2^27之间(单块最多512MiB)");
}
if (Files.exists(output)) {
throw new java.nio.file.FileAlreadyExistsException(output.toString());
}
if (n == 0) {
try (DataOutputStream ignored = openNewOutput(output)) {
// 输入为空,输出也是空文件。
}
return;
}
Path workDir = output.resolveSibling(output.getFileName().toString() + ".merge_work");
// createDirectory:目录已经存在时直接报错,绝不清空或复用未知目录。
Files.createDirectory(workDir);
List<Path> createdRuns = new ArrayList<>();
boolean completed = false;
try {
int[] block = new int[chunkInts];
List<Path> runs = new ArrayList<>();
try (DataInputStream in = openInput(input)) {
long remaining = n;
int index = 0;
while (remaining > 0) {
int len = (int) Math.min((long) chunkInts, remaining);
for (int i = 0; i < len; i++) {
block[i] = in.readInt();
}
Arrays.sort(block, 0, len);
Path run = workDir.resolve(String.format("run_%05d.bin", index++));
createdRuns.add(run);
try (DataOutputStream out = openNewOutput(run)) {
for (int i = 0; i < len; i++) {
out.writeInt(block[i]);
}
}
runs.add(run);
remaining -= len;
}
}
// 已将所有内存分块转化为磁盘有序分片,释放大数组空间引用。
block = null;
int pass = 0;
while (runs.size() > MAX_MERGE_WAYS) {
List<Path> nextRuns = new ArrayList<>();
for (int begin = 0; begin < runs.size(); begin += MAX_MERGE_WAYS) {
int end = Math.min(begin + MAX_MERGE_WAYS, runs.size());
Path merged = workDir.resolve(String.format(
"pass_%02d_group_%05d.bin", pass, begin / MAX_MERGE_WAYS));
createdRuns.add(merged);
mergeSortedRuns(runs.subList(begin, end), merged);
nextRuns.add(merged);
}
runs = nextRuns;
pass++;
}
mergeSortedRuns(runs, output);
if (numberCount(output) != n) {
throw new IOException("归并输出数量不一致");
}
completed = true;
} finally {
if (completed) {
for (Path run : createdRuns) {
Files.deleteIfExists(run);
}
Files.delete(workDir);
}
}
}
private static class RunHead {
final int value;
final int runIndex;
RunHead(int value, int runIndex) {
this.value = value;
this.runIndex = runIndex;
}
}
// 多路归并:每个输入分片至多一个整数在小根堆中,堆大小<=分片数。
private static void mergeSortedRuns(List<Path> runs, Path output) throws IOException {
DataInputStream[] streams = new DataInputStream[runs.size()];
long[] left = new long[runs.size()];
PriorityQueue<RunHead> minHeap = new PriorityQueue<>(
Comparator.<RunHead>comparingInt(h -> h.value)
.thenComparingInt(h -> h.runIndex));
try {
for (int i = 0; i < runs.size(); i++) {
left[i] = numberCount(runs.get(i));
streams[i] = openInput(runs.get(i));
if (left[i] > 0) {
minHeap.offer(new RunHead(streams[i].readInt(), i));
left[i]--;
}
}
try (DataOutputStream out = openNewOutput(output)) {
while (!minHeap.isEmpty()) {
RunHead smallest = minHeap.poll();
out.writeInt(smallest.value);
int i = smallest.runIndex;
if (left[i] > 0) {
minHeap.offer(new RunHead(streams[i].readInt(), i));
left[i]--;
}
}
}
} finally {
IOException failure = null;
for (DataInputStream in : streams) {
if (in != null) {
try {
in.close();
} catch (IOException e) {
if (failure == null) failure = e;
else failure.addSuppressed(e);
}
}
}
if (failure != null) throw failure;
}
}
// 仅用于小数据演示:创建10万个有大量重复值的整数,存放在项目目录。
private static void createDemoInputIfAbsent(Path input) throws IOException {
if (Files.exists(input)) {
return;
}
Random random = new Random(20261016L);
try (DataOutputStream out = openNewOutput(input)) {
for (int i = 0; i < 100_000; i++) {
out.writeInt(random.nextInt(2001) - 1000);
}
}
}
private static void verifyDemoOutput(Path input, Path output) throws IOException {
int[] expected = new int[(int) numberCount(input)];
try (DataInputStream in = openInput(input)) {
for (int i = 0; i < expected.length; i++) {
expected[i] = in.readInt();
}
}
Arrays.sort(expected);
if (numberCount(output) != expected.length) {
throw new AssertionError("输出元素总数不正确:" + output);
}
try (DataInputStream in = openInput(output)) {
for (int i = 0; i < expected.length; i++) {
int actual = in.readInt();
if (actual != expected[i]) {
throw new AssertionError("第" + i + "项不匹配:应为"
+ expected[i] + ",实际为" + actual);
}
}
}
System.out.println("验证通过:" + output.getFileName()
+ ",共" + expected.length + "条整数");
}
private static void runDemo() throws IOException {
Path dir = Paths.get("data", "class013", "external_sort");
Files.createDirectories(dir);
Path input = dir.resolve("demo_input_100000.bin");
Path outRange = dir.resolve("demo_range_hash_minheap_sorted.bin");
Path outHeap = dir.resolve("demo_heap_sorted.bin");
Path outMerge = dir.resolve("demo_merge_sorted.bin");
createDemoInputIfAbsent(input);
// 只检查 [-1000, 1000],避免为了演示而扫描完整 2^32 的值域。
// 方法一在本测试中只记录实际出现的值及其词频;方法二容量4000,扫描25轮。
if (!Files.exists(outRange)) {
sortByValueRange(input, outRange, -1000, 1001, 512);
}
if (!Files.exists(outHeap)) {
sortByBatchMaxHeap(input, outHeap, 4000);
}
if (!Files.exists(outMerge)) {
// 100000/1024约98个分片,演示超出64路时的分组多轮归并。
sortByExternalMerge(input, outMerge, 1024);
}
verifyDemoOutput(input, outRange);
verifyDemoOutput(input, outHeap);
verifyDemoOutput(input, outMerge);
if (Files.mismatch(outRange, outHeap) != -1
|| Files.mismatch(outRange, outMerge) != -1) {
throw new AssertionError("三种算法的有序输出不一致");
}
System.out.println("三种算法的输出文件逐字节相同");
System.out.println("测试文件目录:" + dir.toAbsolutePath());
}
public static void main(String[] args) throws IOException {
if (args.length == 0 || (args.length == 1 && "demo".equals(args[0]))) {
runDemo();
} else if (args.length == 3 && "range".equals(args[0])) {
sortByValueRange(Paths.get(args[1]), Paths.get(args[2]),
Integer.MIN_VALUE, (long) Integer.MAX_VALUE + 1,
REAL_RANGE_WIDTH);
System.out.println("值域分段排序完成:" + Paths.get(args[2]).toAbsolutePath());
} else if ((args.length == 3 || args.length == 4) && "heap".equals(args[0])) {
int k = args.length == 4 ? Integer.parseInt(args[3]) : REAL_HEAP_K;
sortByBatchMaxHeap(Paths.get(args[1]), Paths.get(args[2]), k);
System.out.println("大根堆分批排序完成:" + Paths.get(args[2]).toAbsolutePath());
} else if ((args.length == 3 || args.length == 4) && "merge".equals(args[0])) {
int chunkInts = args.length == 4 ? Integer.parseInt(args[3]) : REAL_MERGE_CHUNK_INTS;
sortByExternalMerge(Paths.get(args[1]), Paths.get(args[2]), chunkInts);
System.out.println("外部归并排序完成:" + Paths.get(args[2]).toAbsolutePath());
} else {
throw new IllegalArgumentException(
"用法:demo | range <输入.bin> <输出.bin> | heap <输入.bin> <输出.bin> [K]"
+ " | merge <输入.bin> <输出.bin> [chunkInts]");
}
}
}
运行结果:
java
验证通过:demo_range_sorted.bin,共100000条整数
验证通过:demo_heap_sorted.bin,共100000条整数
验证通过:demo_merge_sorted.bin,共100000条整数
三种算法的输出文件逐字节相同
测试文件目录:D:\Learn\Java\workspace\project1\untitled1\data\class013\external_sort
问题条件:
-
原始文件大小:10GiB。
-
整数总数:约26.84亿个
int。 -
内存限制:5GiB。
-
输出文件大小:10GiB。
-
方法一:每个值域区间包含 2^{27} 种可能的整数,共32个区间。
-
方法二:大根堆容量为 2^{27}。
-
方法三:每次读取256MiB数据,生成40个有序分片。
三种算法对比:
| 指标 | 方法一:值域分段 + Hash + 小根堆 | 方法二:大根堆分批 | 方法三:外部归并排序 |
|---|---|---|---|
| 核心数据结构 | Hash表 + 小根堆 | 大根堆 | int[] + 小根堆 |
| 分批依据 | 整数数值范围 | 排序后的排名 | 输入文件大小 |
| 每轮处理 | 统计当前区间实际出现的数字及词频 | 选出尚未输出的最小K个整数 | 内存分块排序或多路归并 |
| 核心内存占用 | 随不同整数数量变化;按16B/记录估算上限约2GiB | 约1.5GiB(紧凑数组实现) | 约256MiB + 归并缓冲 |
| 原文件扫描次数 | 32次 | 20次 | 1次 |
| 原文件读取量 | 320GiB | 200GiB | 10GiB |
| 中间文件读取量 | 0 | 0 | 10GiB |
| 总磁盘I/O | 约330GiB | 约210GiB | 约40GiB |
| 额外临时磁盘空间 | 不需要 | 不需要 | 约10GiB |
| 是否遍历全部 \(2^{32}\) 种整数 | 否 | 否 | 否 |
| 重复数字处理 | Hash统计词频 | 原始位置区分 | 自然保留 |
| 是否适用于其他数据类型 | 主要适合可划分值域的数据 | 可以扩展 | 容易扩展 |
| 实际大文件排序适用性 | 值域明确、适合分段统计 | 理论可行,但反复扫描开销大 | 通常更合适 |
三种算法的时间复杂度
设:
-
N:原始文件中整数总数量。
-
P=32:方法一的值域区间数量。
-
Ui:方法一第 i 个区间实际出现的不同整数数量。
-
K:方法二的大根堆容量。
-
B:方法三每块包含的整数数量。
-
R:方法三生成的有序分片数量。
方法一:值域分段 + Hash + 小根堆
每轮扫描原始文件,使用Hash统计当前区间实际出现的整数及词频,再通过小根堆按升序输出。
其中:
-
扫描32轮文件:O(32N)。
-
Hash统计词频:平均总计 O(N)。
-
小根堆排序:
-
输出全部整数:O(N)。
不包含额外的 O(2^{32}) 值域遍历。 算法只对实际出现的整数执行Hash统计和堆操作。
方法二:大根堆分批
每轮扫描原始文件,使用容量为K的大根堆保留尚未输出的最小K条记录,排序后输出,再进入下一轮。
需要的轮数:
保守时间复杂度:
本题中:
因此需要约20轮扫描。
重复整数必须使用 (value, position) 等方式区分,避免不同批次遗漏或重复输出。
方法三:外部归并排序
第一阶段:每次读取B个整数,在内存中排序,生成若干有序分片。
第二阶段:使用小根堆进行R路归并。
如果所有分片能够一次归并,时间复杂度约为:
其中:
-
分块排序:O(Nlog B)。
-
小根堆多路归并:O(Nlog R)。
本题生成40个分片,可以采用一次40路归并,不需要反复扫描原始文件。
6.位运算比较
之前介绍过一些,下面继续
给定两个有符号32位整数a和b,返回a和b中较大的。
【要求】
不用做任何比较判断。
代码:
java
package Class013;
public class Code_GetMax {
//请保证参数n,不是1就是0的情况下
//1->0
//0->1
public static int flip(int n){
return n^1;
}
//n是非负数,返回1
//n是负数,返回0
public static int sign(int n){
//n右移31位,那么n的符号位就跑到了最右侧的位置,再与1相与
return flip((n>>31)&1);
}
//不考虑溢出的情况
public static int getMax1(int a,int b){
int c=a-b;
int scA=sign(c);//a-b为非负,scA为1;a-b为负,scA为0
int scB=flip(scA);//scA为0,scB为1;scA为1,scB为0
//scA为0,scB必为1;scA为1,scB必为0
return a*scA+b*scB;
}
//充分考虑溢出的情况
public static int getMax2(int a,int b){
int c=a-b;
int sa=sign(a);
int sb=sign(b);
int sc=sign(c);
int difSab=sa^sb;//a和b的符号不一样,为1:一样,为0
int sameSab=flip(difSab);//a和b的符号一样,为1:不一样,为0
int returnA=difSab*sa+sameSab*sc;
int returnB=flip(returnA);
return a*returnA+b*returnB;
}
public static void main(String []args){
int a=-16;
int b=1;
System.out.println(getMax1(a,b));
System.out.println(getMax2(a,b));
a=2147483647;
b=-2147480000;
System.out.println(getMax1(a,b));
System.out.println(getMax2(a,b));
}
}
运行结果:
java
1
1
-2147480000
2147483647
7.判断幂次
判断一个32位正数是不是2的幂,4的幂
分析:在2进制中,2^0表示为00000001,2^1表示为00000010,2^2表示为00000100,2^3表示为00001000。对于32位的数,我们只要取2进制下最右侧的1即可,如果最右侧1后面。如果x只有一个1,x-1后会把1打散,比如2^0-1表示为00000000,2^1表示为00000001,2^2表示为00000011,2^3表示为00000111。此时x&(x-1)==0。
在4进制中,首先判断x是否只有一个1(是否满足二进制特征)。接着判断是否满足4进制的特征:4^0表示为00000001,4^1表示为00000100,4^2表示为00010000,4^3表示为01000000。其中1出现在0,2,4,6位上。接着我们让x&010101......01,如果x&010101......01!=0,则是4的某个幂,如果x&010101......01=0,则不是4的某个幂。
代码:
java
package Class013;
public class Code_Power {
public static boolean is2Power(int n) {
return n > 0 && (n & (n - 1)) == 0;
}
public static boolean is4Power(int n) {
return n > 0
&& (n & (n - 1)) == 0
&& (n & 0x55555555) != 0;
}
public static void main(String[] args) {
int[] arr = {0, 1, 2, 3, 4, 8, 16, 32, 64, 100, 256, 1024};
for (int n : arr) {
System.out.println(
n + ":是否为2的幂:" + is2Power(n)
+ ",是否为4的幂:" + is4Power(n)
);
}
System.out.println("===========================");
int n1=3456;
System.out.println(
n1 + ":是否为2的幂:" + is2Power(n1)
+ ",是否为4的幂:" + is4Power(n1)
);
}
}
运行结果:
java
0:是否为2的幂:false,是否为4的幂:false
1:是否为2的幂:true,是否为4的幂:true
2:是否为2的幂:true,是否为4的幂:false
3:是否为2的幂:false,是否为4的幂:false
4:是否为2的幂:true,是否为4的幂:true
8:是否为2的幂:true,是否为4的幂:false
16:是否为2的幂:true,是否为4的幂:true
32:是否为2的幂:true,是否为4的幂:false
64:是否为2的幂:true,是否为4的幂:true
100:是否为2的幂:false,是否为4的幂:false
256:是否为2的幂:true,是否为4的幂:true
1024:是否为2的幂:true,是否为4的幂:true
===========================
3456:是否为2的幂:false,是否为4的幂:false
8.不用算数运算符实现加减乘除
给定两个有符号32位整数a和b,不能使用算术运算符,分别实现a和b的加、减、乘、除运算。
【要求】
如果给定a、b执行加减乘除的运算结果就会导致数据的溢出,那么你实现的函数不必对此负责,除此之外请保证计算过程不发生溢出。
相关基础知识,见王道的计算机组成原理的补码和移码相关课程。
分析:
java
一、加法:13 + 7
13 = 01101
7 = 00111
第一轮:
a = 01101
b = 00111
无进位相加:
a ^ b = 01010
进位:
(a & b) << 1 = 01010
更新:
a = 01010
b = 01010
第二轮:
a = 01010
b = 01010
无进位相加:
a ^ b = 00000
进位:
(a & b) << 1 = 10100
更新:
a = 00000
b = 10100
第三轮:
a = 00000
b = 10100
无进位相加:
a ^ b = 10100
进位:
(a & b) << 1 = 00000
更新:
a = 10100
b = 00000
此时b=0,进位消失,结束循环。
最终结果:
10100 = 20
二、减法:13 - 7
减法转换为加法:
13 - 7 = 13 + (-7)
其中:
-7 = ~7 + 1
使用8位二进制演示。
第一步:计算-7的补码
7 = 00000111
按位取反:
~7 = 11111000
加1:
11111000
00000001
--------
11111001
所以:
-7 = 11111001
第二步:计算13 + (-7)
00001101
+ 11111001
----------
1 00000110
丢弃超出8位的最高进位:
00000110 = 6
使用位运算模拟这一过程:
第一轮:
a = 00001101
b = 11111001
a ^ b = 11110100
(a & b)<<1 = 00010010
更新:
a = 11110100
b = 00010010
第二轮:
a ^ b = 11100110
(a & b)<<1 = 00100000
更新:
a = 11100110
b = 00100000
第三轮:
a ^ b = 11000110
(a & b)<<1 = 01000000
更新:
a = 11000110
b = 01000000
第四轮:
a ^ b = 10000110
(a & b)<<1 = 10000000
更新:
a = 10000110
b = 10000000
第五轮:
a ^ b = 00000110
(a & b)<<1 = 00000000
进位为0,结束。
最终结果:
00000110 = 6
三、乘法:13 × 7
13 = 01101
7 = 00111
初始化:
a = 13
b = 7
res = 0
乘数7的二进制为00111。
7 = 2^2 + 2^1 + 2^0
所以:
13 × 7 = 13 + 26 + 52
第一轮:
a = 13 = 01101
b = 7 = 00111
res = 0
判断乘数最低位:
b & 1 = 1
最低位为1,需要累加当前部分积。
res = add(0,13) = 13
被乘数左移:
a <<= 1
a = 26 = 11010
乘数无符号右移:
b >>>= 1
b = 3 = 00011
第二轮:
a = 26 = 11010
b = 3 = 00011
res = 13
b & 1 = 1
res = add(13,26) = 39
被乘数左移:
a = 52 = 110100
乘数无符号右移:
b = 1 = 00001
第三轮:
a = 52 = 110100
b = 1 = 000001
res = 39
b & 1 = 1
res = add(39,52) = 91
被乘数左移:
a = 104
乘数无符号右移:
b = 0
循环结束。
二进制竖式:
0001101
0011010
0110100
-------
1011011
最终结果:
1011011 = 91
四、除法:29 ÷ 5
29 = 11101
5 = 00101
初始化:
x = 29
y = 5
res = 0
从商的最高位向最低位试探。
判断条件:
(x >> i) >= y
条件成立:
商的第i位置1,并减去y << i。
条件不成立:
商的第i位保持0。
当i >= 3时:
29 >> 3 = 3
3 < 5
因此i=3以及更高的商位全部为0。
第一轮有效试探,i=2:
x = 29
y = 5
x >> 2 = 7
7 >= 5,成立。
商的第2位置1:
res |= (1 << 2)
res = 00100 = 4
计算对应部分积:
y << 2 = 5 × 4 = 20
更新剩余被除数:
x = minus(29,20) = 9
第二轮,i=1:
x = 9
y = 5
x >> 1 = 4
4 < 5,不成立。
商的第1位保持0。
res = 00100 = 4
x = 9
第三轮,i=0:
x = 9
y = 5
x >> 0 = 9
9 >= 5,成立。
商的第0位置1:
res |= (1 << 0)
res = 00101 = 5
计算对应部分积:
y << 0 = 5
更新剩余被除数:
x = minus(9,5) = 4
所有商位判断完成:
res = 00101 = 5
x = 4
所以:
29 = 5 × 5 + 4
商 = 5
余数 = 4
最终整数除法结果:
29 / 5 = 5
五、除法特殊情况:Integer.MIN_VALUE / 3
a = -2147483648
b = 3
由于:
Integer.MIN_VALUE = -2147483648
其相反数2147483648超过int最大值,
因此不能直接计算绝对值。
第一步:被除数加1
a + 1 = -2147483647
第二步:先执行普通除法
res = div(-2147483647,3)
得到:
res = -715827882
第三步:计算已经处理的部分
multi(res,b)
= -715827882 × 3
= -2147483646
第四步:计算剩余量
remainder = minus(a,multi(res,b))
= -2147483648 - (-2147483646)
= -2
第五步:计算修正商
correction = div(-2,3)
由于整数除法向0截断:
correction = 0
第六步:累加修正结果
answer = add(res,correction)
= -715827882 + 0
= -715827882
最终结果:
-2147483648 / 3 = -715827882
代码:
java
package Class013;
public class Code_AddMinusMultDivideByBit {
// 1. 加法:异或求无进位和,与运算左移求进位
public static int add(int a, int b) {
int sum = a;
while (b != 0) {
sum = a ^ b; // 无进位相加
b = (a & b) << 1; // 产生进位
a = sum; // 下一轮继续加进位
}
return sum;
}
// 2. 求相反数:补码 = 按位取反 + 1
public static int negNum(int n) {
return add(~n, 1);
}
// 3. 减法:a-b = a+(-b)
public static int minus(int a, int b) {
return add(a, negNum(b));
}
// 4. 乘法:二进制移位累加
public static int multi(int a, int b) {
int res = 0;
while (b != 0) {
// 当前乘数最低位为1,需要累加部分积
if ((b & 1) != 0) {
res = add(res, a);
}
// 被乘数左移一位
a <<= 1;
// 乘数无符号右移
// 避免负数右移持续补1
b >>>= 1;
}
return res;
}
// 5. 判断是否为负数
public static boolean isNeg(int n) {
return n < 0;
}
// 6. 二进制长除法
// 只用于a、b不是MIN_VALUE且b不为0的情况
public static int div(int a, int b) {
// 先转换为非负数
int x = isNeg(a) ? negNum(a) : a;
int y = isNeg(b) ? negNum(b) : b;
int res = 0;
// 从商的最高位开始试探
for (int i = 31; i > -1; i = minus(i, 1)) {
// 判断x是否至少包含y*2^i
// 右移x比较,避免先左移y造成溢出
if ((x >> i) >= y) {
// 商的第i位置1
res |= (1 << i);
// 从剩余被除数中扣除对应部分
x = minus(x, y << i);
}
}
// 根据原始操作数符号决定商的符号
return isNeg(a) ^ isNeg(b)
? negNum(res)
: res;
}
// 7. 完整除法:处理最小整数等特殊情况
public static int divide(int a, int b) {
if (b == 0) {
throw new RuntimeException("divisor is 0");
}
if (a == Integer.MIN_VALUE
&& b == Integer.MIN_VALUE) {
return 1;
} else if (b == Integer.MIN_VALUE) {
// 此时a不是MIN_VALUE
// |a| < |b|,商只能为0
return 0;
} else if (a == Integer.MIN_VALUE) {
// 先使用a+1,规避最小整数无法取绝对值
int res = div(add(a, 1), b);
// 计算剩余部分,并对商进行修正
int remainder = minus(a, multi(res, b));
return add(res, div(remainder, b));
} else {
return div(a, b);
}
}
// 8. 测试函数
// 测试代码允许使用原生算术运算符验证结果
public static void main(String[] args) {
int[][] tests = {
{13, 7},
{29, 5},
{-13, 7},
{13, -7},
{-13, -7},
{0, 7},
{7, 0},
{1, 1},
{-1, -1},
{Integer.MAX_VALUE, 1},
{Integer.MIN_VALUE, 1},
{Integer.MIN_VALUE, 2},
{Integer.MIN_VALUE, -3},
{Integer.MIN_VALUE, Integer.MIN_VALUE},
{Integer.MAX_VALUE, Integer.MIN_VALUE},
{Integer.MIN_VALUE, -1}
};
for (int[] test : tests) {
int a = test[0];
int b = test[1];
System.out.println("\na=" + a + ", b=" + b);
System.out.println("加法:" + add(a, b));
System.out.println("减法:" + minus(a, b));
System.out.println("乘法:" + multi(a, b));
if (b != 0) {
System.out.println("除法:" + divide(a, b));
} else {
System.out.println("除法:除数为0");
}
}
}
}
运行结果:
java
a=13, b=7
加法:20
减法:6
乘法:91
除法:1
a=29, b=5
加法:34
减法:24
乘法:145
除法:5
a=-13, b=7
加法:-6
减法:-20
乘法:-91
除法:-1
a=13, b=-7
加法:6
减法:20
乘法:-91
除法:-1
a=-13, b=-7
加法:-20
减法:-6
乘法:91
除法:1
a=0, b=7
加法:7
减法:-7
乘法:0
除法:0
a=7, b=0
加法:7
减法:7
乘法:0
除法:除数为0
a=1, b=1
加法:2
减法:0
乘法:1
除法:1
a=-1, b=-1
加法:-2
减法:0
乘法:1
除法:1
a=2147483647, b=1
加法:-2147483648
减法:2147483646
乘法:2147483647
除法:2147483647
a=-2147483648, b=1
加法:-2147483647
减法:2147483647
乘法:-2147483648
除法:-2147483648
a=-2147483648, b=2
加法:-2147483646
减法:2147483646
乘法:0
除法:-1073741824
a=-2147483648, b=-3
加法:2147483645
减法:-2147483645
乘法:-2147483648
除法:715827882
a=-2147483648, b=-2147483648
加法:0
减法:0
乘法:0
除法:1
a=2147483647, b=-2147483648
加法:-1
减法:-1
乘法:-2147483648
除法:0
a=-2147483648, b=-1
加法:2147483647
减法:-2147483647
乘法:-2147483648
除法:-2147483648