19

次のような経時的な値を記録するテーブルがあります。

RecordId  Time   Name
========================
1         10     Running
2         18     Running
3         21     Running
4         29     Walking
5         33     Walking
6         57     Running
7         66     Running

このテーブルをクエリした後、次のような結果が必要です。

FromTime  ToTime  Name
=========================
10        29      Running
29        57      Walking
57        NULL    Running

集計関数 (MIN、MAX など)、PARTITION、および CTE のいくつかをいじってみましたが、適切な解決策にたどり着けないようです。SQL の第一人者が手を貸してくれるか、少なくとも正しい方向に向けてくれることを願っています。これをクエリするかなり簡単な方法はありますか (できればカーソルなしで?)

4

5 に答える 5

22

結合の代わりに集計による「ToTime」の検索

1 回の論理読み取りでテーブルを 1 回スキャンするだけの非常にワイルドなクエリを共有したいと思います。比較すると、このページの他の最良の回答である Simon Kingston のクエリは、2 回のスキャンが必要です。

非常に大きなデータ セット (17,408 の入力行、8,193 の結果行を生成) では、CPU 574 と 2645 の時間がかかりますが、Simon Kingston のクエリは CPU 63,820 と 37,108 の時間がかかります。

インデックスを使用すると、ページ上の他のクエリのパフォーマンスが何倍も向上する可能性がありますが、クエリを書き直すだけで CPU が 111 倍、速度が 14 倍向上することは興味深いことです。

(注意してください: サイモン キングストンや他の誰かを軽視しているわけではありません。このクエリのアイデアが非常にうまく機能することに単純に興奮しています。彼のクエリは、パフォーマンスが十分であり、実際に理解可能で保守しやすいため、私のクエリよりも優れています。 、私とは異なります。)

これが不可能なクエリです。わかりにくいです。書きづらかったです。しかし、それは素晴らしいです。:)

WITH Ranks AS (
   SELECT
      T = Dense_Rank() OVER (ORDER BY Time, Num),
      N = Dense_Rank() OVER (PARTITION BY Name ORDER BY Time, Num),
      *
   FROM
      #Data D
      CROSS JOIN (
         VALUES (1), (2)
      ) X (Num)
), Items AS (
   SELECT
      FromTime = Min(Time),
      ToTime = Max(Time),
      Name = IsNull(Min(CASE WHEN Num = 2 THEN Name END), Min(Name)),
      I = IsNull(Min(CASE WHEN Num = 2 THEN T - N END), Min(T - N)),
      MinNum = Min(Num)
   FROM
      Ranks
   GROUP BY
      T / 2
)
SELECT
   FromTime = Min(FromTime),
   ToTime = CASE WHEN MinNum = 2 THEN NULL ELSE Max(ToTime) END,
   Name
FROM Items
GROUP BY
   I, Name, MinNum
ORDER BY
   FromTime

注: これには、SQL 2008 以降が必要です。SQL 2005 で機能させるには、VALUES 句を に変更しますSELECT 1 UNION ALL SELECT 2

更新されたクエリ

これについて少し考えた後、2 つの別々の論理タスクを同時に実行していることに気付きました。これにより、クエリが不必要に複雑になりました。新しいタスク) および 2) 次の行から「ToTime」値を取得します。#1を #2 の前に実行することにより、クエリはより単純になり、約半分の CPU で実行されます!

したがって、ここでは単純化されたクエリを示します。最初に重要でない行を削除し、次にJOIN ではなく集計を使用して ToTime 値を取得します。はい、2 つではなく 3 つのウィンドウ関数がありますが、最終的には行が少ないため (重要でない行を削除した後)、実行する作業が少なくなります。

WITH Ranks AS (
   SELECT
      Grp =
         Row_Number() OVER (ORDER BY Time)
         - Row_Number() OVER (PARTITION BY Name ORDER BY Time),
      [Time], Name
   FROM #Data D
), Ranges AS (
   SELECT
      Result = Row_Number() OVER (ORDER BY Min(R.[Time]), X.Num) / 2,
      [Time] = Min(R.[Time]),
      R.Name, X.Num
   FROM
      Ranks R
      CROSS JOIN (VALUES (1), (2)) X (Num)
   GROUP BY
      R.Name, R.Grp, X.Num
)
SELECT
   FromTime = Min([Time]),
   ToTime = CASE WHEN Count(*) = 1 THEN NULL ELSE Max([Time]) END,
   Name = IsNull(Min(CASE WHEN Num = 2 THEN Name ELSE NULL END), Min(Name))
