Try an interactive version of this dialog: Sign up at solve.it.com, click Upload, and pass this URL.

Code: 60 ()

import sys
sys.path.append('.')
from leet_utils import lc_setup, get_leetcode_problem, lc_export
lc_setup("https://leetcode.com/problems/3sum/", add_cells=False)

Output: 31

✓ Problem loaded: 15. 3Sum

Note: 10

15. 3Sum

Note: 24

  • URL: https://leetcode.com/problems/3sum/
  • Difficulty: Medium

Code: 42 ()

from IPython.display import HTML
problem = get_leetcode_problem("https://leetcode.com/problems/3sum/")
HTML(problem["content"])

Output: 759

Given an integer array nums, return all the triplets [nums[i], nums[j], nums[k]] such that i != j, i != k, and j != k, and nums[i] + nums[j] + nums[k] == 0.

Notice that the solution set must not contain duplicate triplets.

 

Example 1:

Input: nums = [-1,0,1,2,-1,-4]
Output: [[-1,-1,2],[-1,0,1]]
Explanation: 
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0.
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0.
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0.
The distinct triplets are [-1,0,1] and [-1,-1,2].
Notice that the order of the output and the order of the triplets does not matter.

Example 2:

Input: nums = [0,1,1]
Output: []
Explanation: The only possible triplet does not sum up to 0.

Example 3:

Input: nums = [0,0,0]
Output: [[0,0,0]]
Explanation: The only possible triplet sums up to 0.

 

Constraints:

  • 3 <= nums.length <= 3000
  • -105 <= nums[i] <= 105

Note: 6

Step 1

Note: 229

実装1

  • アルゴリズムの選択
    • 全探索だとO(n^3)。Hashmapを使うことでO(n^2)はできそう。
    • 重複なく数え上げるのが難しく、ナイーブにはset()に重複も許して詰めていき、最後にlistにしてreturn。
    • これ以上良い方法は思いつかない。
  • 実装
    • 等号成立の個数によって場合分けする。
  • 計算量
    • Time: O(n^2)
    • Extra Space: O(n)
    • Output Space: O(n^2)

Code: 387 ()

import collections
import itertools


class Solution:
    def threeSum(self, nums: list[int]) -> list[list[int]]:
        result = set()
        num_to_count = collections.Counter(nums)

        # num1 != num2, num1 != num3, and num2 != num3
        for num1, num2 in itertools.combinations(num_to_count.keys(), 2):
            num3 = -(num1 + num2)
            if num1 == num3 or num2 == num3:
                continue
            if num3 in num_to_count:
                result.add(tuple(sorted([num1, num2, num3])))
        
        # num1 == num2, num1 != num3
        for num in num_to_count.keys():
            if num_to_count[num] < 2:
                continue
            num3 = -num * 2
            if num == num3:
                continue
            if num3 in num_to_count.keys():
                result.add(tuple(sorted([num, num, num3])))
        
        # num1 == num2 == num3
        if num_to_count.get(0, 0) >= 3:
            result.add((0, 0, 0))
        
        return list(result)

Note: 100

  • 振り返り
    • 思考もコードもごちゃごちゃしている。
    • どうせ時間計算量はO(n^2)なので、num_to_count.keys()は最初にソートした方が計算量で得であり、見通しも良かった。

Note: 6

Step 2

Prompt: 24

Step 1のコードをレビューして、改善点を教えてください。

Assistant: 240

コードを拝見しました。ロジックは正しく動作していますし、計算量の分析も正確ですね!

ご自身で「思考もコードもごちゃごちゃしている」と振り返られていますが、具体的にどの部分が気になっていますか?

一つ気づいた点を挙げると、3つのケース(全部異なる、2つ同じ、3つ同じ)に分けているところですが、この場合分けを減らすアプローチがあるかもしれません。

ヒント:配列をソートしてから処理すると、重複の扱いがシンプルになることがあります。このアプローチについて考えてみたいですか?

Note: 250

実装2

  • アルゴリズムの検討
    • 最初にnum_to_count.keys()をソートしておくと見通しがよくなる。
    • 具体的には、num1 < num2 < num3の関係性を担保しやすくなる。
      • unique_numsから狭義昇順な全てのペアnum1, num2 (num1 < num2)を回す。
      • なのでnum2 < num3だけ確かめるようにすればnum1 < num2 < num3は保証される。
  • 計算量
    • Time: O(n^2)
    • Extra Space: O(n)
    • Output Space: O(n^2)

Code: 360 ()

import collections
import itertools


