Temukan baris induk yang memiliki rangkaian baris anak yang identik

9

Misalkan saya memiliki struktur seperti ini:

Meja resep

RecipeID
Name
Description

Tabel Bahan Resep

RecipeID
IngredientID
Quantity
UOM

Kuncinya RecipeIngredientsadalah (RecipeID, IngredientID).

Apa sajakah cara yang baik untuk menemukan resep rangkap? Resep rangkap didefinisikan memiliki bahan dan jumlah bahan yang sama untuk setiap bahan.

Saya sudah memikirkan menggunakan FOR XML PATHuntuk menggabungkan bahan-bahan menjadi satu kolom. Saya belum sepenuhnya mengeksplorasi ini tetapi itu harus bekerja jika saya memastikan bahan / UOM / jumlah diurutkan dalam urutan yang sama dan memiliki pemisah yang tepat. Apakah ada pendekatan yang lebih baik?

Ada 48 ribu resep dan 200 ribu baris bahan.

menyodok
sumber

Jawaban:

7

Untuk skema diasumsikan berikut dan contoh data

CREATE TABLE dbo.RecipeIngredients
    (
      RecipeId INT NOT NULL ,
      IngredientID INT NOT NULL ,
      Quantity INT NOT NULL ,
      UOM INT NOT NULL ,
      CONSTRAINT RecipeIngredients_PK 
          PRIMARY KEY ( RecipeId, IngredientID ) WITH (IGNORE_DUP_KEY = ON)
    ) ;

INSERT INTO dbo.RecipeIngredients
SELECT TOP (210000) ABS(CRYPT_GEN_RANDOM(8)/50000),
                     ABS(CRYPT_GEN_RANDOM(8) % 100),
                     ABS(CRYPT_GEN_RANDOM(8) % 10),
                     ABS(CRYPT_GEN_RANDOM(8) % 5)
FROM master..spt_values v1,                     
     master..spt_values v2


SELECT DISTINCT RecipeId, 'X' AS Name
INTO Recipes 
FROM  dbo.RecipeIngredients 

Baris ramuan ini terdiri dari 205.009 baris dan 42.613 resep. Ini akan sedikit berbeda setiap kali karena elemen acak.

Ini mengasumsikan dupes yang relatif sedikit (output setelah menjalankan contoh adalah 217 kelompok resep rangkap dengan dua atau tiga resep per kelompok). Kasus paling patologis berdasarkan angka-angka dalam OP akan menjadi 48.000 duplikat yang tepat.

Script untuk mengaturnya adalah

DROP TABLE dbo.RecipeIngredients,Recipes
GO

CREATE TABLE Recipes(
RecipeId INT IDENTITY,
Name VARCHAR(1))

INSERT INTO Recipes 
SELECT TOP 48000 'X'
FROM master..spt_values v1,                     
     master..spt_values v2

CREATE TABLE dbo.RecipeIngredients
    (
      RecipeId INT NOT NULL ,
      IngredientID INT NOT NULL ,
      Quantity INT NOT NULL ,
      UOM INT NOT NULL ,
      CONSTRAINT RecipeIngredients_PK 
          PRIMARY KEY ( RecipeId, IngredientID )) ;

INSERT INTO dbo.RecipeIngredients
SELECT RecipeId,IngredientID,Quantity,UOM
FROM Recipes
CROSS JOIN (SELECT 1,1,1 UNION ALL SELECT 2,2,2 UNION ALL  SELECT 3,3,3 UNION ALL SELECT 4,4,4) I(IngredientID,Quantity,UOM)

Berikut ini diselesaikan dalam waktu kurang dari satu detik pada mesin saya untuk kedua kasus.

CREATE TABLE #Concat
  (
     RecipeId     INT,
     concatenated VARCHAR(8000),
     PRIMARY KEY (concatenated, RecipeId)
  )

INSERT INTO #Concat
SELECT R.RecipeId,
       ISNULL(concatenated, '')
FROM   Recipes R
       CROSS APPLY (SELECT CAST(IngredientID AS VARCHAR(10)) + ',' + CAST(Quantity AS VARCHAR(10)) + ',' + CAST(UOM AS VARCHAR(10)) + ','
                    FROM   dbo.RecipeIngredients RI
                    WHERE  R.RecipeId = RecipeId
                    ORDER  BY IngredientID
                    FOR XML PATH('')) X (concatenated);