FROM Ranges R
WHERE Result > 0
GROUP BY Result
ORDER BY FromTime;

この更新されたクエリには、説明で示したのと同じ問題がすべて含まれていますが、余分な不要な行を処理していないため、解決が容易です。また、除外しなければならなかった 0の値もわかりRow_Number() / 2ます。前のクエリから除外しなかった理由がわかりませんが、いずれにせよ、これは完全に機能し、驚くほど高速です。

外側の適用で整理整頓

最後に、Simon Kingston のクエリと基本的に同じバージョンを示します。これは構文を理解しやすいと思います。

SELECT
   FromTime = Min(D.Time),
   X.ToTime,
   D.Name
FROM
   #Data D
   OUTER APPLY (
      SELECT TOP 1 ToTime = D2.[Time]
      FROM #Data D2
      WHERE
         D.[Time] < D2.[Time]
         AND D.[Name] <> D2.[Name]
      ORDER BY D2.[Time]
   ) X
GROUP BY
   X.ToTime,
   D.Name
ORDER BY
   FromTime;

より大きなデータ セットでパフォーマンスを比較する場合のセットアップ スクリプトは次のとおりです。

CREATE TABLE #Data (
    RecordId int,
    [Time]  int,
    Name varchar(10)
);
INSERT #Data VALUES
    (1, 10, 'Running'),
    (2, 18, 'Running'),
    (3, 21, 'Running'),
    (4, 29, 'Walking'),
    (5, 33, 'Walking'),
    (6, 57, 'Running'),
    (7, 66, 'Running'),
    (8, 77, 'Running'),
    (9, 81, 'Walking'),
    (10, 89, 'Running'),
    (11, 93, 'Walking'),
    (12, 99, 'Running'),
    (13, 107, 'Running'),
    (14, 113, 'Walking'),
    (15, 124, 'Walking'),
    (16, 155, 'Walking'),
    (17, 178, 'Running');
GO
insert #data select recordid + (select max(recordid) from #data), time + (select max(time) +25 from #data), name from #data
GO 10

説明