class Solution:
    def threeSum(self, nums: list[int]) -> list[list[int]]:
        result = []
        num_to_count = collections.Counter(nums)
        unique_nums = sorted(num_to_count)

        # num1 < num2 < num3
        for num1, num2 in itertools.combinations(unique_nums, 2):
            num3 = -(num1 + num2)
            if num3 <= num2:
                continue
            if num3 in num_to_count:
                result.append([num1, num2, num3])
        
        # num1 == num2, num1 != num3
        for num in unique_nums:
            if num_to_count[num] < 2:
                continue
            num3 = -num * 2
            if num == num3:
                continue
            if num3 in num_to_count:
                result.append(sorted([num, num, num3]))
        
        # num1 == num2 == num3
        if num_to_count.get(0, 0) >= 3:
            result.append([0, 0, 0])
        
        return result

Prompt: 4

こうか?

Assistant: 258

良い改善です!

主な変更点:

  • set()list に変更
  • sorted(num_to_count) で事前ソート
  • num3 <= num2 のチェックで重複を防ぐ

これにより、最初のケースでは tuple(sorted(...)) が不要になり、シンプルになりましたね。

ただ、3つのケースに分ける構造自体はまだ残っていますね。ご自身で「ごちゃごちゃ」と感じていたのはこの部分でしょうか?

もし興味があれば、Two-pointer(2つのポインタ)というアプローチがあります。これを使うと、ケース分けなしで1つのループ構造で書けます。試してみたいですか?

Prompt: 7

試してみたい。

Assistant: 366

Two-pointerアプローチの基本的な考え方を説明しますね。

手順:

  1. 配列をソートする
  2. 各要素 nums[i] を固定して、残りの部分で「2つの数の和が -nums[i] になる組」を探す
  3. その探索に two-pointer を使う:左端(left)と右端(right)から始めて、和が大きすぎれば right を減らし、小さすぎれば left を増やす

重複スキップのポイント:

  • nums[i] が前の値と同じなら、スキップ
  • leftright も同様に重複をスキップ

まずは、ソート済み配列で「2つの数の和が target になる組を全て見つける」two-pointer の関数を書いてみてください。それができたら、3Sumに拡張しましょう。

何か質問はありますか?

Note: 186 ()

実装3

  • アルゴリズムの再考
    • two pointersを使った方法。
    • 最初に思い切ってsortしておくからこそ思いつける。
    • Counter()やset()などのHashmapの構築が不要。
    • num1, num2, num3の等号成立の有無による場合分けなども不要。
  • 計算量
    • Time: O(n^2)
    • Extra Space: O(1)
    • Output Space: O(n^2)

Code: 333 ()

class Solution:
    def threeSum(self, nums: list[int]) -> list[list[int]]:
        sorted_nums = sorted(nums)
        result = []
        
        for i in range(len(sorted_nums) - 2):
            if i > 0 and sorted_nums[i] == sorted_nums[i - 1]:
                continue
            
            left, right = i + 1, len(sorted_nums) - 1
            target = -sorted_nums[i]
            
            while left < right:
                total = sorted_nums[left] + sorted_nums[right]
                
                if total < target:
                    left += 1
                    continue
                if total > target:
                    right -= 1
                    continue

                result.append([sorted_nums[i], sorted_nums[left], sorted_nums[right]])
                while left < right and sorted_nums[left] == sorted_nums[left + 1]:
                    left += 1
                while left < right and sorted_nums[right] == sorted_nums[right - 1]:
                    right -= 1
                left += 1
                right -= 1
        
        return result

Note: 102

  • コメント集:空
  • PR
    • https://github.com/yamashita-ki/codingTest/pull/4
    • https://github.com/huyfififi/coding-challenges/pull/30
    • https://github.com/Ryotaro25/leetcode_first60/pull/74

Prompt: 67

上記に他の人が解いたPRをまとめています。それぞれのPRの代表的なソリューションとレビューコメントをチェックし、PRごとの内容を簡潔にまとめてください。

Assistant: 1,378

Copied!
{
  "id": "toolu_01WnvmdveH9B9RCEZuXnefbA",
  "call": {
    "function": "read_url",
    "arguments": {
      "url": "https://github.com/yamashita-ki/codingTest/pull/4"
    }
  },
  "result": "'Skip to content\\n\\n## Navigation Menu\\n\\nToggle navigation\\n\\n[ ](/)\\n\\n[ Sign in ](/login?return_t<TRUNCATED>"
}
Copied!
{
  "id": "toolu_01VJs1GtZYUMbqXDbJyM7HS9",
  "call": {
    "function": "read_url",
    "arguments": {
      "url": "https://github.com/huyfififi/coding-challenges/pull/30"
    }
  },
  "result": "'Skip to content\\n\\n## Navigation Menu\\n\\nToggle navigation\\n\\n[ ](/)\\n\\n[ Sign in ](/login?return_t<TRUNCATED>"
}
Copied!
{
  "id": "toolu_013V8E4tRx88CPJAms6kB1Bi",
  "call": {
    "function": "read_url",
    "arguments": {
      "url": "https://github.com/Ryotaro25/leetcode_first60/pull/74"
    }
  },
  "result": "'Skip to content\\n\\n## Navigation Menu\\n\\nToggle navigation\\n\\n[ ](/)\\n\\n[ Sign in ](/login?return_t<TRUNCATED>"
}