WITH C1
     AS (SELECT DISTINCT concatenated
         FROM   #Concat)
SELECT STUFF(Recipes, 1, 1, '')
FROM   C1
       CROSS APPLY (SELECT ',' + CAST(RecipeId AS VARCHAR(10))
                    FROM   #Concat C2
                    WHERE  C1.concatenated = C2.concatenated
                    ORDER  BY RecipeId
                    FOR XML PATH('')) R(Recipes)
WHERE  Recipes LIKE '%,%,%'

DROP TABLE #Concat 

Satu peringatan

Saya berasumsi bahwa panjang string gabungan tidak akan melebihi 896 byte. Jika hal ini dilakukan, ini akan menimbulkan kesalahan pada saat dijalankan daripada gagal diam-diam. Anda harus menghapus kunci utama (dan indeks yang dibuat secara implisit) dari #temptabel. Panjang maksimum string gabungan dalam pengaturan pengujian saya adalah 125 karakter.

Jika string gabungan terlalu panjang untuk diindeks, maka kinerja XML PATHkueri terakhir yang mengonsolidasikan resep yang sama bisa jadi buruk. Menginstal dan menggunakan agregasi string CLR khusus akan menjadi salah satu solusi karena dapat melakukan penggabungan dengan satu lintasan data daripada bergabung dengan diri yang tidak diindeks.

SELECT YourClrAggregate(RecipeId)
FROM #Concat
GROUP BY concatenated

Saya juga mencoba

WITH Agg
     AS (SELECT RecipeId,
                MAX(IngredientID)          AS MaxIngredientID,
                MIN(IngredientID)          AS MinIngredientID,
                SUM(IngredientID)          AS SumIngredientID,
                COUNT(IngredientID)        AS CountIngredientID,
                CHECKSUM_AGG(IngredientID) AS ChkIngredientID,
                MAX(Quantity)              AS MaxQuantity,
                MIN(Quantity)              AS MinQuantity,
                SUM(Quantity)              AS SumQuantity,
                COUNT(Quantity)            AS CountQuantity,
                CHECKSUM_AGG(Quantity)     AS ChkQuantity,
                MAX(UOM)                   AS MaxUOM,
                MIN(UOM)                   AS MinUOM,
                SUM(UOM)                   AS SumUOM,
                COUNT(UOM)                 AS CountUOM,
                CHECKSUM_AGG(UOM)          AS ChkUOM
         FROM   dbo.RecipeIngredients
         GROUP  BY RecipeId)
SELECT  A1.RecipeId AS RecipeId1,
        A2.RecipeId AS RecipeId2
FROM   Agg A1
       JOIN Agg A2
         ON A1.MaxIngredientID = A2.MaxIngredientID
            AND A1.MinIngredientID = A2.MinIngredientID
            AND A1.SumIngredientID = A2.SumIngredientID
            AND A1.CountIngredientID = A2.CountIngredientID
            AND A1.ChkIngredientID = A2.ChkIngredientID
            AND A1.MaxQuantity = A2.MaxQuantity
            AND A1.MinQuantity = A2.MinQuantity
            AND A1.SumQuantity = A2.SumQuantity
            AND A1.CountQuantity = A2.CountQuantity
            AND A1.ChkQuantity = A2.ChkQuantity
            AND A1.MaxUOM = A2.MaxUOM
            AND A1.MinUOM = A2.MinUOM
            AND A1.SumUOM = A2.SumUOM
            AND A1.CountUOM = A2.CountUOM
            AND A1.ChkUOM = A2.ChkUOM
            AND A1.RecipeId <> A2.RecipeId
WHERE  NOT EXISTS (SELECT *
                   FROM   (SELECT *
                           FROM   RecipeIngredients
                           WHERE  RecipeId = A1.RecipeId) R1
                          FULL OUTER JOIN (SELECT *
                                           FROM   RecipeIngredients
                                           WHERE  RecipeId = A2.RecipeId) R2
                            ON R1.IngredientID = R2.IngredientID
                               AND R1.Quantity = R2.Quantity
                               AND R1.UOM = R2.UOM
                   WHERE  R1.RecipeId IS NULL
                           OR R2.RecipeId IS NULL) 

Ini bekerja dengan baik ketika ada duplikat yang relatif sedikit (kurang dari satu detik untuk data contoh pertama) tetapi berkinerja buruk dalam kasus patologis karena agregasi awal mengembalikan hasil yang persis sama untuk setiap RecipeIDdan karenanya tidak berhasil mengurangi jumlah perbandingan sama sekali.

Martin Smith
sumber
Saya tidak yakin apakah masuk akal untuk membandingkan resep "kosong" tapi saya juga mengubah kueri saya untuk efek itu sebelum akhirnya mempostingnya, karena itulah yang dilakukan solusi @ ypercube.
Andriy M
@AndriyM - Joe Celko membandingkannya dengan pembagian dengan nol di artikel relasionalnya
Martin Smith
10

Ini adalah generalisasi dari masalah pembagian relasional. Tidak tahu seberapa efisien ini:

; WITH cte AS
( SELECT RecipeID_1 = r1.RecipeID, Name_1 = r1.Name,
         RecipeID_2 = r2.RecipeID, Name_2 = r2.Name  
  FROM Recipes AS r1
    JOIN Recipes AS r2
      ON r1.RecipeID <> r2.RecipeID
  WHERE NOT EXISTS
        ( SELECT 1
          FROM RecipeIngredients AS ri1
          WHERE ri1.RecipeID = r1.RecipeID 
            AND NOT EXISTS
                ( SELECT 1
                  FROM RecipeIngredients AS ri2
                  WHERE ri2.RecipeID = r2.RecipeID 
                    AND ri1.IngredientID = ri2.IngredientID
                    AND ri1.Quantity = ri2.Quantity
                    AND ri1.UOM = ri2.UOM
                )
         )
)
SELECT c1.*
FROM cte AS c1
  JOIN cte AS c2
    ON  c1.RecipeID_1 = c2.RecipeID_2
    AND c1.RecipeID_2 = c2.RecipeID_1
    AND c1.RecipeID_1 < c1.RecipeID_2;

Pendekatan (serupa) lainnya:

SELECT RecipeID_1 = r1.RecipeID, Name_1 = r1.Name,
       RecipeID_2 = r2.RecipeID, Name_2 = r2.Name 
FROM Recipes AS r1
  JOIN Recipes AS r2
    ON  r1.RecipeID < r2.RecipeID 
    AND NOT EXISTS
        ( SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri1
          WHERE ri1.RecipeID = r1.RecipeID
        EXCEPT 
          SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri2
          WHERE ri2.RecipeID = r2.RecipeID
        )
    AND NOT EXISTS
        ( SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri2
          WHERE ri2.RecipeID = r2.RecipeID
        EXCEPT 
          SELECT IngredientID, Quantity, UOM
          FROM RecipeIngredients AS ri1
          WHERE ri1.RecipeID = r1.RecipeID
        ) ;

Dan satu lagi, yang berbeda:

; WITH cte AS
( SELECT RecipeID_1 = r.RecipeID, RecipeID_2 = ri.RecipeID, 
          ri.IngredientID, ri.Quantity, ri.UOM
  FROM Recipes AS r
    CROSS JOIN RecipeIngredients AS ri
)
, cte2 AS
( SELECT RecipeID_1, RecipeID_2,
         IngredientID, Quantity, UOM
  FROM cte
EXCEPT
  SELECT RecipeID_2, RecipeID_1,
         IngredientID, Quantity, UOM
  FROM cte
)

  SELECT RecipeID_1 = r1.RecipeID, RecipeID_2 = r2.RecipeID
  FROM Recipes AS r1
    JOIN Recipes AS r2
      ON r1.RecipeID < r2.RecipeID
EXCEPT 
  SELECT RecipeID_1, RecipeID_2
  FROM cte2
EXCEPT 
  SELECT RecipeID_2, RecipeID_1
  FROM cte2 ;

Diuji di SQL-Fiddle


Menggunakan CHECKSUM()dan CHECKSUM_AGG()fungsi, tes di SQL-Fiddle-2 :
( abaikan ini karena dapat memberikan hasil positif palsu )

ALTER TABLE RecipeIngredients
  ADD ck AS CHECKSUM( IngredientID, Quantity, UOM )
    PERSISTED ;

CREATE INDEX ckecksum_IX
  ON RecipeIngredients
    ( RecipeID, ck ) ;

; WITH cte AS
( SELECT RecipeID,
         cka = CHECKSUM_AGG(ck)
  FROM RecipeIngredients AS ri
  GROUP BY RecipeID
)
SELECT RecipeID_1 = c1.RecipeID, RecipeID_2 = c2.RecipeID
FROM cte AS c1
  JOIN cte AS c2
    ON  c1.cka = c2.cka
    AND c1.RecipeID < c2.RecipeID  ;

ypercubeᵀᴹ
sumber
Rencana eksekusi agak menakutkan.
ypercubeᵀᴹ
Ini menjadi inti pertanyaan saya, yang bagaimana melakukan ini. Namun, rencana pelaksanaannya mungkin merupakan pemecah kesepakatan untuk situasi khusus saya.
colek
1
CHECKSUMdan CHECKSUM_AGGmasih meninggalkan Anda perlu memeriksa positif palsu.
Martin Smith
Untuk versi pengurangan contoh data dalam jawaban saya dengan 470 resep dan 2057 baris permintaan bahan 1 Table 'RecipeIngredients'. Scan count 220514, logical reads 443643dan 2 Table 'RecipeIngredients'. Scan count 110218, logical reads 441214. Yang ketiga tampaknya memiliki bacaan yang relatif lebih rendah dari dua tetapi masih terhadap data sampel lengkap saya membatalkan permintaan setelah 8 menit.
Martin Smith
Anda harus dapat mempercepat ini dengan membandingkan jumlah terlebih dahulu. Pada dasarnya sepasang resep tidak dapat memiliki bahan yang sama persis jika jumlah bahan tidak sama.
TomTom