これが私のクエリの背後にある基本的な考え方です。

  1. 切り替えを表す時間は、隣接する 2 つの行に表示する必要があります。1 つは前のアクティビティを終了する時間で、もう 1 つは次のアクティビティを開始する時間です。これに対する自然な解決策は、出力行がそれ自体の行 (開始時間) と次に変更された行 (終了時間)からプルできるように結合することです。

  2. ただし、私のクエリでは、行をCROSS JOIN (VALUES (1), (2)). これで、すべての行が複製されました。JOIN を使用して列全体で計算を行う代わりに、何らかの形式の集計を使用して、目的の行の各ペアを 1 つに折りたたむという考え方です。

  3. 次のタスクは、重複する各行を適切に分割して、1 つのインスタンスが前のペアに対応し、別のインスタンスが次のペアに対応するようにすることです。これは、T 列を でROW_NUMBER()並べTime替え、2 で割ることで実現されます (ただし、この場合は ROW_NUMBER と同じ値を返すため、対称性のために DENSE_RANK() を実行するように変更しました)。効率化のために、次のステップで除算を実行して、行番号を別の計算で再利用できるようにしました (読み続けてください)。行番号は 1 から始まり、暗黙のうちに 2 で除算すると int に変換さ0 1 1 2 2 3 3 4 4 ...れるため、この計算値でグループ化することにより、目的の結果を持つシーケンスを生成する効果があります。Num行番号では、最初のセットの後のすべてのセットが「前の」行の Num = 2 と「次の」行の Num = 1 で構成されることがわかりました。

  4. 次の難しい作業は、気にしない行を削除し、ブロックの開始時刻をブロックの終了時刻と同じ行にまとめる方法を見つけることです。必要なのは、ランニングまたはウォーキングの個別のセットごとに独自の番号を付けて、グループ化できるようにする方法です。DENSE_RANK()は自然な解決策ですが、問題はORDER BY節の各値に注意を払うことです。が変更されるたびに計算が変更されないDENSE_RANK() OVER (PREORDER BY Time ORDER BY Name)ようにするための構文がありません。少し考えた後、 Itzik Ben-Gan のグループ化された島のソリューション の背後にあるロジックを少し理解できることに気付きました。TimeRANKNameTimeNameで並べTime替えた場合、同じグループ内の各行で同じであるが、他のグループとは異なる値が得られます。4 5 6一般的なグループ化された島の手法では、となどの行とロックステップで上昇する 2 つの計算値を作成します1 2 3。この値を減算すると同じ値が得られます (この例では、、 、3 3 3の結果)。注:最初は計算のために始めましたが、うまくいきませんでした。正解は、申し訳ありませんが、その時点でなぜこれを結論付けたのか覚えていないため、もう一度掘り下げて理解する必要があるというものでした。でも、やっぱりそういうこと4 - 15 - 26 - 3ROW_NUMBER()NDENSE_RANK()T-N計算: 1 つのステータス (ランニングまたはウォーキング) の各「島」を分離するためにグループ化できる数値。

  5. でもこれで終わりではありません。まず、各グループの「次の」行にはName、 、N、およびの誤った値が含まれていますT。これを回避するには、各グループから、Num = 2行が存在する場合は行の値を選択します (存在しない場合は、残りの値を使用します)。これにより、次のような式が得られます。これによりCASE WHEN NUM = 2 THEN x END、不適切な「次の」行の値が適切に取り除かれます。

  6. T - Nいくつかの実験の後、ウォーキンググループとランニンググループの両方が同じ計算値を持つ可能性があるため、単独でグループ化するだけでは不十分であることに気付きました(最大 17 まで提供されたサンプルデータの場合、2 つのT - N値があります)。 6)。ただし、単純にグループ化するだけで、Nameこの問題は解決します。「ランニング」または「ウォーキング」のいずれのグループにも、反対のタイプから同じ数の介在値が含まれることはありません。つまり、最初のグループは「Running」で始まり、次の「Running」グループの前に 2 つの「Walking」行があるため、N の値はT次の「Running」グループの値よりも 2 少なくなります。 . これについて考える 1 つの方法は、T - N計算は、現在の行の前に同じ値 "Running" または "Walking" に属さない行の数をカウントします。これが正しいことを示すいくつかの考えがあります: 3 番目の "Running" グループに移ると、それらを分離する "Walking" グループがあるため、それは 3 番目のグループにすぎないため、間に入る行の数が異なります。その前であり、より高い位置から開始するため、値が複製できないほど十分に高いです。

  7. 最後に、最終的なグループは 1 行だけで構成されているため (終了時刻がなく、NULL代わりに を表示する必要があります)、終了時刻があるかどうかを判断するために使用できる計算を投入する必要がありました。これはMin(Num)式で達成され、最後に Min(Num) が 2 の場合 (「次の」行がないことを意味します) を検出しNULL、値の代わりにa を表示しMax(ToTime)ます。

この説明が人々の役に立つことを願っています。私の「行乗算」手法が一般的に有用であり、実稼働環境のほとんどの SQL クエリ作成者に適用できるかどうかはわかりません。これは、それを理解するのが難しく、メンテナンスが困難であるためです。コード (反応はおそらく「一体何をしているの!?!」の後に「書き直す時間だ!」というものです)。

あなたがここまでたどり着いたのなら、あなたの時間と、信じられないほど楽しい SQL パズルの世界への私の小さな遠足に私を甘やかしてくれてありがとう。

自分の目で確かめてください

別名「PREORDER BY」をシミュレートします。

最後のメモ。ジョブがどのように機能するかを確認するT - Nには (私の方法のこの部分を使用すると、SQL コミュニティには一般的に適用できない可能性があることに注意してください)、サンプル データの最初の 17 行に対して次のクエリを実行します。