各PRの内容を簡潔にまとめます:


PR 1: yamashita-ki/codingTest#4 (Java)

  • 解法: 3重ループとtwo pointersの両方を実装
  • レビューコメント:
    • コードスタイルの揺れ(演算子周りのスペースや;後のスペース)が気になる
    • ぶら下がりifif (...) break;のように波括弧なし)は事故が起きやすいので避けるべき
    • curSumcurrentは不要な情報 ― 唯一のsumなので単にsumで十分

PR 2: huyfififi/coding-challenges#30 (Python)

  • 解法: Two Sumベースの解法(step1でTLE)、two pointers解法(step2以降)
  • レビューコメント:
    • 外側ループだけfixed_numberと名付けているのは違和感
    • nums.sort()で入力を変更しているのは問題 ― 呼び出し側が驚く可能性があるので、入力は変更しないか、コメントで明記すべき

PR 3: Ryotaro25/leetcode_first60#74 (C++)

  • 解法: sort + two pointers
  • レビューコメント:
    • sum < 0sum > 0の場合はcontinueして、sum == 0のネストを下げた方が読みやすい
    • do-whileを使うと1行減らせる(読みやすさは変わらないかも)
    • numsが非const参照だと変更されると思わせてしまう ― 変更しないならconst参照で渡すべき

共通して参考になるポイント:

  1. 入力を変更する場合は明示的に(sorted(nums)を使うなど)
  2. 変数名は冗長な修飾を避ける(currentなど)
  3. ネストを浅くするために早期continue/returnを活用

Prompt: 27

各トピックについて、さらに深掘りして詳しく解説してください。

Assistant: 205

どのトピックについて深掘りしたいですか?

  1. 入力の変更に関する問題nums.sort() vs sorted(nums)
  2. 変数名の付け方curSumfixed_numberなど)
  3. ネストを浅くする技法(early continue/return)
  4. ぶら下がりif(波括弧なしのif文)の問題点
  5. const参照(C++特有の話題)

どれから始めましょうか?それとも全部順番に説明しましょうか?

Prompt: 30

実装2、実装3のコードをレビューして、改善点を教えてください。

Assistant: 853

両方のコードを確認しました。

実装2について:

  1. 2つ目のケースのsorted()が無駄

    Copied!
    result.append(sorted([num, num, num3]))
    

    numnum3の大小関係を考えると、num3 = -num * 2なので:

    • num < 0num3 > 0[num, num, num3]
    • num > 0num3 < 0[num3, num, num]

    sorted()を使わず、条件分岐で書けます。ただし、その分コードは長くなるのでトレードオフですね。

  2. num_to_count.get(0, 0)と他の箇所の一貫性 他ではnum3 in num_to_countを使っているのに、最後だけ.get()を使っています。統一するなら:

    Copied!
    if 0 in num_to_count and num_to_count[0] >= 3:
    

実装3について:

  1. PRのレビューで出た「入力変更」の観点 sorted(nums)を使っているので問題なし。良いですね!

  2. 重複スキップのwhileループに潜在的な問題

    Copied!
    while left < right and sorted_nums[left] == sorted_nums[left + 1]:
        left += 1
    

    left + 1が範囲外になる可能性は? → left < rightが先に評価されるので大丈夫ですが、left + 1 < len(sorted_nums)の方が意図が明確かもしれません。

  3. 変数名totaltarget 両方"t"で始まり、役割が似ているので混乱しやすいかも。current_sumtarget、あるいはtwo_sumtargetなど、差別化してもよいかもしれません。

  4. early continueパターンの適用(PRで言及されていた点) 現在のコードは十分読みやすいですが、一致した後の処理を関数に切り出すとさらにスッキリする可能性があります。


全体として、実装3はかなり綺麗に書けていると思います。特に気になる点はありますか?

Prompt: 144

Copied!
        def skip_duplicates(start, stop, step):
            i = start
            while i + step < stop and sorted_nums[i] == sorted_nums[i + step]:
                i += step
            i += step
            return i

こんな感じで、i, left, rightのskip_duplicates操作をまとめたいと思っていますが、これはstep < 0のとき正しく動作しません。 どうやればいいと思う?

Assistant: 612

良い着眼点ですね!問題は境界チェックの不等号の向きです。

  • step > 0(left用): i + step < stop が必要
  • step < 0(right用): i + step > stop が必要

