目录

题目描述

149. 直线上最多的点数

题意分析

给一堆平面整点,问「存在一条直线,最多能同时穿过其中几个点」,返回这个最大数量。注意直线是任意的,不限于水平或竖直,也不需要输出直线本身。

约束里点数很小(数百量级),坐标是绝对值上万的整数。点数小意味着允许平方级枚举;坐标是整数则是一个强信号——两点之间的横纵差值都是整数,这条直线的方向完全可以用一对整数来精确描述,不必引入实数。

这里有个必须点明的坑:直觉上会想用 dy / dx 这个浮点斜率当哈希键,但浮点数除法带来舍入误差,两条本该相同的斜率算出来可能差在最后几位有效数字上,被当成两条不同的直线;反过来两条略有差异的斜率也可能舍入成同一个值。正确做法是用最简分数表示方向:把 (dy, dx) 同时除以它们的最大公约数,再统一符号,得到唯一的整数对作为键。整个过程只用整数运算,没有任何精度损失。

边界包括:竖直线(dx 为 0,不能做除法)、水平线(dy 为 0)、负方向((1, 2)(-1, -2) 是同一条直线的方向,必须归一化成同一个键)、题目允许存在重复点(同一坐标出现多次,它们与任何直线的关系都一样,要单独计数)、以及总点数不超过 2 时答案就是点数本身,因为任意两点必定共线。

解法:枚举基准点 + 方向向量规范化

核心思路

一条直线由两个点确定。枚举每个点 i 作为基准点,统计其他点 j 相对它的方向;方向相同的点与基准点共线,最大分组大小加上基准点本身,就是经过 i 的最多点数。

不能直接用浮点斜率 dy / dx:除零需要特判,且不同分数可能因精度舍入碰撞。应把方向表示为整数对 (dx, dy),再除以 gcd(abs(dx), abs(dy)) 约分。

约分后还要统一符号,否则 (1,1)(-1,-1) 会被分到不同组。本文规定 dx 为正;当 dx == 0 时规定 dy 为正。这样水平线统一成 (1,0),竖直线统一成 (0,1),每个几何方向只有一种编码。

对固定基准点,哈希表计数的是“其他点”的数量,所以更新全局答案时要加 1。题目保证点互不相同;若取消该条件,还需单独统计与基准点重合的点。

解题步骤

  1. 枚举基准点 i,为它创建方向计数表。
  2. 枚举 j > i,计算 dx = xj - xidy = yj - yi
  3. 用最大公约数约分 dxdy
  4. dx < 0,或 dx == 0 && dy < 0,同时翻转二者符号。
  5. 将规范化后的整数对作为哈希键并累加计数,用“计数 + 1”更新答案。

例如相对基准点的方向 (2,2)(-3,-3) 都会规范化为 (1,1);方向 (0,-4) 会规范化为 (0,1)

代码实现

import java.util.HashMap;
import java.util.Map;

class Solution {
    public int maxPoints(int[][] points) {
        int answer = 1;
        for (int i = 0; i < points.length; i++) {
            Map<String, Integer> count = new HashMap<>();
            for (int j = i + 1; j < points.length; j++) {
                int dx = points[j][0] - points[i][0];
                int dy = points[j][1] - points[i][1];
                int divisor = gcd(Math.abs(dx), Math.abs(dy));
                dx /= divisor;
                dy /= divisor;

                if (dx < 0 || (dx == 0 && dy < 0)) {
                    dx = -dx;
                    dy = -dy;
                }

                String direction = dx + "," + dy;
                int sameDirection = count.getOrDefault(direction, 0) + 1;
                count.put(direction, sameDirection);
                answer = Math.max(answer, sameDirection + 1);
            }
        }
        return answer;
    }

    private int gcd(int a, int b) {
        while (b != 0) {
            int remainder = a % b;
            a = b;
            b = remainder;
        }
        return a;
    }
}
func maxPoints(points [][]int) int {
	answer := 1
	for i := 0; i < len(points); i++ {
		count := make(map[[2]int]int)
		for j := i + 1; j < len(points); j++ {
			dx := points[j][0] - points[i][0]
			dy := points[j][1] - points[i][1]
			divisor := gcd(abs(dx), abs(dy))
			dx /= divisor
			dy /= divisor

			if dx < 0 || dx == 0 && dy < 0 {
				dx = -dx
				dy = -dy
			}

			direction := [2]int{dx, dy}
			count[direction]++
			if count[direction]+1 > answer {
				answer = count[direction] + 1
			}
		}
	}
	return answer
}

func gcd(a, b int) int {
	for b != 0 {
		a, b = b, a%b
	}
	return a
}

func abs(value int) int {
	if value < 0 {
		return -value
	}
	return value
}

复杂度分析

  • 时间复杂度:$O(n^2 \log C)$,其中 $C$ 是坐标差的最大值;每对点计算一次最大公约数。坐标范围固定时通常记为 $O(n^2)$。
  • 空间复杂度:$O(n)$,固定一个基准点时,哈希表最多保存 $n-1$ 个方向。

关键点总结

  • 枚举基准点后,二维共线问题转化为方向分组计数。
  • 用约分后的整数对表示方向,避免浮点精度和竖直线除零问题。
  • 最大公约数只消除倍数,符号规范化负责合并相反向量。
  • 哈希表不含基准点自身,因此局部计数要加 1。

易错点总结

  • 使用 double 斜率可能把不同方向误判为相同,也必须额外处理 dx == 0
  • 只做最大公约数约分、不统一符号,会把 (1,1)(-1,-1) 分开。
  • 只统一 dx < 0 而漏掉竖直方向,会把 (0,1)(0,-1) 分开。
  • 局部方向计数没有包含基准点,直接拿它更新答案会少算 1。
  • 若输入允许重复点,dx == 0 && dy == 0 会使最大公约数为 0,必须另计重合点;本题的点互不相同,因此无需该分支。

相似题目

题目 难度 考察点
1232. 缀点成线 简单 单条直线的共线判定
面试题 16.14. 最佳直线 中等 同时要求返回直线上的点
447. 回旋镖的数量 中等 按距离分组的点对统计
593. 有效的正方形 中等 整数运算避免开方误差
1071. 字符串的最大公因子 简单 辗转相除法的另一类应用