WITH Ranks AS (
   SELECT
      T = Dense_Rank() OVER (ORDER BY Time),
      N = Dense_Rank() OVER (PARTITION BY Name ORDER BY Time),
      *
   FROM
      #Data D
)
SELECT
   *,
   T - N
FROM Ranks
ORDER BY
   [Time];

これにより、次の結果が得られます。

RecordId    Time Name       T    N    T - N
----------- ---- ---------- ---- ---- -----
1           10   Running    1    1    0
2           18   Running    2    2    0
3           21   Running    3    3    0
4           29   Walking    4    1    3
5           33   Walking    5    2    3
6           57   Running    6    4    2
7           66   Running    7    5    2
8           77   Running    8    6    2
9           81   Walking    9    3    6
10          89   Running    10   7    3
11          93   Walking    11   4    7
12          99   Running    12   8    4
13          107  Running    13   9    4
14          113  Walking    14   5    9
15          124  Walking    15   6    9
16          155  Walking    16   7    9
17          178  Running    17   10   7

重要な部分は、「ウォーキング」または「ランニング」の各グループが同じ値をT - N持ち、同じ名前を持つ他のグループとは区別されるということです。

パフォーマンス

私のクエリが他の人のクエリよりも高速であるという点について、詳しく説明したくありません。ただし、(インデックスがない場合) 違いがどれほど大きいかを考えると、数値を表形式で表示したかったのです。これは、この種の行間の相関の高いパフォーマンスが必要な場合に適した手法です。

各クエリを実行する前に、DBCC FREEPROCCACHE; DBCC DROPCLEANBUFFERS;. クエリごとに MAXDOP を 1 に設定して、並列処理による時間の崩壊の影響を取り除きます。クライアントのデータ転送ではなくパフォーマンスのみを測定するために、結果セットをクライアントに返すのではなく、変数に選択しました。すべてのクエリに同じ ORDER BY 句が与えられました。すべてのテストで 17,408 の入力行が使用され、8,193 の結果行が生成されました。

次の人/理由の結果は表示されません:

RichardTheKiwi *Could not test--query needs updating*
ypercube       *No SQL 2012 environment yet :)*
Tim S          *Did not complete tests within 5 minutes*

インデックスなし:

               CPU         Duration    Reads       Writes
               ----------- ----------- ----------- -----------
ErikE          344         344         99          0
Simon Kingston 68672       69582       549203      49

インデックス付きCREATE UNIQUE CLUSTERED INDEX CI_#Data ON #Data (Time);:

               CPU         Duration    Reads       Writes
               ----------- ----------- ----------- -----------
ErikE          328         336         99          0
Simon Kingston 70391       71291       549203      49          * basically not worse

インデックス付きCREATE UNIQUE CLUSTERED INDEX CI_#Data ON #Data (Time, Name);:

               CPU         Duration    Reads       Writes
               ----------- ----------- ----------- -----------
ErikE          375         414         359         0           * IO WINNER
Simon Kingston 172         189         38273       0           * CPU WINNER

したがって、この話の教訓は次のとおりです。

適切なインデックスは、クエリ ウィザードよりも重要です

適切なインデックスを使用すると、Simon Kingston のバージョンが総合的に優れており、特にクエリの複雑性と保守性が含まれている場合に顕著です。

このレッスンによく注意してください!38k の読み取りは実際にはそれほど多くなく、Simon Kingston のバージョンは私のバージョンの半分の時間で実行されました。私のクエリの速度の向上は完全に、テーブルにインデックスがないことと、これが結合を必要とするすべてのクエリに与えた壊滅的なコスト (私はそうではありませんでした) によるものでした。インデックスを使用すると、彼のクエリはクラスター化されたインデックス シーク (ブックマーク ルックアップとも呼ばれます) を使用して入れ子になったループを実行できるようになり、処理が非常に高速になりました。

Time のクラスタ化インデックスだけでは不十分であったことは興味深いことです。時間は一意であり、名前は一度に 1 つしか発生しませんでしたが、名前を適切に利用するには、名前をインデックスの一部にする必要がありました。

データがいっぱいになったときにクラスター化インデックスをテーブルに追加するのに 1 秒もかかりませんでした。インデックスを無視しないでください。