いくつかのアプローチがあります:

案1: 比較関数を動的に決める

Copied!
def skip_duplicates(start, stop, step):
    i = start
    in_range = (lambda x: x < stop) if step > 0 else (lambda x: x > stop)
    while in_range(i + step) and sorted_nums[i] == sorted_nums[i + step]:
        i += step
    return i + step

案2: 数学的トリック

Copied!
def skip_duplicates(start, stop, step):
    i = start
    while (i + step - stop) * step < 0 and sorted_nums[i] == sorted_nums[i + step]:
        i += step
    return i + step

stepを掛けることで不等号の向きを揃える)

案3: そもそも別のアプローチ

Copied!
def skip_while_equal(start, step):
    i = start
    while 0 <= i + step < len(sorted_nums) and sorted_nums[i] == sorted_nums[i + step]:
        i += step
    return i + step

stopを渡さず、配列の境界だけチェック。left < rightは呼び出し側で確認)

どのアプローチが好みですか?個人的には案1が意図が明確で読みやすいと思いますが、案3も呼び出し側の責務を明確にする点で悪くないです。

Prompt: 25

上記のように書いてみました。どうでしょうか?レビューしてください。

Assistant: 601

とても良いリファクタリングですね!

良い点:

  • 関数名skip_to_next_differentが意図を明確に表現している
  • 境界チェック0 <= index + step < nがシンプルで両方向に対応
  • forからwhileに変えたことで、skip_to_next_differentの使い方が統一された
  • i, j, kという変数名はtwo-pointers問題で一般的で分かりやすい
  • invariantコメントがある

細かい改善案:

  1. 関数の定義位置 skip_to_next_differentsorted_numsnをクロージャで参照しています。これ自体は問題ありませんが、テストしやすさを考えると引数で渡す設計もあります(今のままでも十分OK)。

  2. 条件式の改行スタイル

    Copied!
    while (
        0 <= index + step < n
        and sorted_nums[index] == sorted_nums[index + step]
        ):
    

    閉じ括弧と:の位置が少し珍しいです。以下のどちらかが一般的:

    Copied!
    while (
        0 <= index + step < n
        and sorted_nums[index] == sorted_nums[index + step]
    ):
    

    または

    Copied!
    while (0 <= index + step < n
           and sorted_nums[index] == sorted_nums[index + step]):
    

全体として、元のコードよりかなりスッキリしました。重複スキップのロジックが一箇所にまとまったので、バグが入りにくくなっています。

Note: 6

Step 3

Note: 180

実装4

  • 実装3をベースにリファクタ
    • 重複した数字を飛ばす部分を関数化して切り出す。
    • ポインタはi, left, rightではなく単にi, j, kにした。
      • どうせleft, rightとしてもj, k程度しか情報量はない。(進む向きとかはわからない。)
      • ならi, j, kの方が統一感がある。

Code: 381 ()

class Solution:
    def threeSum(self, nums: list[int]) -> list[list[int]]:
        sorted_nums = sorted(nums)
        n = len(sorted_nums)
        result = []

        def skip_to_next_different(start, step):
            index = start
            while (0 <= index + step < n
                and sorted_nums[index] == sorted_nums[index + step]):
                index += step
            return index + step
        
        i = 0
        while i < n - 2:
            j, k = i + 1, n - 1
            target = -sorted_nums[i]
            
            # two pointers
            # invariant: i < j < k (< n)
            while j < k:
                total = sorted_nums[j] + sorted_nums[k]
                
                if total < target:
                    j += 1
                    continue
                if total > target:
                    k -= 1
                    continue

                result.append([sorted_nums[i], sorted_nums[j], sorted_nums[k]])
                j = skip_to_next_different(j, 1)
                k = skip_to_next_different(k, -1)
            
            i = skip_to_next_different(i, 1)
        
        return result

Note: 6

Step 4

Note: 18

自分のPR:(自分のPRへのリンク)

Prompt: 46

ここに、私が投稿したStep1-3のPRに対してレビューコメントがついています。それぞれチェックしてまとめてください。

Prompt: 25

それぞれのレビューコメントについて、もう少し詳しく教えてください。

Note: 37

実装5

  • レビューを受けて書き直し
    • (修正点)

Code: 7 ()

class Solution:
    pass

Code: 99 ()

from IPython.display import Markdown

md = lc_export(
    next_url="https://leetcode.com/problems/clone-graph",
    dry_run=False,
    desc="",
    notebook_url="https://share.solve.it.com/d/c560acdd7314a91fab380075345bc406"
)
# Markdown(md)

Output: 94

✓ Created branch: 0015-3sum
✓ Add 0015-3sum: 0015-3sum/0015-3sum.md
✓ Created PR: https://github.com/docto-rin/leetcode/pull/75