于 2012-11-29T02:45:17.950 に答える
9

LAG()これは SQL Server 2008 では機能せず、およびLEAD()分析機能を備えた SQL Server 2012 バージョンでのみ機能しますが、新しいバージョンのユーザーのためにここに残しておきます。

SELECT Time AS FromTime
     , LEAD(Time) OVER (ORDER BY Time) AS ToTime
     , Name
FROM
  ( SELECT Time 
         , LAG(Name) OVER (ORDER BY Time) AS PreviousName
         , Name
    FROM Data  
  ) AS tmp
WHERE PreviousName <> Name 
   OR PreviousName IS NULL ;

SQLフィドルでテスト済み

インデックス(Time, Name)がある場合は、インデックス スキャンが必要になります。

編集:

有効なエントリとして取得する必要NULLがある有効な値である場合は、次の句を使用します。NameWHERE

WHERE PreviousName <> Name 
   OR (PreviousName IS NULL AND Name IS NOT NULL)
   OR (PreviousName IS NOT NULL AND Name IS NULL) ;
于 2012-11-28T22:33:55.633 に答える
4

RecordID は常に連続しているとは限らないため、CTE は非破壊的な連続番号を作成します。

SQLフィドル

;with SequentiallyNumbered as (
    select *, N = row_number() over (order by RecordId)
      from Data)
, Tmp as (
    select A.*, RN=row_number() over (order by A.Time)
      from SequentiallyNumbered A
 left join SequentiallyNumbered B on B.N = A.N-1 and A.name = B.name
     where B.name is null)
   select A.Time FromTime, B.Time ToTime, A.Name
     from Tmp A
left join Tmp B on B.RN = A.RN + 1;

テストに使用したデータセット

create table Data (
    RecordId int,
    Time  int,
    Name varchar(10));
insert Data values
    (1         ,10     ,'Running'),
    (2         ,18     ,'Running'),
    (3         ,21     ,'Running'),
    (4         ,29     ,'Walking'),
    (5         ,33     ,'Walking'),
    (6         ,57     ,'Running'),
    (7         ,66     ,'Running');
于 2012-11-28T21:56:36.893 に答える
4

「名前」があるレコードから次のレコードに変わる場所に本質的に興味があると思います(「時間」の順序で)。これがどこで発生するかを特定できれば、目的の出力を生成できます。

CTEについて言及したので、SQL Server 2005以降を使用しているため、ROW_NUMBER()関数を使用できると想定します。ROW_NUMBER()レコードの連続したペアを識別し、「名前」が変化する場所を見つける便利な方法として使用できます。

これはどう:

WITH OrderedTable AS
(
    SELECT
        *,
        ROW_NUMBER() OVER (ORDER BY Time) AS Ordinal
    FROM
        [YourTable]
),
NameChange AS
(
    SELECT
        after.Time AS Time,
        after.Name AS Name,
        ROW_NUMBER() OVER (ORDER BY after.Time) AS Ordinal
    FROM
        OrderedTable before
        RIGHT JOIN OrderedTable after ON after.Ordinal = before.Ordinal + 1
    WHERE
        ISNULL(before.Name, '') <> after.Name
)

SELECT
    before.Time AS FromTime,
    after.Time AS ToTime,
    before.Name
FROM
    NameChange before
    LEFT JOIN NameChange after ON after.Ordinal = before.Ordinal + 1
于 2012-11-28T21:59:38.497 に答える
4

求めている結果を得る CTE ソリューションは次のとおりです。

;WITH TheRecords (FirstTime,SecondTime,[Name])
AS
(
    SELECT [Time],
    (
        SELECT MIN([Time]) 
        FROM ActivityTable at2
        WHERE at2.[Time]>at.[Time]
        AND at2.[Name]<>at.[Name]
    ),
    [Name]
    FROM ActivityTable at
)
SELECT MIN(FirstTime) AS FromTime,SecondTime AS ToTime,MIN([Name]) AS [Name]
FROM TheRecords
GROUP BY SecondTime
ORDER BY FromTime,ToTime
于 2012-11-28T21:56:53.173 に答える